@edkimmel/expo-audio-stream 0.5.0 → 0.6.1

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 (34) hide show
  1. package/NATIVE_EVENTS.md +60 -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 +12 -3
  15. package/android/src/main/java/expo/modules/audiostream/pipeline/AudioPipeline.kt +12 -9
  16. package/build/events.d.ts +22 -0
  17. package/build/events.d.ts.map +1 -1
  18. package/build/events.js +18 -0
  19. package/build/events.js.map +1 -1
  20. package/build/index.d.ts +2 -0
  21. package/build/index.d.ts.map +1 -1
  22. package/build/index.js +21 -10
  23. package/build/index.js.map +1 -1
  24. package/build/types.d.ts +13 -0
  25. package/build/types.d.ts.map +1 -1
  26. package/build/types.js.map +1 -1
  27. package/ios/ExpoPlayAudioStreamModule.swift +29 -12
  28. package/ios/Microphone.swift +153 -197
  29. package/ios/MicrophoneDataDelegate.swift +10 -1
  30. package/ios/SharedAudioEngine.swift +18 -0
  31. package/package.json +1 -1
  32. package/src/events.ts +28 -0
  33. package/src/index.ts +29 -26
  34. package/src/types.ts +13 -0
@@ -2,215 +2,142 @@ import AVFoundation
2
2
  import ExpoModulesCore
3
3
 
4
4
 
