@edkimmel/expo-audio-stream 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/NATIVE_EVENTS.md +97 -6
  2. package/android/.gradle/8.9/checksums/checksums.lock +0 -0
  3. package/android/.gradle/8.9/dependencies-accessors/gc.properties +0 -0
  4. package/android/.gradle/8.9/fileChanges/last-build.bin +0 -0
  5. package/android/.gradle/8.9/fileHashes/fileHashes.lock +0 -0
  6. package/android/.gradle/8.9/gc.properties +0 -0
  7. package/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
  8. package/android/.gradle/buildOutputCleanup/cache.properties +2 -0
  9. package/android/.gradle/vcs-1/gc.properties +0 -0
  10. package/android/src/main/java/expo/modules/audiostream/AudioEffectsManager.kt +6 -11
  11. package/android/src/main/java/expo/modules/audiostream/AudioRecorderManager.kt +23 -6
  12. package/android/src/main/java/expo/modules/audiostream/CommunicationAudioManager.kt +155 -0
  13. package/android/src/main/java/expo/modules/audiostream/Constants.kt +1 -0
  14. package/android/src/main/java/expo/modules/audiostream/ExpoPlayAudioStreamModule.kt +17 -3
  15. package/android/src/main/java/expo/modules/audiostream/pipeline/AudioPipeline.kt +104 -11
  16. package/android/src/main/java/expo/modules/audiostream/pipeline/PipelineIntegration.kt +14 -0
  17. package/build/events.d.ts +26 -0
  18. package/build/events.d.ts.map +1 -1
  19. package/build/events.js +18 -0
  20. package/build/events.js.map +1 -1
  21. package/build/index.d.ts +3 -1
  22. package/build/index.d.ts.map +1 -1
  23. package/build/index.js +19 -7
  24. package/build/index.js.map +1 -1
  25. package/build/pipeline/index.d.ts +14 -1
  26. package/build/pipeline/index.d.ts.map +1 -1
  27. package/build/pipeline/index.js +15 -0
  28. package/build/pipeline/index.js.map +1 -1
  29. package/build/pipeline/types.d.ts +14 -0
  30. package/build/pipeline/types.d.ts.map +1 -1
  31. package/build/pipeline/types.js.map +1 -1
  32. package/build/types.d.ts +21 -0
  33. package/build/types.d.ts.map +1 -1
  34. package/build/types.js.map +1 -1
  35. package/ios/AudioPipeline.swift +67 -2
  36. package/ios/ExpoPlayAudioStreamModule.swift +43 -12
  37. package/ios/Microphone.swift +167 -120
  38. package/ios/MicrophoneDataDelegate.swift +10 -1
  39. package/ios/PipelineIntegration.swift +11 -1
  40. package/ios/SharedAudioEngine.swift +18 -0
  41. package/package.json +1 -2
  42. package/plugin/build/index.js +5 -0
  43. package/plugin/src/index.ts +5 -0
  44. package/src/events.ts +32 -0
  45. package/src/index.ts +27 -18
  46. package/src/pipeline/index.ts +17 -0
  47. package/src/pipeline/types.ts +15 -0
  48. package/src/types.ts +22 -0
@@ -2,106 +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
-
32
- init() {
33
- NotificationCenter.default.addObserver(
34
- self,
35
- selector: #selector(handleRouteChange),
36
- name: AVAudioSession.routeChangeNotification,
37
- object: nil
38
- )
27
+
28
+ private var lastIntervalMs: Int = 100
29
+ private var pendingInterruptionResume: Bool = false
30
+
31
+ // ── SharedAudioEngineDelegate ────────────────────────────────────────
32
+
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
+ ))
46
+ }
39
47
  }
40
-
41
- /// Handles audio route changes (e.g. headphones connected/disconnected)
42
- /// - Parameter notification: The notification object containing route change information
43
- @objc private func handleRouteChange(notification: Notification) {
44
- guard let info = notification.userInfo,
45
- let reasonValue = info[AVAudioSessionRouteChangeReasonKey] as? UInt,
46
- let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else {
47
- return
48
+
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
+ ))
48
62
  }
