@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.
Files changed (35) hide show
  1. package/CHANGELOG.md +9 -1
  2. package/android/src/main/java/net/siteed/audiostream/LogUtils.kt +3 -3
  3. package/build/AudioDeviceManager.d.ts +1 -1
  4. package/build/AudioDeviceManager.js +1 -1
  5. package/build/AudioDeviceManager.js.map +1 -1
  6. package/build/ExpoAudioStream.types.d.ts +19 -1
  7. package/build/ExpoAudioStream.types.d.ts.map +1 -1
  8. package/build/ExpoAudioStream.types.js.map +1 -1
  9. package/build/ExpoAudioStream.web.d.ts.map +1 -1
  10. package/build/ExpoAudioStream.web.js +80 -9
  11. package/build/ExpoAudioStream.web.js.map +1 -1
  12. package/build/WebRecorder.web.d.ts +14 -4
  13. package/build/WebRecorder.web.d.ts.map +1 -1
  14. package/build/WebRecorder.web.js +121 -14
  15. package/build/WebRecorder.web.js.map +1 -1
  16. package/build/useAudioRecorder.d.ts.map +1 -1
  17. package/build/useAudioRecorder.js +1 -1
  18. package/build/useAudioRecorder.js.map +1 -1
  19. package/build/utils/writeWavHeader.d.ts +3 -18
  20. package/build/utils/writeWavHeader.d.ts.map +1 -1
  21. package/build/utils/writeWavHeader.js +19 -26
  22. package/build/utils/writeWavHeader.js.map +1 -1
  23. package/ios/AudioDeviceManager.swift +65 -65
  24. package/ios/AudioProcessor.swift +32 -32
  25. package/ios/AudioStreamManager.swift +323 -158
  26. package/ios/ExpoAudioStreamModule.swift +92 -75
  27. package/ios/ISSUE_IOS.md +26 -3
  28. package/ios/Logger.swift +27 -7
  29. package/package.json +1 -1
  30. package/src/AudioDeviceManager.ts +1 -1
  31. package/src/ExpoAudioStream.types.ts +21 -1
  32. package/src/ExpoAudioStream.web.ts +99 -9
  33. package/src/WebRecorder.web.ts +146 -21
  34. package/src/useAudioRecorder.tsx +1 -2
  35. package/src/utils/writeWavHeader.ts +26 -25
@@ -51,11 +51,14 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
51
51
  var isPaused = false
52
52
  var isPrepared = false // Add this new state flag
53
53
 
54
+ // Move static variables to class level
55
+ private var debugBufferCounter = 0
56
+ private var tapCallCount = 0
57
+
54
58
  // Wake lock related properties
55
59
  private var wasIdleTimerDisabled: Bool = false // Track previous idle timer state
56
60
  private var isWakeLockEnabled: Bool = false // Track current wake lock state
57
61
 
58
-
59
62
  // Data emission for onAudioStream
60
63
  internal var lastEmissionTime: Date?
61
64
  internal var lastEmittedSize: Int64 = 0
@@ -153,7 +156,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
153
156
 
154
157
  switch type {
155
158
  case .began:
156
- Logger.debug("Audio session interruption began")
159
+ Logger.debug("AudioStreamManager", "Audio session interruption began")
157
160
  // Store the pause start time if not already paused
158
161
  if !wasSuspended {
159
162
  currentPauseStart = Date()
@@ -170,17 +173,17 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
170
173
  )
171
174
 
172
175
  case .ended:
173
- Logger.debug("Audio session interruption ended - autoResume: \(autoResumeAfterInterruption), wasSuspended: \(wasSuspended)")
176
+ Logger.debug("AudioStreamManager", "Audio session interruption ended - autoResume: \(autoResumeAfterInterruption), wasSuspended: \(wasSuspended)")
174
177
  if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt {
175
178
  let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
176
- Logger.debug("Interruption options - shouldResume: \(options.contains(.shouldResume))")
179
+ Logger.debug("AudioStreamManager", "Interruption options - shouldResume: \(options.contains(.shouldResume))")
177
180
 
178
181
  // Calculate pause duration if we have a pause start time
179
182
  if let pauseStart = currentPauseStart {
180
183
  let pauseDuration = Date().timeIntervalSince(pauseStart)
181
184
  totalPausedDuration += pauseDuration
182
185
  currentPauseStart = nil
183
- Logger.debug("Added interruption pause duration: \(pauseDuration), total paused: \(totalPausedDuration)")
186
+ Logger.debug("AudioStreamManager", "Added interruption pause duration: \(pauseDuration), total paused: \(totalPausedDuration)")
184
187
  }
185
188
 
186
189
  // For phone calls, we should auto-resume if enabled, regardless of previous pause state
@@ -188,7 +191,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
188
191
  // Add a longer delay for phone calls and ensure proper session setup
189
192
  DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
190
193
  guard let self = self else { return }
191
- Logger.debug("Attempting to auto-resume recording after phone call")
194
+ Logger.debug("AudioStreamManager", "Attempting to auto-resume recording after phone call")
192
195
 
193
196
  // Configure audio session
194
197
  do {
@@ -198,14 +201,14 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
198
201
 
199
202
  // Resume if we're still recording and paused
200
203
  if self.isRecording && self.isPaused {
201
- Logger.debug("Resuming recording after phone call interruption")
204
+ Logger.debug("AudioStreamManager", "Resuming recording after phone call interruption")
202
205
  self.audioEngine.prepare()
203
206
  self.resumeRecording()
204
207
  } else {
205
- Logger.debug("Cannot resume - recording state invalid: isRecording=\(self.isRecording), isPaused=\(self.isPaused)")
208
+ Logger.debug("AudioStreamManager", "Cannot resume - recording state invalid: isRecording=\(self.isRecording), isPaused=\(self.isPaused)")
206
209
  }
207
210
  } catch {
208
- Logger.debug("Failed to reactivate audio session: \(error)")
211
+ Logger.debug("AudioStreamManager", "Failed to reactivate audio session: \(error)")
209
212
  self.delegate?.audioStreamManager(self, didFailWithError: "Failed to auto-resume: \(error.localizedDescription)")
210
213
  }
211
214
  }
@@ -233,7 +236,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
233
236
  try audioSession?.setCategory(.playAndRecord, mode: .default, options: [.allowBluetooth, .mixWithOthers])
234
237
  try audioSession?.setActive(true)
235
238
  } catch {
236
- Logger.debug("Failed to configure audio session: \(error)")
239
+ Logger.debug("AudioStreamManager", "Failed to configure audio session: \(error)")
237
240
  }