5
- class Microphone {
5
+ class Microphone: SharedAudioEngineDelegate {
6
6
  weak var delegate: MicrophoneDataDelegate?
7
-
8
- private var audioEngine: AVAudioEngine!
9
- private var audioConverter: AVAudioConverter!
10
- private var inputNode: AVAudioInputNode!
11
-
7
+
8
+ /// The shared engine whose VP-enabled input node this microphone taps.
9
+ /// Must be set and configured before calling startRecording.
10
+ weak var sharedAudioEngine: SharedAudioEngine?
11
+
12
12
  public private(set) var isVoiceProcessingEnabled: Bool = false
13
-
14
-
13
+
15
14
  internal var lastEmittedSize: Int64 = 0
16
15
  private var totalDataSize: Int64 = 0
17
16
  internal var recordingSettings: RecordingSettings?
18
17
 
19
18
  internal var mimeType: String = "audio/wav"
20
19
  private var lastBufferTime: AVAudioTime?
21
-
20
+
22
21
  private var startTime: Date?
23
- private var pauseStartTime: Date?
24
-
25
22
 
26
- private var inittedAudioSession = false
27
23
  private var isRecording: Bool = false
28
24
  private var isSilent: Bool = false
29
25
  private var frequencyBandAnalyzer: FrequencyBandAnalyzer?
30
26
  private var frequencyBandConfig: (lowCrossoverHz: Float, highCrossoverHz: Float)?
31
27
 
32
- /// Interval (in ms) the consumer last requested. Used to rebuild the tap
33
- /// with the same cadence when resuming after an interruption.
34
28
  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
29
  private var pendingInterruptionResume: Bool = false
39
-
40
- init() {
41
- NotificationCenter.default.addObserver(
42
- self,
43
- selector: #selector(handleRouteChange),
44
- name: AVAudioSession.routeChangeNotification,
45
- object: nil
46
- )
47
- NotificationCenter.default.addObserver(
48
- self,
49
- selector: #selector(handleInterruption),
50
- name: AVAudioSession.interruptionNotification,
51
- object: nil
52
- )
53
- }
54
30
 
55
- deinit {
56
- NotificationCenter.default.removeObserver(self)
57
- }
31
+ // ── SharedAudioEngineDelegate ────────────────────────────────────────
58
32
 
59
- /// Handles audio route changes (e.g. headphones connected/disconnected)
60
- /// - Parameter notification: The notification object containing route change information
61
- @objc private func handleRouteChange(notification: Notification) {
62
- guard let info = notification.userInfo,
63
- let reasonValue = info[AVAudioSessionRouteChangeReasonKey] as? UInt,
64
- let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else {
65
- return
66
- }
67
-
68
- Logger.debug("[Microphone] Route is changed \(reason)")
69
-
70
- switch reason {
71
- case .newDeviceAvailable, .oldDeviceUnavailable:
72
- if isRecording {
73
- stopRecording(resolver: nil)
74
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
75
- guard let self = self, let settings = self.recordingSettings else { return }
76
-
77
- _ = startRecording(settings: self.recordingSettings!, intervalMilliseconds: 100, frequencyBandConfig: self.frequencyBandConfig)
78
- }
79
- }
80
- case .categoryChange:
81
- Logger.debug("[Microphone] Audio Session category changed")
82
- default:
83
- break
33
+ func engineDidRestartAfterRouteChange() {
34
+ guard isRecording else { return }
35
+ // Engine restarted in-place; tap was removed on stop — reinstall.
36
+ do {
37
+ try installTap(intervalMilliseconds: lastIntervalMs)
38
+ } catch {
39
+ Logger.debug("[Microphone] Failed to reinstall tap after route change: \(error)")
40
+ failRecording(MicrophoneErrorInfo(
41
+ code: "RESTART_FAILED",
42
+ message: "Could not reinstall microphone tap after route change: \(error.localizedDescription)",
43
+ isFatal: true,
44
+ autoResuming: false
45
+ ))
84
46
  }
85
47
  }
86
48
 
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
49
+ func engineDidRebuild() {
50
+ guard isRecording else { return }
51
+ // New AVAudioEngine instance installTap picks up the new inputNode automatically.
52
+ do {
53
+ try installTap(intervalMilliseconds: lastIntervalMs)
54
+ } catch {
55
+ Logger.debug("[Microphone] Failed to reinstall tap after engine rebuild: \(error)")
56
+ failRecording(MicrophoneErrorInfo(
57
+ code: "RESTART_FAILED",
58
+ message: "Could not reinstall microphone tap after engine rebuild: \(error.localizedDescription)",
59
+ isFatal: true,
60
+ autoResuming: false
61
+ ))
149
62
  }
150
63
  }
151
64
 
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 }
65
+ func audioSessionResumeDenied() {
66
+ guard isRecording else { return }
67
+ failRecording(MicrophoneErrorInfo(
68
+ code: "RESUME_DENIED",
69
+ message: "System did not permit microphone resume after interruption",
70
+ isFatal: true,
71
+ autoResuming: false
72
+ ))
73
+ }
163
74
 
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
- }
75
+ func audioSessionInterruptionBegan() {
76
+ guard isRecording else { return }
77
+ pendingInterruptionResume = true
78
+ delegate?.onMicrophoneError(MicrophoneErrorInfo(
79
+ code: "INTERRUPTED",
80
+ message: "Audio session interrupted by system",
81
+ isFatal: false,
82
+ autoResuming: true
83
+ ))
84
+ }
169
85
 
170
- self.processAudioBuffer(buffer)
171
- self.lastBufferTime = time
86
+ func audioSessionInterruptionEnded() {
87
+ guard pendingInterruptionResume else { return }
88
+ pendingInterruptionResume = false
89
+ do {
90
+ try installTap(intervalMilliseconds: lastIntervalMs)
91
+ } catch {
92
+ Logger.debug("[Microphone] Failed to reinstall tap after interruption resume: \(error)")
93
+ failRecording(MicrophoneErrorInfo(
94
+ code: "RESUME_FAILED",
95
+ message: "Could not reinstall microphone tap after interruption: \(error.localizedDescription)",
96
+ isFatal: true,
97
+ autoResuming: false
98
+ ))
172
99
  }
100
+ }
173
101
 
174
- try audioEngine.start()
102
+ func engineDidDie(reason: String) {
103
+ guard isRecording else { return }
104
+ failRecording(MicrophoneErrorInfo(
105
+ code: "ENGINE_DIED",
106
+ message: reason,
107
+ isFatal: true,
108
+ autoResuming: false
109
+ ))
175
110
  }
176
-
111
+
112
+ // ── Core recording API ───────────────────────────────────────────────
113
+
177
114
  func toggleSilence(isSilent: Bool) {
178
115
  Logger.debug("[Microphone] toggleSilence")
179
116
  self.isSilent = isSilent
180
117
  }
181
-
118
+
182
119
  func startRecording(settings: RecordingSettings, intervalMilliseconds: Int,
183
120
  frequencyBandConfig: (lowCrossoverHz: Float, highCrossoverHz: Float)? = nil) -> StartRecordingResult? {
184
121
  guard !isRecording else {
185
- Logger.debug("Debug: Recording is already in progress.")
122
+ Logger.debug("[Microphone] Recording is already in progress.")
186
123
  return StartRecordingResult(error: "Recording is already in progress.")
187
124
  }
188
-
189
- if self.audioEngine == nil {
190
- self.audioEngine = AVAudioEngine()
125
+ guard let shared = sharedAudioEngine, shared.isConfigured, let engine = shared.engine else {
126
+ Logger.debug("[Microphone] Shared audio engine is not configured.")
127
+ return StartRecordingResult(error: "Shared audio engine is not configured.")
191
128
  }
192
-
193
- if self.audioEngine != nil && audioEngine.isRunning {
194
- Logger.debug("Debug: Audio engine already running.")
195
- audioEngine.stop()
196
- }
197
-
198
- var newSettings = settings // Make settings mutable
199
129
 
130
+ var newSettings = settings
200
131
  totalDataSize = 0
201
-
202
- // Use the hardware's native format for the tap to avoid Core Audio format mismatch crashes.
203
- // The inputNode delivers audio in the hardware format (e.g. 48kHz Float32).
204
- // Resampling and format conversion to the desired settings happens in processAudioBuffer.
205
- let hardwareFormat = audioEngine.inputNode.inputFormat(forBus: 0)
206
- newSettings.sampleRate = hardwareFormat.sampleRate
207
- Logger.debug("Debug: Hardware sample rate is \(hardwareFormat.sampleRate) Hz, desired sample rate is \(settings.sampleRate) Hz")
208
132
 
209
- recordingSettings = newSettings // Update the class property with the new settings
133
+ let hardwareFormat = engine.inputNode.inputFormat(forBus: 0)
134
+ newSettings.sampleRate = hardwareFormat.sampleRate
135
+ Logger.debug("[Microphone] Hardware sample rate: \(hardwareFormat.sampleRate) Hz, desired: \(settings.sampleRate) Hz")
210
136
 
137
+ recordingSettings = newSettings
211
138
  self.frequencyBandConfig = frequencyBandConfig
212
139
  self.lastIntervalMs = intervalMilliseconds
213
- // Analyzer uses the desired (target) sample rate, not hardware rate
140
+
214
141
  let targetRate = Int(settings.desiredSampleRate ?? settings.sampleRate)
215
142
  let fbConfig = frequencyBandConfig ?? (lowCrossoverHz: Float(300), highCrossoverHz: Float(2000))
216
143
  frequencyBandAnalyzer = FrequencyBandAnalyzer(
@@ -219,11 +146,12 @@ class Microphone {
219
146
  highCrossoverHz: fbConfig.highCrossoverHz
220
147
  )
221
148
 
149
+ sharedAudioEngine?.addDelegate(self)
222
150
  do {
223
151
  startTime = Date()
224
- try installTapAndStartEngine(intervalMilliseconds: intervalMilliseconds)
152
+ try installTap(intervalMilliseconds: intervalMilliseconds)
225
153
  isRecording = true
226
- Logger.debug("Debug: Recording started successfully.")
154
+ Logger.debug("[Microphone] Recording started successfully.")
227
155
  return StartRecordingResult(
228
156
  fileUri: "",
229
157
  mimeType: mimeType,
@@ -232,49 +160,81 @@ class Microphone {
232
160
  sampleRate: settings.sampleRate
233
161
  )
234
162
  } catch {
235
- Logger.debug("Error: Could not start the audio engine: \(error.localizedDescription)")
163
+ Logger.debug("[Microphone] Could not start recording: \(error.localizedDescription)")
164
+ sharedAudioEngine?.removeDelegate(self)
236
165
  isRecording = false
237
- return StartRecordingResult(error: "Error: Could not start the audio engine: \(error.localizedDescription)")
166
+ return StartRecordingResult(error: "Could not start recording: \(error.localizedDescription)")
238
167
  }
239
168
  }
240
-
169
+
241
170
  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
171
  pendingInterruptionResume = false
245
- guard self.isRecording else {
246
- if let promiseResolver = promise {
247
- promiseResolver.resolve(nil)
248
- }
172
+ guard isRecording else {
173
+ promise?.resolve(nil)
249
174
  return
250
175
  }
251
- self.isRecording = false
252
- self.isVoiceProcessingEnabled = false
253
-
254
- // Remove tap before stopping the engine
255
- if audioEngine != nil {
256
- audioEngine.inputNode.removeTap(onBus: 0)
257
- audioEngine.stop()
258
- frequencyBandAnalyzer = nil
176
+ isRecording = false
177
+ isVoiceProcessingEnabled = false
178
+ sharedAudioEngine?.engine?.inputNode.removeTap(onBus: 0)
179
+ sharedAudioEngine?.removeDelegate(self)
180
+ frequencyBandAnalyzer = nil
181
+ promise?.resolve(nil)
182
+ }
183
+
184
+ // ── Private helpers ──────────────────────────────────────────────────
185
+
186
+ private func failRecording(_ error: MicrophoneErrorInfo) {
187
+ isRecording = false
188
+ pendingInterruptionResume = false
189
+ sharedAudioEngine?.engine?.inputNode.removeTap(onBus: 0)
190
+ sharedAudioEngine?.removeDelegate(self)
191
+ frequencyBandAnalyzer = nil
192
+ delegate?.onMicrophoneError(error)
193
+ }
194
+
195
+ private func installTap(intervalMilliseconds: Int) throws {
196
+ guard let inputNode = sharedAudioEngine?.engine?.inputNode else {
197
+ throw NSError(domain: "Microphone", code: -1,
198
+ userInfo: [NSLocalizedDescriptionKey: "Shared engine input node unavailable"])
259
199
  }
260
200
 
261
- if let promiseResolver = promise {
262
- promiseResolver.resolve(nil)
201
+ // Remove any stale tap before installing — AVAudioEngine crashes if a tap
202
+ // is already present on the bus. Engine restarts do not guarantee tap removal.
203
+ inputNode.removeTap(onBus: 0)
204
+
205
+ let hardwareFormat = inputNode.inputFormat(forBus: 0)
206
+ recordingSettings?.sampleRate = hardwareFormat.sampleRate
207
+
208
+ let intervalSamples = AVAudioFrameCount(
209
+ Double(intervalMilliseconds) / 1000.0 * hardwareFormat.sampleRate
210
+ )
211
+ let tapBufferSize = max(intervalSamples, 256)
212
+
213
+ inputNode.installTap(onBus: 0, bufferSize: tapBufferSize, format: nil) { [weak self] (buffer, time) in
214
+ guard let self = self else { return }
215
+ guard buffer.frameLength > 0 else {
216
+ Logger.debug("[Microphone] Received empty buffer in tap callback")
217
+ self.delegate?.onMicrophoneError(MicrophoneErrorInfo(
218
+ code: "READ_ERROR",
219
+ message: "Received empty audio buffer",
220
+ isFatal: false,
221
+ autoResuming: false
222
+ ))
223
+ return
224
+ }
225
+ self.processAudioBuffer(buffer)
226
+ self.lastBufferTime = time
263
227
  }
264
228
  }
265
-
266
- /// Processes the audio buffer and writes data to the file. Also handles audio processing if enabled.
267
- /// - Parameters:
268
- /// - buffer: The audio buffer to process.
269
- /// - fileURL: The URL of the file to write the data to.
229
+
270
230
  private func processAudioBuffer(_ buffer: AVAudioPCMBuffer) {
271
231
  let targetSampleRate = recordingSettings?.desiredSampleRate ?? buffer.format.sampleRate
272
232
  let targetBitDepth = recordingSettings?.bitDepth ?? 16
273
233
  var currentBuffer = buffer
274
234
 
275
- // Resample if needed
276
235
  if currentBuffer.format.sampleRate != targetSampleRate {
277
- if let resampledBuffer = AudioUtils.resampleAudioBuffer(currentBuffer, from: currentBuffer.format.sampleRate, to: targetSampleRate) {
236
+ if let resampledBuffer = AudioUtils.resampleAudioBuffer(
237
+ currentBuffer, from: currentBuffer.format.sampleRate, to: targetSampleRate) {
278
238
  currentBuffer = resampledBuffer
279
239
  } else if let convertedBuffer = AudioUtils.tryConvertToFormat(
280
240
  inputBuffer: currentBuffer,
@@ -284,13 +244,12 @@ class Microphone {
284
244
  ) {
285
245
  currentBuffer = convertedBuffer
286
246
  } else {
287
- Logger.debug("Failed to resample audio buffer.")
247
+ Logger.debug("[Microphone] Failed to resample audio buffer.")
288
248
  }
289
249
  }
290
250
 
291
251
  let powerLevel: Float = self.isSilent ? -160.0 : AudioUtils.calculatePowerLevel(from: currentBuffer)
292
252
 
293
- // Convert Float32 → Int16 PCM if needed (the tap delivers hardware-native Float32)
294
253
  let data: Data
295
254
  if isSilent {
296
255
  let byteCount = Int(currentBuffer.frameCapacity) * Int(currentBuffer.format.streamDescription.pointee.mBytesPerFrame)
@@ -311,13 +270,12 @@ class Microphone {
311
270
  } else {
312
271
  let audioData = currentBuffer.audioBufferList.pointee.mBuffers
313
272
  guard let bufferData = audioData.mData else {
314
- Logger.debug("Buffer data is nil.")
273
+ Logger.debug("[Microphone] Buffer data is nil.")
315
274
  return
316
275
  }
317
276
  data = Data(bytes: bufferData, count: Int(audioData.mDataByteSize))
318
277
  }
319
278
 
320
- // Compute frequency bands from the Int16 PCM data
321
279
  let bands: FrequencyBands?
322
280
  if isSilent {
323
281
  bands = .zero
@@ -329,8 +287,6 @@ class Microphone {
329
287
  }
330
288
 
331
289
  totalDataSize += Int64(data.count)
332
-
333
- // Emit immediately — tap buffer size is already interval-aligned
334
290
  self.delegate?.onMicrophoneData(data, powerLevel, bands)
335
291
  self.lastEmittedSize = totalDataSize
336
292
  }
@@ -1,4 +1,13 @@
1
+ struct MicrophoneErrorInfo {
2
+ let code: String
3
+ let message: String
4
+ /// True when the recording has stopped and the caller must reconnect to resume.
5
+ let isFatal: Bool
6
+ /// True only for INTERRUPTED — the library will reinstall the tap automatically.
7
+ let autoResuming: Bool
8
+ }
9
+
1
10
  protocol MicrophoneDataDelegate: AnyObject {
2
11
  func onMicrophoneData(_ microphoneData: Data, _ soundLevel: Float?, _ frequencyBands: FrequencyBands?)
3
- func onMicrophoneError(_ error: String, _ errorMessage: String)
12
+ func onMicrophoneError(_ error: MicrophoneErrorInfo)
4
13
  }
@@ -21,12 +21,20 @@ protocol SharedAudioEngineDelegate: AnyObject {
21
21
  /// Consumer should restart playback.
22
22
  func audioSessionInterruptionEnded()
23
23
 
24
+ /// Audio session interruption ended but the system did not signal shouldResume.
25
+ /// Recording has been stopped. Consumer should clean up and wait for the user to reconnect.
26
+ func audioSessionResumeDenied()
27
+
24
28
  /// Engine failed to restart after exhausting all retry attempts.
25
29
  /// All state has been torn down. Consumer should report the failure
26
30
  /// to JS and clean up its own state so a fresh connect can succeed.
27
31
  func engineDidDie(reason: String)
28
32
  }
29
33
 
34
+ extension SharedAudioEngineDelegate {
35
+ func audioSessionResumeDenied() {}
36
+ }
37
+
30
38
  /// Owns the single AVAudioEngine shared between AudioPipeline consumers.
31
39
  ///
32
40
  /// Responsibilities:
@@ -494,6 +502,16 @@ class SharedAudioEngine {
494
502
  notifyDelegates { $0.audioSessionInterruptionBegan() }
495
503
  } else if type == .ended {
496
504
  Logger.debug("[\(SharedAudioEngine.TAG)] Audio session interruption ended")
505
+ let optionsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
506
+ let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
507
+ guard options.contains(.shouldResume) else {
508
+ // shouldResume absent — system hints we should not restart yet.
509
+ // Note: this flag is advisory; may be worth revisiting if it causes
510
+ // false negatives in practice.
511
+ Logger.debug("[\(SharedAudioEngine.TAG)] shouldResume not set — skipping restart")
512
+ notifyDelegates { $0.audioSessionResumeDenied() }
513
+ return
514
+ }
497
515
  // Reactivate session and restart engine
498
516
  try? AVAudioSession.sharedInstance().setActive(true)
499
517
  if let engine = engine, !engine.isRunning {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edkimmel/expo-audio-stream",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "Expo Play Audio Stream module",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
package/src/events.ts CHANGED
@@ -42,6 +42,7 @@ export type DeviceReconnectedEventPayload = {
42
42
 
43
43
  export const AudioEvents = {
44
44
  AudioData: "AudioData",
45
+ MicrophoneError: "MicrophoneError",
45
46
  DeviceReconnected: "DeviceReconnected",
46
47
  };
47
48
 
@@ -51,6 +52,33 @@ export function addAudioEventListener(
51
52
  return (emitter as any).addListener("AudioData", listener);
52
53
  }
53
54
 
55
+ export interface MicrophoneErrorEventPayload {
56
+ code: string;
57
+ message: string;
58
+ isFatal: boolean;
59
+ autoResuming: boolean;
60
+ }
61
+
62
+ /**
63
+ * Subscribe to the dedicated MicrophoneError native event.
64
+ *
65
+ * OTA safe: if the running native binary predates this feature, the event is
66
+ * never emitted and this listener is never called. Apps that also subscribe to
67
+ * AudioData errors via addAudioEventListener continue to work unchanged.
68
+ *
69
+ * @example
70
+ * const sub = addMicrophoneErrorListener((e) => {
71
+ * if (e.isFatal) { stopMicrophone(); reconnect(); }
72
+ * else if (e.autoResuming) { showPausedUI(); }
73
+ * })
74
+ * // cleanup: sub.remove()
75
+ */
76
+ export function addMicrophoneErrorListener(
77
+ listener: (event: MicrophoneErrorEventPayload) => void
78
+ ): EventSubscription {
79
+ return (emitter as any).addListener("MicrophoneError", listener);
80
+ }
81
+
54
82
  export function subscribeToEvent<T extends unknown>(
55
83
  eventName: string,
56
84
  listener: (event: T | undefined) => Promise<void>
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,
@@ -61,34 +62,22 @@ export class ExpoPlayAudioStream {
61
62
  try {
62
63
  const { onAudioStream, onError, ...options } = recordingConfig;
63
64
 
64
- if (
65
- (onAudioStream && typeof onAudioStream == "function") ||
66
- (onError && typeof onError == "function")
67
- ) {
68
- subscription = addAudioEventListener(
69
- async (event: AudioEventPayload) => {
70
- const {
71
- fileUri,
72
- deltaSize,
73
- totalSize,
74
- position,
75
- encoded,
76
- soundLevel,
77
- frequencyBands,
78
- error,
79
- errorMessage,
80
- } = event;
81
- if (error) {
82
- onError?.({ code: error, message: errorMessage ?? "" });
83
- return;
84
- }
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
+ // Only suppress AudioData error events when onError is wired up — the
72
+ // MicrophoneError subscription will handle them. Without onError (old
73
+ // integration or old native binary), fall through so the missing-encoded
74
+ // path below preserves the pre-v0.6.0 behavior.
75
+ if (error && onError) return;
85
76
  if (!encoded) {
86
- console.error(
87
- `[ExpoPlayAudioStream] Encoded audio data is missing`
88
- );
77
+ console.error(`[ExpoPlayAudioStream] Encoded audio data is missing`);
89
78
  throw new Error("Encoded audio data is missing");
90
79
  }
91
- onAudioStream?.({
80
+ onAudioStream({
92
81
  data: encoded,
93
82
  position,
94
83
  fileUri,
@@ -97,10 +86,22 @@ export class ExpoPlayAudioStream {
97
86
  soundLevel,
98
87
  frequencyBands,
99
88
  });
100
- }
89
+ })
90
+ );
91
+ }
92
+
93
+ if (onError && typeof onError === "function") {
94
+ subscriptions.push(
95
+ addMicrophoneErrorListener((event) => {
96
+ onError(event);
97
+ })
101
98
  );
102
99
  }
103
100
 
101
+ if (subscriptions.length > 0) {
102
+ subscription = { remove: () => subscriptions.forEach((s) => s.remove()) };
103
+ }
104
+
104
105
  const result = await ExpoPlayAudioStreamModule.startMicrophone(options);
105
106
 
106
107
  return { recordingResult: result, subscription };
@@ -249,6 +250,8 @@ export {
249
250
  // Re-export Subscription type for backwards compatibility
250
251
  export type { EventSubscription } from "expo-modules-core";
251
252
  export type { Subscription } from "./events";
253
+ export { addMicrophoneErrorListener } from "./events";
254
+ export type { MicrophoneErrorEventPayload } from "./events";
252
255
 
253
256
  // Export native audio pipeline V3
254
257
  export { Pipeline } from "./pipeline";