@edkimmel/expo-audio-stream 0.4.2 → 0.5.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/build/types.d.ts CHANGED
@@ -103,9 +103,17 @@ export interface RecordingConfig {
103
103
  enableProcessing?: boolean;
104
104
  pointsPerSecond?: number;
105
105
  onAudioStream?: (event: AudioDataEvent) => Promise<void>;
106
+ /** Fired when the native layer reports a mid-recording error (e.g. system
107
+ * interruption like Siri or a phone call). The consumer should treat the
108
+ * recording session as terminated and clean up. */
109
+ onError?: (event: MicrophoneErrorEvent) => void;
106
110
  /** Optional frequency band crossover configuration. */
107
111
  frequencyBandConfig?: FrequencyBandConfig;
108
112
  }
113
+ export interface MicrophoneErrorEvent {
114
+ code: string;
115
+ message: string;
116
+ }
109
117
  export interface Chunk {
110
118
  text: string;
111
119
  timestamp: [number, number | null];
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,qBAAqB,GAC7B,WAAW,GACX,WAAW,GACX,UAAU,CAAC;AACf,MAAM,MAAM,UAAU,GAAG,KAAK,GAAG,KAAK,GAAG,KAAK,GAAG,KAAK,CAAC;AACvD,MAAM,MAAM,QAAQ,GAAG,CAAC,GAAG,EAAE,GAAG,EAAE,CAAC;AAEnC,eAAO,MAAM,aAAa;;;;CAIhB,CAAC;AACX;;GAEG;AACH,MAAM,MAAM,YAAY,GACtB,CAAC,OAAO,aAAa,CAAC,CAAC,MAAM,OAAO,aAAa,CAAC,CAAC;AAErD;;GAEG;AACH,MAAM,WAAW,oBAAoB;IACnC;;OAEG;IACH,MAAM,EAAE,MAAM,CAAC;IAEf;;OAEG;IACH,QAAQ,CAAC,EAAE,QAAQ,CAAC;IAEpB;;OAEG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC,kBAAkB,CAAC,CAAC;IAE3C;;OAEG;IACH,cAAc,CAAC,EAAE,CAAC,OAAO,EAAE,oBAAoB,KAAK,IAAI,CAAC;CAC1D;AAED,eAAO,MAAM,aAAa;;;CAGhB,CAAC;AAEX;;GAEG;AACH,MAAM,MAAM,QAAQ,GAClB,CAAC,OAAO,aAAa,CAAC,CAAC,MAAM,OAAO,aAAa,CAAC,CAAC;AAErD,mDAAmD;AACnD,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;CACd;AAED,2DAA2D;AAC3D,MAAM,WAAW,mBAAmB;IAClC,6CAA6C;IAC7C,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,+CAA+C;IAC/C,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED;;GAEG;AACH,MAAM,MAAM,eAAe,GACvB,cAAc,GACd,UAAU,GACV,YAAY,GACZ,UAAU,CAAC;AAEf;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,eAAe,CAAC;IACtB,iBAAiB,CAAC,EAAE,iBAAiB,CAAC;IACtC,kBAAkB,CAAC,EAAE;QACnB,aAAa,CAAC,EAAE,MAAM,CAAC;QACvB,YAAY,CAAC,EAAE,MAAM,CAAC;QACtB,iBAAiB,CAAC,EAAE,MAAM,CAAC;KAC5B,CAAC;CACH;AAED,MAAM,WAAW,oBAAoB;IACnC,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,UAAU,CAAC,EAAE,UAAU,CAAC;CACzB;AAED,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,GAAG,YAAY,CAAC;IAC5B,SAAS,CAAC,EAAE,MAAM,GAAG,YAAY,CAAC;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,mEAAmE;IACnE,cAAc,CAAC,EAAE,cAAc,CAAC;CACjC;AAED,MAAM,WAAW,eAAe;IAC9B,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,QAAQ,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC;IACjB,QAAQ,CAAC,EAAE,qBAAqB,CAAC;IACjC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAGlB,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACzD,uDAAuD;IACvD,mBAAmB,CAAC,EAAE,mBAAmB,CAAC;CAC3C;AAED,MAAM,WAAW,KAAK;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC,CAAC;CACpC;AAED,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,OAAO,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,KAAK,EAAE,CAAC;CACjB;AAED,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,QAAQ,CAAC;IACnB,UAAU,EAAE,UAAU,CAAC;IACvB,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,eAAe,EAAE,CAAC;IAChC,UAAU,CAAC,EAAE,YAAY,CAAC;CAC3B;AAID;;GAEG;AACH,MAAM,WAAW,kBAAkB;IACjC,cAAc,EAAE,MAAM,CAAC;IACvB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,eAAe,EAAE,MAAM,CAAC;CACzB;AAED;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED;;GAEG;AACH,MAAM,WAAW,WAAW;IAC1B,cAAc,EAAE,MAAM,CAAC;IACvB,IAAI,EAAE,iBAAiB,CAAC;IACxB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;GAEG;AACH,MAAM,MAAM,iBAAiB,GACzB,MAAM,GACN,SAAS,GACT,UAAU,GACV,UAAU,CAAC;AAEf;;GAEG;AACH,MAAM,WAAW,oBAAoB;IACnC,eAAe,EAAE,MAAM,CAAC;IACxB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC;IACtB,iBAAiB,EAAE,iBAAiB,CAAC;IACrC,wBAAwB,EAAE,MAAM,CAAC;CAClC;AAED;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC,aAAa,CAAC,SAAS,EAAE,iBAAiB,GAAG,IAAI,CAAC;IAClD,aAAa,IAAI,IAAI,CAAC;IACtB,YAAY,IAAI,IAAI,CAAC;IACrB,SAAS,IAAI,OAAO,CAAC;IACrB,gBAAgB,IAAI,oBAAoB,CAAC;IACzC,YAAY,CAAC,MAAM,EAAE,OAAO,CAAC,kBAAkB,CAAC,GAAG,IAAI,CAAC;IACxD,wBAAwB,IAAI,IAAI,CAAC;IACjC,OAAO,IAAI,IAAI,CAAC;IAChB,kBAAkB,IAAI,MAAM,CAAC;CAC9B;AAED;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,UAAU,CAAC,OAAO,EAAE,iBAAiB,GAAG,WAAW,EAAE,CAAC;IACtD,KAAK,IAAI,IAAI,CAAC;CACf;AAED;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5C,cAAc,IAAI,IAAI,CAAC;IACvB,aAAa,IAAI,IAAI,CAAC;IACtB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1C,UAAU,IAAI,oBAAoB,CAAC;IACnC,oBAAoB,CAClB,SAAS,EAAE,OAAO,EAClB,gBAAgB,EAAE,MAAM,GACvB,iBAAiB,CAAC;IACrB,wBAAwB,IAAI,MAAM,CAAC;IACnC,KAAK,IAAI,IAAI,CAAC;CACf"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,qBAAqB,GAC7B,WAAW,GACX,WAAW,GACX,UAAU,CAAC;AACf,MAAM,MAAM,UAAU,GAAG,KAAK,GAAG,KAAK,GAAG,KAAK,GAAG,KAAK,CAAC;AACvD,MAAM,MAAM,QAAQ,GAAG,CAAC,GAAG,EAAE,GAAG,EAAE,CAAC;AAEnC,eAAO,MAAM,aAAa;;;;CAIhB,CAAC;AACX;;GAEG;AACH,MAAM,MAAM,YAAY,GACtB,CAAC,OAAO,aAAa,CAAC,CAAC,MAAM,OAAO,aAAa,CAAC,CAAC;AAErD;;GAEG;AACH,MAAM,WAAW,oBAAoB;IACnC;;OAEG;IACH,MAAM,EAAE,MAAM,CAAC;IAEf;;OAEG;IACH,QAAQ,CAAC,EAAE,QAAQ,CAAC;IAEpB;;OAEG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC,kBAAkB,CAAC,CAAC;IAE3C;;OAEG;IACH,cAAc,CAAC,EAAE,CAAC,OAAO,EAAE,oBAAoB,KAAK,IAAI,CAAC;CAC1D;AAED,eAAO,MAAM,aAAa;;;CAGhB,CAAC;AAEX;;GAEG;AACH,MAAM,MAAM,QAAQ,GAClB,CAAC,OAAO,aAAa,CAAC,CAAC,MAAM,OAAO,aAAa,CAAC,CAAC;AAErD,mDAAmD;AACnD,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;CACd;AAED,2DAA2D;AAC3D,MAAM,WAAW,mBAAmB;IAClC,6CAA6C;IAC7C,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,+CAA+C;IAC/C,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED;;GAEG;AACH,MAAM,MAAM,eAAe,GACvB,cAAc,GACd,UAAU,GACV,YAAY,GACZ,UAAU,CAAC;AAEf;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,eAAe,CAAC;IACtB,iBAAiB,CAAC,EAAE,iBAAiB,CAAC;IACtC,kBAAkB,CAAC,EAAE;QACnB,aAAa,CAAC,EAAE,MAAM,CAAC;QACvB,YAAY,CAAC,EAAE,MAAM,CAAC;QACtB,iBAAiB,CAAC,EAAE,MAAM,CAAC;KAC5B,CAAC;CACH;AAED,MAAM,WAAW,oBAAoB;IACnC,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,UAAU,CAAC,EAAE,UAAU,CAAC;CACzB;AAED,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,GAAG,YAAY,CAAC;IAC5B,SAAS,CAAC,EAAE,MAAM,GAAG,YAAY,CAAC;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,mEAAmE;IACnE,cAAc,CAAC,EAAE,cAAc,CAAC;CACjC;AAED,MAAM,WAAW,eAAe;IAC9B,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,QAAQ,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC;IACjB,QAAQ,CAAC,EAAE,qBAAqB,CAAC;IACjC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAGlB,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACzD;;uDAEmD;IACnD,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,oBAAoB,KAAK,IAAI,CAAC;IAChD,uDAAuD;IACvD,mBAAmB,CAAC,EAAE,mBAAmB,CAAC;CAC3C;AAED,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,KAAK;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC,CAAC;CACpC;AAED,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,OAAO,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,KAAK,EAAE,CAAC;CACjB;AAED,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,QAAQ,CAAC;IACnB,UAAU,EAAE,UAAU,CAAC;IACvB,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,eAAe,EAAE,CAAC;IAChC,UAAU,CAAC,EAAE,YAAY,CAAC;CAC3B;AAID;;GAEG;AACH,MAAM,WAAW,kBAAkB;IACjC,cAAc,EAAE,MAAM,CAAC;IACvB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,eAAe,EAAE,MAAM,CAAC;CACzB;AAED;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED;;GAEG;AACH,MAAM,WAAW,WAAW;IAC1B,cAAc,EAAE,MAAM,CAAC;IACvB,IAAI,EAAE,iBAAiB,CAAC;IACxB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;GAEG;AACH,MAAM,MAAM,iBAAiB,GACzB,MAAM,GACN,SAAS,GACT,UAAU,GACV,UAAU,CAAC;AAEf;;GAEG;AACH,MAAM,WAAW,oBAAoB;IACnC,eAAe,EAAE,MAAM,CAAC;IACxB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC;IACtB,iBAAiB,EAAE,iBAAiB,CAAC;IACrC,wBAAwB,EAAE,MAAM,CAAC;CAClC;AAED;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC,aAAa,CAAC,SAAS,EAAE,iBAAiB,GAAG,IAAI,CAAC;IAClD,aAAa,IAAI,IAAI,CAAC;IACtB,YAAY,IAAI,IAAI,CAAC;IACrB,SAAS,IAAI,OAAO,CAAC;IACrB,gBAAgB,IAAI,oBAAoB,CAAC;IACzC,YAAY,CAAC,MAAM,EAAE,OAAO,CAAC,kBAAkB,CAAC,GAAG,IAAI,CAAC;IACxD,wBAAwB,IAAI,IAAI,CAAC;IACjC,OAAO,IAAI,IAAI,CAAC;IAChB,kBAAkB,IAAI,MAAM,CAAC;CAC9B;AAED;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,UAAU,CAAC,OAAO,EAAE,iBAAiB,GAAG,WAAW,EAAE,CAAC;IACtD,KAAK,IAAI,IAAI,CAAC;CACf;AAED;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5C,cAAc,IAAI,IAAI,CAAC;IACvB,aAAa,IAAI,IAAI,CAAC;IACtB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1C,UAAU,IAAI,oBAAoB,CAAC;IACnC,oBAAoB,CAClB,SAAS,EAAE,OAAO,EAClB,gBAAgB,EAAE,MAAM,GACvB,iBAAiB,CAAC;IACrB,wBAAwB,IAAI,MAAM,CAAC;IACnC,KAAK,IAAI,IAAI,CAAC;CACf"}
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAOA,MAAM,CAAC,MAAM,aAAa,GAAG;IAC3B,OAAO,EAAE,SAAS;IAClB,gBAAgB,EAAE,iBAAiB;IACnC,YAAY,EAAE,cAAc;CACpB,CAAC;AAgCX,MAAM,CAAC,MAAM,aAAa,GAAG;IAC3B,SAAS,EAAE,WAAW;IACtB,SAAS,EAAE,WAAW;CACd,CAAC","sourcesContent":["export type RecordingEncodingType =\n | 'pcm_32bit'\n | 'pcm_16bit'\n | 'pcm_8bit';\nexport type SampleRate = 16000 | 24000 | 44100 | 48000;\nexport type BitDepth = 8 | 16 | 32;\n\nexport const PlaybackModes = {\n REGULAR: 'regular',\n VOICE_PROCESSING: 'voiceProcessing',\n CONVERSATION: 'conversation',\n} as const;\n/**\n * Defines different playback modes for audio processing\n */\nexport type PlaybackMode =\n (typeof PlaybackModes)[keyof typeof PlaybackModes];\n\n/**\n * Configuration for buffered audio streaming\n */\nexport interface BufferedStreamConfig {\n /**\n * Turn ID for queue management\n */\n turnId: string;\n\n /**\n * Audio encoding format\n */\n encoding?: Encoding;\n\n /**\n * Buffer configuration options\n */\n bufferConfig?: Partial<IAudioBufferConfig>;\n\n /**\n * Callback for buffer health updates\n */\n onBufferHealth?: (metrics: IBufferHealthMetrics) => void;\n}\n\nexport const EncodingTypes = {\n PCM_F32LE: 'pcm_f32le',\n PCM_S16LE: 'pcm_s16le',\n} as const;\n\n/**\n * Defines different encoding formats for audio data\n */\nexport type Encoding =\n (typeof EncodingTypes)[keyof typeof EncodingTypes];\n\n/** RMS energy per frequency band, range [0, 1]. */\nexport interface FrequencyBands {\n low: number;\n mid: number;\n high: number;\n}\n\n/** Crossover frequency configuration for band analysis. */\nexport interface FrequencyBandConfig {\n /** Low/mid crossover in Hz (default 300). */\n lowCrossoverHz?: number;\n /** Mid/high crossover in Hz (default 2000). */\n highCrossoverHz?: number;\n}\n\n/**\n * Smart buffering mode options\n */\nexport type SmartBufferMode =\n | 'conservative'\n | 'balanced'\n | 'aggressive'\n | 'adaptive';\n\n/**\n * Network condition indicators for smart buffering\n */\nexport interface NetworkConditions {\n latency?: number; // Round-trip time in ms\n jitter?: number; // Network jitter in ms\n packetLoss?: number; // Packet loss percentage (0-100)\n bandwidth?: number; // Available bandwidth estimate\n}\n\n/**\n * Smart buffering configuration\n */\nexport interface SmartBufferConfig {\n mode: SmartBufferMode;\n networkConditions?: NetworkConditions;\n adaptiveThresholds?: {\n highLatencyMs?: number; // Threshold to enable aggressive buffering\n highJitterMs?: number; // Threshold to increase buffer size\n packetLossPercent?: number; // Threshold to enable buffering\n };\n}\n\nexport interface StartRecordingResult {\n fileUri: string;\n mimeType: string;\n channels?: number;\n bitDepth?: BitDepth;\n sampleRate?: SampleRate;\n}\n\nexport interface AudioDataEvent {\n data: string | Float32Array;\n data16kHz?: string | Float32Array;\n position: number;\n fileUri: string;\n eventDataSize: number;\n totalSize: number;\n soundLevel?: number;\n /** Frequency band RMS energy, present when recording is active. */\n frequencyBands?: FrequencyBands;\n}\n\nexport interface RecordingConfig {\n sampleRate?: SampleRate; // Sample rate for recording\n channels?: 1 | 2; // 1 or 2 (MONO or STEREO)\n encoding?: RecordingEncodingType; // Encoding type for the recording\n interval?: number; // Interval in milliseconds at which to emit recording data\n\n // Optional parameters for audio processing\n enableProcessing?: boolean; // Boolean to enable/disable audio processing (default is false)\n pointsPerSecond?: number; // Number of data points to extract per second of audio (default is 1000)\n onAudioStream?: (event: AudioDataEvent) => Promise<void>; // Callback function to handle audio stream\n /** Optional frequency band crossover configuration. */\n frequencyBandConfig?: FrequencyBandConfig;\n}\n\nexport interface Chunk {\n text: string;\n timestamp: [number, number | null];\n}\n\nexport interface TranscriberData {\n id: string;\n isBusy: boolean;\n text: string;\n startTime: number;\n endTime: number;\n chunks: Chunk[];\n}\n\nexport interface AudioRecording {\n fileUri: string;\n filename: string;\n durationMs: number;\n size: number;\n channels: number;\n bitDepth: BitDepth;\n sampleRate: SampleRate;\n mimeType: string;\n transcripts?: TranscriberData[];\n wavPCMData?: Float32Array; // Full PCM data for the recording in WAV format (only on web, for native use the fileUri)\n}\n\n// Audio Jitter Buffer Types\n\n/**\n * Configuration for audio buffer management\n */\nexport interface IAudioBufferConfig {\n targetBufferMs: number; // Target buffer size in milliseconds\n minBufferMs: number; // Minimum buffer size before underrun handling\n maxBufferMs: number; // Maximum buffer size before overrun handling\n frameIntervalMs: number; // Expected frame interval in milliseconds\n}\n\n/**\n * Audio payload for playback containing base64 encoded audio data\n */\nexport interface IAudioPlayPayload {\n audioData: string; // Base64 encoded PCM audio data\n isFirst?: boolean; // True if this is the first chunk in a stream\n isFinal?: boolean; // True if this is the final chunk in a stream\n}\n\n/**\n * Processed audio frame with metadata\n */\nexport interface IAudioFrame {\n sequenceNumber: number; // Sequential frame number\n data: IAudioPlayPayload; // Original audio payload\n duration: number; // Estimated frame duration in milliseconds\n timestamp: number; // Frame timestamp when processed\n}\n\n/**\n * Buffer health states for quality monitoring\n */\nexport type BufferHealthState =\n | 'idle'\n | 'healthy'\n | 'degraded'\n | 'critical';\n\n/**\n * Comprehensive buffer health and quality metrics\n */\nexport interface IBufferHealthMetrics {\n currentBufferMs: number; // Current buffer level in milliseconds\n targetBufferMs: number; // Target buffer level in milliseconds\n underrunCount: number; // Total number of buffer underruns\n overrunCount: number; // Total number of buffer overruns\n averageJitter: number; // Average network jitter in milliseconds\n bufferHealthState: BufferHealthState; // Current buffer health assessment\n adaptiveAdjustmentsCount: number; // Number of adaptive adjustments made\n}\n\n/**\n * Interface for audio buffer management\n */\nexport interface IAudioBufferManager {\n enqueueFrames(audioData: IAudioPlayPayload): void;\n startPlayback(): void;\n stopPlayback(): void;\n isPlaying(): boolean;\n getHealthMetrics(): IBufferHealthMetrics;\n updateConfig(config: Partial<IAudioBufferConfig>): void;\n applyAdaptiveAdjustments(): void;\n destroy(): void;\n getCurrentBufferMs(): number;\n}\n\n/**\n * Interface for frame processing\n */\nexport interface IFrameProcessor {\n parseChunk(payload: IAudioPlayPayload): IAudioFrame[];\n reset(): void;\n}\n\n/**\n * Interface for quality monitoring\n */\nexport interface IQualityMonitor {\n recordFrameArrival(timestamp: number): void;\n recordUnderrun(): void;\n recordOverrun(): void;\n updateBufferLevel(bufferMs: number): void;\n getMetrics(): IBufferHealthMetrics;\n getBufferHealthState(\n isPlaying: boolean,\n currentLatencyMs: number\n ): BufferHealthState;\n getRecommendedAdjustment(): number;\n reset(): void;\n}\n"]}
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAOA,MAAM,CAAC,MAAM,aAAa,GAAG;IAC3B,OAAO,EAAE,SAAS;IAClB,gBAAgB,EAAE,iBAAiB;IACnC,YAAY,EAAE,cAAc;CACpB,CAAC;AAgCX,MAAM,CAAC,MAAM,aAAa,GAAG;IAC3B,SAAS,EAAE,WAAW;IACtB,SAAS,EAAE,WAAW;CACd,CAAC","sourcesContent":["export type RecordingEncodingType =\n | 'pcm_32bit'\n | 'pcm_16bit'\n | 'pcm_8bit';\nexport type SampleRate = 16000 | 24000 | 44100 | 48000;\nexport type BitDepth = 8 | 16 | 32;\n\nexport const PlaybackModes = {\n REGULAR: 'regular',\n VOICE_PROCESSING: 'voiceProcessing',\n CONVERSATION: 'conversation',\n} as const;\n/**\n * Defines different playback modes for audio processing\n */\nexport type PlaybackMode =\n (typeof PlaybackModes)[keyof typeof PlaybackModes];\n\n/**\n * Configuration for buffered audio streaming\n */\nexport interface BufferedStreamConfig {\n /**\n * Turn ID for queue management\n */\n turnId: string;\n\n /**\n * Audio encoding format\n */\n encoding?: Encoding;\n\n /**\n * Buffer configuration options\n */\n bufferConfig?: Partial<IAudioBufferConfig>;\n\n /**\n * Callback for buffer health updates\n */\n onBufferHealth?: (metrics: IBufferHealthMetrics) => void;\n}\n\nexport const EncodingTypes = {\n PCM_F32LE: 'pcm_f32le',\n PCM_S16LE: 'pcm_s16le',\n} as const;\n\n/**\n * Defines different encoding formats for audio data\n */\nexport type Encoding =\n (typeof EncodingTypes)[keyof typeof EncodingTypes];\n\n/** RMS energy per frequency band, range [0, 1]. */\nexport interface FrequencyBands {\n low: number;\n mid: number;\n high: number;\n}\n\n/** Crossover frequency configuration for band analysis. */\nexport interface FrequencyBandConfig {\n /** Low/mid crossover in Hz (default 300). */\n lowCrossoverHz?: number;\n /** Mid/high crossover in Hz (default 2000). */\n highCrossoverHz?: number;\n}\n\n/**\n * Smart buffering mode options\n */\nexport type SmartBufferMode =\n | 'conservative'\n | 'balanced'\n | 'aggressive'\n | 'adaptive';\n\n/**\n * Network condition indicators for smart buffering\n */\nexport interface NetworkConditions {\n latency?: number; // Round-trip time in ms\n jitter?: number; // Network jitter in ms\n packetLoss?: number; // Packet loss percentage (0-100)\n bandwidth?: number; // Available bandwidth estimate\n}\n\n/**\n * Smart buffering configuration\n */\nexport interface SmartBufferConfig {\n mode: SmartBufferMode;\n networkConditions?: NetworkConditions;\n adaptiveThresholds?: {\n highLatencyMs?: number; // Threshold to enable aggressive buffering\n highJitterMs?: number; // Threshold to increase buffer size\n packetLossPercent?: number; // Threshold to enable buffering\n };\n}\n\nexport interface StartRecordingResult {\n fileUri: string;\n mimeType: string;\n channels?: number;\n bitDepth?: BitDepth;\n sampleRate?: SampleRate;\n}\n\nexport interface AudioDataEvent {\n data: string | Float32Array;\n data16kHz?: string | Float32Array;\n position: number;\n fileUri: string;\n eventDataSize: number;\n totalSize: number;\n soundLevel?: number;\n /** Frequency band RMS energy, present when recording is active. */\n frequencyBands?: FrequencyBands;\n}\n\nexport interface RecordingConfig {\n sampleRate?: SampleRate; // Sample rate for recording\n channels?: 1 | 2; // 1 or 2 (MONO or STEREO)\n encoding?: RecordingEncodingType; // Encoding type for the recording\n interval?: number; // Interval in milliseconds at which to emit recording data\n\n // Optional parameters for audio processing\n enableProcessing?: boolean; // Boolean to enable/disable audio processing (default is false)\n pointsPerSecond?: number; // Number of data points to extract per second of audio (default is 1000)\n onAudioStream?: (event: AudioDataEvent) => Promise<void>; // Callback function to handle audio stream\n /** Fired when the native layer reports a mid-recording error (e.g. system\n * interruption like Siri or a phone call). The consumer should treat the\n * recording session as terminated and clean up. */\n onError?: (event: MicrophoneErrorEvent) => void;\n /** Optional frequency band crossover configuration. */\n frequencyBandConfig?: FrequencyBandConfig;\n}\n\nexport interface MicrophoneErrorEvent {\n code: string;\n message: string;\n}\n\nexport interface Chunk {\n text: string;\n timestamp: [number, number | null];\n}\n\nexport interface TranscriberData {\n id: string;\n isBusy: boolean;\n text: string;\n startTime: number;\n endTime: number;\n chunks: Chunk[];\n}\n\nexport interface AudioRecording {\n fileUri: string;\n filename: string;\n durationMs: number;\n size: number;\n channels: number;\n bitDepth: BitDepth;\n sampleRate: SampleRate;\n mimeType: string;\n transcripts?: TranscriberData[];\n wavPCMData?: Float32Array; // Full PCM data for the recording in WAV format (only on web, for native use the fileUri)\n}\n\n// Audio Jitter Buffer Types\n\n/**\n * Configuration for audio buffer management\n */\nexport interface IAudioBufferConfig {\n targetBufferMs: number; // Target buffer size in milliseconds\n minBufferMs: number; // Minimum buffer size before underrun handling\n maxBufferMs: number; // Maximum buffer size before overrun handling\n frameIntervalMs: number; // Expected frame interval in milliseconds\n}\n\n/**\n * Audio payload for playback containing base64 encoded audio data\n */\nexport interface IAudioPlayPayload {\n audioData: string; // Base64 encoded PCM audio data\n isFirst?: boolean; // True if this is the first chunk in a stream\n isFinal?: boolean; // True if this is the final chunk in a stream\n}\n\n/**\n * Processed audio frame with metadata\n */\nexport interface IAudioFrame {\n sequenceNumber: number; // Sequential frame number\n data: IAudioPlayPayload; // Original audio payload\n duration: number; // Estimated frame duration in milliseconds\n timestamp: number; // Frame timestamp when processed\n}\n\n/**\n * Buffer health states for quality monitoring\n */\nexport type BufferHealthState =\n | 'idle'\n | 'healthy'\n | 'degraded'\n | 'critical';\n\n/**\n * Comprehensive buffer health and quality metrics\n */\nexport interface IBufferHealthMetrics {\n currentBufferMs: number; // Current buffer level in milliseconds\n targetBufferMs: number; // Target buffer level in milliseconds\n underrunCount: number; // Total number of buffer underruns\n overrunCount: number; // Total number of buffer overruns\n averageJitter: number; // Average network jitter in milliseconds\n bufferHealthState: BufferHealthState; // Current buffer health assessment\n adaptiveAdjustmentsCount: number; // Number of adaptive adjustments made\n}\n\n/**\n * Interface for audio buffer management\n */\nexport interface IAudioBufferManager {\n enqueueFrames(audioData: IAudioPlayPayload): void;\n startPlayback(): void;\n stopPlayback(): void;\n isPlaying(): boolean;\n getHealthMetrics(): IBufferHealthMetrics;\n updateConfig(config: Partial<IAudioBufferConfig>): void;\n applyAdaptiveAdjustments(): void;\n destroy(): void;\n getCurrentBufferMs(): number;\n}\n\n/**\n * Interface for frame processing\n */\nexport interface IFrameProcessor {\n parseChunk(payload: IAudioPlayPayload): IAudioFrame[];\n reset(): void;\n}\n\n/**\n * Interface for quality monitoring\n */\nexport interface IQualityMonitor {\n recordFrameArrival(timestamp: number): void;\n recordUnderrun(): void;\n recordOverrun(): void;\n updateBufferLevel(bufferMs: number): void;\n getMetrics(): IBufferHealthMetrics;\n getBufferHealthState(\n isPlaying: boolean,\n currentLatencyMs: number\n ): BufferHealthState;\n getRecommendedAdjustment(): number;\n reset(): void;\n}\n"]}
@@ -17,6 +17,7 @@ protocol PipelineListener: AnyObject {
17
17
  func onZombieDetected(stalledMs: Int64)
18
18
  func onUnderrun(count: Int)
19
19
  func onDrained(turnId: String)
20
+ func onPlaybackStopped(turnId: String)
20
21
  func onAudioFocusLost()
21
22
  func onAudioFocusResumed()
22
23
  func onFrequencyBands(low: Float, mid: Float, high: Float)
@@ -84,6 +85,10 @@ class AudioPipeline: SharedAudioEngineDelegate {
84
85
  /// This prevents duplicate chains and stale callbacks from re-entering after a rebuild.
85
86
  private var scheduleGeneration: Int = 0
86
87
 
88
+ /// Pending PlaybackStopped dispatch — cancelled on new turn / disconnect.
89
+ /// Always mutate from the main queue to avoid races with the drain timer.
90
+ private var pendingPlaybackStoppedWork: DispatchWorkItem?
91
+
87
92
  // ── Timers ──────────────────────────────────────────────────────────
88
93
  private var stateTimer: DispatchSourceTimer?
89
94
  private var zombieTimer: DispatchSourceTimer?
@@ -122,7 +127,7 @@ class AudioPipeline: SharedAudioEngineDelegate {
122
127
  // Connect / Disconnect
123
128
  // ════════════════════════════════════════════════════════════════════
124
129
 
125
- func connect() {
130
+ func connect() throws {
126
131
  guard !running else {
127
132
  Logger.debug("[\(AudioPipeline.TAG)] connect() called while already running — ignoring")
128
133
  return
@@ -197,12 +202,17 @@ class AudioPipeline: SharedAudioEngineDelegate {
197
202
  } catch {
198
203
  Logger.debug("[\(AudioPipeline.TAG)] connect() failed: \(error)")
199
204
  setState(.error)
200
- listener?.onError(code: "CONNECT_FAILED", message: error.localizedDescription)
201
205
  disconnect()
206
+ throw error
202
207
  }
203
208
  }
204
209
 
205
210
  func disconnect() {
211
+ // Cancel any pending PlaybackStopped before tearing down.
212
+ // DispatchWorkItem.cancel is thread-safe; we may be on any queue here.
213
+ pendingPlaybackStoppedWork?.cancel()
214
+ pendingPlaybackStoppedWork = nil
215
+
206
216
  running = false
207
217
  // Invalidate all in-flight completion handlers before detaching.
208
218
  scheduleGeneration += 1
@@ -365,6 +375,9 @@ class AudioPipeline: SharedAudioEngineDelegate {
365
375
  lastReportedUnderrunCount = 0
366
376
  setState(.streaming)
367
377
  frequencyBandAnalyzer?.reset()
378
+ DispatchQueue.main.async { [weak self] in
379
+ self?.cancelPendingPlaybackStopped()
380
+ }
368
381
  }
369
382
 
370
383
  // ── Decode base64 → PCM shorts ──────────────────────────────────
@@ -407,6 +420,9 @@ class AudioPipeline: SharedAudioEngineDelegate {
407
420
  lastReportedUnderrunCount = 0
408
421
  setState(.idle)
409
422
  frequencyBandAnalyzer?.reset()
423
+ DispatchQueue.main.async { [weak self] in
424
+ self?.cancelPendingPlaybackStopped()
425
+ }
410
426
  }
411
427
 
412
428
  // ════════════════════════════════════════════════════════════════════
@@ -433,6 +449,18 @@ class AudioPipeline: SharedAudioEngineDelegate {
433
449
  ]
434
450
  }
435
451
 
452
+ /// Current platform output latency in milliseconds.
453
+ ///
454
+ /// Reads `AVAudioSession.sharedInstance().outputLatency`. The value
455
+ /// reflects total HW output latency to the speaker and changes on
456
+ /// audio route changes (built-in vs. Bluetooth vs. wired).
457
+ ///
458
+ /// Returns 0 if not running.
459
+ func outputLatencyMs() -> Double {
460
+ guard running else { return 0 }
461
+ return AVAudioSession.sharedInstance().outputLatency * 1000.0
462
+ }
463
+
436
464
  // ════════════════════════════════════════════════════════════════════
437
465
  // Scheduling loop
438
466
  // ════════════════════════════════════════════════════════════════════
@@ -548,11 +576,48 @@ class AudioPipeline: SharedAudioEngineDelegate {
548
576
  if buf.isDrained() && currentState == .draining {
549
577
  if let tid = turnId {
550
578
  listener?.onDrained(turnId: tid)
579
+ schedulePlaybackStopped(turnId: tid)
551
580
  }
552
581
  setState(.idle)
553
582
  }
554
583
  }
555
584
 
585
+ /// Schedule a `PlaybackStopped` event approximately `outputLatencyMs`
586
+ /// after `Drained`. Cancels any previously pending dispatch.
587
+ ///
588
+ /// Approximation note: at drain detection, up to `PRE_SCHEDULE_COUNT`
589
+ /// pre-scheduled buffers may still be in the player node's chain — but
590
+ /// they read silence from the empty jitter buffer, so the last audible
591
+ /// sample stops emitting roughly `outputLatency` after this point.
592
+ private func schedulePlaybackStopped(turnId: String) {
593
+ pendingPlaybackStoppedWork?.cancel()
594
+ pendingPlaybackStoppedWork = nil
595
+
596
+ let latencyMs = outputLatencyMs()
597
+ let work = DispatchWorkItem { [weak self] in
598
+ guard let self = self else { return }
599
+ self.pendingPlaybackStoppedWork = nil
600
+ self.listener?.onPlaybackStopped(turnId: turnId)
601
+ }
602
+ pendingPlaybackStoppedWork = work
603
+
604
+ if latencyMs > 0 {
605
+ DispatchQueue.main.asyncAfter(
606
+ deadline: .now() + .milliseconds(Int(latencyMs.rounded())),
607
+ execute: work
608
+ )
609
+ } else {
610
+ DispatchQueue.main.async(execute: work)
611
+ }
612
+ }
613
+
614
+ /// Cancel any pending PlaybackStopped dispatch. Call from turn-boundary
615
+ /// transitions (new pushAudio, invalidateTurn) and disconnect.
616
+ private func cancelPendingPlaybackStopped() {
617
+ pendingPlaybackStoppedWork?.cancel()
618
+ pendingPlaybackStoppedWork = nil
619
+ }
620
+
556
621
  // ════════════════════════════════════════════════════════════════════
557
622
  // Zombie detection
558
623
  // ════════════════════════════════════════════════════════════════════
@@ -48,6 +48,7 @@ public class ExpoPlayAudioStreamModule: Module, MicrophoneDataDelegate, Pipeline
48
48
  PipelineIntegration.EVENT_ZOMBIE_DETECTED,
49
49
  PipelineIntegration.EVENT_UNDERRUN,
50
50
  PipelineIntegration.EVENT_DRAINED,
51
+ PipelineIntegration.EVENT_PLAYBACK_STOPPED,
51
52
  PipelineIntegration.EVENT_AUDIO_FOCUS_LOST,
52
53
  PipelineIntegration.EVENT_AUDIO_FOCUS_RESUMED,
53
54
  PipelineIntegration.EVENT_FREQUENCY_BANDS,
@@ -209,6 +210,15 @@ public class ExpoPlayAudioStreamModule: Module, MicrophoneDataDelegate, Pipeline
209
210
 
210
211
  promise.resolve(result)
211
212
  } catch {
213
+ // Reset session + engine state so a subsequent connect can recover.
214
+ // Without this, a partial failure (e.g. setActive denial after the
215
+ // iOS local-network permission prompt) leaves the session stuck and
216
+ // every retry fails the same way.
217
+ self._pipelineIntegration?.removeAsDelegate(from: self.sharedAudioEngine)
218
+ self._pipelineIntegration?.disconnect()
219
+ self.sharedAudioEngine.teardown()
220
+ try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
221
+ self.isAudioSessionInitialized = false
212
222
  promise.reject("PIPELINE_CONNECT_ERROR", error.localizedDescription)
213
223
  }
214
224
  }
@@ -248,6 +258,10 @@ public class ExpoPlayAudioStreamModule: Module, MicrophoneDataDelegate, Pipeline
248
258
  Function("getPipelineState") { () -> String in
249
259
  return self.pipelineIntegration.getState()
250
260
  }
261
+
262
+ Function("getPipelineOutputLatencyMs") { () -> Double in
263
+ return self.pipelineIntegration.outputLatencyMs()
264
+ }
251
265
  }
252
266
 
253
267
  private func ensureAudioSessionInitialized(settings recordingSettings: RecordingSettings? = nil) throws {
@@ -28,6 +28,14 @@ class Microphone {
28
28
  private var isSilent: Bool = false
29
29
  private var frequencyBandAnalyzer: FrequencyBandAnalyzer?
30
30
  private var frequencyBandConfig: (lowCrossoverHz: Float, highCrossoverHz: Float)?
31
+
32
+ /// Interval (in ms) the consumer last requested. Used to rebuild the tap
33
+ /// with the same cadence when resuming after an interruption.
34
+ private var lastIntervalMs: Int = 100
35
+ /// Set when an audio session interruption begins while recording is active.
36
+ /// Cleared either by `stopRecording` (consumer chose disconnect) or by the
37
+ /// `.ended` handler after a successful auto-resume.
38
+ private var pendingInterruptionResume: Bool = false
31
39
 
32
40
  init() {
33
41
  NotificationCenter.default.addObserver(
@@ -36,8 +44,18 @@ class Microphone {
36
44
  name: AVAudioSession.routeChangeNotification,
37
45
  object: nil
38
46
  )
47
+ NotificationCenter.default.addObserver(
48
+ self,
49
+ selector: #selector(handleInterruption),
50
+ name: AVAudioSession.interruptionNotification,
51
+ object: nil
52
+ )
39
53
  }
40
-
54
+
55
+ deinit {
56
+ NotificationCenter.default.removeObserver(self)
57
+ }
58
+
41
59
  /// Handles audio route changes (e.g. headphones connected/disconnected)
42
60
  /// - Parameter notification: The notification object containing route change information
43
61
  @objc private func handleRouteChange(notification: Notification) {
@@ -46,7 +64,7 @@ class Microphone {
46
64
  let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else {
47
65
  return
48
66
  }
49
-
67
+
50
68
  Logger.debug("[Microphone] Route is changed \(reason)")
51
69
 
52
70
  switch reason {
@@ -55,7 +73,7 @@ class Microphone {
55
73
  stopRecording(resolver: nil)
56
74
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
57
75
  guard let self = self, let settings = self.recordingSettings else { return }
58
-
76
+
59
77
  _ = startRecording(settings: self.recordingSettings!, intervalMilliseconds: 100, frequencyBandConfig: self.frequencyBandConfig)
60
78
  }
61
79
  }
@@ -65,6 +83,96 @@ class Microphone {
65
83
  break
66
84
  }
67
85
  }
86
+
87
+ /// Handles audio session interruptions (e.g. Siri, phone call, alarm).
88
+ ///
89
+ /// On `.began` we stop the engine (iOS deactivates the session regardless)
90
+ /// but keep `isRecording = true` and emit an `INTERRUPTED` error so the
91
+ /// consumer can decide:
92
+ /// - Call `stopRecording` to disconnect explicitly (clears the resume flag).
93
+ /// - Do nothing to let the library auto-resume when the system gives the
94
+ /// mic back on `.ended`.
95
+ @objc private func handleInterruption(notification: Notification) {
96
+ guard let info = notification.userInfo,
97
+ let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
98
+ let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
99
+ return
100
+ }
101
+
102
+ switch type {
103
+ case .began:
104
+ Logger.debug("[Microphone] Audio session interruption began")
105
+ guard isRecording else { return }
106
+ // Tear down the engine but leave isRecording true so the consumer's
107
+ // intent is preserved. The .ended branch will decide whether to
108
+ // resume based on whether stopRecording was called in between.
109
+ pendingInterruptionResume = true
110
+ if let engine = audioEngine {
111
+ engine.inputNode.removeTap(onBus: 0)
112
+ engine.stop()
113
+ }
114
+ delegate?.onMicrophoneError("INTERRUPTED", "Audio session interrupted by system")
115
+ case .ended:
116
+ Logger.debug("[Microphone] Audio session interruption ended")
117
+ guard pendingInterruptionResume else { return }
118
+ pendingInterruptionResume = false
119
+ // iOS uses AVAudioSessionInterruptionOptionKey.shouldResume to hint
120
+ // whether the app can safely reactivate. Absent means another app
121
+ // took over (e.g. an ongoing call) — in that case we surface a
122
+ // terminal error instead of attempting a doomed restart.
123
+ let optionsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
124
+ let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
125
+ guard options.contains(.shouldResume) else {
126
+ Logger.debug("[Microphone] System did not signal shouldResume — treating as terminal stop")
127
+ isRecording = false
128
+ delegate?.onMicrophoneError(
129
+ "RESUME_DENIED",
130
+ "System did not permit microphone resume after interruption"
131
+ )
132
+ return
133
+ }
134
+ do {
135
+ try AVAudioSession.sharedInstance().setActive(true)
136
+ try installTapAndStartEngine(intervalMilliseconds: lastIntervalMs)
137
+ Logger.debug("[Microphone] Auto-resumed after interruption")
138
+ } catch {
139
+ Logger.debug("[Microphone] Auto-resume failed: \(error.localizedDescription)")
140
+ // Engine couldn't restart — surface as terminal stop.
141
+ isRecording = false
142
+ delegate?.onMicrophoneError(
143
+ "RESUME_FAILED",
144
+ "Could not restart microphone after interruption: \(error.localizedDescription)"
145
+ )
146
+ }
147
+ @unknown default:
148
+ break
149
+ }
150
+ }
151
+
152
+ /// Installs the audio tap on the input node and starts the engine.
153
+ /// Shared between fresh `startRecording` and post-interruption resume.
154
+ private func installTapAndStartEngine(intervalMilliseconds: Int) throws {
155
+ let hardwareFormat = audioEngine.inputNode.inputFormat(forBus: 0)
156
+ let intervalSamples = AVAudioFrameCount(
157
+ Double(intervalMilliseconds) / 1000.0 * hardwareFormat.sampleRate
158
+ )
159
+ let tapBufferSize = max(intervalSamples, 256)
160
+
161
+ audioEngine.inputNode.installTap(onBus: 0, bufferSize: tapBufferSize, format: nil) { [weak self] (buffer, time) in
162
+ guard let self = self else { return }
163
+
164
+ guard buffer.frameLength > 0 else {
165
+ Logger.debug("Error: received empty buffer in tap callback")
166
+ self.delegate?.onMicrophoneError("READ_ERROR", "Received empty audio buffer")
167
+ return
168
+ }
169
+
170
+ self.processAudioBuffer(buffer)
171
+ self.lastBufferTime = time
172
+ }
173
+
174
+ try audioEngine.start()
175
+ }
68
176
 
69
177
  func toggleSilence(isSilent: Bool) {
70
178
  Logger.debug("[Microphone] toggleSilence")
@@ -101,6 +209,7 @@ class Microphone {
101
209
  recordingSettings = newSettings // Update the class property with the new settings
102
210
 
103
211
  self.frequencyBandConfig = frequencyBandConfig
212
+ self.lastIntervalMs = intervalMilliseconds
104
213
  // Analyzer uses the desired (target) sample rate, not hardware rate
105
214
  let targetRate = Int(settings.desiredSampleRate ?? settings.sampleRate)
106
215
  let fbConfig = frequencyBandConfig ?? (lowCrossoverHz: Float(300), highCrossoverHz: Float(2000))
@@ -110,30 +219,9 @@ class Microphone {
110
219
  highCrossoverHz: fbConfig.highCrossoverHz
111
220
  )
112
221
 
113
- // Compute tap buffer size from interval so Core Audio delivers at the right cadence
114
- let intervalSamples = AVAudioFrameCount(
115
- Double(intervalMilliseconds) / 1000.0 * hardwareFormat.sampleRate
116
- )
117
- let tapBufferSize = max(intervalSamples, 256) // floor at 256 frames (~5ms at 48kHz)
118
-
119
- // Pass nil for format to use the hardware's native format, avoiding format mismatch crashes.
120
- // Core Audio does not support format conversion (e.g. Float32 -> Int16) on the tap itself.
121
- audioEngine.inputNode.installTap(onBus: 0, bufferSize: tapBufferSize, format: nil) { [weak self] (buffer, time) in
122
- guard let self = self else { return }
123
-
124
- guard buffer.frameLength > 0 else {
125
- Logger.debug("Error: received empty buffer in tap callback")
126
- self.delegate?.onMicrophoneError("READ_ERROR", "Received empty audio buffer")
127
- return
128
- }
129
-
130
- self.processAudioBuffer(buffer)
131
- self.lastBufferTime = time
132
- }
133
-
134
222
  do {
135
223
  startTime = Date()
136
- try audioEngine.start()
224
+ try installTapAndStartEngine(intervalMilliseconds: intervalMilliseconds)
137
225
  isRecording = true
138
226
  Logger.debug("Debug: Recording started successfully.")
139
227
  return StartRecordingResult(
@@ -151,6 +239,9 @@ class Microphone {
151
239
  }
152
240
 
153
241
  public func stopRecording(resolver promise: Promise?) {
242
+ // Clear resume intent up-front so a stopRecording call during an
243
+ // active interruption window cancels the auto-resume on .ended.
244
+ pendingInterruptionResume = false
154
245
  guard self.isRecording else {
155
246
  if let promiseResolver = promise {
156
247
  promiseResolver.resolve(nil)
@@ -20,6 +20,7 @@ class PipelineIntegration: PipelineListener {
20
20
  static let EVENT_ZOMBIE_DETECTED = "PipelineZombieDetected"
21
21
  static let EVENT_UNDERRUN = "PipelineUnderrun"
22
22
  static let EVENT_DRAINED = "PipelineDrained"
23
+ static let EVENT_PLAYBACK_STOPPED = "PipelinePlaybackStopped"
23
24
  static let EVENT_AUDIO_FOCUS_LOST = "PipelineAudioFocusLost"
24
25
  static let EVENT_AUDIO_FOCUS_RESUMED = "PipelineAudioFocusResumed"
25
26
  static let EVENT_FREQUENCY_BANDS = "PipelineFrequencyBands"
@@ -72,7 +73,7 @@ class PipelineIntegration: PipelineListener {
72
73
  sharedEngine: sharedEngine,
73
74
  listener: self
74
75
  )
75
- p.connect()
76
+ try p.connect()
76
77
  pipeline = p
77
78
 
78
79
  return [
@@ -152,6 +153,11 @@ class PipelineIntegration: PipelineListener {
152
153
  return pipeline?.getState().rawValue ?? PipelineState.idle.rawValue
153
154
  }
154
155
 
156
+ /// Current platform output latency in milliseconds. Returns 0 if not connected.
157
+ func outputLatencyMs() -> Double {
158
+ return pipeline?.outputLatencyMs() ?? 0
159
+ }
160
+
155
161
  /// Register the pipeline as a delegate on the shared engine.
156
162
  /// Called by the module after connect() so route changes and interruptions
157
163
  /// are forwarded to the AudioPipeline instance.
@@ -206,6 +212,10 @@ class PipelineIntegration: PipelineListener {
206
212
  sendEvent(PipelineIntegration.EVENT_DRAINED, ["turnId": turnId])
207
213
  }
208
214
 
215
+ func onPlaybackStopped(turnId: String) {
216
+ sendEvent(PipelineIntegration.EVENT_PLAYBACK_STOPPED, ["turnId": turnId])
217
+ }
218
+
209
219
  func onAudioFocusLost() {
210
220
  sendEvent(PipelineIntegration.EVENT_AUDIO_FOCUS_LOST, [:])
211
221
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edkimmel/expo-audio-stream",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "Expo Play Audio Stream module",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -9,7 +9,6 @@
9
9
  "build": "expo-module build",
10
10
  "clean": "expo-module clean",
11
11
  "lint": "expo-module lint",
12
- "test": "expo-module test",
13
12
  "prepare": "expo-module prepare && husky || true",
14
13
  "prepublishOnly": "expo-module prepublishOnly",
15
14
  "expo-module": "expo-module",
@@ -2,12 +2,17 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const config_plugins_1 = require("@expo/config-plugins");
4
4
  const MICROPHONE_USAGE = 'Allow $(PRODUCT_NAME) to access your microphone';
5
+ // AVFoundation enumerates AirPlay/Continuity audio devices on the local network
6
+ // even though we don't use them — without this description, iOS shows a generic
7
+ // prompt and a denial leaves the audio session unable to activate.
8
+ const LOCAL_NETWORK_USAGE = 'Allow $(PRODUCT_NAME) to discover audio devices on your local network';
5
9
  const withRecordingPermission = (config, existingPerms) => {
6
10
  if (!existingPerms) {
7
11
  console.warn('No previous permissions provided');
8
12
  }
9
13
  config = (0, config_plugins_1.withInfoPlist)(config, (config) => {
10
14
  config.modResults['NSMicrophoneUsageDescription'] = config.modResults['NSMicrophoneUsageDescription'] || MICROPHONE_USAGE;
15
+ config.modResults['NSLocalNetworkUsageDescription'] = config.modResults['NSLocalNetworkUsageDescription'] || LOCAL_NETWORK_USAGE;
11
16
  // Add audio to UIBackgroundModes to allow background audio recording
12
17
  const existingBackgroundModes = config.modResults.UIBackgroundModes || [];
13
18
  if (!existingBackgroundModes.includes('audio')) {
@@ -6,6 +6,10 @@ import {
6
6
  } from '@expo/config-plugins'
7
7
 
8
8
  const MICROPHONE_USAGE = 'Allow $(PRODUCT_NAME) to access your microphone'
9
+ // AVFoundation enumerates AirPlay/Continuity audio devices on the local network
10
+ // even though we don't use them — without this description, iOS shows a generic
11
+ // prompt and a denial leaves the audio session unable to activate.
12
+ const LOCAL_NETWORK_USAGE = 'Allow $(PRODUCT_NAME) to discover audio devices on your local network'
9
13
 
10
14
  const withRecordingPermission: ConfigPlugin<{
11
15
  microphonePermission: string
@@ -15,6 +19,7 @@ const withRecordingPermission: ConfigPlugin<{
15
19
  }
16
20
  config = withInfoPlist(config, (config) => {
17
21
  config.modResults['NSMicrophoneUsageDescription'] = config.modResults['NSMicrophoneUsageDescription'] || MICROPHONE_USAGE
22
+ config.modResults['NSLocalNetworkUsageDescription'] = config.modResults['NSLocalNetworkUsageDescription'] || LOCAL_NETWORK_USAGE
18
23
 
19
24
  // Add audio to UIBackgroundModes to allow background audio recording
20
25
  const existingBackgroundModes =
package/src/events.ts CHANGED
@@ -21,6 +21,10 @@ export interface AudioEventPayload {
21
21
  streamUuid: string;
22
22
  soundLevel?: number;
23
23
  frequencyBands?: { low: number; mid: number; high: number };
24
+ /** Set by native when a mid-recording error occurs (interruption, read failure).
25
+ * When present, `encoded` is absent and the recording is no longer active. */
26
+ error?: string;
27
+ errorMessage?: string;
24
28
  }
25
29
 
26
30
  export const DeviceReconnectedReasons = {
package/src/index.ts CHANGED
@@ -59,9 +59,12 @@ export class ExpoPlayAudioStream {
59
59
  }> {
60
60
  let subscription: Subscription | undefined;
61
61
  try {
62
- const { onAudioStream, ...options } = recordingConfig;
62
+ const { onAudioStream, onError, ...options } = recordingConfig;
63
63
 
64
- if (onAudioStream && typeof onAudioStream == "function") {
64
+ if (
65
+ (onAudioStream && typeof onAudioStream == "function") ||
66
+ (onError && typeof onError == "function")
67
+ ) {
65
68
  subscription = addAudioEventListener(
66
69
  async (event: AudioEventPayload) => {
67
70
  const {
@@ -72,7 +75,13 @@ export class ExpoPlayAudioStream {
72
75
  encoded,
73
76
  soundLevel,
74
77
  frequencyBands,
78
+ error,
79
+ errorMessage,
75
80
  } = event;
81
+ if (error) {
82
+ onError?.({ code: error, message: errorMessage ?? "" });
83
+ return;
84
+ }
76
85
  if (!encoded) {
77
86
  console.error(
78
87
  `[ExpoPlayAudioStream] Encoded audio data is missing`
@@ -259,6 +268,7 @@ export type {
259
268
  PipelineZombieDetectedEvent,
260
269
  PipelineUnderrunEvent,
261
270
  PipelineDrainedEvent,
271
+ PipelinePlaybackStoppedEvent,
262
272
  PipelineAudioFocusLostEvent,
263
273
  PipelineAudioFocusResumedEvent,
264
274
  } 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;