238
241
 
239
242
  // Setup Now Playing info
@@ -378,7 +381,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
378
381
  let pauseDuration = Date().timeIntervalSince(pauseStart)
379
382
  totalPausedDuration += pauseDuration
380
383
  currentPauseStart = nil
381
- Logger.debug("Added background pause duration: \(pauseDuration), total paused: \(totalPausedDuration)")
384
+ Logger.debug("AudioStreamManager", "Added background pause duration: \(pauseDuration), total paused: \(totalPausedDuration)")
382
385
  }
383
386
 
384
387
  notificationManager?.stopUpdates()
@@ -433,7 +436,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
433
436
  self.wasIdleTimerDisabled = UIApplication.shared.isIdleTimerDisabled
434
437
  UIApplication.shared.isIdleTimerDisabled = true
435
438
  self.isWakeLockEnabled = true
436
- Logger.debug("Wake lock enabled")
439
+ Logger.debug("AudioStreamManager", "Wake lock enabled")
437
440
  }
438
441
  }
439
442
 
@@ -447,7 +450,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
447
450
  DispatchQueue.main.async {
448
451
  UIApplication.shared.isIdleTimerDisabled = self.wasIdleTimerDisabled
449
452
  self.isWakeLockEnabled = false
450
- Logger.debug("Wake lock disabled")
453
+ Logger.debug("AudioStreamManager", "Wake lock disabled")
451
454
  }
452
455
  }
453
456
 
@@ -455,17 +458,17 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
455
458
  /// - Returns: The URL of the newly created recording file, or nil if creation failed.
456
459
  private func createRecordingFile(isCompressed: Bool = false) -> URL? {
457
460
  // Add debug logging
458
- Logger.debug("Creating recording file - settings filename: \(recordingSettings?.filename ?? "nil")")
461
+ Logger.debug("AudioStreamManager", "Creating recording file - settings filename: \(recordingSettings?.filename ?? "nil")")
459
462
 
460
463
  // Get base directory - use default if no custom directory provided
461
464
  let baseDirectory: URL
462
465
  if let customDir = recordingSettings?.outputDirectory {
463
466
  baseDirectory = URL(fileURLWithPath: customDir)
464
- Logger.debug("Using custom directory: \(customDir)")
467
+ Logger.debug("AudioStreamManager", "Using custom directory: \(customDir)")
465
468
  } else {
466
469
  // Use existing default behavior
467
470
  baseDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
468
- Logger.debug("Using default directory: \(baseDirectory.path)")
471
+ Logger.debug("AudioStreamManager", "Using default directory: \(baseDirectory.path)")
469
472
  }
470
473
 
471
474
  // Generate or reuse UUID for filename
@@ -478,7 +481,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
478
481
  recordingUUID = newUUID
479
482
  baseFilename = newUUID.uuidString
480
483
  }
481
- Logger.debug("Using base filename: \(baseFilename)")
484
+ Logger.debug("AudioStreamManager", "Using base filename: \(baseFilename)")
482
485
 
483
486
  // Remove any existing extension from the filename
484
487
  let filenameWithoutExtension = baseFilename.replacingOccurrences(
@@ -496,19 +499,19 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
496
499
  }
497
500
 
498
501
  let fullFilename = "\(filenameWithoutExtension).\(fileExtension)"
499
- Logger.debug("Full filename: \(fullFilename)")
502
+ Logger.debug("AudioStreamManager", "Full filename: \(fullFilename)")
500
503
 
501
504
  let fileURL = baseDirectory.appendingPathComponent(fullFilename)
502
- Logger.debug("Final file URL: \(fileURL.path)")
505
+ Logger.debug("AudioStreamManager", "Final file URL: \(fileURL.path)")
503
506
 
504
507
  // Check if file already exists
505
508
  if fileManager.fileExists(atPath: fileURL.path) {
506
- Logger.debug("File already exists at: \(fileURL.path)")
509
+ Logger.debug("AudioStreamManager", "File already exists at: \(fileURL.path)")
507
510
  return nil
508
511
  }
509
512
 
510
513
  if !fileManager.createFile(atPath: fileURL.path, contents: nil, attributes: nil) {
511
- Logger.debug("Failed to create file at: \(fileURL.path)")
514
+ Logger.debug("AudioStreamManager", "Failed to create file at: \(fileURL.path)")
512
515
  return nil
513
516
  }
514
517
  return fileURL
