@edkimmel/expo-audio-stream 0.6.1 → 0.6.3
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/AudioRecorderManager.kt +28 -4
- package/android/src/main/java/expo/modules/audiostream/ExpoPlayAudioStreamModule.kt +4 -1
- package/build/index.d.ts +5 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +7 -0
- package/build/index.js.map +1 -1
- package/ios/AudioPipeline.swift +148 -42
- package/ios/ExpoPlayAudioStreamModule.swift +4 -0
- package/ios/Microphone.swift +63 -16
- package/ios/SharedAudioEngine.swift +76 -30
- package/package.json +1 -1
- package/src/index.ts +8 -0
|
@@ -36,6 +36,7 @@ class AudioRecorderManager(
|
|
|
36
36
|
|
|
37
37
|
// Flag to control whether actual audio data or silence is sent
|
|
38
38
|
private var isSilent = false
|
|
39
|
+
@Volatile private var micGain: Float = 1.0f
|
|
39
40
|
private var frequencyBandAnalyzer: FrequencyBandAnalyzer? = null
|
|
40
41
|
private val gainNormalizer = GainNormalizer()
|
|
41
42
|
|
|
@@ -154,9 +155,10 @@ class AudioRecorderManager(
|
|
|
154
155
|
// Generate a unique ID for this recording stream
|
|
155
156
|
streamUuid = java.util.UUID.randomUUID().toString()
|
|
156
157
|
|
|
157
|
-
|
|
158
|
-
//
|
|
158
|
+
// AEC and other effects must be attached before startRecording() so the
|
|
159
|
+
// hardware echo canceller processes audio from the very first captured frame.
|
|
159
160
|
audioRecord?.let { audioEffectsManager.setupAudioEffects(it) }
|
|
161
|
+
audioRecord?.startRecording()
|
|
160
162
|
|
|
161
163
|
isPaused.set(false)
|
|
162
164
|
isRecording.set(true)
|
|
@@ -385,8 +387,11 @@ class AudioRecorderManager(
|
|
|
385
387
|
}
|
|
386
388
|
|
|
387
389
|
private fun emitAudioData(audioData: ByteArray, length: Int) {
|
|
388
|
-
|
|
389
|
-
|
|
390
|
+
val dataToEncode = when {
|
|
391
|
+
isSilent -> ByteArray(length)
|
|
392
|
+
micGain != 1.0f -> applyMicGain(audioData, length, micGain)
|
|
393
|
+
else -> audioData
|
|
394
|
+
}
|
|
390
395
|
|
|
391
396
|
val encodedBuffer = audioDataEncoder.encodeToBase64(dataToEncode)
|
|
392
397
|
|
|
@@ -471,6 +476,25 @@ class AudioRecorderManager(
|
|
|
471
476
|
/**
|
|
472
477
|
* Toggles between sending actual audio data and silence
|
|
473
478
|
*/
|
|
479
|
+
fun setMicrophoneGain(gain: Float) {
|
|
480
|
+
micGain = gain.coerceIn(0.0f, 1.0f)
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
private fun applyMicGain(data: ByteArray, length: Int, gain: Float): ByteArray {
|
|
484
|
+
val result = ByteArray(length)
|
|
485
|
+
var i = 0
|
|
486
|
+
while (i + 1 < length) {
|
|
487
|
+
val lo = data[i].toInt() and 0xFF
|
|
488
|
+
val hi = data[i + 1].toInt() // sign-extends
|
|
489
|
+
val sample = (hi shl 8) or lo // little-endian Int16
|
|
490
|
+
val scaled = (sample * gain).toInt().coerceIn(-32768, 32767)
|
|
491
|
+
result[i] = (scaled and 0xFF).toByte()
|
|
492
|
+
result[i + 1] = (scaled shr 8).toByte()
|
|
493
|
+
i += 2
|
|
494
|
+
}
|
|
495
|
+
return result
|
|
496
|
+
}
|
|
497
|
+
|
|
474
498
|
fun toggleSilence(isSilent: Boolean) {
|
|
475
499
|
this.isSilent = isSilent
|
|
476
500
|
Log.d(Constants.TAG, "Silence mode toggled: $isSilent")
|
|
@@ -168,10 +168,13 @@ class ExpoPlayAudioStreamModule : Module(), EventSender {
|
|
|
168
168
|
}
|
|
169
169
|
|
|
170
170
|
Function("toggleSilence") { isSilent: Boolean ->
|
|
171
|
-
// Just toggle silence without returning any value
|
|
172
171
|
audioRecorderManager.toggleSilence(isSilent)
|
|
173
172
|
}
|
|
174
173
|
|
|
174
|
+
Function("setMicrophoneGain") { gain: Double ->
|
|
175
|
+
audioRecorderManager.setMicrophoneGain(gain.toFloat())
|
|
176
|
+
}
|
|
177
|
+
|
|
175
178
|
// ── Native Audio Pipeline V3 ────────────────────────────────────
|
|
176
179
|
|
|
177
180
|
AsyncFunction("connectPipeline") { options: Map<String, Any?>, promise: Promise ->
|
package/build/index.d.ts
CHANGED
|
@@ -50,6 +50,11 @@ export declare class ExpoPlayAudioStream {
|
|
|
50
50
|
* @throws {Error} If the microphone fails to toggle silence.
|
|
51
51
|
*/
|
|
52
52
|
static toggleSilence(isSilent: boolean): void;
|
|
53
|
+
/**
|
|
54
|
+
* Sets the microphone capture gain (0.0 = silent, 1.0 = full).
|
|
55
|
+
* Takes effect immediately on the next captured audio chunk.
|
|
56
|
+
*/
|
|
57
|
+
static setMicrophoneGain(gain: number): void;
|
|
53
58
|
/**
|
|
54
59
|
* Requests microphone permission from the user.
|
|
55
60
|
* @returns {Promise<{granted: boolean, canAskAgain?: boolean, status?: string}>} A promise that resolves to the permission result.
|
package/build/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/build/index.js
CHANGED
|
@@ -124,6 +124,13 @@ export class ExpoPlayAudioStream {
|
|
|
124
124
|
static toggleSilence(isSilent) {
|
|
125
125
|
ExpoPlayAudioStreamModule.toggleSilence(isSilent);
|
|
126
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* Sets the microphone capture gain (0.0 = silent, 1.0 = full).
|
|
129
|
+
* Takes effect immediately on the next captured audio chunk.
|
|
130
|
+
*/
|
|
131
|
+
static setMicrophoneGain(gain) {
|
|
132
|
+
ExpoPlayAudioStreamModule.setMicrophoneGain(Math.max(0, Math.min(1, gain)));
|
|
133
|
+
}
|
|
127
134
|
/**
|
|
128
135
|
* Requests microphone permission from the user.
|
|
129
136
|
* @returns {Promise<{granted: boolean, canAskAgain?: boolean, status?: string}>} A promise that resolves to the permission result.
|
package/build/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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","sourcesContent":["import type { EventSubscription } from \"expo-modules-core\";\nimport ExpoPlayAudioStreamModule from \"./ExpoPlayAudioStreamModule\";\n\n// Type alias for backwards compatibility\ntype Subscription = EventSubscription;\nimport {\n AudioDataEvent,\n AudioRecording,\n RecordingConfig,\n StartRecordingResult,\n PlaybackMode,\n Encoding,\n EncodingTypes,\n FrequencyBands,\n PlaybackModes,\n // Audio jitter buffer types\n IAudioBufferConfig,\n IAudioPlayPayload,\n IAudioFrame,\n BufferHealthState,\n IBufferHealthMetrics,\n IAudioBufferManager,\n IFrameProcessor,\n IQualityMonitor,\n BufferedStreamConfig,\n SmartBufferConfig,\n SmartBufferMode,\n NetworkConditions,\n} from \"./types\";\n\nimport {\n addAudioEventListener,\n addMicrophoneErrorListener,\n AudioEventPayload,\n AudioEvents,\n subscribeToEvent,\n DeviceReconnectedReason,\n DeviceReconnectedEventPayload,\n} from \"./events\";\n\nexport class ExpoPlayAudioStream {\n /**\n * Destroys the audio stream module, cleaning up all resources.\n * This should be called when the module is no longer needed.\n * It will reset all internal state and release audio resources.\n */\n static async destroy() {\n await ExpoPlayAudioStreamModule.destroy();\n }\n\n /**\n * Starts microphone streaming.\n * @param {RecordingConfig} recordingConfig - The recording configuration.\n * @returns {Promise<{recordingResult: StartRecordingResult, subscription: Subscription}>} A promise that resolves to an object containing the recording result and a subscription to audio events.\n * @throws {Error} If the recording fails to start.\n */\n static async startMicrophone(recordingConfig: RecordingConfig): Promise<{\n recordingResult: StartRecordingResult;\n subscription?: Subscription;\n }> {\n let subscription: Subscription | undefined;\n try {\n const { onAudioStream, onError, ...options } = recordingConfig;\n\n const subscriptions: EventSubscription[] = [];\n\n if (onAudioStream && typeof onAudioStream === \"function\") {\n subscriptions.push(\n addAudioEventListener(async (event: AudioEventPayload) => {\n const { fileUri, deltaSize, totalSize, position, encoded, soundLevel, frequencyBands, error } = event;\n // Only suppress AudioData error events when onError is wired up — the\n // MicrophoneError subscription will handle them. Without onError (old\n // integration or old native binary), fall through so the missing-encoded\n // path below preserves the pre-v0.6.0 behavior.\n if (error && onError) return;\n if (!encoded) {\n console.error(`[ExpoPlayAudioStream] Encoded audio data is missing`);\n throw new Error(\"Encoded audio data is missing\");\n }\n onAudioStream({\n data: encoded,\n position,\n fileUri,\n eventDataSize: deltaSize,\n totalSize,\n soundLevel,\n frequencyBands,\n });\n })\n );\n }\n\n if (onError && typeof onError === \"function\") {\n subscriptions.push(\n addMicrophoneErrorListener((event) => {\n onError(event);\n })\n );\n }\n\n if (subscriptions.length > 0) {\n subscription = { remove: () => subscriptions.forEach((s) => s.remove()) };\n }\n\n const result = await ExpoPlayAudioStreamModule.startMicrophone(options);\n\n return { recordingResult: result, subscription };\n } catch (error) {\n console.error(error);\n subscription?.remove();\n throw new Error(`Failed to start recording: ${error}`);\n }\n }\n\n /**\n * Stops the current microphone streaming.\n * @returns {Promise<void>}\n * @throws {Error} If the microphone streaming fails to stop.\n */\n static async stopMicrophone(): Promise<AudioRecording | null> {\n try {\n return await ExpoPlayAudioStreamModule.stopMicrophone();\n } catch (error) {\n console.error(error);\n throw new Error(`Failed to stop mic stream: ${error}`);\n }\n }\n\n /**\n * Subscribes to audio events emitted during recording/streaming.\n * @param onMicrophoneStream - Callback function that will be called when audio data is received.\n * @returns {Subscription} A subscription object that can be used to unsubscribe from the events\n */\n static subscribeToAudioEvents(\n onMicrophoneStream: (event: AudioDataEvent) => Promise<void>\n ): Subscription {\n return addAudioEventListener(async (event: AudioEventPayload) => {\n const { fileUri, deltaSize, totalSize, position, encoded, soundLevel, frequencyBands } =\n event;\n if (!encoded) {\n console.error(`[ExpoPlayAudioStream] Encoded audio data is missing`);\n throw new Error(\"Encoded audio data is missing\");\n }\n onMicrophoneStream?.({\n data: encoded,\n position,\n fileUri,\n eventDataSize: deltaSize,\n totalSize,\n soundLevel,\n frequencyBands,\n });\n });\n }\n\n /**\n * Subscribes to events emitted by the audio stream module, for advanced use cases.\n * @param eventName - The name of the event to subscribe to.\n * @param onEvent - Callback function that will be called when the event is emitted.\n * @returns {Subscription} A subscription object that can be used to unsubscribe from the events.\n */\n static subscribe<T extends unknown>(\n eventName: string,\n onEvent: (event: T | undefined) => Promise<void>\n ): Subscription {\n return subscribeToEvent(eventName, onEvent);\n }\n\n /**\n * Prompts the user to select the microphone mode.\n * @returns {Promise<void>}\n * @throws {Error} If the microphone mode fails to prompt.\n */\n static promptMicrophoneModes() {\n ExpoPlayAudioStreamModule.promptMicrophoneModes();\n }\n\n /**\n * Toggles the silence state of the microphone.\n * @returns {Promise<void>}\n * @throws {Error} If the microphone fails to toggle silence.\n */\n static toggleSilence(isSilent: boolean) {\n ExpoPlayAudioStreamModule.toggleSilence(isSilent);\n }\n\n /**\n * Requests microphone permission from the user.\n * @returns {Promise<{granted: boolean, canAskAgain?: boolean, status?: string}>} A promise that resolves to the permission result.\n */\n static async requestPermissionsAsync(): Promise<{\n granted: boolean;\n canAskAgain?: boolean;\n status?: string;\n }> {\n try {\n return await ExpoPlayAudioStreamModule.requestPermissionsAsync();\n } catch (error) {\n console.error(error);\n throw new Error(`Failed to request permissions: ${error}`);\n }\n }\n\n /**\n * Gets the current microphone permission status.\n * @returns {Promise<{granted: boolean, canAskAgain?: boolean, status?: string}>} A promise that resolves to the permission status.\n */\n static async getPermissionsAsync(): Promise<{\n granted: boolean;\n canAskAgain?: boolean;\n status?: string;\n }> {\n try {\n return await ExpoPlayAudioStreamModule.getPermissionsAsync();\n } catch (error) {\n console.error(error);\n throw new Error(`Failed to get permissions: ${error}`);\n }\n }\n}\n\nexport {\n AudioDataEvent,\n DeviceReconnectedReason,\n DeviceReconnectedEventPayload,\n AudioRecording,\n RecordingConfig,\n StartRecordingResult,\n AudioEvents,\n PlaybackMode,\n Encoding,\n EncodingTypes,\n FrequencyBands,\n PlaybackModes,\n // Audio jitter buffer types\n IAudioBufferConfig,\n IAudioPlayPayload,\n IAudioFrame,\n BufferHealthState,\n IBufferHealthMetrics,\n IAudioBufferManager,\n IFrameProcessor,\n IQualityMonitor,\n BufferedStreamConfig,\n SmartBufferConfig,\n SmartBufferMode,\n NetworkConditions,\n};\n\n// Re-export Subscription type for backwards compatibility\nexport type { EventSubscription } from \"expo-modules-core\";\nexport type { Subscription } from \"./events\";\nexport { addMicrophoneErrorListener } from \"./events\";\nexport type { MicrophoneErrorEventPayload } from \"./events\";\n\n// Export native audio pipeline V3\nexport { Pipeline } from \"./pipeline\";\nexport type {\n ConnectPipelineOptions,\n ConnectPipelineResult,\n PushPipelineAudioOptions,\n InvalidatePipelineTurnOptions,\n PipelineState,\n PipelineEventMap,\n PipelineEventName,\n PipelineBufferTelemetry,\n PipelineTelemetry,\n PipelineStateChangedEvent,\n PipelinePlaybackStartedEvent,\n PipelineErrorEvent,\n PipelineZombieDetectedEvent,\n PipelineUnderrunEvent,\n PipelineDrainedEvent,\n PipelinePlaybackStoppedEvent,\n PipelineAudioFocusLostEvent,\n PipelineAudioFocusResumedEvent,\n} from \"./pipeline\";\n"]}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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","sourcesContent":["import type { EventSubscription } from \"expo-modules-core\";\nimport ExpoPlayAudioStreamModule from \"./ExpoPlayAudioStreamModule\";\n\n// Type alias for backwards compatibility\ntype Subscription = EventSubscription;\nimport {\n AudioDataEvent,\n AudioRecording,\n RecordingConfig,\n StartRecordingResult,\n PlaybackMode,\n Encoding,\n EncodingTypes,\n FrequencyBands,\n PlaybackModes,\n // Audio jitter buffer types\n IAudioBufferConfig,\n IAudioPlayPayload,\n IAudioFrame,\n BufferHealthState,\n IBufferHealthMetrics,\n IAudioBufferManager,\n IFrameProcessor,\n IQualityMonitor,\n BufferedStreamConfig,\n SmartBufferConfig,\n SmartBufferMode,\n NetworkConditions,\n} from \"./types\";\n\nimport {\n addAudioEventListener,\n addMicrophoneErrorListener,\n AudioEventPayload,\n AudioEvents,\n subscribeToEvent,\n DeviceReconnectedReason,\n DeviceReconnectedEventPayload,\n} from \"./events\";\n\nexport class ExpoPlayAudioStream {\n /**\n * Destroys the audio stream module, cleaning up all resources.\n * This should be called when the module is no longer needed.\n * It will reset all internal state and release audio resources.\n */\n static async destroy() {\n await ExpoPlayAudioStreamModule.destroy();\n }\n\n /**\n * Starts microphone streaming.\n * @param {RecordingConfig} recordingConfig - The recording configuration.\n * @returns {Promise<{recordingResult: StartRecordingResult, subscription: Subscription}>} A promise that resolves to an object containing the recording result and a subscription to audio events.\n * @throws {Error} If the recording fails to start.\n */\n static async startMicrophone(recordingConfig: RecordingConfig): Promise<{\n recordingResult: StartRecordingResult;\n subscription?: Subscription;\n }> {\n let subscription: Subscription | undefined;\n try {\n const { onAudioStream, onError, ...options } = recordingConfig;\n\n const subscriptions: EventSubscription[] = [];\n\n if (onAudioStream && typeof onAudioStream === \"function\") {\n subscriptions.push(\n addAudioEventListener(async (event: AudioEventPayload) => {\n const { fileUri, deltaSize, totalSize, position, encoded, soundLevel, frequencyBands, error } = event;\n // Only suppress AudioData error events when onError is wired up — the\n // MicrophoneError subscription will handle them. Without onError (old\n // integration or old native binary), fall through so the missing-encoded\n // path below preserves the pre-v0.6.0 behavior.\n if (error && onError) return;\n if (!encoded) {\n console.error(`[ExpoPlayAudioStream] Encoded audio data is missing`);\n throw new Error(\"Encoded audio data is missing\");\n }\n onAudioStream({\n data: encoded,\n position,\n fileUri,\n eventDataSize: deltaSize,\n totalSize,\n soundLevel,\n frequencyBands,\n });\n })\n );\n }\n\n if (onError && typeof onError === \"function\") {\n subscriptions.push(\n addMicrophoneErrorListener((event) => {\n onError(event);\n })\n );\n }\n\n if (subscriptions.length > 0) {\n subscription = { remove: () => subscriptions.forEach((s) => s.remove()) };\n }\n\n const result = await ExpoPlayAudioStreamModule.startMicrophone(options);\n\n return { recordingResult: result, subscription };\n } catch (error) {\n console.error(error);\n subscription?.remove();\n throw new Error(`Failed to start recording: ${error}`);\n }\n }\n\n /**\n * Stops the current microphone streaming.\n * @returns {Promise<void>}\n * @throws {Error} If the microphone streaming fails to stop.\n */\n static async stopMicrophone(): Promise<AudioRecording | null> {\n try {\n return await ExpoPlayAudioStreamModule.stopMicrophone();\n } catch (error) {\n console.error(error);\n throw new Error(`Failed to stop mic stream: ${error}`);\n }\n }\n\n /**\n * Subscribes to audio events emitted during recording/streaming.\n * @param onMicrophoneStream - Callback function that will be called when audio data is received.\n * @returns {Subscription} A subscription object that can be used to unsubscribe from the events\n */\n static subscribeToAudioEvents(\n onMicrophoneStream: (event: AudioDataEvent) => Promise<void>\n ): Subscription {\n return addAudioEventListener(async (event: AudioEventPayload) => {\n const { fileUri, deltaSize, totalSize, position, encoded, soundLevel, frequencyBands } =\n event;\n if (!encoded) {\n console.error(`[ExpoPlayAudioStream] Encoded audio data is missing`);\n throw new Error(\"Encoded audio data is missing\");\n }\n onMicrophoneStream?.({\n data: encoded,\n position,\n fileUri,\n eventDataSize: deltaSize,\n totalSize,\n soundLevel,\n frequencyBands,\n });\n });\n }\n\n /**\n * Subscribes to events emitted by the audio stream module, for advanced use cases.\n * @param eventName - The name of the event to subscribe to.\n * @param onEvent - Callback function that will be called when the event is emitted.\n * @returns {Subscription} A subscription object that can be used to unsubscribe from the events.\n */\n static subscribe<T extends unknown>(\n eventName: string,\n onEvent: (event: T | undefined) => Promise<void>\n ): Subscription {\n return subscribeToEvent(eventName, onEvent);\n }\n\n /**\n * Prompts the user to select the microphone mode.\n * @returns {Promise<void>}\n * @throws {Error} If the microphone mode fails to prompt.\n */\n static promptMicrophoneModes() {\n ExpoPlayAudioStreamModule.promptMicrophoneModes();\n }\n\n /**\n * Toggles the silence state of the microphone.\n * @returns {Promise<void>}\n * @throws {Error} If the microphone fails to toggle silence.\n */\n static toggleSilence(isSilent: boolean) {\n ExpoPlayAudioStreamModule.toggleSilence(isSilent);\n }\n\n /**\n * Sets the microphone capture gain (0.0 = silent, 1.0 = full).\n * Takes effect immediately on the next captured audio chunk.\n */\n static setMicrophoneGain(gain: number) {\n ExpoPlayAudioStreamModule.setMicrophoneGain(Math.max(0, Math.min(1, gain)));\n }\n\n /**\n * Requests microphone permission from the user.\n * @returns {Promise<{granted: boolean, canAskAgain?: boolean, status?: string}>} A promise that resolves to the permission result.\n */\n static async requestPermissionsAsync(): Promise<{\n granted: boolean;\n canAskAgain?: boolean;\n status?: string;\n }> {\n try {\n return await ExpoPlayAudioStreamModule.requestPermissionsAsync();\n } catch (error) {\n console.error(error);\n throw new Error(`Failed to request permissions: ${error}`);\n }\n }\n\n /**\n * Gets the current microphone permission status.\n * @returns {Promise<{granted: boolean, canAskAgain?: boolean, status?: string}>} A promise that resolves to the permission status.\n */\n static async getPermissionsAsync(): Promise<{\n granted: boolean;\n canAskAgain?: boolean;\n status?: string;\n }> {\n try {\n return await ExpoPlayAudioStreamModule.getPermissionsAsync();\n } catch (error) {\n console.error(error);\n throw new Error(`Failed to get permissions: ${error}`);\n }\n }\n}\n\nexport {\n AudioDataEvent,\n DeviceReconnectedReason,\n DeviceReconnectedEventPayload,\n AudioRecording,\n RecordingConfig,\n StartRecordingResult,\n AudioEvents,\n PlaybackMode,\n Encoding,\n EncodingTypes,\n FrequencyBands,\n PlaybackModes,\n // Audio jitter buffer types\n IAudioBufferConfig,\n IAudioPlayPayload,\n IAudioFrame,\n BufferHealthState,\n IBufferHealthMetrics,\n IAudioBufferManager,\n IFrameProcessor,\n IQualityMonitor,\n BufferedStreamConfig,\n SmartBufferConfig,\n SmartBufferMode,\n NetworkConditions,\n};\n\n// Re-export Subscription type for backwards compatibility\nexport type { EventSubscription } from \"expo-modules-core\";\nexport type { Subscription } from \"./events\";\nexport { addMicrophoneErrorListener } from \"./events\";\nexport type { MicrophoneErrorEventPayload } from \"./events\";\n\n// Export native audio pipeline V3\nexport { Pipeline } from \"./pipeline\";\nexport type {\n ConnectPipelineOptions,\n ConnectPipelineResult,\n PushPipelineAudioOptions,\n InvalidatePipelineTurnOptions,\n PipelineState,\n PipelineEventMap,\n PipelineEventName,\n PipelineBufferTelemetry,\n PipelineTelemetry,\n PipelineStateChangedEvent,\n PipelinePlaybackStartedEvent,\n PipelineErrorEvent,\n PipelineZombieDetectedEvent,\n PipelineUnderrunEvent,\n PipelineDrainedEvent,\n PipelinePlaybackStoppedEvent,\n PipelineAudioFocusLostEvent,\n PipelineAudioFocusResumedEvent,\n} from \"./pipeline\";\n"]}
|
package/ios/AudioPipeline.swift
CHANGED
|
@@ -69,9 +69,19 @@ class AudioPipeline: SharedAudioEngineDelegate {
|
|
|
69
69
|
private var outputFormat: AVAudioFormat?
|
|
70
70
|
private var jitterBuffer: JitterBuffer?
|
|
71
71
|
|
|
72
|
-
/// Number of interleaved Int16 samples per scheduled buffer
|
|
72
|
+
/// Number of interleaved Int16 samples read from the JitterBuffer per scheduled buffer
|
|
73
|
+
/// (at pipeline sample rate, e.g. 16 kHz).
|
|
73
74
|
let frameSizeSamples: Int
|
|
74
75
|
|
|
76
|
+
/// Hardware output sample rate — read at connect time from the engine's output node.
|
|
77
|
+
/// The player node connects at this rate so AVAudioEngine injects no hidden resampler,
|
|
78
|
+
/// keeping the AEC echo reference deterministically timed.
|
|
79
|
+
private var hardwareSampleRate: Double = 48000
|
|
80
|
+
|
|
81
|
+
/// Number of frames written to the hardware-rate PCM buffer per scheduled buffer.
|
|
82
|
+
/// Always >= frameSizeSamples; computed as hardwareSampleRate / 50 (20 ms chunks).
|
|
83
|
+
private var hwFramesPerBuffer: Int = 960
|
|
84
|
+
|
|
75
85
|
// ── Threading / state ───────────────────────────────────────────────
|
|
76
86
|
private var running = false
|
|
77
87
|
private let turnLock = NSLock()
|
|
@@ -85,6 +95,15 @@ class AudioPipeline: SharedAudioEngineDelegate {
|
|
|
85
95
|
/// This prevents duplicate chains and stale callbacks from re-entering after a rebuild.
|
|
86
96
|
private var scheduleGeneration: Int = 0
|
|
87
97
|
|
|
98
|
+
/// All `node.scheduleBuffer(...)` calls for this pipeline run on the shared
|
|
99
|
+
/// engine's serial `engineQueue` (the same queue that owns stop/detach/attach),
|
|
100
|
+
/// so `scheduleBuffer()` and teardown's `stop()` can never overlap — that
|
|
101
|
+
/// overlap is the AVAudioPlayerNode deadlock (AttachAndEngineLock ⇄
|
|
102
|
+
/// RealtimeMessenger). The buffer-completion callback re-arms via
|
|
103
|
+
/// `engineQueue.async` (returns immediately so `stop()`'s message flush isn't
|
|
104
|
+
/// blocked); teardown runs on the same queue via `sharedEngine.performSync`,
|
|
105
|
+
/// so it's serialized after any in-flight scheduling with no explicit barrier.
|
|
106
|
+
|
|
88
107
|
/// Pending PlaybackStopped dispatch — cancelled on new turn / disconnect.
|
|
89
108
|
/// Always mutate from the main queue to avoid races with the drain timer.
|
|
90
109
|
private var pendingPlaybackStoppedWork: DispatchWorkItem?
|
|
@@ -134,12 +153,26 @@ class AudioPipeline: SharedAudioEngineDelegate {
|
|
|
134
153
|
}
|
|
135
154
|
setState(.connecting)
|
|
136
155
|
|
|
156
|
+
guard let sharedEngine = sharedEngine else {
|
|
157
|
+
setState(.error)
|
|
158
|
+
throw NSError(domain: "AudioPipeline", code: -1,
|
|
159
|
+
userInfo: [NSLocalizedDescriptionKey: "SharedAudioEngine not set"])
|
|
160
|
+
}
|
|
161
|
+
|
|
137
162
|
do {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
163
|
+
// All graph setup runs on the engine's serial queue so attach / play /
|
|
164
|
+
// seed are serialized against route-change, scheduleBuffer and teardown.
|
|
165
|
+
try sharedEngine.performSync { try self.connectOnQueue(sharedEngine: sharedEngine) }
|
|
166
|
+
} catch {
|
|
167
|
+
Logger.debug("[\(AudioPipeline.TAG)] connect() failed: \(error)")
|
|
168
|
+
setState(.error)
|
|
169
|
+
disconnect()
|
|
170
|
+
throw error
|
|
171
|
+
}
|
|
172
|
+
}
|
|
142
173
|
|
|
174
|
+
/// Graph setup for `connect()`. Runs entirely on `sharedEngine.engineQueue`.
|
|
175
|
+
private func connectOnQueue(sharedEngine: SharedAudioEngine) throws {
|
|
143
176
|
// ── 1. JitterBuffer ─────────────────────────────────────────
|
|
144
177
|
jitterBuffer = JitterBuffer(
|
|
145
178
|
sampleRate: sampleRate,
|
|
@@ -155,10 +188,21 @@ class AudioPipeline: SharedAudioEngineDelegate {
|
|
|
155
188
|
// (ensureAudioSessionInitialized). Just ensure it's active.
|
|
156
189
|
try AVAudioSession.sharedInstance().setActive(true)
|
|
157
190
|
|
|
158
|
-
// ── 4.
|
|
191
|
+
// ── 4. Read hardware rate and create hardware-rate format ───
|
|
192
|
+
// Connecting at the native hardware rate prevents AVAudioEngine from
|
|
193
|
+
// injecting a hidden resampler between the player node and the hardware
|
|
194
|
+
// output. A hidden resampler shifts buffer timestamps and desynchronises
|
|
195
|
+
// the AEC echo reference, causing the adaptive filter to converge slowly
|
|
196
|
+
// or not at all. The scheduling loop handles the 16kHz→48kHz conversion
|
|
197
|
+
// via linear interpolation before handing buffers to the player node.
|
|
198
|
+
let rawHwRate = sharedEngine.engine?.outputNode.outputFormat(forBus: 0).sampleRate ?? 0
|
|
199
|
+
let resolvedHwRate = rawHwRate > 0 ? rawHwRate : 48000
|
|
200
|
+
hardwareSampleRate = resolvedHwRate
|
|
201
|
+
hwFramesPerBuffer = max(1, Int(resolvedHwRate / 50.0))
|
|
202
|
+
|
|
159
203
|
guard let format = AVAudioFormat(
|
|
160
204
|
commonFormat: .pcmFormatFloat32,
|
|
161
|
-
sampleRate:
|
|
205
|
+
sampleRate: resolvedHwRate,
|
|
162
206
|
channels: AVAudioChannelCount(channelCount),
|
|
163
207
|
interleaved: false
|
|
164
208
|
) else {
|
|
@@ -196,20 +240,30 @@ class AudioPipeline: SharedAudioEngineDelegate {
|
|
|
196
240
|
startFrequencyBandTimer()
|
|
197
241
|
|
|
198
242
|
setState(.idle)
|
|
199
|
-
Logger.debug("[\(AudioPipeline.TAG)] Connected —
|
|
200
|
-
"
|
|
243
|
+
Logger.debug("[\(AudioPipeline.TAG)] Connected — pipelineRate=\(sampleRate) " +
|
|
244
|
+
"hwRate=\(Int(resolvedHwRate)) ch=\(channelCount) " +
|
|
245
|
+
"pipelineFrames=\(frameSizeSamples) hwFrames=\(hwFramesPerBuffer) " +
|
|
201
246
|
"targetBuffer=\(targetBufferMs)ms")
|
|
202
|
-
} catch {
|
|
203
|
-
Logger.debug("[\(AudioPipeline.TAG)] connect() failed: \(error)")
|
|
204
|
-
setState(.error)
|
|
205
|
-
disconnect()
|
|
206
|
-
throw error
|
|
207
|
-
}
|
|
208
247
|
}
|
|
209
248
|
|
|
210
249
|
func disconnect() {
|
|
250
|
+
// Run teardown on the engine's serial queue so it's serialized after any
|
|
251
|
+
// in-flight scheduleBuffer pass and against route-change/interruption —
|
|
252
|
+
// no explicit barrier needed. If the shared engine is already gone
|
|
253
|
+
// (module destroyed), just clean up local state.
|
|
254
|
+
if let sharedEngine = sharedEngine {
|
|
255
|
+
sharedEngine.performSync { self.disconnectOnQueue() }
|
|
256
|
+
} else {
|
|
257
|
+
disconnectOnQueue()
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/// Teardown body. Runs on `sharedEngine.engineQueue` (via `performSync`), so
|
|
262
|
+
/// `running = false` and `detachNode`'s `stop()` are ordered after any
|
|
263
|
+
/// scheduling pass; a buffer completion that fires during `stop()` only
|
|
264
|
+
/// re-enqueues onto the same queue and then bails on `running == false`.
|
|
265
|
+
private func disconnectOnQueue() {
|
|
211
266
|
// Cancel any pending PlaybackStopped before tearing down.
|
|
212
|
-
// DispatchWorkItem.cancel is thread-safe; we may be on any queue here.
|
|
213
267
|
pendingPlaybackStoppedWork?.cancel()
|
|
214
268
|
pendingPlaybackStoppedWork = nil
|
|
215
269
|
|
|
@@ -227,7 +281,8 @@ class AudioPipeline: SharedAudioEngineDelegate {
|
|
|
227
281
|
frequencyBandAnalyzer = nil
|
|
228
282
|
lastEmittedBands = nil
|
|
229
283
|
|
|
230
|
-
// Detach node from shared engine (handles pause/stop/disconnect/detach)
|
|
284
|
+
// Detach node from shared engine (handles pause/stop/disconnect/detach).
|
|
285
|
+
// detachNode is performSync → runs inline since we're already on the queue.
|
|
231
286
|
if let node = playerNode {
|
|
232
287
|
sharedEngine?.detachNode(node)
|
|
233
288
|
}
|
|
@@ -278,14 +333,27 @@ class AudioPipeline: SharedAudioEngineDelegate {
|
|
|
278
333
|
// Old node is invalid (detached during teardown). Create a fresh one.
|
|
279
334
|
scheduleGeneration += 1
|
|
280
335
|
|
|
281
|
-
guard let sharedEngine = sharedEngine
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
336
|
+
guard let sharedEngine = sharedEngine else {
|
|
337
|
+
Logger.debug("[\(AudioPipeline.TAG)] engineDidRebuild — engine missing, treating as dead")
|
|
338
|
+
running = false
|
|
339
|
+
setState(.error)
|
|
340
|
+
listener?.onError(code: "ENGINE_DIED", message: "Failed to recreate audio node after engine rebuild")
|
|
341
|
+
return
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Re-read hardware rate from the new engine (route may have changed).
|
|
345
|
+
let rawHwRate = sharedEngine.engine?.outputNode.outputFormat(forBus: 0).sampleRate ?? 0
|
|
346
|
+
let resolvedHwRate = rawHwRate > 0 ? rawHwRate : hardwareSampleRate
|
|
347
|
+
hardwareSampleRate = resolvedHwRate
|
|
348
|
+
hwFramesPerBuffer = max(1, Int(resolvedHwRate / 50.0))
|
|
349
|
+
|
|
350
|
+
guard let format = AVAudioFormat(
|
|
351
|
+
commonFormat: .pcmFormatFloat32,
|
|
352
|
+
sampleRate: resolvedHwRate,
|
|
353
|
+
channels: AVAudioChannelCount(channelCount),
|
|
354
|
+
interleaved: false
|
|
355
|
+
) else {
|
|
356
|
+
Logger.debug("[\(AudioPipeline.TAG)] engineDidRebuild — cannot create format, treating as dead")
|
|
289
357
|
running = false
|
|
290
358
|
setState(.error)
|
|
291
359
|
listener?.onError(code: "ENGINE_DIED", message: "Failed to recreate audio node after engine rebuild")
|
|
@@ -344,6 +412,11 @@ class AudioPipeline: SharedAudioEngineDelegate {
|
|
|
344
412
|
func audioSessionInterruptionEnded() {
|
|
345
413
|
Logger.debug("[\(AudioPipeline.TAG)] Audio session interruption ended")
|
|
346
414
|
isInterrupted = false
|
|
415
|
+
// Reset the zombie baseline as we clear the interruption: scheduling was
|
|
416
|
+
// stalled while interrupted, and the re-seed below runs asynchronously on
|
|
417
|
+
// the engine queue, so without this the zombie timer could false-positive
|
|
418
|
+
// in the gap before the first resumed buffer is scheduled.
|
|
419
|
+
lastScheduleTime = Date()
|
|
347
420
|
// Engine already restarted by SharedAudioEngine. Re-seed scheduling.
|
|
348
421
|
if running {
|
|
349
422
|
scheduleGeneration += 1
|
|
@@ -465,7 +538,19 @@ class AudioPipeline: SharedAudioEngineDelegate {
|
|
|
465
538
|
// Scheduling loop
|
|
466
539
|
// ════════════════════════════════════════════════════════════════════
|
|
467
540
|
|
|
541
|
+
/// Request one buffer-scheduling pass. Hops onto the shared engine's serial
|
|
542
|
+
/// `engineQueue` so the actual `scheduleBuffer()` is serialized against
|
|
543
|
+
/// teardown's `stop()` and graph mutations. All re-seed call sites (connect,
|
|
544
|
+
/// route change, rebuild, interruption) use this; the completion handler
|
|
545
|
+
/// re-arms via `engineQueue.async` directly (see below).
|
|
468
546
|
private func scheduleNextBuffer() {
|
|
547
|
+
sharedEngine?.engineQueue.async { [weak self] in
|
|
548
|
+
self?.scheduleNextBufferOnQueue()
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/// Build and schedule the next PCM buffer. MUST run on `engineQueue`.
|
|
553
|
+
private func scheduleNextBufferOnQueue() {
|
|
469
554
|
guard running,
|
|
470
555
|
let se = sharedEngine, !se.isRebuilding,
|
|
471
556
|
let buf = jitterBuffer,
|
|
@@ -490,28 +575,35 @@ class AudioPipeline: SharedAudioEngineDelegate {
|
|
|
490
575
|
}
|
|
491
576
|
}
|
|
492
577
|
|
|
493
|
-
//
|
|
494
|
-
|
|
578
|
+
// Build a hardware-rate PCM buffer. The scheduling loop resamples from
|
|
579
|
+
// pipeline rate (e.g. 16 kHz) to hardware rate (e.g. 48 kHz) so the
|
|
580
|
+
// player node connects at the native rate and the engine never inserts a
|
|
581
|
+
// hidden resampler that would desynchronise the AEC echo reference.
|
|
582
|
+
let pipelineFrames = frameSizeSamples / channelCount // frames at pipeline rate
|
|
583
|
+
let hwFrames = hwFramesPerBuffer // frames at hardware rate
|
|
495
584
|
guard let pcmBuffer = AVAudioPCMBuffer(
|
|
496
585
|
pcmFormat: format,
|
|
497
|
-
frameCapacity: AVAudioFrameCount(
|
|
586
|
+
frameCapacity: AVAudioFrameCount(hwFrames)
|
|
498
587
|
) else { return }
|
|
499
|
-
pcmBuffer.frameLength = AVAudioFrameCount(
|
|
588
|
+
pcmBuffer.frameLength = AVAudioFrameCount(hwFrames)
|
|
500
589
|
|
|
501
590
|
if let channelData = pcmBuffer.floatChannelData {
|
|
502
591
|
if isInterrupted {
|
|
503
|
-
// Write silence during interruption
|
|
504
592
|
for ch in 0..<channelCount {
|
|
505
|
-
for i in 0..<
|
|
506
|
-
channelData[ch][i] = 0
|
|
507
|
-
}
|
|
593
|
+
for i in 0..<hwFrames { channelData[ch][i] = 0 }
|
|
508
594
|
}
|
|
509
595
|
} else {
|
|
510
|
-
//
|
|
511
|
-
|
|
596
|
+
// Linear interpolation: pipeline rate → hardware rate
|
|
597
|
+
let ratio = hardwareSampleRate / Double(sampleRate)
|
|
598
|
+
for i in 0..<hwFrames {
|
|
599
|
+
let virtualIndex = Double(i) / ratio
|
|
600
|
+
let indexLow = Int(virtualIndex)
|
|
601
|
+
let indexHigh = min(indexLow + 1, pipelineFrames - 1)
|
|
602
|
+
let weight = Float(virtualIndex - Double(indexLow))
|
|
512
603
|
for ch in 0..<channelCount {
|
|
513
|
-
let
|
|
514
|
-
|
|
604
|
+
let sampleLow = Float(renderSamples[indexLow * channelCount + ch]) / 32768.0
|
|
605
|
+
let sampleHigh = Float(renderSamples[indexHigh * channelCount + ch]) / 32768.0
|
|
606
|
+
channelData[ch][i] = sampleLow + weight * (sampleHigh - sampleLow)
|
|
515
607
|
}
|
|
516
608
|
}
|
|
517
609
|
}
|
|
@@ -521,11 +613,21 @@ class AudioPipeline: SharedAudioEngineDelegate {
|
|
|
521
613
|
lastScheduleTime = Date()
|
|
522
614
|
|
|
523
615
|
node.scheduleBuffer(pcmBuffer) { [weak self] in
|
|
524
|
-
guard let self = self
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
|
|
528
|
-
|
|
616
|
+
guard let self = self else { return }
|
|
617
|
+
// Re-arm on the engine queue. Returning from this completion callback
|
|
618
|
+
// IMMEDIATELY is essential: AVAudioPlayerNode.stop() flushes pending
|
|
619
|
+
// completion messages synchronously while holding the engine lock, so
|
|
620
|
+
// calling scheduleBuffer() inline here would deadlock a concurrent
|
|
621
|
+
// stop() (AttachAndEngineLock ⇄ RealtimeMessenger mutex). Hopping to
|
|
622
|
+
// engineQueue lets stop() drain us without blocking, and serializes
|
|
623
|
+
// the next scheduleBuffer() against teardown on the same queue.
|
|
624
|
+
self.sharedEngine?.engineQueue.async {
|
|
625
|
+
// Bail if torn down, or if this completion belongs to a previous
|
|
626
|
+
// scheduling generation (route change rebuilt the engine while
|
|
627
|
+
// this buffer was in flight).
|
|
628
|
+
guard self.running, self.scheduleGeneration == capturedGeneration else { return }
|
|
629
|
+
self.scheduleNextBufferOnQueue()
|
|
630
|
+
}
|
|
529
631
|
}
|
|
530
632
|
}
|
|
531
633
|
|
|
@@ -631,7 +733,11 @@ class AudioPipeline: SharedAudioEngineDelegate {
|
|
|
631
733
|
timer.setEventHandler { [weak self] in
|
|
632
734
|
guard let self = self else { return }
|
|
633
735
|
let stalledMs = Int64(Date().timeIntervalSince(self.lastScheduleTime) * 1000)
|
|
736
|
+
// Don't flag a zombie while interrupted: the engine is stopped, so
|
|
737
|
+
// scheduling legitimately stalls (no buffers feed) until resume. The
|
|
738
|
+
// stall is expected, not a dead scheduling loop.
|
|
634
739
|
if stalledMs >= AudioPipeline.ZOMBIE_STALL_THRESHOLD_MS &&
|
|
740
|
+
!self.isInterrupted &&
|
|
635
741
|
(self.state == .streaming || self.state == .draining) {
|
|
636
742
|
Logger.debug("[\(AudioPipeline.TAG)] Zombie detected! stalledMs=\(stalledMs)")
|
|
637
743
|
self.listener?.onZombieDetected(stalledMs: stalledMs)
|
|
@@ -165,6 +165,10 @@ public class ExpoPlayAudioStreamModule: Module, MicrophoneDataDelegate, Pipeline
|
|
|
165
165
|
microphone.toggleSilence(isSilent: isSilent)
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
Function("setMicrophoneGain") { (gain: Double) in
|
|
169
|
+
microphone.setMicrophoneGain(Float(gain))
|
|
170
|
+
}
|
|
171
|
+
|
|
168
172
|
// ── Pipeline functions ────────────────────────────────────────────
|
|
169
173
|
|
|
170
174
|
AsyncFunction("connectPipeline") { (options: [String: Any], promise: Promise) in
|
package/ios/Microphone.swift
CHANGED
|
@@ -9,6 +9,14 @@ class Microphone: SharedAudioEngineDelegate {
|
|
|
9
9
|
/// Must be set and configured before calling startRecording.
|
|
10
10
|
weak var sharedAudioEngine: SharedAudioEngine?
|
|
11
11
|
|
|
12
|
+
/// Serial queue for mic-input processing. The tap fires on AVFoundation's
|
|
13
|
+
/// real-time render thread; we copy the buffer and hop here so resampling,
|
|
14
|
+
/// frequency analysis and the JS `sendEvent` never run on that thread (a
|
|
15
|
+
/// real-time violation that can stall capture or invert priority). Kept
|
|
16
|
+
/// separate from the engine queue so a congested JS bridge on the mic path
|
|
17
|
+
/// can't stall playback scheduling.
|
|
18
|
+
private let micQueue = DispatchQueue(label: "expo.modules.audio.microphone")
|
|
19
|
+
|
|
12
20
|
public private(set) var isVoiceProcessingEnabled: Bool = false
|
|
13
21
|
|
|
14
22
|
internal var lastEmittedSize: Int64 = 0
|
|
@@ -22,6 +30,9 @@ class Microphone: SharedAudioEngineDelegate {
|
|
|
22
30
|
|
|
23
31
|
private var isRecording: Bool = false
|
|
24
32
|
private var isSilent: Bool = false
|
|
33
|
+
// Read on the audio render thread, written from the JS thread.
|
|
34
|
+
// Float is 4 bytes / naturally aligned — atomic on ARM64, no lock needed.
|
|
35
|
+
private var micGain: Float = 1.0
|
|
25
36
|
private var frequencyBandAnalyzer: FrequencyBandAnalyzer?
|
|
26
37
|
private var frequencyBandConfig: (lowCrossoverHz: Float, highCrossoverHz: Float)?
|
|
27
38
|
|
|
@@ -116,6 +127,10 @@ class Microphone: SharedAudioEngineDelegate {
|
|
|
116
127
|
self.isSilent = isSilent
|
|
117
128
|
}
|
|
118
129
|
|
|
130
|
+
func setMicrophoneGain(_ gain: Float) {
|
|
131
|
+
micGain = max(0.0, min(1.0, gain))
|
|
132
|
+
}
|
|
133
|
+
|
|
119
134
|
func startRecording(settings: RecordingSettings, intervalMilliseconds: Int,
|
|
120
135
|
frequencyBandConfig: (lowCrossoverHz: Float, highCrossoverHz: Float)? = nil) -> StartRecordingResult? {
|
|
121
136
|
guard !isRecording else {
|
|
@@ -205,29 +220,63 @@ class Microphone: SharedAudioEngineDelegate {
|
|
|
205
220
|
let hardwareFormat = inputNode.inputFormat(forBus: 0)
|
|
206
221
|
recordingSettings?.sampleRate = hardwareFormat.sampleRate
|
|
207
222
|
|
|
223
|
+
// outputFormat is what the tap actually receives — under VoiceProcessingIO
|
|
224
|
+
// this may differ from inputFormat (VP may output at a different rate and
|
|
225
|
+
// adds internal metadata channels beyond channel 0).
|
|
226
|
+
let nodeOutputFormat = inputNode.outputFormat(forBus: 0)
|
|
227
|
+
|
|
208
228
|
let intervalSamples = AVAudioFrameCount(
|
|
209
|
-
Double(intervalMilliseconds) / 1000.0 *
|
|
229
|
+
Double(intervalMilliseconds) / 1000.0 * nodeOutputFormat.sampleRate
|
|
210
230
|
)
|
|
211
231
|
let tapBufferSize = max(intervalSamples, 256)
|
|
212
232
|
|
|
213
|
-
|
|
233
|
+
// Explicit mono Float32 format strips VP metadata channels. Passing format: nil
|
|
234
|
+
// here would deliver the raw VP output channels, corrupting the audio stream
|
|
235
|
+
// with echo-tracking metadata that bleeds into downstream processing.
|
|
236
|
+
let tapFormat = AVAudioFormat(
|
|
237
|
+
commonFormat: .pcmFormatFloat32,
|
|
238
|
+
sampleRate: nodeOutputFormat.sampleRate,
|
|
239
|
+
channels: 1,
|
|
240
|
+
interleaved: false
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
inputNode.installTap(onBus: 0, bufferSize: tapBufferSize, format: tapFormat) { [weak self] (buffer, time) in
|
|
214
244
|
guard let self = self else { return }
|
|
215
245
|
guard buffer.frameLength > 0 else {
|
|
216
246
|
Logger.debug("[Microphone] Received empty buffer in tap callback")
|
|
217
|
-
self.
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
247
|
+
self.micQueue.async {
|
|
248
|
+
self.delegate?.onMicrophoneError(MicrophoneErrorInfo(
|
|
249
|
+
code: "READ_ERROR",
|
|
250
|
+
message: "Received empty audio buffer",
|
|
251
|
+
isFatal: false,
|
|
252
|
+
autoResuming: false
|
|
253
|
+
))
|
|
254
|
+
}
|
|
223
255
|
return
|
|
224
256
|
}
|
|
225
|
-
|
|
226
|
-
|
|
257
|
+
// Convert on the render thread — the tap buffer is only valid here, and
|
|
258
|
+
// the conversion yields the `Data` we'd build anyway, so there's no
|
|
259
|
+
// extra copy. Hand the value-typed result to the serial queue; only the
|
|
260
|
+
// JS-bridge delivery (sendEvent) runs off the render thread. Nothing
|
|
261
|
+
// here re-enters the engine, so no separate queue is needed for safety —
|
|
262
|
+
// micQueue exists purely to keep the bridge call off the render thread
|
|
263
|
+
// and preserve chunk ordering.
|
|
264
|
+
guard let payload = self.extractMicData(from: buffer) else { return }
|
|
265
|
+
self.micQueue.async {
|
|
266
|
+
self.totalDataSize += Int64(payload.data.count)
|
|
267
|
+
self.delegate?.onMicrophoneData(payload.data, payload.powerLevel, payload.bands)
|
|
268
|
+
self.lastEmittedSize = self.totalDataSize
|
|
269
|
+
self.lastBufferTime = time
|
|
270
|
+
}
|
|
227
271
|
}
|
|
228
272
|
}
|
|
229
273
|
|
|
230
|
-
|
|
274
|
+
/// Convert a tap buffer into the value-typed payload the delegate needs
|
|
275
|
+
/// (PCM `Data`, power level, frequency bands). Runs on the audio render
|
|
276
|
+
/// thread — it needs the live buffer — but does no cross-thread work, so the
|
|
277
|
+
/// caller hands the result to `micQueue` for the JS-bridge delivery. Returns
|
|
278
|
+
/// nil if the buffer yields no data.
|
|
279
|
+
private func extractMicData(from buffer: AVAudioPCMBuffer) -> (data: Data, powerLevel: Float, bands: FrequencyBands?)? {
|
|
231
280
|
let targetSampleRate = recordingSettings?.desiredSampleRate ?? buffer.format.sampleRate
|
|
232
281
|
let targetBitDepth = recordingSettings?.bitDepth ?? 16
|
|
233
282
|
var currentBuffer = buffer
|
|
@@ -261,7 +310,7 @@ class Microphone: SharedAudioEngineDelegate {
|
|
|
261
310
|
var int16Data = Data(capacity: frameCount * channelCount * 2)
|
|
262
311
|
for frame in 0..<frameCount {
|
|
263
312
|
for ch in 0..<channelCount {
|
|
264
|
-
let sample = max(-1.0, min(1.0, floatData[ch][frame]))
|
|
313
|
+
let sample = max(-1.0, min(1.0, floatData[ch][frame] * micGain))
|
|
265
314
|
var int16Sample = Int16(sample * 32767.0)
|
|
266
315
|
int16Data.append(Data(bytes: &int16Sample, count: 2))
|
|
267
316
|
}
|
|
@@ -271,7 +320,7 @@ class Microphone: SharedAudioEngineDelegate {
|
|
|
271
320
|
let audioData = currentBuffer.audioBufferList.pointee.mBuffers
|
|
272
321
|
guard let bufferData = audioData.mData else {
|
|
273
322
|
Logger.debug("[Microphone] Buffer data is nil.")
|
|
274
|
-
return
|
|
323
|
+
return nil
|
|
275
324
|
}
|
|
276
325
|
data = Data(bytes: bufferData, count: Int(audioData.mDataByteSize))
|
|
277
326
|
}
|
|
@@ -286,8 +335,6 @@ class Microphone: SharedAudioEngineDelegate {
|
|
|
286
335
|
bands = nil
|
|
287
336
|
}
|
|
288
337
|
|
|
289
|
-
|
|
290
|
-
self.delegate?.onMicrophoneData(data, powerLevel, bands)
|
|
291
|
-
self.lastEmittedSize = totalDataSize
|
|
338
|
+
return (data, powerLevel, bands)
|
|
292
339
|
}
|
|
293
340
|
}
|
|
@@ -49,7 +49,33 @@ extension SharedAudioEngineDelegate {
|
|
|
49
49
|
class SharedAudioEngine {
|
|
50
50
|
private static let TAG = "SharedAudioEngine"
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
/// The single serial queue that owns ALL engine graph work: every
|
|
53
|
+
/// attach/detach/connect/stop/start/configure/teardown, the playback
|
|
54
|
+
/// `scheduleBuffer` loop (driven from AudioPipeline), and route/interruption
|
|
55
|
+
/// handling. Serial ordering here is what makes the AVAudioPlayerNode
|
|
56
|
+
/// `stop()`/`scheduleBuffer()` deadlock structurally impossible — they can't
|
|
57
|
+
/// overlap on one serial queue. AVFoundation callbacks hop on via `async`
|
|
58
|
+
/// (never `sync` — they may run with an AV lock held); Expo functions enter
|
|
59
|
+
/// via `performSync`. Replaces the former recursive lock.
|
|
60
|
+
let engineQueue = DispatchQueue(label: "expo.modules.audio.engine")
|
|
61
|
+
private static let queueKey = DispatchSpecificKey<Void>()
|
|
62
|
+
|
|
63
|
+
init() {
|
|
64
|
+
engineQueue.setSpecific(key: SharedAudioEngine.queueKey, value: ())
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/// Run `work` synchronously in the engine's serial domain. Re-entrancy-aware:
|
|
68
|
+
/// if we're already on `engineQueue` (a delegate callback fired from
|
|
69
|
+
/// `notifyDelegates`, or `configure` calling `teardown`/`attachNode`) we run
|
|
70
|
+
/// inline instead of dead-locking on `engineQueue.sync`. This is exactly what
|
|
71
|
+
/// the *recursive* lock used to provide. Callers on AVFoundation callback
|
|
72
|
+
/// threads must use `engineQueue.async` instead — never this.
|
|
73
|
+
func performSync<T>(_ work: () throws -> T) rethrows -> T {
|
|
74
|
+
if DispatchQueue.getSpecific(key: SharedAudioEngine.queueKey) != nil {
|
|
75
|
+
return try work()
|
|
76
|
+
}
|
|
77
|
+
return try engineQueue.sync(execute: work)
|
|
78
|
+
}
|
|
53
79
|
|
|
54
80
|
// ── Engine state ─────────────────────────────────────────────────────
|
|
55
81
|
private(set) var engine: AVAudioEngine?
|
|
@@ -61,17 +87,17 @@ class SharedAudioEngine {
|
|
|
61
87
|
private let delegates = NSHashTable<AnyObject>.weakObjects()
|
|
62
88
|
|
|
63
89
|
func addDelegate(_ d: SharedAudioEngineDelegate) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
90
|
+
performSync {
|
|
91
|
+
if !delegates.contains(d as AnyObject) {
|
|
92
|
+
delegates.add(d as AnyObject)
|
|
93
|
+
}
|
|
68
94
|
}
|
|
69
95
|
}
|
|
70
96
|
|
|
71
97
|
func removeDelegate(_ d: SharedAudioEngineDelegate) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
98
|
+
performSync {
|
|
99
|
+
delegates.remove(d as AnyObject)
|
|
100
|
+
}
|
|
75
101
|
}
|
|
76
102
|
|
|
77
103
|
private func notifyDelegates(_ block: (SharedAudioEngineDelegate) -> Void) {
|
|
@@ -100,12 +126,19 @@ class SharedAudioEngine {
|
|
|
100
126
|
///
|
|
101
127
|
/// - Parameter playbackMode: Determines whether voice processing is enabled.
|
|
102
128
|
func configure(playbackMode: PlaybackMode) throws {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
129
|
+
try performSync { try configureOnQueue(playbackMode: playbackMode) }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private func configureOnQueue(playbackMode: PlaybackMode) throws {
|
|
133
|
+
let needsVP = playbackMode == .conversation || playbackMode == .voiceProcessing
|
|
134
|
+
let vpActive = !needsVP || (engine?.inputNode.isVoiceProcessingEnabled == true)
|
|
135
|
+
if isConfigured && self.playbackMode == playbackMode && engine?.isRunning == true && vpActive {
|
|
106
136
|
Logger.debug("[\(SharedAudioEngine.TAG)] Already configured for \(playbackMode) and engine running, skipping")
|
|
107
137
|
return
|
|
108
138
|
}
|
|
139
|
+
if isConfigured && !vpActive {
|
|
140
|
+
Logger.debug("[\(SharedAudioEngine.TAG)] Voice processing dropped (setCategory side-effect) — forcing reconfiguration")
|
|
141
|
+
}
|
|
109
142
|
|
|
110
143
|
if isConfigured && engine?.isRunning != true {
|
|
111
144
|
Logger.debug("[\(SharedAudioEngine.TAG)] Engine marked configured but not running — forcing reconfiguration")
|
|
@@ -123,7 +156,6 @@ class SharedAudioEngine {
|
|
|
123
156
|
// Done before connecting nodes so the audio graph incorporates VP from the start.
|
|
124
157
|
if playbackMode == .conversation || playbackMode == .voiceProcessing {
|
|
125
158
|
try engine.inputNode.setVoiceProcessingEnabled(true)
|
|
126
|
-
try engine.outputNode.setVoiceProcessingEnabled(true)
|
|
127
159
|
Logger.debug("[\(SharedAudioEngine.TAG)] Voice processing enabled")
|
|
128
160
|
}
|
|
129
161
|
|
|
@@ -168,8 +200,10 @@ class SharedAudioEngine {
|
|
|
168
200
|
/// Connects `node → mainMixerNode` with the given format.
|
|
169
201
|
/// The mixer handles sample-rate conversion to hardware output.
|
|
170
202
|
func attachNode(_ node: AVAudioPlayerNode, format: AVAudioFormat) {
|
|
171
|
-
|
|
172
|
-
|
|
203
|
+
performSync { attachNodeOnQueue(node, format: format) }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private func attachNodeOnQueue(_ node: AVAudioPlayerNode, format: AVAudioFormat) {
|
|
173
207
|
guard let engine = engine else {
|
|
174
208
|
Logger.debug("[\(SharedAudioEngine.TAG)] attachNode called but engine is nil")
|
|
175
209
|
return
|
|
@@ -184,8 +218,10 @@ class SharedAudioEngine {
|
|
|
184
218
|
|
|
185
219
|
/// Detach a consumer's player node from the shared engine.
|
|
186
220
|
func detachNode(_ node: AVAudioPlayerNode) {
|
|
187
|
-
|
|
188
|
-
|
|
221
|
+
performSync { detachNodeOnQueue(node) }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private func detachNodeOnQueue(_ node: AVAudioPlayerNode) {
|
|
189
225
|
guard let engine = engine else { return }
|
|
190
226
|
|
|
191
227
|
node.pause()
|
|
@@ -209,8 +245,10 @@ class SharedAudioEngine {
|
|
|
209
245
|
|
|
210
246
|
/// Tear down the engine completely. Called on reconfigure or module destroy.
|
|
211
247
|
func teardown() {
|
|
212
|
-
|
|
213
|
-
|
|
248
|
+
performSync { teardownOnQueue() }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private func teardownOnQueue() {
|
|
214
252
|
// Remove observers
|
|
215
253
|
NotificationCenter.default.removeObserver(
|
|
216
254
|
self, name: AVAudioSession.routeChangeNotification, object: nil)
|
|
@@ -236,7 +274,6 @@ class SharedAudioEngine {
|
|
|
236
274
|
if playbackMode == .conversation || playbackMode == .voiceProcessing {
|
|
237
275
|
if let engine = engine {
|
|
238
276
|
try? engine.inputNode.setVoiceProcessingEnabled(false)
|
|
239
|
-
try? engine.outputNode.setVoiceProcessingEnabled(false)
|
|
240
277
|
}
|
|
241
278
|
}
|
|
242
279
|
|
|
@@ -260,10 +297,15 @@ class SharedAudioEngine {
|
|
|
260
297
|
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else {
|
|
261
298
|
return
|
|
262
299
|
}
|
|
300
|
+
// Hop off AVFoundation's notification thread onto the engine queue before
|
|
301
|
+
// touching the engine or session — that thread can hold an internal lock
|
|
302
|
+
// when delivering this, and the queue serializes us against connect/disconnect.
|
|
303
|
+
engineQueue.async { [weak self] in
|
|
304
|
+
self?.handleRouteChangeOnQueue(reason: reason)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
263
307
|
|
|
264
|
-
|
|
265
|
-
defer { lock.unlock() }
|
|
266
|
-
|
|
308
|
+
private func handleRouteChangeOnQueue(reason: AVAudioSession.RouteChangeReason) {
|
|
267
309
|
let routeDescription = AVAudioSession.sharedInstance().currentRoute.outputs
|
|
268
310
|
.map { "\($0.portName) (\($0.portType.rawValue))" }
|
|
269
311
|
.joined(separator: ", ")
|
|
@@ -311,7 +353,6 @@ class SharedAudioEngine {
|
|
|
311
353
|
if playbackMode == .conversation || playbackMode == .voiceProcessing {
|
|
312
354
|
do {
|
|
313
355
|
try engine.inputNode.setVoiceProcessingEnabled(true)
|
|
314
|
-
try engine.outputNode.setVoiceProcessingEnabled(true)
|
|
315
356
|
} catch {
|
|
316
357
|
Logger.debug("[\(SharedAudioEngine.TAG)] Voice processing re-enable failed: \(error)")
|
|
317
358
|
}
|
|
@@ -410,7 +451,7 @@ class SharedAudioEngine {
|
|
|
410
451
|
// Voice processing can cause the engine to die asynchronously after
|
|
411
452
|
// appearing to start. Wait 100ms then re-verify before declaring success.
|
|
412
453
|
let verifyDelay: TimeInterval = (self.playbackMode == .conversation || self.playbackMode == .voiceProcessing) ? 0.1 : 0.05
|
|
413
|
-
|
|
454
|
+
self.engineQueue.asyncAfter(deadline: .now() + verifyDelay) { [weak self] in
|
|
414
455
|
guard let self = self, let engine = self.engine else {
|
|
415
456
|
self?.isRebuildingForRouteChange = false
|
|
416
457
|
return
|
|
@@ -449,7 +490,7 @@ class SharedAudioEngine {
|
|
|
449
490
|
|
|
450
491
|
if delay > 0 {
|
|
451
492
|
Logger.debug("[\(SharedAudioEngine.TAG)] Waiting \(Int(delay * 1000))ms before attempt \(attempt + 1)")
|
|
452
|
-
|
|
493
|
+
engineQueue.asyncAfter(deadline: .now() + delay, execute: work)
|
|
453
494
|
} else {
|
|
454
495
|
work()
|
|
455
496
|
}
|
|
@@ -493,16 +534,19 @@ class SharedAudioEngine {
|
|
|
493
534
|
guard let info = notification.userInfo,
|
|
494
535
|
let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
|
|
495
536
|
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return }
|
|
537
|
+
let optionsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
|
|
538
|
+
// Hop off AVFoundation's notification thread onto the engine queue.
|
|
539
|
+
engineQueue.async { [weak self] in
|
|
540
|
+
self?.handleInterruptionOnQueue(type: type, optionsValue: optionsValue)
|
|
541
|
+
}
|
|
542
|
+
}
|
|
496
543
|
|
|
497
|
-
|
|
498
|
-
defer { lock.unlock() }
|
|
499
|
-
|
|
544
|
+
private func handleInterruptionOnQueue(type: AVAudioSession.InterruptionType, optionsValue: UInt) {
|
|
500
545
|
if type == .began {
|
|
501
546
|
Logger.debug("[\(SharedAudioEngine.TAG)] Audio session interruption began")
|
|
502
547
|
notifyDelegates { $0.audioSessionInterruptionBegan() }
|
|
503
548
|
} else if type == .ended {
|
|
504
549
|
Logger.debug("[\(SharedAudioEngine.TAG)] Audio session interruption ended")
|
|
505
|
-
let optionsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
|
|
506
550
|
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
|
|
507
551
|
guard options.contains(.shouldResume) else {
|
|
508
552
|
// shouldResume absent — system hints we should not restart yet.
|
|
@@ -529,6 +573,8 @@ class SharedAudioEngine {
|
|
|
529
573
|
}
|
|
530
574
|
|
|
531
575
|
deinit {
|
|
532
|
-
|
|
576
|
+
// At dealloc there are no other references, so no concurrency — run the
|
|
577
|
+
// body directly rather than dispatching onto the queue during teardown.
|
|
578
|
+
teardownOnQueue()
|
|
533
579
|
}
|
|
534
580
|
}
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -184,6 +184,14 @@ export class ExpoPlayAudioStream {
|
|
|
184
184
|
ExpoPlayAudioStreamModule.toggleSilence(isSilent);
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
+
/**
|
|
188
|
+
* Sets the microphone capture gain (0.0 = silent, 1.0 = full).
|
|
189
|
+
* Takes effect immediately on the next captured audio chunk.
|
|
190
|
+
*/
|
|
191
|
+
static setMicrophoneGain(gain: number) {
|
|
192
|
+
ExpoPlayAudioStreamModule.setMicrophoneGain(Math.max(0, Math.min(1, gain)));
|
|
193
|
+
}
|
|
194
|
+
|
|
187
195
|
/**
|
|
188
196
|
* Requests microphone permission from the user.
|
|
189
197
|
* @returns {Promise<{granted: boolean, canAskAgain?: boolean, status?: string}>} A promise that resolves to the permission result.
|