@siteed/expo-audio-studio 2.4.1 → 2.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/CHANGELOG.md +14 -1
- package/README.md +25 -0
- package/android/src/main/java/net/siteed/audiostream/AudioAnalysisData.kt +22 -0
- package/android/src/main/java/net/siteed/audiostream/AudioDeviceManager.kt +1501 -0
- package/android/src/main/java/net/siteed/audiostream/AudioFileHandler.kt +10 -5
- package/android/src/main/java/net/siteed/audiostream/AudioNotificationsManager.kt +27 -25
- package/android/src/main/java/net/siteed/audiostream/AudioProcessor.kt +73 -71
- package/android/src/main/java/net/siteed/audiostream/AudioRecorderManager.kt +576 -252
- package/android/src/main/java/net/siteed/audiostream/Constants.kt +17 -1
- package/android/src/main/java/net/siteed/audiostream/ExpoAudioStreamModule.kt +419 -155
- package/android/src/main/java/net/siteed/audiostream/LogUtils.kt +65 -0
- package/android/src/main/java/net/siteed/audiostream/RecordingConfig.kt +9 -1
- package/build/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
- package/build/AudioDeviceManager.d.ts +107 -0
- package/build/AudioDeviceManager.d.ts.map +1 -0
- package/build/AudioDeviceManager.js +493 -0
- package/build/AudioDeviceManager.js.map +1 -0
- package/build/AudioRecorder.provider.d.ts.map +1 -1
- package/build/AudioRecorder.provider.js +3 -0
- package/build/AudioRecorder.provider.js.map +1 -1
- package/build/ExpoAudioStream.types.d.ts +104 -1
- package/build/ExpoAudioStream.types.d.ts.map +1 -1
- package/build/ExpoAudioStream.types.js +7 -1
- package/build/ExpoAudioStream.types.js.map +1 -1
- package/build/ExpoAudioStream.web.d.ts +37 -0
- package/build/ExpoAudioStream.web.d.ts.map +1 -1
- package/build/ExpoAudioStream.web.js +478 -62
- package/build/ExpoAudioStream.web.js.map +1 -1
- package/build/ExpoAudioStreamModule.d.ts.map +1 -1
- package/build/ExpoAudioStreamModule.js +20 -0
- package/build/ExpoAudioStreamModule.js.map +1 -1
- package/build/WebRecorder.web.d.ts +74 -11
- package/build/WebRecorder.web.d.ts.map +1 -1
- package/build/WebRecorder.web.js +390 -74
- package/build/WebRecorder.web.js.map +1 -1
- package/build/hooks/useAudioDevices.d.ts +14 -0
- package/build/hooks/useAudioDevices.d.ts.map +1 -0
- package/build/hooks/useAudioDevices.js +151 -0
- package/build/hooks/useAudioDevices.js.map +1 -0
- package/build/index.d.ts +2 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +4 -0
- package/build/index.js.map +1 -1
- package/build/useAudioRecorder.d.ts +1 -0
- package/build/useAudioRecorder.d.ts.map +1 -1
- package/build/useAudioRecorder.js +20 -1
- package/build/useAudioRecorder.js.map +1 -1
- package/build/utils/BlobFix.d.ts.map +1 -1
- package/build/utils/BlobFix.js +2 -2
- package/build/utils/BlobFix.js.map +1 -1
- package/build/utils/writeWavHeader.d.ts +3 -18
- package/build/utils/writeWavHeader.d.ts.map +1 -1
- package/build/utils/writeWavHeader.js +19 -26
- package/build/utils/writeWavHeader.js.map +1 -1
- package/build/workers/InlineFeaturesExtractor.web.d.ts +1 -1
- package/build/workers/InlineFeaturesExtractor.web.d.ts.map +1 -1
- package/build/workers/InlineFeaturesExtractor.web.js +27 -26
- package/build/workers/InlineFeaturesExtractor.web.js.map +1 -1
- package/build/workers/inlineAudioWebWorker.web.d.ts +1 -1
- package/build/workers/inlineAudioWebWorker.web.d.ts.map +1 -1
- package/build/workers/inlineAudioWebWorker.web.js +25 -1
- package/build/workers/inlineAudioWebWorker.web.js.map +1 -1
- package/ios/AudioDeviceManager.swift +654 -0
- package/ios/AudioStreamManager.swift +964 -760
- package/ios/ExpoAudioStreamModule.swift +174 -19
- package/ios/Features.swift +1 -1
- package/ios/ISSUE_IOS.md +45 -0
- package/ios/Logger.swift +13 -1
- package/ios/RecordingSettings.swift +12 -0
- package/package.json +2 -2
- package/src/AudioAnalysis/AudioAnalysis.types.ts +2 -2
- package/src/AudioDeviceManager.ts +571 -0
- package/src/AudioRecorder.provider.tsx +3 -0
- package/src/ExpoAudioStream.types.ts +113 -1
- package/src/ExpoAudioStream.web.ts +609 -69
- package/src/ExpoAudioStreamModule.ts +23 -0
- package/src/WebRecorder.web.ts +482 -92
- package/src/hooks/useAudioDevices.ts +180 -0
- package/src/index.ts +6 -0
- package/src/types/crc-32.d.ts +6 -6
- package/src/useAudioRecorder.tsx +27 -1
- package/src/utils/BlobFix.ts +6 -4
- package/src/utils/writeWavHeader.ts +26 -25
- package/src/workers/InlineFeaturesExtractor.web.tsx +27 -26
- package/src/workers/inlineAudioWebWorker.web.tsx +25 -1
|
@@ -6,12 +6,24 @@ import AVFoundation
|
|
|
6
6
|
private let audioDataEvent: String = "AudioData"
|
|
7
7
|
private let audioAnalysisEvent: String = "AudioAnalysis"
|
|
8
8
|
private let recordingInterruptedEvent: String = "onRecordingInterrupted"
|
|
9
|
+
private let deviceChangedEvent: String = "deviceChangedEvent"
|
|
10
|
+
private let trimProgressEvent: String = "TrimProgress"
|
|
11
|
+
private let errorEvent: String = "error"
|
|
9
12
|
private let DEFAULT_SEGMENT_DURATION_MS = 100
|
|
13
|
+
private let audioDeviceTypeBuiltinMic = "builtin_mic"
|
|
14
|
+
private let audioDeviceTypeBluetooth = "bluetooth"
|
|
15
|
+
private let audioDeviceTypeUSB = "usb"
|
|
16
|
+
private let audioDeviceTypeWiredHeadset = "wired_headset"
|
|
17
|
+
private let audioDeviceTypeWiredHeadphones = "wired_headphones"
|
|
18
|
+
private let audioDeviceTypeSpeaker = "speaker"
|
|
19
|
+
private let audioDeviceTypeUnknown = "unknown"
|
|
10
20
|
|
|
11
21
|
public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
12
22
|
private var streamManager = AudioStreamManager()
|
|
13
23
|
private let notificationCenter = UNUserNotificationCenter.current()
|
|
14
24
|
private let notificationIdentifier = "audio_recording_notification"
|
|
25
|
+
private var deviceManager = AudioDeviceManager()
|
|
26
|
+
private var deviceChangeObserver: Any?
|
|
15
27
|
|
|
16
28
|
public func definition() -> ModuleDefinition {
|
|
17
29
|
Name("ExpoAudioStream")
|
|
@@ -20,14 +32,22 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
20
32
|
Events([
|
|
21
33
|
audioDataEvent,
|
|
22
34
|
audioAnalysisEvent,
|
|
23
|
-
recordingInterruptedEvent
|
|
35
|
+
recordingInterruptedEvent,
|
|
36
|
+
deviceChangedEvent,
|
|
37
|
+
trimProgressEvent,
|
|
38
|
+
errorEvent
|
|
24
39
|
])
|
|
25
40
|
|
|
26
41
|
OnCreate {
|
|
27
|
-
|
|
42
|
+
Logger.debug("[ExpoAudioStreamModule] Module created, setting delegate and starting device monitoring.")
|
|
28
43
|
streamManager.delegate = self
|
|
29
44
|
}
|
|
30
45
|
|
|
46
|
+
OnDestroy {
|
|
47
|
+
Logger.debug("[ExpoAudioStreamModule] Module destroyed, stopping device monitoring.")
|
|
48
|
+
_ = streamManager.stopRecording()
|
|
49
|
+
}
|
|
50
|
+
|
|
31
51
|
/// Extracts audio analysis data from an audio file.
|
|
32
52
|
///
|
|
33
53
|
/// - Parameters:
|
|
@@ -38,8 +58,10 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
38
58
|
/// - promise: A promise to resolve with the extracted audio analysis data or reject with an error.
|
|
39
59
|
/// - Returns: Promise to be resolved with audio analysis data.
|
|
40
60
|
AsyncFunction("extractAudioAnalysis") { (options: [String: Any], promise: Promise) in
|
|
61
|
+
Logger.debug("[ExpoAudioStreamModule] extractAudioAnalysis called with options: \(options)")
|
|
41
62
|
guard let fileUri = options["fileUri"] as? String,
|
|
42
63
|
let url = URL(string: fileUri) else {
|
|
64
|
+
Logger.error("[ExpoAudioStreamModule] extractAudioAnalysis: Invalid file URI.")
|
|
43
65
|
promise.reject("INVALID_ARGUMENTS", "Invalid file URI provided")
|
|
44
66
|
return
|
|
45
67
|
}
|
|
@@ -84,10 +106,11 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
84
106
|
effectiveLength = byteLength
|
|
85
107
|
}
|
|
86
108
|
|
|
109
|
+
Logger.debug("[ExpoAudioStreamModule] extractAudioAnalysis: Processing started for \(fileUri)")
|
|
87
110
|
let audioProcessor = try AudioProcessor(url: url, resolve: { result in
|
|
88
|
-
|
|
111
|
+
Logger.warn("[ExpoAudioStreamModule] extractAudioAnalysis: AudioProcessor resolve called unexpectedly.")
|
|
89
112
|
}, reject: { code, message in
|
|
90
|
-
|
|
113
|
+
Logger.warn("[ExpoAudioStreamModule] extractAudioAnalysis: AudioProcessor reject called unexpectedly: \(code) - \(message)")
|
|
91
114
|
})
|
|
92
115
|
|
|
93
116
|
if let result = audioProcessor.processAudioData(
|
|
@@ -101,11 +124,14 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
101
124
|
position: effectivePosition,
|
|
102
125
|
byteLength: effectiveLength
|
|
103
126
|
) {
|
|
127
|
+
Logger.debug("[ExpoAudioStreamModule] extractAudioAnalysis: Processing successful for \(fileUri)")
|
|
104
128
|
promise.resolve(result.toDictionary())
|
|
105
129
|
} else {
|
|
130
|
+
Logger.error("[ExpoAudioStreamModule] extractAudioAnalysis: audioProcessor.processAudioData returned nil for \(fileUri)")
|
|
106
131
|
promise.reject("PROCESSING_ERROR", "Failed to process audio data")
|
|
107
132
|
}
|
|
108
133
|
} catch {
|
|
134
|
+
Logger.error("[ExpoAudioStreamModule] extractAudioAnalysis: Error initializing AudioProcessor for \(fileUri): \(error.localizedDescription)")
|
|
109
135
|
promise.reject("PROCESSING_ERROR", "Failed to initialize audio processor: \(error.localizedDescription)")
|
|
110
136
|
}
|
|
111
137
|
})
|
|
@@ -132,8 +158,10 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
132
158
|
/// - `bitrate`: The compression bitrate in bps (default is 128000).
|
|
133
159
|
/// - promise: A promise to resolve with the recording settings or reject with an error.
|
|
134
160
|
AsyncFunction("startRecording") { (options: [String: Any], promise: Promise) in
|
|
161
|
+
Logger.debug("[ExpoAudioStreamModule] startRecording called with options: \(options)")
|
|
135
162
|
self.checkMicrophonePermission { granted in
|
|
136
163
|
guard granted else {
|
|
164
|
+
Logger.warn("[ExpoAudioStreamModule] startRecording: Permission denied.")
|
|
137
165
|
promise.reject("PERMISSION_DENIED", "Recording permission has not been granted")
|
|
138
166
|
return
|
|
139
167
|
}
|
|
@@ -143,6 +171,7 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
143
171
|
|
|
144
172
|
switch settingsResult {
|
|
145
173
|
case .success(let settings):
|
|
174
|
+
Logger.debug("[ExpoAudioStreamModule] startRecording: Settings parsed successfully. ShowNotification=\(settings.showNotification)")
|
|
146
175
|
// Initialize notification if enabled
|
|
147
176
|
if settings.showNotification {
|
|
148
177
|
Task {
|
|
@@ -153,6 +182,7 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
153
182
|
}
|
|
154
183
|
}
|
|
155
184
|
|
|
185
|
+
Logger.debug("[ExpoAudioStreamModule] startRecording: Calling streamManager.startRecording")
|
|
156
186
|
if let result = self.streamManager.startRecording(settings: settings) {
|
|
157
187
|
var resultDict: [String: Any] = [
|
|
158
188
|
"fileUri": result.fileUri,
|
|
@@ -172,12 +202,15 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
172
202
|
]
|
|
173
203
|
}
|
|
174
204
|
|
|
205
|
+
Logger.info("[ExpoAudioStreamModule] startRecording: Recording started successfully. fileUri: \(result.fileUri)")
|
|
175
206
|
promise.resolve(resultDict)
|
|
176
207
|
} else {
|
|
208
|
+
Logger.error("[ExpoAudioStreamModule] startRecording: streamManager.startRecording returned nil.")
|
|
177
209
|
promise.reject("ERROR", "Failed to start recording.")
|
|
178
210
|
}
|
|
179
211
|
|
|
180
212
|
case .failure(let error):
|
|
213
|
+
Logger.error("[ExpoAudioStreamModule] startRecording: Invalid settings - \(error.localizedDescription)")
|
|
181
214
|
promise.reject("INVALID_SETTINGS", error.localizedDescription)
|
|
182
215
|
}
|
|
183
216
|
}
|
|
@@ -187,16 +220,54 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
187
220
|
///
|
|
188
221
|
/// - Returns: The current status of the audio stream.Ï
|
|
189
222
|
Function("status") {
|
|
190
|
-
|
|
223
|
+
let currentStatus = self.streamManager.getStatus()
|
|
224
|
+
Logger.debug("[ExpoAudioStreamModule] status requested: isRecording=\(currentStatus["isRecording"] ?? false), isPaused=\(currentStatus["isPaused"] ?? false)")
|
|
225
|
+
return currentStatus
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/// Prepares audio recording with the specified settings without starting it.
|
|
229
|
+
///
|
|
230
|
+
/// - Parameters:
|
|
231
|
+
/// - options: The recording settings to use.
|
|
232
|
+
/// - promise: A promise to resolve with true if preparation was successful.
|
|
233
|
+
/// - Returns: A promise that resolves with a boolean indicating success.
|
|
234
|
+
AsyncFunction("prepareRecording") { (options: [String: Any], promise: Promise) in
|
|
235
|
+
Logger.debug("[ExpoAudioStreamModule] prepareRecording called with options: \(options)")
|
|
236
|
+
self.checkMicrophonePermission { granted in
|
|
237
|
+
guard granted else {
|
|
238
|
+
promise.reject("PERMISSION_DENIED", "Recording permission has not been granted")
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Create settings with validation
|
|
243
|
+
let settingsResult = RecordingSettings.fromDictionary(options)
|
|
244
|
+
|
|
245
|
+
switch settingsResult {
|
|
246
|
+
case .success(let settings):
|
|
247
|
+
Logger.debug("[ExpoAudioStreamModule] prepareRecording: Settings parsed successfully. Calling streamManager.prepareRecording")
|
|
248
|
+
if self.streamManager.prepareRecording(settings: settings) {
|
|
249
|
+
Logger.info("[ExpoAudioStreamModule] prepareRecording: Preparation successful.")
|
|
250
|
+
promise.resolve(true)
|
|
251
|
+
} else {
|
|
252
|
+
Logger.error("[ExpoAudioStreamModule] prepareRecording: streamManager.prepareRecording returned false.")
|
|
253
|
+
promise.reject("ERROR", "Failed to prepare recording.")
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
case .failure(let error):
|
|
257
|
+
promise.reject("INVALID_SETTINGS", error.localizedDescription)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
191
260
|
}
|
|
192
261
|
|
|
193
262
|
/// Pauses audio recording.
|
|
194
263
|
Function("pauseRecording") {
|
|
264
|
+
Logger.debug("[ExpoAudioStreamModule] pauseRecording called.")
|
|
195
265
|
self.streamManager.pauseRecording()
|
|
196
266
|
}
|
|
197
267
|
|
|
198
268
|
/// Resumes audio recording.
|
|
199
269
|
Function("resumeRecording") {
|
|
270
|
+
Logger.debug("[ExpoAudioStreamModule] resumeRecording called.")
|
|
200
271
|
self.streamManager.resumeRecording()
|
|
201
272
|
}
|
|
202
273
|
|
|
@@ -205,6 +276,7 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
205
276
|
/// - Parameters:
|
|
206
277
|
/// - promise: A promise to resolve with the recording result or reject with an error.
|
|
207
278
|
AsyncFunction("stopRecording") { (promise: Promise) in
|
|
279
|
+
Logger.debug("[ExpoAudioStreamModule] stopRecording called.")
|
|
208
280
|
if let recordingResult = self.streamManager.stopRecording() {
|
|
209
281
|
var resultDict: [String: Any] = [
|
|
210
282
|
"fileUri": recordingResult.fileUri,
|
|
@@ -229,8 +301,10 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
229
301
|
]
|
|
230
302
|
}
|
|
231
303
|
|
|
304
|
+
Logger.info("[ExpoAudioStreamModule] stopRecording: Recording stopped successfully. fileUri: \(recordingResult.fileUri), size: \(recordingResult.size)")
|
|
232
305
|
promise.resolve(resultDict)
|
|
233
306
|
} else {
|
|
307
|
+
Logger.error("[ExpoAudioStreamModule] stopRecording: streamManager.stopRecording returned nil.")
|
|
234
308
|
promise.reject("ERROR", "Failed to stop recording or no recording in progress.")
|
|
235
309
|
}
|
|
236
310
|
}
|
|
@@ -241,12 +315,15 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
241
315
|
/// - promise: A promise to resolve with the list of audio file URIs or reject with an error.
|
|
242
316
|
/// - Returns: A promise that resolves with the list of audio file URIs or rejects with an error.
|
|
243
317
|
AsyncFunction("listAudioFiles") { (promise: Promise) in
|
|
318
|
+
Logger.debug("[ExpoAudioStreamModule] listAudioFiles called.")
|
|
244
319
|
let files = listAudioFiles()
|
|
320
|
+
Logger.debug("[ExpoAudioStreamModule] listAudioFiles returning \(files.count) files.")
|
|
245
321
|
promise.resolve(files)
|
|
246
322
|
}
|
|
247
323
|
|
|
248
324
|
/// Clears all audio files stored in the document directory.
|
|
249
325
|
Function("clearAudioFiles") {
|
|
326
|
+
Logger.debug("[ExpoAudioStreamModule] clearAudioFiles called.")
|
|
250
327
|
clearAudioFiles()
|
|
251
328
|
}
|
|
252
329
|
|
|
@@ -335,7 +412,7 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
335
412
|
let ranges = options["ranges"] as? [[String: Double]]
|
|
336
413
|
let outputFileName = options["outputFileName"] as? String
|
|
337
414
|
let outputFormat = options["outputFormat"] as? [String: Any]
|
|
338
|
-
let decodingOptions = options["decodingOptions"] as? [String: Any]
|
|
415
|
+
let decodingOptions = options["decodingOptions"] as? [String: Any] ?? [:]
|
|
339
416
|
|
|
340
417
|
// Add detailed logging for filename and format options
|
|
341
418
|
Logger.debug("Trim audio request:")
|
|
@@ -378,7 +455,7 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
378
455
|
)
|
|
379
456
|
|
|
380
457
|
let progressCallback: (Float, Int64, Int64) -> Void = { progress, bytesProcessed, totalBytes in
|
|
381
|
-
self.sendEvent(
|
|
458
|
+
self.sendEvent(trimProgressEvent, [
|
|
382
459
|
"progress": progress,
|
|
383
460
|
"bytesProcessed": bytesProcessed,
|
|
384
461
|
"totalBytes": totalBytes
|
|
@@ -476,6 +553,9 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
476
553
|
let position = options["position"] as? Int
|
|
477
554
|
let length = options["length"] as? Int
|
|
478
555
|
let includeWavHeader = options["includeWavHeader"] as? Bool ?? false
|
|
556
|
+
let includeNormalizedData = options["includeNormalizedData"] as? Bool ?? false
|
|
557
|
+
let decodingOptions = options["decodingOptions"] as? [String: Any] ?? [:]
|
|
558
|
+
let includeBase64Data = options["includeBase64Data"] as? Bool ?? false
|
|
479
559
|
|
|
480
560
|
// Validate that we have either time range or byte range, but not both and not neither
|
|
481
561
|
let hasTimeRange = startTimeMs != nil && endTimeMs != nil
|
|
@@ -520,9 +600,6 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
520
600
|
|
|
521
601
|
let frameCount = AVAudioFrameCount(endFrame - startFrame)
|
|
522
602
|
|
|
523
|
-
// Create decoding config that includes normalization preference
|
|
524
|
-
var decodingOptions = options["decodingOptions"] as? [String: Any] ?? [:]
|
|
525
|
-
let includeNormalizedData = options["includeNormalizedData"] as? Bool ?? false
|
|
526
603
|
|
|
527
604
|
// Pass both options separately - normalizeAudio from decodingOptions, and includeNormalizedData as is
|
|
528
605
|
let decodingConfig = DecodingConfig.fromDictionary(decodingOptions)
|
|
@@ -534,7 +611,7 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
534
611
|
format: format,
|
|
535
612
|
decodingConfig: decodingConfig,
|
|
536
613
|
includeNormalizedData: includeNormalizedData,
|
|
537
|
-
includeBase64Data:
|
|
614
|
+
includeBase64Data: includeBase64Data
|
|
538
615
|
)
|
|
539
616
|
|
|
540
617
|
var resultDict: [String: Any] = [:]
|
|
@@ -602,9 +679,82 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
602
679
|
"Mel spectrogram extraction is currently only available on Android and is experimental"
|
|
603
680
|
)
|
|
604
681
|
}
|
|
682
|
+
|
|
683
|
+
/// Gets available audio input devices with an optional refresh parameter
|
|
684
|
+
/// - Parameters:
|
|
685
|
+
/// - options: Optional dictionary containing a refresh parameter
|
|
686
|
+
/// - promise: A promise to resolve with a list of available audio input devices
|
|
687
|
+
AsyncFunction("getAvailableInputDevices") { (options: [String: Any]?, promise: Promise) in
|
|
688
|
+
Logger.debug("[ExpoAudioStreamModule] getAvailableInputDevices called. Refresh: \(options?["refresh"] ?? false)")
|
|
689
|
+
if let options = options, let refresh = options["refresh"] as? Bool, refresh {
|
|
690
|
+
Logger.debug("Forcing refresh of audio devices")
|
|
691
|
+
self.deviceManager.forceRefreshAudioSession()
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Call the device manager with the promise
|
|
695
|
+
self.deviceManager.getAvailableInputDevices(promise: promise)
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/// Refreshes the audio session to detect newly connected devices
|
|
699
|
+
/// - Returns: Boolean indicating success
|
|
700
|
+
Function("refreshAudioDevices") {
|
|
701
|
+
Logger.debug("[ExpoAudioStreamModule] refreshAudioDevices called.")
|
|
702
|
+
let success = self.deviceManager.forceRefreshAudioSession()
|
|
703
|
+
Logger.debug("[ExpoAudioStreamModule] refreshAudioDevices result: \(success)")
|
|
704
|
+
return ["success": success]
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/// Gets the currently selected audio input device
|
|
708
|
+
///
|
|
709
|
+
/// - Parameters:
|
|
710
|
+
/// - promise: A promise to resolve with the currently selected audio input device
|
|
711
|
+
AsyncFunction("getCurrentInputDevice") { (promise: Promise) in
|
|
712
|
+
Logger.debug("[ExpoAudioStreamModule] getCurrentInputDevice called.")
|
|
713
|
+
self.deviceManager.getCurrentInputDevice(promise: promise)
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/// Selects a specific audio input device for recording
|
|
717
|
+
///
|
|
718
|
+
/// - Parameters:
|
|
719
|
+
/// - deviceId: The ID of the device to select
|
|
720
|
+
/// - promise: A promise to resolve with boolean indicating success
|
|
721
|
+
AsyncFunction("selectInputDevice") { (deviceId: String, promise: Promise) in
|
|
722
|
+
Logger.debug("[ExpoAudioStreamModule] selectInputDevice called with ID: \(deviceId)")
|
|
723
|
+
self.deviceManager.selectInputDevice(deviceId, promise: promise)
|
|
724
|
+
// Update the audio recorder if recording is in progress or prepared
|
|
725
|
+
if self.streamManager.isRecording || self.streamManager.isPrepared {
|
|
726
|
+
Logger.debug("[ExpoAudioStreamModule] selectInputDevice: Calling updateAudioSessionWithCurrentSettings because recording/prepared.")
|
|
727
|
+
self.streamManager.updateAudioSessionWithCurrentSettings()
|
|
728
|
+
} else {
|
|
729
|
+
Logger.debug("[ExpoAudioStreamModule] selectInputDevice: Not calling updateAudioSessionWithCurrentSettings because not recording/prepared.")
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/// Resets to the default audio input device
|
|
734
|
+
///
|
|
735
|
+
/// - Parameters:
|
|
736
|
+
/// - promise: A promise to resolve with boolean indicating success
|
|
737
|
+
AsyncFunction("resetToDefaultDevice") { (promise: Promise) in
|
|
738
|
+
Logger.debug("[ExpoAudioStreamModule] resetToDefaultDevice called.")
|
|
739
|
+
self.deviceManager.resetToDefaultDevice { success, error in
|
|
740
|
+
if success {
|
|
741
|
+
if self.streamManager.isRecording || self.streamManager.isPrepared {
|
|
742
|
+
Logger.debug("[ExpoAudioStreamModule] resetToDefaultDevice: Calling updateAudioSessionWithCurrentSettings because recording/prepared.")
|
|
743
|
+
self.streamManager.updateAudioSessionWithCurrentSettings()
|
|
744
|
+
} else {
|
|
745
|
+
Logger.debug("[ExpoAudioStreamModule] resetToDefaultDevice: Not calling updateAudioSessionWithCurrentSettings because not recording/prepared.")
|
|
746
|
+
}
|
|
747
|
+
promise.resolve(true)
|
|
748
|
+
} else {
|
|
749
|
+
Logger.error("[ExpoAudioStreamModule] resetToDefaultDevice failed: \(error?.localizedDescription ?? "Unknown error")")
|
|
750
|
+
promise.reject("DEVICE_ERROR", "Failed to reset to default device: \(error?.localizedDescription ?? "Unknown error")")
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
605
754
|
}
|
|
606
755
|
|
|
607
756
|
func audioStreamManager(_ manager: AudioStreamManager, didReceiveInterruption info: [String: Any]) {
|
|
757
|
+
Logger.debug("[ExpoAudioStreamModule] Delegate: didReceiveInterruption: \(info)")
|
|
608
758
|
// Convert iOS interruption events to match the TypeScript types
|
|
609
759
|
var reason: String
|
|
610
760
|
var isPaused: Bool = true
|
|
@@ -641,6 +791,7 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
641
791
|
}
|
|
642
792
|
|
|
643
793
|
func audioStreamManager(_ manager: AudioStreamManager, didPauseRecording pauseTime: Date) {
|
|
794
|
+
Logger.debug("[ExpoAudioStreamModule] Delegate: didPauseRecording")
|
|
644
795
|
sendEvent(recordingInterruptedEvent, [
|
|
645
796
|
"reason": "userPaused",
|
|
646
797
|
"isPaused": true,
|
|
@@ -649,6 +800,7 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
649
800
|
}
|
|
650
801
|
|
|
651
802
|
func audioStreamManager(_ manager: AudioStreamManager, didResumeRecording resumeTime: Date) {
|
|
803
|
+
Logger.debug("[ExpoAudioStreamModule] Delegate: didResumeRecording")
|
|
652
804
|
sendEvent(recordingInterruptedEvent, [
|
|
653
805
|
"reason": "userResumed",
|
|
654
806
|
"isPaused": false,
|
|
@@ -657,6 +809,7 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
657
809
|
}
|
|
658
810
|
|
|
659
811
|
func audioStreamManager(_ manager: AudioStreamManager, didUpdateNotificationState isPaused: Bool) {
|
|
812
|
+
Logger.debug("[ExpoAudioStreamModule] Delegate: didUpdateNotificationState: isPaused=\(isPaused)")
|
|
660
813
|
sendEvent(recordingInterruptedEvent, [
|
|
661
814
|
"reason": "notification",
|
|
662
815
|
"isPaused": isPaused,
|
|
@@ -678,6 +831,8 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
678
831
|
totalDataSize: Int64,
|
|
679
832
|
compressionInfo: [String: Any]?
|
|
680
833
|
) {
|
|
834
|
+
// Reduce log frequency or detail for this potentially high-frequency event
|
|
835
|
+
// Logger.debug("[ExpoAudioStreamModule] Delegate: didReceiveAudioData: size=\(data.count), totalSize=\(totalDataSize)")
|
|
681
836
|
var resultDict: [String: Any] = [
|
|
682
837
|
"fileUri": manager.recordingFileURL?.absoluteString ?? "",
|
|
683
838
|
"lastEmittedSize": totalDataSize,
|
|
@@ -707,10 +862,12 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
707
862
|
}
|
|
708
863
|
|
|
709
864
|
func audioStreamManager(_ manager: AudioStreamManager, didReceiveProcessingResult result: AudioAnalysisData?) {
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
865
|
+
if let data = result {
|
|
866
|
+
Logger.debug("[ExpoAudioStreamModule] Delegate: didReceiveProcessingResult: Received \(data.dataPoints.count) data points.")
|
|
867
|
+
} else {
|
|
868
|
+
Logger.debug("[ExpoAudioStreamModule] Delegate: didReceiveProcessingResult: Received nil result.")
|
|
869
|
+
}
|
|
870
|
+
let resultDict = result?.toDictionary() ?? [:]
|
|
714
871
|
sendEvent(audioAnalysisEvent, resultDict)
|
|
715
872
|
}
|
|
716
873
|
|
|
@@ -799,9 +956,7 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
799
956
|
}
|
|
800
957
|
|
|
801
958
|
func audioStreamManager(_ manager: AudioStreamManager, didFailWithError error: String) {
|
|
802
|
-
|
|
803
|
-
sendEvent("
|
|
804
|
-
"message": error
|
|
805
|
-
])
|
|
959
|
+
Logger.error("[ExpoAudioStreamModule] Delegate: didFailWithError: \(error)")
|
|
960
|
+
sendEvent(errorEvent, [ "message": error ])
|
|
806
961
|
}
|
|
807
962
|
}
|
package/ios/Features.swift
CHANGED
package/ios/ISSUE_IOS.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# iOS Recording Issue: No WAV Data/Analysis When Resampling (e.g., 16kHz) - RESOLVED
|
|
2
|
+
|
|
3
|
+
## Problem Summary
|
|
4
|
+
|
|
5
|
+
Initially, the iOS implementation failed to record WAV audio data or perform real-time analysis when the requested `sampleRate` (e.g., 16,000 Hz) differed from the hardware's native sample rate (e.g., 48,000 Hz), requiring resampling. Recording at the native hardware sample rate worked correctly.
|
|
6
|
+
|
|
7
|
+
The primary symptom was that the tap installed on the `audioEngine.inputNode` using `installTap(onBus:bufferSize:format:)` was **not receiving any audio buffers** when resampling was required (i.e., requested rate != hardware rate). This resulted in:
|
|
8
|
+
|
|
9
|
+
* An empty WAV file (only the initial 44-byte header).
|
|
10
|
+
* No data being sent to the `AudioProcessor` for real-time analysis.
|
|
11
|
+
* No `AudioData` or `AudioAnalysis` events being emitted for the WAV stream.
|
|
12
|
+
|
|
13
|
+
Parallel compressed recording (e.g., AAC) functioned correctly even when the WAV stream failed, indicating the audio engine *was* capturing audio but not delivering it to the tap.
|
|
14
|
+
|
|
15
|
+
## Debugging History & Findings
|
|
16
|
+
|
|
17
|
+
1. **Initial State:** Empty WAV file at 16kHz, working AAC at 16kHz. Confirmed 48kHz WAV worked.
|
|
18
|
+
2. **Tap Installation Format:** Early attempts tried setting the tap format to the *requested* sample rate (16kHz), leading to `AVAudioIONodeImpl.mm:1334 Format mismatch` crashes because the tap format didn't match the actual hardware input format (often 48kHz).
|
|
19
|
+
3. **Using `inputNode.outputFormat`:** Switched to installing the tap using the format reported by `inputNode.outputFormat(forBus: 0)`, assuming this reflected the actual hardware format. Resampling was handled later in `processAudioBuffer`. This fixed the crash but **did not** fix the original issue – the tap still received no buffers at 16kHz.
|
|
20
|
+
4. **Race Condition:** Identified and fixed a race condition where `audioEngine.start()` was called before `isRecording` was set to `true`, causing the tap's initial guard check to fail. This allowed buffers to be processed *after* the flag was set, but the WAV file writing was still faulty (only the first buffer was written).
|
|
21
|
+
5. **Background File I/O Refactor:** Improved WAV file writing by keeping the `FileHandle` open during recording instead of opening/closing for each buffer in the background queue. This fixed the partial file writing issue but didn't solve the core "no buffers received at 16kHz" problem.
|
|
22
|
+
6. **Removing `setPreferredSampleRate`:** Tried removing the `session.setPreferredSampleRate` call, hoping the session would default to the hardware rate, allowing the 48kHz tap (based on `inputNode.outputFormat`) to receive buffers. This **worked** for the internal microphone but caused crashes with Bluetooth headsets, as the Bluetooth hardware *actually* operated at 16kHz, creating a new format mismatch.
|
|
23
|
+
7. **Using `session.sampleRate`:** Attempted to use `session.sampleRate` *after* session activation to determine the tap format. This also proved unreliable, sometimes reporting 16kHz for the session while `inputNode.outputFormat` still reported 48kHz, leading back to the format mismatch crash.
|
|
24
|
+
|
|
25
|
+
## Root Cause
|
|
26
|
+
|
|
27
|
+
The core issue stems from the unreliability and potential inconsistency between:
|
|
28
|
+
|
|
29
|
+
* `AVAudioSession.sampleRate` (especially after `setPreferredSampleRate` or device changes).
|
|
30
|
+
* `audioEngine.inputNode.outputFormat(forBus: 0)` (which might not immediately reflect the true hardware format).
|
|
31
|
+
* The actual sample rate the hardware is delivering to the audio engine.
|
|
32
|
+
|
|
33
|
+
Attempting to force a specific sample rate via `setPreferredSampleRate` or relying solely on `session.sampleRate` post-activation can lead to situations where the format used to install the tap does not match the format the audio engine expects/receives from the hardware input, causing either a crash (`Format mismatch`) or the tap simply not receiving any buffers.
|
|
34
|
+
|
|
35
|
+
## Final Solution
|
|
36
|
+
|
|
37
|
+
The most robust solution was found to be:
|
|
38
|
+
|
|
39
|
+
1. **Configure Session:** Set up the `AVAudioSession` category, mode, and options as required. **Do not** call `setPreferredSampleRate`. Let the session negotiate the rate with the hardware.
|
|
40
|
+
2. **Activate Session:** Activate the audio session.
|
|
41
|
+
3. **Query Input Node Format (Just-In-Time):** Immediately **before** calling `installTap`, query the input node's expected format using `audioEngine.inputNode.outputFormat(forBus: 0)`. This appears to provide the most accurate format that the node *will accept* for the tap at that specific moment.
|
|
42
|
+
4. **Install Tap:** Install the tap using the exact `AVAudioFormat` obtained from `inputNode.outputFormat(forBus: 0)` in the previous step.
|
|
43
|
+
5. **Resample in Tap:** Inside the tap's processing closure (`processAudioBuffer`), check if the received `buffer.format.sampleRate` differs from the `settings.sampleRate` requested by the user. If they differ, perform the resampling explicitly using `AVAudioConverter` (or a similar method) before writing the WAV data or performing analysis.
|
|
44
|
+
|
|
45
|
+
This approach ensures the tap format always matches what the input node requires at the time of installation, regardless of the device (internal mic, Bluetooth) or the requested output sample rate. Subsequent resampling handles the conversion to the user's desired format.
|
package/ios/Logger.swift
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
class Logger {
|
|
2
2
|
static func debug(_ message: @autoclosure () -> String) {
|
|
3
3
|
#if DEBUG
|
|
4
|
-
print(message())
|
|
4
|
+
print("[DEBUG] \(message())")
|
|
5
5
|
#endif
|
|
6
6
|
}
|
|
7
|
+
|
|
8
|
+
static func info(_ message: @autoclosure () -> String) {
|
|
9
|
+
print("[INFO] \(message())")
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
static func warn(_ message: @autoclosure () -> String) {
|
|
13
|
+
print("[WARN] ⚠️ \(message())")
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
static func error(_ message: @autoclosure () -> String) {
|
|
17
|
+
print("[ERROR] 🛑 \(message())")
|
|
18
|
+
}
|
|
7
19
|
}
|
|
@@ -109,6 +109,10 @@ struct RecordingSettings {
|
|
|
109
109
|
// Update default to 100ms
|
|
110
110
|
var segmentDurationMs: Int = 100 // Default 100ms segments
|
|
111
111
|
|
|
112
|
+
// Add these new properties
|
|
113
|
+
var deviceId: String?
|
|
114
|
+
var deviceDisconnectionBehavior: String?
|
|
115
|
+
|
|
112
116
|
static func fromDictionary(_ dict: [String: Any]) -> Result<RecordingSettings, Error> {
|
|
113
117
|
// Extract compression settings
|
|
114
118
|
let compression = dict["compression"] as? [String: Any]
|
|
@@ -127,6 +131,10 @@ struct RecordingSettings {
|
|
|
127
131
|
}
|
|
128
132
|
}
|
|
129
133
|
|
|
134
|
+
// Add extraction of new properties
|
|
135
|
+
let deviceId = dict["deviceId"] as? String
|
|
136
|
+
let deviceDisconnectionBehavior = dict["deviceDisconnectionBehavior"] as? String
|
|
137
|
+
|
|
130
138
|
// Create settings
|
|
131
139
|
var settings = RecordingSettings(
|
|
132
140
|
sampleRate: dict["sampleRate"] as? Double ?? 44100.0,
|
|
@@ -260,6 +268,10 @@ struct RecordingSettings {
|
|
|
260
268
|
|
|
261
269
|
settings.filename = dict["filename"] as? String
|
|
262
270
|
|
|
271
|
+
// Set new properties
|
|
272
|
+
settings.deviceId = deviceId
|
|
273
|
+
settings.deviceDisconnectionBehavior = deviceDisconnectionBehavior
|
|
274
|
+
|
|
263
275
|
return .success(settings)
|
|
264
276
|
}
|
|
265
277
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@siteed/expo-audio-studio",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.0",
|
|
4
4
|
"description": "Comprehensive audio processing library for React Native and Expo with recording, analysis, visualization, and streaming capabilities across iOS, Android, and web",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "build/index.js",
|
|
@@ -108,7 +108,7 @@
|
|
|
108
108
|
"size-limit": "^11.1.4",
|
|
109
109
|
"ts-node": "^10.9.2",
|
|
110
110
|
"typedoc": "^0.27.4",
|
|
111
|
-
"typedoc-plugin-markdown": "
|
|
111
|
+
"typedoc-plugin-markdown": "~4.4.2",
|
|
112
112
|
"typescript": "~5.3.3"
|
|
113
113
|
},
|
|
114
114
|
"peerDependencies": {
|
|
@@ -166,7 +166,7 @@ export interface PreviewOptions extends AudioRangeOptions {
|
|
|
166
166
|
|
|
167
167
|
/**
|
|
168
168
|
* Options for mel-spectrogram extraction
|
|
169
|
-
*
|
|
169
|
+
*
|
|
170
170
|
* @experimental This feature is experimental and currently only available on Android.
|
|
171
171
|
* The API may change in future versions.
|
|
172
172
|
*/
|
|
@@ -189,7 +189,7 @@ export interface ExtractMelSpectrogramOptions {
|
|
|
189
189
|
|
|
190
190
|
/**
|
|
191
191
|
* Return type for mel spectrogram extraction
|
|
192
|
-
*
|
|
192
|
+
*
|
|
193
193
|
* @experimental This feature is experimental and currently only available on Android.
|
|
194
194
|
* The API may change in future versions.
|
|
195
195
|
*/
|