@mentra/bluetooth-sdk 3.3.0-dev.516 → 3.3.0-dev.523
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.
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +2 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +55 -10
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +63 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +26 -13
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +31 -19
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +5 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +111 -2
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +33 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +44 -4
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +82 -13
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoMicReconnectTest.kt +93 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +11 -0
- package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +94 -0
- package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +15 -9
- package/build/BluetoothSdk.types.d.ts +2 -0
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js +1 -0
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +2 -0
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +4 -0
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/DeviceManager.swift +66 -50
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/audio/GlassesMicWatchdog.swift +33 -0
- package/ios/Source/sgcs/Ar99.swift +1 -2
- package/ios/Source/sgcs/G2.swift +259 -245
- package/ios/Source/sgcs/G2ConnectionSession.swift +101 -0
- package/ios/Source/sgcs/Nimo.swift +104 -43
- package/ios/Source/sgcs/NimoCanvasCoordinator.swift +20 -6
- package/ios/Source/sgcs/NimoCanvasSession.swift +30 -14
- package/ios/Source/sgcs/NimoDiscovery.swift +49 -0
- package/ios/Source/status/DeviceStatus.swift +1 -0
- package/ios/Source/stt/SherpaOnnxTranscriber.swift +49 -189
- package/ios/Source/utils/DemandDrivenAudioWorker.swift +150 -0
- package/ios/Tests/DemandDrivenAudioWorkerTests.swift +157 -0
- package/ios/Tests/DeviceManagerReconnectTests.swift +362 -0
- package/ios/Tests/G2ConnectionSessionTests.swift +172 -0
- package/ios/Tests/NimoCanvasSessionTests.swift +74 -5
- package/ios/Tests/NimoCanvasTransportTests.swift +25 -0
- package/ios/Tests/NimoDeviceCommandsTests.swift +16 -0
- package/ios/Tests/NimoDiscoveryTests.swift +79 -0
- package/package.json +2 -2
- package/src/BluetoothSdk.types.ts +3 -0
- package/src/_private/BluetoothSdkModule.ts +2 -0
- 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
|
|
13
|
+
private final class Session {
|
|
14
|
+
let recognizer: SherpaOnnxRecognizer
|
|
15
|
+
var lastPartialResult = ""
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
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
|
-
///
|
|
28
|
-
|
|
29
|
+
/// Construct the worker on the manager's actor before audio can arrive.
|
|
30
|
+
init() {
|
|
31
|
+
_ = worker
|
|
32
|
+
}
|
|
29
33
|
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
278
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|