@edkimmel/expo-audio-stream 0.6.1 → 0.6.2

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.
@@ -85,6 +85,15 @@ class AudioPipeline: SharedAudioEngineDelegate {
85
85
  /// This prevents duplicate chains and stale callbacks from re-entering after a rebuild.
86
86
  private var scheduleGeneration: Int = 0
87
87
 
88
+ /// All `node.scheduleBuffer(...)` calls for this pipeline run on the shared
89
+ /// engine's serial `engineQueue` (the same queue that owns stop/detach/attach),
90
+ /// so `scheduleBuffer()` and teardown's `stop()` can never overlap — that
91
+ /// overlap is the AVAudioPlayerNode deadlock (AttachAndEngineLock ⇄
92
+ /// RealtimeMessenger). The buffer-completion callback re-arms via
93
+ /// `engineQueue.async` (returns immediately so `stop()`'s message flush isn't
94
+ /// blocked); teardown runs on the same queue via `sharedEngine.performSync`,
95
+ /// so it's serialized after any in-flight scheduling with no explicit barrier.
96
+
88
97
  /// Pending PlaybackStopped dispatch — cancelled on new turn / disconnect.
89
98
  /// Always mutate from the main queue to avoid races with the drain timer.
90
99
  private var pendingPlaybackStoppedWork: DispatchWorkItem?
@@ -134,12 +143,26 @@ class AudioPipeline: SharedAudioEngineDelegate {
134
143
  }
135
144
  setState(.connecting)
136
145
 
146
+ guard let sharedEngine = sharedEngine else {
147
+ setState(.error)
148
+ throw NSError(domain: "AudioPipeline", code: -1,
149
+ userInfo: [NSLocalizedDescriptionKey: "SharedAudioEngine not set"])
150
+ }
151
+
137
152
  do {
138
- guard let sharedEngine = sharedEngine else {
139
- throw NSError(domain: "AudioPipeline", code: -1,
140
- userInfo: [NSLocalizedDescriptionKey: "SharedAudioEngine not set"])
141
- }
153
+ // All graph setup runs on the engine's serial queue so attach / play /
154
+ // seed are serialized against route-change, scheduleBuffer and teardown.
155
+ try sharedEngine.performSync { try self.connectOnQueue(sharedEngine: sharedEngine) }
156
+ } catch {
157
+ Logger.debug("[\(AudioPipeline.TAG)] connect() failed: \(error)")
158
+ setState(.error)
159
+ disconnect()
160
+ throw error
161
+ }
162
+ }
142
163
 
