@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.
- package/ios/AudioPipeline.swift +81 -17
- package/ios/Microphone.swift +38 -13
- package/ios/SharedAudioEngine.swift +70 -26
- package/package.json +1 -1
package/ios/AudioPipeline.swift
CHANGED
|
@@ -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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
|
|
528
|
-
|
|
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)
|
package/ios/Microphone.swift
CHANGED
|
@@ -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.
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
226
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
172
|
-
|
|
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
|
-
|
|
188
|
-
|
|
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
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|