@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.
Files changed (75) hide show
  1. package/.eslintrc.js +5 -0
  2. package/.yarnrc.yml +8 -0
  3. package/NATIVE_EVENTS.md +270 -0
  4. package/README.md +289 -0
  5. package/android/build.gradle +92 -0
  6. package/android/src/main/AndroidManifest.xml +4 -0
  7. package/android/src/main/java/expo/modules/audiostream/AudioDataEncoder.kt +178 -0
  8. package/android/src/main/java/expo/modules/audiostream/AudioEffectsManager.kt +107 -0
  9. package/android/src/main/java/expo/modules/audiostream/AudioPlaybackManager.kt +651 -0
  10. package/android/src/main/java/expo/modules/audiostream/AudioRecorderManager.kt +509 -0
  11. package/android/src/main/java/expo/modules/audiostream/Constants.kt +21 -0
  12. package/android/src/main/java/expo/modules/audiostream/EventSender.kt +7 -0
  13. package/android/src/main/java/expo/modules/audiostream/ExpoAudioStreamView.kt +7 -0
  14. package/android/src/main/java/expo/modules/audiostream/ExpoPlayAudioStreamModule.kt +280 -0
  15. package/android/src/main/java/expo/modules/audiostream/PermissionUtils.kt +16 -0
  16. package/android/src/main/java/expo/modules/audiostream/RecordingConfig.kt +60 -0
  17. package/android/src/main/java/expo/modules/audiostream/SoundConfig.kt +46 -0
  18. package/android/src/main/java/expo/modules/audiostream/pipeline/AudioPipeline.kt +685 -0
  19. package/android/src/main/java/expo/modules/audiostream/pipeline/JitterBuffer.kt +227 -0
  20. package/android/src/main/java/expo/modules/audiostream/pipeline/PipelineIntegration.kt +315 -0
  21. package/app.plugin.js +1 -0
  22. package/build/ExpoPlayAudioStreamModule.d.ts +3 -0
  23. package/build/ExpoPlayAudioStreamModule.d.ts.map +1 -0
  24. package/build/ExpoPlayAudioStreamModule.js +5 -0
  25. package/build/ExpoPlayAudioStreamModule.js.map +1 -0
  26. package/build/events.d.ts +36 -0
  27. package/build/events.d.ts.map +1 -0
  28. package/build/events.js +25 -0
  29. package/build/events.js.map +1 -0
  30. package/build/index.d.ts +125 -0
  31. package/build/index.d.ts.map +1 -0
  32. package/build/index.js +222 -0
  33. package/build/index.js.map +1 -0
  34. package/build/pipeline/index.d.ts +81 -0
  35. package/build/pipeline/index.d.ts.map +1 -0
  36. package/build/pipeline/index.js +140 -0
  37. package/build/pipeline/index.js.map +1 -0
  38. package/build/pipeline/types.d.ts +132 -0
  39. package/build/pipeline/types.d.ts.map +1 -0
  40. package/build/pipeline/types.js +5 -0
  41. package/build/pipeline/types.js.map +1 -0
  42. package/build/types.d.ts +221 -0
  43. package/build/types.d.ts.map +1 -0
  44. package/build/types.js +10 -0
  45. package/build/types.js.map +1 -0
  46. package/expo-module.config.json +9 -0
  47. package/ios/AudioPipeline.swift +562 -0
  48. package/ios/AudioUtils.swift +356 -0
  49. package/ios/ExpoPlayAudioStream.podspec +27 -0
  50. package/ios/ExpoPlayAudioStreamModule.swift +436 -0
  51. package/ios/ExpoPlayAudioStreamView.swift +7 -0
  52. package/ios/JitterBuffer.swift +208 -0
  53. package/ios/Logger.swift +7 -0
  54. package/ios/Microphone.swift +221 -0
  55. package/ios/MicrophoneDataDelegate.swift +4 -0
  56. package/ios/PipelineIntegration.swift +214 -0
  57. package/ios/RecordingResult.swift +10 -0
  58. package/ios/RecordingSettings.swift +11 -0
  59. package/ios/SharedAudioEngine.swift +484 -0
  60. package/ios/SoundConfig.swift +45 -0
  61. package/ios/SoundPlayer.swift +408 -0
  62. package/ios/SoundPlayerDelegate.swift +7 -0
  63. package/package.json +49 -0
  64. package/plugin/build/index.d.ts +5 -0
  65. package/plugin/build/index.js +28 -0
  66. package/plugin/src/index.ts +53 -0
  67. package/plugin/tsconfig.json +9 -0
  68. package/plugin/tsconfig.tsbuildinfo +1 -0
  69. package/src/ExpoPlayAudioStreamModule.ts +5 -0
  70. package/src/events.ts +66 -0
  71. package/src/index.ts +359 -0
  72. package/src/pipeline/index.ts +216 -0
  73. package/src/pipeline/types.ts +169 -0
  74. package/src/types.ts +270 -0
  75. package/tsconfig.json +9 -0
package/.eslintrc.js ADDED
@@ -0,0 +1,5 @@
1
+ module.exports = {
2
+ root: true,
3
+ extends: ['universe/native', 'universe/web'],
4
+ ignorePatterns: ['build'],
5
+ };
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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}
@@ -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
+ }
@@ -0,0 +1,4 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ <uses-permission android:name="android.permission.RECORD_AUDIO"/>
3
+ <uses-permission android:name="android.permission.INTERNET"/>
4
+ </manifest>