@edkimmel/expo-audio-stream 0.3.3 → 0.4.0

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.
@@ -3,17 +3,14 @@ import AVFoundation
3
3
  import ExpoModulesCore
4
4
 
5
5
  let audioDataEvent: String = "AudioData"
6
- let soundIsPlayedEvent: String = "SoundChunkPlayed"
7
- let soundIsStartedEvent: String = "SoundStarted"
8
6
  let deviceReconnectedEvent: String = "DeviceReconnected"
9
7
 
10
8
 
11
- public class ExpoPlayAudioStreamModule: Module, MicrophoneDataDelegate, SoundPlayerDelegate, PipelineEventSender {
9
+ public class ExpoPlayAudioStreamModule: Module, MicrophoneDataDelegate, PipelineEventSender {
12
10
  private var _microphone: Microphone?
13
- private var _soundPlayer: SoundPlayer?
14
11
  private var _pipelineIntegration: PipelineIntegration?
15
12
 
16
- /// Single shared AVAudioEngine used by both SoundPlayer and AudioPipeline.
13
+ /// Single shared AVAudioEngine used by AudioPipeline.
17
14
  private let sharedAudioEngine = SharedAudioEngine()
18
15
 
19
16
  private var microphone: Microphone {
@@ -24,15 +21,6 @@ public class ExpoPlayAudioStreamModule: Module, MicrophoneDataDelegate, SoundPla
24
21
  return _microphone!
25
22
  }
26
23
 
27
- private var soundPlayer: SoundPlayer {
28
- if _soundPlayer == nil {
29
- _soundPlayer = SoundPlayer()
30
- _soundPlayer?.delegate = self
31
- _soundPlayer?.setSharedEngine(sharedAudioEngine)
32
- }
33
- return _soundPlayer!
34
- }
35
-
36
24
  private var pipelineIntegration: PipelineIntegration {
37
25
  if _pipelineIntegration == nil {
38
26
  _pipelineIntegration = PipelineIntegration(eventSender: self, sharedEngine: sharedAudioEngine)
@@ -53,8 +41,6 @@ public class ExpoPlayAudioStreamModule: Module, MicrophoneDataDelegate, SoundPla
53
41
  // Defines event names that the module can send to JavaScript.
54
42
  Events([
55
43
  audioDataEvent,
56
- soundIsPlayedEvent,
57
- soundIsStartedEvent,
58
44
  deviceReconnectedEvent,
59
45
  PipelineIntegration.EVENT_STATE_CHANGED,
60
46
  PipelineIntegration.EVENT_PLAYBACK_STARTED,
@@ -67,7 +53,7 @@ public class ExpoPlayAudioStreamModule: Module, MicrophoneDataDelegate, SoundPla
67
53
  PipelineIntegration.EVENT_FREQUENCY_BANDS,
68
54
  ])
69
55
 
70
- Function("destroy") {
56
+ AsyncFunction("destroy") { (promise: Promise) in
71
57
  self._pipelineIntegration?.destroy()
72
58
  self._pipelineIntegration = nil
73
59
  self.sharedAudioEngine.teardown()
@@ -77,7 +63,7 @@ public class ExpoPlayAudioStreamModule: Module, MicrophoneDataDelegate, SoundPla
77
63
  self.isAudioSessionInitialized = false
78
64
  }
79
65
  self._microphone = nil
80
- self._soundPlayer = nil
66
+ promise.resolve(nil)
81
67
  }
82
68
 
83
69
  /// Prompts the user to select the microphone mode.
@@ -108,55 +94,12 @@ public class ExpoPlayAudioStreamModule: Module, MicrophoneDataDelegate, SoundPla
108
94
  ])
109
95
  }
110
96
 
111
- AsyncFunction("playSound") { (base64Chunk: String, turnId: String, encoding: String?, promise: Promise) in
112
- Logger.debug("Play sound")
113
- do {
114
- if !isAudioSessionInitialized {
115
- try ensureAudioSessionInitialized()
116
- }
117
-
118
- // Ensure shared engine is configured (playSound may be called without setSoundConfig)
119
- if !self.sharedAudioEngine.isConfigured {
120
- try self.sharedAudioEngine.configure(playbackMode: .regular)
121
- self.sharedAudioEngine.addDelegate(self.soundPlayer)
122
- }
123
-
124
- // Determine the audio format based on the encoding parameter
125
- let commonFormat: AVAudioCommonFormat
126
- switch encoding {
127
- case "pcm_f32le":
128
- commonFormat = .pcmFormatFloat32
129
- case "pcm_s16le", nil:
130
- commonFormat = .pcmFormatInt16
131
- default:
132
- Logger.debug("[ExpoPlayAudioStreamModule] Unsupported encoding: \(encoding ?? "nil"), defaulting to PCM_S16LE")
133
- commonFormat = .pcmFormatInt16
134
- }
135
-
136
- try soundPlayer.play(audioChunk: base64Chunk, turnId: turnId, resolver: {
137
- _ in promise.resolve(nil)
138
- }, rejecter: {code, message, error in
139
- promise.reject(code ?? "ERR_UNKNOWN", message ?? "Unknown error")
140
- }, commonFormat: commonFormat)
141
- } catch {
142
- print("Error enqueuing audio: \(error.localizedDescription)")
143
- }
144
- }
145
-
146
- AsyncFunction("stopSound") { (promise: Promise) in
147
- soundPlayer.stop(promise)
148
- }
149
-
150
- AsyncFunction("clearSoundQueueByTurnId") { (turnId: String, promise: Promise) in
151
- soundPlayer.clearSoundQueue(turnIdToClear: turnId, resolver: promise)
152
- }
153
-
154
97
  AsyncFunction("startMicrophone") { (options: [String: Any], promise: Promise) in
155
98
  // Create recording settings
156
99
  // Extract settings from provided options, using default values if necessary
157
- let sampleRate = options["sampleRate"] as? Double ?? 16000.0 // it fails if not 48000, why?
158
- let numberOfChannels = options["channelConfig"] as? Int ?? 1 // Mono channel configuration
159
- let bitDepth = options["audioFormat"] as? Int ?? 16 // 16bits
100
+ let sampleRate = options["sampleRate"] as? Double ?? 16000.0
101
+ let numberOfChannels = options["channelConfig"] as? Int ?? 1
102
+ let bitDepth = options["audioFormat"] as? Int ?? 16
160
103
  let interval = options["interval"] as? Int ?? 1000
161
104
 
162
105
  let fbConfigDict = options["frequencyBandConfig"] as? [String: Any]
@@ -203,11 +146,6 @@ public class ExpoPlayAudioStreamModule: Module, MicrophoneDataDelegate, SoundPla
203
146
  }
204
147
  }
