@edkimmel/expo-audio-stream 0.3.2-beta.2 → 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.
- package/android/src/main/java/expo/modules/audiostream/Constants.kt +0 -2
- package/android/src/main/java/expo/modules/audiostream/ExpoPlayAudioStreamModule.kt +3 -61
- package/android/src/main/java/expo/modules/audiostream/GainNormalizer.kt +5 -1
- package/build/events.d.ts +0 -6
- package/build/events.d.ts.map +1 -1
- package/build/events.js +0 -5
- package/build/events.js.map +1 -1
- package/build/index.d.ts +4 -52
- package/build/index.d.ts.map +1 -1
- package/build/index.js +4 -86
- package/build/index.js.map +1 -1
- package/build/types.d.ts +0 -29
- package/build/types.d.ts.map +1 -1
- package/build/types.js.map +1 -1
- package/ios/AudioUtils.swift +0 -7
- package/ios/ExpoPlayAudioStreamModule.swift +8 -138
- package/ios/SharedAudioEngine.swift +90 -60
- package/ios/SoundConfig.swift +2 -36
- package/package.json +2 -3
- package/src/events.ts +0 -12
- package/src/index.ts +2 -102
- package/src/types.ts +0 -35
- package/android/src/main/java/expo/modules/audiostream/AudioPlaybackManager.kt +0 -651
- package/android/src/main/java/expo/modules/audiostream/SoundConfig.kt +0 -46
- package/ios/SoundPlayer.swift +0 -408
- package/ios/SoundPlayerDelegate.swift +0 -7
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
158
|
-
let numberOfChannels = options["channelConfig"] as? Int ?? 1
|
|
159
|
-
let bitDepth = options["audioFormat"] as? Int ?? 16
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
183
|
+
queue.sync { _detachNode(node) }
|
|
184
|
+
}
|
|
170
185
|
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
439
|
+
self._rebuildEngine()
|
|
418
440
|
} else {
|
|
419
|
-
self.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
469
|
+
_teardown()
|
|
446
470
|
|
|
447
471
|
do {
|
|
448
|
-
try
|
|
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
|
-
|
|
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
|
-
|
|
524
|
+
_teardown()
|
|
495
525
|
}
|
|
496
526
|
}
|
package/ios/SoundConfig.swift
CHANGED
|
@@ -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
|
+
"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",
|
|
@@ -45,6 +45,5 @@
|
|
|
45
45
|
"publishConfig": {
|
|
46
46
|
"access": "public",
|
|
47
47
|
"registry": "https://registry.npmjs.org/"
|
|
48
|
-
}
|
|
49
|
-
"stableVersion": "0.3.1"
|
|
48
|
+
}
|
|
50
49
|
}
|