@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.
- package/NATIVE_EVENTS.md +97 -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 +17 -3
- package/android/src/main/java/expo/modules/audiostream/pipeline/AudioPipeline.kt +104 -11
- package/android/src/main/java/expo/modules/audiostream/pipeline/PipelineIntegration.kt +14 -0
- package/build/events.d.ts +26 -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 +3 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +19 -7
- package/build/index.js.map +1 -1
- package/build/pipeline/index.d.ts +14 -1
- package/build/pipeline/index.d.ts.map +1 -1
- package/build/pipeline/index.js +15 -0
- package/build/pipeline/index.js.map +1 -1
- package/build/pipeline/types.d.ts +14 -0
- package/build/pipeline/types.d.ts.map +1 -1
- package/build/pipeline/types.js.map +1 -1
- package/build/types.d.ts +21 -0
- package/build/types.d.ts.map +1 -1
- package/build/types.js.map +1 -1
- package/ios/AudioPipeline.swift +67 -2
- package/ios/ExpoPlayAudioStreamModule.swift +43 -12
- package/ios/Microphone.swift +167 -120
- package/ios/MicrophoneDataDelegate.swift +10 -1
- package/ios/PipelineIntegration.swift +11 -1
- package/ios/SharedAudioEngine.swift +18 -0
- package/package.json +1 -2
- package/plugin/build/index.js +5 -0
- package/plugin/src/index.ts +5 -0
- package/src/events.ts +32 -0
- package/src/index.ts +27 -18
- package/src/pipeline/index.ts +17 -0
- package/src/pipeline/types.ts +15 -0
- package/src/types.ts +22 -0
package/ios/Microphone.swift
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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("
|
|
122
|
+
Logger.debug("[Microphone] Recording is already in progress.")
|
|
78
123
|
return StartRecordingResult(error: "Recording is already in progress.")
|
|
79
124
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
152
|
+
try installTap(intervalMilliseconds: intervalMilliseconds)
|
|
137
153
|
isRecording = true
|
|
138
|
-
Logger.debug("
|
|
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("
|
|
163
|
+
Logger.debug("[Microphone] Could not start recording: \(error.localizedDescription)")
|
|
164
|
+
sharedAudioEngine?.removeDelegate(self)
|
|
148
165
|
isRecording = false
|
|
149
|
-
return StartRecordingResult(error: "
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
}
|
|
171
|
+
pendingInterruptionResume = false
|
|
172
|
+
guard isRecording else {
|
|
173
|
+
promise?.resolve(nil)
|
|
158
174
|
return
|
|
159
175
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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(
|
|
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:
|
|
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.
|
|
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",
|
package/plugin/build/index.js
CHANGED
|
@@ -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')) {
|
package/plugin/src/index.ts
CHANGED
|
@@ -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>
|