205
148
 
206
- /// Stops the microphone recording and releases associated resources
207
- /// - Parameter promise: A promise to resolve when microphone recording is stopped
208
- /// - Note: This method stops the active recording session, processes any remaining audio data,
209
- /// and releases hardware resources. It should be called when the app no longer needs
210
- /// microphone access to conserve battery and system resources.
211
149
  AsyncFunction("stopMicrophone") { (promise: Promise) in
212
150
  microphone.stopRecording(resolver: promise)
213
151
  }
@@ -216,62 +154,6 @@ public class ExpoPlayAudioStreamModule: Module, MicrophoneDataDelegate, SoundPla
216
154
  microphone.toggleSilence(isSilent: isSilent)
217
155
  }
218
156
 
219
- /// Sets the sound player configuration
220
- /// - Parameters:
221
- /// - config: A dictionary containing configuration options:
222
- /// - `sampleRate`: The sample rate for audio playback (default is 16000.0).
223
- /// - `playbackMode`: The playback mode ("regular", "voiceProcessing", or "conversation").
224
- /// - `useDefault`: When true, resets to default configuration regardless of other parameters.
225
- /// - promise: A promise to resolve when configuration is updated or reject with an error.
226
- AsyncFunction("setSoundConfig") { (config: [String: Any], promise: Promise) in
227
- // Check if we should use default configuration
228
- let useDefault = config["useDefault"] as? Bool ?? false
229
-
230
- do {
231
- if !isAudioSessionInitialized {
232
- try ensureAudioSessionInitialized()
233
- }
234
-
235
- if useDefault {
236
- // Reset to default configuration — configure engine for regular mode
237
- Logger.debug("[ExpoPlayAudioStreamModule] Resetting sound configuration to default values")
238
- try self.sharedAudioEngine.configure(playbackMode: .regular)
239
- self.sharedAudioEngine.addDelegate(self.soundPlayer)
240
- try soundPlayer.resetConfigToDefault()
241
- } else {
242
- // Extract configuration values from the provided dictionary
243
- let sampleRate = config["sampleRate"] as? Double ?? 16000.0
244
- let playbackModeString = config["playbackMode"] as? String ?? "regular"
245
-
246
- // Convert string playback mode to enum
247
- let playbackMode: PlaybackMode
248
- switch playbackModeString {
249
- case "voiceProcessing":
250
- playbackMode = .voiceProcessing
251
- case "conversation":
252
- playbackMode = .conversation
253
- default:
254
- playbackMode = .regular
255
- }
256
-
257
- // Configure shared engine first (handles voice processing)
258
- try self.sharedAudioEngine.configure(playbackMode: playbackMode)
259
- self.sharedAudioEngine.addDelegate(self.soundPlayer)
260
-
261
- // Create a new SoundConfig object
262
- let soundConfig = SoundConfig(sampleRate: sampleRate, playbackMode: playbackMode)
263
-
264
- // Update the sound player configuration (attaches node to shared engine)
265
- Logger.debug("[ExpoPlayAudioStreamModule] Setting sound configuration - sampleRate: \(sampleRate), playbackMode: \(playbackModeString)")
266
- try soundPlayer.updateConfig(soundConfig)
267
- }
268
-
269
- promise.resolve(nil)
270
- } catch {
271
- promise.reject("ERROR_CONFIG_UPDATE", "Failed to set sound configuration: \(error.localizedDescription)")
272
- }
273
- }
274
-
275
157
  // ── Pipeline functions ────────────────────────────────────────────
