@edkimmel/expo-audio-stream 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/NATIVE_EVENTS.md +97 -6
  2. package/android/.gradle/8.9/checksums/checksums.lock +0 -0
  3. package/android/.gradle/8.9/dependencies-accessors/gc.properties +0 -0
  4. package/android/.gradle/8.9/fileChanges/last-build.bin +0 -0
  5. package/android/.gradle/8.9/fileHashes/fileHashes.lock +0 -0
  6. package/android/.gradle/8.9/gc.properties +0 -0
  7. package/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
  8. package/android/.gradle/buildOutputCleanup/cache.properties +2 -0
  9. package/android/.gradle/vcs-1/gc.properties +0 -0
  10. package/android/src/main/java/expo/modules/audiostream/AudioEffectsManager.kt +6 -11
  11. package/android/src/main/java/expo/modules/audiostream/AudioRecorderManager.kt +23 -6
  12. package/android/src/main/java/expo/modules/audiostream/CommunicationAudioManager.kt +155 -0
  13. package/android/src/main/java/expo/modules/audiostream/Constants.kt +1 -0
  14. package/android/src/main/java/expo/modules/audiostream/ExpoPlayAudioStreamModule.kt +17 -3
  15. package/android/src/main/java/expo/modules/audiostream/pipeline/AudioPipeline.kt +104 -11
  16. package/android/src/main/java/expo/modules/audiostream/pipeline/PipelineIntegration.kt +14 -0
  17. package/build/events.d.ts +26 -0
  18. package/build/events.d.ts.map +1 -1
  19. package/build/events.js +18 -0
  20. package/build/events.js.map +1 -1
  21. package/build/index.d.ts +3 -1
  22. package/build/index.d.ts.map +1 -1
  23. package/build/index.js +19 -7
  24. package/build/index.js.map +1 -1
  25. package/build/pipeline/index.d.ts +14 -1
  26. package/build/pipeline/index.d.ts.map +1 -1
  27. package/build/pipeline/index.js +15 -0
  28. package/build/pipeline/index.js.map +1 -1
  29. package/build/pipeline/types.d.ts +14 -0
  30. package/build/pipeline/types.d.ts.map +1 -1
  31. package/build/pipeline/types.js.map +1 -1
  32. package/build/types.d.ts +21 -0
  33. package/build/types.d.ts.map +1 -1
  34. package/build/types.js.map +1 -1
  35. package/ios/AudioPipeline.swift +67 -2
  36. package/ios/ExpoPlayAudioStreamModule.swift +43 -12
  37. package/ios/Microphone.swift +167 -120
  38. package/ios/MicrophoneDataDelegate.swift +10 -1
  39. package/ios/PipelineIntegration.swift +11 -1
  40. package/ios/SharedAudioEngine.swift +18 -0
  41. package/package.json +1 -2
  42. package/plugin/build/index.js +5 -0
  43. package/plugin/src/index.ts +5 -0
  44. package/src/events.ts +32 -0
  45. package/src/index.ts +27 -18
  46. package/src/pipeline/index.ts +17 -0
  47. package/src/pipeline/types.ts +15 -0
  48. package/src/types.ts +22 -0
