@mentra/bluetooth-sdk 3.3.0-dev.517 → 3.3.0-dev.525

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 (50) hide show
  1. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +2 -0
  2. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +55 -10
  3. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  4. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +63 -6
  5. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +26 -13
  6. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +31 -19
  7. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +5 -0
  8. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +111 -2
  9. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +33 -0
  10. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +44 -4
  11. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +82 -13
  12. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoMicReconnectTest.kt +93 -0
  13. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +11 -0
  14. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +94 -0
  15. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +15 -9
  16. package/build/BluetoothSdk.types.d.ts +2 -0
  17. package/build/BluetoothSdk.types.d.ts.map +1 -1
  18. package/build/BluetoothSdk.types.js +1 -0
  19. package/build/BluetoothSdk.types.js.map +1 -1
  20. package/build/_private/BluetoothSdkModule.d.ts +2 -0
  21. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  22. package/build/_private/BluetoothSdkModule.js.map +1 -1
  23. package/build/generated/releaseMetadata.js +5 -5
  24. package/build/generated/releaseMetadata.js.map +1 -1
  25. package/ios/BluetoothSdkModule.swift +4 -0
  26. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  27. package/ios/Source/DeviceManager.swift +66 -50
  28. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  29. package/ios/Source/audio/GlassesMicWatchdog.swift +33 -0
  30. package/ios/Source/sgcs/Ar99.swift +1 -2
  31. package/ios/Source/sgcs/G2.swift +259 -245
  32. package/ios/Source/sgcs/G2ConnectionSession.swift +101 -0
  33. package/ios/Source/sgcs/Nimo.swift +104 -43
  34. package/ios/Source/sgcs/NimoCanvasCoordinator.swift +20 -6
  35. package/ios/Source/sgcs/NimoCanvasSession.swift +30 -14
  36. package/ios/Source/sgcs/NimoDiscovery.swift +49 -0
  37. package/ios/Source/status/DeviceStatus.swift +1 -0
  38. package/ios/Source/stt/SherpaOnnxTranscriber.swift +49 -189
  39. package/ios/Source/utils/DemandDrivenAudioWorker.swift +150 -0
  40. package/ios/Tests/DemandDrivenAudioWorkerTests.swift +157 -0
  41. package/ios/Tests/DeviceManagerReconnectTests.swift +362 -0
  42. package/ios/Tests/G2ConnectionSessionTests.swift +172 -0
  43. package/ios/Tests/NimoCanvasSessionTests.swift +74 -5
  44. package/ios/Tests/NimoCanvasTransportTests.swift +25 -0
  45. package/ios/Tests/NimoDeviceCommandsTests.swift +16 -0
  46. package/ios/Tests/NimoDiscoveryTests.swift +79 -0
  47. package/package.json +2 -2
  48. package/src/BluetoothSdk.types.ts +3 -0
  49. package/src/_private/BluetoothSdkModule.ts +2 -0
  50. package/src/generated/releaseMetadata.ts +5 -5
@@ -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
  }
