@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.
- package/NATIVE_EVENTS.md +60 -6
- package/android/.gradle/8.9/checksums/checksums.lock +0 -0
- package/android/.gradle/8.9/dependencies-accessors/gc.properties +0 -0
- package/android/.gradle/8.9/fileChanges/last-build.bin +0 -0
- package/android/.gradle/8.9/fileHashes/fileHashes.lock +0 -0
- package/android/.gradle/8.9/gc.properties +0 -0
- package/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
- package/android/.gradle/buildOutputCleanup/cache.properties +2 -0
- package/android/.gradle/vcs-1/gc.properties +0 -0
- package/android/src/main/java/expo/modules/audiostream/AudioEffectsManager.kt +6 -11
- package/android/src/main/java/expo/modules/audiostream/AudioRecorderManager.kt +23 -6
- package/android/src/main/java/expo/modules/audiostream/CommunicationAudioManager.kt +155 -0
- package/android/src/main/java/expo/modules/audiostream/Constants.kt +1 -0
- package/android/src/main/java/expo/modules/audiostream/ExpoPlayAudioStreamModule.kt +12 -3
- package/android/src/main/java/expo/modules/audiostream/pipeline/AudioPipeline.kt +12 -9
- package/build/events.d.ts +22 -0
- package/build/events.d.ts.map +1 -1
- package/build/events.js +18 -0
- package/build/events.js.map +1 -1
- package/build/index.d.ts +2 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +21 -10
- package/build/index.js.map +1 -1
- package/build/types.d.ts +13 -0
- package/build/types.d.ts.map +1 -1
- package/build/types.js.map +1 -1
- package/ios/ExpoPlayAudioStreamModule.swift +29 -12
- package/ios/Microphone.swift +153 -197
- package/ios/MicrophoneDataDelegate.swift +10 -1
- package/ios/SharedAudioEngine.swift +18 -0
- package/package.json +1 -1
- package/src/events.ts +28 -0
- package/src/index.ts +29 -26
- package/src/types.ts +13 -0
package/ios/Microphone.swift
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
56
|
-
NotificationCenter.default.removeObserver(self)
|
|
57
|
-
}
|
|
31
|
+
// ── SharedAudioEngineDelegate ────────────────────────────────────────
|
|
58
32
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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("
|
|
122
|
+
Logger.debug("[Microphone] Recording is already in progress.")
|
|
186
123
|
return StartRecordingResult(error: "Recording is already in progress.")
|
|
187
124
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
152
|
+
try installTap(intervalMilliseconds: intervalMilliseconds)
|
|
225
153
|
isRecording = true
|
|
226
|
-
Logger.debug("
|
|
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("
|
|
163
|
+
Logger.debug("[Microphone] Could not start recording: \(error.localizedDescription)")
|
|
164
|
+
sharedAudioEngine?.removeDelegate(self)
|
|
236
165
|
isRecording = false
|
|
237
|
-
return StartRecordingResult(error: "
|
|
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
|
|
246
|
-
|
|
247
|
-
promiseResolver.resolve(nil)
|
|
248
|
-
}
|
|
172
|
+
guard isRecording else {
|
|
173
|
+
promise?.resolve(nil)
|
|
249
174
|
return
|
|
250
175
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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(
|
|
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:
|
|
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
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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";
|