@@ -588,7 +591,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
588
591
  do {
589
592
  let compressedAttributes = try FileManager.default.attributesOfItem(atPath: compressedURL.path)
590
593
  if let compressedSize = compressedAttributes[.size] as? Int64 {
591
- Logger.debug("Compressed file status - Size: \(compressedSize)")
594
+ Logger.debug("AudioStreamManager", "Compressed file status - Size: \(compressedSize)")
592
595
  let compressionBundle: [String: Any] = [
593
596
  "fileUri": compressedURL.absoluteString,
594
597
  "mimeType": compressedFormat == "aac" ? "audio/aac" : "audio/opus",
@@ -599,7 +602,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
599
602
  status["compression"] = compressionBundle
600
603
  }
601
604
  } catch {
602
- Logger.debug("Error getting compressed file attributes: \(error)")
605
+ Logger.debug("AudioStreamManager", "Error getting compressed file attributes: \(error)")
603
606
  }
604
607
  }
605
608
 
@@ -616,6 +619,52 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
616
619
  audioSession.currentRoute.outputs.contains { $0.portType == .builtInReceiver }
617
620
  }
618
621
 
622
+ /// Installs the audio tap with the hardware-compatible format
623
+ /// - Parameters:
624
+ /// - customTapBlock: Optional custom tap block for specialized processing (like in fallback)
625
+ /// - prepareEngine: Whether to call prepare() on the engine after installing the tap (default: true)
626
+ /// - Returns: The hardware input format that was used for the tap
627
+ private func installTapWithHardwareFormat(
628
+ customTapBlock: ((AVAudioPCMBuffer, AVAudioTime) -> Void)? = nil,
629
+ prepareEngine: Bool = true
630
+ ) -> AVAudioFormat {
631
+ // Get the hardware input format
632
+ let inputNode = audioEngine.inputNode
633
+ let inputHardwareFormat = inputNode.inputFormat(forBus: 0)
634
+ let nodeOutputFormat = inputNode.outputFormat(forBus: 0)
635
+
636
+ // Log format information for diagnostic purposes
637
+ Logger.debug("AudioStreamManager", "Installing tap - Hardware input format: \(describeAudioFormat(inputHardwareFormat))")
638
+ Logger.debug("AudioStreamManager", "Node output format: \(describeAudioFormat(nodeOutputFormat))")
639
+
640
+ // Remove any existing tap
641
+ inputNode.removeTap(onBus: 0)
642
+
643
+ // Create the default tap block if none provided
644
+ let tapBlock = customTapBlock ?? { [weak self] (buffer, time) in
645
+ guard let self = self,
646
+ let fileURL = self.recordingFileURL,
647
+ self.isRecording else {
648
+ return
649
+ }
650
+ // processAudioBuffer will handle resampling if needed
651
+ self.processAudioBuffer(buffer, fileURL: fileURL)
652
+ self.lastBufferTime = time
653
+ }
654
+
655
+ // Install the tap with hardware format
656
+ inputNode.installTap(onBus: 0, bufferSize: 1024, format: inputHardwareFormat, block: tapBlock)
657
+ Logger.debug("AudioStreamManager", "Tap installed with hardware-compatible format")
658
+
659
+ // Prepare the engine if requested
660
+ if prepareEngine {
661
+ audioEngine.prepare()
662
+ Logger.debug("AudioStreamManager", "Engine prepared after tap installation")
663
+ }
664
+
665
+ return inputHardwareFormat
666
+ }
667
+
619
668
  /// Prepares the audio recording with the specified settings without starting it.
620
669
  /// This reduces latency when startRecording is called later.
621
670
  /// - Parameters:
@@ -627,13 +676,13 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
627
676
 
628
677
  // Skip if already prepared or recording
629
678
  guard !isPrepared && !isRecording else {
630
- Logger.debug("Already prepared or recording in progress.")
679
+ Logger.debug("AudioStreamManager", "Already prepared or recording in progress.")
631
680
  return isPrepared
632
681
  }
633
682
 
634
683
  // Check for active call using the new method
635
684
  if isPhoneCallActive() {
636
- Logger.debug("Cannot prepare recording during an active phone call")
685
+ Logger.debug("AudioStreamManager", "Cannot prepare recording during an active phone call")
637
686
  delegate?.audioStreamManager(self, didFailWithError: "Cannot prepare recording during an active phone call")
638
687
  return false
639
688
  }
@@ -645,7 +694,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
645
694
  Thread.sleep(forTimeInterval: 0.1) // Brief pause to ensure clean state
646
695
  try session.setActive(true, options: .notifyOthersOnDeactivation)
647
696
  } catch {
648
- Logger.debug("Failed to reset audio session: \(error)")
697
+ Logger.debug("AudioStreamManager", "Failed to reset audio session: \(error)")
649
698
  delegate?.audioStreamManager(self, didFailWithError: "Failed to reset audio session: \(error.localizedDescription)")
650
699
  return false
651
700
  }
@@ -682,14 +731,14 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
682
731
  let header = createWavHeader(dataSize: 0)
683
732
  self.fileHandle?.write(header)
684
733
  self.totalDataSize = Int64(WAV_HEADER_SIZE) // Initialize size with header size
685
- Logger.debug("File handle opened and initial header written for \(url.path). Initial size: \(self.totalDataSize)")
734
+ Logger.debug("AudioStreamManager", "File handle opened and initial header written for \(url.path). Initial size: \(self.totalDataSize)")
686
735
  } catch {
687
- Logger.debug("Error creating/opening file handle: \(error.localizedDescription)")
736
+ Logger.debug("AudioStreamManager", "Error creating/opening file handle: \(error.localizedDescription)")
688
737
  // No need to call cleanupPreparation here, return false will handle it
689
738
  return false
690
739
  }
691
740
  } else {
692
- Logger.debug("Error: Failed to create recording file URL.")
741
+ Logger.debug("AudioStreamManager", "Error: Failed to create recording file URL.")
693
742
  return false
694
743
  }
