@siteed/expo-audio-studio 2.5.0 → 2.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/CHANGELOG.md +9 -1
- package/android/src/main/java/net/siteed/audiostream/LogUtils.kt +3 -3
- package/build/AudioDeviceManager.d.ts +1 -1
- package/build/AudioDeviceManager.js +1 -1
- package/build/AudioDeviceManager.js.map +1 -1
- package/build/ExpoAudioStream.types.d.ts +19 -1
- package/build/ExpoAudioStream.types.d.ts.map +1 -1
- package/build/ExpoAudioStream.types.js.map +1 -1
- package/build/ExpoAudioStream.web.d.ts.map +1 -1
- package/build/ExpoAudioStream.web.js +80 -9
- package/build/ExpoAudioStream.web.js.map +1 -1
- package/build/WebRecorder.web.d.ts +14 -4
- package/build/WebRecorder.web.d.ts.map +1 -1
- package/build/WebRecorder.web.js +121 -14
- package/build/WebRecorder.web.js.map +1 -1
- package/build/useAudioRecorder.d.ts.map +1 -1
- package/build/useAudioRecorder.js +1 -1
- package/build/useAudioRecorder.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/ios/AudioDeviceManager.swift +65 -65
- package/ios/AudioProcessor.swift +32 -32
- package/ios/AudioStreamManager.swift +323 -158
- package/ios/ExpoAudioStreamModule.swift +92 -75
- package/ios/ISSUE_IOS.md +26 -3
- package/ios/Logger.swift +27 -7
- package/package.json +1 -1
- package/src/AudioDeviceManager.ts +1 -1
- package/src/ExpoAudioStream.types.ts +21 -1
- package/src/ExpoAudioStream.web.ts +99 -9
- package/src/WebRecorder.web.ts +146 -21
- package/src/useAudioRecorder.tsx +1 -2
- package/src/utils/writeWavHeader.ts +26 -25
|
@@ -39,12 +39,12 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
39
39
|
])
|
|
40
40
|
|
|
41
41
|
OnCreate {
|
|
42
|
-
Logger.debug("
|
|
42
|
+
Logger.debug("ExpoAudioStreamModule", "Module created, setting delegate and starting device monitoring.")
|
|
43
43
|
streamManager.delegate = self
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
OnDestroy {
|
|
47
|
-
Logger.debug("
|
|
47
|
+
Logger.debug("ExpoAudioStreamModule", "Module destroyed, stopping device monitoring.")
|
|
48
48
|
_ = streamManager.stopRecording()
|
|
49
49
|
}
|
|
50
50
|
|
|
@@ -58,10 +58,10 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
58
58
|
/// - promise: A promise to resolve with the extracted audio analysis data or reject with an error.
|
|
59
59
|
/// - Returns: Promise to be resolved with audio analysis data.
|
|
60
60
|
AsyncFunction("extractAudioAnalysis") { (options: [String: Any], promise: Promise) in
|
|
61
|
-
Logger.debug("
|
|
61
|
+
Logger.debug("ExpoAudioStreamModule", "extractAudioAnalysis called with options: \(options)")
|
|
62
62
|
guard let fileUri = options["fileUri"] as? String,
|
|
63
63
|
let url = URL(string: fileUri) else {
|
|
64
|
-
Logger.error("
|
|
64
|
+
Logger.error("ExpoAudioStreamModule", "extractAudioAnalysis: Invalid file URI.")
|
|
65
65
|
promise.reject("INVALID_ARGUMENTS", "Invalid file URI provided")
|
|
66
66
|
return
|
|
67
67
|
}
|
|
@@ -106,11 +106,11 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
106
106
|
effectiveLength = byteLength
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
Logger.debug("
|
|
109
|
+
Logger.debug("ExpoAudioStreamModule", "extractAudioAnalysis: Processing started for \(fileUri)")
|
|
110
110
|
let audioProcessor = try AudioProcessor(url: url, resolve: { result in
|
|
111
|
-
Logger.warn("
|
|
111
|
+
Logger.warn("ExpoAudioStreamModule", "extractAudioAnalysis: AudioProcessor resolve called unexpectedly.")
|
|
112
112
|
}, reject: { code, message in
|
|
113
|
-
Logger.warn("
|
|
113
|
+
Logger.warn("ExpoAudioStreamModule", "extractAudioAnalysis: AudioProcessor reject called unexpectedly: \(code) - \(message)")
|
|
114
114
|
})
|
|
115
115
|
|
|
116
116
|
if let result = audioProcessor.processAudioData(
|
|
@@ -124,14 +124,14 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
124
124
|
position: effectivePosition,
|
|
125
125
|
byteLength: effectiveLength
|
|
126
126
|
) {
|
|
127
|
-
Logger.debug("
|
|
127
|
+
Logger.debug("ExpoAudioStreamModule", "extractAudioAnalysis: Processing successful for \(fileUri)")
|
|
128
128
|
promise.resolve(result.toDictionary())
|
|
129
129
|
} else {
|
|
130
|
-
Logger.error("
|
|
130
|
+
Logger.error("ExpoAudioStreamModule", "extractAudioAnalysis: audioProcessor.processAudioData returned nil for \(fileUri)")
|
|
131
131
|
promise.reject("PROCESSING_ERROR", "Failed to process audio data")
|
|
132
132
|
}
|
|
133
133
|
} catch {
|
|
134
|
-
Logger.error("
|
|
134
|
+
Logger.error("ExpoAudioStreamModule", "extractAudioAnalysis: Error initializing AudioProcessor for \(fileUri): \(error.localizedDescription)")
|
|
135
135
|
promise.reject("PROCESSING_ERROR", "Failed to initialize audio processor: \(error.localizedDescription)")
|
|
136
136
|
}
|
|
137
137
|
})
|
|
@@ -158,31 +158,48 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
158
158
|
/// - `bitrate`: The compression bitrate in bps (default is 128000).
|
|
159
159
|
/// - promise: A promise to resolve with the recording settings or reject with an error.
|
|
160
160
|
AsyncFunction("startRecording") { (options: [String: Any], promise: Promise) in
|
|
161
|
-
Logger.debug("
|
|
161
|
+
Logger.debug("ExpoAudioStreamModule", "startRecording called with options: \(options)")
|
|
162
162
|
self.checkMicrophonePermission { granted in
|
|
163
163
|
guard granted else {
|
|
164
|
-
Logger.warn("
|
|
164
|
+
Logger.warn("ExpoAudioStreamModule", "startRecording: Permission denied.")
|
|
165
165
|
promise.reject("PERMISSION_DENIED", "Recording permission has not been granted")
|
|
166
166
|
return
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
-
//
|
|
170
|
-
|
|
169
|
+
// Check if compression is enabled and format is Opus
|
|
170
|
+
var modifiedOptions = options
|
|
171
|
+
if let compression = options["compression"] as? [String: Any],
|
|
172
|
+
let enabled = compression["enabled"] as? Bool, enabled,
|
|
173
|
+
let format = compression["format"] as? String,
|
|
174
|
+
format.lowercased() == "opus" {
|
|
175
|
+
|
|
176
|
+
// Create a mutable copy of the compression dictionary
|
|
177
|
+
var modifiedCompression = compression
|
|
178
|
+
|
|
179
|
+
// Change format to AAC and log warning
|
|
180
|
+
modifiedCompression["format"] = "aac"
|
|
181
|
+
modifiedOptions["compression"] = modifiedCompression
|
|
182
|
+
|
|
183
|
+
Logger.warn("ExpoAudioStreamModule", "startRecording: Opus format is not supported on iOS. Falling back to AAC format.")
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Create settings with validation using the potentially modified options
|
|
187
|
+
let settingsResult = RecordingSettings.fromDictionary(modifiedOptions)
|
|
171
188
|
|
|
172
189
|
switch settingsResult {
|
|
173
190
|
case .success(let settings):
|
|
174
|
-
Logger.debug("
|
|
191
|
+
Logger.debug("ExpoAudioStreamModule", "startRecording: Settings parsed successfully. ShowNotification=\(settings.showNotification)")
|
|
175
192
|
// Initialize notification if enabled
|
|
176
193
|
if settings.showNotification {
|
|
177
194
|
Task {
|
|
178
195
|
let notificationGranted = await self.requestNotificationPermissions()
|
|
179
196
|
if !notificationGranted {
|
|
180
|
-
Logger.debug("Notification permissions not granted")
|
|
197
|
+
Logger.debug("ExpoAudioStreamModule", "Notification permissions not granted")
|
|
181
198
|
}
|
|
182
199
|
}
|
|
183
200
|
}
|
|
184
201
|
|
|
185
|
-
Logger.debug("
|
|
202
|
+
Logger.debug("ExpoAudioStreamModule", "startRecording: Calling streamManager.startRecording")
|
|
186
203
|
if let result = self.streamManager.startRecording(settings: settings) {
|
|
187
204
|
var resultDict: [String: Any] = [
|
|
188
205
|
"fileUri": result.fileUri,
|
|
@@ -202,15 +219,15 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
202
219
|
]
|
|
203
220
|
}
|
|
204
221
|
|
|
205
|
-
Logger.info("
|
|
222
|
+
Logger.info("ExpoAudioStreamModule", "startRecording: Recording started successfully. fileUri: \(result.fileUri)")
|
|
206
223
|
promise.resolve(resultDict)
|
|
207
224
|
} else {
|
|
208
|
-
Logger.error("
|
|
225
|
+
Logger.error("ExpoAudioStreamModule", "startRecording: streamManager.startRecording returned nil.")
|
|
209
226
|
promise.reject("ERROR", "Failed to start recording.")
|
|
210
227
|
}
|
|
211
228
|
|
|
212
229
|
case .failure(let error):
|
|
213
|
-
Logger.error("
|
|
230
|
+
Logger.error("ExpoAudioStreamModule", "startRecording: Invalid settings - \(error.localizedDescription)")
|
|
214
231
|
promise.reject("INVALID_SETTINGS", error.localizedDescription)
|
|
215
232
|
}
|
|
216
233
|
}
|
|
@@ -221,7 +238,7 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
221
238
|
/// - Returns: The current status of the audio stream.Ï
|
|
222
239
|
Function("status") {
|
|
223
240
|
let currentStatus = self.streamManager.getStatus()
|
|
224
|
-
Logger.debug("
|
|
241
|
+
Logger.debug("ExpoAudioStreamModule", "status requested: isRecording=\(currentStatus["isRecording"] ?? false), isPaused=\(currentStatus["isPaused"] ?? false)")
|
|
225
242
|
return currentStatus
|
|
226
243
|
}
|
|
227
244
|
|
|
@@ -232,7 +249,7 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
232
249
|
/// - promise: A promise to resolve with true if preparation was successful.
|
|
233
250
|
/// - Returns: A promise that resolves with a boolean indicating success.
|
|
234
251
|
AsyncFunction("prepareRecording") { (options: [String: Any], promise: Promise) in
|
|
235
|
-
Logger.debug("
|
|
252
|
+
Logger.debug("ExpoAudioStreamModule", "prepareRecording called with options: \(options)")
|
|
236
253
|
self.checkMicrophonePermission { granted in
|
|
237
254
|
guard granted else {
|
|
238
255
|
promise.reject("PERMISSION_DENIED", "Recording permission has not been granted")
|
|
@@ -244,12 +261,12 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
244
261
|
|
|
245
262
|
switch settingsResult {
|
|
246
263
|
case .success(let settings):
|
|
247
|
-
Logger.debug("
|
|
264
|
+
Logger.debug("ExpoAudioStreamModule", "prepareRecording: Settings parsed successfully. Calling streamManager.prepareRecording")
|
|
248
265
|
if self.streamManager.prepareRecording(settings: settings) {
|
|
249
|
-
Logger.info("
|
|
266
|
+
Logger.info("ExpoAudioStreamModule", "prepareRecording: Preparation successful.")
|
|
250
267
|
promise.resolve(true)
|
|
251
268
|
} else {
|
|
252
|
-
Logger.error("
|
|
269
|
+
Logger.error("ExpoAudioStreamModule", "prepareRecording: streamManager.prepareRecording returned false.")
|
|
253
270
|
promise.reject("ERROR", "Failed to prepare recording.")
|
|
254
271
|
}
|
|
255
272
|
|
|
@@ -261,13 +278,13 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
261
278
|
|
|
262
279
|
/// Pauses audio recording.
|
|
263
280
|
Function("pauseRecording") {
|
|
264
|
-
Logger.debug("
|
|
281
|
+
Logger.debug("ExpoAudioStreamModule", "pauseRecording called.")
|
|
265
282
|
self.streamManager.pauseRecording()
|
|
266
283
|
}
|
|
267
284
|
|
|
268
285
|
/// Resumes audio recording.
|
|
269
286
|
Function("resumeRecording") {
|
|
270
|
-
Logger.debug("
|
|
287
|
+
Logger.debug("ExpoAudioStreamModule", "resumeRecording called.")
|
|
271
288
|
self.streamManager.resumeRecording()
|
|
272
289
|
}
|
|
273
290
|
|
|
@@ -276,7 +293,7 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
276
293
|
/// - Parameters:
|
|
277
294
|
/// - promise: A promise to resolve with the recording result or reject with an error.
|
|
278
295
|
AsyncFunction("stopRecording") { (promise: Promise) in
|
|
279
|
-
Logger.debug("
|
|
296
|
+
Logger.debug("ExpoAudioStreamModule", "stopRecording called.")
|
|
280
297
|
if let recordingResult = self.streamManager.stopRecording() {
|
|
281
298
|
var resultDict: [String: Any] = [
|
|
282
299
|
"fileUri": recordingResult.fileUri,
|
|
@@ -301,10 +318,10 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
301
318
|
]
|
|
302
319
|
}
|
|
303
320
|
|
|
304
|
-
Logger.info("
|
|
321
|
+
Logger.info("ExpoAudioStreamModule", "stopRecording: Recording stopped successfully. fileUri: \(recordingResult.fileUri), size: \(recordingResult.size)")
|
|
305
322
|
promise.resolve(resultDict)
|
|
306
323
|
} else {
|
|
307
|
-
Logger.error("
|
|
324
|
+
Logger.error("ExpoAudioStreamModule", "stopRecording: streamManager.stopRecording returned nil.")
|
|
308
325
|
promise.reject("ERROR", "Failed to stop recording or no recording in progress.")
|
|
309
326
|
}
|
|
310
327
|
}
|
|
@@ -315,15 +332,15 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
315
332
|
/// - promise: A promise to resolve with the list of audio file URIs or reject with an error.
|
|
316
333
|
/// - Returns: A promise that resolves with the list of audio file URIs or rejects with an error.
|
|
317
334
|
AsyncFunction("listAudioFiles") { (promise: Promise) in
|
|
318
|
-
Logger.debug("
|
|
335
|
+
Logger.debug("ExpoAudioStreamModule", "listAudioFiles called.")
|
|
319
336
|
let files = listAudioFiles()
|
|
320
|
-
Logger.debug("
|
|
337
|
+
Logger.debug("ExpoAudioStreamModule", "listAudioFiles returning \(files.count) files.")
|
|
321
338
|
promise.resolve(files)
|
|
322
339
|
}
|
|
323
340
|
|
|
324
341
|
/// Clears all audio files stored in the document directory.
|
|
325
342
|
Function("clearAudioFiles") {
|
|
326
|
-
Logger.debug("
|
|
343
|
+
Logger.debug("ExpoAudioStreamModule", "clearAudioFiles called.")
|
|
327
344
|
clearAudioFiles()
|
|
328
345
|
}
|
|
329
346
|
|
|
@@ -415,14 +432,14 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
415
432
|
let decodingOptions = options["decodingOptions"] as? [String: Any] ?? [:]
|
|
416
433
|
|
|
417
434
|
// Add detailed logging for filename and format options
|
|
418
|
-
Logger.debug("Trim audio request:")
|
|
419
|
-
Logger.debug("- Input file: \(fileUri)")
|
|
420
|
-
Logger.debug("- Mode: \(mode)")
|
|
421
|
-
Logger.debug("- Output filename: \(outputFileName ?? "not specified (will generate UUID)")")
|
|
435
|
+
Logger.debug("ExpoAudioStreamModule", "Trim audio request:")
|
|
436
|
+
Logger.debug("ExpoAudioStreamModule", "- Input file: \(fileUri)")
|
|
437
|
+
Logger.debug("ExpoAudioStreamModule", "- Mode: \(mode)")
|
|
438
|
+
Logger.debug("ExpoAudioStreamModule", "- Output filename: \(outputFileName ?? "not specified (will generate UUID)")")
|
|
422
439
|
if let format = outputFormat?["format"] as? String {
|
|
423
|
-
Logger.debug("- Output format: \(format)")
|
|
440
|
+
Logger.debug("ExpoAudioStreamModule", "- Output format: \(format)")
|
|
424
441
|
} else {
|
|
425
|
-
Logger.debug("- Output format: not specified (will use default)")
|
|
442
|
+
Logger.debug("ExpoAudioStreamModule", "- Output format: not specified (will use default)")
|
|
426
443
|
}
|
|
427
444
|
|
|
428
445
|
// Input validation based on mode
|
|
@@ -478,28 +495,28 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
478
495
|
resultDict["processingInfo"] = ["durationMs": processingTimeMs]
|
|
479
496
|
|
|
480
497
|
let uri = result.uri
|
|
481
|
-
Logger.debug("Trim completed successfully in \(processingTimeMs)ms")
|
|
482
|
-
Logger.debug("Output file URI: \(uri)")
|
|
498
|
+
Logger.debug("ExpoAudioStreamModule", "Trim completed successfully in \(processingTimeMs)ms")
|
|
499
|
+
Logger.debug("ExpoAudioStreamModule", "Output file URI: \(uri)")
|
|
483
500
|
|
|
484
501
|
// Verify file exists
|
|
485
502
|
let fileManager = FileManager.default
|
|
486
503
|
if let url = URL(string: uri) {
|
|
487
504
|
let exists = fileManager.fileExists(atPath: url.path)
|
|
488
|
-
Logger.debug("File exists at path \(url.path): \(exists)")
|
|
505
|
+
Logger.debug("ExpoAudioStreamModule", "File exists at path \(url.path): \(exists)")
|
|
489
506
|
|
|
490
507
|
// Log filename details
|
|
491
|
-
Logger.debug("Filename: \(url.lastPathComponent)")
|
|
492
|
-
Logger.debug("File extension: \(url.pathExtension.lowercased())")
|
|
508
|
+
Logger.debug("ExpoAudioStreamModule", "Filename: \(url.lastPathComponent)")
|
|
509
|
+
Logger.debug("ExpoAudioStreamModule", "File extension: \(url.pathExtension.lowercased())")
|
|
493
510
|
|
|
494
511
|
// If format is AAC, ensure we're using the correct extension and MIME type
|
|
495
512
|
if let format = outputFormat?["format"] as? String,
|
|
496
513
|
format.lowercased() == "aac" {
|
|
497
514
|
|
|
498
|
-
Logger.debug("AAC format detected - ensuring correct metadata")
|
|
515
|
+
Logger.debug("ExpoAudioStreamModule", "AAC format detected - ensuring correct metadata")
|
|
499
516
|
|
|
500
517
|
// For AAC format, ensure we're using the correct extension and MIME type
|
|
501
518
|
if url.pathExtension.lowercased() == "m4a" {
|
|
502
|
-
Logger.debug("File has correct m4a extension for AAC audio")
|
|
519
|
+
Logger.debug("ExpoAudioStreamModule", "File has correct m4a extension for AAC audio")
|
|
503
520
|
|
|
504
521
|
// Just update the MIME type in the result to ensure correct playback
|
|
505
522
|
if var compression = resultDict["compression"] as? [String: Any] {
|
|
@@ -510,18 +527,18 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
510
527
|
resultDict["mimeType"] = "audio/mp4"
|
|
511
528
|
resultDict["actualFormat"] = "m4a"
|
|
512
529
|
} else {
|
|
513
|
-
Logger.debug("Warning: AAC format should use .m4a extension, but found .\(url.pathExtension.lowercased())")
|
|
530
|
+
Logger.debug("ExpoAudioStreamModule", "Warning: AAC format should use .m4a extension, but found .\(url.pathExtension.lowercased())")
|
|
514
531
|
}
|
|
515
532
|
}
|
|
516
533
|
}
|
|
517
534
|
|
|
518
535
|
promise.resolve(resultDict)
|
|
519
536
|
} else {
|
|
520
|
-
Logger.debug("Failed to trim audio")
|
|
537
|
+
Logger.debug("ExpoAudioStreamModule", "Failed to trim audio")
|
|
521
538
|
promise.reject("TRIM_ERROR", "Failed to trim audio")
|
|
522
539
|
}
|
|
523
540
|
} catch {
|
|
524
|
-
Logger.debug("Failed to initialize audio processor: \(error.localizedDescription)")
|
|
541
|
+
Logger.debug("ExpoAudioStreamModule", "Failed to initialize audio processor: \(error.localizedDescription)")
|
|
525
542
|
promise.reject("PROCESSING_ERROR", "Failed to initialize audio processor: \(error.localizedDescription)")
|
|
526
543
|
}
|
|
527
544
|
}
|
|
@@ -649,7 +666,7 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
649
666
|
let checksum = calculateCRC32(data: pcmData)
|
|
650
667
|
resultDict["checksum"] = Int(checksum)
|
|
651
668
|
|
|
652
|
-
Logger.debug("Computed CRC32 checksum: \(checksum)")
|
|
669
|
+
Logger.debug("ExpoAudioStreamModule", "Computed CRC32 checksum: \(checksum)")
|
|
653
670
|
}
|
|
654
671
|
|
|
655
672
|
if let includeBase64Data = options["includeBase64Data"] as? Bool, includeBase64Data {
|
|
@@ -685,9 +702,9 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
685
702
|
/// - options: Optional dictionary containing a refresh parameter
|
|
686
703
|
/// - promise: A promise to resolve with a list of available audio input devices
|
|
687
704
|
AsyncFunction("getAvailableInputDevices") { (options: [String: Any]?, promise: Promise) in
|
|
688
|
-
Logger.debug("
|
|
705
|
+
Logger.debug("ExpoAudioStreamModule", "getAvailableInputDevices called. Refresh: \(options?["refresh"] ?? false)")
|
|
689
706
|
if let options = options, let refresh = options["refresh"] as? Bool, refresh {
|
|
690
|
-
Logger.debug("Forcing refresh of audio devices")
|
|
707
|
+
Logger.debug("ExpoAudioStreamModule", "Forcing refresh of audio devices")
|
|
691
708
|
self.deviceManager.forceRefreshAudioSession()
|
|
692
709
|
}
|
|
693
710
|
|
|
@@ -698,9 +715,9 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
698
715
|
/// Refreshes the audio session to detect newly connected devices
|
|
699
716
|
/// - Returns: Boolean indicating success
|
|
700
717
|
Function("refreshAudioDevices") {
|
|
701
|
-
Logger.debug("
|
|
718
|
+
Logger.debug("ExpoAudioStreamModule", "refreshAudioDevices called.")
|
|
702
719
|
let success = self.deviceManager.forceRefreshAudioSession()
|
|
703
|
-
Logger.debug("
|
|
720
|
+
Logger.debug("ExpoAudioStreamModule", "refreshAudioDevices result: \(success)")
|
|
704
721
|
return ["success": success]
|
|
705
722
|
}
|
|
706
723
|
|
|
@@ -709,7 +726,7 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
709
726
|
/// - Parameters:
|
|
710
727
|
/// - promise: A promise to resolve with the currently selected audio input device
|
|
711
728
|
AsyncFunction("getCurrentInputDevice") { (promise: Promise) in
|
|
712
|
-
Logger.debug("
|
|
729
|
+
Logger.debug("ExpoAudioStreamModule", "getCurrentInputDevice called.")
|
|
713
730
|
self.deviceManager.getCurrentInputDevice(promise: promise)
|
|
714
731
|
}
|
|
715
732
|
|
|
@@ -719,14 +736,14 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
719
736
|
/// - deviceId: The ID of the device to select
|
|
720
737
|
/// - promise: A promise to resolve with boolean indicating success
|
|
721
738
|
AsyncFunction("selectInputDevice") { (deviceId: String, promise: Promise) in
|
|
722
|
-
Logger.debug("
|
|
739
|
+
Logger.debug("ExpoAudioStreamModule", "selectInputDevice called with ID: \(deviceId)")
|
|
723
740
|
self.deviceManager.selectInputDevice(deviceId, promise: promise)
|
|
724
741
|
// Update the audio recorder if recording is in progress or prepared
|
|
725
742
|
if self.streamManager.isRecording || self.streamManager.isPrepared {
|
|
726
|
-
Logger.debug("
|
|
743
|
+
Logger.debug("ExpoAudioStreamModule", "selectInputDevice: Calling updateAudioSessionWithCurrentSettings because recording/prepared.")
|
|
727
744
|
self.streamManager.updateAudioSessionWithCurrentSettings()
|
|
728
745
|
} else {
|
|
729
|
-
Logger.debug("
|
|
746
|
+
Logger.debug("ExpoAudioStreamModule", "selectInputDevice: Not calling updateAudioSessionWithCurrentSettings because not recording/prepared.")
|
|
730
747
|
}
|
|
731
748
|
}
|
|
732
749
|
|
|
@@ -735,18 +752,18 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
735
752
|
/// - Parameters:
|
|
736
753
|
/// - promise: A promise to resolve with boolean indicating success
|
|
737
754
|
AsyncFunction("resetToDefaultDevice") { (promise: Promise) in
|
|
738
|
-
Logger.debug("
|
|
755
|
+
Logger.debug("ExpoAudioStreamModule", "resetToDefaultDevice called.")
|
|
739
756
|
self.deviceManager.resetToDefaultDevice { success, error in
|
|
740
757
|
if success {
|
|
741
758
|
if self.streamManager.isRecording || self.streamManager.isPrepared {
|
|
742
|
-
Logger.debug("
|
|
759
|
+
Logger.debug("ExpoAudioStreamModule", "resetToDefaultDevice: Calling updateAudioSessionWithCurrentSettings because recording/prepared.")
|
|
743
760
|
self.streamManager.updateAudioSessionWithCurrentSettings()
|
|
744
761
|
} else {
|
|
745
|
-
Logger.debug("
|
|
762
|
+
Logger.debug("ExpoAudioStreamModule", "resetToDefaultDevice: Not calling updateAudioSessionWithCurrentSettings because not recording/prepared.")
|
|
746
763
|
}
|
|
747
764
|
promise.resolve(true)
|
|
748
765
|
} else {
|
|
749
|
-
Logger.error("
|
|
766
|
+
Logger.error("ExpoAudioStreamModule", "resetToDefaultDevice failed: \(error?.localizedDescription ?? "Unknown error")")
|
|
750
767
|
promise.reject("DEVICE_ERROR", "Failed to reset to default device: \(error?.localizedDescription ?? "Unknown error")")
|
|
751
768
|
}
|
|
752
769
|
}
|
|
@@ -754,7 +771,7 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
754
771
|
}
|
|
755
772
|
|
|
756
773
|
func audioStreamManager(_ manager: AudioStreamManager, didReceiveInterruption info: [String: Any]) {
|
|
757
|
-
Logger.debug("
|
|
774
|
+
Logger.debug("ExpoAudioStreamModule", "Delegate: didReceiveInterruption: \(info)")
|
|
758
775
|
// Convert iOS interruption events to match the TypeScript types
|
|
759
776
|
var reason: String
|
|
760
777
|
var isPaused: Bool = true
|
|
@@ -791,7 +808,7 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
791
808
|
}
|
|
792
809
|
|
|
793
810
|
func audioStreamManager(_ manager: AudioStreamManager, didPauseRecording pauseTime: Date) {
|
|
794
|
-
Logger.debug("
|
|
811
|
+
Logger.debug("ExpoAudioStreamModule", "Delegate: didPauseRecording")
|
|
795
812
|
sendEvent(recordingInterruptedEvent, [
|
|
796
813
|
"reason": "userPaused",
|
|
797
814
|
"isPaused": true,
|
|
@@ -800,7 +817,7 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
800
817
|
}
|
|
801
818
|
|
|
802
819
|
func audioStreamManager(_ manager: AudioStreamManager, didResumeRecording resumeTime: Date) {
|
|
803
|
-
Logger.debug("
|
|
820
|
+
Logger.debug("ExpoAudioStreamModule", "Delegate: didResumeRecording")
|
|
804
821
|
sendEvent(recordingInterruptedEvent, [
|
|
805
822
|
"reason": "userResumed",
|
|
806
823
|
"isPaused": false,
|
|
@@ -809,7 +826,7 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
809
826
|
}
|
|
810
827
|
|
|
811
828
|
func audioStreamManager(_ manager: AudioStreamManager, didUpdateNotificationState isPaused: Bool) {
|
|
812
|
-
Logger.debug("
|
|
829
|
+
Logger.debug("ExpoAudioStreamModule", "Delegate: didUpdateNotificationState: isPaused=\(isPaused)")
|
|
813
830
|
sendEvent(recordingInterruptedEvent, [
|
|
814
831
|
"reason": "notification",
|
|
815
832
|
"isPaused": isPaused,
|
|
@@ -856,16 +873,16 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
856
873
|
let options: UNAuthorizationOptions = [.alert, .sound]
|
|
857
874
|
return try await notificationCenter.requestAuthorization(options: options)
|
|
858
875
|
} catch {
|
|
859
|
-
Logger.debug("Failed to request notification permissions: \(error)")
|
|
876
|
+
Logger.debug("ExpoAudioStreamModule", "Failed to request notification permissions: \(error)")
|
|
860
877
|
return false
|
|
861
878
|
}
|
|
862
879
|
}
|
|
863
880
|
|
|
864
881
|
func audioStreamManager(_ manager: AudioStreamManager, didReceiveProcessingResult result: AudioAnalysisData?) {
|
|
865
882
|
if let data = result {
|
|
866
|
-
Logger.debug("
|
|
883
|
+
Logger.debug("ExpoAudioStreamModule", "Delegate: didReceiveProcessingResult: Received \(data.dataPoints.count) data points.")
|
|
867
884
|
} else {
|
|
868
|
-
Logger.debug("
|
|
885
|
+
Logger.debug("ExpoAudioStreamModule", "Delegate: didReceiveProcessingResult: Received nil result.")
|
|
869
886
|
}
|
|
870
887
|
let resultDict = result?.toDictionary() ?? [:]
|
|
871
888
|
sendEvent(audioAnalysisEvent, resultDict)
|
|
@@ -899,12 +916,12 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
899
916
|
if let fileURL = URL(string: fileURLString) {
|
|
900
917
|
do {
|
|
901
918
|
try FileManager.default.removeItem(at: fileURL)
|
|
902
|
-
print("Removed file at:", fileURL.path)
|
|
919
|
+
print("ExpoAudioStreamModule", "Removed file at:", fileURL.path)
|
|
903
920
|
} catch {
|
|
904
|
-
print("Error removing file at \(fileURL.path):", error.localizedDescription)
|
|
921
|
+
print("ExpoAudioStreamModule", "Error removing file at \(fileURL.path):", error.localizedDescription)
|
|
905
922
|
}
|
|
906
923
|
} else {
|
|
907
|
-
print("Invalid URL string: \(fileURLString)")
|
|
924
|
+
print("ExpoAudioStreamModule", "Invalid URL string: \(fileURLString)")
|
|
908
925
|
}
|
|
909
926
|
}
|
|
910
927
|
}
|
|
@@ -941,7 +958,7 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
941
958
|
/// - Returns: An array of file URIs as strings.
|
|
942
959
|
func listAudioFiles() -> [String] {
|
|
943
960
|
guard let documentDirectory = try? FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false) else {
|
|
944
|
-
print("Failed to access document directory.")
|
|
961
|
+
print("ExpoAudioStreamModule", "Failed to access document directory.")
|
|
945
962
|
return []
|
|
946
963
|
}
|
|
947
964
|
|
|
@@ -950,13 +967,13 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
950
967
|
let audioFiles = files.filter { $0.pathExtension == "wav" }.map { $0.absoluteString }
|
|
951
968
|
return audioFiles
|
|
952
969
|
} catch {
|
|
953
|
-
print("Error listing audio files:", error.localizedDescription)
|
|
970
|
+
print("ExpoAudioStreamModule", "Error listing audio files:", error.localizedDescription)
|
|
954
971
|
return []
|
|
955
972
|
}
|
|
956
973
|
}
|
|
957
974
|
|
|
958
975
|
func audioStreamManager(_ manager: AudioStreamManager, didFailWithError error: String) {
|
|
959
|
-
Logger.error("
|
|
976
|
+
Logger.error("ExpoAudioStreamModule", "Delegate: didFailWithError: \(error)")
|
|
960
977
|
sendEvent(errorEvent, [ "message": error ])
|
|
961
978
|
}
|
|
962
979
|
}
|
package/ios/ISSUE_IOS.md
CHANGED
|
@@ -21,6 +21,7 @@ Parallel compressed recording (e.g., AAC) functioned correctly even when the WAV
|
|
|
21
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
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
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
|
+
8. **Using `inputNode.inputFormat`:** After further analysis, discovered that `inputNode.inputFormat(forBus: 0)` gives the actual hardware input format, which may differ from the output format. This is the format that iOS strictly requires for a tap.
|
|
24
25
|
|
|
25
26
|
## Root Cause
|
|
26
27
|
|
|
@@ -28,6 +29,7 @@ The core issue stems from the unreliability and potential inconsistency between:
|
|
|
28
29
|
|
|
29
30
|
* `AVAudioSession.sampleRate` (especially after `setPreferredSampleRate` or device changes).
|
|
30
31
|
* `audioEngine.inputNode.outputFormat(forBus: 0)` (which might not immediately reflect the true hardware format).
|
|
32
|
+
* `audioEngine.inputNode.inputFormat(forBus: 0)` (which provides the hardware's actual input format).
|
|
31
33
|
* The actual sample rate the hardware is delivering to the audio engine.
|
|
32
34
|
|
|
33
35
|
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.
|
|
@@ -38,8 +40,29 @@ The most robust solution was found to be:
|
|
|
38
40
|
|
|
39
41
|
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
42
|
2. **Activate Session:** Activate the audio session.
|
|
41
|
-
3. **Query Input
|
|
42
|
-
4. **Install Tap:** Install the tap using the exact `AVAudioFormat` obtained from `inputNode.
|
|
43
|
+
3. **Query Hardware Input Format (Just-In-Time):** Immediately **before** calling `installTap`, query the hardware's input format using `audioEngine.inputNode.inputFormat(forBus: 0)`. This provides the actual format that the hardware is delivering and that iOS requires for the tap.
|
|
44
|
+
4. **Install Tap:** Install the tap using the exact `AVAudioFormat` obtained from `inputNode.inputFormat(forBus: 0)` in the previous step.
|
|
43
45
|
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
46
|
|
|
45
|
-
This approach ensures the tap format always matches what the
|
|
47
|
+
This approach ensures the tap format always matches what the hardware requires, regardless of the device (internal mic, Bluetooth) or the requested output sample rate. Subsequent resampling handles the conversion to the user's desired format.
|
|
48
|
+
|
|
49
|
+
## Additional Findings: Device Disconnection Handling
|
|
50
|
+
|
|
51
|
+
During testing, we discovered an issue with device disconnection (particularly Bluetooth headsets):
|
|
52
|
+
|
|
53
|
+
1. When a recording device disconnects, iOS reports a route change notification.
|
|
54
|
+
2. If we attempt to resume recording after the device disconnection or switch to a new device, the app would crash with `Format mismatch: input hw <AVAudioFormat: 1 ch, 16000 Hz, Float32>, client format <AVAudioFormat: 1 ch, 48000 Hz, Float32>`.
|
|
55
|
+
|
|
56
|
+
We implemented a robust solution with the following components:
|
|
57
|
+
|
|
58
|
+
1. **Hardware Format Detection:** Created a shared `installTapWithHardwareFormat` method that always queries the current hardware input format using `inputNode.inputFormat(forBus: 0)` before installing a tap.
|
|
59
|
+
|
|
60
|
+
2. **Format Verification on Resume:** When resuming recording after a pause (potentially due to device disconnect), we reinstall the tap with the current hardware format.
|
|
61
|
+
|
|
62
|
+
3. **Fallback Device Handling:** Implemented a configurable device disconnection behavior:
|
|
63
|
+
- `pause`: Pause recording when the current device disconnects (default)
|
|
64
|
+
- `fallback`: Automatically switch to the default device (built-in mic) and continue recording
|
|
65
|
+
|
|
66
|
+
4. **Size Tracking Preservation:** Ensured that during device transitions, the total audio data size is preserved to maintain continuity in the recording.
|
|
67
|
+
|
|
68
|
+
These improvements ensure that when audio devices change during a recording session, the app can either pause gracefully or continue recording with a fallback device, without data loss or crashes due to format mismatches.
|
package/ios/Logger.swift
CHANGED
|
@@ -1,19 +1,39 @@
|
|
|
1
1
|
class Logger {
|
|
2
|
-
|
|
2
|
+
// Similar to Android's TAG_PREFIX for consistent cross-platform logging
|
|
3
|
+
private static let TAG_PREFIX = "ExpoAudioStudio"
|
|
4
|
+
|
|
5
|
+
static func debug(_ className: String, _ message: @autoclosure () -> String) {
|
|
3
6
|
#if DEBUG
|
|
4
|
-
print("[DEBUG] \(message())")
|
|
7
|
+
print("[\(TAG_PREFIX):\(className)] [DEBUG] \(message())")
|
|
5
8
|
#endif
|
|
6
9
|
}
|
|
7
10
|
|
|
8
|
-
static func info(_ message: @autoclosure () -> String) {
|
|
9
|
-
print("[INFO] \(message())")
|
|
11
|
+
static func info(_ className: String, _ message: @autoclosure () -> String) {
|
|
12
|
+
print("[\(TAG_PREFIX):\(className)] [INFO] \(message())")
|
|
10
13
|
}
|
|
11
14
|
|
|
12
|
-
static func warn(_ message: @autoclosure () -> String) {
|
|
13
|
-
print("[WARN] ⚠️ \(message())")
|
|
15
|
+
static func warn(_ className: String, _ message: @autoclosure () -> String) {
|
|
16
|
+
print("[\(TAG_PREFIX):\(className)] [WARN] ⚠️ \(message())")
|
|
14
17
|
}
|
|
15
18
|
|
|
19
|
+
static func error(_ className: String, _ message: @autoclosure () -> String) {
|
|
20
|
+
print("[\(TAG_PREFIX):\(className)] [ERROR] 🛑 \(message())")
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// For backward compatibility with code that doesn't specify a class name
|
|
24
|
+
static func debug(_ message: @autoclosure () -> String) {
|
|
25
|
+
debug("General", message())
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
static func info(_ message: @autoclosure () -> String) {
|
|
29
|
+
info("General", message())
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
static func warn(_ message: @autoclosure () -> String) {
|
|
33
|
+
warn("General", message())
|
|
34
|
+
}
|
|
35
|
+
|
|
16
36
|
static func error(_ message: @autoclosure () -> String) {
|
|
17
|
-
|
|
37
|
+
error("General", message())
|
|
18
38
|
}
|
|
19
39
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@siteed/expo-audio-studio",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.1",
|
|
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",
|
|
@@ -130,7 +130,7 @@ export class AudioDeviceManager {
|
|
|
130
130
|
|
|
131
131
|
/**
|
|
132
132
|
* Get all available audio input devices
|
|
133
|
-
* @param options Optional settings
|
|
133
|
+
* @param options Optional settings to force refresh the device list. Can include a refresh flag.
|
|
134
134
|
* @returns Promise resolving to an array of audio devices conforming to AudioDevice interface
|
|
135
135
|
*/
|
|
136
136
|
async getAvailableDevices(options?: {
|