276
158
 
277
159
  AsyncFunction("connectPipeline") { (options: [String: Any], promise: Promise) in
@@ -352,10 +234,8 @@ public class ExpoPlayAudioStreamModule: Module, MicrophoneDataDelegate, SoundPla
352
234
  options: [.defaultToSpeaker, .allowBluetooth, .allowBluetoothA2DP])
353
235
  if let settings = recordingSettings {
354
236
  try audioSession.setPreferredSampleRate(settings.sampleRate)
355
- // Set IO buffer duration short enough to support the desired emission interval.
356
- // Use the hardware sample rate (not the desired rate) since this is a hardware-level setting.
357
237
  let hwSampleRate = audioSession.sampleRate > 0 ? audioSession.sampleRate : 48000.0
358
- let preferredDuration = 512.0 / hwSampleRate // ~10.7ms at 48kHz
238
+ let preferredDuration = 512.0 / hwSampleRate
359
239
  try audioSession.setPreferredIOBufferDuration(preferredDuration)
360
240
  }
361
241
  try audioSession.setActive(true)
@@ -395,7 +275,6 @@ public class ExpoPlayAudioStreamModule: Module, MicrophoneDataDelegate, SoundPla
395
275
 
396
276
  func onMicrophoneData(_ microphoneData: Data, _ soundLevel: Float?, _ frequencyBands: FrequencyBands?) {
397
277
  let encodedData = microphoneData.base64EncodedString()
398
- // Construct the event payload similar to Android
399
278
  var eventBody: [String: Any] = [
400
279
  "fileUri": "",
401
280
  "lastEmittedSize": 0,
@@ -413,7 +292,6 @@ public class ExpoPlayAudioStreamModule: Module, MicrophoneDataDelegate, SoundPla
413
292
  "high": bands.high
414
293
  ]
415
294
  }
416
- // Emit the event to JavaScript
417
295
  sendEvent(audioDataEvent, eventBody)
418
296
  }
419
297
 
@@ -441,12 +319,4 @@ public class ExpoPlayAudioStreamModule: Module, MicrophoneDataDelegate, SoundPla
441
319
 
