@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.
- package/NATIVE_EVENTS.md +97 -6
- package/android/.gradle/8.9/checksums/checksums.lock +0 -0
- package/android/.gradle/8.9/dependencies-accessors/gc.properties +0 -0
- package/android/.gradle/8.9/fileChanges/last-build.bin +0 -0
- package/android/.gradle/8.9/fileHashes/fileHashes.lock +0 -0
- package/android/.gradle/8.9/gc.properties +0 -0
- package/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
- package/android/.gradle/buildOutputCleanup/cache.properties +2 -0
- package/android/.gradle/vcs-1/gc.properties +0 -0
- package/android/src/main/java/expo/modules/audiostream/AudioEffectsManager.kt +6 -11
- package/android/src/main/java/expo/modules/audiostream/AudioRecorderManager.kt +23 -6
- package/android/src/main/java/expo/modules/audiostream/CommunicationAudioManager.kt +155 -0
- package/android/src/main/java/expo/modules/audiostream/Constants.kt +1 -0
- package/android/src/main/java/expo/modules/audiostream/ExpoPlayAudioStreamModule.kt +17 -3
- package/android/src/main/java/expo/modules/audiostream/pipeline/AudioPipeline.kt +104 -11
- package/android/src/main/java/expo/modules/audiostream/pipeline/PipelineIntegration.kt +14 -0
- package/build/events.d.ts +26 -0
- package/build/events.d.ts.map +1 -1
- package/build/events.js +18 -0
- package/build/events.js.map +1 -1
- package/build/index.d.ts +3 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +19 -7
- package/build/index.js.map +1 -1
- package/build/pipeline/index.d.ts +14 -1
- package/build/pipeline/index.d.ts.map +1 -1
- package/build/pipeline/index.js +15 -0
- package/build/pipeline/index.js.map +1 -1
- package/build/pipeline/types.d.ts +14 -0
- package/build/pipeline/types.d.ts.map +1 -1
- package/build/pipeline/types.js.map +1 -1
- package/build/types.d.ts +21 -0
- package/build/types.d.ts.map +1 -1
- package/build/types.js.map +1 -1
- package/ios/AudioPipeline.swift +67 -2
- package/ios/ExpoPlayAudioStreamModule.swift +43 -12
- package/ios/Microphone.swift +167 -120
- package/ios/MicrophoneDataDelegate.swift +10 -1
- package/ios/PipelineIntegration.swift +11 -1
- package/ios/SharedAudioEngine.swift +18 -0
- package/package.json +1 -2
- package/plugin/build/index.js +5 -0
- package/plugin/src/index.ts +5 -0
- package/src/events.ts +32 -0
- package/src/index.ts +27 -18
- package/src/pipeline/index.ts +17 -0
- package/src/pipeline/types.ts +15 -0
- 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.
|
|
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
|
|
34
|
+
| `error` | `string` | Error code — see `MicrophoneError` section for full table |
|
|
33
35
|
| `errorMessage` | `string` | Human-readable description |
|
|
34
|
-
| `streamUuid` | `string` | Stream
|
|
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
|
-
-
|
|
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
|
|
Binary file
|
|
File without changes
|
|
Binary file
|
|
Binary file
|
|
File without changes
|
|
Binary file
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
18
|
-
*
|
|
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(
|
|
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
|
-
//
|
|
483
|
-
//
|
|
484
|
-
|
|
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 =
|