@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/src/index.ts ADDED
@@ -0,0 +1,359 @@
1
+ import type { EventSubscription } from "expo-modules-core";
2
+ import ExpoPlayAudioStreamModule from "./ExpoPlayAudioStreamModule";
3
+
4
+ // Type alias for backwards compatibility
5
+ type Subscription = EventSubscription;
6
+ import {
7
+ AudioDataEvent,
8
+ AudioRecording,
9
+ RecordingConfig,
10
+ StartRecordingResult,
11
+ SoundConfig,
12
+ PlaybackMode,
13
+ Encoding,
14
+ EncodingTypes,
15
+ PlaybackModes,
16
+ // Audio jitter buffer types
17
+ IAudioBufferConfig,
18
+ IAudioPlayPayload,
19
+ IAudioFrame,
20
+ BufferHealthState,
21
+ IBufferHealthMetrics,
22
+ IAudioBufferManager,
23
+ IFrameProcessor,
24
+ IQualityMonitor,
25
+ BufferedStreamConfig,
26
+ SmartBufferConfig,
27
+ SmartBufferMode,
28
+ NetworkConditions,
29
+ } from "./types";
30
+
31
+ import {
32
+ addAudioEventListener,
33
+ addSoundChunkPlayedListener,
34
+ AudioEventPayload,
35
+ SoundChunkPlayedEventPayload,
36
+ AudioEvents,
37
+ subscribeToEvent,
38
+ DeviceReconnectedReason,
39
+ DeviceReconnectedEventPayload,
40
+ } from "./events";
41
+
42
+ const SuspendSoundEventTurnId = "suspend-sound-events";
43
+
44
+ export class ExpoPlayAudioStream {
45
+ /**
46
+ * Destroys the audio stream module, cleaning up all resources.
47
+ * This should be called when the module is no longer needed.
48
+ * It will reset all internal state and release audio resources.
49
+ */
50
+ static destroy() {
51
+ ExpoPlayAudioStreamModule.destroy();
52
+ }
53
+
54
+ /**
55
+ * @deprecated Use the `Pipeline` class for more efficient audio streaming with better error handling and telemetry.
56
+ * Plays a sound.
57
+ * @param {string} audio - The audio to play.
58
+ * @param {string} turnId - The turn ID.
59
+ * @param {string} [encoding] - The encoding format of the audio data ('pcm_f32le' or 'pcm_s16le').
60
+ * @returns {Promise<void>}
61
+ * @throws {Error} If the sound fails to play.
62
+ */
63
+ static async playSound(
64
+ audio: string,
65
+ turnId: string,
66
+ encoding?: Encoding
67
+ ): Promise<void> {
68
+ try {
69
+ await ExpoPlayAudioStreamModule.playSound(
70
+ audio,
71
+ turnId,
72
+ encoding ?? EncodingTypes.PCM_S16LE
73
+ );
74
+ } catch (error) {
75
+ console.error(error);
76
+ throw new Error(`Failed to enqueue audio: ${error}`);
77
+ }
78
+ }
79
+
80
+ /**
81
+ * @deprecated Use the `Pipeline` class for more efficient audio streaming with better error handling and telemetry.
82
+ * Stops the currently playing sound.
83
+ * @returns {Promise<void>}
84
+ * @throws {Error} If the sound fails to stop.
85
+ */
86
+ static async stopSound(): Promise<void> {
87
+ try {
88
+ await ExpoPlayAudioStreamModule.stopSound();
89
+ } catch (error) {
90
+ console.error(error);
91
+ throw new Error(`Failed to stop enqueued audio: ${error}`);
92
+ }
93
+ }
94
+
95
+ /**
96
+ * @deprecated Use the `Pipeline` class for more efficient audio streaming with better error handling and telemetry.
97
+ * Clears the sound queue by turn ID.
98
+ * @param {string} turnId - The turn ID.
99
+ * @returns {Promise<void>}
100
+ * @throws {Error} If the sound queue fails to clear.
101
+ */
102
+ static async clearSoundQueueByTurnId(turnId: string): Promise<void> {
103
+ try {
104
+ await ExpoPlayAudioStreamModule.clearSoundQueueByTurnId(turnId);
105
+ } catch (error) {
106
+ console.error(error);
107
+ throw new Error(`Failed to clear sound queue: ${error}`);
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Starts microphone streaming.
113
+ * @param {RecordingConfig} recordingConfig - The recording configuration.
114
+ * @returns {Promise<{recordingResult: StartRecordingResult, subscription: Subscription}>} A promise that resolves to an object containing the recording result and a subscription to audio events.
115
+ * @throws {Error} If the recording fails to start.
116
+ */
117
+ static async startMicrophone(recordingConfig: RecordingConfig): Promise<{
118
+ recordingResult: StartRecordingResult;
119
+ subscription?: Subscription;
120
+ }> {
121
+ let subscription: Subscription | undefined;
122
+ try {
123
+ const { onAudioStream, ...options } = recordingConfig;
124
+
125
+ if (onAudioStream && typeof onAudioStream == "function") {
126
+ subscription = addAudioEventListener(
127
+ async (event: AudioEventPayload) => {
128
+ const {
129
+ fileUri,
130
+ deltaSize,
131
+ totalSize,
132
+ position,
133
+ encoded,
134
+ soundLevel,
135
+ } = event;
136
+ if (!encoded) {
137
+ console.error(
138
+ `[ExpoPlayAudioStream] Encoded audio data is missing`
139
+ );
140
+ throw new Error("Encoded audio data is missing");
141
+ }
142
+ onAudioStream?.({
143
+ data: encoded,
144
+ position,
145
+ fileUri,
146
+ eventDataSize: deltaSize,
147
+ totalSize,
148
+ soundLevel,
149
+ });
150
+ }
151
+ );
152
+ }
153
+
154
+ const result = await ExpoPlayAudioStreamModule.startMicrophone(options);
155
+
156
+ return { recordingResult: result, subscription };
157
+ } catch (error) {
158
+ console.error(error);
159
+ subscription?.remove();
160
+ throw new Error(`Failed to start recording: ${error}`);
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Stops the current microphone streaming.
166
+ * @returns {Promise<void>}
167
+ * @throws {Error} If the microphone streaming fails to stop.
168
+ */
169
+ static async stopMicrophone(): Promise<AudioRecording | null> {
170
+ try {
171
+ return await ExpoPlayAudioStreamModule.stopMicrophone();
172
+ } catch (error) {
173
+ console.error(error);
174
+ throw new Error(`Failed to stop mic stream: ${error}`);
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Subscribes to audio events emitted during recording/streaming.
180
+ * @param onMicrophoneStream - Callback function that will be called when audio data is received.
181
+ * The callback receives an AudioDataEvent containing:
182
+ * - data: Base64 encoded audio data at original sample rate
183
+ * - data16kHz: Optional base64 encoded audio data resampled to 16kHz
184
+ * - position: Current position in the audio stream
185
+ * - fileUri: URI of the recording file
186
+ * - eventDataSize: Size of the current audio data chunk
187
+ * - totalSize: Total size of recorded audio so far
188
+ * @returns {Subscription} A subscription object that can be used to unsubscribe from the events
189
+ * @throws {Error} If encoded audio data is missing from the event
190
+ */
191
+ static subscribeToAudioEvents(
192
+ onMicrophoneStream: (event: AudioDataEvent) => Promise<void>
193
+ ): Subscription {
194
+ return addAudioEventListener(async (event: AudioEventPayload) => {
195
+ const { fileUri, deltaSize, totalSize, position, encoded, soundLevel } =
196
+ event;
197
+ if (!encoded) {
198
+ console.error(`[ExpoPlayAudioStream] Encoded audio data is missing`);
199
+ throw new Error("Encoded audio data is missing");
200
+ }
201
+ onMicrophoneStream?.({
202
+ data: encoded,
203
+ position,
204
+ fileUri,
205
+ eventDataSize: deltaSize,
206
+ totalSize,
207
+ soundLevel,
208
+ });
209
+ });
210
+ }
211
+
212
+ /**
213
+ * Subscribes to events emitted when a sound chunk has finished playing.
214
+ * @param onSoundChunkPlayed - Callback function that will be called when a sound chunk is played.
215
+ * The callback receives a SoundChunkPlayedEventPayload indicating if this was the final chunk.
216
+ * @returns {Subscription} A subscription object that can be used to unsubscribe from the events.
217
+ */
218
+ static subscribeToSoundChunkPlayed(
219
+ onSoundChunkPlayed: (event: SoundChunkPlayedEventPayload) => Promise<void>
220
+ ): Subscription {
221
+ return addSoundChunkPlayedListener(onSoundChunkPlayed);
222
+ }
223
+
224
+ /**
225
+ * Subscribes to events emitted by the audio stream module, for advanced use cases.
226
+ * @param eventName - The name of the event to subscribe to.
227
+ * @param onEvent - Callback function that will be called when the event is emitted.
228
+ * @returns {Subscription} A subscription object that can be used to unsubscribe from the events.
229
+ */
230
+ static subscribe<T extends unknown>(
231
+ eventName: string,
232
+ onEvent: (event: T | undefined) => Promise<void>
233
+ ): Subscription {
234
+ return subscribeToEvent(eventName, onEvent);
235
+ }
236
+
237
+ /**
238
+ * Sets the sound player configuration.
239
+ * @param {SoundConfig} config - Configuration options for the sound player.
240
+ * @returns {Promise<void>}
241
+ * @throws {Error} If the configuration fails to update.
242
+ */
243
+ static async setSoundConfig(config: SoundConfig): Promise<void> {
244
+ try {
245
+ await ExpoPlayAudioStreamModule.setSoundConfig(config);
246
+ } catch (error) {
247
+ console.error(error);
248
+ throw new Error(`Failed to set sound configuration: ${error}`);
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Prompts the user to select the microphone mode.
254
+ * @returns {Promise<void>}
255
+ * @throws {Error} If the microphone mode fails to prompt.
256
+ */
257
+ static promptMicrophoneModes() {
258
+ ExpoPlayAudioStreamModule.promptMicrophoneModes();
259
+ }
260
+
261
+ /**
262
+ * Toggles the silence state of the microphone.
263
+ * @returns {Promise<void>}
264
+ * @throws {Error} If the microphone fails to toggle silence.
265
+ */
266
+ static toggleSilence(isSilent: boolean) {
267
+ ExpoPlayAudioStreamModule.toggleSilence(isSilent);
268
+ }
269
+
270
+ /**
271
+ * Requests microphone permission from the user.
272
+ * @returns {Promise<{granted: boolean, canAskAgain?: boolean, status?: string}>} A promise that resolves to the permission result.
273
+ */
274
+ static async requestPermissionsAsync(): Promise<{
275
+ granted: boolean;
276
+ canAskAgain?: boolean;
277
+ status?: string;
278
+ }> {
279
+ try {
280
+ return await ExpoPlayAudioStreamModule.requestPermissionsAsync();
281
+ } catch (error) {
282
+ console.error(error);
283
+ throw new Error(`Failed to request permissions: ${error}`);
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Gets the current microphone permission status.
289
+ * @returns {Promise<{granted: boolean, canAskAgain?: boolean, status?: string}>} A promise that resolves to the permission status.
290
+ */
291
+ static async getPermissionsAsync(): Promise<{
292
+ granted: boolean;
293
+ canAskAgain?: boolean;
294
+ status?: string;
295
+ }> {
296
+ try {
297
+ return await ExpoPlayAudioStreamModule.getPermissionsAsync();
298
+ } catch (error) {
299
+ console.error(error);
300
+ throw new Error(`Failed to get permissions: ${error}`);
301
+ }
302
+ }
303
+ }
304
+
305
+ export {
306
+ AudioDataEvent,
307
+ SoundChunkPlayedEventPayload,
308
+ DeviceReconnectedReason,
309
+ DeviceReconnectedEventPayload,
310
+ AudioRecording,
311
+ RecordingConfig,
312
+ StartRecordingResult,
313
+ AudioEvents,
314
+ SuspendSoundEventTurnId,
315
+ SoundConfig,
316
+ PlaybackMode,
317
+ Encoding,
318
+ EncodingTypes,
319
+ PlaybackModes,
320
+ // Audio jitter buffer types
321
+ IAudioBufferConfig,
322
+ IAudioPlayPayload,
323
+ IAudioFrame,
324
+ BufferHealthState,
325
+ IBufferHealthMetrics,
326
+ IAudioBufferManager,
327
+ IFrameProcessor,
328
+ IQualityMonitor,
329
+ BufferedStreamConfig,
330
+ SmartBufferConfig,
331
+ SmartBufferMode,
332
+ NetworkConditions,
333
+ };
334
+
335
+ // Re-export Subscription type for backwards compatibility
336
+ export type { EventSubscription } from "expo-modules-core";
337
+ export type { Subscription } from "./events";
338
+
339
+ // Export native audio pipeline V3
340
+ export { Pipeline } from "./pipeline";
341
+ export type {
342
+ ConnectPipelineOptions,
343
+ ConnectPipelineResult,
344
+ PushPipelineAudioOptions,
345
+ InvalidatePipelineTurnOptions,
346
+ PipelineState,
347
+ PipelineEventMap,
348
+ PipelineEventName,
349
+ PipelineBufferTelemetry,
350
+ PipelineTelemetry,
351
+ PipelineStateChangedEvent,
352
+ PipelinePlaybackStartedEvent,
353
+ PipelineErrorEvent,
354
+ PipelineZombieDetectedEvent,
355
+ PipelineUnderrunEvent,
356
+ PipelineDrainedEvent,
357
+ PipelineAudioFocusLostEvent,
358
+ PipelineAudioFocusResumedEvent,
359
+ } from "./pipeline";
@@ -0,0 +1,216 @@
1
+ // ────────────────────────────────────────────────────────────────────────────
2
+ // Native Audio Pipeline — V3 TypeScript Wrapper
3
+ // ────────────────────────────────────────────────────────────────────────────
4
+ //
5
+ // Thin wrapper over the existing ExpoPlayAudioStreamModule (not a new native
6
+ // module). Uses static methods matching the existing codebase pattern.
7
+ //
8
+ // Hot path: pushAudioSync() — synchronous Function call, no Promise overhead.
9
+ // Cold path: pushAudio() — async with error propagation via Promise.
10
+
11
+ import type { EventSubscription } from 'expo-modules-core';
12
+ import ExpoPlayAudioStreamModule from '../ExpoPlayAudioStreamModule';
13
+ import { subscribeToEvent } from '../events';
14
+
15
+ import type {
16
+ ConnectPipelineOptions,
17
+ ConnectPipelineResult,
18
+ PushPipelineAudioOptions,
19
+ InvalidatePipelineTurnOptions,
20
+ PipelineState,
21
+ PipelineEventMap,
22
+ PipelineEventName,
23
+ PipelineTelemetry,
24
+ } from './types';
25
+
26
+ export class Pipeline {
27
+ // ════════════════════════════════════════════════════════════════════════
28
+ // Lifecycle
29
+ // ════════════════════════════════════════════════════════════════════════
30
+
31
+ /**
32
+ * Connect the native audio pipeline.
33
+ *
34
+ * Creates an AudioTrack (buffer size from device HAL), jitter buffer, and
35
+ * MAX_PRIORITY write thread. Config is immutable per session — disconnect
36
+ * and reconnect to change sample rate.
37
+ */
38
+ static async connect(
39
+ options: ConnectPipelineOptions = {}
40
+ ): Promise<ConnectPipelineResult> {
41
+ return await ExpoPlayAudioStreamModule.connectPipeline(options);
42
+ }
43
+
44
+ /**
45
+ * Disconnect the pipeline. Tears down AudioTrack, write thread, audio
46
+ * focus, volume guard, and zombie detection.
47
+ */
48
+ static async disconnect(): Promise<void> {
49
+ return await ExpoPlayAudioStreamModule.disconnectPipeline();
50
+ }
51
+
52
+ // ════════════════════════════════════════════════════════════════════════
53
+ // Push audio
54
+ // ════════════════════════════════════════════════════════════════════════
55
+
56
+ /**
57
+ * Push base64-encoded PCM16 audio into the jitter buffer (async).
58
+ *
59
+ * Use this when you need error propagation via Promise rejection.
60
+ * For the hot path (e.g., inside a WebSocket message handler), prefer
61
+ * [pushAudioSync] which avoids Promise overhead.
62
+ */
63
+ static async pushAudio(options: PushPipelineAudioOptions): Promise<void> {
64
+ return await ExpoPlayAudioStreamModule.pushPipelineAudio(options);
65
+ }
66
+
67
+ /**
68
+ * Push base64-encoded PCM16 audio synchronously (no Promise overhead).
69
+ *
70
+ * Designed for the hot path — call this from your WebSocket onmessage
71
+ * handler for minimum latency. Returns `true` on success, `false` on
72
+ * failure (errors are also reported via PipelineError events).
73
+ */
74
+ static pushAudioSync(options: PushPipelineAudioOptions): boolean {
75
+ return ExpoPlayAudioStreamModule.pushPipelineAudioSync(options);
76
+ }
77
+
78
+ // ════════════════════════════════════════════════════════════════════════
79
+ // Turn management
80
+ // ════════════════════════════════════════════════════════════════════════
81
+
82
+ /**
83
+ * Invalidate the current turn. Resets the jitter buffer so stale audio
84
+ * from the old turn is discarded immediately.
85
+ */
86
+ static async invalidateTurn(
87
+ options: InvalidatePipelineTurnOptions
88
+ ): Promise<void> {
89
+ return await ExpoPlayAudioStreamModule.invalidatePipelineTurn(options);
90
+ }
91
+
92
+ // ════════════════════════════════════════════════════════════════════════
93
+ // State & Telemetry
94
+ // ════════════════════════════════════════════════════════════════════════
95
+
96
+ /** Get the current pipeline state synchronously. */
97
+ static getState(): PipelineState {
98
+ return ExpoPlayAudioStreamModule.getPipelineState() as PipelineState;
99
+ }
100
+
101
+ /** Get a telemetry snapshot (buffer levels, counters, etc.). */
102
+ static getTelemetry(): PipelineTelemetry {
103
+ return ExpoPlayAudioStreamModule.getPipelineTelemetry() as PipelineTelemetry;
104
+ }
105
+
106
+ // ════════════════════════════════════════════════════════════════════════
107
+ // Event subscriptions
108
+ // ════════════════════════════════════════════════════════════════════════
109
+
110
+ /**
111
+ * Subscribe to a specific pipeline event with full type safety.
112
+ *
113
+ * @example
114
+ * ```ts
115
+ * const sub = Pipeline.subscribe('PipelineStateChanged', async (e) => {
116
+ * console.log('State:', e.state);
117
+ * });
118
+ * // Later:
119
+ * sub.remove();
120
+ * ```
121
+ */
122
+ static subscribe<K extends PipelineEventName>(
123
+ eventName: K,
124
+ listener: (event: PipelineEventMap[K]) => Promise<void> | void
125
+ ): EventSubscription {
126
+ return subscribeToEvent<PipelineEventMap[K]>(
127
+ eventName,
128
+ async (event) => {
129
+ if (event !== undefined) {
130
+ await listener(event);
131
+ }
132
+ }
133
+ );
134
+ }
135
+
136
+ /**
137
+ * Convenience: subscribe to both PipelineError and PipelineZombieDetected.
138
+ *
139
+ * Useful for a single error handler that covers fatal and near-fatal
140
+ * conditions. The callback receives a normalized `{ code, message }`.
141
+ */
142
+ static onError(
143
+ listener: (error: { code: string; message: string }) => void
144
+ ): { remove: () => void } {
145
+ const subs: EventSubscription[] = [];
146
+
147
+ subs.push(
148
+ Pipeline.subscribe('PipelineError', async (e) => {
149
+ listener({ code: e.code, message: e.message });
150
+ })
151
+ );
152
+
153
+ subs.push(
154
+ Pipeline.subscribe('PipelineZombieDetected', async (e) => {
155
+ listener({
156
+ code: 'ZOMBIE_DETECTED',
157
+ message: `AudioTrack stalled for ${e.stalledMs}ms at head=${e.playbackHead}`,
158
+ });
159
+ })
160
+ );
161
+
162
+ return {
163
+ remove: () => subs.forEach((s) => s.remove()),
164
+ };
165
+ }
166
+
167
+ /**
168
+ * Convenience: subscribe to audio focus loss and resumption events.
169
+ *
170
+ * During focus loss the pipeline writes silence instead of real audio.
171
+ * The caller should typically invalidateTurn + re-request audio from the
172
+ * AI backend on focus regain.
173
+ */
174
+ static onAudioFocus(
175
+ listener: (event: { focused: boolean }) => void
176
+ ): { remove: () => void } {
177
+ const subs: EventSubscription[] = [];
178
+
179
+ subs.push(
180
+ Pipeline.subscribe('PipelineAudioFocusLost', async () => {
181
+ listener({ focused: false });
182
+ })
183
+ );
184
+
185
+ subs.push(
186
+ Pipeline.subscribe('PipelineAudioFocusResumed', async () => {
187
+ listener({ focused: true });
188
+ })
189
+ );
190
+
191
+ return {
192
+ remove: () => subs.forEach((s) => s.remove()),
193
+ };
194
+ }
195
+ }
196
+
197
+ // Re-export all types for consumer convenience
198
+ export type {
199
+ ConnectPipelineOptions,
200
+ ConnectPipelineResult,
201
+ PushPipelineAudioOptions,
202
+ InvalidatePipelineTurnOptions,
203
+ PipelineState,
204
+ PipelineEventMap,
205
+ PipelineEventName,
206
+ PipelineBufferTelemetry,
207
+ PipelineTelemetry,
208
+ PipelineStateChangedEvent,
209
+ PipelinePlaybackStartedEvent,
210
+ PipelineErrorEvent,
211
+ PipelineZombieDetectedEvent,
212
+ PipelineUnderrunEvent,
213
+ PipelineDrainedEvent,
214
+ PipelineAudioFocusLostEvent,
215
+ PipelineAudioFocusResumedEvent,
216
+ } from './types';