@mentra/bluetooth-sdk 3.2.1-dev.314 โ†’ 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/README.md +4 -4
  2. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +368 -0
  3. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocol.kt +156 -0
  4. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +3 -0
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +6 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +2 -1
  7. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +129 -25
  8. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +37 -13
  9. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +2 -2
  10. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  11. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +4 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +13 -2
  13. package/android/src/main/java/com/mentra/bluetoothsdk/requests/DisplayRequests.kt +1 -1
  14. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +33 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/services/G2ConnectionRecovery.kt +47 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +2 -2
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +72 -21
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +101 -22
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +12 -8
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +314 -395
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +245 -0
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +238 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasEncoder.kt +58 -0
  24. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +180 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddress.kt +17 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +5 -0
  28. package/android/src/main/java/com/mentra/bluetoothsdk/utils/G2Text.kt +7 -0
  29. package/android/src/release/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +20 -0
  30. package/android/src/test/java/com/mentra/bluetoothsdk/DashboardContentFormatterTest.kt +2 -2
  31. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceIdentityPairingTest.kt +59 -0
  32. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +666 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/G2TextTest.kt +13 -0
  34. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +71 -0
  35. package/android/src/test/java/com/mentra/bluetoothsdk/services/G2ConnectionRecoveryTest.kt +51 -0
  36. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1PairingTest.kt +105 -0
  37. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2DisplayThreadTest.kt +120 -0
  38. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2PairingTargetTest.kt +90 -0
  39. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveFileTransferSessionTest.kt +55 -0
  40. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +33 -0
  41. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingTargetTest.kt +65 -0
  42. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraNexPairingTest.kt +151 -0
  43. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoBitmapAcceptanceTest.kt +105 -0
  44. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +220 -0
  45. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +348 -0
  46. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +339 -0
  47. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoMicReconnectTest.kt +93 -0
  48. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +11 -0
  49. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddressTest.kt +27 -0
  50. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnosticsTest.kt +287 -0
  51. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocolTest.kt +127 -0
  52. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +288 -0
  53. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +205 -0
  54. package/build/BluetoothSdk.types.d.ts +13 -0
  55. package/build/BluetoothSdk.types.d.ts.map +1 -1
  56. package/build/BluetoothSdk.types.js +1 -0
  57. package/build/BluetoothSdk.types.js.map +1 -1
  58. package/build/_private/BluetoothSdkModule.d.ts +4 -0
  59. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  60. package/build/_private/BluetoothSdkModule.js +3 -0
  61. package/build/_private/BluetoothSdkModule.js.map +1 -1
  62. package/build/generated/changelogCatalog.d.ts +2 -2
  63. package/build/generated/changelogCatalog.js +2 -2
  64. package/build/generated/changelogCatalog.js.map +1 -1
  65. package/build/generated/releaseMetadata.js +5 -5
  66. package/build/generated/releaseMetadata.js.map +1 -1
  67. package/build/index.d.ts.map +1 -1
  68. package/build/index.js +1 -0
  69. package/build/index.js.map +1 -1
  70. package/ios/BluetoothSdkModule.swift +9 -0
  71. package/ios/MentraBluetoothSDK.podspec +3 -2
  72. package/ios/Source/BluetoothBackgroundMode.swift +32 -0
  73. package/ios/Source/BluetoothSdkDefaults.swift +6 -1
  74. package/ios/Source/Bridge.swift +15 -2
  75. package/ios/Source/DeviceManager.swift +96 -63
  76. package/ios/Source/DeviceStore.swift +26 -7
  77. package/ios/Source/GeneratedChangelogCatalog.swift +2 -2
  78. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  79. package/ios/Source/MentraBluetoothSDK.swift +18 -0
  80. package/ios/Source/audio/GlassesMicWatchdog.swift +33 -0
  81. package/ios/Source/requests/DisplayRequests.swift +1 -1
  82. package/ios/Source/requests/VersionInfoResponseAccumulator.swift +11 -1
  83. package/ios/Source/sgcs/Ar99.swift +1 -2
  84. package/ios/Source/sgcs/G1.swift +68 -38
  85. package/ios/Source/sgcs/G1ConnectionTarget.swift +13 -0
  86. package/ios/Source/sgcs/G2.swift +261 -247
  87. package/ios/Source/sgcs/G2ConnectionSession.swift +101 -0
  88. package/ios/Source/sgcs/Mach1.swift +19 -7
  89. package/ios/Source/sgcs/MentraLive.swift +201 -156
  90. package/ios/Source/sgcs/MentraLiveFileTransferSession.swift +137 -0
  91. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +47 -0
  92. package/ios/Source/sgcs/MentraNex.swift +8 -31
  93. package/ios/Source/sgcs/NexConnectionTarget.swift +15 -0
  94. package/ios/Source/sgcs/Nimo.swift +280 -408
  95. package/ios/Source/sgcs/NimoCanvasCodec.swift +269 -0
  96. package/ios/Source/sgcs/NimoCanvasCoordinator.swift +233 -0
  97. package/ios/Source/sgcs/NimoCanvasImage.swift +53 -0
  98. package/ios/Source/sgcs/NimoCanvasSession.swift +166 -0
  99. package/ios/Source/sgcs/NimoDiscovery.swift +49 -0
  100. package/ios/Source/sgcs/SGCManager.swift +24 -0
  101. package/ios/Source/sgcs/VuzixConnectionTarget.swift +15 -0
  102. package/ios/Source/status/DeviceStatus.swift +1 -0
  103. package/ios/Source/stt/SherpaOnnxTranscriber.swift +49 -189
  104. package/ios/Source/utils/DemandDrivenAudioWorker.swift +150 -0
  105. package/ios/Source/utils/G2Text.swift +11 -0
  106. package/ios/Tests/BluetoothBackgroundModeTests.swift +32 -0
  107. package/ios/Tests/DashboardContentFormatterTests.swift +2 -2
  108. package/ios/Tests/DashboardRenderingTests.swift +31 -0
  109. package/ios/Tests/DemandDrivenAudioWorkerTests.swift +157 -0
  110. package/ios/Tests/DeviceIdentityPairingTests.swift +49 -0
  111. package/ios/Tests/DeviceManagerReconnectTests.swift +362 -0
  112. package/ios/Tests/G1PairingTests.swift +162 -0
  113. package/ios/Tests/G2ConnectionSessionTests.swift +172 -0
  114. package/ios/Tests/G2TextTests.swift +10 -0
  115. package/ios/Tests/MentraLiveFileTransferSessionTests.swift +47 -0
  116. package/ios/Tests/MentraLiveReconnectPolicyTests.swift +58 -0
  117. package/ios/Tests/NimoCanvasCodecTests.swift +204 -0
  118. package/ios/Tests/NimoCanvasSessionTests.swift +181 -0
  119. package/ios/Tests/NimoCanvasTransportTests.swift +186 -0
  120. package/ios/Tests/NimoDeviceCommandsTests.swift +16 -0
  121. package/ios/Tests/NimoDiscoveryTests.swift +79 -0
  122. package/ios/Tests/SGCConnectionTargetTests.swift +48 -0
  123. package/ios/Tests/VersionInfoCommandTests.swift +53 -0
  124. package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +65 -0
  125. package/ios/Tests/VideoRecordingCommandTests.swift +81 -0
  126. package/package.json +2 -2
  127. package/plugin/build/withIos.d.ts +2 -0
  128. package/plugin/build/withIos.js +12 -1
  129. package/src/BluetoothSdk.types.ts +14 -0
  130. package/src/_private/BluetoothSdkModule.ts +8 -0
  131. package/src/generated/changelogCatalog.ts +2 -2
  132. package/src/generated/releaseMetadata.ts +5 -5
  133. package/src/index.ts +1 -0