164
+ /// Graph setup for `connect()`. Runs entirely on `sharedEngine.engineQueue`.
165
+ private func connectOnQueue(sharedEngine: SharedAudioEngine) throws {
143
166
  // ── 1. JitterBuffer ─────────────────────────────────────────
144
167
  jitterBuffer = JitterBuffer(
145
168
  sampleRate: sampleRate,
@@ -199,17 +222,26 @@ class AudioPipeline: SharedAudioEngineDelegate {
199
222
  Logger.debug("[\(AudioPipeline.TAG)] Connected — sampleRate=\(sampleRate) " +
200
223
  "ch=\(channelCount) frameSamples=\(frameSizeSamples) " +
201
224
  "targetBuffer=\(targetBufferMs)ms")
202
- } catch {
203
- Logger.debug("[\(AudioPipeline.TAG)] connect() failed: \(error)")
204
- setState(.error)
205
- disconnect()
206
- throw error
207
- }
208
225
  }
209
226
 
210
227
  func disconnect() {
228
+ // Run teardown on the engine's serial queue so it's serialized after any
229
+ // in-flight scheduleBuffer pass and against route-change/interruption —
230
+ // no explicit barrier needed. If the shared engine is already gone
231
+ // (module destroyed), just clean up local state.
232
+ if let sharedEngine = sharedEngine {
233
+ sharedEngine.performSync { self.disconnectOnQueue() }
234
+ } else {
235
+ disconnectOnQueue()
236
+ }
237
+ }
238
+
239
+ /// Teardown body. Runs on `sharedEngine.engineQueue` (via `performSync`), so
240
+ /// `running = false` and `detachNode`'s `stop()` are ordered after any
241
+ /// scheduling pass; a buffer completion that fires during `stop()` only
242
+ /// re-enqueues onto the same queue and then bails on `running == false`.
243
+ private func disconnectOnQueue() {
211
244
  // Cancel any pending PlaybackStopped before tearing down.
212
- // DispatchWorkItem.cancel is thread-safe; we may be on any queue here.
213
245
  pendingPlaybackStoppedWork?.cancel()
214
246
  pendingPlaybackStoppedWork = nil
215
247
 
@@ -227,7 +259,8 @@ class AudioPipeline: SharedAudioEngineDelegate {
227
259
  frequencyBandAnalyzer = nil
228
260
  lastEmittedBands = nil
229
261
 
230
- // Detach node from shared engine (handles pause/stop/disconnect/detach)
262
+ // Detach node from shared engine (handles pause/stop/disconnect/detach).
263
+ // detachNode is performSync → runs inline since we're already on the queue.
231
264
  if let node = playerNode {
232
265
  sharedEngine?.detachNode(node)
233
266
  }
@@ -344,6 +377,11 @@ class AudioPipeline: SharedAudioEngineDelegate {
344
377
  func audioSessionInterruptionEnded() {
345
378
  Logger.debug("[\(AudioPipeline.TAG)] Audio session interruption ended")
346
379
  isInterrupted = false
380
+ // Reset the zombie baseline as we clear the interruption: scheduling was
381
+ // stalled while interrupted, and the re-seed below runs asynchronously on
382
+ // the engine queue, so without this the zombie timer could false-positive
383
+ // in the gap before the first resumed buffer is scheduled.
384
+ lastScheduleTime = Date()
347
385
  // Engine already restarted by SharedAudioEngine. Re-seed scheduling.
348
386
  if running {
349
387
  scheduleGeneration += 1
@@ -465,7 +503,19 @@ class AudioPipeline: SharedAudioEngineDelegate {
465
503
  // Scheduling loop
466
504
  // ════════════════════════════════════════════════════════════════════
467
505
 
506
+ /// Request one buffer-scheduling pass. Hops onto the shared engine's serial
507
+ /// `engineQueue` so the actual `scheduleBuffer()` is serialized against
508
+ /// teardown's `stop()` and graph mutations. All re-seed call sites (connect,
509
+ /// route change, rebuild, interruption) use this; the completion handler
510
+ /// re-arms via `engineQueue.async` directly (see below).
468
511
  private func scheduleNextBuffer() {
512
+ sharedEngine?.engineQueue.async { [weak self] in
513
+ self?.scheduleNextBufferOnQueue()
514
+ }
515
+ }
516
+
517
+ /// Build and schedule the next PCM buffer. MUST run on `engineQueue`.
518
+ private func scheduleNextBufferOnQueue() {
469
519
  guard running,
470
520
  let se = sharedEngine, !se.isRebuilding,
471
521
  let buf = jitterBuffer,
@@ -521,11 +571,21 @@ class AudioPipeline: SharedAudioEngineDelegate {
521
571
  lastScheduleTime = Date()
522
572
 
523
573
  node.scheduleBuffer(pcmBuffer) { [weak self] in
524
- guard let self = self, self.running else { return }
525
- // Bail if this completion belongs to a previous scheduling generation
526
- // (route change rebuilt the engine while this buffer was in flight).
527
- guard self.scheduleGeneration == capturedGeneration else { return }
528
- self.scheduleNextBuffer()
574
+ guard let self = self else { return }
575
+ // Re-arm on the engine queue. Returning from this completion callback
576
+ // IMMEDIATELY is essential: AVAudioPlayerNode.stop() flushes pending
577
+ // completion messages synchronously while holding the engine lock, so
578
+ // calling scheduleBuffer() inline here would deadlock a concurrent
579
+ // stop() (AttachAndEngineLock ⇄ RealtimeMessenger mutex). Hopping to
580
+ // engineQueue lets stop() drain us without blocking, and serializes
581
+ // the next scheduleBuffer() against teardown on the same queue.
582
+ self.sharedEngine?.engineQueue.async {
583
+ // Bail if torn down, or if this completion belongs to a previous
584
+ // scheduling generation (route change rebuilt the engine while
585
+ // this buffer was in flight).
586
+ guard self.running, self.scheduleGeneration == capturedGeneration else { return }
587
+ self.scheduleNextBufferOnQueue()
588
+ }
529
589
  }
530
590
  }
531
591
 
@@ -631,7 +691,11 @@ class AudioPipeline: SharedAudioEngineDelegate {
631
691
  timer.setEventHandler { [weak self] in
632
692
  guard let self = self else { return }
633
693
  let stalledMs = Int64(Date().timeIntervalSince(self.lastScheduleTime) * 1000)
694
+ // Don't flag a zombie while interrupted: the engine is stopped, so
695
+ // scheduling legitimately stalls (no buffers feed) until resume. The
696
+ // stall is expected, not a dead scheduling loop.
634
697
  if stalledMs >= AudioPipeline.ZOMBIE_STALL_THRESHOLD_MS &&
698
+ !self.isInterrupted &&
635
699
  (self.state == .streaming || self.state == .draining) {
636
700
  Logger.debug("[\(AudioPipeline.TAG)] Zombie detected! stalledMs=\(stalledMs)")
637
701
  self.listener?.onZombieDetected(stalledMs: stalledMs)
@@ -9,6 +9,14 @@ class Microphone: SharedAudioEngineDelegate {
9
9
  /// Must be set and configured before calling startRecording.
10
10
  weak var sharedAudioEngine: SharedAudioEngine?
11
11
 
12
+ /// Serial queue for mic-input processing. The tap fires on AVFoundation's
13
+ /// real-time render thread; we copy the buffer and hop here so resampling,
14
+ /// frequency analysis and the JS `sendEvent` never run on that thread (a
15
+ /// real-time violation that can stall capture or invert priority). Kept
16
+ /// separate from the engine queue so a congested JS bridge on the mic path
17
+ /// can't stall playback scheduling.
18
+ private let micQueue = DispatchQueue(label: "expo.modules.audio.microphone")
19
+
12
20
  public private(set) var isVoiceProcessingEnabled: Bool = false
13
21
 
14
22
  internal var lastEmittedSize: Int64 = 0
@@ -214,20 +222,39 @@ class Microphone: SharedAudioEngineDelegate {
214
222
  guard let self = self else { return }
215
223
  guard buffer.frameLength > 0 else {
216
224
  Logger.debug("[Microphone] Received empty buffer in tap callback")
217
- self.delegate?.onMicrophoneError(MicrophoneErrorInfo(
218
- code: "READ_ERROR",
219
- message: "Received empty audio buffer",
220
- isFatal: false,
221
- autoResuming: false
222
- ))
225
+ self.micQueue.async {
226
+ self.delegate?.onMicrophoneError(MicrophoneErrorInfo(
227
+ code: "READ_ERROR",
228
+ message: "Received empty audio buffer",
229
+ isFatal: false,
230
+ autoResuming: false
231
+ ))
232
+ }
223
233
  return
224
234
  }
225
- self.processAudioBuffer(buffer)
226
- self.lastBufferTime = time
235
+ // Convert on the render thread — the tap buffer is only valid here, and
236
+ // the conversion yields the `Data` we'd build anyway, so there's no
237
+ // extra copy. Hand the value-typed result to the serial queue; only the
238
+ // JS-bridge delivery (sendEvent) runs off the render thread. Nothing
239
+ // here re-enters the engine, so no separate queue is needed for safety —
240
+ // micQueue exists purely to keep the bridge call off the render thread
241
+ // and preserve chunk ordering.
242
+ guard let payload = self.extractMicData(from: buffer) else { return }
243
+ self.micQueue.async {
244
+ self.totalDataSize += Int64(payload.data.count)
245
+ self.delegate?.onMicrophoneData(payload.data, payload.powerLevel, payload.bands)
246
+ self.lastEmittedSize = self.totalDataSize
247
+ self.lastBufferTime = time
248
+ }
227
249
  }
228
250
  }
229
251
 
230
- private func processAudioBuffer(_ buffer: AVAudioPCMBuffer) {
252
+ /// Convert a tap buffer into the value-typed payload the delegate needs
253
+ /// (PCM `Data`, power level, frequency bands). Runs on the audio render
254
+ /// thread — it needs the live buffer — but does no cross-thread work, so the
255
+ /// caller hands the result to `micQueue` for the JS-bridge delivery. Returns
256
+ /// nil if the buffer yields no data.
257
+ private func extractMicData(from buffer: AVAudioPCMBuffer) -> (data: Data, powerLevel: Float, bands: FrequencyBands?)? {
231
258
  let targetSampleRate = recordingSettings?.desiredSampleRate ?? buffer.format.sampleRate
232
259
  let targetBitDepth = recordingSettings?.bitDepth ?? 16
233
260
  var currentBuffer = buffer
@@ -271,7 +298,7 @@ class Microphone: SharedAudioEngineDelegate {
271
298
  let audioData = currentBuffer.audioBufferList.pointee.mBuffers
272
299
  guard let bufferData = audioData.mData else {
273
300
  Logger.debug("[Microphone] Buffer data is nil.")
274
- return
301
+ return nil
275
302
  }
276
303
  data = Data(bytes: bufferData, count: Int(audioData.mDataByteSize))
277
304
  }
@@ -286,8 +313,6 @@ class Microphone: SharedAudioEngineDelegate {
286
313
  bands = nil
287
314
  }
288
315
 
289
- totalDataSize += Int64(data.count)
290
- self.delegate?.onMicrophoneData(data, powerLevel, bands)
291
- self.lastEmittedSize = totalDataSize
316
+ return (data, powerLevel, bands)
292
317
  }
293
318
  }
@@ -49,7 +49,33 @@ extension SharedAudioEngineDelegate {
49
49
  class SharedAudioEngine {
50
50
  private static let TAG = "SharedAudioEngine"
51
51
 
52
- private let lock = NSRecursiveLock()
52
+ /// The single serial queue that owns ALL engine graph work: every
53
+ /// attach/detach/connect/stop/start/configure/teardown, the playback
54
+ /// `scheduleBuffer` loop (driven from AudioPipeline), and route/interruption
55
+ /// handling. Serial ordering here is what makes the AVAudioPlayerNode
56
+ /// `stop()`/`scheduleBuffer()` deadlock structurally impossible — they can't
57
+ /// overlap on one serial queue. AVFoundation callbacks hop on via `async`
58
+ /// (never `sync` — they may run with an AV lock held); Expo functions enter
59
+ /// via `performSync`. Replaces the former recursive lock.
60
+ let engineQueue = DispatchQueue(label: "expo.modules.audio.engine")
61
+ private static let queueKey = DispatchSpecificKey<Void>()
62
+
63
+ init() {
64
+ engineQueue.setSpecific(key: SharedAudioEngine.queueKey, value: ())
65
+ }
66
+
67
+ /// Run `work` synchronously in the engine's serial domain. Re-entrancy-aware:
68
+ /// if we're already on `engineQueue` (a delegate callback fired from
69
+ /// `notifyDelegates`, or `configure` calling `teardown`/`attachNode`) we run
70
+ /// inline instead of dead-locking on `engineQueue.sync`. This is exactly what
71
+ /// the *recursive* lock used to provide. Callers on AVFoundation callback
72
+ /// threads must use `engineQueue.async` instead — never this.
73
+ func performSync<T>(_ work: () throws -> T) rethrows -> T {
74
+ if DispatchQueue.getSpecific(key: SharedAudioEngine.queueKey) != nil {
75
+ return try work()
76
+ }
77
+ return try engineQueue.sync(execute: work)
78
+ }
53
79
 
54
80
  // ── Engine state ─────────────────────────────────────────────────────
55
81
  private(set) var engine: AVAudioEngine?
@@ -61,17 +87,17 @@ class SharedAudioEngine {
61
87
  private let delegates = NSHashTable<AnyObject>.weakObjects()
62
88
 
63
89
  func addDelegate(_ d: SharedAudioEngineDelegate) {
64
- lock.lock()
65
- defer { lock.unlock() }
66
- if !delegates.contains(d as AnyObject) {
67
- delegates.add(d as AnyObject)
90
+ performSync {
91
+ if !delegates.contains(d as AnyObject) {
92
+ delegates.add(d as AnyObject)
93
+ }
68
94
  }
69
95
  }
70
96
 
71
97
  func removeDelegate(_ d: SharedAudioEngineDelegate) {
72
- lock.lock()
73
- defer { lock.unlock() }
74
- delegates.remove(d as AnyObject)
98
+ performSync {
99
+ delegates.remove(d as AnyObject)
100
+ }
75
101
  }
76
102
 
77
103
  private func notifyDelegates(_ block: (SharedAudioEngineDelegate) -> Void) {
@@ -100,8 +126,10 @@ class SharedAudioEngine {
100
126
  ///
101
127
  /// - Parameter playbackMode: Determines whether voice processing is enabled.
102
128
  func configure(playbackMode: PlaybackMode) throws {
103
- lock.lock()
104
- defer { lock.unlock() }
129
+ try performSync { try configureOnQueue(playbackMode: playbackMode) }
130
+ }
131
+
132
+ private func configureOnQueue(playbackMode: PlaybackMode) throws {
105
133
  if isConfigured && self.playbackMode == playbackMode && engine?.isRunning == true {
106
134
  Logger.debug("[\(SharedAudioEngine.TAG)] Already configured for \(playbackMode) and engine running, skipping")
107
135
  return
@@ -168,8 +196,10 @@ class SharedAudioEngine {
168
196
  /// Connects `node → mainMixerNode` with the given format.
169
197
  /// The mixer handles sample-rate conversion to hardware output.
170
198
  func attachNode(_ node: AVAudioPlayerNode, format: AVAudioFormat) {
171
- lock.lock()
172
- defer { lock.unlock() }
199
+ performSync { attachNodeOnQueue(node, format: format) }
200
+ }
201
+
202
+ private func attachNodeOnQueue(_ node: AVAudioPlayerNode, format: AVAudioFormat) {
173
203
  guard let engine = engine else {
174
204
  Logger.debug("[\(SharedAudioEngine.TAG)] attachNode called but engine is nil")
175
205
  return
@@ -184,8 +214,10 @@ class SharedAudioEngine {
184
214
 
185
215
  /// Detach a consumer's player node from the shared engine.
186
216
  func detachNode(_ node: AVAudioPlayerNode) {
187
- lock.lock()
188
- defer { lock.unlock() }
217
+ performSync { detachNodeOnQueue(node) }
218
+ }
219
+
220
+ private func detachNodeOnQueue(_ node: AVAudioPlayerNode) {
189
221
  guard let engine = engine else { return }
190
222
 
191
223
  node.pause()
@@ -209,8 +241,10 @@ class SharedAudioEngine {
209
241
 
210
242
  /// Tear down the engine completely. Called on reconfigure or module destroy.
211
243
  func teardown() {
212
- lock.lock()
213
- defer { lock.unlock() }
244
+ performSync { teardownOnQueue() }
245
+ }
246
+
247
+ private func teardownOnQueue() {
214
248
  // Remove observers
215
249
  NotificationCenter.default.removeObserver(
216
250
  self, name: AVAudioSession.routeChangeNotification, object: nil)
@@ -260,10 +294,15 @@ class SharedAudioEngine {
260
294
  let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else {
261
295
  return
262
296
  }
297
+ // Hop off AVFoundation's notification thread onto the engine queue before
298
+ // touching the engine or session — that thread can hold an internal lock
299
+ // when delivering this, and the queue serializes us against connect/disconnect.
300
+ engineQueue.async { [weak self] in
301
+ self?.handleRouteChangeOnQueue(reason: reason)
302
+ }
303
+ }
263
304
 
264
- lock.lock()
265
- defer { lock.unlock() }
266
-
305
+ private func handleRouteChangeOnQueue(reason: AVAudioSession.RouteChangeReason) {
267
306
  let routeDescription = AVAudioSession.sharedInstance().currentRoute.outputs
268
307
  .map { "\($0.portName) (\($0.portType.rawValue))" }
269
308
  .joined(separator: ", ")
@@ -410,7 +449,7 @@ class SharedAudioEngine {
410
449
  // Voice processing can cause the engine to die asynchronously after
411
450
  // appearing to start. Wait 100ms then re-verify before declaring success.
412
451
  let verifyDelay: TimeInterval = (self.playbackMode == .conversation || self.playbackMode == .voiceProcessing) ? 0.1 : 0.05
413
- DispatchQueue.main.asyncAfter(deadline: .now() + verifyDelay) { [weak self] in
452
+ self.engineQueue.asyncAfter(deadline: .now() + verifyDelay) { [weak self] in
414
453
  guard let self = self, let engine = self.engine else {
415
454
  self?.isRebuildingForRouteChange = false
416
455
  return
@@ -449,7 +488,7 @@ class SharedAudioEngine {
449
488
 
450
489
  if delay > 0 {
451
490
  Logger.debug("[\(SharedAudioEngine.TAG)] Waiting \(Int(delay * 1000))ms before attempt \(attempt + 1)")
452
- DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
491
+ engineQueue.asyncAfter(deadline: .now() + delay, execute: work)
453
492
  } else {
454
493
  work()
455
494
  }
@@ -493,16 +532,19 @@ class SharedAudioEngine {
493
532
  guard let info = notification.userInfo,
494
533
  let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
495
534
  let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return }
535
+ let optionsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
536
+ // Hop off AVFoundation's notification thread onto the engine queue.
537
+ engineQueue.async { [weak self] in
538
+ self?.handleInterruptionOnQueue(type: type, optionsValue: optionsValue)
539
+ }
540
+ }
496
541
 
497
- lock.lock()
498
- defer { lock.unlock() }
499
-
542
+ private func handleInterruptionOnQueue(type: AVAudioSession.InterruptionType, optionsValue: UInt) {
500
543
  if type == .began {
501
544
  Logger.debug("[\(SharedAudioEngine.TAG)] Audio session interruption began")
502
545
  notifyDelegates { $0.audioSessionInterruptionBegan() }
503
546
  } else if type == .ended {
504
547
  Logger.debug("[\(SharedAudioEngine.TAG)] Audio session interruption ended")
505
- let optionsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
506
548
  let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
507
549
  guard options.contains(.shouldResume) else {
508
550
  // shouldResume absent — system hints we should not restart yet.
@@ -529,6 +571,8 @@ class SharedAudioEngine {
529
571
  }
530
572
 
531
573
  deinit {
532
- teardown()
574
+ // At dealloc there are no other references, so no concurrency — run the
575
+ // body directly rather than dispatching onto the queue during teardown.
576
+ teardownOnQueue()
533
577
  }
534
578
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edkimmel/expo-audio-stream",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "Expo Play Audio Stream module",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",