package/src/index.ts CHANGED
@@ -30,6 +30,7 @@ import {
30
30
 
31
31
  import {
32
32
  addAudioEventListener,
33
+ addMicrophoneErrorListener,
33
34
  AudioEventPayload,
34
35
  AudioEvents,
35
36
  subscribeToEvent,
@@ -59,27 +60,20 @@ export class ExpoPlayAudioStream {
59
60
  }> {
60
61
  let subscription: Subscription | undefined;
61
62
  try {
62
- const { onAudioStream, ...options } = recordingConfig;
63
+ const { onAudioStream, onError, ...options } = recordingConfig;
63
64
 
64
- if (onAudioStream && typeof onAudioStream == "function") {
65
- subscription = addAudioEventListener(
66
- async (event: AudioEventPayload) => {
67
- const {
68
- fileUri,
69
- deltaSize,
70
- totalSize,
71
- position,
72
- encoded,
73
- soundLevel,
74
- frequencyBands,
75
- } = event;
65
+ const subscriptions: EventSubscription[] = [];
66
+
67
+ if (onAudioStream && typeof onAudioStream === "function") {
68
+ subscriptions.push(
69
+ addAudioEventListener(async (event: AudioEventPayload) => {
70
+ const { fileUri, deltaSize, totalSize, position, encoded, soundLevel, frequencyBands, error } = event;
71
+ if (error) return; // handled by MicrophoneError subscription; ignore here
76
72
  if (!encoded) {
77
- console.error(
78
- `[ExpoPlayAudioStream] Encoded audio data is missing`
79
- );
73
+ console.error(`[ExpoPlayAudioStream] Encoded audio data is missing`);
80
74
  throw new Error("Encoded audio data is missing");
81
75
  }
82
- onAudioStream?.({
76
+ onAudioStream({
83
77
  data: encoded,
84
78
  position,
85
79
  fileUri,
@@ -88,10 +82,22 @@ export class ExpoPlayAudioStream {
88
82
  soundLevel,
89
83
  frequencyBands,
90
84
  });
91
- }
85
+ })
92
86
  );
93
87
  }
94
88
 
89
+ if (onError && typeof onError === "function") {
90
+ subscriptions.push(
91
+ addMicrophoneErrorListener((event) => {
92
+ onError(event);
93
+ })
94
+ );
95
+ }
96
+
97
+ if (subscriptions.length > 0) {
98
+ subscription = { remove: () => subscriptions.forEach((s) => s.remove()) };
99
+ }
100
+
95
101
  const result = await ExpoPlayAudioStreamModule.startMicrophone(options);
96
102
 
97
103
  return { recordingResult: result, subscription };
@@ -240,6 +246,8 @@ export {
240
246
  // Re-export Subscription type for backwards compatibility
241
247
  export type { EventSubscription } from "expo-modules-core";
242
248
  export type { Subscription } from "./events";
249
+ export { addMicrophoneErrorListener } from "./events";
250
+ export type { MicrophoneErrorEventPayload } from "./events";
243
251
 
244
252
  // Export native audio pipeline V3
245
253
  export { Pipeline } from "./pipeline";
@@ -259,6 +267,7 @@ export type {
259
267
  PipelineZombieDetectedEvent,
260
268
  PipelineUnderrunEvent,
261
269
  PipelineDrainedEvent,
270
+ PipelinePlaybackStoppedEvent,
262
271
  PipelineAudioFocusLostEvent,
263
272
  PipelineAudioFocusResumedEvent,
264
273
  } from "./pipeline";
@@ -103,6 +103,22 @@ export class Pipeline {
103
103
  return ExpoPlayAudioStreamModule.getPipelineTelemetry() as PipelineTelemetry;
104
104
  }
105
105
 
106
+ /**
107
+ * Query the platform's current output latency — i.e., how long after a
108
+ * sample is written to the native buffer before it actually leaves the
109
+ * speaker.
110
+ *
111
+ * Value can change mid-session, notably on audio route changes such as
112
+ * switching from built-in speaker to Bluetooth (Bluetooth typically adds
113
+ * 100+ ms). **Always query at the moment you care; do not cache.**
114
+ *
115
+ * Returns 0 if the pipeline is not connected or the platform cannot
116
+ * report a value.
117
+ */
118
+ static getOutputLatencyMs(): number {
119
+ return ExpoPlayAudioStreamModule.getPipelineOutputLatencyMs() as number;
120
+ }
121
+
106
122
  // ════════════════════════════════════════════════════════════════════════
107
123
  // Event subscriptions
108
124
  // ════════════════════════════════════════════════════════════════════════
@@ -211,6 +227,7 @@ export type {
211
227
  PipelineZombieDetectedEvent,
212
228
  PipelineUnderrunEvent,
213
229
  PipelineDrainedEvent,
230
+ PipelinePlaybackStoppedEvent,
214
231
  PipelineAudioFocusLostEvent,
215
232
  PipelineAudioFocusResumedEvent,
216
233
  } from './types';
@@ -135,6 +135,20 @@ export interface PipelineDrainedEvent {
135
135
  turnId: string;
136
136
  }
137
137
 
138
+ /**
139
+ * Payload for `PipelinePlaybackStopped`.
140
+ *
141
+ * Fired when the last sample physically leaves the speaker, approximately
142
+ * `outputLatencyMs` after `PipelineDrained` for the same turn. Pairs with
143
+ * `PipelinePlaybackStarted` (start-of-emission ↔ end-of-emission).
144
+ *
145
+ * Note: this is a physical-world milestone, distinct from `state: 'idle'`
146
+ * (the pipeline-state-machine value reported via `PipelineStateChanged`).
147
+ */
148
+ export interface PipelinePlaybackStoppedEvent {
149
+ turnId: string;
150
+ }
151
+
138
152
  /** Payload for `PipelineAudioFocusLost` (empty — presence is the signal). */
139
153
  export type PipelineAudioFocusLostEvent = Record<string, never>;
140
154
 
@@ -155,6 +169,7 @@ export interface PipelineEventMap {
155
169
  PipelineZombieDetected: PipelineZombieDetectedEvent;
156
170
  PipelineUnderrun: PipelineUnderrunEvent;
157
171
  PipelineDrained: PipelineDrainedEvent;
172
+ PipelinePlaybackStopped: PipelinePlaybackStoppedEvent;
158
173
  PipelineAudioFocusLost: PipelineAudioFocusLostEvent;
159
174
  PipelineAudioFocusResumed: PipelineAudioFocusResumedEvent;
160
175
  PipelineFrequencyBands: PipelineFrequencyBandsEvent;
package/src/types.ts CHANGED
@@ -129,10 +129,32 @@ export interface RecordingConfig {
129
129
  enableProcessing?: boolean; // Boolean to enable/disable audio processing (default is false)
130
130
  pointsPerSecond?: number; // Number of data points to extract per second of audio (default is 1000)
131
131
  onAudioStream?: (event: AudioDataEvent) => Promise<void>; // Callback function to handle audio stream
132
+ /** Fired when the native layer reports a mid-recording error (e.g. system
133
+ * interruption like Siri or a phone call). The consumer should treat the
134
+ * recording session as terminated and clean up. */
135
+ onError?: (event: MicrophoneErrorEvent) => void;
132
136
  /** Optional frequency band crossover configuration. */
133
137
  frequencyBandConfig?: FrequencyBandConfig;
134
138
  }
135
139
 
140
+ export interface MicrophoneErrorEvent {
141
+ code: string;
142
+ message: string;
143
+ /**
144
+ * True when the recording session has stopped and the caller must call
145
+ * stopMicrophone() and reconnect to resume. False for transient conditions
146
+ * where recording continues (READ_ERROR on iOS) or the library will
147
+ * auto-recover (INTERRUPTED + autoResuming: true).
148
+ */
149
+ isFatal: boolean;
150
+ /**
151
+ * True only when code is INTERRUPTED — the library is waiting for the
152
+ * system to return the audio session and will reinstall the tap
153
+ * automatically. Always false when isFatal is true.
154
+ */
155
+ autoResuming: boolean;
156
+ }
157
+
136
158
  export interface Chunk {
137
159
  text: string;
138
160
  timestamp: [number, number | null];