695
744
 
@@ -697,11 +746,11 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
697
746
 
698
747
  // Then set up audio session and tap
699
748
  do {
700
- Logger.debug("Configuring audio session with sample rate: \(settings.sampleRate) Hz")
749
+ Logger.debug("AudioStreamManager", "Configuring audio session with sample rate: \(settings.sampleRate) Hz")
701
750
 
702
751
  let session = AVAudioSession.sharedInstance()
703
752
  if let currentRoute = session.currentRoute.outputs.first {
704
- Logger.debug("Current audio output: \(currentRoute.portType)")
753
+ Logger.debug("AudioStreamManager", "Current audio output: \(currentRoute.portType)")
705
754
  newSettings.sampleRate = settings.sampleRate // Keep original sample rate
706
755
  }
707
756
 
@@ -718,12 +767,12 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
718
767
 
719
768
  // Append necessary options for background recording if keepAwake is enabled
720
769
  if settings.keepAwake {
721
- Logger.debug("keepAwake enabled - configuring for background recording")
770
+ Logger.debug("AudioStreamManager", "keepAwake enabled - configuring for background recording")
722
771
  // Add background audio option
723
772
  options.insert(.mixWithOthers)
724
773
  try session.setActive(true, options: .notifyOthersOnDeactivation)
725
774
  } else {
726
- Logger.debug("keepAwake disabled - using standard session configuration")
775
+ Logger.debug("AudioStreamManager", "keepAwake disabled - using standard session configuration")
727
776
  // If keepAwake is false, don't add background audio options
728
777
  try session.setActive(true)
729
778
  }
@@ -739,61 +788,30 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
739
788
  try session.setActive(true, options: .notifyOthersOnDeactivation)
740
789
 
741
790
  // Log session config details as single lines for clarity
742
- Logger.debug("Audio session configured:")
743
- Logger.debug(" - category: \(category)")
744
- Logger.debug(" - mode: \(mode)")
745
- Logger.debug(" - options: \(options)")
746
- Logger.debug(" - keepAwake: \(settings.keepAwake)")
747
- Logger.debug(" - emission interval: \(emissionInterval * 1000)ms")
748
- Logger.debug(" - analysis interval: \(emissionIntervalAnalysis * 1000)ms")
749
- Logger.debug(" - requested sample rate: \(settings.sampleRate)Hz")
750
- Logger.debug(" - actual session sample rate: \(session.sampleRate)Hz") // Log actual rate
751
- Logger.debug(" - channels: \(settings.numberOfChannels)")
752
- Logger.debug(" - bit depth: \(settings.bitDepth)-bit")
753
- Logger.debug(" - compression enabled: \(settings.enableCompressedOutput)")
754
-
755
- // --- Revised Tap Format Logic ---
756
- // Get the input node's format primarily for channel count and data type.
757
- let nodeFormat = audioEngine.inputNode.outputFormat(forBus: 0)
758
- let actualSessionRate = session.sampleRate // Use the session's negotiated rate.
759
-
760
- Logger.debug("Node format suggests: \(describeAudioFormat(nodeFormat))")
761
- Logger.debug("Session reports actual rate: \(actualSessionRate) Hz")
762
-
763
- // Create the tap format using the ACTUAL session sample rate, but node's channel count/type.
764
- // This aims to match the hardware stream (like 16kHz HFP) more reliably.
765
- guard let tapFormat = AVAudioFormat(
766
- commonFormat: nodeFormat.commonFormat, // Keep node's format (e.g., Float32)
767
- sampleRate: actualSessionRate, // Use ACTUAL session rate
768
- channels: nodeFormat.channelCount, // Use node's channel count
769
- interleaved: nodeFormat.isInterleaved // Use node's interleaving
770
- ) else {
771
- Logger.debug("Failed to create tap format with session rate \(actualSessionRate) and node details.")
772
- // Throw an error to prevent proceeding with invalid setup
773
- throw NSError(domain: "AudioStreamManager", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to create tap format for installation."])
774
- }
775
-
776
- // Log tap config details as single lines
777
- Logger.debug("Final Tap Configuration (Using Session Rate):")
778
- Logger.debug(" - Tap Format: \(describeAudioFormat(tapFormat))")
779
- Logger.debug(" - Node Format Was: \(describeAudioFormat(nodeFormat))")
780
- Logger.debug(" - Requested Output Format: \(settings.bitDepth)-bit at \(settings.sampleRate)Hz")
791
+ Logger.debug("AudioStreamManager", "Audio session configured:")
792
+ Logger.debug("AudioStreamManager", " - category: \(category)")
793
+ Logger.debug("AudioStreamManager", " - mode: \(mode)")
794
+ Logger.debug("AudioStreamManager", " - options: \(options)")
795
+ Logger.debug("AudioStreamManager", " - keepAwake: \(settings.keepAwake)")
796
+ Logger.debug("AudioStreamManager", " - emission interval: \(emissionInterval * 1000)ms")
797
+ Logger.debug("AudioStreamManager", " - analysis interval: \(emissionIntervalAnalysis * 1000)ms")
798
+ Logger.debug("AudioStreamManager", " - requested sample rate: \(settings.sampleRate)Hz")
799
+ Logger.debug("AudioStreamManager", " - actual session sample rate: \(session.sampleRate)Hz") // Log actual rate
800
+ Logger.debug("AudioStreamManager", " - channels: \(settings.numberOfChannels)")
801
+ Logger.debug("AudioStreamManager", " - bit depth: \(settings.bitDepth)-bit")
802
+ Logger.debug("AudioStreamManager", " - compression enabled: \(settings.enableCompressedOutput)")
803
+
804
+ // Use our shared tap installation method
805
+ let tapFormat = installTapWithHardwareFormat()
806
+
807
+ // Log tap configuration
808
+ Logger.debug("AudioStreamManager", "Final Tap Configuration (Using Hardware Format):")
809
+ Logger.debug("AudioStreamManager", " - Tap Format: \(describeAudioFormat(tapFormat))")
810
+ Logger.debug("AudioStreamManager", " - Session Rate: \(session.sampleRate) Hz")
811
+ Logger.debug("AudioStreamManager", " - Requested Output Format: \(settings.bitDepth)-bit at \(settings.sampleRate)Hz")
781
812
 
782
813
  recordingSettings = newSettings // Keep original settings with desired sample rate
783
814
 
784
- // Install tap with the format derived from session sample rate
785
- audioEngine.inputNode.installTap(onBus: 0, bufferSize: 1024, format: tapFormat) { [weak self] (buffer, time) in // Use newly constructed tapFormat
786
- guard let self = self,
787
- let fileURL = self.recordingFileURL,
788
- self.isRecording else { // Only process buffer if actually recording
789
- // Logger.debug("Tap received buffer but self, fileURL, or isRecording is invalid. Ignoring.")
790
- return
791
- }
792
- // processAudioBuffer will handle resampling if tapFormat.sampleRate != settings.sampleRate
793
- self.processAudioBuffer(buffer, fileURL: fileURL)
794
- self.lastBufferTime = time
795
- }
796
-
797
815
  audioEngine.prepare() // Prepare the engine without starting it
798
816
 
799
817
  // Setup compressed recording if enabled
@@ -808,13 +826,14 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
808
826
  AVEncoderBitDepthHintKey: settings.bitDepth
809
827
  ]
810
828
 
811
- Logger.debug("Initializing compressed recording with settings: \(compressedSettings)")
829
+
830
+ Logger.debug("AudioStreamManager", "Initializing compressed recording with settings: \(compressedSettings)")
812
831
 
813
832
  // Create file for compressed recording
814
833
  compressedFileURL = createRecordingFile(isCompressed: true)
815
834
 
816
835
  if let url = compressedFileURL {
817
- Logger.debug("Using compressed file URL: \(url.path)")
836
+ Logger.debug("AudioStreamManager", "Using compressed file URL: \(url.path)")
818
837
 
819
838
  // Initialize recorder with proper error handling
820
839
  do {
@@ -823,28 +842,28 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
823
842
  recorder.delegate = self
824
843
 
825
844
  if !recorder.prepareToRecord() {
826
- Logger.debug("Failed to prepare recorder")
845
+ Logger.debug("AudioStreamManager", "Failed to prepare recorder")
827
846
  compressedFileURL = nil
828
847
  compressedRecorder = nil
829
848
  } else {
830
849
  // Note: We don't start the recorder yet, just prepare it
831
- Logger.debug("Compressed recording prepared successfully")
850
+ Logger.debug("AudioStreamManager", "Compressed recording prepared successfully")
832
851
  compressedFormat = settings.compressedFormat
833
852
  compressedBitRate = settings.compressedBitRate
834
853
  }
835
854
  }
836
855
  } catch {
837
- Logger.debug("Failed to initialize compressed recorder: \(error)")
856
+ Logger.debug("AudioStreamManager", "Failed to initialize compressed recorder: \(error)")
838
857
  compressedFileURL = nil
839
858
  compressedRecorder = nil
840
859
  }
841
860
  } else {
842
- Logger.debug("Failed to create compressed recording file")
861
+ Logger.debug("AudioStreamManager", "Failed to create compressed recording file")
843
862
  }
