@edkimmel/expo-audio-stream 0.4.1 → 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.
@@ -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.1",
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';
@@ -6,6 +6,18 @@ import { PlaybackMode, FrequencyBandConfig, FrequencyBands } from "../types";
6
6
 
7
7
  // ── Connect ─────────────────────────────────────────────────────────────────
8
8
 
9
+ /**
10
+ * How the pipeline's playback should coexist with other audio on the device.
11
+ *
12
+ * - `'mixWithOthers'` (default): plays alongside other apps without
13
+ * interrupting them. On Android no audio focus is requested. Best for
14
+ * sound effects and short clips.
15
+ * - `'duckOthers'`: requests audio focus with ducking. Other apps lower
16
+ * their volume but keep playing.
17
+ * - `'doNotMix'`: requests exclusive audio focus. Other apps pause.
18
+ */
19
+ export type PipelineAudioMode = 'mixWithOthers' | 'duckOthers' | 'doNotMix';
20
+
9
21
  /** Options passed to `connectPipeline()`. */
10
22
  export interface ConnectPipelineOptions {
11
23
  /** Sample rate in Hz (default 24000). */
@@ -25,6 +37,15 @@ export interface ConnectPipelineOptions {
25
37
  frequencyBandIntervalMs?: number;
26
38
  /** Optional frequency band crossover configuration. */
27
39
  frequencyBandConfig?: FrequencyBandConfig;
40
+ /**
41
+ * How pipeline playback should coexist with other apps' audio.
42
+ * Default is `'mixWithOthers'` (matches expo-audio).
43
+ *
44
+ * Note: this is a **behavior change** vs. prior versions of this library,
45
+ * which effectively used `'doNotMix'`. Pass `'doNotMix'` explicitly to
46
+ * preserve that old behavior.
47
+ */
48
+ audioMode?: PipelineAudioMode;
28
49
  }
29
50
 
30
51
  /** Result returned from a successful `connectPipeline()` call. */
@@ -114,6 +135,20 @@ export interface PipelineDrainedEvent {
114
135
  turnId: string;
115
136
  }
116
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
+
117
152
  /** Payload for `PipelineAudioFocusLost` (empty — presence is the signal). */
118
153
  export type PipelineAudioFocusLostEvent = Record<string, never>;
119
154
 
@@ -134,6 +169,7 @@ export interface PipelineEventMap {
134
169
  PipelineZombieDetected: PipelineZombieDetectedEvent;
135
170
  PipelineUnderrun: PipelineUnderrunEvent;
136
171
  PipelineDrained: PipelineDrainedEvent;
172
+ PipelinePlaybackStopped: PipelinePlaybackStoppedEvent;
137
173
  PipelineAudioFocusLost: PipelineAudioFocusLostEvent;
138
174
  PipelineAudioFocusResumed: PipelineAudioFocusResumedEvent;
139
175
  PipelineFrequencyBands: PipelineFrequencyBandsEvent;
package/src/types.ts CHANGED
@@ -129,10 +129,19 @@ 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
+
136
145
  export interface Chunk {
137
146
  text: string;
138
147
  timestamp: [number, number | null];