@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.
@@ -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
- audioRecord?.startRecording()
158
- // Apply audio effects after starting recording using the manager
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
- // If silent mode is active, replace audioData with zeros (using concise expression)
389
- val dataToEncode = if (isSilent) ByteArray(length) else audioData
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.
@@ -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":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAI3D,KAAK,YAAY,GAAG,iBAAiB,CAAC;AACtC,OAAO,EACL,cAAc,EACd,cAAc,EACd,eAAe,EACf,oBAAoB,EACpB,YAAY,EACZ,QAAQ,EACR,aAAa,EACb,cAAc,EACd,aAAa,EAEb,kBAAkB,EAClB,iBAAiB,EACjB,WAAW,EACX,iBAAiB,EACjB,oBAAoB,EACpB,mBAAmB,EACnB,eAAe,EACf,eAAe,EACf,oBAAoB,EACpB,iBAAiB,EACjB,eAAe,EACf,iBAAiB,EAClB,MAAM,SAAS,CAAC;AAEjB,OAAO,EAIL,WAAW,EAEX,uBAAuB,EACvB,6BAA6B,EAC9B,MAAM,UAAU,CAAC;AAElB,qBAAa,mBAAmB;IAC9B;;;;OAIG;WACU,OAAO;IAIpB;;;;;OAKG;WACU,eAAe,CAAC,eAAe,EAAE,eAAe,GAAG,OAAO,CAAC;QACtE,eAAe,EAAE,oBAAoB,CAAC;QACtC,YAAY,CAAC,EAAE,YAAY,CAAC;KAC7B,CAAC;IAuDF;;;;OAIG;WACU,cAAc,IAAI,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC;IAS7D;;;;OAIG;IACH,MAAM,CAAC,sBAAsB,CAC3B,kBAAkB,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,OAAO,CAAC,IAAI,CAAC,GAC3D,YAAY;IAoBf;;;;;OAKG;IACH,MAAM,CAAC,SAAS,CAAC,CAAC,SAAS,OAAO,EAChC,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,CAAC,KAAK,EAAE,CAAC,GAAG,SAAS,KAAK,OAAO,CAAC,IAAI,CAAC,GAC/C,YAAY;IAIf;;;;OAIG;IACH,MAAM,CAAC,qBAAqB;IAI5B;;;;OAIG;IACH,MAAM,CAAC,aAAa,CAAC,QAAQ,EAAE,OAAO;IAItC;;;OAGG;IACH,MAAM,CAAC,iBAAiB,CAAC,IAAI,EAAE,MAAM;IAIrC;;;OAGG;WACU,uBAAuB,IAAI,OAAO,CAAC;QAC9C,OAAO,EAAE,OAAO,CAAC;QACjB,WAAW,CAAC,EAAE,OAAO,CAAC;QACtB,MAAM,CAAC,EAAE,MAAM,CAAC;KACjB,CAAC;IASF;;;OAGG;WACU,mBAAmB,IAAI,OAAO,CAAC;QAC1C,OAAO,EAAE,OAAO,CAAC;QACjB,WAAW,CAAC,EAAE,OAAO,CAAC;QACtB,MAAM,CAAC,EAAE,MAAM,CAAC;KACjB,CAAC;CAQH;AAED,OAAO,EACL,cAAc,EACd,uBAAuB,EACvB,6BAA6B,EAC7B,cAAc,EACd,eAAe,EACf,oBAAoB,EACpB,WAAW,EACX,YAAY,EACZ,QAAQ,EACR,aAAa,EACb,cAAc,EACd,aAAa,EAEb,kBAAkB,EAClB,iBAAiB,EACjB,WAAW,EACX,iBAAiB,EACjB,oBAAoB,EACpB,mBAAmB,EACnB,eAAe,EACf,eAAe,EACf,oBAAoB,EACpB,iBAAiB,EACjB,eAAe,EACf,iBAAiB,GAClB,CAAC;AAGF,YAAY,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAC3D,YAAY,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAC7C,OAAO,EAAE,0BAA0B,EAAE,MAAM,UAAU,CAAC;AACtD,YAAY,EAAE,2BAA2B,EAAE,MAAM,UAAU,CAAC;AAG5D,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AACtC,YAAY,EACV,sBAAsB,EACtB,qBAAqB,EACrB,wBAAwB,EACxB,6BAA6B,EAC7B,aAAa,EACb,gBAAgB,EAChB,iBAAiB,EACjB,uBAAuB,EACvB,iBAAiB,EACjB,yBAAyB,EACzB,4BAA4B,EAC5B,kBAAkB,EAClB,2BAA2B,EAC3B,qBAAqB,EACrB,oBAAoB,EACpB,4BAA4B,EAC5B,2BAA2B,EAC3B,8BAA8B,GAC/B,MAAM,YAAY,CAAC"}
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.
@@ -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"]}
@@ -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
- guard let sharedEngine = sharedEngine else {
139
- throw NSError(domain: "AudioPipeline", code: -1,
140
- userInfo: [NSLocalizedDescriptionKey: "SharedAudioEngine not set"])
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. Create format and attach player node to shared engine ─
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: Double(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 — sampleRate=\(sampleRate) " +
200
- "ch=\(channelCount) frameSamples=\(frameSizeSamples) " +
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
- let format = AVAudioFormat(
283
- commonFormat: .pcmFormatFloat32,
284
- sampleRate: Double(sampleRate),
285
- channels: AVAudioChannelCount(channelCount),
286
- interleaved: false
287
- ) else {
288
- Logger.debug("[\(AudioPipeline.TAG)] engineDidRebuild — cannot create format or engine missing, treating as dead")
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
- // Convert to non-interleaved Float32 for AVAudioEngine
494
- let framesPerBuffer = frameSizeSamples / channelCount
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(framesPerBuffer)
586
+ frameCapacity: AVAudioFrameCount(hwFrames)
498
587
  ) else { return }
499
- pcmBuffer.frameLength = AVAudioFrameCount(framesPerBuffer)
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..<framesPerBuffer {
506
- channelData[ch][i] = 0
507
- }
593
+ for i in 0..<hwFrames { channelData[ch][i] = 0 }
508
594
  }
509
595
  } else {
510
- // De-interleave Int16non-interleaved Float32
511
- for frame in 0..<framesPerBuffer {
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 sampleIndex = frame * channelCount + ch
514
- channelData[ch][frame] = Float(renderSamples[sampleIndex]) / 32768.0
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, self.running else { return }
525
- // Bail if this completion belongs to a previous scheduling generation
526
- // (route change rebuilt the engine while this buffer was in flight).
527
- guard self.scheduleGeneration == capturedGeneration else { return }
528
- self.scheduleNextBuffer()
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
@@ -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 * hardwareFormat.sampleRate
229
+ Double(intervalMilliseconds) / 1000.0 * nodeOutputFormat.sampleRate
210
230
  )
211
231
  let tapBufferSize = max(intervalSamples, 256)
212
232
 
213
- inputNode.installTap(onBus: 0, bufferSize: tapBufferSize, format: nil) { [weak self] (buffer, time) in
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.delegate?.onMicrophoneError(MicrophoneErrorInfo(
218
- code: "READ_ERROR",
219
- message: "Received empty audio buffer",
220
- isFatal: false,
221
- autoResuming: false
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
- self.processAudioBuffer(buffer)
226
- self.lastBufferTime = time
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
- private func processAudioBuffer(_ buffer: AVAudioPCMBuffer) {
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
- totalDataSize += Int64(data.count)
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
- private let lock = NSRecursiveLock()
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
- lock.lock()
65
- defer { lock.unlock() }
66
- if !delegates.contains(d as AnyObject) {
67
- delegates.add(d as AnyObject)
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
- lock.lock()
73
- defer { lock.unlock() }
74
- delegates.remove(d as AnyObject)
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
- lock.lock()
104
- defer { lock.unlock() }
105
- if isConfigured && self.playbackMode == playbackMode && engine?.isRunning == true {
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
- lock.lock()
172
- defer { lock.unlock() }
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
- lock.lock()
188
- defer { lock.unlock() }
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
- lock.lock()
213
- defer { lock.unlock() }
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
- lock.lock()
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
- DispatchQueue.main.asyncAfter(deadline: .now() + verifyDelay) { [weak self] in
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
- DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
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
- lock.lock()
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
- teardown()
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edkimmel/expo-audio-stream",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "Expo Play Audio Stream module",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
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.