49
-
50
- Logger.debug("[Microphone] Route is changed \(reason)")
51
-
52
- switch reason {
53
- case .newDeviceAvailable, .oldDeviceUnavailable:
54
- if isRecording {
55
- stopRecording(resolver: nil)
56
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
57
- guard let self = self, let settings = self.recordingSettings else { return }
58
-
59
- _ = startRecording(settings: self.recordingSettings!, intervalMilliseconds: 100, frequencyBandConfig: self.frequencyBandConfig)
60
- }
61
- }
62
- case .categoryChange:
63
- Logger.debug("[Microphone] Audio Session category changed")
64
- default:
65
- break
63
+ }
64
+
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
+ }
74
+
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
+ }
85
+
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
+ ))
66
99
  }
67
100
  }
68
-
101
+
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
+ ))
110
+ }
111
+
112
+ // ── Core recording API ───────────────────────────────────────────────
113
+
69
114
  func toggleSilence(isSilent: Bool) {
70
115
  Logger.debug("[Microphone] toggleSilence")
71
116
  self.isSilent = isSilent
72
117
  }
73
-
118
+
74
119
  func startRecording(settings: RecordingSettings, intervalMilliseconds: Int,
75
120
  frequencyBandConfig: (lowCrossoverHz: Float, highCrossoverHz: Float)? = nil) -> StartRecordingResult? {
76
121
  guard !isRecording else {
77
- Logger.debug("Debug: Recording is already in progress.")
122
+ Logger.debug("[Microphone] Recording is already in progress.")
78
123
  return StartRecordingResult(error: "Recording is already in progress.")
79
124
  }
80
-
81
- if self.audioEngine == nil {
82
- 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.")
83
128
  }
84
-
85
- if self.audioEngine != nil && audioEngine.isRunning {
86
- Logger.debug("Debug: Audio engine already running.")
87
- audioEngine.stop()
88
- }
89
-
90
- var newSettings = settings // Make settings mutable
91
129
 
130
+ var newSettings = settings
92
131
  totalDataSize = 0
93
-
94
- // Use the hardware's native format for the tap to avoid Core Audio format mismatch crashes.
95
- // The inputNode delivers audio in the hardware format (e.g. 48kHz Float32).
96
- // Resampling and format conversion to the desired settings happens in processAudioBuffer.
97
- let hardwareFormat = audioEngine.inputNode.inputFormat(forBus: 0)
98
- newSettings.sampleRate = hardwareFormat.sampleRate
99
- Logger.debug("Debug: Hardware sample rate is \(hardwareFormat.sampleRate) Hz, desired sample rate is \(settings.sampleRate) Hz")
100
132
 
101
- 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")
102
136
 
137
+ recordingSettings = newSettings
103
138
  self.frequencyBandConfig = frequencyBandConfig
104
- // Analyzer uses the desired (target) sample rate, not hardware rate
139
+ self.lastIntervalMs = intervalMilliseconds
140
+
105
141
  let targetRate = Int(settings.desiredSampleRate ?? settings.sampleRate)
106
142
  let fbConfig = frequencyBandConfig ?? (lowCrossoverHz: Float(300), highCrossoverHz: Float(2000))
107
143
  frequencyBandAnalyzer = FrequencyBandAnalyzer(
@@ -110,32 +146,12 @@ class Microphone {
110
146
  highCrossoverHz: fbConfig.highCrossoverHz
111
147
  )
112
148
 
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
-
149
+ sharedAudioEngine?.addDelegate(self)
134
150
  do {
135
151
  startTime = Date()
136
- try audioEngine.start()
152
+ try installTap(intervalMilliseconds: intervalMilliseconds)
137
153
  isRecording = true
138
- Logger.debug("Debug: Recording started successfully.")
154
+ Logger.debug("[Microphone] Recording started successfully.")
139
155
  return StartRecordingResult(
140
156
  fileUri: "",
141
157
  mimeType: mimeType,
@@ -144,46 +160,81 @@ class Microphone {
144
160
  sampleRate: settings.sampleRate
145
161
  )
146
162
  } catch {
147
- 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)
148
165
  isRecording = false
149
- return StartRecordingResult(error: "Error: Could not start the audio engine: \(error.localizedDescription)")
166
+ return StartRecordingResult(error: "Could not start recording: \(error.localizedDescription)")
150
167
  }
151
168
  }
152
-
169
+
153
170
  public func stopRecording(resolver promise: Promise?) {
154
- guard self.isRecording else {
155
- if let promiseResolver = promise {
156
- promiseResolver.resolve(nil)
157
- }
171
+ pendingInterruptionResume = false
172
+ guard isRecording else {
173
+ promise?.resolve(nil)
158
174
  return
159
175
  }
160
- self.isRecording = false
161
- self.isVoiceProcessingEnabled = false
162
-
163
- // Remove tap before stopping the engine
164
- if audioEngine != nil {
165
- audioEngine.inputNode.removeTap(onBus: 0)
166
- audioEngine.stop()
167
- 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"])
168
199
  }
169
200
 
170
- if let promiseResolver = promise {
171
- 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
172
227
  }
173
228
  }
174
-
175
- /// Processes the audio buffer and writes data to the file. Also handles audio processing if enabled.
176
- /// - Parameters:
177
- /// - buffer: The audio buffer to process.
178
- /// - fileURL: The URL of the file to write the data to.
229
+
179
230
  private func processAudioBuffer(_ buffer: AVAudioPCMBuffer) {
180
231
  let targetSampleRate = recordingSettings?.desiredSampleRate ?? buffer.format.sampleRate
181
232
  let targetBitDepth = recordingSettings?.bitDepth ?? 16
182
233
  var currentBuffer = buffer
183
234
 
184
- // Resample if needed
185
235
  if currentBuffer.format.sampleRate != targetSampleRate {
186
- 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) {
187
238
  currentBuffer = resampledBuffer
188
239
  } else if let convertedBuffer = AudioUtils.tryConvertToFormat(
189
240
  inputBuffer: currentBuffer,
@@ -193,13 +244,12 @@ class Microphone {
193
244
  ) {
194
245
  currentBuffer = convertedBuffer
195
246
  } else {
196
- Logger.debug("Failed to resample audio buffer.")
247
+ Logger.debug("[Microphone] Failed to resample audio buffer.")
197
248
  }
198
249
  }
199
250
 
200
251
  let powerLevel: Float = self.isSilent ? -160.0 : AudioUtils.calculatePowerLevel(from: currentBuffer)
201
252
 
202
- // Convert Float32 → Int16 PCM if needed (the tap delivers hardware-native Float32)
203
253
  let data: Data
204
254
  if isSilent {
205
255
  let byteCount = Int(currentBuffer.frameCapacity) * Int(currentBuffer.format.streamDescription.pointee.mBytesPerFrame)
@@ -220,13 +270,12 @@ class Microphone {
220
270
  } else {
221
271
  let audioData = currentBuffer.audioBufferList.pointee.mBuffers
222
272
  guard let bufferData = audioData.mData else {
223
- Logger.debug("Buffer data is nil.")
273
+ Logger.debug("[Microphone] Buffer data is nil.")
224
274
  return
225
275
  }
226
276
  data = Data(bytes: bufferData, count: Int(audioData.mDataByteSize))
227
277
  }
228
278
 
229
- // Compute frequency bands from the Int16 PCM data
230
279
  let bands: FrequencyBands?
231
280
  if isSilent {
232
281
  bands = .zero
@@ -238,8 +287,6 @@ class Microphone {
238
287
  }
239
288
 
240
289
  totalDataSize += Int64(data.count)
241
-
242
- // Emit immediately — tap buffer size is already interval-aligned
243
290
  self.delegate?.onMicrophoneData(data, powerLevel, bands)
244
291
  self.lastEmittedSize = totalDataSize
245
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
  }
@@ -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
  }
@@ -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.4.2",
3
+ "version": "0.6.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 = {
@@ -38,6 +42,7 @@ export type DeviceReconnectedEventPayload = {
38
42
 
39
43
  export const AudioEvents = {
40
44
  AudioData: "AudioData",
45
+ MicrophoneError: "MicrophoneError",
41
46
  DeviceReconnected: "DeviceReconnected",
42
47
  };
43
48
 
@@ -47,6 +52,33 @@ export function addAudioEventListener(
47
52
  return (emitter as any).addListener("AudioData", listener);
48
53
  }
49
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
+
50
82
  export function subscribeToEvent<T extends unknown>(
51
83
  eventName: string,
52
84
  listener: (event: T | undefined) => Promise<void>