@edkimmel/expo-audio-stream 0.2.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.
Files changed (75) hide show
  1. package/.eslintrc.js +5 -0
  2. package/.yarnrc.yml +8 -0
  3. package/NATIVE_EVENTS.md +270 -0
  4. package/README.md +289 -0
  5. package/android/build.gradle +92 -0
  6. package/android/src/main/AndroidManifest.xml +4 -0
  7. package/android/src/main/java/expo/modules/audiostream/AudioDataEncoder.kt +178 -0
  8. package/android/src/main/java/expo/modules/audiostream/AudioEffectsManager.kt +107 -0
  9. package/android/src/main/java/expo/modules/audiostream/AudioPlaybackManager.kt +651 -0
  10. package/android/src/main/java/expo/modules/audiostream/AudioRecorderManager.kt +509 -0
  11. package/android/src/main/java/expo/modules/audiostream/Constants.kt +21 -0
  12. package/android/src/main/java/expo/modules/audiostream/EventSender.kt +7 -0
  13. package/android/src/main/java/expo/modules/audiostream/ExpoAudioStreamView.kt +7 -0
  14. package/android/src/main/java/expo/modules/audiostream/ExpoPlayAudioStreamModule.kt +280 -0
  15. package/android/src/main/java/expo/modules/audiostream/PermissionUtils.kt +16 -0
  16. package/android/src/main/java/expo/modules/audiostream/RecordingConfig.kt +60 -0
  17. package/android/src/main/java/expo/modules/audiostream/SoundConfig.kt +46 -0
  18. package/android/src/main/java/expo/modules/audiostream/pipeline/AudioPipeline.kt +685 -0
  19. package/android/src/main/java/expo/modules/audiostream/pipeline/JitterBuffer.kt +227 -0
  20. package/android/src/main/java/expo/modules/audiostream/pipeline/PipelineIntegration.kt +315 -0
  21. package/app.plugin.js +1 -0
  22. package/build/ExpoPlayAudioStreamModule.d.ts +3 -0
  23. package/build/ExpoPlayAudioStreamModule.d.ts.map +1 -0
  24. package/build/ExpoPlayAudioStreamModule.js +5 -0
  25. package/build/ExpoPlayAudioStreamModule.js.map +1 -0
  26. package/build/events.d.ts +36 -0
  27. package/build/events.d.ts.map +1 -0
  28. package/build/events.js +25 -0
  29. package/build/events.js.map +1 -0
  30. package/build/index.d.ts +125 -0
  31. package/build/index.d.ts.map +1 -0
  32. package/build/index.js +222 -0
  33. package/build/index.js.map +1 -0
  34. package/build/pipeline/index.d.ts +81 -0
  35. package/build/pipeline/index.d.ts.map +1 -0
  36. package/build/pipeline/index.js +140 -0
  37. package/build/pipeline/index.js.map +1 -0
  38. package/build/pipeline/types.d.ts +132 -0
  39. package/build/pipeline/types.d.ts.map +1 -0
  40. package/build/pipeline/types.js +5 -0
  41. package/build/pipeline/types.js.map +1 -0
  42. package/build/types.d.ts +221 -0
  43. package/build/types.d.ts.map +1 -0
  44. package/build/types.js +10 -0
  45. package/build/types.js.map +1 -0
  46. package/expo-module.config.json +9 -0
  47. package/ios/AudioPipeline.swift +562 -0
  48. package/ios/AudioUtils.swift +356 -0
  49. package/ios/ExpoPlayAudioStream.podspec +27 -0
  50. package/ios/ExpoPlayAudioStreamModule.swift +436 -0
  51. package/ios/ExpoPlayAudioStreamView.swift +7 -0
  52. package/ios/JitterBuffer.swift +208 -0
  53. package/ios/Logger.swift +7 -0
  54. package/ios/Microphone.swift +221 -0
  55. package/ios/MicrophoneDataDelegate.swift +4 -0
  56. package/ios/PipelineIntegration.swift +214 -0
  57. package/ios/RecordingResult.swift +10 -0
  58. package/ios/RecordingSettings.swift +11 -0
  59. package/ios/SharedAudioEngine.swift +484 -0
  60. package/ios/SoundConfig.swift +45 -0
  61. package/ios/SoundPlayer.swift +408 -0
  62. package/ios/SoundPlayerDelegate.swift +7 -0
  63. package/package.json +49 -0
  64. package/plugin/build/index.d.ts +5 -0
  65. package/plugin/build/index.js +28 -0
  66. package/plugin/src/index.ts +53 -0
  67. package/plugin/tsconfig.json +9 -0
  68. package/plugin/tsconfig.tsbuildinfo +1 -0
  69. package/src/ExpoPlayAudioStreamModule.ts +5 -0
  70. package/src/events.ts +66 -0
  71. package/src/index.ts +359 -0
  72. package/src/pipeline/index.ts +216 -0
  73. package/src/pipeline/types.ts +169 -0
  74. package/src/types.ts +270 -0
  75. package/tsconfig.json +9 -0
