@edkimmel/expo-audio-stream 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/NATIVE_EVENTS.md +97 -6
  2. package/android/.gradle/8.9/checksums/checksums.lock +0 -0
  3. package/android/.gradle/8.9/dependencies-accessors/gc.properties +0 -0
  4. package/android/.gradle/8.9/fileChanges/last-build.bin +0 -0
  5. package/android/.gradle/8.9/fileHashes/fileHashes.lock +0 -0
  6. package/android/.gradle/8.9/gc.properties +0 -0
  7. package/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
  8. package/android/.gradle/buildOutputCleanup/cache.properties +2 -0
  9. package/android/.gradle/vcs-1/gc.properties +0 -0
  10. package/android/src/main/java/expo/modules/audiostream/AudioEffectsManager.kt +6 -11
  11. package/android/src/main/java/expo/modules/audiostream/AudioRecorderManager.kt +23 -6
  12. package/android/src/main/java/expo/modules/audiostream/CommunicationAudioManager.kt +155 -0
  13. package/android/src/main/java/expo/modules/audiostream/Constants.kt +1 -0
  14. package/android/src/main/java/expo/modules/audiostream/ExpoPlayAudioStreamModule.kt +17 -3
  15. package/android/src/main/java/expo/modules/audiostream/pipeline/AudioPipeline.kt +104 -11
  16. package/android/src/main/java/expo/modules/audiostream/pipeline/PipelineIntegration.kt +14 -0
  17. package/build/events.d.ts +26 -0
  18. package/build/events.d.ts.map +1 -1
  19. package/build/events.js +18 -0
  20. package/build/events.js.map +1 -1
  21. package/build/index.d.ts +3 -1
  22. package/build/index.d.ts.map +1 -1
  23. package/build/index.js +19 -7
  24. package/build/index.js.map +1 -1
  25. package/build/pipeline/index.d.ts +14 -1
  26. package/build/pipeline/index.d.ts.map +1 -1
  27. package/build/pipeline/index.js +15 -0
  28. package/build/pipeline/index.js.map +1 -1
  29. package/build/pipeline/types.d.ts +14 -0
  30. package/build/pipeline/types.d.ts.map +1 -1
  31. package/build/pipeline/types.js.map +1 -1
  32. package/build/types.d.ts +21 -0
  33. package/build/types.d.ts.map +1 -1
  34. package/build/types.js.map +1 -1
  35. package/ios/AudioPipeline.swift +67 -2
  36. package/ios/ExpoPlayAudioStreamModule.swift +43 -12
  37. package/ios/Microphone.swift +167 -120
  38. package/ios/MicrophoneDataDelegate.swift +10 -1
  39. package/ios/PipelineIntegration.swift +11 -1
  40. package/ios/SharedAudioEngine.swift +18 -0
  41. package/package.json +1 -2
  42. package/plugin/build/index.js +5 -0
  43. package/plugin/src/index.ts +5 -0
  44. package/src/events.ts +32 -0
  45. package/src/index.ts +27 -18
  46. package/src/pipeline/index.ts +17 -0
  47. package/src/pipeline/types.ts +15 -0
  48. package/src/types.ts +22 -0
package/NATIVE_EVENTS.md CHANGED
@@ -9,8 +9,7 @@ including payload shapes, trigger conditions, and recommended JS responses.
9
9
 
10
10
  ### `AudioData`
11
11
 
12
- Emitted at the configured interval during microphone recording. Also doubles as
13
- the recording error channel.
12
+ Emitted at the configured interval during microphone recording.
14
13
 
15
14
  | Field | Type | Notes |
16
15
  |---|---|---|
@@ -27,19 +26,21 @@ the recording error channel.
27
26
 
28
27
  **Error variant** (same event name, different shape):
29
28
 
29
+ > Errors are also emitted on the richer `MicrophoneError` event (see below).
30
+ > This variant is kept for backward compatibility.
31
+
30
32
  | Field | Type | Notes |
31
33
  |---|---|---|
32
- | `error` | `string` | Error code: `READ_ERROR`, `RECORDING_CRASH` |
34
+ | `error` | `string` | Error code see `MicrophoneError` section for full table |
33
35
  | `errorMessage` | `string` | Human-readable description |