844
863
  }
845
864
 
846
865
  } catch {
847
- Logger.debug("Error: Failed to set up audio session with preferred settings: \(error.localizedDescription)")
866
+ Logger.debug("AudioStreamManager", "Error: Failed to set up audio session with preferred settings: \(error.localizedDescription)")
848
867
  return false
849
868
  }
850
869
 
@@ -857,7 +876,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
857
876
  }, reject: { code, message in
858
877
  // Handle the rejection here if needed
859
878
  })
860
- Logger.debug("AudioProcessor activated successfully.")
879
+ Logger.debug("AudioStreamManager", "AudioProcessor activated successfully.")
861
880
  }
862
881
 
863
882
  // Prepare notifications if enabled but don't show yet
@@ -880,6 +899,10 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
880
899
  // If already prepared, use the prepared state
881
900
  if isPrepared {
882
901
  Logger.debug("Using prepared recording state")
902
+
903
+ // Install tap with hardware format
904
+ _ = installTapWithHardwareFormat()
905
+ Logger.debug("Tap was reinstalled during recording start")
883
906
  } else {
884
907
  // If not prepared, prepare now
885
908
  Logger.debug("Not prepared, preparing recording first")
@@ -889,6 +912,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
889
912
  }
890
913
  }
891
914
 
915
+ // Rest of the method remains unchanged
892
916
  // Check for active phone call again, in case one started after preparation
893
917
  if isPhoneCallActive() {
894
918
  Logger.debug("Cannot start recording during an active phone call")
@@ -1078,6 +1102,10 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
1078
1102
  }
1079
1103
 