@@ -0,0 +1,166 @@
1
+ import Foundation
2
+
3
+ /// Serializes launch/update/exit. A business ACK means acceptance, not visible pixels.
4
+ final class NimoCanvasSession {
5
+ enum Action: Equatable {
6
+ case send(key: Int, frame: Data, ticket: UInt64)
7
+ case reconnect(String)
8
+ case rejected(Int)
9
+ }
10
+
11
+ private struct Flight {
12
+ let key: Int
13
+ let frame: Data
14
+ let ticket: UInt64
15
+ }
16
+
17
+ private var ready = false
18
+ private var transportReady = false
19
+ private var active = false
20
+ private var locked = false
21
+ private var launchBlocked = false
22
+ private var waitingReadiness = false
23
+ private var observedNotReady = false
24
+ private var readinessRetries = 0
25
+ private var probeRetries = 0
26
+ private var exitRequested = false
27
+ private var desired: Data?
28
+ private var accepted: Data?
29
+ private var rejected: Data?
30
+ private var scope: String?
31
+ private var forceRevision: UInt64 = 0
32
+ private var sentRevision: UInt64 = 0
33
+ private var ticket: UInt64 = 0
34
+ private var flight: Flight?
35
+
36
+ func offer(_ frame: Data, scope: String, force: Bool = false) -> [Action] {
37
+ if self.scope != scope || force { forceRevision &+= 1 }
38
+ self.scope = scope
39
+ desired = frame
40
+ exitRequested = false
41
+ return pump()
42
+ }
43
+
44
+ /// Enter a blank canvas unless a newer host scene survived the connection change.
45
+ func activate() -> [Action] {
46
+ if desired == nil { desired = Data([0, 0, 1]) }
47
+ exitRequested = false
48
+ return pump()
49
+ }
50
+
51
+ /// The firmware validates peer readiness on Launch. Only an explicit NOT_READY
52
+ /// permits a bounded retry; an ambiguous ACK still requires a new transport.
53
+ func retryNotReady() -> [Action] {
54
+ guard transportReady, waitingReadiness, desired != nil, probeRetries < 3 else { return [] }
55
+ probeRetries += 1
56
+ waitingReadiness = false
57
+ ready = true
58
+ return pump()
59
+ }
60
+
61
+ /// Only a fresh heartbeat samples both TWS and peer Companion readiness.
62
+ func readiness(_ value: Bool, confirmed: Bool = false) -> [Action] {
63
+ transportReady = value
64
+ if !value { readinessRetries = 0; probeRetries = 0 }
65
+ if !value, flight != nil { return resetLink("Readiness lost during canvas command") }
66
+ if waitingReadiness {
67
+ if !value { observedNotReady = true }
68
+ else if !observedNotReady {
69
+ if !confirmed || readinessRetries >= 3 { return [] }
70
+ readinessRetries += 1
71
+ }
72
+ }
73
+ ready = value
74
+ if !value { active = false; accepted = nil }
75
+ if value { waitingReadiness = false }
76
+ return pump()
77
+ }
78
+
79
+ /// Keep only the latest desired scene across a new transport generation.
80
+ func disconnected() {
81
+ ready = false; transportReady = false; active = false; locked = false; launchBlocked = false
82
+ waitingReadiness = false; observedNotReady = false; readinessRetries = 0; probeRetries = 0
83
+ accepted = nil; rejected = nil; flight = nil
84
+ ticket &+= 1; forceRevision &+= 1
85
+ }
86
+
87
+ func exit() -> [Action] {
88
+ desired = nil; scope = nil; accepted = nil; rejected = nil; exitRequested = true
89
+ return pump()
90
+ }
91
+
92
+ /// Stock UI reports do not relinquish host ownership. Replay after the current ACK.
93
+ func nativeApp(_ appId: Int, entered: Bool) -> [Action] {
94
+ if (appId == NimoCanvasCodec.appId && !entered) || (appId != NimoCanvasCodec.appId && entered) {
95
+ active = false
96
+ accepted = nil
97
+ return pump()
98
+ }
99
+ return []
100
+ }
101
+
102
+ func acceptsResponse(key: Int, payload: Data) -> Bool {
103
+ guard let current = flight, current.key == key, let status = payload.first else { return false }
104
+ let echo = key == 4 ? Data([0, 0xFD, 0, 0, 0]) : Data([0, 0xFD])
105
+ if status == 0 { return payload == echo }
106
+ return payload.count == 1 || (payload.count == echo.count && payload.dropFirst() == echo.dropFirst())
107
+ }
108
+
109
+ func response(key: Int, payload: Data) -> [Action] {
110
+ guard acceptsResponse(key: key, payload: payload), let current = flight, let first = payload.first else { return [] }
111
+ let status = Int(first)
112
+ flight = nil
113
+ if status != 0 {
114
+ switch status {
115
+ case 7:
116
+ ready = false; waitingReadiness = true; observedNotReady = false; active = false; accepted = nil
117
+ case 8:
118
+ locked = true; active = false; accepted = nil
119
+ case 2:
120
+ return [.rejected(status)] + resetLink("Canvas device timeout is ambiguous")
121
+ default:
122
+ if key == 4 { rejected = current.frame }
123
+ else {
124
+ if key == 1 { launchBlocked = true } else { desired = nil }
125
+ active = false; exitRequested = false
126
+ }
127
+ }
128
+ return [.rejected(status)] + pump()
129
+ }
130
+ switch key {
131
+ case 1: active = true; accepted = nil
132
+ case 4: accepted = current.frame; rejected = nil; readinessRetries = 0; probeRetries = 0
133
+ case 3: active = false; accepted = nil; exitRequested = false
134
+ default: break
135
+ }
136
+ return pump()
137
+ }
138
+
139
+ func timeout(_ expectedTicket: UInt64) -> [Action] {
140
+ guard flight?.ticket == expectedTicket else { return [] }
141
+ return resetLink("Canvas ACK timeout; reconnect before reusing a command key")
142
+ }
143
+
144
+ private func resetLink(_ reason: String) -> [Action] {
145
+ ready = false; transportReady = false; active = false; accepted = nil; flight = nil
146
+ ticket &+= 1; forceRevision &+= 1
147
+ return [.reconnect(reason)]
148
+ }
149
+
150
+ private func pump() -> [Action] {
151
+ guard ready, !locked, !launchBlocked, !waitingReadiness, flight == nil else { return [] }
152
+ let key: Int
153
+ if exitRequested, active { key = 3 }
154
+ else if exitRequested { exitRequested = false; return [] }
155
+ else if desired == nil { return [] }
156
+ else if !active { key = 1 }
157
+ else if desired == rejected { return [] }
158
+ else if desired == accepted, sentRevision == forceRevision { return [] }
159
+ else { key = 4 }
160
+ let frame = key == 4 ? desired! : Data()
161
+ if key == 4 { sentRevision = forceRevision }
162
+ ticket &+= 1
163
+ flight = Flight(key: key, frame: frame, ticket: ticket)
164
+ return [.send(key: key, frame: frame, ticket: ticket)]
165
+ }
166
+ }
@@ -0,0 +1,49 @@
1
+ import Foundation
2
+
3
+ /// Combines system-connected classic devices with incoming CoreBluetooth discoveries.
4
+ /// Kept independent of the radio so discovery can be exercised without hardware.
5
+ @MainActor
6
+ final class NimoDiscovery<Peripheral> {
7
+ private(set) var active = false
8
+ private let register: () -> Void
9
+ private let connected: () -> [Peripheral]
10
+ private let scan: () -> Void
11
+ private let stop: () -> Void
12
+ private let name: (Peripheral) -> String?
13
+ private let found: (Peripheral, String, Int?) -> Void
14
+
15
+ init(register: @escaping () -> Void, connected: @escaping () -> [Peripheral],
16
+ scan: @escaping () -> Void, stop: @escaping () -> Void,
17
+ name: @escaping (Peripheral) -> String?, found: @escaping (Peripheral, String, Int?) -> Void)
18
+ {
19
+ self.register = register
20
+ self.connected = connected
21
+ self.scan = scan
22
+ self.stop = stop
23
+ self.name = name
24
+ self.found = found
25
+ }
26
+
27
+ func start() {
28
+ active = true
29
+ // Register first so pairing in Settings cannot race the connected snapshot.
30
+ register()
31
+ for peripheral in connected() {
32
+ receive(peripheral)
33
+ }
34
+ if active { scan() }
35
+ }
36
+
37
+ func cancel() {
38
+ active = false
39
+ stop()
40
+ }
41
+
42
+ func receive(_ peripheral: Peripheral, advertisedName: String? = nil, rssi: Int? = nil) {
43
+ guard active, let name = advertisedName ?? name(peripheral) else { return }
44
+ let lower = name.lowercased()
45
+ // The separate ANCS peripheral does not host the Companion data service.
46
+ guard lower.hasPrefix("nimo"), !lower.hasSuffix("_ble") else { return }
47
+ found(peripheral, name, rssi)
48
+ }
49
+ }
@@ -96,6 +96,10 @@ protocol SGCManager {
96
96
 
97
97
  // MARK: - Display Control
98
98
 
99
+ /// Full-frame adapters preserve the active scene instead of showing temporary overlays.
100
+ var showBrightnessConfirmation: Bool { get }
101
+ var showConnectionConfirmation: Bool { get }
102
+ var sceneHandoffRequiresClear: Bool { get }
99
103
  func setBrightness(_ level: Int, autoMode: Bool)
100
104
  func clearDisplay()
101
105
  func sendText(_ text: String) async
@@ -228,6 +232,10 @@ protocol SGCManager {
228
232
 
229
233
  func sendLoudnessGateSetting()
230
234
 
235
+ // MARK: - Auto power-off when unworn
236
+
237
+ func sendAutoPowerOffSetting()
238
+
231
239
  // MARK: - Mic tuning (super-mode only)
232
240
 
233
241
  func sendMicTuningSetting()
@@ -254,6 +262,18 @@ protocol SGCManager {
254
262
  /// doesn't seem to work for concurrency reasons :(
255
263
  /// we can make read-only getters for convienence though:
256
264
  extension SGCManager {
265
+ var showBrightnessConfirmation: Bool {
266
+ true
267
+ }
268
+
269
+ var showConnectionConfirmation: Bool {
270
+ true
271
+ }
272
+
273
+ var sceneHandoffRequiresClear: Bool {
274
+ true
275
+ }
276
+
257
277
  var isMicSuspendedForAudio: Bool {
258
278
  false
259
279
  }
@@ -435,6 +455,10 @@ extension SGCManager {
435
455
 
436
456
  func sendLoudnessGateSetting() {}
437
457
 
458
+ // MARK: - Auto power-off (default no-op โ€” Mentra Live supports this)
459
+
460
+ func sendAutoPowerOffSetting() {}
461
+
438
462
  // MARK: - Mic tuning (default no-op โ€” Mentra Live supports this)
439
463
 
440
464
  func sendMicTuningSetting() {}
@@ -0,0 +1,15 @@
1
+ import Foundation
2
+
3
+ enum VuzixConnectionTarget {
4
+ static func identifier(_ name: String) -> String {
5
+ guard let start = name.lastIndex(of: "["),
6
+ let end = name[start...].firstIndex(of: "]")
7
+ else { return name }
8
+ return String(name[name.index(after: start) ..< end])
9
+ }
10
+
11
+ static func matches(name: String?, target: String) -> Bool {
12
+ guard let name, !target.isEmpty else { return false }
13
+ return identifier(name) == identifier(target)
14
+ }
15
+ }
@@ -123,6 +123,7 @@ struct GlassesStatus: CustomStringConvertible {
123
123
  "wifiLocalIp": "",
124
124
  "signalStrength": -1,
125
125
  "signalStrengthUpdatedAt": 0,
126
+ "g2MissingArm": NSNull(),
126
127
  ]))
127
128
  }
128
129
 
@@ -1,5 +1,4 @@
1
1
  import Foundation
2
- import UIKit
3
2
 
4
3
  /**
5
4
  * SherpaOnnxTranscriber handles real-time audio transcription using Sherpa-ONNX.
@@ -11,24 +10,30 @@ class SherpaOnnxTranscriber {
11
10
  private static let TAG = "SherpaOnnxTranscriber"
12
11
 
13
12
  private static let SAMPLE_RATE = 16000 // Sherpa-ONNX model's required sample rate
14
- private static let QUEUE_CAPACITY = 100 // Max number of audio buffers to keep in queue
13
+ private final class Session {
14
+ let recognizer: SherpaOnnxRecognizer
15
+ var lastPartialResult = ""
15
16
 
16
- private let pcmQueue = DispatchQueue(label: "com.augmentos.sherpaonnx.pcmQueue", qos: .userInteractive)
17
- private var pcmBuffers = [Data]()
18
- private var isRunning = false
19
- private var processingQueue: DispatchQueue?
20
- private var processingTask: DispatchWorkItem?
21
-
22
- /// The underlying Sherpa-ONNX objects
23
- private var recognizer: SherpaOnnxRecognizer?
17
+ init(_ recognizer: SherpaOnnxRecognizer) {
18
+ self.recognizer = recognizer
19
+ }
20
+ }
24
21
 
25
- private var lastPartialResult = ""
22
+ private lazy var worker = DemandDrivenAudioWorker<Session>(
23
+ makeProcessor: { Self.makeRecognizer().map(Session.init) },
24
+ process: { [weak self] session, data, generation in
25
+ self?.process(session, data: data, generation: generation)
26
+ }
27
+ )
26
28
 
27
- /// Parent context
28
- private weak var context: UIViewController?
29
+ /// Construct the worker on the manager's actor before audio can arrive.
30
+ init() {
31
+ _ = worker
32
+ }
29
33
 
30
- /// Session start time for relative timestamps
31
- private var transcriptionSessionStart: Date
34
+ func setActive(_ active: Bool) {
35
+ worker.setActive(active)
36
+ }
32
37
 
33
38
  /// Dynamic model path support
34
39
  private static var customModelPath: String? {
@@ -65,23 +70,9 @@ class SherpaOnnxTranscriber {
65
70
  return nil
66
71
  }
67
72
 
68
- /**
69
- * Constructor that accepts a UIViewController to load model assets.
70
- */
71
- init(context: UIViewController) {
72
- self.context = context
73
- transcriptionSessionStart = Date()
74
- }
75
-
76
- deinit {
77
- shutdown()
78
- }
79
-
80
- /**
81
- * Initialize the Sherpa-ONNX recognizer.
82
- * Loads models and configuration, sets up processing thread.
83
- */
84
- func initialize() {
73
+ /// Runs exclusively on the worker queue, never on the main/audio delivery thread.
74
+ private static func makeRecognizer() -> SherpaOnnxRecognizer? {
75
+ var recognizer: SherpaOnnxRecognizer?
85
76
  do {
86
77
  var tokensPath: String
87
78
  var modelType = "unknown"
@@ -201,30 +192,26 @@ class SherpaOnnxTranscriber {
201
192
  Bridge.log("No Sherpa ONNX model available. Transcription will be disabled.")
202
193
  Bridge.log("Please download a model using the model downloader in settings.")
203
194
  recognizer = nil
204
- isRunning = false
205
- return
195
+ return nil
206
196
  }
207
197
 
208
198
  if recognizer == nil {
209
199
  throw NSError(domain: "SherpaOnnxTranscriber", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to create recognizer"])
210
200
  }
211
201
 
212
- startProcessingTask()
213
- isRunning = true
214
-
215
202
  Bridge.log("Sherpa-ONNX ASR initialized successfully with \(modelType) model")
203
+ return recognizer
216
204
 
217
205
  } catch {
218
206
  Bridge.log("Failed to initialize Sherpa-ONNX: \(error.localizedDescription)")
207
+ return nil
219
208
  }
220
209
  }
221
210
 
222
- /**
223
- * Handle transcription results - send only to delegate
224
- */
225
- private func handleTranscriptionResult(text: String, isFinal: Bool) {
226
- // Forward to delegate if set
211
+ private func handleTranscriptionResult(text: String, isFinal: Bool, generation: UInt64) {
227
212
  DispatchQueue.main.async { [weak self] in
213
+ // A decode or main-queue delivery may finish after stop/model replacement.
214
+ guard let self, self.worker.isCurrent(generation) else { return }
228
215
  if isFinal {
229
216
  STTTools.didReceiveFinalTranscription(text)
230
217
  } else {
@@ -233,123 +220,30 @@ class SherpaOnnxTranscriber {
233
220
  }
234
221
  }
235
222
 
236
- /**
237
- * Feed PCM audio data (16-bit little endian) into the transcriber.
238
- * This method should be called continuously with short chunks (e.g., 100-300ms).
239
- *
240
- * Audio is queued directly; microphone VAD gating is not applied in the SDK.
241
- */
242
223
  func acceptAudio(pcm16le: Data) {
243
- guard isRunning else {
244
- return
245
- }
246
-
247
- queueAudioData(pcm16le)
248
- }
249
-
250
- private func queueAudioData(_ pcm16le: Data) {
251
- pcmQueue.async { [weak self] in
252
- guard let self = self else { return }
253
-
254
- let queueSizeBefore = self.pcmBuffers.count
255
- self.pcmBuffers.append(pcm16le)
256
-
257
- // Keep queue size manageable
258
- if self.pcmBuffers.count > Self.QUEUE_CAPACITY {
259
- let removedBuffer = self.pcmBuffers.removeFirst()
260
- Bridge.log("โš ๏ธ Audio queue overflow - dropped buffer of \(removedBuffer.count) bytes")
261
- }
262
- }
224
+ worker.accept(pcm16le)
263
225
  }
264
226
 
265
- /**
266
- * Start a background task to continuously consume audio and decode using Sherpa.
267
- */
268
- private func startProcessingTask() {
269
- Bridge.log("๐Ÿš€ Starting Sherpa-ONNX processing task...")
270
-
271
- processingQueue = DispatchQueue(label: "com.augmentos.sherpaonnx.processor", qos: .userInitiated)
272
-
273
- let workItem = DispatchWorkItem { [weak self] in
274
- self?.runLoop()
227
+ private func process(_ session: Session, data: Data, generation: UInt64) {
228
+ guard worker.isCurrent(generation) else { return }
229
+ let recognizer = session.recognizer
230
+ recognizer.acceptWaveform(samples: toFloatArray(from: data), sampleRate: Self.SAMPLE_RATE)
231
+ while recognizer.isReady() {
232
+ guard worker.isCurrent(generation) else { return }
233
+ recognizer.decode()
275
234
  }
276
-
277
- processingTask = workItem
278
- processingQueue?.async(execute: workItem)
279
- }
280
-
281
- /**
282
- * Main processing loop that handles transcription in real-time.
283
- * Pulls audio from queue, feeds into Sherpa, emits partial/final results.
284
- */
285
- private func runLoop() {
286
- Bridge.log("๐Ÿ”„ Sherpa-ONNX processing loop started")
287
-
288
- while isRunning {
289
- // Pull data from queue
290
- var audioData: Data?
291
-
292
- pcmQueue.sync {
293
- if !self.pcmBuffers.isEmpty {
294
- audioData = self.pcmBuffers.removeFirst()
295
- }
296
- }
297
-
298
- if let data = audioData {
299
- // Synchronize access to recognizer to prevent race conditions
300
- objc_sync_enter(self)
301
- defer { objc_sync_exit(self) }
302
-
303
- guard let recognizer = recognizer else {
304
- Bridge.log("โš ๏ธ Recognizer not available, skipping audio chunk")
305
- continue
306
- }
307
-
308
- do {
309
- // Convert PCM to float [-1.0, 1.0]
310
- let floatBuf = toFloatArray(from: data)
311
-
312
- // Pass audio data to the Sherpa-ONNX stream
313
- recognizer.acceptWaveform(samples: floatBuf, sampleRate: Self.SAMPLE_RATE)
314
-
315
- // Decode continuously while model is ready
316
- var decodeCount = 0
317
- while recognizer.isReady() {
318
- recognizer.decode()
319
- decodeCount += 1
320
- }
321
-
322
- // If utterance endpoint detected
323
- if recognizer.isEndpoint() {
324
- let result = recognizer.getResult()
325
- let finalText = result.text.trimmingCharacters(in: .whitespacesAndNewlines)
326
-
327
- if !finalText.isEmpty {
328
- handleTranscriptionResult(text: finalText, isFinal: true)
329
- }
330
-
331
- recognizer.reset() // Start new utterance
332
- lastPartialResult = ""
333
- } else {
334
- // Emit partial results if changed
335
- let result = recognizer.getResult()
336
- let partial = result.text.trimmingCharacters(in: .whitespacesAndNewlines)
337
-
338
- if partial != lastPartialResult, !partial.isEmpty {
339
- handleTranscriptionResult(text: partial, isFinal: false)
340
- lastPartialResult = partial
341
- }
342
- }
343
- } catch {
344
- Bridge.log("โŒ Error processing audio: \(error.localizedDescription)")
345
- }
346
- } else {
347
- // Sleep briefly to avoid tight CPU loop if no audio is available
348
- Thread.sleep(forTimeInterval: 0.01)
235
+ guard worker.isCurrent(generation) else { return }
236
+ let text = recognizer.getResult().text.trimmingCharacters(in: .whitespacesAndNewlines)
237
+ if recognizer.isEndpoint() {
238
+ if !text.isEmpty {
239
+ handleTranscriptionResult(text: text, isFinal: true, generation: generation)
349
240
  }
241
+ recognizer.reset()
242
+ session.lastPartialResult = ""
243
+ } else if !text.isEmpty, text != session.lastPartialResult {
244
+ handleTranscriptionResult(text: text, isFinal: false, generation: generation)
245
+ session.lastPartialResult = text
350
246
  }
351
-
352
- Bridge.log("ASR processing thread stopped")
353
247
  }
354
248
 
355
249
  /**
@@ -377,45 +271,11 @@ class SherpaOnnxTranscriber {
377
271
  return samples
378
272
  }
379
273
 
380
- /**
381
- * Stop transcription processing.
382
- * This shuts down the processing thread and releases Sherpa-ONNX resources.
383
- */
384
274
  func shutdown() {
385
- Bridge.log("๐Ÿ›‘ Shutting down SherpaOnnxTranscriber...")
386
-
387
- isRunning = false
388
- processingTask?.cancel()
389
-
390
- // Synchronize access to recognizer during shutdown
391
- objc_sync_enter(self)
392
- defer { objc_sync_exit(self) }
393
-
394
- // The recognizer will be automatically cleaned up by ARC when set to nil
395
- if recognizer != nil {
396
- Bridge.log("๐Ÿงน Cleaning up Sherpa-ONNX recognizer")
397
- recognizer = nil
398
- }
399
-
400
- // Clear any remaining audio buffers
401
- pcmQueue.sync {
402
- let remainingBuffers = self.pcmBuffers.count
403
- if remainingBuffers > 0 {
404
- Bridge.log("๐Ÿ—‘๏ธ Clearing \(remainingBuffers) remaining audio buffers")
405
- }
406
- self.pcmBuffers.removeAll()
407
- }
408
-
409
- Bridge.log("โœ… SherpaOnnxTranscriber shutdown complete")
275
+ worker.setActive(false)
410
276
  }
411
277
 
412
- /**
413
- * Restarts the transcriber after a model change.
414
- * Shuts down existing resources, clears buffers, and reinitializes the recognizer.
415
- */
416
278
  func restart() {
417
- Bridge.log("โ™ป๏ธ Restarting SherpaOnnxTranscriber...")
418
- shutdown()
419
- initialize()
279
+ worker.restart()
420
280
  }
421
281
  }