34
- | `streamUuid` | `string` | Stream that errored |
36
+ | `streamUuid` | `string` | Stream ID (Android only; `""` on iOS) |
35
37
 
36
38
  **Platform:** Android, iOS
37
39
 
38
40
  **JS response:**
39
41
  - Forward the base64 PCM to your STT pipeline or WebSocket.
40
42
  - Use `soundLevel` for VAD or UI visualisation.
41
- - Check for the `error` field before assuming the payload is audio data.
42
- On error, stop the conversation turn or retry.
43
+ - Prefer subscribing via `addMicrophoneErrorListener` it receives the structured `MicrophoneError` event with `isFatal` and `autoResuming` fields.
43
44
 
44
45
  ---
45
46
 
@@ -62,6 +63,59 @@ wired headset/headphones, USB headset).
62
63
 
63
64
  ---
64
65
 
66
+ ### `MicrophoneError`
67
+
68
+ Fired on every microphone error alongside the backward-compatible `AudioData`
69
+ error variant. Carries structured `isFatal` and `autoResuming` fields decided
70
+ by native code.
71
+
72
+ **OTA safe:** apps can subscribe to this event before the native binary
73
+ includes it — old native simply never emits it, so the listener is never
74
+ called. Apps using the `AudioData` error variant continue to work unchanged.
75
+
76
+ | Field | Type | Notes |
77
+ |---|---|---|
78
+ | `code` | `string` | Error code (see table below) |
79
+ | `message` | `string` | Human-readable description including any underlying system error |
80
+ | `isFatal` | `boolean` | `true` = recording has stopped; caller must call `stopMicrophone()` and reconnect |
81
+ | `autoResuming` | `boolean` | `true` = library will reinstall the tap automatically (`INTERRUPTED` only) |
82
+
83
+ **Error code reference:**
84
+
85
+ | Code | Platform | `isFatal` | `autoResuming` | Meaning | Recommended action |
86
+ |------|----------|-----------|----------------|---------|-------------------|
87
+ | `INTERRUPTED` | iOS | false | true | System interrupted the audio session | Show paused UI; do NOT stop — library auto-resumes |
88
+ | `RESUME_FAILED` | iOS | true | false | System allowed resume but engine restart failed | `stopMicrophone()` then reconnect |
89
+ | `RESTART_FAILED` | iOS | true | false | Route-change or engine-rebuild recovery failed | `stopMicrophone()` then reconnect |
90
+ | `ENGINE_DIED` | iOS | true | false | SharedAudioEngine exhausted all recovery | `stopMicrophone()` + `disconnectPipeline()` then reconnect both |
91
+ | `READ_ERROR` | iOS | false | false | Single empty buffer — transient, recording continues | Log for diagnostics; no action |
92
+ | `READ_ERROR` | Android | true | false | `AudioRecord.read()` failed 10× consecutively | `stopMicrophone()` then reconnect |
93
+ | `RECORDING_CRASH` | Android | true | false | Recording thread threw an unexpected exception | `stopMicrophone()` then reconnect |
94
+
95
+ **Platform:** Android, iOS
96
+
97
+ **JS response:**
98
+
99
+ ```typescript
100
+ import { addMicrophoneErrorListener } from "@edkimmel/expo-audio-stream"
101
+
102
+ const sub = addMicrophoneErrorListener((error) => {
103
+ if (error.isFatal) {
104
+ stopMicrophone()
105
+ if (error.code === "ENGINE_DIED") disconnectPipeline()
106
+ reconnect()
107
+ } else if (error.autoResuming) {
108
+ showPausedUI() // INTERRUPTED — library will reinstall tap automatically
109
+ }
110
+ // isFatal: false, autoResuming: false → READ_ERROR on iOS: log and ignore
111
+ })
112
+
113
+ // cleanup
114
+ sub.remove()
115
+ ```
116
+
117
+ ---
118
+
65
119
  ## Legacy Playback Events (AudioPlaybackManager)
66
120
 
67
121
  ### `SoundStarted`
@@ -200,6 +254,43 @@ The pipeline transitions from `draining` to `idle`.
200
254
 
201
255
  ---
202
256
 