1080
1104
  do {
1105
+ // Check and reinstall tap with hardware format
1106
+ _ = installTapWithHardwareFormat()
1107
+ Logger.debug("Tap reinstalled for resume")
1108
+
1081
1109
  // Try to restart the engine
1082
1110
  try audioEngine.start()
1083
1111
 
@@ -1095,10 +1123,14 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
1095
1123
  // Clear the stored valid duration
1096
1124
  lastValidDuration = nil
1097
1125
 
1126
+ // Reset emission timers to ensure emission starts immediately after resume
1127
+ lastEmissionTime = Date()
1128
+ lastEmissionTimeAnalysis = Date()
1129
+
1098
1130
  // Notify delegate
1099
1131
  delegate?.audioStreamManager(self, didResumeRecording: Date())
1100
1132
 
1101
- Logger.debug("Recording resumed")
1133
+ Logger.debug("Recording resumed successfully")
1102
1134
 
1103
1135
  } catch {
1104
1136
  Logger.debug("Failed to resume recording: \(error.localizedDescription)")
@@ -1285,6 +1317,14 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
1285
1317
  return
1286
1318
  }
1287
1319
 
1320
+ // DEBUG: Add tap and buffer info
1321
+ debugBufferCounter += 1
1322
+
1323
+ // Log every 10th buffer to avoid excessive logs
1324
+ if debugBufferCounter % 10 == 0 {
1325
+ Logger.debug("BUFFER DEBUG: Processing buffer #\(debugBufferCounter), channelCount: \(buffer.format.channelCount), frameLength: \(buffer.frameLength)")
1326
+ }
1327
+
1288
1328
  // targetSampleRate and targetFormat remain the user's requested final format
1289
1329
  let targetSampleRate = Double(settings.sampleRate)
1290
1330
  let targetFormat: AVAudioCommonFormat = settings.bitDepth == 32 ? .pcmFormatFloat32 : .pcmFormatInt16
@@ -1360,24 +1400,37 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
1360
1400
  let currentTotalSize = self.totalDataSize // Use the most up-to-date size for events
1361
1401
 
1362
1402
  // Emit AudioData event
1363
- if let lastEmission = self.lastEmissionTime,
1364
- currentTime.timeIntervalSince(lastEmission) >= emissionInterval,
1365
- !accumulatedData.isEmpty {
1366
- let dataToEmit = accumulatedData
1367
- let recordingTime = currentRecordingDuration()
1368
- self.lastEmissionTime = currentTime
1369
- self.lastEmittedSize = currentTotalSize
1370
- accumulatedData.removeAll()
1371
- var compressionInfo: [String: Any]? = nil
1372
- // TODO: Get actual compressed file size if needed for this event
1373
- delegate?.audioStreamManager(
1374
- self,
1375
- didReceiveAudioData: dataToEmit,
1376
- recordingTime: recordingTime,
1377
- totalDataSize: currentTotalSize,
1378
- compressionInfo: compressionInfo
1379
- )
1380
- // Logger.debug("Emitted didReceiveAudioData event.") // Optional: Re-enable if needed
1403
+ if let lastEmission = self.lastEmissionTime {
1404
+ // Log emission evaluation every 10th buffer
1405
+ if debugBufferCounter % 10 == 0 {
1406
+ let timeGap = currentTime.timeIntervalSince(lastEmission)
1407
+ let isTimeReady = timeGap >= emissionInterval
1408
+ Logger.debug("EMISSION DEBUG: Time since last: \(timeGap)s, Threshold: \(emissionInterval)s, Ready: \(isTimeReady), DataSize: \(accumulatedData.count) bytes")
1409
+ }
1410
+
1411
+ if currentTime.timeIntervalSince(lastEmission) >= emissionInterval,
1412
+ !accumulatedData.isEmpty {
1413
+ let dataToEmit = accumulatedData
1414
+ let recordingTime = currentRecordingDuration()
1415
+ self.lastEmissionTime = currentTime
1416
+ self.lastEmittedSize = currentTotalSize
1417
+ accumulatedData.removeAll()
1418
+ var compressionInfo: [String: Any]? = nil
1419
+
1420
+ Logger.debug("EMISSION SUCCESS: Emitting \(dataToEmit.count) bytes at recording time \(recordingTime)s")
1421
+
1422
+ delegate?.audioStreamManager(
1423
+ self,
1424
+ didReceiveAudioData: dataToEmit,
1425
+ recordingTime: recordingTime,
1426
+ totalDataSize: currentTotalSize,
1427
+ compressionInfo: compressionInfo
1428
+ )
1429
+ }
1430
+ } else {
1431
+ // This case occurs when lastEmissionTime is nil (either first run or after reset)
1432
+ Logger.debug("EMISSION DEBUG: lastEmissionTime is nil, setting to current time")
1433
+ lastEmissionTime = currentTime
1381
1434
  }
1382
1435
 
1383
1436
  // Dispatch analysis task
@@ -1768,12 +1821,14 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
1768
1821
  // Switch on the *enum* value
1769
1822
  switch behavior {
1770
1823
  case .PAUSE:
1824
+ Logger.debug("Device disconnect behavior set to PAUSE. Pausing recording.")
1771
1825
  performPauseAction(reason: .deviceDisconnected)
1772
1826
 
1773
1827
  case .FALLBACK:
1774
- Task {
1828
+ Logger.debug("Device disconnect behavior set to FALLBACK. Attempting to switch to default device.")
1829
+ Task {
1775
1830
  await performFallbackAction()
1776
- }
1831
+ }
1777
1832
  }
1778
1833
  } else {
1779
1834
  Logger.debug("A different device disconnected (\(normalizedDisconnectedId)). Current recording device (\(normalizedCurrentId)) is still active. Ignoring.")
@@ -1807,13 +1862,26 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
1807
1862
  }
1808
1863
  Logger.debug("Found default device for fallback: \(defaultDevice.name) (ID: \(defaultDevice.id))")
1809
1864
 
