@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.
Files changed (85) hide show
  1. package/CHANGELOG.md +14 -1
  2. package/README.md +25 -0
  3. package/android/src/main/java/net/siteed/audiostream/AudioAnalysisData.kt +22 -0
  4. package/android/src/main/java/net/siteed/audiostream/AudioDeviceManager.kt +1501 -0
  5. package/android/src/main/java/net/siteed/audiostream/AudioFileHandler.kt +10 -5
  6. package/android/src/main/java/net/siteed/audiostream/AudioNotificationsManager.kt +27 -25
  7. package/android/src/main/java/net/siteed/audiostream/AudioProcessor.kt +73 -71
  8. package/android/src/main/java/net/siteed/audiostream/AudioRecorderManager.kt +576 -252
  9. package/android/src/main/java/net/siteed/audiostream/Constants.kt +17 -1
  10. package/android/src/main/java/net/siteed/audiostream/ExpoAudioStreamModule.kt +419 -155
  11. package/android/src/main/java/net/siteed/audiostream/LogUtils.kt +65 -0
  12. package/android/src/main/java/net/siteed/audiostream/RecordingConfig.kt +9 -1
  13. package/build/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
  14. package/build/AudioDeviceManager.d.ts +107 -0
  15. package/build/AudioDeviceManager.d.ts.map +1 -0
  16. package/build/AudioDeviceManager.js +493 -0
  17. package/build/AudioDeviceManager.js.map +1 -0
  18. package/build/AudioRecorder.provider.d.ts.map +1 -1
  19. package/build/AudioRecorder.provider.js +3 -0
  20. package/build/AudioRecorder.provider.js.map +1 -1
  21. package/build/ExpoAudioStream.types.d.ts +104 -1
  22. package/build/ExpoAudioStream.types.d.ts.map +1 -1
  23. package/build/ExpoAudioStream.types.js +7 -1
  24. package/build/ExpoAudioStream.types.js.map +1 -1
  25. package/build/ExpoAudioStream.web.d.ts +37 -0
  26. package/build/ExpoAudioStream.web.d.ts.map +1 -1
  27. package/build/ExpoAudioStream.web.js +478 -62
  28. package/build/ExpoAudioStream.web.js.map +1 -1
  29. package/build/ExpoAudioStreamModule.d.ts.map +1 -1
  30. package/build/ExpoAudioStreamModule.js +20 -0
  31. package/build/ExpoAudioStreamModule.js.map +1 -1
  32. package/build/WebRecorder.web.d.ts +74 -11
  33. package/build/WebRecorder.web.d.ts.map +1 -1
  34. package/build/WebRecorder.web.js +390 -74
  35. package/build/WebRecorder.web.js.map +1 -1
  36. package/build/hooks/useAudioDevices.d.ts +14 -0
  37. package/build/hooks/useAudioDevices.d.ts.map +1 -0
  38. package/build/hooks/useAudioDevices.js +151 -0
  39. package/build/hooks/useAudioDevices.js.map +1 -0
  40. package/build/index.d.ts +2 -0
  41. package/build/index.d.ts.map +1 -1
  42. package/build/index.js +4 -0
  43. package/build/index.js.map +1 -1
  44. package/build/useAudioRecorder.d.ts +1 -0
  45. package/build/useAudioRecorder.d.ts.map +1 -1
  46. package/build/useAudioRecorder.js +20 -1
  47. package/build/useAudioRecorder.js.map +1 -1
  48. package/build/utils/BlobFix.d.ts.map +1 -1
  49. package/build/utils/BlobFix.js +2 -2
  50. package/build/utils/BlobFix.js.map +1 -1
  51. package/build/utils/writeWavHeader.d.ts +3 -18
  52. package/build/utils/writeWavHeader.d.ts.map +1 -1
  53. package/build/utils/writeWavHeader.js +19 -26
  54. package/build/utils/writeWavHeader.js.map +1 -1
  55. package/build/workers/InlineFeaturesExtractor.web.d.ts +1 -1
  56. package/build/workers/InlineFeaturesExtractor.web.d.ts.map +1 -1
  57. package/build/workers/InlineFeaturesExtractor.web.js +27 -26
  58. package/build/workers/InlineFeaturesExtractor.web.js.map +1 -1
  59. package/build/workers/inlineAudioWebWorker.web.d.ts +1 -1
  60. package/build/workers/inlineAudioWebWorker.web.d.ts.map +1 -1
  61. package/build/workers/inlineAudioWebWorker.web.js +25 -1
  62. package/build/workers/inlineAudioWebWorker.web.js.map +1 -1
  63. package/ios/AudioDeviceManager.swift +654 -0
  64. package/ios/AudioStreamManager.swift +964 -760
  65. package/ios/ExpoAudioStreamModule.swift +174 -19
  66. package/ios/Features.swift +1 -1
  67. package/ios/ISSUE_IOS.md +45 -0
  68. package/ios/Logger.swift +13 -1
  69. package/ios/RecordingSettings.swift +12 -0
  70. package/package.json +2 -2
  71. package/src/AudioAnalysis/AudioAnalysis.types.ts +2 -2
  72. package/src/AudioDeviceManager.ts +571 -0
  73. package/src/AudioRecorder.provider.tsx +3 -0
  74. package/src/ExpoAudioStream.types.ts +113 -1
  75. package/src/ExpoAudioStream.web.ts +609 -69
  76. package/src/ExpoAudioStreamModule.ts +23 -0
  77. package/src/WebRecorder.web.ts +482 -92
  78. package/src/hooks/useAudioDevices.ts +180 -0
  79. package/src/index.ts +6 -0
  80. package/src/types/crc-32.d.ts +6 -6
  81. package/src/useAudioRecorder.tsx +27 -1
  82. package/src/utils/BlobFix.ts +6 -4
  83. package/src/utils/writeWavHeader.ts +26 -25
  84. package/src/workers/InlineFeaturesExtractor.web.tsx +27 -26
  85. 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
- print("Setting streamManager delegate")
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
- promise.resolve(result)
111
+ Logger.warn("[ExpoAudioStreamModule] extractAudioAnalysis: AudioProcessor resolve called unexpectedly.")
89
112
  }, reject: { code, message in
90
- promise.reject(code, message)
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
- return self.streamManager.getStatus()
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("TrimProgress", [
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: options["includeBase64Data"] as? Bool ?? false
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
- // Handle the processed audio data
711
- // Emit the processing result event to JavaScript
712
- let resultDict = result?.toDictionary() ?? [:]
713
- Logger.debug("emitting \(audioAnalysisEvent) event with \(resultDict)")
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
- // Send error event to JavaScript
803
- sendEvent("error", [
804
- "message": error
805
- ])
959
+ Logger.error("[ExpoAudioStreamModule] Delegate: didFailWithError: \(error)")
960
+ sendEvent(errorEvent, [ "message": error ])
806
961
  }
807
962
  }
@@ -70,7 +70,7 @@ public struct Features {
70
70
 
71
71
  extension Features {
72
72
  func toDictionary() -> [String: Any] {
73
- var dict: [String: Any] = [
73
+ let dict: [String: Any] = [
74
74
  "energy": energy,
75
75
  "mfcc": mfcc,
76
76
  "rms": rms,
@@ -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.4.1",
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": "^4.3.2",
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
  */