@@ -0,0 +1,484 @@
1
+ import AVFoundation
2
+
3
+ /// Delegate for receiving engine lifecycle events.
4
+ /// Both SoundPlayer and AudioPipeline implement this
5
+ /// to handle route changes and interruptions.
6
+ protocol SharedAudioEngineDelegate: AnyObject {
7
+ /// Called after the engine has been restarted due to a route change.
8
+ /// Consumer's node has already been re-attached and reconnected.
9
+ /// Consumer should restart playback (re-seed scheduling, etc.).
10
+ func engineDidRestartAfterRouteChange()
11
+
12
+ /// Called after the engine was fully rebuilt (fresh AVAudioEngine instance).
13
+ /// Old nodes are invalid — consumer MUST create and attach a fresh
14
+ /// AVAudioPlayerNode, then restart playback.
15
+ func engineDidRebuild()
16
+
17
+ /// Audio session was interrupted (e.g. phone call).
18
+ func audioSessionInterruptionBegan()
19
+
20
+ /// Audio session interruption ended. Engine has been restarted.
21
+ /// Consumer should restart playback.
22
+ func audioSessionInterruptionEnded()
23
+
24
+ /// Engine failed to restart after exhausting all retry attempts.
25
+ /// All state has been torn down. Consumer should report the failure
26
+ /// to JS and clean up its own state so a fresh connect can succeed.
27
+ func engineDidDie(reason: String)
28
+ }
29
+
30
+ /// Owns the single AVAudioEngine shared between SoundPlayer and AudioPipeline.
31
+ ///
32
+ /// Responsibilities:
33
+ /// - Engine lifecycle (create, start, stop, teardown)
34
+ /// - Voice processing enable/disable based on PlaybackMode
35
+ /// - Route change handling (rebuild node connections transparently)
36
+ /// - Interruption handling (restart engine, notify delegate)
37
+ ///
38
+ /// Consumers attach their own AVAudioPlayerNode via `attachNode(_:format:)`.
39
+ /// The mixer handles sample-rate conversion from each node's format to the
40
+ /// hardware output format automatically.
41
+ class SharedAudioEngine {
42
+ private static let TAG = "SharedAudioEngine"
43
+
44
+ // ── Engine state ─────────────────────────────────────────────────────
45
+ private(set) var engine: AVAudioEngine?
46
+ private(set) var playbackMode: PlaybackMode = .regular
47
+ private(set) var isConfigured = false
48
+
49
+ /// All registered consumers receive route-change and interruption callbacks.
50
+ /// Uses NSHashTable with weak references so delegates are auto-zeroed on dealloc.
51
+ private let delegates = NSHashTable<AnyObject>.weakObjects()
52
+
53
+ func addDelegate(_ d: SharedAudioEngineDelegate) {
54
+ if !delegates.contains(d as AnyObject) {
55
+ delegates.add(d as AnyObject)
56
+ }
57
+ }
58
+
59
+ func removeDelegate(_ d: SharedAudioEngineDelegate) {
60
+ delegates.remove(d as AnyObject)
61
+ }
62
+
63
+ private func notifyDelegates(_ block: (SharedAudioEngineDelegate) -> Void) {
64
+ for obj in delegates.allObjects {
65
+ if let d = obj as? SharedAudioEngineDelegate {
66
+ block(d)
67
+ }
68
+ }
69
+ }
70
+
71
+ // ── Attached nodes (for route-change rebuild) ────────────────────────
72
+ private struct AttachedNodeInfo {
73
+ let node: AVAudioPlayerNode
74
+ let format: AVAudioFormat
75
+ }
76
+ private var attachedNodes: [AttachedNodeInfo] = []
77
+
78
+ // ════════════════════════════════════════════════════════════════════
79
+ // Configure
80
+ // ════════════════════════════════════════════════════════════════════
81
+
82
+ /// Configure (or reconfigure) the shared engine.
83
+ ///
84
+ /// If already configured with the same playbackMode, this is a no-op.
85
+ /// Otherwise tears down the existing engine and creates a fresh one.
86
+ ///
87
+ /// - Parameter playbackMode: Determines whether voice processing is enabled.
88
+ func configure(playbackMode: PlaybackMode) throws {
89
+ if isConfigured && self.playbackMode == playbackMode && engine?.isRunning == true {
90
+ Logger.debug("[\(SharedAudioEngine.TAG)] Already configured for \(playbackMode) and engine running, skipping")
91
+ return
92
+ }
93
+
94
+ if isConfigured && engine?.isRunning != true {
95
+ Logger.debug("[\(SharedAudioEngine.TAG)] Engine marked configured but not running — forcing reconfiguration")
96
+ }
97
+
98
+ // Tear down existing engine (keeps attachedNodes info for re-attach)
99
+ let previousNodes = attachedNodes
100
+ teardown()
101
+
102
+ Logger.debug("[\(SharedAudioEngine.TAG)] Configuring engine — playbackMode=\(playbackMode)")
103
+
104
+ let engine = AVAudioEngine()
105
+
106
+ // Enable voice processing for conversation / voiceProcessing modes.
107
+ // Done before connecting nodes so the audio graph incorporates VP from the start.
108
+ if playbackMode == .conversation || playbackMode == .voiceProcessing {
109
+ try engine.inputNode.setVoiceProcessingEnabled(true)
110
+ try engine.outputNode.setVoiceProcessingEnabled(true)
111
+ Logger.debug("[\(SharedAudioEngine.TAG)] Voice processing enabled")
112
+ }
113
+
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.
118
+
119
+ try engine.start()
120
+
121
+ self.engine = engine
122
+ self.playbackMode = playbackMode
123
+ self.isConfigured = true
124
+
125
+ // Register for notifications
126
+ NotificationCenter.default.addObserver(
127
+ self, selector: #selector(handleRouteChange),
128
+ name: AVAudioSession.routeChangeNotification, object: nil)
129
+ NotificationCenter.default.addObserver(
130
+ self, selector: #selector(handleInterruption),
131
+ name: AVAudioSession.interruptionNotification, object: nil)
132
+
133
+ // Re-attach any nodes that were connected before reconfiguration
134
+ for info in previousNodes {
135
+ attachNode(info.node, format: info.format)
136
+ info.node.play()
137
+ }
138
+
139
+ Logger.debug("[\(SharedAudioEngine.TAG)] Engine started")
140
+ }
141
+
142
+ // ════════════════════════════════════════════════════════════════════
143
+ // Node management
144
+ // ════════════════════════════════════════════════════════════════════
145
+
146
+ /// Whether the engine is mid-rebuild (route change). Consumers should
147
+ /// bail out of completion handlers instead of re-scheduling.
148
+ var isRebuilding: Bool { return isRebuildingForRouteChange }
149
+
150
+ /// Attach a consumer's player node to the shared engine.
151
+ ///
152
+ /// Connects `node → mainMixerNode` with the given format.
153
+ /// The mixer handles sample-rate conversion to hardware output.
154
+ func attachNode(_ node: AVAudioPlayerNode, format: AVAudioFormat) {
155
+ guard let engine = engine else {
156
+ Logger.debug("[\(SharedAudioEngine.TAG)] attachNode called but engine is nil")
157
+ return
158
+ }
159
+
160
+ engine.attach(node)
161
+ engine.connect(node, to: engine.mainMixerNode, format: format)
162
+ attachedNodes.append(AttachedNodeInfo(node: node, format: format))
163
+
164
+ Logger.debug("[\(SharedAudioEngine.TAG)] Node attached — format=\(format)")
165
+ }
166
+
167
+ /// Detach a consumer's player node from the shared engine.
168
+ func detachNode(_ node: AVAudioPlayerNode) {
169
+ guard let engine = engine else { return }
170
+
171
+ node.pause()
172
+ node.stop()
173
+ engine.disconnectNodeOutput(node)
174
+ engine.detach(node)
175
+ attachedNodes.removeAll { $0.node === node }
176
+
177
+ Logger.debug("[\(SharedAudioEngine.TAG)] Node detached")
178
+ }
179
+
180
+ // ════════════════════════════════════════════════════════════════════
181
+ // Teardown
182
+ // ════════════════════════════════════════════════════════════════════
183
+
184
+ /// Tear down the engine completely. Called on reconfigure or module destroy.
185
+ func teardown() {
186
+ // Remove observers
187
+ NotificationCenter.default.removeObserver(
188
+ self, name: AVAudioSession.routeChangeNotification, object: nil)
189
+ NotificationCenter.default.removeObserver(
190
+ self, name: AVAudioSession.interruptionNotification, object: nil)
191
+
192
+ // Detach all tracked nodes
193
+ if let engine = engine {
194
+ for info in attachedNodes {
195
+ info.node.pause()
196
+ info.node.stop()
197
+ engine.disconnectNodeOutput(info.node)
198
+ engine.detach(info.node)
199
+ }
200
+ }
201
+ attachedNodes.removeAll()
202
+
203
+ // Disable voice processing before stopping
204
+ if playbackMode == .conversation || playbackMode == .voiceProcessing {
205
+ if let engine = engine {
206
+ try? engine.inputNode.setVoiceProcessingEnabled(false)
207
+ try? engine.outputNode.setVoiceProcessingEnabled(false)
208
+ }
209
+ }
210
+
211
+ engine?.stop()
212
+ engine = nil
213
+ isConfigured = false
214
+
215
+ Logger.debug("[\(SharedAudioEngine.TAG)] Teardown complete")
216
+ }
217
+
218
+ // ════════════════════════════════════════════════════════════════════
219
+ // Route change handling
220
+ // ════════════════════════════════════════════════════════════════════
221
+
222
+ /// Flag to suppress completion-handler re-entry during route-change rebuild.
223
+ private var isRebuildingForRouteChange = false
224
+
225
+ @objc private func handleRouteChange(notification: Notification) {
226
+ guard let info = notification.userInfo,
227
+ let reasonValue = info[AVAudioSessionRouteChangeReasonKey] as? UInt,
228
+ let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else {
229
+ return
230
+ }
231
+
232
+ let routeDescription = AVAudioSession.sharedInstance().currentRoute.outputs
233
+ .map { "\($0.portName) (\($0.portType.rawValue))" }
234
+ .joined(separator: ", ")
235
+ Logger.debug("[\(SharedAudioEngine.TAG)] Route changed: reason=\(reason.rawValue) → outputs=[\(routeDescription)]")
236
+
237
+ switch reason {
238
+ case .newDeviceAvailable, .oldDeviceUnavailable:
239
+ guard let engine = engine else {
240
+ Logger.debug("[\(SharedAudioEngine.TAG)] Route change ignored — engine is nil")
241
+ return
242
+ }
243
+
244
+ Logger.debug("[\(SharedAudioEngine.TAG)] Route change rebuild START — " +
245
+ "engineRunning=\(engine.isRunning) attachedNodes=\(attachedNodes.count) " +
246
+ "reason=\(reason.rawValue == AVAudioSession.RouteChangeReason.newDeviceAvailable.rawValue ? "newDeviceAvailable" : "oldDeviceUnavailable")")
247
+
248
+ // Suppress completion handlers from node.stop() re-entering the scheduling loop
249
+ isRebuildingForRouteChange = true
250
+
251
+ // 1. Stop all attached nodes (completion handlers fire but are gated)
252
+ for info in attachedNodes {
253
+ Logger.debug("[\(SharedAudioEngine.TAG)] Stopping node — isPlaying=\(info.node.isPlaying)")
254
+ info.node.pause()
255
+ info.node.stop()
256
+ }
257
+
258
+ // 2. Stop engine
259
+ if engine.isRunning {
260
+ engine.stop()
261
+ Logger.debug("[\(SharedAudioEngine.TAG)] Engine stopped")
262
+ } else {
263
+ Logger.debug("[\(SharedAudioEngine.TAG)] Engine was already stopped")
264
+ }
265
+
266
+ // 3. Detach all nodes
267
+ for info in attachedNodes {
268
+ engine.disconnectNodeOutput(info.node)
269
+ engine.detach(info.node)
270
+ }
271
+ Logger.debug("[\(SharedAudioEngine.TAG)] Nodes detached (\(attachedNodes.count))")
272
+
273
+ // 4. Re-enable voice processing (resets after engine stop)
274
+ if playbackMode == .conversation || playbackMode == .voiceProcessing {
275
+ do {
276
+ try engine.inputNode.setVoiceProcessingEnabled(true)
277
+ try engine.outputNode.setVoiceProcessingEnabled(true)
278
+ } catch {
279
+ Logger.debug("[\(SharedAudioEngine.TAG)] Voice processing re-enable failed: \(error)")
280
+ }
281
+ }
282
+
283
+ // 5. Re-attach all nodes
284
+ for info in attachedNodes {
285
+ engine.attach(info.node)
286
+ engine.connect(info.node, to: engine.mainMixerNode, format: info.format)
287
+ }
288
+ Logger.debug("[\(SharedAudioEngine.TAG)] Nodes re-attached (\(attachedNodes.count))")
289
+
290
+ // 6. Reactivate session and restart engine with retry.
291
+ // Voice processing mode switches the underlying audio unit (RemoteIO ↔
292
+ // VoiceProcessingIO). This swap completes asynchronously — if we call
293
+ // engine.start() immediately, the engine appears to start (isRunning=true)
294
+ // but silently dies moments later, leaving nodes in isPlaying=false.
295
+ // We retry with increasing delays to let the IO swap settle.
296
+ let useVoiceProcessing = (playbackMode == .conversation || playbackMode == .voiceProcessing)
297
+ let retryDelays: [TimeInterval] = useVoiceProcessing
298
+ ? [0.15, 0.3, 0.6] // 150ms, 300ms, 600ms pre-start delay for VP mode (+100ms post-start verify)
299
+ : [0.0, 0.1, 0.25] // immediate, then backoff for non-VP (+50ms post-start verify)
300
+
301
+ self.attemptRestart(engine: engine, retryDelays: retryDelays, attempt: 0)
302
+
303
+ case .categoryChange:
304
+ Logger.debug("[\(SharedAudioEngine.TAG)] Audio session category changed")
305
+ default:
306
+ break
307
+ }
308
+ }
309
+
310
+ /// Retry engine restart with backoff delays. Validates that the engine
311
+ /// is truly running and nodes are playing before declaring success.
312
+ /// On final failure, falls back to a full rebuild. If that also fails,
313
+ /// tears down everything and notifies delegates via `engineDidDie`.
314
+ private func attemptRestart(engine: AVAudioEngine, retryDelays: [TimeInterval], attempt: Int) {
315
+ guard attempt < retryDelays.count else {
316
+ // Exhausted in-place retries — try a full rebuild as last resort
317
+ Logger.debug("[\(SharedAudioEngine.TAG)] All \(retryDelays.count) restart attempts failed — attempting full rebuild")
318
+ isRebuildingForRouteChange = false
319
+ rebuildEngine()
320
+ return
321
+ }
322
+
323
+ let delay = retryDelays[attempt]
324
+ let work = { [weak self] in
325
+ guard let self = self, let engine = self.engine else {
326
+ self?.isRebuildingForRouteChange = false
327
+ return
328
+ }
329
+
330
+ Logger.debug("[\(SharedAudioEngine.TAG)] Restart attempt \(attempt + 1)/\(retryDelays.count)")
331
+
332
+ // Reactivate audio session
333
+ do {
334
+ try AVAudioSession.sharedInstance().setActive(true)
335
+ let newRoute = AVAudioSession.sharedInstance().currentRoute.outputs
336
+ .map { "\($0.portName) (\($0.portType.rawValue))" }
337
+ .joined(separator: ", ")
338
+ Logger.debug("[\(SharedAudioEngine.TAG)] Session reactivated — new route=[\(newRoute)]")
339
+ } catch {
340
+ Logger.debug("[\(SharedAudioEngine.TAG)] setActive(true) failed: \(error)")
341
+ }
342
+
343
+ // Try to start engine
344
+ do {
345
+ if !engine.isRunning {
346
+ try engine.start()
347
+ }
348
+ } catch {
349
+ Logger.debug("[\(SharedAudioEngine.TAG)] engine.start() threw on attempt \(attempt + 1): \(error)")
350
+ self.attemptRestart(engine: engine, retryDelays: retryDelays, attempt: attempt + 1)
351
+ return
352
+ }
353
+
354
+ // Start nodes
355
+ for info in self.attachedNodes {
356
+ info.node.play()
357
+ }
358
+
359
+ // Immediate sanity check
360
+ let immediateRunning = engine.isRunning
361
+ let immediateNodesPlaying = self.attachedNodes.allSatisfy { $0.node.isPlaying }
362
+ Logger.debug("[\(SharedAudioEngine.TAG)] Attempt \(attempt + 1) immediate check — " +
363
+ "isRunning=\(immediateRunning), allNodesPlaying=\(immediateNodesPlaying)")
364
+
365
+ if !immediateRunning || !immediateNodesPlaying {
366
+ // Failed immediately — no point waiting, retry now
367
+ Logger.debug("[\(SharedAudioEngine.TAG)] Restart attempt \(attempt + 1) failed immediately")
368
+ if engine.isRunning { engine.stop() }
369
+ self.attemptRestart(engine: engine, retryDelays: retryDelays, attempt: attempt + 1)
370
+ return
371
+ }
372
+
373
+ // Voice processing can cause the engine to die asynchronously after
374
+ // appearing to start. Wait 100ms then re-verify before declaring success.
375
+ let verifyDelay: TimeInterval = (self.playbackMode == .conversation || self.playbackMode == .voiceProcessing) ? 0.1 : 0.05
376
+ DispatchQueue.main.asyncAfter(deadline: .now() + verifyDelay) { [weak self] in
377
+ guard let self = self, let engine = self.engine else {
378
+ self?.isRebuildingForRouteChange = false
379
+ return
380
+ }
381
+
382
+ let stillRunning = engine.isRunning
383
+ let stillPlaying = self.attachedNodes.allSatisfy { $0.node.isPlaying }
384
+
385
+ if stillRunning && stillPlaying {
386
+ // Truly stable — declare success
387
+ self.isRebuildingForRouteChange = false
388
+ Logger.debug("[\(SharedAudioEngine.TAG)] Restart VERIFIED on attempt \(attempt + 1) — " +
389
+ "isRunning=\(stillRunning), allNodesPlaying=\(stillPlaying), " +
390
+ "notifying \(self.delegates.count) delegate(s)")
391
+ self.notifyDelegates { $0.engineDidRestartAfterRouteChange() }
392
+ } else {
393
+ // Engine died after appearing to start
394
+ Logger.debug("[\(SharedAudioEngine.TAG)] Restart attempt \(attempt + 1) died after verification — " +
395
+ "isRunning=\(stillRunning), allNodesPlaying=\(stillPlaying)")
396
+ if engine.isRunning { engine.stop() }
397
+
398
+ // For VP mode, the IO swap corrupts the engine instance — further
399
+ // in-place retries on the same engine produce silent audio even when
400
+ // isRunning appears true. Skip straight to a full rebuild.
401
+ let isVP = (self.playbackMode == .conversation || self.playbackMode == .voiceProcessing)
402
+ if isVP {
403
+ Logger.debug("[\(SharedAudioEngine.TAG)] VP mode — skipping remaining in-place retries, going to full rebuild")
404
+ self.isRebuildingForRouteChange = false
405
+ self.rebuildEngine()
406
+ } else {
407
+ self.attemptRestart(engine: engine, retryDelays: retryDelays, attempt: attempt + 1)
408
+ }
409
+ }
410
+ }
411
+ }
412
+
413
+ if delay > 0 {
414
+ Logger.debug("[\(SharedAudioEngine.TAG)] Waiting \(Int(delay * 1000))ms before attempt \(attempt + 1)")
415
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
416
+ } else {
417
+ work()
418
+ }
419
+ }
420
+
421
+ /// Last-resort recovery: tear down and recreate the engine from scratch.
422
+ /// Old AVAudioPlayerNodes are NOT re-attached — they may carry stale state
423
+ /// from the dead engine. Delegates are notified via `engineDidRebuild()` so
424
+ /// they can create fresh nodes and re-attach.
425
+ ///
426
+ /// If this also fails, declare the engine dead, tear down all state, and
427
+ /// notify delegates so they can report the failure to JS.
428
+ private func rebuildEngine() {
429
+ Logger.debug("[\(SharedAudioEngine.TAG)] rebuildEngine — creating fresh engine (old nodes will NOT be reused)")
430
+ let savedMode = playbackMode
431
+
432
+ // Full teardown (clears attachedNodes, stops engine, nils it)
433
+ teardown()
434
+
435
+ do {
436
+ try configure(playbackMode: savedMode)
437
+ // Do NOT re-attach old nodes. The VP IO swap can leave old
438
+ // AVAudioPlayerNode instances in a broken state. Delegates must
439
+ // create fresh nodes in their engineDidRebuild() callback.
440
+ Logger.debug("[\(SharedAudioEngine.TAG)] rebuildEngine succeeded — notifying \(delegates.count) delegate(s) to create fresh nodes")
441
+ notifyDelegates { $0.engineDidRebuild() }
442
+ } catch {
443
+ Logger.debug("[\(SharedAudioEngine.TAG)] rebuildEngine FAILED — engine is dead: \(error)")
444
+ // Ensure everything is torn down so a future connect() starts clean
445
+ teardown()
446
+ let reason = "Route change recovery failed after all retries: \(error.localizedDescription)"
447
+ notifyDelegates { $0.engineDidDie(reason: reason) }
448
+ }
449
+ }
450
+
451
+ // ════════════════════════════════════════════════════════════════════
452
+ // Interruption handling
453
+ // ════════════════════════════════════════════════════════════════════
454
+
455
+ @objc private func handleInterruption(notification: Notification) {
456
+ guard let info = notification.userInfo,
457
+ let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
458
+ let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return }
459
+
460
+ if type == .began {
461
+ Logger.debug("[\(SharedAudioEngine.TAG)] Audio session interruption began")
462
+ notifyDelegates { $0.audioSessionInterruptionBegan() }
463
+ } else if type == .ended {
464
+ Logger.debug("[\(SharedAudioEngine.TAG)] Audio session interruption ended")
465
+ // Reactivate session and restart engine
466
+ try? AVAudioSession.sharedInstance().setActive(true)
467
+ if let engine = engine, !engine.isRunning {
468
+ do {
469
+ try engine.start()
470
+ for info in attachedNodes {
471
+ info.node.play()
472
+ }
473
+ } catch {
474
+ Logger.debug("[\(SharedAudioEngine.TAG)] Failed to restart after interruption: \(error)")
475
+ }
476
+ }
477
+ notifyDelegates { $0.audioSessionInterruptionEnded() }
478
+ }
479
+ }
480
+
481
+ deinit {
482
+ teardown()
483
+ }
484
+ }
@@ -0,0 +1,45 @@
1
+ /// Defines different playback modes for audio processing
2
+ public enum PlaybackMode {
3
+ /// Regular playback mode for standard audio playback
4
+ case regular
5
+
6
+ /// Conversation mode optimized for speech
7
+ case conversation
8
+
9
+ /// Voice processing mode with enhanced voice quality and automatic engine cleanup
10
+ case voiceProcessing
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
+ }