@@ -0,0 +1,150 @@
1
+ import Foundation
2
+
3
+ /// Owns a processor on one serial queue. Empty queues schedule no recurring work.
4
+ /// Demand changes invalidate queued audio/results immediately; creation, processing,
5
+ /// and destruction finish in order without blocking the caller (including the UI).
6
+ final class DemandDrivenAudioWorker<Processor> {
7
+ struct Snapshot {
8
+ let active: Bool
9
+ let initialized: Bool
10
+ let processing: Bool
11
+ let queuedBuffers: Int
12
+ let processedBuffers: UInt64
13
+ let droppedBuffers: UInt64
14
+ }
15
+
16
+ private let lock = NSLock()
17
+ private let queue: DispatchQueue
18
+ private let capacity: Int
19
+ private let makeProcessor: () -> Processor?
20
+ private let process: (Processor, Data, UInt64) -> Void
21
+ private var active = false
22
+ private var generation: UInt64 = 0
23
+ private var buffers = [Data]()
24
+ private var scheduled = false
25
+ private var initialized = false
26
+ private var processing = false
27
+ private var processedBuffers: UInt64 = 0
28
+ private var droppedBuffers: UInt64 = 0
29
+
30
+ // Accessed only on queue, including release of the previous processor.
31
+ private var processor: Processor?
32
+ private var processorGeneration: UInt64?
33
+ private var attemptedInitialization = false
34
+
35
+ init(capacity: Int = 100,
36
+ queue: DispatchQueue = DispatchQueue(label: "com.mentra.speech.processor", qos: .userInitiated),
37
+ makeProcessor: @escaping () -> Processor?,
38
+ process: @escaping (Processor, Data, UInt64) -> Void)
39
+ {
40
+ precondition(capacity > 0)
41
+ self.capacity = capacity
42
+ self.queue = queue
43
+ self.makeProcessor = makeProcessor
44
+ self.process = process
45
+ }
46
+
47
+ func setActive(_ value: Bool) {
48
+ lock.lock()
49
+ defer { lock.unlock() }
50
+ guard active != value else { return }
51
+ active = value
52
+ invalidateLocked()
53
+ }
54
+
55
+ /// Reload a changed model only if there is still local-transcription demand.
56
+ func restart() {
57
+ lock.lock()
58
+ defer { lock.unlock() }
59
+ invalidateLocked()
60
+ }
61
+
62
+ private func invalidateLocked() {
63
+ generation &+= 1
64
+ buffers.removeAll(keepingCapacity: true)
65
+ initialized = false
66
+ scheduleLocked()
67
+ }
68
+
69
+ func accept(_ data: Data) {
70
+ guard !data.isEmpty else { return }
71
+ lock.lock()
72
+ defer { lock.unlock() }
73
+ guard active else { return }
74
+ // Bound even audio arriving while model loading or decoding is blocked.
75
+ if buffers.count == capacity {
76
+ buffers.removeFirst()
77
+ droppedBuffers &+= 1
78
+ }
79
+ buffers.append(data)
80
+ scheduleLocked()
81
+ }
82
+
83
+ func isCurrent(_ token: UInt64) -> Bool {
84
+ lock.lock()
85
+ defer { lock.unlock() }
86
+ return active && generation == token
87
+ }
88
+
89
+ func snapshot() -> Snapshot {
90
+ lock.lock()
91
+ defer { lock.unlock() }
92
+ return Snapshot(active: active, initialized: initialized, processing: processing,
93
+ queuedBuffers: buffers.count, processedBuffers: processedBuffers,
94
+ droppedBuffers: droppedBuffers)
95
+ }
96
+
97
+ private func scheduleLocked() {
98
+ guard !scheduled else { return }
99
+ scheduled = true
100
+ queue.async { self.drain() }
101
+ }
102
+
103
+ private func drain() {
104
+ while true {
105
+ lock.lock()
106
+ let token = generation
107
+ if processorGeneration != token {
108
+ lock.unlock()
109
+ // ARC destruction stays serialized with inference and model loading.
110
+ processor = nil
111
+ processorGeneration = token
112
+ attemptedInitialization = false
113
+ continue
114
+ }
115
+ guard active else {
116
+ scheduled = false
117
+ lock.unlock()
118
+ return
119
+ }
120
+ if !attemptedInitialization {
121
+ attemptedInitialization = true
122
+ lock.unlock()
123
+ processor = makeProcessor()
124
+ lock.lock()
125
+ if generation == token { initialized = processor != nil }
126
+ lock.unlock()
127
+ continue
128
+ }
129
+ guard let processor else {
130
+ buffers.removeAll(keepingCapacity: true)
131
+ scheduled = false
132
+ lock.unlock()
133
+ return // Missing model: retry on a demand transition or model restart.
134
+ }
135
+ guard !buffers.isEmpty else {
136
+ scheduled = false
137
+ lock.unlock()
138
+ return
139
+ }
140
+ let data = buffers.removeFirst()
141
+ processing = true
142
+ lock.unlock()
143
+ process(processor, data, token)
144
+ lock.lock()
145
+ processedBuffers &+= 1
146
+ processing = false
147
+ lock.unlock()
148
+ }
149
+ }
150
+ }
@@ -0,0 +1,157 @@
1
+ import Foundation
2
+ @testable import MentraBluetoothSDK
3
+ import XCTest
4
+
5
+ final class DemandDrivenAudioWorkerTests: XCTestCase {
6
+ private final class Processor {
7
+ let onRelease: () -> Void
8
+ init(onRelease: @escaping () -> Void = {}) {
9
+ self.onRelease = onRelease
10
+ }
11
+
12
+ deinit { onRelease() }
13
+ }
14
+
15
+ func testNoDemandAndEmptyActiveQueueScheduleNoRecurringWork() {
16
+ let queue = DispatchQueue(label: "test.speech")
17
+ var creations = 0
18
+ var processed = [Data]()
19
+ let worker = DemandDrivenAudioWorker(queue: queue, makeProcessor: {
20
+ creations += 1
21
+ return Processor()
22
+ }, process: { _, data, _ in processed.append(data) })
23
+ worker.accept(Data([1]))
24
+ queue.sync {}
25
+ XCTAssertEqual(creations, 0)
26
+ worker.setActive(true)
27
+ worker.setActive(true)
28
+ queue.sync {}
29
+ XCTAssertEqual(creations, 1)
30
+ XCTAssertTrue(worker.snapshot().initialized)
31
+ XCTAssertFalse(worker.snapshot().processing)
32
+ // A serial barrier completes with an empty queue; no sleeping consumer holds it.
33
+ queue.sync {}
34
+ XCTAssertTrue(processed.isEmpty)
35
+ worker.setActive(false)
36
+ queue.sync {}
37
+ XCTAssertFalse(worker.snapshot().initialized)
38
+ }
39
+
40
+ func testFirstAudioIsRetainedDuringAsynchronousInitializationAndBounded() {
41
+ let queue = DispatchQueue(label: "test.speech")
42
+ let loading = DispatchSemaphore(value: 0)
43
+ let release = DispatchSemaphore(value: 0)
44
+ var processed = [Data]()
45
+ let worker = DemandDrivenAudioWorker(capacity: 3, queue: queue, makeProcessor: {
46
+ XCTAssertFalse(Thread.isMainThread)
47
+ loading.signal()
48
+ release.wait()
49
+ return Processor()
50
+ }, process: { _, data, _ in processed.append(data) })
51
+ worker.setActive(true)
52
+ XCTAssertEqual(loading.wait(timeout: .now() + 2), .success)
53
+ worker.accept(Data([1]))
54
+ worker.accept(Data([2]))
55
+ XCTAssertEqual(worker.snapshot().queuedBuffers, 2)
56
+ release.signal()
57
+ queue.sync {}
58
+ XCTAssertEqual(processed, [Data([1]), Data([2])])
59
+ worker.setActive(false)
60
+ queue.sync {}
61
+ }
62
+
63
+ func testStopDuringLoadingDropsOldAudioAndCannotResurrectProcessor() {
64
+ let queue = DispatchQueue(label: "test.speech")
65
+ let loading = DispatchSemaphore(value: 0)
66
+ let release = DispatchSemaphore(value: 0)
67
+ var released = 0
68
+ var processed = 0
69
+ let worker = DemandDrivenAudioWorker(capacity: 2, queue: queue, makeProcessor: {
70
+ loading.signal()
71
+ release.wait()
72
+ return Processor { released += 1 }
73
+ }, process: { _, _, _ in processed += 1 })
74
+ worker.setActive(true)
75
+ XCTAssertEqual(loading.wait(timeout: .now() + 2), .success)
76
+ for byte: UInt8 in 1 ... 10 {
77
+ worker.accept(Data([byte]))
78
+ }
79
+ XCTAssertEqual(worker.snapshot().queuedBuffers, 2)
80
+ XCTAssertEqual(worker.snapshot().droppedBuffers, 8)
81
+ worker.setActive(false)
82
+ XCTAssertEqual(worker.snapshot().queuedBuffers, 0)
83
+ release.signal()
84
+ queue.sync {}
85
+ XCTAssertEqual(processed, 0)
86
+ XCTAssertEqual(released, 1)
87
+ XCTAssertFalse(worker.snapshot().initialized)
88
+ }
89
+
90
+ func testRestartAndRapidDemandChangesSerializeOldAndNewProcessors() {
91
+ let queue = DispatchQueue(label: "test.speech")
92
+ let processing = DispatchSemaphore(value: 0)
93
+ let release = DispatchSemaphore(value: 0)
94
+ var creations = 0
95
+ var destructions = 0
96
+ var tokens = [UInt64]()
97
+ var dataSeen = [Data]()
98
+ let worker = DemandDrivenAudioWorker(queue: queue, makeProcessor: {
99
+ XCTAssertEqual(creations, destructions)
100
+ creations += 1
101
+ return Processor { destructions += 1 }
102
+ }, process: { _, data, token in
103
+ dataSeen.append(data)
104
+ tokens.append(token)
105
+ if data == Data([1]) {
106
+ processing.signal()
107
+ release.wait()
108
+ }
109
+ })
110
+ worker.setActive(true)
111
+ worker.accept(Data([1]))
112
+ XCTAssertEqual(processing.wait(timeout: .now() + 2), .success)
113
+ worker.accept(Data([2]))
114
+ worker.restart()
115
+ worker.setActive(false)
116
+ worker.setActive(true)
117
+ worker.accept(Data([3]))
118
+ release.signal()
119
+ queue.sync {}
120
+ XCTAssertEqual(dataSeen, [Data([1]), Data([3])])
121
+ XCTAssertFalse(worker.isCurrent(tokens[0]))
122
+ XCTAssertTrue(worker.isCurrent(tokens[1]))
123
+ XCTAssertEqual(creations, 2)
124
+ worker.setActive(false)
125
+ queue.sync {}
126
+ XCTAssertEqual(destructions, 2)
127
+ XCTAssertFalse(worker.isCurrent(tokens[1]))
128
+ }
129
+
130
+ func testMissingModelDoesNotRetryEveryFrameAndRestartWithoutDemandDoesNotLoad() {
131
+ let queue = DispatchQueue(label: "test.speech")
132
+ var attempts = 0
133
+ let worker = DemandDrivenAudioWorker<Processor>(queue: queue, makeProcessor: {
134
+ attempts += 1
135
+ return nil
136
+ }, process: { _, _, _ in XCTFail("Missing processor") })
137
+ worker.restart()
138
+ queue.sync {}
139
+ XCTAssertEqual(attempts, 0)
140
+ worker.setActive(true)
141
+ for _ in 0 ..< 5 {
142
+ worker.accept(Data([1]))
143
+ queue.sync {}
144
+ }
145
+ XCTAssertEqual(attempts, 1)
146
+ XCTAssertEqual(worker.snapshot().queuedBuffers, 0)
147
+ worker.restart()
148
+ queue.sync {}
149
+ XCTAssertEqual(attempts, 2)
150
+ worker.setActive(false)
151
+ worker.setActive(true)
152
+ queue.sync {}
153
+ XCTAssertEqual(attempts, 3)
154
+ worker.setActive(false)
155
+ queue.sync {}
156
+ }
157
+ }