257
+ ### `PipelinePlaybackStopped`
258
+
259
+ Fired approximately `outputLatencyMs` after `PipelineDrained` for the same turn —
260
+ i.e., when the last sample physically leaves the speaker. Pairs symmetrically
261
+ with `PipelinePlaybackStarted` (start-of-emission ↔ end-of-emission).
262
+
263
+ **Distinct from `state: 'idle'`:** that's the pipeline-state-machine value
264
+ reported via `PipelineStateChanged`. `PipelinePlaybackStopped` is a
265
+ physical-world milestone about audible audio, not a state-machine transition.
266
+
267
+ | Field | Type | Notes |
268
+ |---|---|---|
269
+ | `turnId` | `string` | The turn whose last sample just stopped emitting |
270
+
271
+ **Cancellation:** the scheduled dispatch is cancelled if a new turn starts,
272
+ `invalidatePipelineTurn` is called, or the pipeline disconnects before the
273
+ delay elapses. Consumers will not receive a late-arriving event mid-stream.
274
+
275
+ **Approximation:** the delay uses `outputLatencyMs` captured at drain time.
276
+ On iOS the value reflects `AVAudioSession.outputLatency` (total HW output
277
+ latency). On Android it uses `AudioTrack.getTimestamp()` to compute frames
278
+ in-flight, falling back to a HAL-buffer estimate if the timestamp call fails.
279
+ A route change during the latency window (e.g. switch to Bluetooth mid-tail)
280
+ is *not* re-measured — the captured value is used. The error is typically
281
+ single-digit milliseconds and is acceptable for VAD-gating use cases.
282
+
283
+ **Platform:** Android, iOS
284
+
285
+ **JS response:**
286
+ - Use this — not `PipelineDrained` — when you need to know when **mic-side
287
+ echo from the speaker is over**. Useful for voice-agent pipelines that
288
+ gate server-side VAD on the agent's own playback ending.
289
+ - If you do not care about the physical-world emission boundary, prefer
290
+ `PipelineDrained` (cheaper, fires earlier).
291
+
292
+ ---
293
+
203
294
  ### `PipelineFrequencyBands`
204
295
 
205
296
  Fired at the interval configured by `frequencyBandIntervalMs` during pipeline
File without changes
@@ -0,0 +1,2 @@
1
+ #Thu Jun 04 15:44:34 EDT 2026
2
+ gradle.version=8.9
File without changes
@@ -9,18 +9,13 @@ import android.util.Log
9
9
  /**
10
10
  * Manages hardware audio effects for voice recording.
11
11
  *
12
- * We use VOICE_RECOGNITION as our audio source. The Android CDD (Section 5.4)
13
- * mandates that this source delivers unprocessed audio:
14
- * [C-1-2] MUST disable noise reduction by default
15
- * [C-1-3] MUST disable automatic gain control by default
12
+ * We use VOICE_COMMUNICATION as our audio source. This source enables
13
+ * platform-managed AEC at the HAL level automatically. The explicit
14
+ * AcousticEchoCanceler effect here is belt-and-suspenders for devices
15
+ * where the platform does not apply it automatically.
16
16
  *
17
- * NS and AGC are therefore off by default to honor the spec. Enabling them
18
- * re-introduces the processing the CDD explicitly prohibits for this source
19
- * and can cause low-volume capture on many OEMs.
20
- *
21
- * AEC is the one effect the CDD permits for VOICE_RECOGNITION ("expects a
22
- * stream that has an echo cancellation effect if available"), so it is
23
- * enabled by default.
17
+ * NS and AGC remain opt-in VOICE_COMMUNICATION applies its own
18
+ * processing and additional effects can cause over-processing on some OEMs.
24
19
  */
25
20
  class AudioEffectsManager(
26
21
  /** Enable hardware noise suppressor. Default false — CDD 5.4 [C-1-2] prohibits it for VOICE_RECOGNITION. */
@@ -339,23 +339,29 @@ class AudioRecorderManager(
339
339
  consecutiveErrors++
340
340
  if (consecutiveErrors >= 10) {
341
341
  Log.e(Constants.TAG, "Too many consecutive read errors ($consecutiveErrors), stopping")
342
- emitRecordingError("READ_ERROR", "AudioRecord read failed after $consecutiveErrors consecutive errors")
342
+ emitRecordingError("READ_ERROR", "AudioRecord read failed after $consecutiveErrors consecutive errors", isFatal = true)
343
343
  break
344
344
  }
345
345
  }
346
346
  }