1810
- // 2. Stop engine temporarily & Remove existing tap
1811
- let wasManuallyPaused = isPaused
1812
- if audioEngine.isRunning {
1813
- audioEngine.pause()
1814
- }
1865
+ // CRITICAL: Complete engine reset - stronger than just pausing
1866
+ audioEngine.stop()
1867
+ audioEngine.reset() // Reset the entire engine
1815
1868
  audioEngine.inputNode.removeTap(onBus: 0)
1816
- Logger.debug("Fallback: Paused engine and removed existing tap.")
1869
+
1870
+ // More aggressive session reset
1871
+ do {
1872
+ let session = AVAudioSession.sharedInstance()
1873
+ try session.setActive(false, options: .notifyOthersOnDeactivation)
1874
+ Thread.sleep(forTimeInterval: 0.2) // Give system time to release resources
1875
+
1876
+ // Reconfigure the session completely
1877
+ try session.setCategory(.playAndRecord, mode: .default, options: [.allowBluetooth, .mixWithOthers])
1878
+ try session.setActive(true, options: .notifyOthersOnDeactivation)
1879
+ Thread.sleep(forTimeInterval: 0.1) // Allow the session to activate fully
1880
+ } catch {
1881
+ Logger.debug("Session reset error: \(error.localizedDescription)")
1882
+ }
1883
+
1884
+ let wasManuallyPaused = isPaused
1817
1885
 
1818
1886
  // 3. Update settings and select the new device in the session
1819
1887
  recordingSettings?.deviceId = defaultDevice.id // Update setting
@@ -1824,56 +1892,153 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
1824
1892
  return
1825
1893
  }
1826
1894
  Logger.debug("Successfully selected default device \(defaultDevice.id) in session.")
1827
-
1828
- // --- Reinstall Tap ---
1829
- // 4. Get the tap format for the *new* device
1830
- let session = AVAudioSession.sharedInstance()
1831
- let nodeFormat = audioEngine.inputNode.outputFormat(forBus: 0)
1832
- let actualSessionRate = session.sampleRate
1833
- Logger.debug("Fallback: New device node format: \(describeAudioFormat(nodeFormat))")
1834
- Logger.debug("Fallback: New device session rate: \(actualSessionRate) Hz")
1835
1895
 
1836
- guard let newTapFormat = AVAudioFormat(
1837
- commonFormat: nodeFormat.commonFormat,
1838
- sampleRate: actualSessionRate,
1839
- channels: nodeFormat.channelCount,
1840
- interleaved: nodeFormat.isInterleaved
1841
- ) else {
1842
- Logger.debug("Fallback failed: Could not create tap format for new device.")
1843
- performPauseAction(reason: .deviceSwitchFailed)
1844
- return
1845
- }
1846
- Logger.debug("Fallback: Determined new tap format: \(describeAudioFormat(newTapFormat))")
1896
+ // Additional forced reset of engine to ensure clean state
1897
+ audioEngine.reset()
1898
+ audioEngine.prepare()
1847
1899
 