442
320
  sendEvent(deviceReconnectedEvent, ["reason": reasonString])
443
321
  }
444
-
445
- func onSoundChunkPlayed(_ isFinal: Bool) {
446
- sendEvent(soundIsPlayedEvent, ["isFinal": isFinal])
447
- }
448
-
449
- func onSoundStartedPlaying() {
450
- sendEvent(soundIsStartedEvent)
451
- }
452
322
  }
@@ -1,7 +1,7 @@
1
1
  import AVFoundation
2
2
 
3
3
  /// Delegate for receiving engine lifecycle events.
4
- /// Both SoundPlayer and AudioPipeline implement this
4
+ /// Both AudioPipeline consumers implement this
5
5
  /// to handle route changes and interruptions.
6
6
  protocol SharedAudioEngineDelegate: AnyObject {
7
7
  /// Called after the engine has been restarted due to a route change.
@@ -27,7 +27,7 @@ protocol SharedAudioEngineDelegate: AnyObject {
27
27
  func engineDidDie(reason: String)
28
28
  }
29
29
 
30
- /// Owns the single AVAudioEngine shared between SoundPlayer and AudioPipeline.
30
+ /// Owns the single AVAudioEngine shared between AudioPipeline consumers.
31
31
  ///
32
32
  /// Responsibilities:
33
33
  /// - Engine lifecycle (create, start, stop, teardown)
@@ -38,9 +38,15 @@ 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.
41
44
  class SharedAudioEngine {
42
45
  private static let TAG = "SharedAudioEngine"
43
46
 
47
+ /// Serial queue that protects all engine / node / state mutations.
48
+ private let queue = DispatchQueue(label: "expo.audio.SharedAudioEngine")
49
+
44
50
  // ── Engine state ─────────────────────────────────────────────────────
45
51
  private(set) var engine: AVAudioEngine?
46
52
  private(set) var playbackMode: PlaybackMode = .regular
@@ -51,13 +57,11 @@ class SharedAudioEngine {
51
57
  private let delegates = NSHashTable<AnyObject>.weakObjects()
52
58
 
53
59
  func addDelegate(_ d: SharedAudioEngineDelegate) {
54
- if !delegates.contains(d as AnyObject) {
55
- delegates.add(d as AnyObject)
56
- }
60
+ queue.sync { delegates.add(d as AnyObject) }
57
61
  }
58
62
 
59
63
  func removeDelegate(_ d: SharedAudioEngineDelegate) {
60
- delegates.remove(d as AnyObject)
64
+ queue.sync { delegates.remove(d as AnyObject) }
61
65
  }
62
66
 
63
67
  private func notifyDelegates(_ block: (SharedAudioEngineDelegate) -> Void) {
@@ -86,6 +90,10 @@ class SharedAudioEngine {
86
90
  ///
87
91
  /// - Parameter playbackMode: Determines whether voice processing is enabled.
88
92
  func configure(playbackMode: PlaybackMode) throws {
93
+ try queue.sync { try _configure(playbackMode: playbackMode) }
94
+ }
95
+
96
+ private func _configure(playbackMode: PlaybackMode) throws {
89
97
  if isConfigured && self.playbackMode == playbackMode && engine?.isRunning == true {
90
98
  Logger.debug("[\(SharedAudioEngine.TAG)] Already configured for \(playbackMode) and engine running, skipping")
91
99
  return
@@ -97,7 +105,7 @@ class SharedAudioEngine {
97
105
 
98
106
  // Tear down existing engine (keeps attachedNodes info for re-attach)
99
107
  let previousNodes = attachedNodes
100
- teardown()
108
+ _teardown()
101
109
 
102
110
  Logger.debug("[\(SharedAudioEngine.TAG)] Configuring engine — playbackMode=\(playbackMode)")
103
111
 
@@ -111,10 +119,12 @@ class SharedAudioEngine {
111
119
  Logger.debug("[\(SharedAudioEngine.TAG)] Voice processing enabled")
112
120
  }
113
121
 
114
- // Do NOT explicitly connect mainMixerNode outputNode.
115
- // The engine auto-negotiates the hardware format for that hop,
116
- // avoiding IsFormatSampleRateAndChannelCountValid crashes when
117
- // the consumer's format doesn't match the hardware sample rate.
122
+ // Force the output node (and implicitly the graph) to be created
123
+ // before starting. inputNode/outputNode are lazy if neither is
124
+ // accessed, the graph has zero nodes and Initialize crashes with
125
+ // "inputNode != nullptr || outputNode != nullptr".
126
+ // The VP path above already accesses both; this covers regular mode.
127
+ _ = engine.mainMixerNode
118
128
 
119
129
  try engine.start()
120
130
 
@@ -132,7 +142,7 @@ class SharedAudioEngine {
132
142
 
133
143
  // Re-attach any nodes that were connected before reconfiguration
134
144
  for info in previousNodes {
135
- attachNode(info.node, format: info.format)
145
+ _attachNode(info.node, format: info.format)
136
146
  info.node.play()
137
147
  }
138
148
 
@@ -152,6 +162,10 @@ class SharedAudioEngine {
152
162
  /// Connects `node → mainMixerNode` with the given format.
153
163
  /// The mixer handles sample-rate conversion to hardware output.
154
164
  func attachNode(_ node: AVAudioPlayerNode, format: AVAudioFormat) {
165
+ queue.sync { _attachNode(node, format: format) }
166
+ }
167
+
168
+ private func _attachNode(_ node: AVAudioPlayerNode, format: AVAudioFormat) {
155
169
  guard let engine = engine else {
156
170
  Logger.debug("[\(SharedAudioEngine.TAG)] attachNode called but engine is nil")
157
171
  return
@@ -166,21 +180,25 @@ class SharedAudioEngine {
166
180
 
167
181
  /// Detach a consumer's player node from the shared engine.
168
182
  func detachNode(_ node: AVAudioPlayerNode) {
169
- guard let engine = engine else { return }
183
+ queue.sync { _detachNode(node) }
184
+ }
170
185
 
171
- node.pause()
172
- node.stop()
186
+ private func _detachNode(_ node: AVAudioPlayerNode) {
187
+ guard let engine = engine else { return }
173
188
 
174
- // Only disconnect/detach if the node is still attached to this engine.
175
- // The node may already have been removed (e.g. engine died, concurrent
176
- // teardown, or duplicate disconnect call).
177
189
  if node.engine === engine {
178
190
  engine.disconnectNodeOutput(node)
179
191
  engine.detach(node)
180
192
  }
181
193
  attachedNodes.removeAll { $0.node === node }
182
194
 
183
- Logger.debug("[\(SharedAudioEngine.TAG)] Node detached")
195
+ // Stop the engine if no nodes remain — no reason to keep it running.
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
+ }
184
202
  }
185
203
 
186
204
  // ════════════════════════════════════════════════════════════════════
@@ -189,19 +207,31 @@ class SharedAudioEngine {
189
207
 
190
208
  /// Tear down the engine completely. Called on reconfigure or module destroy.
191
209
  func teardown() {
210
+ queue.sync { _teardown() }
211
+ }
212
+
213
+ private func _teardown() {
192
214
  // Remove observers
193
215
  NotificationCenter.default.removeObserver(
194
216
  self, name: AVAudioSession.routeChangeNotification, object: nil)
195
217
  NotificationCenter.default.removeObserver(
196
218
  self, name: AVAudioSession.interruptionNotification, object: nil)
197
219
 
198
- // Detach all tracked nodes
220
+ // Disable voice processing BEFORE stopping so the system begins
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()
199
233
  if let engine = engine {
200
234
  for info in attachedNodes {
201
- info.node.pause()
202
- info.node.stop()
203
- // Guard against nodes already removed from engine (e.g. engine
204
- // died or node was detached by a concurrent disconnect call).
205
235
  if info.node.engine === engine {
206
236
  engine.disconnectNodeOutput(info.node)
207
237
  engine.detach(info.node)
@@ -210,15 +240,6 @@ class SharedAudioEngine {
210
240
  }
211
241
  attachedNodes.removeAll()
212
242
 
213
- // Disable voice processing before stopping
214
- if playbackMode == .conversation || playbackMode == .voiceProcessing {
215
- if let engine = engine {
216
- try? engine.inputNode.setVoiceProcessingEnabled(false)
217
- try? engine.outputNode.setVoiceProcessingEnabled(false)
218
- }
219
- }
220
-
221
- engine?.stop()
222
243
  engine = nil
223
244
  isConfigured = false
224
245
 
@@ -239,6 +260,12 @@ class SharedAudioEngine {
239
260
  return
240
261
  }
241
262
 
263
+ queue.async { [weak self] in
264
+ self?._handleRouteChange(reason: reason)
265
+ }
266
+ }
267
+
268
+ private func _handleRouteChange(reason: AVAudioSession.RouteChangeReason) {
242
269
  let routeDescription = AVAudioSession.sharedInstance().currentRoute.outputs
243
270
  .map { "\($0.portName) (\($0.portType.rawValue))" }
244
271
  .joined(separator: ", ")
@@ -258,14 +285,7 @@ class SharedAudioEngine {
258
285
  // Suppress completion handlers from node.stop() re-entering the scheduling loop
259
286
  isRebuildingForRouteChange = true
260
287
 
261
- // 1. Stop all attached nodes (completion handlers fire but are gated)
262
- for info in attachedNodes {
263
- Logger.debug("[\(SharedAudioEngine.TAG)] Stopping node — isPlaying=\(info.node.isPlaying)")
264
- info.node.pause()
265
- info.node.stop()
266
- }
267
-
268
- // 2. Stop engine
288
+ // 1. Stop engine
269
289
  if engine.isRunning {
270
290
  engine.stop()
271
291
  Logger.debug("[\(SharedAudioEngine.TAG)] Engine stopped")
@@ -273,7 +293,7 @@ class SharedAudioEngine {
273
293
  Logger.debug("[\(SharedAudioEngine.TAG)] Engine was already stopped")
274
294
  }
275
295
 
276
- // 3. Detach all nodes
296
+ // 2. Detach all nodes
277
297
  for info in attachedNodes {
278
298
  if info.node.engine === engine {
279
299
  engine.disconnectNodeOutput(info.node)
@@ -282,7 +302,7 @@ class SharedAudioEngine {
282
302
  }
283
303
  Logger.debug("[\(SharedAudioEngine.TAG)] Nodes detached (\(attachedNodes.count))")
284
304
 
285
- // 4. Re-enable voice processing (resets after engine stop)
305
+ // 3. Re-enable voice processing (resets after engine stop)
286
306
  if playbackMode == .conversation || playbackMode == .voiceProcessing {
287
307
  do {
288
308
  try engine.inputNode.setVoiceProcessingEnabled(true)
@@ -292,14 +312,14 @@ class SharedAudioEngine {
292
312
  }
293
313
  }
294
314
 
295
- // 5. Re-attach all nodes
315
+ // 4. Re-attach all nodes
296
316
  for info in attachedNodes {
297
317
  engine.attach(info.node)
298
318
  engine.connect(info.node, to: engine.mainMixerNode, format: info.format)
299
319
  }
300
320
  Logger.debug("[\(SharedAudioEngine.TAG)] Nodes re-attached (\(attachedNodes.count))")
301
321
 
302
- // 6. Reactivate session and restart engine with retry.
322
+ // 5. Reactivate session and restart engine with retry.
303
323
  // Voice processing mode switches the underlying audio unit (RemoteIO ↔
304
324
  // VoiceProcessingIO). This swap completes asynchronously — if we call
305
325
  // engine.start() immediately, the engine appears to start (isRunning=true)
@@ -310,7 +330,7 @@ class SharedAudioEngine {
310
330
  ? [0.15, 0.3, 0.6] // 150ms, 300ms, 600ms pre-start delay for VP mode (+100ms post-start verify)
311
331
  : [0.0, 0.1, 0.25] // immediate, then backoff for non-VP (+50ms post-start verify)
312
332
 
313
- self.attemptRestart(engine: engine, retryDelays: retryDelays, attempt: 0)
333
+ _attemptRestart(engine: engine, retryDelays: retryDelays, attempt: 0)
314
334
 
315
335
  case .categoryChange:
316
336
  Logger.debug("[\(SharedAudioEngine.TAG)] Audio session category changed")
@@ -323,12 +343,14 @@ class SharedAudioEngine {
323
343
  /// is truly running and nodes are playing before declaring success.
324
344
  /// On final failure, falls back to a full rebuild. If that also fails,
325
345
  /// tears down everything and notifies delegates via `engineDidDie`.
326
- private func attemptRestart(engine: AVAudioEngine, retryDelays: [TimeInterval], attempt: Int) {
346
+ ///
347
+ /// Must be called on `queue`.
348
+ private func _attemptRestart(engine: AVAudioEngine, retryDelays: [TimeInterval], attempt: Int) {
327
349
  guard attempt < retryDelays.count else {
328
350
  // Exhausted in-place retries — try a full rebuild as last resort
329
351
  Logger.debug("[\(SharedAudioEngine.TAG)] All \(retryDelays.count) restart attempts failed — attempting full rebuild")
330
352
  isRebuildingForRouteChange = false
331
- rebuildEngine()
353
+ _rebuildEngine()
332
354
  return
333
355
  }
334
356
 
@@ -359,7 +381,7 @@ class SharedAudioEngine {
359
381
  }
360
382
  } catch {
361
383
  Logger.debug("[\(SharedAudioEngine.TAG)] engine.start() threw on attempt \(attempt + 1): \(error)")
362
- self.attemptRestart(engine: engine, retryDelays: retryDelays, attempt: attempt + 1)
384
+ self._attemptRestart(engine: engine, retryDelays: retryDelays, attempt: attempt + 1)
363
385
  return
364
386
  }
365
387
 
@@ -378,14 +400,14 @@ class SharedAudioEngine {
378
400
  // Failed immediately — no point waiting, retry now
379
401
  Logger.debug("[\(SharedAudioEngine.TAG)] Restart attempt \(attempt + 1) failed immediately")
380
402
  if engine.isRunning { engine.stop() }
381
- self.attemptRestart(engine: engine, retryDelays: retryDelays, attempt: attempt + 1)
403
+ self._attemptRestart(engine: engine, retryDelays: retryDelays, attempt: attempt + 1)
382
404
  return
383
405
  }
384
406
 
385
407
  // Voice processing can cause the engine to die asynchronously after
386
- // appearing to start. Wait 100ms then re-verify before declaring success.
408
+ // appearing to start. Wait then re-verify before declaring success.
387
409
  let verifyDelay: TimeInterval = (self.playbackMode == .conversation || self.playbackMode == .voiceProcessing) ? 0.1 : 0.05
388
- DispatchQueue.main.asyncAfter(deadline: .now() + verifyDelay) { [weak self] in
410
+ self.queue.asyncAfter(deadline: .now() + verifyDelay) { [weak self] in
389
411
  guard let self = self, let engine = self.engine else {
390
412
  self?.isRebuildingForRouteChange = false
391
413
  return
@@ -414,9 +436,9 @@ class SharedAudioEngine {
414
436
  if isVP {
415
437
  Logger.debug("[\(SharedAudioEngine.TAG)] VP mode — skipping remaining in-place retries, going to full rebuild")
416
438
  self.isRebuildingForRouteChange = false
417
- self.rebuildEngine()
439
+ self._rebuildEngine()
418
440
  } else {
419
- self.attemptRestart(engine: engine, retryDelays: retryDelays, attempt: attempt + 1)
441
+ self._attemptRestart(engine: engine, retryDelays: retryDelays, attempt: attempt + 1)
420
442
  }
421
443
  }
422
444
  }
@@ -424,7 +446,7 @@ class SharedAudioEngine {
424
446
 
425
447
  if delay > 0 {
426
448
  Logger.debug("[\(SharedAudioEngine.TAG)] Waiting \(Int(delay * 1000))ms before attempt \(attempt + 1)")
427
- DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
449
+ queue.asyncAfter(deadline: .now() + delay, execute: work)
428
450
  } else {
429
451
  work()
430
452
  }
@@ -437,15 +459,17 @@ class SharedAudioEngine {
437
459
  ///
438
460
  /// If this also fails, declare the engine dead, tear down all state, and
439
461
  /// notify delegates so they can report the failure to JS.
440
- private func rebuildEngine() {
462
+ ///
463
+ /// Must be called on `queue`.
464
+ private func _rebuildEngine() {
441
465
  Logger.debug("[\(SharedAudioEngine.TAG)] rebuildEngine — creating fresh engine (old nodes will NOT be reused)")
442
466
  let savedMode = playbackMode
443
467
 
444
468
  // Full teardown (clears attachedNodes, stops engine, nils it)
445
- teardown()
469
+ _teardown()
446
470
 
447
471
  do {
448
- try configure(playbackMode: savedMode)
472
+ try _configure(playbackMode: savedMode)
449
473
  // Do NOT re-attach old nodes. The VP IO swap can leave old
450
474
  // AVAudioPlayerNode instances in a broken state. Delegates must
451
475
  // create fresh nodes in their engineDidRebuild() callback.
@@ -454,7 +478,7 @@ class SharedAudioEngine {
454
478
  } catch {
455
479
  Logger.debug("[\(SharedAudioEngine.TAG)] rebuildEngine FAILED — engine is dead: \(error)")
456
480
  // Ensure everything is torn down so a future connect() starts clean
457
- teardown()
481
+ _teardown()
458
482
  let reason = "Route change recovery failed after all retries: \(error.localizedDescription)"
459
483
  notifyDelegates { $0.engineDidDie(reason: reason) }
460
484
  }
@@ -469,6 +493,12 @@ class SharedAudioEngine {
469
493
  let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
470
494
  let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return }
471
495
 
496
+ queue.async { [weak self] in
497
+ self?._handleInterruption(type: type)
498
+ }
499
+ }
500
+
501
+ private func _handleInterruption(type: AVAudioSession.InterruptionType) {
472
502
  if type == .began {
473
503
  Logger.debug("[\(SharedAudioEngine.TAG)] Audio session interruption began")
474
504
  notifyDelegates { $0.audioSessionInterruptionBegan() }
@@ -491,6 +521,6 @@ class SharedAudioEngine {
491
521
  }
492
522
 
493
523
  deinit {
494
- teardown()
524
+ _teardown()
495
525
  }
496
526
  }
@@ -2,44 +2,10 @@
2
2
  public enum PlaybackMode {
3
3
  /// Regular playback mode for standard audio playback
4
4
  case regular
5
-
5
+
6
6
  /// Conversation mode optimized for speech
7
7
  case conversation
8
-
8
+
9
9
  /// Voice processing mode with enhanced voice quality and automatic engine cleanup
10
10
  case voiceProcessing
11
11
  }
12
-
13
- /// Configuration for audio playback settings
14
- public struct SoundConfig {
15
- /// The sample rate for audio playback in Hz
16
- public var sampleRate: Double
17
-
18
- /// The playback mode (regular, conversation, or voiceProcessing)
19
- public var playbackMode: PlaybackMode
20
-
21
- /// Default configuration with standard settings
22
- public static let defaultConfig = SoundConfig(
23
- sampleRate: 44100.0,
24
- playbackMode: .regular
25
- )
26
-
27
- /// Creates a new sound configuration with the specified settings
28
- /// - Parameters:
29
- /// - sampleRate: The sample rate in Hz (default: 44100.0)
30
- /// - playbackMode: The playback mode (default: .regular)
31
- public init(
32
- sampleRate: Double = 44100.0,
33
- playbackMode: PlaybackMode = .regular
34
- ) {
35
- self.sampleRate = sampleRate
36
- self.playbackMode = playbackMode
37
- }
38
-
39
- /// Resets the configuration to default values
40
- /// - Returns: The updated configuration with default values
41
- public mutating func resetToDefault() -> SoundConfig {
42
- self = SoundConfig.defaultConfig
43
- return self
44
- }
45
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edkimmel/expo-audio-stream",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "description": "Expo Play Audio Stream module",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",