@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.
- package/.eslintrc.js +5 -0
- package/.yarnrc.yml +8 -0
- package/NATIVE_EVENTS.md +270 -0
- package/README.md +289 -0
- package/android/build.gradle +92 -0
- package/android/src/main/AndroidManifest.xml +4 -0
- package/android/src/main/java/expo/modules/audiostream/AudioDataEncoder.kt +178 -0
- package/android/src/main/java/expo/modules/audiostream/AudioEffectsManager.kt +107 -0
- package/android/src/main/java/expo/modules/audiostream/AudioPlaybackManager.kt +651 -0
- package/android/src/main/java/expo/modules/audiostream/AudioRecorderManager.kt +509 -0
- package/android/src/main/java/expo/modules/audiostream/Constants.kt +21 -0
- package/android/src/main/java/expo/modules/audiostream/EventSender.kt +7 -0
- package/android/src/main/java/expo/modules/audiostream/ExpoAudioStreamView.kt +7 -0
- package/android/src/main/java/expo/modules/audiostream/ExpoPlayAudioStreamModule.kt +280 -0
- package/android/src/main/java/expo/modules/audiostream/PermissionUtils.kt +16 -0
- package/android/src/main/java/expo/modules/audiostream/RecordingConfig.kt +60 -0
- package/android/src/main/java/expo/modules/audiostream/SoundConfig.kt +46 -0
- package/android/src/main/java/expo/modules/audiostream/pipeline/AudioPipeline.kt +685 -0
- package/android/src/main/java/expo/modules/audiostream/pipeline/JitterBuffer.kt +227 -0
- package/android/src/main/java/expo/modules/audiostream/pipeline/PipelineIntegration.kt +315 -0
- package/app.plugin.js +1 -0
- package/build/ExpoPlayAudioStreamModule.d.ts +3 -0
- package/build/ExpoPlayAudioStreamModule.d.ts.map +1 -0
- package/build/ExpoPlayAudioStreamModule.js +5 -0
- package/build/ExpoPlayAudioStreamModule.js.map +1 -0
- package/build/events.d.ts +36 -0
- package/build/events.d.ts.map +1 -0
- package/build/events.js +25 -0
- package/build/events.js.map +1 -0
- package/build/index.d.ts +125 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +222 -0
- package/build/index.js.map +1 -0
- package/build/pipeline/index.d.ts +81 -0
- package/build/pipeline/index.d.ts.map +1 -0
- package/build/pipeline/index.js +140 -0
- package/build/pipeline/index.js.map +1 -0
- package/build/pipeline/types.d.ts +132 -0
- package/build/pipeline/types.d.ts.map +1 -0
- package/build/pipeline/types.js +5 -0
- package/build/pipeline/types.js.map +1 -0
- package/build/types.d.ts +221 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +10 -0
- package/build/types.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/AudioPipeline.swift +562 -0
- package/ios/AudioUtils.swift +356 -0
- package/ios/ExpoPlayAudioStream.podspec +27 -0
- package/ios/ExpoPlayAudioStreamModule.swift +436 -0
- package/ios/ExpoPlayAudioStreamView.swift +7 -0
- package/ios/JitterBuffer.swift +208 -0
- package/ios/Logger.swift +7 -0
- package/ios/Microphone.swift +221 -0
- package/ios/MicrophoneDataDelegate.swift +4 -0
- package/ios/PipelineIntegration.swift +214 -0
- package/ios/RecordingResult.swift +10 -0
- package/ios/RecordingSettings.swift +11 -0
- package/ios/SharedAudioEngine.swift +484 -0
- package/ios/SoundConfig.swift +45 -0
- package/ios/SoundPlayer.swift +408 -0
- package/ios/SoundPlayerDelegate.swift +7 -0
- package/package.json +49 -0
- package/plugin/build/index.d.ts +5 -0
- package/plugin/build/index.js +28 -0
- package/plugin/src/index.ts +53 -0
- package/plugin/tsconfig.json +9 -0
- package/plugin/tsconfig.tsbuildinfo +1 -0
- package/src/ExpoPlayAudioStreamModule.ts +5 -0
- package/src/events.ts +66 -0
- package/src/index.ts +359 -0
- package/src/pipeline/index.ts +216 -0
- package/src/pipeline/types.ts +169 -0
- package/src/types.ts +270 -0
- 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
|
+
}
|