1848
- // 5. Install the new tap
1849
- audioEngine.inputNode.installTap(onBus: 0, bufferSize: 1024, format: newTapFormat) { [weak self] (buffer, time) in
1900
+ // Create a counter for tracking buffers since fallback
1901
+ var buffersSinceFallback = 0
1902
+
1903
+ // Create a specialized tap block for fallback with aggressive emission
1904
+ let fallbackTapBlock = { [weak self] (buffer: AVAudioPCMBuffer, time: AVAudioTime) -> Void in
1850
1905
  guard let self = self, self.isRecording else { return }
1906
+
1907
+ // Process the buffer and ensure it's written to file
1851
1908
  self.processAudioBuffer(buffer, fileURL: self.recordingFileURL!)
1852
1909
  self.lastBufferTime = time
1910
+
1911
+ // Special handling for fallback: force emission regularly to restart flow
1912
+ let audioData = buffer.audioBufferList.pointee.mBuffers
1913
+ guard let bufferData = audioData.mData else { return }
1914
+
1915
+ let dataToAdd = Data(bytes: bufferData, count: Int(audioData.mDataByteSize))
1916
+ if !dataToAdd.isEmpty {
1917
+ // Force emission every few buffers regardless of timing during recovery period
1918
+ buffersSinceFallback += 1
1919
+
1920
+ // MORE AGGRESSIVE: Force emission every 2 buffers for the first 30 buffers
1921
+ if buffersSinceFallback <= 30 && buffersSinceFallback % 2 == 0 {
1922
+ DispatchQueue.main.async {
1923
+ // Bypass normal timing checks to ensure data flows
1924
+ let recordingTime = self.currentRecordingDuration()
1925
+ let totalSize = self.totalDataSize // Make sure we use the current value
1926
+ Logger.debug("FALLBACK FORCE EMIT: Forcing emission after fallback (buffer #\(buffersSinceFallback), size: \(dataToAdd.count) bytes, totalSize: \(totalSize))")
1927
+
1928
+ self.delegate?.audioStreamManager(
1929
+ self,
1930
+ didReceiveAudioData: dataToAdd,
1931
+ recordingTime: recordingTime,
1932
+ totalDataSize: totalSize,
1933
+ compressionInfo: nil
1934
+ )
1935
+ }
1936
+ }
1937
+ }
1853
1938
  }
1854
- Logger.debug("Fallback: Re-installed tap with new format.")
1855
1939
 
1856
- // 6. Prepare and Restart engine if it wasn't manually paused before
1940
+ // Use our shared tap installation method with the custom block
1941
+ installTapWithHardwareFormat(customTapBlock: fallbackTapBlock)
1942
+ Logger.debug("Fallback: Re-installed tap with enhanced emission handling")
1943
+
1944
+ // Force prepare engine again to ensure it's ready
1857
1945
  audioEngine.prepare()
1858
1946
  Logger.debug("Fallback: Prepared audio engine.")
1859
1947
 
1860
1948
  if !wasManuallyPaused {
1861
- // Only start if it's not running (it should have been paused earlier)
1862
- if !audioEngine.isRunning {
1863
- do {
1864
- try audioEngine.start()
1865
- Logger.debug("Audio engine restarted for fallback.")
1866
- } catch {
1867
- Logger.debug("Fallback failed: Could not restart audio engine after tap reinstall. Pausing. Error: \(error)")
1868
- performPauseAction(reason: .deviceSwitchFailed)
1869
- return
1870
- }
1871
- } else {
1949
+ // Only start if it's not running (it should have been paused earlier)
1950
+ if !audioEngine.isRunning {
1951
+ do {
1952
+ try audioEngine.start()
1953
+ Logger.debug("Audio engine restarted for fallback.")
1954
+ } catch {
1955
+ // Try ONE more time with delay
1956
+ Thread.sleep(forTimeInterval: 0.2)
1957
+ do {
1958
+ try audioEngine.start()
1959
+ Logger.debug("Audio engine restarted on second attempt after fallback.")
1960
+ } catch {
1961
+ Logger.debug("Fallback failed: Could not restart audio engine after tap reinstall. Pausing. Error: \(error)")
1962
+ performPauseAction(reason: .deviceSwitchFailed)
1963
+ return
1964
+ }
1965
+ }
1966
+ } else {
1872
1967
  Logger.debug("Audio engine was already running during fallback attempt? Unexpected state.")
1873
- }
1874
- } else {
1875
- Logger.debug("Recording was manually paused, leaving engine paused after fallback.")
1876
- }
1968
+ }
1969
+ } else {
1970
+ Logger.debug("Recording was manually paused, leaving engine paused after fallback.")
1971
+ }
1972
+
1973
+ // Emit any remaining audio data from the previous device before resetting timers
1974
+ if !accumulatedData.isEmpty {
1975
+ Logger.debug("Emitting final audio chunk of \(accumulatedData.count) bytes from previous device")
1976
+ let recordingTime = currentRecordingDuration()
1977
+ let finalTotalSize = self.totalDataSize
1978
+
1979
+ // Create a copy of accumulated data to avoid race conditions
1980
+ let finalData = accumulatedData
1981
+
1982
+ // Notify delegate with final audio data from previous device
1983
+ delegate?.audioStreamManager(
1984
+ self,
1985
+ didReceiveAudioData: finalData,
1986
+ recordingTime: recordingTime,
1987
+ totalDataSize: finalTotalSize,
1988
+ compressionInfo: nil
1989
+ )
1990
+ }
1991
+
1992
+ // Reset emission timers to force new data emission with the fallback device
1993
+ lastEmissionTime = Date() // Reset to force immediate emission
1994
+ lastEmissionTimeAnalysis = Date() // Reset analysis timer too
1995
+
1996
+ // Important: Do not reset totalDataSize here - it needs to be maintained
1997
+ // We only clear the buffers to start accumulating new data from the fallback device
1998
+ accumulatedData.removeAll() // Clear any partial data from previous device
1999
+ accumulatedAnalysisData.removeAll() // Clear analysis data buffer
2000
+ Logger.debug("Emission timers reset. Current totalDataSize: \(totalDataSize)")
2001
+
2002
+ // CRITICAL: Multiple scheduled recovery attempts
2003
+ for delaySeconds in [0.5, 1.0, 2.0, 3.0] {
2004
+ DispatchQueue.main.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
2005
+ guard let self = self, self.isRecording, !self.isPaused else { return }
2006
+ Logger.debug("FALLBACK RECOVERY: Checking for data at \(delaySeconds)s")
2007
+
2008
+ // Force an immediate emission if data is being received but not emitted
2009
+ if !self.accumulatedData.isEmpty {
2010
+ Logger.debug("FALLBACK RECOVERY: Forcing emission from accumulated data after \(delaySeconds)s (total size: \(self.totalDataSize))")
2011
+ let dataToEmit = self.accumulatedData
2012
+ let recordingTime = self.currentRecordingDuration()
2013
+ let totalSize = self.totalDataSize
2014
+
2015
+ self.lastEmissionTime = Date() // Reset the emission timer
2016
+ self.accumulatedData.removeAll() // Clear the buffer
2017
+
2018
+ // Direct delegate call with accumulated data
2019
+ self.delegate?.audioStreamManager(
2020
+ self,
2021
+ didReceiveAudioData: dataToEmit,
2022
+ recordingTime: recordingTime,
2023
+ totalDataSize: totalSize,
2024
+ compressionInfo: nil
2025
+ )
2026
+ }
2027
+
2028
+ // If we're at the 3-second mark and the engine appears to not be running, attempt restart
2029
+ if delaySeconds >= 3.0 && (!self.audioEngine.isRunning || self.lastEmissionTime!.timeIntervalSinceNow < -3) {
2030
+ Logger.debug("FALLBACK RECOVERY: Emergency engine restart attempt")
2031
+ do {
2032
+ self.audioEngine.reset()
2033
+ self.audioEngine.prepare()
2034
+ try self.audioEngine.start()
2035
+ self.lastEmissionTime = Date()
2036
+ } catch {
2037
+ Logger.debug("Emergency restart failed: \(error)")
2038
+ }
2039
+ }
2040
+ }
2041
+ }
1877
2042
 
1878
2043
  // 7. Notify JS about successful fallback
1879
2044
  delegate?.audioStreamManager(self, didReceiveInterruption: [