@edkimmel/expo-audio-stream 0.4.0 → 0.4.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.
|
@@ -162,16 +162,15 @@ public class ExpoPlayAudioStreamModule: Module, MicrophoneDataDelegate, Pipeline
|
|
|
162
162
|
try self.ensureAudioSessionInitialized()
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
-
// Parse playback mode from options to configure shared engine
|
|
166
|
-
|
|
165
|
+
// Parse playback mode from options to configure shared engine.
|
|
166
|
+
// Always use VP — this library is meant for mic+speaker combos.
|
|
167
|
+
let playbackModeString = options["playbackMode"] as? String ?? "conversation"
|
|
167
168
|
let playbackMode: PlaybackMode
|
|
168
169
|
switch playbackModeString {
|
|
169
170
|
case "voiceProcessing":
|
|
170
171
|
playbackMode = .voiceProcessing
|
|
171
|
-
case "conversation":
|
|
172
|
-
playbackMode = .conversation
|
|
173
172
|
default:
|
|
174
|
-
playbackMode = .
|
|
173
|
+
playbackMode = .conversation
|
|
175
174
|
}
|
|
176
175
|
|
|
177
176
|
// Configure shared engine (handles voice processing)
|
|
@@ -38,18 +38,14 @@ protocol SharedAudioEngineDelegate: AnyObject {
|
|
|
38
38
|
/// Consumers attach their own AVAudioPlayerNode via `attachNode(_:format:)`.
|
|
39
39
|
/// The mixer handles sample-rate conversion from each node's format to the
|
|
40
40
|
/// hardware output format automatically.
|
|
41
|
-
///
|
|
42
|
-
/// All public methods are serialized on an internal queue to prevent races
|
|
43
|
-
/// between Expo async-function calls, notification handlers, and teardown.
|
|
44
41
|
class SharedAudioEngine {
|
|
45
42
|
private static let TAG = "SharedAudioEngine"
|
|
46
43
|
|
|
47
|
-
|
|
48
|
-
private let queue = DispatchQueue(label: "expo.audio.SharedAudioEngine")
|
|
44
|
+
private let lock = NSRecursiveLock()
|
|
49
45
|
|
|
50
46
|
// ── Engine state ─────────────────────────────────────────────────────
|
|
51
47
|
private(set) var engine: AVAudioEngine?
|
|
52
|
-
private(set) var playbackMode: PlaybackMode = .
|
|
48
|
+
private(set) var playbackMode: PlaybackMode = .conversation
|
|
53
49
|
private(set) var isConfigured = false
|
|
54
50
|
|
|
55
51
|
/// All registered consumers receive route-change and interruption callbacks.
|
|
@@ -57,11 +53,17 @@ class SharedAudioEngine {
|
|
|
57
53
|
private let delegates = NSHashTable<AnyObject>.weakObjects()
|
|
58
54
|
|
|
59
55
|
func addDelegate(_ d: SharedAudioEngineDelegate) {
|
|
60
|
-
|
|
56
|
+
lock.lock()
|
|
57
|
+
defer { lock.unlock() }
|
|
58
|
+
if !delegates.contains(d as AnyObject) {
|
|
59
|
+
delegates.add(d as AnyObject)
|
|
60
|
+
}
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
func removeDelegate(_ d: SharedAudioEngineDelegate) {
|
|
64
|
-
|
|
64
|
+
lock.lock()
|
|
65
|
+
defer { lock.unlock() }
|
|
66
|
+
delegates.remove(d as AnyObject)
|
|
65
67
|
}
|
|
66
68
|
|
|
67
69
|
private func notifyDelegates(_ block: (SharedAudioEngineDelegate) -> Void) {
|
|
@@ -90,10 +92,8 @@ class SharedAudioEngine {
|
|
|
90
92
|
///
|
|
91
93
|
/// - Parameter playbackMode: Determines whether voice processing is enabled.
|
|
92
94
|
func configure(playbackMode: PlaybackMode) throws {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
private func _configure(playbackMode: PlaybackMode) throws {
|
|
95
|
+
lock.lock()
|
|
96
|
+
defer { lock.unlock() }
|
|
97
97
|
if isConfigured && self.playbackMode == playbackMode && engine?.isRunning == true {
|
|
98
98
|
Logger.debug("[\(SharedAudioEngine.TAG)] Already configured for \(playbackMode) and engine running, skipping")
|
|
99
99
|
return
|
|
@@ -105,7 +105,7 @@ class SharedAudioEngine {
|
|
|
105
105
|
|
|
106
106
|
// Tear down existing engine (keeps attachedNodes info for re-attach)
|
|
107
107
|
let previousNodes = attachedNodes
|
|
108
|
-
|
|
108
|
+
teardown()
|
|
109
109
|
|
|
110
110
|
Logger.debug("[\(SharedAudioEngine.TAG)] Configuring engine — playbackMode=\(playbackMode)")
|
|
111
111
|
|
|
@@ -119,12 +119,10 @@ class SharedAudioEngine {
|
|
|
119
119
|
Logger.debug("[\(SharedAudioEngine.TAG)] Voice processing enabled")
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
// The VP path above already accesses both; this covers regular mode.
|
|
127
|
-
_ = engine.mainMixerNode
|
|
122
|
+
// VP accesses inputNode/outputNode above, which creates the graph.
|
|
123
|
+
// Do NOT access mainMixerNode here — inserting the mixer after
|
|
124
|
+
// setVoiceProcessingEnabled disrupts VoiceProcessingIO's internal
|
|
125
|
+
// graph and causes scheduleBuffer completions to never fire.
|
|
128
126
|
|
|
129
127
|
try engine.start()
|
|
130
128
|
|
|
@@ -142,7 +140,7 @@ class SharedAudioEngine {
|
|
|
142
140
|
|
|
143
141
|
// Re-attach any nodes that were connected before reconfiguration
|
|
144
142
|
for info in previousNodes {
|
|
145
|
-
|
|
143
|
+
attachNode(info.node, format: info.format)
|
|
146
144
|
info.node.play()
|
|
147
145
|
}
|
|
148
146
|
|
|
@@ -162,10 +160,8 @@ class SharedAudioEngine {
|
|
|
162
160
|
/// Connects `node → mainMixerNode` with the given format.
|
|
163
161
|
/// The mixer handles sample-rate conversion to hardware output.
|
|
164
162
|
func attachNode(_ node: AVAudioPlayerNode, format: AVAudioFormat) {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
private func _attachNode(_ node: AVAudioPlayerNode, format: AVAudioFormat) {
|
|
163
|
+
lock.lock()
|
|
164
|
+
defer { lock.unlock() }
|
|
169
165
|
guard let engine = engine else {
|
|
170
166
|
Logger.debug("[\(SharedAudioEngine.TAG)] attachNode called but engine is nil")
|
|
171
167
|
return
|
|
@@ -180,25 +176,23 @@ class SharedAudioEngine {
|
|
|
180
176
|
|
|
181
177
|
/// Detach a consumer's player node from the shared engine.
|
|
182
178
|
func detachNode(_ node: AVAudioPlayerNode) {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
private func _detachNode(_ node: AVAudioPlayerNode) {
|
|
179
|
+
lock.lock()
|
|
180
|
+
defer { lock.unlock() }
|
|
187
181
|
guard let engine = engine else { return }
|
|
188
182
|
|
|
183
|
+
node.pause()
|
|
184
|
+
node.stop()
|
|
185
|
+
|
|
186
|
+
// Only disconnect/detach if the node is still attached to this engine.
|
|
187
|
+
// The node may already have been removed (e.g. engine died, concurrent
|
|
188
|
+
// teardown, or duplicate disconnect call).
|
|
189
189
|
if node.engine === engine {
|
|
190
190
|
engine.disconnectNodeOutput(node)
|
|
191
191
|
engine.detach(node)
|
|
192
192
|
}
|
|
193
193
|
attachedNodes.removeAll { $0.node === node }
|
|
194
194
|
|
|
195
|
-
|
|
196
|
-
if attachedNodes.isEmpty && engine.isRunning {
|
|
197
|
-
engine.stop()
|
|
198
|
-
Logger.debug("[\(SharedAudioEngine.TAG)] Node detached, engine stopped (no remaining nodes)")
|
|
199
|
-
} else {
|
|
200
|
-
Logger.debug("[\(SharedAudioEngine.TAG)] Node detached")
|
|
201
|
-
}
|
|
195
|
+
Logger.debug("[\(SharedAudioEngine.TAG)] Node detached")
|
|
202
196
|
}
|
|
203
197
|
|
|
204
198
|
// ════════════════════════════════════════════════════════════════════
|
|
@@ -207,31 +201,21 @@ class SharedAudioEngine {
|
|
|
207
201
|
|
|
208
202
|
/// Tear down the engine completely. Called on reconfigure or module destroy.
|
|
209
203
|
func teardown() {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
private func _teardown() {
|
|
204
|
+
lock.lock()
|
|
205
|
+
defer { lock.unlock() }
|
|
214
206
|
// Remove observers
|
|
215
207
|
NotificationCenter.default.removeObserver(
|
|
216
208
|
self, name: AVAudioSession.routeChangeNotification, object: nil)
|
|
217
209
|
NotificationCenter.default.removeObserver(
|
|
218
210
|
self, name: AVAudioSession.interruptionNotification, object: nil)
|
|
219
211
|
|
|
220
|
-
//
|
|
221
|
-
// swapping VoiceProcessingIO back to RemoteIO while we clean up.
|
|
222
|
-
// Without this, a new engine created immediately after teardown can
|
|
223
|
-
// crash in Initialize (inputNode/outputNode both nil) because the
|
|
224
|
-
// IO unit is still mid-swap.
|
|
225
|
-
if playbackMode == .conversation || playbackMode == .voiceProcessing {
|
|
226
|
-
if let engine = engine {
|
|
227
|
-
try? engine.inputNode.setVoiceProcessingEnabled(false)
|
|
228
|
-
try? engine.outputNode.setVoiceProcessingEnabled(false)
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
engine?.stop()
|
|
212
|
+
// Detach all tracked nodes
|
|
233
213
|
if let engine = engine {
|
|
234
214
|
for info in attachedNodes {
|
|
215
|
+
info.node.pause()
|
|
216
|
+
info.node.stop()
|
|
217
|
+
// Guard against nodes already removed from engine (e.g. engine
|
|
218
|
+
// died or node was detached by a concurrent disconnect call).
|
|
235
219
|
if info.node.engine === engine {
|
|
236
220
|
engine.disconnectNodeOutput(info.node)
|
|
237
221
|
engine.detach(info.node)
|
|
@@ -240,6 +224,15 @@ class SharedAudioEngine {
|
|
|
240
224
|
}
|
|
241
225
|
attachedNodes.removeAll()
|
|
242
226
|
|
|
227
|
+
// Disable voice processing before stopping
|
|
228
|
+
if playbackMode == .conversation || playbackMode == .voiceProcessing {
|
|
229
|
+
if let engine = engine {
|
|
230
|
+
try? engine.inputNode.setVoiceProcessingEnabled(false)
|
|
231
|
+
try? engine.outputNode.setVoiceProcessingEnabled(false)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
engine?.stop()
|
|
243
236
|
engine = nil
|
|
244
237
|
isConfigured = false
|
|
245
238
|
|
|
@@ -260,12 +253,9 @@ class SharedAudioEngine {
|
|
|
260
253
|
return
|
|
261
254
|
}
|
|
262
255
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
}
|
|
266
|
-
}
|
|
256
|
+
lock.lock()
|
|
257
|
+
defer { lock.unlock() }
|
|
267
258
|
|
|
268
|
-
private func _handleRouteChange(reason: AVAudioSession.RouteChangeReason) {
|
|
269
259
|
let routeDescription = AVAudioSession.sharedInstance().currentRoute.outputs
|
|
270
260
|
.map { "\($0.portName) (\($0.portType.rawValue))" }
|
|
271
261
|
.joined(separator: ", ")
|
|
@@ -285,7 +275,14 @@ class SharedAudioEngine {
|
|
|
285
275
|
// Suppress completion handlers from node.stop() re-entering the scheduling loop
|
|
286
276
|
isRebuildingForRouteChange = true
|
|
287
277
|
|
|
288
|
-
// 1. Stop
|
|
278
|
+
// 1. Stop all attached nodes (completion handlers fire but are gated)
|
|
279
|
+
for info in attachedNodes {
|
|
280
|
+
Logger.debug("[\(SharedAudioEngine.TAG)] Stopping node — isPlaying=\(info.node.isPlaying)")
|
|
281
|
+
info.node.pause()
|
|
282
|
+
info.node.stop()
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// 2. Stop engine
|
|
289
286
|
if engine.isRunning {
|
|
290
287
|
engine.stop()
|
|
291
288
|
Logger.debug("[\(SharedAudioEngine.TAG)] Engine stopped")
|
|
@@ -293,7 +290,7 @@ class SharedAudioEngine {
|
|
|
293
290
|
Logger.debug("[\(SharedAudioEngine.TAG)] Engine was already stopped")
|
|
294
291
|
}
|
|
295
292
|
|
|
296
|
-
//
|
|
293
|
+
// 3. Detach all nodes
|
|
297
294
|
for info in attachedNodes {
|
|
298
295
|
if info.node.engine === engine {
|
|
299
296
|
engine.disconnectNodeOutput(info.node)
|
|
@@ -302,7 +299,7 @@ class SharedAudioEngine {
|
|
|
302
299
|
}
|
|
303
300
|
Logger.debug("[\(SharedAudioEngine.TAG)] Nodes detached (\(attachedNodes.count))")
|
|
304
301
|
|
|
305
|
-
//
|
|
302
|
+
// 4. Re-enable voice processing (resets after engine stop)
|
|
306
303
|
if playbackMode == .conversation || playbackMode == .voiceProcessing {
|
|
307
304
|
do {
|
|
308
305
|
try engine.inputNode.setVoiceProcessingEnabled(true)
|
|
@@ -312,14 +309,14 @@ class SharedAudioEngine {
|
|
|
312
309
|
}
|
|
313
310
|
}
|
|
314
311
|
|
|
315
|
-
//
|
|
312
|
+
// 5. Re-attach all nodes
|
|
316
313
|
for info in attachedNodes {
|
|
317
314
|
engine.attach(info.node)
|
|
318
315
|
engine.connect(info.node, to: engine.mainMixerNode, format: info.format)
|
|
319
316
|
}
|
|
320
317
|
Logger.debug("[\(SharedAudioEngine.TAG)] Nodes re-attached (\(attachedNodes.count))")
|
|
321
318
|
|
|
322
|
-
//
|
|
319
|
+
// 6. Reactivate session and restart engine with retry.
|
|
323
320
|
// Voice processing mode switches the underlying audio unit (RemoteIO ↔
|
|
324
321
|
// VoiceProcessingIO). This swap completes asynchronously — if we call
|
|
325
322
|
// engine.start() immediately, the engine appears to start (isRunning=true)
|
|
@@ -330,7 +327,7 @@ class SharedAudioEngine {
|
|
|
330
327
|
? [0.15, 0.3, 0.6] // 150ms, 300ms, 600ms pre-start delay for VP mode (+100ms post-start verify)
|
|
331
328
|
: [0.0, 0.1, 0.25] // immediate, then backoff for non-VP (+50ms post-start verify)
|
|
332
329
|
|
|
333
|
-
|
|
330
|
+
self.attemptRestart(engine: engine, retryDelays: retryDelays, attempt: 0)
|
|
334
331
|
|
|
335
332
|
case .categoryChange:
|
|
336
333
|
Logger.debug("[\(SharedAudioEngine.TAG)] Audio session category changed")
|
|
@@ -343,14 +340,12 @@ class SharedAudioEngine {
|
|
|
343
340
|
/// is truly running and nodes are playing before declaring success.
|
|
344
341
|
/// On final failure, falls back to a full rebuild. If that also fails,
|
|
345
342
|
/// tears down everything and notifies delegates via `engineDidDie`.
|
|
346
|
-
|
|
347
|
-
/// Must be called on `queue`.
|
|
348
|
-
private func _attemptRestart(engine: AVAudioEngine, retryDelays: [TimeInterval], attempt: Int) {
|
|
343
|
+
private func attemptRestart(engine: AVAudioEngine, retryDelays: [TimeInterval], attempt: Int) {
|
|
349
344
|
guard attempt < retryDelays.count else {
|
|
350
345
|
// Exhausted in-place retries — try a full rebuild as last resort
|
|
351
346
|
Logger.debug("[\(SharedAudioEngine.TAG)] All \(retryDelays.count) restart attempts failed — attempting full rebuild")
|
|
352
347
|
isRebuildingForRouteChange = false
|
|
353
|
-
|
|
348
|
+
rebuildEngine()
|
|
354
349
|
return
|
|
355
350
|
}
|
|
356
351
|
|
|
@@ -381,7 +376,7 @@ class SharedAudioEngine {
|
|
|
381
376
|
}
|
|
382
377
|
} catch {
|
|
383
378
|
Logger.debug("[\(SharedAudioEngine.TAG)] engine.start() threw on attempt \(attempt + 1): \(error)")
|
|
384
|
-
self.
|
|
379
|
+
self.attemptRestart(engine: engine, retryDelays: retryDelays, attempt: attempt + 1)
|
|
385
380
|
return
|
|
386
381
|
}
|
|
387
382
|
|
|
@@ -400,14 +395,14 @@ class SharedAudioEngine {
|
|
|
400
395
|
// Failed immediately — no point waiting, retry now
|
|
401
396
|
Logger.debug("[\(SharedAudioEngine.TAG)] Restart attempt \(attempt + 1) failed immediately")
|
|
402
397
|
if engine.isRunning { engine.stop() }
|
|
403
|
-
self.
|
|
398
|
+
self.attemptRestart(engine: engine, retryDelays: retryDelays, attempt: attempt + 1)
|
|
404
399
|
return
|
|
405
400
|
}
|
|
406
401
|
|
|
407
402
|
// Voice processing can cause the engine to die asynchronously after
|
|
408
|
-
// appearing to start. Wait then re-verify before declaring success.
|
|
403
|
+
// appearing to start. Wait 100ms then re-verify before declaring success.
|
|
409
404
|
let verifyDelay: TimeInterval = (self.playbackMode == .conversation || self.playbackMode == .voiceProcessing) ? 0.1 : 0.05
|
|
410
|
-
|
|
405
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + verifyDelay) { [weak self] in
|
|
411
406
|
guard let self = self, let engine = self.engine else {
|
|
412
407
|
self?.isRebuildingForRouteChange = false
|
|
413
408
|
return
|
|
@@ -436,9 +431,9 @@ class SharedAudioEngine {
|
|
|
436
431
|
if isVP {
|
|
437
432
|
Logger.debug("[\(SharedAudioEngine.TAG)] VP mode — skipping remaining in-place retries, going to full rebuild")
|
|
438
433
|
self.isRebuildingForRouteChange = false
|
|
439
|
-
self.
|
|
434
|
+
self.rebuildEngine()
|
|
440
435
|
} else {
|
|
441
|
-
self.
|
|
436
|
+
self.attemptRestart(engine: engine, retryDelays: retryDelays, attempt: attempt + 1)
|
|
442
437
|
}
|
|
443
438
|
}
|
|
444
439
|
}
|
|
@@ -446,7 +441,7 @@ class SharedAudioEngine {
|
|
|
446
441
|
|
|
447
442
|
if delay > 0 {
|
|
448
443
|
Logger.debug("[\(SharedAudioEngine.TAG)] Waiting \(Int(delay * 1000))ms before attempt \(attempt + 1)")
|
|
449
|
-
|
|
444
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
|
|
450
445
|
} else {
|
|
451
446
|
work()
|
|
452
447
|
}
|
|
@@ -459,17 +454,15 @@ class SharedAudioEngine {
|
|
|
459
454
|
///
|
|
460
455
|
/// If this also fails, declare the engine dead, tear down all state, and
|
|
461
456
|
/// notify delegates so they can report the failure to JS.
|
|
462
|
-
|
|
463
|
-
/// Must be called on `queue`.
|
|
464
|
-
private func _rebuildEngine() {
|
|
457
|
+
private func rebuildEngine() {
|
|
465
458
|
Logger.debug("[\(SharedAudioEngine.TAG)] rebuildEngine — creating fresh engine (old nodes will NOT be reused)")
|
|
466
459
|
let savedMode = playbackMode
|
|
467
460
|
|
|
468
461
|
// Full teardown (clears attachedNodes, stops engine, nils it)
|
|
469
|
-
|
|
462
|
+
teardown()
|
|
470
463
|
|
|
471
464
|
do {
|
|
472
|
-
try
|
|
465
|
+
try configure(playbackMode: savedMode)
|
|
473
466
|
// Do NOT re-attach old nodes. The VP IO swap can leave old
|
|
474
467
|
// AVAudioPlayerNode instances in a broken state. Delegates must
|
|
475
468
|
// create fresh nodes in their engineDidRebuild() callback.
|
|
@@ -478,7 +471,7 @@ class SharedAudioEngine {
|
|
|
478
471
|
} catch {
|
|
479
472
|
Logger.debug("[\(SharedAudioEngine.TAG)] rebuildEngine FAILED — engine is dead: \(error)")
|
|
480
473
|
// Ensure everything is torn down so a future connect() starts clean
|
|
481
|
-
|
|
474
|
+
teardown()
|
|
482
475
|
let reason = "Route change recovery failed after all retries: \(error.localizedDescription)"
|
|
483
476
|
notifyDelegates { $0.engineDidDie(reason: reason) }
|
|
484
477
|
}
|
|
@@ -493,12 +486,9 @@ class SharedAudioEngine {
|
|
|
493
486
|
let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
|
|
494
487
|
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return }
|
|
495
488
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
}
|
|
499
|
-
}
|
|
489
|
+
lock.lock()
|
|
490
|
+
defer { lock.unlock() }
|
|
500
491
|
|
|
501
|
-
private func _handleInterruption(type: AVAudioSession.InterruptionType) {
|
|
502
492
|
if type == .began {
|
|
503
493
|
Logger.debug("[\(SharedAudioEngine.TAG)] Audio session interruption began")
|
|
504
494
|
notifyDelegates { $0.audioSessionInterruptionBegan() }
|
|
@@ -521,6 +511,6 @@ class SharedAudioEngine {
|
|
|
521
511
|
}
|
|
522
512
|
|
|
523
513
|
deinit {
|
|
524
|
-
|
|
514
|
+
teardown()
|
|
525
515
|
}
|
|
526
516
|
}
|