@edkimmel/expo-audio-stream 0.2.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/.eslintrc.js +5 -0
- package/.yarnrc.yml +8 -0
- package/NATIVE_EVENTS.md +270 -0
- package/README.md +289 -0
- package/android/build.gradle +92 -0
- package/android/src/main/AndroidManifest.xml +4 -0
- package/android/src/main/java/expo/modules/audiostream/AudioDataEncoder.kt +178 -0
- package/android/src/main/java/expo/modules/audiostream/AudioEffectsManager.kt +107 -0
- package/android/src/main/java/expo/modules/audiostream/AudioPlaybackManager.kt +651 -0
- package/android/src/main/java/expo/modules/audiostream/AudioRecorderManager.kt +509 -0
- package/android/src/main/java/expo/modules/audiostream/Constants.kt +21 -0
- package/android/src/main/java/expo/modules/audiostream/EventSender.kt +7 -0
- package/android/src/main/java/expo/modules/audiostream/ExpoAudioStreamView.kt +7 -0
- package/android/src/main/java/expo/modules/audiostream/ExpoPlayAudioStreamModule.kt +280 -0
- package/android/src/main/java/expo/modules/audiostream/PermissionUtils.kt +16 -0
- package/android/src/main/java/expo/modules/audiostream/RecordingConfig.kt +60 -0
- package/android/src/main/java/expo/modules/audiostream/SoundConfig.kt +46 -0
- package/android/src/main/java/expo/modules/audiostream/pipeline/AudioPipeline.kt +685 -0
- package/android/src/main/java/expo/modules/audiostream/pipeline/JitterBuffer.kt +227 -0
- package/android/src/main/java/expo/modules/audiostream/pipeline/PipelineIntegration.kt +315 -0
- package/app.plugin.js +1 -0
- package/build/ExpoPlayAudioStreamModule.d.ts +3 -0
- package/build/ExpoPlayAudioStreamModule.d.ts.map +1 -0
- package/build/ExpoPlayAudioStreamModule.js +5 -0
- package/build/ExpoPlayAudioStreamModule.js.map +1 -0
- package/build/events.d.ts +36 -0
- package/build/events.d.ts.map +1 -0
- package/build/events.js +25 -0
- package/build/events.js.map +1 -0
- package/build/index.d.ts +125 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +222 -0
- package/build/index.js.map +1 -0
- package/build/pipeline/index.d.ts +81 -0
- package/build/pipeline/index.d.ts.map +1 -0
- package/build/pipeline/index.js +140 -0
- package/build/pipeline/index.js.map +1 -0
- package/build/pipeline/types.d.ts +132 -0
- package/build/pipeline/types.d.ts.map +1 -0
- package/build/pipeline/types.js +5 -0
- package/build/pipeline/types.js.map +1 -0
- package/build/types.d.ts +221 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +10 -0
- package/build/types.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/AudioPipeline.swift +562 -0
- package/ios/AudioUtils.swift +356 -0
- package/ios/ExpoPlayAudioStream.podspec +27 -0
- package/ios/ExpoPlayAudioStreamModule.swift +436 -0
- package/ios/ExpoPlayAudioStreamView.swift +7 -0
- package/ios/JitterBuffer.swift +208 -0
- package/ios/Logger.swift +7 -0
- package/ios/Microphone.swift +221 -0
- package/ios/MicrophoneDataDelegate.swift +4 -0
- package/ios/PipelineIntegration.swift +214 -0
- package/ios/RecordingResult.swift +10 -0
- package/ios/RecordingSettings.swift +11 -0
- package/ios/SharedAudioEngine.swift +484 -0
- package/ios/SoundConfig.swift +45 -0
- package/ios/SoundPlayer.swift +408 -0
- package/ios/SoundPlayerDelegate.swift +7 -0
- package/package.json +49 -0
- package/plugin/build/index.d.ts +5 -0
- package/plugin/build/index.js +28 -0
- package/plugin/src/index.ts +53 -0
- package/plugin/tsconfig.json +9 -0
- package/plugin/tsconfig.tsbuildinfo +1 -0
- package/src/ExpoPlayAudioStreamModule.ts +5 -0
- package/src/events.ts +66 -0
- package/src/index.ts +359 -0
- package/src/pipeline/index.ts +216 -0
- package/src/pipeline/types.ts +169 -0
- package/src/types.ts +270 -0
- package/tsconfig.json +9 -0
package/.eslintrc.js
ADDED
package/.yarnrc.yml
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
yarnPath: .yarn/releases/yarn-4.1.1.cjs
|
|
2
|
+
nodeLinker: node-modules
|
|
3
|
+
nmMode: hardlinks-local
|
|
4
|
+
|
|
5
|
+
npmScopes:
|
|
6
|
+
edkimmel:
|
|
7
|
+
npmPublishRegistry: ${NPM_PUBLISH_REGISTRY:-https://npm.pkg.github.com}
|
|
8
|
+
npmAuthToken: ${NODE_AUTH_TOKEN:-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}
|
package/NATIVE_EVENTS.md
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
# Native-to-JS Events Reference
|
|
2
|
+
|
|
3
|
+
Complete catalog of events emitted from native code (Android/iOS) to JavaScript,
|
|
4
|
+
including payload shapes, trigger conditions, and recommended JS responses.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Recording Events
|
|
9
|
+
|
|
10
|
+
### `AudioData`
|
|
11
|
+
|
|
12
|
+
Emitted at the configured interval during microphone recording. Also doubles as
|
|
13
|
+
the recording error channel.
|
|
14
|
+
|
|
15
|
+
| Field | Type | Notes |
|
|
16
|
+
|---|---|---|
|
|
17
|
+
| `encoded` | `string` | Base64-encoded PCM audio |
|
|
18
|
+
| `deltaSize` | `number` | Bytes in this chunk |
|
|
19
|
+
| `position` | `number` | Position in ms from start of recording |
|
|
20
|
+
| `totalSize` | `number` | Cumulative bytes recorded |
|
|
21
|
+
| `soundLevel` | `number?` | Power level in dB (-160 when silent) |
|
|
22
|
+
| `streamUuid` | `string` | Unique ID for this recording stream |
|
|
23
|
+
| `fileUri` | `string` | Always `""` (file I/O removed) |
|
|
24
|
+
| `lastEmittedSize` | `number` | Previous `totalSize` value |
|
|
25
|
+
| `mimeType` | `string` | e.g. `"audio/wav"` |
|
|
26
|
+
|
|
27
|
+
**Error variant** (same event name, different shape):
|
|
28
|
+
|
|
29
|
+
| Field | Type | Notes |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| `error` | `string` | Error code: `READ_ERROR`, `RECORDING_CRASH` |
|
|
32
|
+
| `errorMessage` | `string` | Human-readable description |
|
|
33
|
+
| `streamUuid` | `string` | Stream that errored |
|
|
34
|
+
|
|
35
|
+
**Platform:** Android, iOS
|
|
36
|
+
|
|
37
|
+
**JS response:**
|
|
38
|
+
- Forward the base64 PCM to your STT pipeline or WebSocket.
|
|
39
|
+
- Use `soundLevel` for VAD or UI visualisation.
|
|
40
|
+
- Check for the `error` field before assuming the payload is audio data.
|
|
41
|
+
On error, stop the conversation turn or retry.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
### `DeviceReconnected`
|
|
46
|
+
|
|
47
|
+
Fired when an audio device is connected or disconnected (Bluetooth SCO/A2DP,
|
|
48
|
+
wired headset/headphones, USB headset).
|
|
49
|
+
|
|
50
|
+
| Field | Type | Notes |
|
|
51
|
+
|---|---|---|
|
|
52
|
+
| `reason` | `string` | `"newDeviceAvailable"` \| `"oldDeviceUnavailable"` \| `"unknown"` |
|
|
53
|
+
|
|
54
|
+
**Platform:** Android, iOS
|
|
55
|
+
|
|
56
|
+
**JS response:**
|
|
57
|
+
- `oldDeviceUnavailable`: headset/BT just disconnected. Decide whether to stop
|
|
58
|
+
recording, switch to speaker, or show a UI prompt.
|
|
59
|
+
- `newDeviceAvailable`: a device was plugged in. May want to re-route audio or
|
|
60
|
+
update UI to reflect the new output device.
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Legacy Playback Events (AudioPlaybackManager)
|
|
65
|
+
|
|
66
|
+
### `SoundStarted`
|
|
67
|
+
|
|
68
|
+
Fired once per turn when the first audio chunk begins playback.
|
|
69
|
+
|
|
70
|
+
| Field | Type | Notes |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| *(empty payload)* | | |
|
|
73
|
+
|
|
74
|
+
**Platform:** Android, iOS
|
|
75
|
+
|
|
76
|
+
**JS response:**
|
|
77
|
+
- Show a "speaking" indicator in the UI.
|
|
78
|
+
- If half-duplex, mute the microphone.
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
### `SoundChunkPlayed`
|
|
83
|
+
|
|
84
|
+
Fired after each audio chunk finishes playback.
|
|
85
|
+
|
|
86
|
+
| Field | Type | Notes |
|
|
87
|
+
|---|---|---|
|
|
88
|
+
| `isFinal` | `boolean` | `true` if this was the last chunk in the sequence |
|
|
89
|
+
|
|
90
|
+
**Platform:** Android, iOS
|
|
91
|
+
|
|
92
|
+
**JS response:**
|
|
93
|
+
- `isFinal === false`: progress notification. Use for UI or ignore.
|
|
94
|
+
- `isFinal === true`: the turn is done playing. Resume mic recording, send next
|
|
95
|
+
turn, update UI to "listening".
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## Pipeline Events (Android only)
|
|
100
|
+
|
|
101
|
+
These events are emitted by `AudioPipeline` via `PipelineIntegration`. They are
|
|
102
|
+
not available on iOS, which still uses the legacy `SoundStarted`/`SoundChunkPlayed`
|
|
103
|
+
path.
|
|
104
|
+
|
|
105
|
+
### `PipelineStateChanged`
|
|
106
|
+
|
|
107
|
+
Fired on every pipeline state transition.
|
|
108
|
+
|
|
109
|
+
| Field | Type | Notes |
|
|
110
|
+
|---|---|---|
|
|
111
|
+
| `state` | `string` | `"idle"` \| `"connecting"` \| `"streaming"` \| `"draining"` \| `"error"` |
|
|
112
|
+
|
|
113
|
+
**JS response:**
|
|
114
|
+
- Drive your UI state machine. `streaming` = show playback indicator.
|
|
115
|
+
`idle` = ready for next turn. `error` = surface to user or trigger reconnect.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
### `PipelinePlaybackStarted`
|
|
120
|
+
|
|
121
|
+
Fired once per turn when the jitter buffer has primed and real audio is hitting
|
|
122
|
+
the speaker.
|
|
123
|
+
|
|
124
|
+
| Field | Type | Notes |
|
|
125
|
+
|---|---|---|
|
|
126
|
+
| `turnId` | `string` | Conversation turn identifier |
|
|
127
|
+
|
|
128
|
+
**JS response:**
|
|
129
|
+
- This is the "time-to-first-audio" measurement point.
|
|
130
|
+
- Update UI to "speaking".
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
### `PipelineError`
|
|
135
|
+
|
|
136
|
+
Fired on any pipeline error condition.
|
|
137
|
+
|
|
138
|
+
| Field | Type | Notes |
|
|
139
|
+
|---|---|---|
|
|
140
|
+
| `code` | `string` | See table below |
|
|
141
|
+
| `message` | `string` | Human-readable description |
|
|
142
|
+
|
|
143
|
+
| Code | Meaning | Recommended action |
|
|
144
|
+
|---|---|---|
|
|
145
|
+
| `CONNECT_FAILED` | AudioTrack or JitterBuffer creation failed | Retry `pipelineConnect()` |
|
|
146
|
+
| `DECODE_ERROR` | Base64 decode failed on incoming chunk | Bad data from server -- log and drop the chunk |
|
|
147
|
+
| `WRITE_ERROR` | `AudioTrack.write()` returned an error code | `pipelineDisconnect()` then `pipelineConnect()` |
|
|
148
|
+
| `NOT_CONNECTED` | `pushAudio` called before `connect` | Ensure connection is established before pushing |
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
### `PipelineZombieDetected`
|
|
153
|
+
|
|
154
|
+
Fired when the AudioTrack's `playbackHeadPosition` has not moved for 5+ seconds
|
|
155
|
+
during `streaming` or `draining` state.
|
|
156
|
+
|
|
157
|
+
| Field | Type | Notes |
|
|
158
|
+
|---|---|---|
|
|
159
|
+
| `playbackHead` | `number` | Last known playback head position |
|
|
160
|
+
| `stalledMs` | `number` | Milliseconds since head last moved |
|
|
161
|
+
|
|
162
|
+
**JS response:**
|
|
163
|
+
- The AudioTrack is stuck. Tear down and reconnect:
|
|
164
|
+
`pipelineDisconnect()` then `pipelineConnect()`.
|
|
165
|
+
- This is a device-level issue (some OEMs, Bluetooth routing glitches).
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
### `PipelineUnderrun`
|
|
170
|
+
|
|
171
|
+
Fired when the jitter buffer runs dry during playback. Debounced: only fires
|
|
172
|
+
when the cumulative count increases, not on every silence frame.
|
|
173
|
+
|
|
174
|
+
| Field | Type | Notes |
|
|
175
|
+
|---|---|---|
|
|
176
|
+
| `count` | `number` | Total underrun count for this turn |
|
|
177
|
+
|
|
178
|
+
**JS response:**
|
|
179
|
+
- A single underrun during initial priming is normal.
|
|
180
|
+
- Repeated underruns during `streaming` mean the server or JS bridge is not
|
|
181
|
+
delivering audio fast enough. Log for diagnostics. If `count` climbs quickly,
|
|
182
|
+
consider increasing `targetBufferMs`.
|
|
183
|
+
- No immediate corrective action is typically needed.
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
### `PipelineDrained`
|
|
188
|
+
|
|
189
|
+
Fired when the pipeline has played all buffered audio after `markEndOfStream`.
|
|
190
|
+
The pipeline transitions from `draining` to `idle`.
|
|
191
|
+
|
|
192
|
+
| Field | Type | Notes |
|
|
193
|
+
|---|---|---|
|
|
194
|
+
| `turnId` | `string` | The turn that just finished playing |
|
|
195
|
+
|
|
196
|
+
**JS response:**
|
|
197
|
+
- This is your "turn complete" signal. Resume mic recording, send next request,
|
|
198
|
+
update UI to "listening".
|
|
199
|
+
- Equivalent to `SoundChunkPlayed` with `isFinal=true` but for the pipeline path.
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
### `PipelineAudioFocusLost`
|
|
204
|
+
|
|
205
|
+
Fired when another app takes audio focus (phone call, navigation, music).
|
|
206
|
+
The pipeline continues writing silence to keep the AudioTrack alive.
|
|
207
|
+
|
|
208
|
+
| Field | Type | Notes |
|
|
209
|
+
|---|---|---|
|
|
210
|
+
| *(empty payload)* | | |
|
|
211
|
+
|
|
212
|
+
**JS response:**
|
|
213
|
+
- Decide whether to pause the conversation, mute the mic via `toggleSilence(isSilent)`,
|
|
214
|
+
or show a "paused" UI.
|
|
215
|
+
- The pipeline will resume playing real audio automatically when focus returns.
|
|
216
|
+
|
|
217
|
+
---
|
|
218
|
+
|
|
219
|
+
### `PipelineAudioFocusResumed`
|
|
220
|
+
|
|
221
|
+
Fired when audio focus is regained (`AUDIOFOCUS_GAIN`).
|
|
222
|
+
|
|
223
|
+
| Field | Type | Notes |
|
|
224
|
+
|---|---|---|
|
|
225
|
+
| *(empty payload)* | | |
|
|
226
|
+
|
|
227
|
+
**JS response:**
|
|
228
|
+
- If you paused the conversation or muted the mic on focus loss, undo that now.
|
|
229
|
+
- Audio that arrived during the focus-loss window was still buffered in the
|
|
230
|
+
jitter buffer (up to its capacity) and will play out normally.
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
## System Event Handling Gaps
|
|
235
|
+
|
|
236
|
+
The following system events are **not currently handled** by native code. JS
|
|
237
|
+
cannot respond to them because no event is emitted.
|
|
238
|
+
|
|
239
|
+
| Gap | Impact | Notes |
|
|
240
|
+
|---|---|---|
|
|
241
|
+
| Phone call interruptions | No `TelephonyManager` / `READ_PHONE_STATE` listener on Android. Partially covered by audio focus loss on the pipeline side; recording side is unprotected. | On iOS, phone calls trigger `interruptionNotification`, but the `.began` handler is a no-op. |
|
|
242
|
+
| App lifecycle (background/foreground) | No `OnPause`/`OnResume`/`OnStop` handling on Android. No `willResignActive`/`didBecomeActive` on iOS. | Recording continues in background until the system kills it. No foreground service. |
|
|
243
|
+
| `ACTION_AUDIO_BECOMING_NOISY` | Headphones unplugged mid-playback could route audio to the speaker unexpectedly. | Android only. Not registered. |
|
|
244
|
+
| Runtime permission revocation | If mic permission is revoked while recording, `AudioRecord.read()` returns errors. Detected by consecutive error counting but no proactive event. | Both platforms. |
|
|
245
|
+
| Media button events | No `MediaSession` or remote command center integration. | Both platforms. |
|
|
246
|
+
| Do Not Disturb | No detection or adaptation. | Both platforms. Low priority. |
|
|
247
|
+
|
|
248
|
+
---
|
|
249
|
+
|
|
250
|
+
## TypeScript Types
|
|
251
|
+
|
|
252
|
+
All event payload types are defined in `src/events.ts` and
|
|
253
|
+
`src/pipeline/types.ts`. Pipeline events can be subscribed to via:
|
|
254
|
+
|
|
255
|
+
```typescript
|
|
256
|
+
import { Pipeline } from "expo-audio-stream";
|
|
257
|
+
|
|
258
|
+
Pipeline.subscribe("PipelineDrained", (event) => {
|
|
259
|
+
console.log("Turn complete:", event.turnId);
|
|
260
|
+
});
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Recording and legacy playback events use the helpers in `src/events.ts`:
|
|
264
|
+
|
|
265
|
+
```typescript
|
|
266
|
+
import { addAudioEventListener, addSoundChunkPlayedListener } from "expo-audio-stream";
|
|
267
|
+
|
|
268
|
+
addAudioEventListener(async (event) => { /* ... */ });
|
|
269
|
+
addSoundChunkPlayedListener(async (event) => { /* ... */ });
|
|
270
|
+
```
|
package/README.md
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
# @edkimmel/expo-audio-stream
|
|
2
|
+
|
|
3
|
+
Native audio recording and low-latency playback for Expo/React Native. Designed for real-time voice AI applications: microphone capture, chunked PCM playback, and a jitter-buffered native pipeline for streaming audio from AI backends.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx expo install @edkimmel/expo-audio-stream
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
### Microphone Recording
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
import { ExpoPlayAudioStream } from "@edkimmel/expo-audio-stream";
|
|
17
|
+
|
|
18
|
+
const { recordingResult, subscription } =
|
|
19
|
+
await ExpoPlayAudioStream.startMicrophone({
|
|
20
|
+
sampleRate: 16000,
|
|
21
|
+
channels: 1,
|
|
22
|
+
encoding: "pcm_16bit",
|
|
23
|
+
interval: 100,
|
|
24
|
+
onAudioStream: async (event) => {
|
|
25
|
+
// event.data: base64-encoded PCM chunk
|
|
26
|
+
// event.soundLevel: current mic level (dB)
|
|
27
|
+
sendToBackend(event.data);
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Later:
|
|
32
|
+
await ExpoPlayAudioStream.stopMicrophone();
|
|
33
|
+
subscription?.remove();
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Chunked Playback (playSound)
|
|
37
|
+
|
|
38
|
+
For playing base64-encoded PCM audio in a queue with turn management:
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
import {
|
|
42
|
+
ExpoPlayAudioStream,
|
|
43
|
+
EncodingTypes,
|
|
44
|
+
} from "@edkimmel/expo-audio-stream";
|
|
45
|
+
|
|
46
|
+
await ExpoPlayAudioStream.setSoundConfig({
|
|
47
|
+
sampleRate: 24000,
|
|
48
|
+
playbackMode: "conversation",
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Enqueue chunks as they arrive
|
|
52
|
+
await ExpoPlayAudioStream.playSound(
|
|
53
|
+
base64Chunk,
|
|
54
|
+
"turn-1",
|
|
55
|
+
EncodingTypes.PCM_S16LE
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
// Listen for playback completion
|
|
59
|
+
const sub = ExpoPlayAudioStream.subscribeToSoundChunkPlayed(async (e) => {
|
|
60
|
+
if (e.isFinal) console.log("Turn finished playing");
|
|
61
|
+
});
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Native Pipeline (recommended for AI voice streaming)
|
|
65
|
+
|
|
66
|
+
The `Pipeline` class provides jitter-buffered, low-latency playback with a native write thread. Use this for streaming audio from AI backends over WebSockets.
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
import { Pipeline } from "@edkimmel/expo-audio-stream";
|
|
70
|
+
|
|
71
|
+
// Connect with desired config
|
|
72
|
+
const result = await Pipeline.connect({
|
|
73
|
+
sampleRate: 24000,
|
|
74
|
+
channelCount: 1,
|
|
75
|
+
targetBufferMs: 80,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Subscribe to events
|
|
79
|
+
const errorSub = Pipeline.onError((err) => {
|
|
80
|
+
console.error(`Pipeline error: ${err.code} - ${err.message}`);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const focusSub = Pipeline.onAudioFocus(({ focused }) => {
|
|
84
|
+
if (!focused) {
|
|
85
|
+
// Another app took audio focus; re-request audio on regain
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Hot path: push audio synchronously from WebSocket handler
|
|
90
|
+
ws.onmessage = (msg) => {
|
|
91
|
+
Pipeline.pushAudioSync({
|
|
92
|
+
audio: msg.data, // base64 PCM16 LE
|
|
93
|
+
turnId: currentTurnId,
|
|
94
|
+
isFirstChunk: isFirst,
|
|
95
|
+
isLastChunk: isLast,
|
|
96
|
+
});
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// On new turn, invalidate stale audio
|
|
100
|
+
Pipeline.invalidateTurn({ turnId: newTurnId });
|
|
101
|
+
|
|
102
|
+
// Tear down
|
|
103
|
+
await Pipeline.disconnect();
|
|
104
|
+
errorSub.remove();
|
|
105
|
+
focusSub.remove();
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## API Reference
|
|
109
|
+
|
|
110
|
+
### ExpoPlayAudioStream
|
|
111
|
+
|
|
112
|
+
All methods are static.
|
|
113
|
+
|
|
114
|
+
#### Lifecycle
|
|
115
|
+
|
|
116
|
+
| Method | Returns | Description |
|
|
117
|
+
|--------|---------|-------------|
|
|
118
|
+
| `destroy()` | `void` | Release all resources. Resets internal state on both platforms. |
|
|
119
|
+
|
|
120
|
+
#### Permissions
|
|
121
|
+
|
|
122
|
+
| Method | Returns | Description |
|
|
123
|
+
|--------|---------|-------------|
|
|
124
|
+
| `requestPermissionsAsync()` | `Promise<PermissionResult>` | Prompt the user for microphone permission. |
|
|
125
|
+
| `getPermissionsAsync()` | `Promise<PermissionResult>` | Check the current microphone permission status. |
|
|
126
|
+
|
|
127
|
+
#### Microphone
|
|
128
|
+
|
|
129
|
+
| Method | Returns | Description |
|
|
130
|
+
|--------|---------|-------------|
|
|
131
|
+
| `startMicrophone(config)` | `Promise<{ recordingResult, subscription? }>` | Start mic capture. Audio is delivered as base64 PCM via `onAudioStream` or `subscribeToAudioEvents`. |
|
|
132
|
+
| `stopMicrophone()` | `Promise<AudioRecording \| null>` | Stop mic capture and return recording metadata. |
|
|
133
|
+
| `toggleSilence(isSilent)` | `void` | Mute/unmute the mic stream without stopping the session. Silenced frames are zero-filled. |
|
|
134
|
+
| `promptMicrophoneModes()` | `void` | (iOS only) Show the system voice isolation picker (iOS 15+). |
|
|
135
|
+
|
|
136
|
+
#### Sound Playback
|
|
137
|
+
|
|
138
|
+
| Method | Returns | Description |
|
|
139
|
+
|--------|---------|-------------|
|
|
140
|
+
| `playSound(audio, turnId, encoding?)` | `Promise<void>` | Enqueue a base64 PCM chunk for playback. |
|
|
141
|
+
| `stopSound()` | `Promise<void>` | Stop playback and clear the queue. |
|
|
142
|
+
| `setSoundConfig(config)` | `Promise<void>` | Update playback sample rate and mode. |
|
|
143
|
+
|
|
144
|
+
#### Event Subscriptions
|
|
145
|
+
|
|
146
|
+
| Method | Returns | Description |
|
|
147
|
+
|--------|---------|-------------|
|
|
148
|
+
| `subscribeToAudioEvents(callback)` | `Subscription` | Receive `AudioDataEvent` during mic capture. |
|
|
149
|
+
| `subscribeToSoundChunkPlayed(callback)` | `Subscription` | Notified when a chunk finishes playing. `isFinal` is true when the queue drains. |
|
|
150
|
+
| `subscribe(eventName, callback)` | `Subscription` | Generic event listener for any module event. |
|
|
151
|
+
|
|
152
|
+
### Pipeline
|
|
153
|
+
|
|
154
|
+
All methods are static. The pipeline manages its own native write thread, jitter buffer, and audio focus.
|
|
155
|
+
|
|
156
|
+
#### Lifecycle
|
|
157
|
+
|
|
158
|
+
| Method | Returns | Description |
|
|
159
|
+
|--------|---------|-------------|
|
|
160
|
+
| `connect(options?)` | `Promise<ConnectPipelineResult>` | Create the native audio track, jitter buffer, and write thread. Config is immutable per session. |
|
|
161
|
+
| `disconnect()` | `Promise<void>` | Tear down the pipeline and release all native resources. |
|
|
162
|
+
|
|
163
|
+
#### Audio Push
|
|
164
|
+
|
|
165
|
+
| Method | Returns | Description |
|
|
166
|
+
|--------|---------|-------------|
|
|
167
|
+
| `pushAudio(options)` | `Promise<void>` | Push base64 PCM16 LE audio (async, with error propagation). |
|
|
168
|
+
| `pushAudioSync(options)` | `boolean` | Push audio synchronously. No Promise overhead -- use in WebSocket `onmessage` for minimum latency. Returns `false` on failure. |
|
|
169
|
+
|
|
170
|
+
#### Turn Management
|
|
171
|
+
|
|
172
|
+
| Method | Returns | Description |
|
|
173
|
+
|--------|---------|-------------|
|
|
174
|
+
| `invalidateTurn(options)` | `Promise<void>` | Discard buffered audio for the old turn. The jitter buffer is reset. |
|
|
175
|
+
|
|
176
|
+
#### State & Telemetry
|
|
177
|
+
|
|
178
|
+
| Method | Returns | Description |
|
|
179
|
+
|--------|---------|-------------|
|
|
180
|
+
| `getState()` | `PipelineState` | Current state: `idle`, `connecting`, `streaming`, `draining`, or `error`. |
|
|
181
|
+
| `getTelemetry()` | `PipelineTelemetry` | Snapshot of buffer levels, push counts, write loops, underruns, etc. |
|
|
182
|
+
|
|
183
|
+
#### Event Subscriptions
|
|
184
|
+
|
|
185
|
+
| Method | Returns | Description |
|
|
186
|
+
|--------|---------|-------------|
|
|
187
|
+
| `subscribe(eventName, listener)` | `EventSubscription` | Type-safe subscription to any pipeline event. |
|
|
188
|
+
| `onError(listener)` | `{ remove }` | Convenience: handles both `PipelineError` and `PipelineZombieDetected`. |
|
|
189
|
+
| `onAudioFocus(listener)` | `{ remove }` | Convenience: `{ focused: true/false }` on audio focus changes. |
|
|
190
|
+
|
|
191
|
+
## Configuration Types
|
|
192
|
+
|
|
193
|
+
### RecordingConfig
|
|
194
|
+
|
|
195
|
+
```typescript
|
|
196
|
+
interface RecordingConfig {
|
|
197
|
+
sampleRate?: 16000 | 24000 | 44100 | 48000;
|
|
198
|
+
channels?: 1 | 2;
|
|
199
|
+
encoding?: "pcm_32bit" | "pcm_16bit" | "pcm_8bit";
|
|
200
|
+
interval?: number; // ms between audio data emissions (default 1000)
|
|
201
|
+
onAudioStream?: (event: AudioDataEvent) => Promise<void>;
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### SoundConfig
|
|
206
|
+
|
|
207
|
+
```typescript
|
|
208
|
+
interface SoundConfig {
|
|
209
|
+
sampleRate?: 16000 | 24000 | 44100 | 48000;
|
|
210
|
+
playbackMode?: "regular" | "voiceProcessing" | "conversation";
|
|
211
|
+
useDefault?: boolean; // reset to defaults
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### ConnectPipelineOptions
|
|
216
|
+
|
|
217
|
+
```typescript
|
|
218
|
+
interface ConnectPipelineOptions {
|
|
219
|
+
sampleRate?: number; // default 24000
|
|
220
|
+
channelCount?: number; // default 1 (mono)
|
|
221
|
+
targetBufferMs?: number; // ms to buffer before priming gate opens (default 80)
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### PushPipelineAudioOptions
|
|
226
|
+
|
|
227
|
+
```typescript
|
|
228
|
+
interface PushPipelineAudioOptions {
|
|
229
|
+
audio: string; // base64-encoded PCM 16-bit signed LE
|
|
230
|
+
turnId: string;
|
|
231
|
+
isFirstChunk?: boolean; // resets jitter buffer
|
|
232
|
+
isLastChunk?: boolean; // marks end-of-stream, begins drain
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## Events
|
|
237
|
+
|
|
238
|
+
### Core Events
|
|
239
|
+
|
|
240
|
+
| Event | Payload | Description |
|
|
241
|
+
|-------|---------|-------------|
|
|
242
|
+
| `AudioData` | `{ encoded, position, deltaSize, totalSize, soundLevel, ... }` | Emitted during mic capture at the configured interval. |
|
|
243
|
+
| `SoundChunkPlayed` | `{ isFinal: boolean }` | A queued chunk finished playing. `isFinal` when the queue is empty. |
|
|
244
|
+
| `SoundStarted` | (none) | Playback began for a new turn. |
|
|
245
|
+
| `DeviceReconnected` | `{ reason }` | Audio route changed (headphones, Bluetooth, etc). |
|
|
246
|
+
|
|
247
|
+
### Pipeline Events
|
|
248
|
+
|
|
249
|
+
| Event | Payload | Description |
|
|
250
|
+
|-------|---------|-------------|
|
|
251
|
+
| `PipelineStateChanged` | `{ state }` | Pipeline state transition. |
|
|
252
|
+
| `PipelinePlaybackStarted` | `{ turnId }` | Priming gate opened, audio is now audible. |
|
|
253
|
+
| `PipelineError` | `{ code, message }` | Non-recoverable error. |
|
|
254
|
+
| `PipelineZombieDetected` | `{ playbackHead, stalledMs }` | Audio track stalled. |
|
|
255
|
+
| `PipelineUnderrun` | `{ count }` | Jitter buffer underrun (silence inserted). |
|
|
256
|
+
| `PipelineDrained` | `{ turnId }` | All buffered audio for the turn has been played. |
|
|
257
|
+
| `PipelineAudioFocusLost` | (empty) | Another app took audio focus. |
|
|
258
|
+
| `PipelineAudioFocusResumed` | (empty) | Audio focus regained. |
|
|
259
|
+
|
|
260
|
+
## Constants
|
|
261
|
+
|
|
262
|
+
```typescript
|
|
263
|
+
import {
|
|
264
|
+
EncodingTypes, // { PCM_F32LE: "pcm_f32le", PCM_S16LE: "pcm_s16le" }
|
|
265
|
+
PlaybackModes, // { REGULAR, VOICE_PROCESSING, CONVERSATION }
|
|
266
|
+
AudioEvents, // { AudioData, SoundChunkPlayed, SoundStarted, DeviceReconnected }
|
|
267
|
+
SuspendSoundEventTurnId, // "suspend-sound-events" -- suppresses playback events
|
|
268
|
+
} from "@edkimmel/expo-audio-stream";
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
## Platform Notes
|
|
272
|
+
|
|
273
|
+
### iOS
|
|
274
|
+
|
|
275
|
+
- Uses `AVAudioEngine` with `AVAudioPlayerNode` for sound playback and pipeline audio.
|
|
276
|
+
- Microphone capture via `AVAudioEngine.inputNode` tap.
|
|
277
|
+
- Audio session configured as `.playAndRecord` with `.voiceChat` mode.
|
|
278
|
+
- Voice processing (AEC/noise reduction) available via `voiceProcessing` and `conversation` playback modes.
|
|
279
|
+
- `promptMicrophoneModes()` exposes the iOS 15+ system voice isolation picker.
|
|
280
|
+
|
|
281
|
+
### Android
|
|
282
|
+
|
|
283
|
+
- Uses `AudioTrack` (float PCM, `MODE_STREAM`) for sound playback.
|
|
284
|
+
- Microphone capture via `AudioRecord` with `VOICE_RECOGNITION` source for far-field mic gain.
|
|
285
|
+
- AEC, noise suppression, and AGC applied via `AudioEffectsManager`.
|
|
286
|
+
|
|
287
|
+
## License
|
|
288
|
+
|
|
289
|
+
MIT
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
apply plugin: 'com.android.library'
|
|
2
|
+
apply plugin: 'kotlin-android'
|
|
3
|
+
apply plugin: 'maven-publish'
|
|
4
|
+
|
|
5
|
+
group = 'expo.modules.audiostream'
|
|
6
|
+
version = '0.1.0'
|
|
7
|
+
|
|
8
|
+
buildscript {
|
|
9
|
+
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
|
10
|
+
if (expoModulesCorePlugin.exists()) {
|
|
11
|
+
apply from: expoModulesCorePlugin
|
|
12
|
+
applyKotlinExpoModulesCorePlugin()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Simple helper that allows the root project to override versions declared by this library.
|
|
16
|
+
ext.safeExtGet = { prop, fallback ->
|
|
17
|
+
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Ensures backward compatibility
|
|
21
|
+
ext.getKotlinVersion = {
|
|
22
|
+
if (ext.has("kotlinVersion")) {
|
|
23
|
+
ext.kotlinVersion()
|
|
24
|
+
} else {
|
|
25
|
+
ext.safeExtGet("kotlinVersion", "2.0.21")
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
repositories {
|
|
30
|
+
mavenCentral()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
dependencies {
|
|
34
|
+
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${getKotlinVersion()}")
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
afterEvaluate {
|
|
39
|
+
publishing {
|
|
40
|
+
publications {
|
|
41
|
+
release(MavenPublication) {
|
|
42
|
+
from components.release
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
repositories {
|
|
46
|
+
maven {
|
|
47
|
+
url = mavenLocal().url
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
android {
|
|
54
|
+
compileSdkVersion safeExtGet("compileSdkVersion", 35)
|
|
55
|
+
|
|
56
|
+
def agpVersion = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION
|
|
57
|
+
if (agpVersion.tokenize('.')[0].toInteger() < 8) {
|
|
58
|
+
compileOptions {
|
|
59
|
+
sourceCompatibility JavaVersion.VERSION_11
|
|
60
|
+
targetCompatibility JavaVersion.VERSION_11
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
kotlinOptions {
|
|
64
|
+
jvmTarget = JavaVersion.VERSION_11.majorVersion
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
namespace "expo.modules.audiostream"
|
|
69
|
+
defaultConfig {
|
|
70
|
+
minSdkVersion safeExtGet("minSdkVersion", 24)
|
|
71
|
+
targetSdkVersion safeExtGet("targetSdkVersion", 35)
|
|
72
|
+
versionCode 1
|
|
73
|
+
versionName "0.1.0"
|
|
74
|
+
}
|
|
75
|
+
lint {
|
|
76
|
+
abortOnError false
|
|
77
|
+
}
|
|
78
|
+
publishing {
|
|
79
|
+
singleVariant("release") {
|
|
80
|
+
withSourcesJar()
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
repositories {
|
|
86
|
+
mavenCentral()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
dependencies {
|
|
90
|
+
implementation project(':expo-modules-core')
|
|
91
|
+
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${getKotlinVersion()}"
|
|
92
|
+
}
|