347
347
  } catch (e: Exception) {
348
348
  Log.e(Constants.TAG, "Recording thread crashed", e)
349
- emitRecordingError("RECORDING_CRASH", e.message ?: "Recording thread unexpected error")
349
+ emitRecordingError("RECORDING_CRASH", e.message ?: "Recording thread unexpected error", isFatal = true)
350
350
  }
351
351
  }
352
352
 
353
353
  /**
354
354
  * Sends a recording error event to JS so the caller can react.
355
355
  */
356
- private fun emitRecordingError(code: String, message: String) {
356
+ private fun emitRecordingError(
357
+ code: String,
358
+ message: String,
359
+ isFatal: Boolean,
360
+ autoResuming: Boolean = false
361
+ ) {
357
362
  mainHandler.post {
358
363
  try {
364
+ // Backward-compat: keep the error variant on AudioData for existing consumers
359
365
  eventSender.sendExpoEvent(
360
366
  Constants.AUDIO_EVENT_NAME, bundleOf(
361
367
  "error" to code,
@@ -363,6 +369,15 @@ class AudioRecorderManager(
363
369
  "streamUuid" to streamUuid
364
370
  )
365
371
  )
372
+ // Rich structured channel for new consumers
373
+ eventSender.sendExpoEvent(
374
+ Constants.MICROPHONE_ERROR_EVENT_NAME, bundleOf(
375
+ "code" to code,
376
+ "message" to message,
377
+ "isFatal" to isFatal,
378
+ "autoResuming" to autoResuming
379
+ )
380
+ )
366
381
  } catch (e: Exception) {
367
382
  Log.e(Constants.TAG, "Failed to send error event", e)
368
383
  }
@@ -479,9 +494,11 @@ class AudioRecorderManager(
479
494
  return null
480
495
  }
481
496
 
482
- // Use VOICE_RECOGNITION for far-field/speakerphone use higher mic gain,
483
- // no near-field gain reduction. AEC/NS/AGC are applied separately via AudioEffectsManager.
484
- val audioSource = MediaRecorder.AudioSource.VOICE_RECOGNITION
497
+ // VOICE_COMMUNICATION enables platform-managed AEC (echo cancellation happens at
498
+ // the HAL level, not just via the explicit AcousticEchoCanceler effect).
499
+ // VOICE_RECOGNITION intentionally bypasses platform AEC per the Android CDD,
500
+ // making hardware echo cancellation ineffective regardless of AudioEffectsManager.
501
+ val audioSource = MediaRecorder.AudioSource.VOICE_COMMUNICATION
485
502
 
486
503
  val record = AudioRecord(
487
504
  audioSource,
@@ -0,0 +1,155 @@
1
+ package expo.modules.audiostream
2
+
3
+ import android.app.Activity
4
+ import android.media.AudioDeviceInfo
5
+ import android.media.AudioManager
6
+ import android.os.Build
7
+ import android.util.Log
8
+ import java.util.concurrent.atomic.AtomicInteger
9
+
10
+ /**
11
+ * Owns Android communication-mode lifecycle and output device routing for
12
+ * hands-free voice sessions (microphone + speaker playback with AEC).
13
+ *
14
+ * Responsibilities:
15
+ * - Set MODE_IN_COMMUNICATION before AudioRecord starts so the HAL echo
16
+ * reference path is active when AcousticEchoCanceler initializes.
17
+ * - Route output to the best available device: Bluetooth HFP > wired headset
18
+ * > built-in speaker. Re-routes automatically when devices connect or
19
+ * disconnect.
20
+ * - Bind hardware volume buttons to STREAM_VOICE_CALL while a session is
21
+ * active so the user's volume buttons control playback volume.
22
+ * - Reset everything when all callers have stopped so the phone's audio
23
+ * state is clean.
24
+ *
25
+ * Reference-counted: mic and pipeline each call startSession/stopSession
26
+ * independently. Communication mode stays active until both have stopped.
27
+ *
28
+ * Usage:
29
+ * cam.startSession(activity) // call before AudioRecord.startRecording() or pipeline connect
30
+ * cam.onDeviceChanged() // call from AudioDeviceCallback
31
+ * cam.stopSession(activity) // call after AudioRecord.stop() or pipeline disconnect
32
+ */
33
+ class CommunicationAudioManager(private val audioManager: AudioManager) {
34
+
35
+ private val refCount = AtomicInteger(0)
36
+
37
+ private val sessionActive get() = refCount.get() > 0
38
+
39
+ /**
40
+ * Start (or join) a voice session. Safe to call from multiple owners —
41
+ * communication mode is activated on the first call.
42
+ * Pass the current Activity so volume buttons bind to STREAM_VOICE_CALL.
43
+ */
44
+ fun startSession(activity: Activity? = null) {
45
+ if (refCount.getAndIncrement() == 0) {
46
+ audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
47
+ activity?.volumeControlStream = AudioManager.STREAM_VOICE_CALL
48
+ Log.d(TAG, "Session started — MODE_IN_COMMUNICATION")
49
+ }
50
+ applyBestRoute()
51
+ }
52
+
53
+ /**
54
+ * Release this owner's hold on the session. Communication mode is reset
55
+ * only when all owners have called stopSession.
56
+ * No-op if the session was already ended via forceReset.
57
+ */
58
+ fun stopSession(activity: Activity? = null) {
59
+ // Decrement only if currently positive — prevents post-forceReset calls
60
+ // from re-triggering cleanup (getAndUpdate returns the previous value).
61
+ val prev = refCount.getAndUpdate { if (it > 0) it - 1 else 0 }
62
+ if (prev <= 0) {
63
+ Log.d(TAG, "stopSession — already stopped, no-op")
64
+ return
65
+ }
66
+ val remaining = prev - 1
67
+ if (remaining == 0) {
68
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
69
+ audioManager.clearCommunicationDevice()
70
+ } else {
71
+ @Suppress("DEPRECATION")
72
+ audioManager.stopBluetoothSco()
73
+ @Suppress("DEPRECATION")
74
+ audioManager.isSpeakerphoneOn = false
75
+ }
76
+ audioManager.mode = AudioManager.MODE_NORMAL
77
+ activity?.volumeControlStream = AudioManager.USE_DEFAULT_STREAM_TYPE
78
+ Log.d(TAG, "Session ended — audio mode reset to NORMAL")
79
+ } else {
80
+ Log.d(TAG, "stopSession — $remaining owner(s) still active")
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Re-evaluate and apply the best available output route.
86
+ * Call this from AudioDeviceCallback whenever devices are added or removed.
87
+ */
88
+ fun onDeviceChanged() {
89
+ applyBestRoute()
90
+ }
91
+
92
+ /**
93
+ * Unconditionally reset all communication audio state regardless of ref count.
94
+ * Use in OnDestroy and explicit destroy flows where normal lifecycle won't run.
95
+ */
96
+ fun forceReset(activity: Activity? = null) {
97
+ refCount.set(0)
98
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
99
+ audioManager.clearCommunicationDevice()
100
+ } else {
101
+ @Suppress("DEPRECATION")
102
+ audioManager.stopBluetoothSco()
103
+ @Suppress("DEPRECATION")
104
+ audioManager.isSpeakerphoneOn = false
105
+ }
106
+ audioManager.mode = AudioManager.MODE_NORMAL
107
+ activity?.volumeControlStream = AudioManager.USE_DEFAULT_STREAM_TYPE
108
+ Log.d(TAG, "forceReset — audio mode reset to NORMAL")
109
+ }
110
+
111
+ // ── Private ─────────────────────────────────────────────────────────────
112
+
113
+ private fun applyBestRoute() {
114
+ if (!sessionActive) return
115
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
116
+ applyBestRouteApi31()
117
+ } else {
118
+ applyBestRouteLegacy()
119
+ }
120
+ }
121
+
122
+ private fun applyBestRouteApi31() {
123
+ val devices = audioManager.availableCommunicationDevices
124
+ // Priority: BT HFP > wired headset > built-in speaker
125
+ val preferred = devices.firstOrNull { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO }
126
+ ?: devices.firstOrNull { it.type == AudioDeviceInfo.TYPE_WIRED_HEADSET }
127
+ ?: devices.firstOrNull { it.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER }
128
+ if (preferred != null) {
129
+ audioManager.setCommunicationDevice(preferred)
130
+ Log.d(TAG, "Route → ${preferred.productName} (type=${preferred.type})")
131
+ } else {
132
+ Log.w(TAG, "No suitable communication device found")
133
+ }
134
+ }
135
+
136
+ @Suppress("DEPRECATION")
137
+ private fun applyBestRouteLegacy() {
138
+ // Detect a connected BT HFP device via the full device list (API 23+).
139
+ val allDevices = audioManager.getDevices(AudioManager.GET_DEVICES_ALL)
140
+ val btSco = allDevices.firstOrNull { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO }
141
+ if (btSco != null) {
142
+ audioManager.startBluetoothSco()
143
+ audioManager.isSpeakerphoneOn = false
144
+ Log.d(TAG, "Route → Bluetooth SCO (${btSco.productName})")
145
+ } else {
146
+ audioManager.stopBluetoothSco()
147
+ audioManager.isSpeakerphoneOn = true
148
+ Log.d(TAG, "Route → built-in speaker")
149
+ }
150
+ }
151
+
152
+ companion object {
153
+ private const val TAG = "CommunicationAudioMgr"
154
+ }
155
+ }
@@ -2,6 +2,7 @@ package expo.modules.audiostream
2
2
 
3
3
  object Constants {
4
4
  const val AUDIO_EVENT_NAME = "AudioData"
5
+ const val MICROPHONE_ERROR_EVENT_NAME = "MicrophoneError"
5
6
  const val AUDIO_ANALYSIS_EVENT_NAME = "AudioAnalysis"
6
7
  const val DEVICE_RECONNECTED_EVENT_NAME = "DeviceReconnected"
7
8
  const val DEFAULT_SAMPLE_RATE = 16000 // Default sample rate for audio recording
@@ -26,6 +26,7 @@ class ExpoPlayAudioStreamModule : Module(), EventSender {
26
26
  private lateinit var audioRecorderManager: AudioRecorderManager
27
27
  private lateinit var audioManager: AudioManager
28
28
  private lateinit var pipelineIntegration: PipelineIntegration
29
+ private lateinit var communicationAudioManager: CommunicationAudioManager
29
30
 
30
31
  // Ensure callbacks are delivered on the main thread
31
32
  private val mainHandler by lazy { Handler(Looper.getMainLooper()) }
@@ -62,6 +63,7 @@ class ExpoPlayAudioStreamModule : Module(), EventSender {
62
63
  if (firstOfGroup?.isNotEmpty()==true) {
63
64
  val matched = firstOfGroup.map { "${it.productName} (type=${it.type})" }
64
65
  Log.d("ExpoAudioCallback", "AudioDeviceCallback ➜ ADDED (interesting): $matched")
66
+ communicationAudioManager.onDeviceChanged()
65
67
  pipelineIntegration.logAudioTrackHealth("device_added")
66
68
  val params = Bundle()
67
69
  params.putString("reason", "newDeviceAvailable")
@@ -79,6 +81,7 @@ class ExpoPlayAudioStreamModule : Module(), EventSender {
79
81
  if (lastOfGroup?.isNotEmpty() == true) {
80
82
  val matched = lastOfGroup.map { "${it.productName} (type=${it.type})" }
81
83
  Log.d("ExpoAudioCallback", "AudioDeviceCallback ➜ REMOVED (interesting): $matched")
84
+ communicationAudioManager.onDeviceChanged()
82
85
  pipelineIntegration.logAudioTrackHealth("device_removed")
83
86
  val params = Bundle()
84
87
  params.putString("reason", "oldDeviceUnavailable")
@@ -94,6 +97,7 @@ class ExpoPlayAudioStreamModule : Module(), EventSender {
94
97
 
95
98
  Events(
96
99
  Constants.AUDIO_EVENT_NAME,
100
+ Constants.MICROPHONE_ERROR_EVENT_NAME,
97
101
  Constants.DEVICE_RECONNECTED_EVENT_NAME,
98
102
  PipelineIntegration.EVENT_STATE_CHANGED,
99
103
  PipelineIntegration.EVENT_PLAYBACK_STARTED,
@@ -101,6 +105,7 @@ class ExpoPlayAudioStreamModule : Module(), EventSender {
101
105
  PipelineIntegration.EVENT_ZOMBIE_DETECTED,
102
106
  PipelineIntegration.EVENT_UNDERRUN,
103
107
  PipelineIntegration.EVENT_DRAINED,
108
+ PipelineIntegration.EVENT_PLAYBACK_STOPPED,
104
109
  PipelineIntegration.EVENT_AUDIO_FOCUS_LOST,
105
110
  PipelineIntegration.EVENT_AUDIO_FOCUS_RESUMED,
106
111
  PipelineIntegration.EVENT_FREQUENCY_BANDS
@@ -112,26 +117,27 @@ class ExpoPlayAudioStreamModule : Module(), EventSender {
112
117
 
113
118
  OnCreate {
114
119
  audioManager = appContext.reactContext?.getSystemService(Context.AUDIO_SERVICE) as AudioManager
120
+ communicationAudioManager = CommunicationAudioManager(audioManager)
115
121
  audioManager.registerAudioDeviceCallback(audioCallCallback, mainHandler)
116
122
  }
117
123
 
118
124
  OnDestroy {
119
125
  reportedGroups.clear()
120
126
  audioManager.unregisterAudioDeviceCallback(audioCallCallback)
121
- // Module is being destroyed (app shutdown)
122
- // Just clean up resources without reinitialization
123
127
  pipelineIntegration.destroy()
124
128
  audioRecorderManager.release()
129
+ communicationAudioManager.forceReset(appContext.currentActivity)
125
130
  }
126
131
 
127
132
  AsyncFunction("destroy") { promise: Promise ->
128
- // User explicitly called destroy - clean up and reinitialize for reuse
129
133
  pipelineIntegration.destroy()
130
134
  audioRecorderManager.release()
135
+ communicationAudioManager.forceReset(appContext.currentActivity)
131
136
 
132
137
  // Reinitialize all managers so the module can be used again
133
138
  initializeManager()
134
139
  initializePipeline()
140
+ communicationAudioManager = CommunicationAudioManager(audioManager)
135
141
  promise.resolve(null)
136
142
  }
137
143
 
@@ -152,11 +158,13 @@ class ExpoPlayAudioStreamModule : Module(), EventSender {
152
158
  }
153
159
 
154
160
  AsyncFunction("startMicrophone") { options: Map<String, Any?>, promise: Promise ->
161
+ communicationAudioManager.startSession(appContext.currentActivity)
155
162
  audioRecorderManager.startRecording(options, promise)
156
163
  }
157
164
 
158
165
  AsyncFunction("stopMicrophone") { promise: Promise ->
159
166
  audioRecorderManager.stopRecording(promise)
167
+ communicationAudioManager.stopSession(appContext.currentActivity)
160
168
  }
161
169
 
162
170
  Function("toggleSilence") { isSilent: Boolean ->
@@ -167,6 +175,7 @@ class ExpoPlayAudioStreamModule : Module(), EventSender {
167
175
  // ── Native Audio Pipeline V3 ────────────────────────────────────
168
176
 
169
177
  AsyncFunction("connectPipeline") { options: Map<String, Any?>, promise: Promise ->
178
+ communicationAudioManager.startSession(appContext.currentActivity)
170
179
  pipelineIntegration.connect(options, promise)
171
180
  }
172
181
 
@@ -180,6 +189,7 @@ class ExpoPlayAudioStreamModule : Module(), EventSender {
180
189
 
181
190
  AsyncFunction("disconnectPipeline") { promise: Promise ->
182
191
  pipelineIntegration.disconnect(promise)
192
+ communicationAudioManager.stopSession(appContext.currentActivity)
183
193
  }
184
194
 
185
195
  AsyncFunction("invalidatePipelineTurn") { options: Map<String, Any?>, promise: Promise ->
@@ -194,6 +204,10 @@ class ExpoPlayAudioStreamModule : Module(), EventSender {
194
204
  pipelineIntegration.getState()
195
205
  }
196
206
 
207
+ Function("getPipelineOutputLatencyMs") {
208
+ pipelineIntegration.outputLatencyMs()
209
+ }
210
+
197
211
  }
198
212
  private fun initializeManager() {
199
213
  val androidContext =