@siteed/expo-audio-studio 2.4.0 → 2.5.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 (81) 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 +581 -255
  9. package/android/src/main/java/net/siteed/audiostream/Constants.kt +17 -1
  10. package/android/src/main/java/net/siteed/audiostream/ExpoAudioStreamModule.kt +435 -158
  11. package/android/src/main/java/net/siteed/audiostream/LogUtils.kt +65 -0
  12. package/android/src/main/java/net/siteed/audiostream/PermissionUtils.kt +14 -5
  13. package/android/src/main/java/net/siteed/audiostream/RecordingConfig.kt +9 -1
  14. package/build/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
  15. package/build/AudioDeviceManager.d.ts +107 -0
  16. package/build/AudioDeviceManager.d.ts.map +1 -0
  17. package/build/AudioDeviceManager.js +493 -0
  18. package/build/AudioDeviceManager.js.map +1 -0
  19. package/build/AudioRecorder.provider.d.ts.map +1 -1
  20. package/build/AudioRecorder.provider.js +3 -0
  21. package/build/AudioRecorder.provider.js.map +1 -1
  22. package/build/ExpoAudioStream.types.d.ts +90 -1
  23. package/build/ExpoAudioStream.types.d.ts.map +1 -1
  24. package/build/ExpoAudioStream.types.js +7 -1
  25. package/build/ExpoAudioStream.types.js.map +1 -1
  26. package/build/ExpoAudioStream.web.d.ts +37 -0
  27. package/build/ExpoAudioStream.web.d.ts.map +1 -1
  28. package/build/ExpoAudioStream.web.js +399 -54
  29. package/build/ExpoAudioStream.web.js.map +1 -1
  30. package/build/ExpoAudioStreamModule.d.ts.map +1 -1
  31. package/build/ExpoAudioStreamModule.js +20 -0
  32. package/build/ExpoAudioStreamModule.js.map +1 -1
  33. package/build/WebRecorder.web.d.ts +63 -10
  34. package/build/WebRecorder.web.d.ts.map +1 -1
  35. package/build/WebRecorder.web.js +277 -68
  36. package/build/WebRecorder.web.js.map +1 -1
  37. package/build/hooks/useAudioDevices.d.ts +14 -0
  38. package/build/hooks/useAudioDevices.d.ts.map +1 -0
  39. package/build/hooks/useAudioDevices.js +151 -0
  40. package/build/hooks/useAudioDevices.js.map +1 -0
  41. package/build/index.d.ts +2 -0
  42. package/build/index.d.ts.map +1 -1
  43. package/build/index.js +4 -0
  44. package/build/index.js.map +1 -1
  45. package/build/useAudioRecorder.d.ts +1 -0
  46. package/build/useAudioRecorder.d.ts.map +1 -1
  47. package/build/useAudioRecorder.js +20 -1
  48. package/build/useAudioRecorder.js.map +1 -1
  49. package/build/utils/BlobFix.d.ts.map +1 -1
  50. package/build/utils/BlobFix.js +2 -2
  51. package/build/utils/BlobFix.js.map +1 -1
  52. package/build/workers/InlineFeaturesExtractor.web.d.ts +1 -1
  53. package/build/workers/InlineFeaturesExtractor.web.d.ts.map +1 -1
  54. package/build/workers/InlineFeaturesExtractor.web.js +27 -26
  55. package/build/workers/InlineFeaturesExtractor.web.js.map +1 -1
  56. package/build/workers/inlineAudioWebWorker.web.d.ts +1 -1
  57. package/build/workers/inlineAudioWebWorker.web.d.ts.map +1 -1
  58. package/build/workers/inlineAudioWebWorker.web.js +25 -1
  59. package/build/workers/inlineAudioWebWorker.web.js.map +1 -1
  60. package/ios/AudioDeviceManager.swift +654 -0
  61. package/ios/AudioStreamManager.swift +964 -760
  62. package/ios/ExpoAudioStreamModule.swift +174 -19
  63. package/ios/Features.swift +1 -1
  64. package/ios/ISSUE_IOS.md +45 -0
  65. package/ios/Logger.swift +13 -1
  66. package/ios/RecordingSettings.swift +12 -0
  67. package/package.json +2 -2
  68. package/src/AudioAnalysis/AudioAnalysis.types.ts +2 -2
  69. package/src/AudioDeviceManager.ts +571 -0
  70. package/src/AudioRecorder.provider.tsx +3 -0
  71. package/src/ExpoAudioStream.types.ts +97 -1
  72. package/src/ExpoAudioStream.web.ts +513 -63
  73. package/src/ExpoAudioStreamModule.ts +23 -0
  74. package/src/WebRecorder.web.ts +346 -81
  75. package/src/hooks/useAudioDevices.ts +180 -0
  76. package/src/index.ts +6 -0
  77. package/src/types/crc-32.d.ts +6 -6
  78. package/src/useAudioRecorder.tsx +27 -1
  79. package/src/utils/BlobFix.ts +6 -4
  80. package/src/workers/InlineFeaturesExtractor.web.tsx +27 -26
  81. package/src/workers/inlineAudioWebWorker.web.tsx +25 -1
@@ -30,18 +30,26 @@ extension UInt16 {
30
30
  }
31
31
  }
32
32
 
33
- class AudioStreamManager: NSObject {
33
+ // Define DeviceDisconnectionBehavior enum mirroring ExpoAudioStream.types.ts
34
+ enum DeviceDisconnectionBehavior: String {
35
+ case PAUSE = "pause"
36
+ case FALLBACK = "fallback"
37
+ }
38
+
39
+ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
34
40
  private let audioEngine = AVAudioEngine()
35
41
  private var inputNode: AVAudioInputNode {
36
42
  return audioEngine.inputNode
37
43
  }
38
44
  internal var recordingFileURL: URL?
39
45
  private var audioProcessor: AudioProcessor?
46
+ private var fileHandle: FileHandle?
40
47
  private var startTime: Date?
41
48
  private var totalPausedDuration: TimeInterval = 0 // Track total paused time
42
49
  private var currentPauseStart: Date? // Track current pause start
43
- private var isRecording = false
44
- private var isPaused = false
50
+ var isRecording = false
51
+ var isPaused = false
52
+ var isPrepared = false // Add this new state flag
45
53
 
46
54
  // Wake lock related properties
47
55
  private var wasIdleTimerDisabled: Bool = false // Track previous idle timer state
@@ -96,9 +104,13 @@ class AudioStreamManager: NSObject {
96
104
  private var emissionInterval: TimeInterval = 1.0 // Default 1 second
97
105
  private var emissionIntervalAnalysis: TimeInterval = 0.5 // Default 0.5 seconds
98
106
 
107
+ // ---> ADD BACK deviceManager PROPERTY <---
108
+ private let deviceManager = AudioDeviceManager()
109
+
99
110
  /// Initializes the AudioStreamManager
100
111
  override init() {
101
112
  super.init()
113
+ deviceManager.delegate = self // Set the delegate
102
114
  // Only keep audio session interruption observer here
103
115
  NotificationCenter.default.addObserver(
104
116
  self,
@@ -304,8 +316,7 @@ class AudioStreamManager: NSObject {
304
316
  return lastDuration
305
317
  }
306
318
 
307
- guard let settings = recordingSettings,
308
- let startTime = self.startTime else { return 0 }
319
+ guard let startTime = self.startTime else { return 0 }
309
320
 
310
321
  let now = Date()
311
322
  var duration = now.timeIntervalSince(startTime)
@@ -554,11 +565,22 @@ class AudioStreamManager: NSObject {
554
565
  "isRecording": isRecording,
555
566
  "isPaused": isPaused,
556
567
  "mimeType": mimeType,
557
- "size": totalDataSize,
558
- "interval": settings.interval,
559
- "intervalAnalysis": settings.intervalAnalysis
568
+ "size": totalDataSize
560
569
  ]
561
570
 
571
+ // Safely handle optional interval values
572
+ if let interval = settings.interval {
573
+ status["interval"] = interval
574
+ } else {
575
+ status["interval"] = 1000 // Default value
576
+ }
577
+
578
+ if let intervalAnalysis = settings.intervalAnalysis {
579
+ status["intervalAnalysis"] = intervalAnalysis
580
+ } else {
581
+ status["intervalAnalysis"] = 500 // Default value
582
+ }
583
+
562
584
  // Add compression info if enabled
563
585
  if settings.enableCompressedOutput,
564
586
  let compressedURL = compressedFileURL,
@@ -594,22 +616,29 @@ class AudioStreamManager: NSObject {
594
616
  audioSession.currentRoute.outputs.contains { $0.portType == .builtInReceiver }
595
617
  }
596
618
 
597
- /// Starts a new audio recording with the specified settings.
619
+ /// Prepares the audio recording with the specified settings without starting it.
620
+ /// This reduces latency when startRecording is called later.
598
621
  /// - Parameters:
599
622
  /// - settings: The recording settings to use.
600
- /// - Returns: A StartRecordingResult object if recording starts successfully, or nil otherwise.
601
- func startRecording(settings: RecordingSettings) -> StartRecordingResult? {
623
+ /// - Returns: A boolean indicating if preparation was successful.
624
+ func prepareRecording(settings: RecordingSettings) -> Bool {
625
+ // Store settings first before doing anything else
626
+ recordingSettings = settings
627
+
628
+ // Skip if already prepared or recording
629
+ guard !isPrepared && !isRecording else {
630
+ Logger.debug("Already prepared or recording in progress.")
631
+ return isPrepared
632
+ }
633
+
602
634
  // Check for active call using the new method
603
635
  if isPhoneCallActive() {
604
- Logger.debug("Cannot start recording during an active phone call")
605
- delegate?.audioStreamManager(self, didFailWithError: "Cannot start recording during an active phone call")
606
- return nil
636
+ Logger.debug("Cannot prepare recording during an active phone call")
637
+ delegate?.audioStreamManager(self, didFailWithError: "Cannot prepare recording during an active phone call")
638
+ return false
607
639
  }
608
640
 
609
- // Store settings first before doing anything else
610
- recordingSettings = settings
611
-
612
- // Reset audio session before starting new recording
641
+ // Reset audio session before preparing new recording
613
642
  do {
614
643
  let session = AVAudioSession.sharedInstance()
615
644
  try session.setActive(false, options: .notifyOthersOnDeactivation)
@@ -618,29 +647,16 @@ class AudioStreamManager: NSObject {
618
647
  } catch {
619
648
  Logger.debug("Failed to reset audio session: \(error)")
620
649
  delegate?.audioStreamManager(self, didFailWithError: "Failed to reset audio session: \(error.localizedDescription)")
621
- return nil
650
+ return false
622
651
  }
623
652
 
624
653
  // Update auto-resume preference from settings
625
654
  autoResumeAfterInterruption = settings.autoResumeAfterInterruption
626
655
 
627
- guard !isRecording else {
628
- Logger.debug("Debug: Recording is already in progress.")
629
- return nil
630
- }
631
-
632
- guard !audioEngine.isRunning else {
633
- Logger.debug("Debug: Audio engine already running.")
634
- return nil
635
- }
636
-
637
- let session = AVAudioSession.sharedInstance()
638
- var newSettings = settings
639
-
640
656
  emissionInterval = max(100.0, Double(settings.interval ?? 1000)) / 1000.0
641
657
  emissionIntervalAnalysis = max(100.0, Double(settings.intervalAnalysis ?? 500)) / 1000.0
642
- lastEmissionTime = Date()
643
- lastEmissionTimeAnalysis = Date()
658
+ lastEmissionTime = nil // Will be set when recording starts
659
+ lastEmissionTimeAnalysis = nil // Will be set when recording starts
644
660
  accumulatedData.removeAll()
645
661
  accumulatedAnalysisData.removeAll()
646
662
  totalDataSize = 0
@@ -650,18 +666,40 @@ class AudioStreamManager: NSObject {
650
666
  lastEmittedCompressedSizeAnalysis = 0
651
667
  isPaused = false
652
668
 
653
-
654
669
  // Create recording file first
655
670
  recordingFileURL = createRecordingFile()
656
- if recordingFileURL == nil {
657
- Logger.debug("Error: Failed to create recording file.")
658
- return nil
671
+ if let url = recordingFileURL {
672
+ do {
673
+ // Ensure directory exists if needed (createRecordingFile should handle this, but belt-and-suspenders)
674
+ try fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
675
+ // Create the file if it doesn't exist (createRecordingFile should also handle this)
676
+ if !fileManager.fileExists(atPath: url.path) {
677
+ fileManager.createFile(atPath: url.path, contents: nil, attributes: nil)
678
+ }
679
+ // Open the handle for writing
680
+ self.fileHandle = try FileHandle(forWritingTo: url)
681
+ // Write initial dummy header immediately
682
+ let header = createWavHeader(dataSize: 0)
683
+ self.fileHandle?.write(header)
684
+ 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)")
686
+ } catch {
687
+ Logger.debug("Error creating/opening file handle: \(error.localizedDescription)")
688
+ // No need to call cleanupPreparation here, return false will handle it
689
+ return false
690
+ }
691
+ } else {
692
+ Logger.debug("Error: Failed to create recording file URL.")
693
+ return false
659
694
  }
660
695
 
696
+ var newSettings = settings
697
+
661
698
  // Then set up audio session and tap
662
699
  do {
663
700
  Logger.debug("Configuring audio session with sample rate: \(settings.sampleRate) Hz")
664
701
 
702
+ let session = AVAudioSession.sharedInstance()
665
703
  if let currentRoute = session.currentRoute.outputs.first {
666
704
  Logger.debug("Current audio output: \(currentRoute.portType)")
667
705
  newSettings.sampleRate = settings.sampleRate // Keep original sample rate
@@ -692,161 +730,127 @@ class AudioStreamManager: NSObject {
692
730
 
693
731
  // Apply the final configuration
694
732
  try session.setCategory(category, mode: mode, options: options)
733
+ // NOTE: We intentionally DO NOT call session.setPreferredSampleRate().
734
+ // Trying to force a sample rate different from the hardware's actual rate
735
+ // often prevents the input node's tap from receiving any buffers.
736
+ // Instead, we let the session negotiate the rate.
737
+ // Resampling to the desired settings.sampleRate happens later in processAudioBuffer.
738
+ try session.setPreferredIOBufferDuration(1024 / Double(settings.sampleRate)) // Use desired rate for buffer duration hint
695
739
  try session.setActive(true, options: .notifyOthersOnDeactivation)
696
-
697
- Logger.debug("""
698
- Audio session configured:
699
- - category: \(category)
700
- - mode: \(mode)
701
- - options: \(options)
702
- - keepAwake: \(settings.keepAwake)
703
- - emission interval: \(emissionInterval * 1000)ms
704
- - analysis interval: \(emissionIntervalAnalysis * 1000)ms
705
- - sample rate: \(settings.sampleRate)Hz
706
- - channels: \(settings.numberOfChannels)
707
- - bit depth: \(settings.bitDepth)-bit
708
- - compression enabled: \(settings.enableCompressedOutput)
709
- """)
710
-
711
- // Set preferred sample rate but don't rely on it being applied
712
- try session.setPreferredSampleRate(Double(settings.sampleRate))
713
- try session.setPreferredIOBufferDuration(1024 / Double(settings.sampleRate))
714
- try session.setActive(true)
715
- Logger.debug("Audio session activated successfully.")
716
-
717
- // CRITICAL FIX: In iOS, the hardware sometimes doesn't honor our preferred sample rate.
718
- // Here we query the *actual* hardware input format to ensure we match exactly what the hardware gives us.
719
- let reportedSessionRate = session.sampleRate
720
-
721
- // Get the format directly from the input node, which is the most reliable way to determine the hardware format
722
- let inputNodeFormat = audioEngine.inputNode.outputFormat(forBus: 0)
723
- let actualHardwareSampleRate = inputNodeFormat.sampleRate
724
-
725
- Logger.debug("""
726
- Sample rate detection:
727
- - Requested rate: \(settings.sampleRate)Hz
728
- - iOS session reported rate: \(reportedSessionRate)Hz
729
- - Input node actual rate: \(actualHardwareSampleRate)Hz
730
- - Will use input node rate for tap and resample to requested rate
731
- """)
732
-
733
- recordingSettings = newSettings // Keep original settings with desired sample rate
734
- enableWakeLock()
735
-
736
- // CRITICAL FIX: Create format matching ACTUAL hardware capabilities from the input node
737
- guard let hardwareFormat = AVAudioFormat(
738
- commonFormat: .pcmFormatFloat32,
739
- sampleRate: actualHardwareSampleRate,
740
- channels: AVAudioChannelCount(settings.numberOfChannels),
741
- interleaved: true
740
+
741
+ // 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
742
770
  ) else {
743
- Logger.debug("Failed to create hardware format")
744
- return nil
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."])
745
774
  }
746
-
747
- Logger.debug("""
748
- Audio format configuration:
749
- - Hardware input format: \(describeAudioFormat(inputNodeFormat))
750
- - Tap format: \(describeAudioFormat(hardwareFormat))
751
- - Final output format: \(settings.bitDepth)-bit at \(settings.sampleRate)Hz
752
- - Channels: \(settings.numberOfChannels)
753
- """)
754
775
 
755
- // CRITICAL FIX: Install tap with the ACTUAL hardware format from the input node
756
- audioEngine.inputNode.installTap(onBus: 0, bufferSize: 1024, format: hardwareFormat) { [weak self] (buffer, time) in
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")
781
+
782
+ recordingSettings = newSettings // Keep original settings with desired sample rate
783
+
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
757
786
  guard let self = self,
758
- let fileURL = self.recordingFileURL else {
759
- Logger.debug("Error: File URL or self is nil during buffer processing.")
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.")
760
790
  return
761
791
  }
792
+ // processAudioBuffer will handle resampling if tapFormat.sampleRate != settings.sampleRate
762
793
  self.processAudioBuffer(buffer, fileURL: fileURL)
763
794
  self.lastBufferTime = time
764
795
  }
796
+
797
+ audioEngine.prepare() // Prepare the engine without starting it
765
798
 
766
799
  // Setup compressed recording if enabled
767
800
  if settings.enableCompressedOutput {
768
- do {
769
- let compressedSettings: [String: Any] = [
770
- AVFormatIDKey: settings.compressedFormat == "aac" ? kAudioFormatMPEG4AAC : kAudioFormatOpus,
771
- AVSampleRateKey: Float64(settings.sampleRate),
772
- AVNumberOfChannelsKey: settings.numberOfChannels,
773
- AVEncoderBitRateKey: settings.compressedBitRate,
774
- AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue,
775
- AVEncoderBitDepthHintKey: settings.bitDepth
776
- ]
777
-
778
- Logger.debug("Initializing compressed recording with settings: \(compressedSettings)")
779
-
780
- compressedFileURL = createRecordingFile(isCompressed: true)
801
+ // Create compressed settings
802
+ let compressedSettings: [String: Any] = [
803
+ AVFormatIDKey: settings.compressedFormat == "aac" ? kAudioFormatMPEG4AAC : kAudioFormatOpus,
804
+ AVSampleRateKey: Float64(settings.sampleRate),
805
+ AVNumberOfChannelsKey: settings.numberOfChannels,
806
+ AVEncoderBitRateKey: settings.compressedBitRate,
807
+ AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue,
808
+ AVEncoderBitDepthHintKey: settings.bitDepth
809
+ ]
810
+
811
+ Logger.debug("Initializing compressed recording with settings: \(compressedSettings)")
812
+
813
+ // Create file for compressed recording
814
+ compressedFileURL = createRecordingFile(isCompressed: true)
815
+
816
+ if let url = compressedFileURL {
817
+ Logger.debug("Using compressed file URL: \(url.path)")
781
818
 
782
- if let url = compressedFileURL {
783
- Logger.debug("Using compressed file URL: \(url.path)")
784
-
785
- // Initialize recorder with proper error handling
786
- do {
787
- compressedRecorder = try AVAudioRecorder(url: url, settings: compressedSettings)
788
- if let recorder = compressedRecorder {
789
- recorder.delegate = self
790
-
791
- if !recorder.prepareToRecord() {
792
- Logger.debug("Failed to prepare recorder")
793
- throw NSError(domain: "AudioStreamManager", code: -1,
794
- userInfo: [NSLocalizedDescriptionKey: "Failed to prepare recorder"])
795
- }
796
-
797
- if !recorder.record() {
798
- Logger.debug("Failed to start recorder")
799
- throw NSError(domain: "AudioStreamManager", code: -2,
800
- userInfo: [NSLocalizedDescriptionKey: "Failed to start recorder"])
801
- }
802
-
803
- Logger.debug("Compressed recording started successfully")
819
+ // Initialize recorder with proper error handling
820
+ do {
821
+ compressedRecorder = try AVAudioRecorder(url: url, settings: compressedSettings)
822
+ if let recorder = compressedRecorder {
823
+ recorder.delegate = self
824
+
825
+ if !recorder.prepareToRecord() {
826
+ Logger.debug("Failed to prepare recorder")
827
+ compressedFileURL = nil
828
+ compressedRecorder = nil
829
+ } else {
830
+ // Note: We don't start the recorder yet, just prepare it
831
+ Logger.debug("Compressed recording prepared successfully")
804
832
  compressedFormat = settings.compressedFormat
805
833
  compressedBitRate = settings.compressedBitRate
806
834
  }
807
- } catch {
808
- Logger.debug("Failed to initialize compressed recorder: \(error)")
809
- compressedFileURL = nil
810
- compressedRecorder = nil
811
835
  }
836
+ } catch {
837
+ Logger.debug("Failed to initialize compressed recorder: \(error)")
838
+ compressedFileURL = nil
839
+ compressedRecorder = nil
812
840
  }
813
- } catch {
814
- Logger.debug("Failed to setup compressed recording: \(error)")
815
- compressedFileURL = nil
816
- compressedRecorder = nil
841
+ } else {
842
+ Logger.debug("Failed to create compressed recording file")
817
843
  }
818
844
  }
819
845
 
820
846
  } catch {
821
847
  Logger.debug("Error: Failed to set up audio session with preferred settings: \(error.localizedDescription)")
822
- return nil
848
+ return false
823
849
  }
824
850
 
825
851
  NotificationCenter.default.addObserver(self, selector: #selector(handleAudioSessionInterruption), name: AVAudioSession.interruptionNotification, object: nil)
826
852
 
827
- // Create audio format based on recording settings
828
- let commonFormat: AVAudioCommonFormat
829
- switch newSettings.bitDepth {
830
- case 16:
831
- commonFormat = .pcmFormatInt16
832
- case 32:
833
- commonFormat = .pcmFormatFloat32
834
- default:
835
- Logger.debug("Unsupported bit depth: \(newSettings.bitDepth), falling back to 16-bit")
836
- commonFormat = .pcmFormatInt16
837
- }
838
-
839
- guard let audioFormat = AVAudioFormat(
840
- commonFormat: commonFormat,
841
- sampleRate: newSettings.sampleRate,
842
- channels: UInt32(newSettings.numberOfChannels),
843
- interleaved: true
844
- ) else {
845
- Logger.debug("Error: Failed to create audio format with bit depth: \(newSettings.bitDepth)")
846
- return nil
847
- }
848
-
849
- if newSettings.enableProcessing == true {
853
+ if settings.enableProcessing == true {
850
854
  // Initialize the AudioProcessor for buffer-based processing
851
855
  self.audioProcessor = AudioProcessor(resolve: { result in
852
856
  // Handle the result here if needed
@@ -856,20 +860,79 @@ class AudioStreamManager: NSObject {
856
860
  Logger.debug("AudioProcessor activated successfully.")
857
861
  }
858
862
 
863
+ // Prepare notifications if enabled but don't show yet
859
864
  if settings.showNotification {
860
865
  initializeNotifications()
861
866
  }
862
867
 
868
+ // Mark preparation as complete
869
+ isPrepared = true
870
+ Logger.debug("Recording prepared successfully. Ready to start.")
871
+
872
+ return true
873
+ }
874
+
875
+ /// Starts a new audio recording with the specified settings.
876
+ /// - Parameters:
877
+ /// - settings: The recording settings to use.
878
+ /// - Returns: A StartRecordingResult object if recording starts successfully, or nil otherwise.
879
+ func startRecording(settings: RecordingSettings) -> StartRecordingResult? {
880
+ // If already prepared, use the prepared state
881
+ if isPrepared {
882
+ Logger.debug("Using prepared recording state")
883
+ } else {
884
+ // If not prepared, prepare now
885
+ Logger.debug("Not prepared, preparing recording first")
886
+ if !prepareRecording(settings: settings) {
887
+ Logger.debug("Failed to prepare recording")
888
+ return nil
889
+ }
890
+ }
891
+
892
+ // Check for active phone call again, in case one started after preparation
893
+ if isPhoneCallActive() {
894
+ Logger.debug("Cannot start recording during an active phone call")
895
+ delegate?.audioStreamManager(self, didFailWithError: "Cannot start recording during an active phone call")
896
+ cleanupPreparation()
897
+ return nil
898
+ }
899
+
900
+ guard !isRecording else {
901
+ Logger.debug("Recording already in progress")
902
+ return nil
903
+ }
904
+
905
+ guard let settings = recordingSettings,
906
+ let fileUri = recordingFileURL?.absoluteString else {
907
+ Logger.debug("Missing settings or file URI")
908
+ return nil
909
+ }
910
+
863
911
  do {
912
+ enableWakeLock()
913
+
914
+ // Set recording state *before* starting engine to avoid race condition
864
915
  startTime = Date()
865
- totalPausedDuration = 0 // Reset pause tracking
916
+ totalPausedDuration = 0
866
917
  currentPauseStart = nil
867
- Logger.debug("Starting new recording - Reset pause tracking")
868
-
869
- try audioEngine.start()
918
+ lastEmissionTime = Date()
919
+ lastEmissionTimeAnalysis = Date()
870
920
  isRecording = true
871
921
  isPaused = false
872
- Logger.debug("Debug: Recording started successfully.")
922
+
923
+ // Start the audio engine
924
+ try audioEngine.start()
925
+
926
+ // Start the compressed recorder if prepared
927
+ compressedRecorder?.record()
928
+
929
+ // Show notifications if enabled
930
+ if settings.showNotification {
931
+ notificationManager?.startUpdates(startTime: startTime ?? Date())
932
+ updateNowPlayingInfo(isPaused: false)
933
+ }
934
+
935
+ Logger.debug("Recording started successfully")
873
936
 
874
937
  var compression = compressedRecorder != nil ? CompressedRecordingInfo(
875
938
  compressedFileUri: compressedFileURL?.absoluteString ?? "",
@@ -886,7 +949,7 @@ class AudioStreamManager: NSObject {
886
949
  }
887
950
 
888
951
  return StartRecordingResult(
889
- fileUri: recordingFileURL!.path,
952
+ fileUri: fileUri,
890
953
  mimeType: mimeType,
891
954
  channels: settings.numberOfChannels,
892
955
  bitDepth: settings.bitDepth,
@@ -895,495 +958,271 @@ class AudioStreamManager: NSObject {
895
958
  )
896
959
 
897
960
  } catch {
898
- Logger.debug("Error: Could not start the audio engine: \(error.localizedDescription)")
961
+ Logger.debug("Error starting audio engine: \(error.localizedDescription)")
899
962
  isRecording = false
963
+ cleanupPreparation()
900
964
  return nil
901
965
  }
902
966
  }
903
-
904
- /// Pauses the current audio recording.
905
- public func pauseRecording() {
906
- guard isRecording && !isPaused else { return }
967
+
968
+ /// Cleans up resources if preparation was done but recording didn't start.
969
+ private func cleanupPreparation() {
970
+ // Only run if prepared but not recording
971
+ guard isPrepared && !isRecording else { return }
907
972
 
908
- // Store the current duration when pausing
909
- lastValidDuration = currentRecordingDuration()
910
- Logger.debug("Storing duration at pause: \(lastValidDuration ?? 0)")
973
+ Logger.debug("Cleaning up prepared resources that weren't used")
911
974
 
912
- disableWakeLock()
913
- audioEngine.pause()
914
- isPaused = true
975
+ // Remove input tap
976
+ audioEngine.inputNode.removeTap(onBus: 0)
915
977
 
916
- updateNowPlayingInfo(isPaused: true)
917
- notificationManager?.updateState(isPaused: true)
918
- delegate?.audioStreamManager(self, didPauseRecording: Date())
919
- delegate?.audioStreamManager(self, didUpdateNotificationState: true)
978
+ // Stop compressed recorder if created but not started
979
+ compressedRecorder?.stop()
980
+ compressedRecorder = nil
920
981
 
921
- // Pause compressed recording if active
922
- compressedRecorder?.pause()
982
+ // Delete created files that weren't used
983
+ if let fileURL = recordingFileURL, FileManager.default.fileExists(atPath: fileURL.path) {
984
+ try? FileManager.default.removeItem(at: fileURL)
985
+ }
986
+
987
+ if let compressedURL = compressedFileURL, FileManager.default.fileExists(atPath: compressedURL.path) {
988
+ try? FileManager.default.removeItem(at: compressedURL)
989
+ }
990
+
991
+ // Reset audio session
992
+ do {
993
+ try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
994
+ } catch {
995
+ Logger.debug("Error deactivating audio session: \(error)")
996
+ }
997
+
998
+ // Clear notification setup if it was initialized
999
+ notificationManager?.stopUpdates()
1000
+ notificationManager = nil
1001
+
1002
+ // --- Restore missing cleanup lines and remove log ---
1003
+ // Logger.debug("cleanupPreparation: Clearing recordingSettings. Current deviceId: \(recordingSettings?.deviceId ?? \"nil\")")
1004
+ recordingFileURL = nil // Restore
1005
+ compressedFileURL = nil // Restore
1006
+ audioProcessor = nil // Restore
1007
+ recordingSettings = nil
1008
+ isPrepared = false // Restore
1009
+ // --- End restored lines and removed log ---
1010
+
1011
+ Logger.debug("Preparation cleanup completed")
923
1012
  }
924
-
925
- private func initializeNotifications() {
926
- guard recordingSettings?.showNotification == true else { return }
1013
+
1014
+ /// Pauses the current audio recording.
1015
+ func pauseRecording() {
1016
+ guard isRecording, !isPaused else { return }
927
1017
 
928
- // Setup notification manager if not already initialized
929
- if notificationManager == nil {
930
- UNUserNotificationCenter.current().delegate = self
1018
+ Logger.debug("Pausing recording...")
1019
+
1020
+ // Emit any remaining audio data before pausing
1021
+ if !accumulatedData.isEmpty {
1022
+ Logger.debug("Emitting final audio chunk of \(accumulatedData.count) bytes before pausing")
1023
+ let recordingTime = currentRecordingDuration()
1024
+ let finalTotalSize = self.totalDataSize
931
1025
 
932
- notificationManager = AudioNotificationManager()
1026
+ // Create a copy of accumulated data to avoid race conditions
1027
+ let finalData = accumulatedData
1028
+ accumulatedData.removeAll()
933
1029
 
934
- // Request permissions first
935
- UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
936
- if granted {
937
- DispatchQueue.main.async {
938
- self.notificationManager?.initialize(with: self.recordingSettings?.notification)
939
- self.setupNowPlayingInfo()
940
-
941
- // Start media info update timer
942
- self.mediaInfoUpdateTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
943
- self?.updateMediaInfo()
944
- }
945
-
946
- // Setup notification observers
947
- NotificationCenter.default.addObserver(
948
- self,
949
- selector: #selector(self.handlePauseNotification),
950
- name: .pauseRecording,
951
- object: nil
952
- )
953
-
954
- NotificationCenter.default.addObserver(
955
- self,
956
- selector: #selector(self.handleResumeNotification),
957
- name: .resumeRecording,
958
- object: nil
959
- )
960
-
961
- // Start updates if recording is already in progress
962
- if let startTime = self.startTime {
963
- self.notificationManager?.startUpdates(startTime: startTime)
964
- }
965
- }
966
- } else if let error = error {
967
- Logger.debug("Failed to get notification permission: \(error.localizedDescription)")
968
- }
969
- }
1030
+ // Notify delegate with final audio data
1031
+ delegate?.audioStreamManager(
1032
+ self,
1033
+ didReceiveAudioData: finalData,
1034
+ recordingTime: recordingTime,
1035
+ totalDataSize: finalTotalSize,
1036
+ compressionInfo: nil
1037
+ )
1038
+ }
1039
+
1040
+ // Store when we paused
1041
+ currentPauseStart = Date()
1042
+
1043
+ // Update state
1044
+ isPaused = true
1045
+
1046
+ // Stop the engine but don't remove the tap
1047
+ audioEngine.pause()
1048
+
1049
+ // Pause the compressed recorder if active
1050
+ compressedRecorder?.pause()
1051
+
1052
+ // Update notification state if enabled
1053
+ if recordingSettings?.showNotification == true {
1054
+ updateNotificationState(isPaused: true)
970
1055
  }
1056
+
1057
+ // Store valid duration for notifications
1058
+ lastValidDuration = currentRecordingDuration()
1059
+
1060
+ // Notify delegate
1061
+ delegate?.audioStreamManager(self, didPauseRecording: Date())
1062
+
1063
+ Logger.debug("Recording paused")
971
1064
  }
972
1065
 
973
- /// Resumes the current audio recording.
974
- public func resumeRecording() {
975
- // Check for active phone call
976
- if isPhoneCallActive() {
977
- Logger.debug("Cannot resume recording during an active phone call")
978
- delegate?.audioStreamManager(self, didFailWithError: "Cannot resume recording during an active phone call")
979
- return
980
- }
981
-
982
- guard isRecording && isPaused else { return }
1066
+ /// Resumes a paused recording.
1067
+ func resumeRecording() {
1068
+ guard isRecording, isPaused else { return }
983
1069
 
984
- lastValidDuration = nil // Clear the stored duration when resuming
1070
+ Logger.debug("Resuming recording...")
1071
+
1072
+ // Calculate and add the pause duration if we have a pause start time
1073
+ if let pauseStart = currentPauseStart {
1074
+ let pauseDuration = Date().timeIntervalSince(pauseStart)
1075
+ totalPausedDuration += pauseDuration
1076
+ currentPauseStart = nil // Reset pause start time
1077
+ Logger.debug("Added pause duration: \(pauseDuration), total paused: \(totalPausedDuration)")
1078
+ }
985
1079
 
986
- enableWakeLock()
987
- audioEngine.prepare()
988
1080
  do {
1081
+ // Try to restart the engine
989
1082
  try audioEngine.start()
990
1083
 
991
- // Add the completed pause duration to total
992
- if let pauseStart = currentPauseStart {
993
- let currentPauseDuration = Date().timeIntervalSince(pauseStart)
994
- totalPausedDuration += currentPauseDuration
995
- currentPauseStart = nil
996
-
997
- Logger.debug("""
998
- Resume completed:
999
- - Added pause duration: \(currentPauseDuration)
1000
- - New total pause duration: \(totalPausedDuration)
1001
- """)
1002
- }
1084
+ // Resume the compressed recorder if active
1085
+ compressedRecorder?.record()
1003
1086
 
1087
+ // Update state
1004
1088
  isPaused = false
1005
1089
 
1006
- updateNowPlayingInfo(isPaused: false)
1007
- notificationManager?.updateState(isPaused: false)
1090
+ // Update notification state if enabled
1091
+ if recordingSettings?.showNotification == true {
1092
+ updateNotificationState(isPaused: false)
1093
+ }
1094
+
1095
+ // Clear the stored valid duration
1096
+ lastValidDuration = nil
1097
+
1098
+ // Notify delegate
1008
1099
  delegate?.audioStreamManager(self, didResumeRecording: Date())
1009
- delegate?.audioStreamManager(self, didUpdateNotificationState: false)
1010
1100
 
1011
- // Resume compressed recording if active
1012
- compressedRecorder?.record()
1101
+ Logger.debug("Recording resumed")
1013
1102
 
1014
1103
  } catch {
1015
- Logger.debug("Error: Failed to resume recording: \(error.localizedDescription)")
1016
- }
1017
- }
1018
-
1019
- /// Describes the format of the given audio format.
1020
- /// - Parameter format: The AVAudioFormat object to describe.
1021
- /// - Returns: A string description of the audio format.
1022
- func describeAudioFormat(_ format: AVAudioFormat) -> String {
1023
- let formatDescription = """
1024
- - Sample rate: \(format.sampleRate)Hz
1025
- - Channels: \(format.channelCount)
1026
- - Interleaved: \(format.isInterleaved)
1027
- - Common format: \(describeCommonFormat(format.commonFormat))
1028
- """
1029
- return formatDescription
1030
- }
1031
-
1032
- func describeCommonFormat(_ format: AVAudioCommonFormat) -> String {
1033
- switch format {
1034
- case .pcmFormatFloat32:
1035
- return "32-bit float"
1036
- case .pcmFormatFloat64:
1037
- return "64-bit float"
1038
- case .pcmFormatInt16:
1039
- return "16-bit int"
1040
- case .pcmFormatInt32:
1041
- return "32-bit int"
1042
- default:
1043
- return "Unknown format"
1104
+ Logger.debug("Failed to resume recording: \(error.localizedDescription)")
1105
+ delegate?.audioStreamManager(self, didFailWithError: "Failed to resume recording: \(error.localizedDescription)")
1044
1106
  }
1045
1107
  }
1046
1108
 
1047
- /// Stops the current audio recording.
1048
- /// - Returns: A RecordingResult object if the recording stopped successfully, or nil otherwise.
1049
- func stopRecording() -> RecordingResult? {
1050
- guard isRecording else { return nil }
1109
+ /// Initializes the notification manager to show recording notifications.
1110
+ private func initializeNotifications() {
1111
+ guard let settings = recordingSettings else { return }
1051
1112
 
1052
- Logger.debug("Stopping recording...")
1113
+ // Create notification manager
1114
+ notificationManager = AudioNotificationManager()
1115
+ notificationManager?.initialize(with: settings.notification)
1053
1116
 
1054
- disableWakeLock()
1055
- audioEngine.stop()
1056
- audioEngine.inputNode.removeTap(onBus: 0)
1117
+ // Add pause/resume handlers via notification observers
1118
+ NotificationCenter.default.addObserver(
1119
+ self,
1120
+ selector: #selector(handlePauseNotification),
1121
+ name: Notification.Name("PAUSE_RECORDING"),
1122
+ object: nil
1123
+ )
1057
1124
 
1058
- // Stop compressed recording if active
1059
- compressedRecorder?.stop()
1125
+ NotificationCenter.default.addObserver(
1126
+ self,
1127
+ selector: #selector(handleResumeNotification),
1128
+ name: Notification.Name("RESUME_RECORDING"),
1129
+ object: nil
1130
+ )
1060
1131
 
1061
- // Get the final duration before changing state
1062
- let finalDuration = currentRecordingDuration()
1132
+ // Setup media controls (iOS control center) if enabled
1133
+ setupNowPlayingInfo()
1063
1134
 
1064
- isRecording = false
1065
- isPaused = false
1066
-
1067
- if recordingSettings?.showNotification == true {
1068
- // Stop and clean up timer
1069
- mediaInfoUpdateTimer?.invalidate()
1070
- mediaInfoUpdateTimer = nil
1071
-
1072
- // Clean up notification manager
1073
- notificationManager?.stopUpdates()
1074
- notificationManager = nil
1075
-
1076
- // Clean up media controls
1077
- DispatchQueue.main.async {
1078
- UIApplication.shared.endReceivingRemoteControlEvents()
1079
- self.remoteCommandCenter?.pauseCommand.isEnabled = false
1080
- self.remoteCommandCenter?.playCommand.isEnabled = false
1081
- self.notificationView?.nowPlayingInfo = nil
1082
- }
1083
- }
1084
-
1085
- // Reset audio session
1086
- do {
1087
- try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
1088
- } catch {
1089
- Logger.debug("Error deactivating audio session: \(error)")
1090
- }
1091
-
1092
- // Reset audio engine
1093
- audioEngine.reset()
1094
-
1095
- guard let fileURL = recordingFileURL,
1096
- let settings = recordingSettings else {
1097
- Logger.debug("Recording or file URL is nil.")
1098
- return nil
1099
- }
1100
-
1101
- // Validate WAV file
1102
- let wavPath = fileURL.path
1103
- do {
1104
- // Check if WAV file exists
1105
- let wavFileAttributes = try FileManager.default.attributesOfItem(atPath: wavPath)
1106
- let wavFileSize = wavFileAttributes[FileAttributeKey.size] as? Int64 ?? 0
1107
-
1108
- Logger.debug("""
1109
- WAV File validation:
1110
- - Path: \(wavPath)
1111
- - Exists: true
1112
- - Size: \(wavFileSize) bytes
1113
- - Duration: \(finalDuration) seconds
1114
- - Expected minimum size: \(WAV_HEADER_SIZE) bytes (WAV header)
1115
- """)
1116
-
1117
-
1118
-
1119
- // Return nil if the file is too small
1120
- if wavFileSize <= WAV_HEADER_SIZE {
1121
- Logger.debug("Recording file is too small (≤ \(WAV_HEADER_SIZE) bytes), likely no audio data was recorded")
1122
- return nil
1123
- }
1124
-
1125
- // Update the WAV header with the correct file size
1126
- updateWavHeader(fileURL: fileURL, totalDataSize: wavFileSize - WAV_HEADER_SIZE)
1127
-
1128
- // Validate compressed file if enabled
1129
- var compression: CompressedRecordingInfo?
1130
- if let compressedURL = compressedFileURL {
1131
- let compressedPath = compressedURL.path
1132
- if FileManager.default.fileExists(atPath: compressedPath) {
1133
- let compressedAttributes = try FileManager.default.attributesOfItem(atPath: compressedPath)
1134
- let compressedSize = compressedAttributes[FileAttributeKey.size] as? Int64 ?? 0
1135
-
1136
- Logger.debug("""
1137
- Compressed File validation:
1138
- - Path: \(compressedPath)
1139
- - Format: \(compressedFormat ?? "unknown")
1140
- - Size: \(compressedSize) bytes
1141
- - Bitrate: \(compressedBitRate ?? 0) bps
1142
- """)
1143
-
1144
- if compressedSize > 0 {
1145
- compression = CompressedRecordingInfo(
1146
- compressedFileUri: compressedURL.absoluteString,
1147
- mimeType: compressedFormat == "aac" ? "audio/aac" : "audio/opus",
1148
- bitrate: compressedBitRate,
1149
- format: compressedFormat,
1150
- size: compressedSize
1151
- )
1152
- } else {
1153
- Logger.debug("Warning: Compressed file exists but is empty")
1154
- }
1155
- } else {
1156
- Logger.debug("Warning: Compressed file not found at path: \(compressedPath)")
1157
- }
1158
- }
1159
-
1160
- let durationMs = Int64(finalDuration * 1000)
1161
-
1162
- let result = RecordingResult(
1163
- fileUri: fileURL.absoluteString,
1164
- filename: fileURL.lastPathComponent,
1165
- mimeType: mimeType,
1166
- duration: durationMs,
1167
- size: wavFileSize,
1168
- channels: settings.numberOfChannels,
1169
- bitDepth: settings.bitDepth,
1170
- sampleRate: settings.sampleRate,
1171
- compression: compression
1172
- )
1173
-
1174
- Logger.debug("""
1175
- Recording completed successfully:
1176
- - WAV file: \(fileURL.lastPathComponent)
1177
- - Size: \(wavFileSize) bytes
1178
- - Duration: \(durationMs)ms
1179
- - Sample rate: \(settings.sampleRate)Hz
1180
- - Bit depth: \(settings.bitDepth)-bit
1181
- - Channels: \(settings.numberOfChannels)
1182
- - Compressed: \(compression != nil ? "yes" : "no")
1183
- """)
1184
-
1185
- // Additional cleanup
1186
- recordingFileURL = nil
1187
- lastBufferTime = nil
1188
- lastValidDuration = nil
1189
- compressedRecorder = nil
1190
- compressedFileURL = nil
1191
- recordingSettings = nil
1192
- startTime = nil
1193
- totalPausedDuration = 0
1194
- currentPauseStart = nil
1195
- lastEmissionTime = nil
1196
- lastEmissionTimeAnalysis = nil
1197
- lastEmittedSize = 0
1198
- lastEmittedSizeAnalysis = 0
1199
- lastEmittedCompressedSize = 0
1200
- accumulatedData.removeAll()
1201
- accumulatedAnalysisData.removeAll()
1202
- recordingUUID = nil
1203
-
1204
- return result
1205
-
1206
- } catch {
1207
- Logger.debug("Failed to validate recording files: \(error)")
1208
- return nil
1135
+ // Set up timer to update media info
1136
+ mediaInfoUpdateTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
1137
+ self?.updateMediaInfo()
1209
1138
  }
1210
1139
  }
1211
1140
 
1212
- /// Resamples the audio buffer using vDSP. If it fails, falls back to manual resampling.
1141
+ /// Resample an audio buffer to a different sample rate.
1213
1142
  /// - Parameters:
1214
- /// - buffer: The original audio buffer to be resampled.
1215
- /// - originalSampleRate: The sample rate of the original audio buffer.
1216
- /// - targetSampleRate: The desired sample rate to resample to.
1217
- /// - Returns: A new audio buffer resampled to the target sample rate, or nil if resampling fails.
1218
- private func resampleAudioBuffer(_ buffer: AVAudioPCMBuffer, from originalSampleRate: Double, to targetSampleRate: Double) -> AVAudioPCMBuffer? {
1219
- guard let settings = recordingSettings else {
1220
- Logger.debug("Recording settings not available")
1221
- return nil
1143
+ /// - buffer: The source audio buffer.
1144
+ /// - sourceRate: The source sample rate.
1145
+ /// - targetRate: The target sample rate.
1146
+ /// - Returns: The resampled audio buffer or nil if resampling failed.
1147
+ private func resampleAudioBuffer(_ buffer: AVAudioPCMBuffer, from sourceRate: Double, to targetRate: Double) -> AVAudioPCMBuffer? {
1148
+ // If the rates are the same, no need to resample
1149
+ if sourceRate == targetRate {
1150
+ return buffer
1222
1151
  }
1223
1152
 
1224
- Logger.debug("""
1225
- Starting resampling:
1226
- - Original format: \(describeAudioFormat(buffer.format))
1227
- - Original frames: \(buffer.frameLength)
1228
- - Target settings:
1229
- • Sample rate: \(targetSampleRate)Hz
1230
- • Bit depth: \(settings.bitDepth)
1231
- • Channels: \(settings.numberOfChannels)
1232
- """)
1233
-
1234
- // Use settings bit depth for output format
1235
- let targetFormat: AVAudioCommonFormat = settings.bitDepth == 32 ? .pcmFormatFloat32 : .pcmFormatInt16
1236
-
1237
- // Create output format matching recording settings exactly
1153
+ // Create source and target formats
1238
1154
  guard let outputFormat = AVAudioFormat(
1239
- commonFormat: targetFormat,
1240
- sampleRate: targetSampleRate,
1241
- channels: AVAudioChannelCount(settings.numberOfChannels),
1242
- interleaved: true
1155
+ commonFormat: buffer.format.commonFormat,
1156
+ sampleRate: targetRate,
1157
+ channels: buffer.format.channelCount,
1158
+ interleaved: buffer.format.isInterleaved
1243
1159
  ) else {
1244
- Logger.debug("Failed to create output format")
1160
+ Logger.debug("Failed to create output format for resampling")
1161
+ return nil
1162
+ }
1163
+
1164
+ // Create a converter
1165
+ guard let converter = AVAudioConverter(from: buffer.format, to: outputFormat) else {
1166
+ Logger.debug("Failed to create audio converter")
1245
1167
  return nil
1246
1168
  }
1247
1169
 
1248
1170
  // Calculate new buffer size
1249
- let ratio = targetSampleRate / originalSampleRate
1250
- let newFrameCount = AVAudioFrameCount(Double(buffer.frameLength) * ratio)
1171
+ let ratio = targetRate / sourceRate
1172
+ let estimatedFrames = AVAudioFrameCount(Double(buffer.frameLength) * ratio)
1251
1173
 
1252
1174
  // Create output buffer
1253
1175
  guard let outputBuffer = AVAudioPCMBuffer(
1254
1176
  pcmFormat: outputFormat,
1255
- frameCapacity: newFrameCount
1177
+ frameCapacity: estimatedFrames
1256
1178
  ) else {
1257
1179
  Logger.debug("Failed to create output buffer")
1258
1180
  return nil
1259
1181
  }
1260
- outputBuffer.frameLength = newFrameCount
1261
1182
 
1262
- // Create intermediate format for high-quality conversion if needed
1263
- let needsIntermediate = buffer.format.commonFormat != outputFormat.commonFormat
1264
- if needsIntermediate {
1265
- Logger.debug("Using intermediate Float32 format for high-quality conversion")
1266
- guard let intermediateFormat = AVAudioFormat(
1267
- commonFormat: .pcmFormatFloat32,
1268
- sampleRate: targetSampleRate,
1269
- channels: AVAudioChannelCount(settings.numberOfChannels),
1270
- interleaved: true
1271
- ) else {
1272
- Logger.debug("Failed to create intermediate format")
1273
- return nil
1274
- }
1275
-
1276
- // First convert to intermediate float format
1277
- guard let converter = AVAudioConverter(from: buffer.format, to: intermediateFormat),
1278
- let intermediateBuffer = AVAudioPCMBuffer(
1279
- pcmFormat: intermediateFormat,
1280
- frameCapacity: newFrameCount
1281
- ) else {
1282
- Logger.debug("Failed to create converter or intermediate buffer")
1283
- return nil
1284
- }
1285
- intermediateBuffer.frameLength = newFrameCount
1286
-
1287
- var error: NSError?
1288
- let inputBlock: AVAudioConverterInputBlock = { inNumPackets, outStatus in
1289
- outStatus.pointee = AVAudioConverterInputStatus.haveData
1290
- return buffer
1291
- }
1292
-
1293
- converter.convert(to: intermediateBuffer, error: &error, withInputFrom: inputBlock)
1294
-
1295
- if let error = error {
1296
- Logger.debug("Intermediate conversion failed: \(error.localizedDescription)")
1297
- return nil
1298
- }
1299
-
1300
- // Then convert to final format
1301
- guard let finalConverter = AVAudioConverter(from: intermediateFormat, to: outputFormat) else {
1302
- Logger.debug("Failed to create final converter")
1303
- return nil
1304
- }
1305
-
1306
- finalConverter.convert(to: outputBuffer, error: &error) { inNumPackets, outStatus in
1307
- outStatus.pointee = AVAudioConverterInputStatus.haveData
1308
- return intermediateBuffer
1309
- }
1310
-
1311
- if let error = error {
1312
- Logger.debug("Final conversion failed: \(error.localizedDescription)")
1313
- return nil
1314
- }
1315
- } else {
1316
- // Direct conversion if formats are compatible
1317
- guard let converter = AVAudioConverter(from: buffer.format, to: outputFormat) else {
1318
- Logger.debug("Failed to create converter")
1319
- return nil
1320
- }
1321
-
1322
- var error: NSError?
1323
- let inputBlock: AVAudioConverterInputBlock = { inNumPackets, outStatus in
1324
- outStatus.pointee = AVAudioConverterInputStatus.haveData
1325
- return buffer
1326
- }
1327
-
1328
- converter.convert(to: outputBuffer, error: &error, withInputFrom: inputBlock)
1329
-
1330
- if let error = error {
1331
- Logger.debug("Conversion failed: \(error.localizedDescription)")
1332
- return nil
1333
- }
1183
+ // Perform conversion
1184
+ var error: NSError?
1185
+ converter.convert(to: outputBuffer, error: &error) { inNumPackets, outStatus in
1186
+ outStatus.pointee = .haveData
1187
+ return buffer
1334
1188
  }
1335
1189
 
1336
- Logger.debug("""
1337
- Resampling completed:
1338
- - Final format: \(describeAudioFormat(outputBuffer.format))
1339
- - Final frames: \(outputBuffer.frameLength)
1340
- - Conversion path: \(needsIntermediate ? "With intermediate Float32" : "Direct")
1341
- """)
1190
+ if let error = error {
1191
+ Logger.debug("Error resampling audio: \(error.localizedDescription)")
1192
+ return nil
1193
+ }
1342
1194
 
1343
1195
  return outputBuffer
1344
1196
  }
1197
+
1198
+ /// Describes the format of the given audio format.
1199
+ /// - Parameter format: The AVAudioFormat object to describe.
1200
+ /// - Returns: A string description of the audio format.
1201
+ func describeAudioFormat(_ format: AVAudioFormat) -> String {
1202
+ let formatDescription = """
1203
+ - Sample rate: \(format.sampleRate)Hz
1204
+ - Channels: \(format.channelCount)
1205
+ - Interleaved: \(format.isInterleaved)
1206
+ - Common format: \(describeCommonFormat(format.commonFormat))
1207
+ """
1208
+ return formatDescription
1209
+ }
1345
1210
 
1346
- /// Manually resamples the audio buffer using linear interpolation.
1347
- /// - Parameters:
1348
- /// - buffer: The original audio buffer to be resampled.
1349
- /// - originalSampleRate: The sample rate of the original audio buffer.
1350
- /// - targetSampleRate: The desired sample rate to resample to.
1351
- /// - Returns: A new audio buffer resampled to the target sample rate, or nil if resampling fails.
1352
- private func manualResampleAudioBuffer(_ buffer: AVAudioPCMBuffer, from originalSampleRate: Double, to targetSampleRate: Double) -> AVAudioPCMBuffer? {
1353
- guard let channelData = buffer.floatChannelData else { return nil }
1354
-
1355
- let sourceFrameCount = Int(buffer.frameLength)
1356
- let sourceChannels = Int(buffer.format.channelCount)
1357
- let targetFrameCount = Int(Double(sourceFrameCount) * targetSampleRate / originalSampleRate)
1358
-
1359
- guard let targetBuffer = AVAudioPCMBuffer(pcmFormat: buffer.format, frameCapacity: AVAudioFrameCount(targetFrameCount)) else { return nil }
1360
- targetBuffer.frameLength = AVAudioFrameCount(targetFrameCount)
1361
-
1362
- let resamplingFactor = Float(targetSampleRate / originalSampleRate)
1363
-
1364
- for channel in 0..<sourceChannels {
1365
- let input = UnsafeBufferPointer(start: channelData[channel], count: sourceFrameCount)
1366
- let output = UnsafeMutableBufferPointer(start: targetBuffer.floatChannelData![channel], count: targetFrameCount)
1367
-
1368
- var y = Array(repeating: Float(0), count: targetFrameCount)
1369
- for i in 0..<targetFrameCount {
1370
- let index = Float(i) / resamplingFactor
1371
- let low = Int(floor(index))
1372
- let high = min(low + 1, sourceFrameCount - 1)
1373
- let weight = index - Float(low)
1374
- y[i] = (1 - weight) * input[low] + weight * input[high]
1375
- }
1376
-
1377
- for i in 0..<targetFrameCount {
1378
- output[i] = y[i]
1379
- }
1211
+ func describeCommonFormat(_ format: AVAudioCommonFormat) -> String {
1212
+ switch format {
1213
+ case .pcmFormatFloat32:
1214
+ return "32-bit float"
1215
+ case .pcmFormatFloat64:
1216
+ return "64-bit float"
1217
+ case .pcmFormatInt16:
1218
+ return "16-bit int"
1219
+ case .pcmFormatInt32:
1220
+ return "32-bit int"
1221
+ default:
1222
+ return "Unknown format"
1380
1223
  }
1381
-
1382
- return targetBuffer
1383
1224
  }
1384
1225
 
1385
-
1386
-
1387
1226
  /// Updates the WAV header with the correct file size.
1388
1227
  /// - Parameters:
1389
1228
  /// - fileURL: The URL of the WAV file.
@@ -1434,208 +1273,154 @@ class AudioStreamManager: NSObject {
1434
1273
  }
1435
1274
  }
1436
1275
 
1437
- /// Processes the audio buffer and writes data to the file. Also handles audio processing if enabled.
1276
+ /// Processes the audio buffer: handles resampling/format conversion if necessary,
1277
+ /// writes the result to the WAV file on a background thread, and triggers
1278
+ /// analysis processing and event emission based on intervals.
1438
1279
  /// - Parameters:
1439
- /// - buffer: The audio buffer to process.
1440
- /// - fileURL: The URL of the file to write the data to.
1280
+ /// - buffer: The audio buffer received from the input node tap.
1281
+ /// - fileURL: The URL of the file to write the data to (ignored, uses self.fileHandle).
1441
1282
  private func processAudioBuffer(_ buffer: AVAudioPCMBuffer, fileURL: URL) {
1442
1283
  guard let settings = recordingSettings else {
1443
- Logger.debug("Recording settings not available")
1444
- return
1445
- }
1446
-
1447
- guard let fileHandle = try? FileHandle(forWritingTo: fileURL) else {
1448
- Logger.debug("Failed to open file handle for URL: \(fileURL)")
1284
+ Logger.debug("processAudioBuffer: Recording settings not available")
1449
1285
  return
1450
1286
  }
1451
- defer {
1452
- fileHandle.closeFile() // Ensure file is always closed
1453
- }
1454
-
1287
+
1288
+ // targetSampleRate and targetFormat remain the user's requested final format
1455
1289
  let targetSampleRate = Double(settings.sampleRate)
1456
1290
  let targetFormat: AVAudioCommonFormat = settings.bitDepth == 32 ? .pcmFormatFloat32 : .pcmFormatInt16
1457
-
1458
- // First handle resampling if needed
1459
- let resampledBuffer: AVAudioPCMBuffer
1291
+
1292
+ // Buffer to be processed - initially the input buffer
1293
+ var bufferToProcess: AVAudioPCMBuffer = buffer
1294
+
1295
+ // 1. Resample if the buffer's sample rate doesn't match the target
1460
1296
  if buffer.format.sampleRate != targetSampleRate {
1461
1297
  if let resampled = resampleAudioBuffer(buffer, from: buffer.format.sampleRate, to: targetSampleRate) {
1462
- resampledBuffer = resampled
1298
+ bufferToProcess = resampled
1463
1299
  } else {
1464
- Logger.debug("Resampling failed")
1300
+ Logger.debug("processAudioBuffer: Resampling FAILED")
1465
1301
  return
1466
1302
  }
1467
- } else {
1468
- resampledBuffer = buffer
1469
1303
  }
1470
-
1471
- // Then ensure format matches user settings
1472
- let finalBuffer: AVAudioPCMBuffer
1473
- if resampledBuffer.format.commonFormat != targetFormat {
1474
- guard let converted = convertBufferFormat(resampledBuffer, to: AVAudioFormat(
1304
+
1305
+ // 2. Convert format if the (potentially resampled) buffer's format doesn't match the target
1306
+ if bufferToProcess.format.commonFormat != targetFormat {
1307
+ guard let targetAVFormat = AVAudioFormat(
1475
1308
  commonFormat: targetFormat,
1476
- sampleRate: targetSampleRate,
1309
+ sampleRate: targetSampleRate, // Use target rate for final format
1477
1310
  channels: AVAudioChannelCount(settings.numberOfChannels),
1478
- interleaved: true
1479
- )!) else {
1480
- Logger.debug("Format conversion failed")
1311
+ interleaved: bufferToProcess.format.isInterleaved // Match interleaving of current buffer
1312
+ ) else {
1313
+ Logger.debug("processAudioBuffer: Failed to create target AVAudioFormat for conversion.")
1314
+ return
1315
+ }
1316
+ if let converted = convertBufferFormat(bufferToProcess, to: targetAVFormat) {
1317
+ bufferToProcess = converted
1318
+ } else {
1319
+ Logger.debug("processAudioBuffer: Format conversion FAILED")
1481
1320
  return
1482
1321
  }
1483
- finalBuffer = converted
1484
- } else {
1485
- finalBuffer = resampledBuffer
1486
1322
  }
1487
-
1488
- let audioData = finalBuffer.audioBufferList.pointee.mBuffers
1323
+
1324
+ // Now bufferToProcess contains the audio data in the desired sample rate and format
1325
+ let audioData = bufferToProcess.audioBufferList.pointee.mBuffers
1489
1326
  guard let bufferData = audioData.mData else {
1490
- Logger.debug("Buffer data is nil.")
1327
+ Logger.debug("Buffer data is nil after processing.")
1491
1328
  return
1492
1329
  }
1493
-
1494
- var data = Data(bytes: bufferData, count: Int(audioData.mDataByteSize))
1495
-
1496
- // Check if this is the first buffer to process
1497
- if totalDataSize == 0 {
1498
- let header = createWavHeader(dataSize: 0)
1499
- data.insert(contentsOf: header, at: 0)
1330
+
1331
+ // Create an immutable copy for background/event emission
1332
+ let dataToWrite = Data(bytes: bufferData, count: Int(audioData.mDataByteSize))
1333
+
1334
+ // --- Background File Writing ---
1335
+ // Use the persistent fileHandle opened during preparation.
1336
+ DispatchQueue.global(qos: .utility).async { [weak self] in
1337
+ guard let self = self, let handle = self.fileHandle else {
1338
+ Logger.debug("BG Write Error: File handle is nil.")
1339
+ return
1340
+ }
1341
+ do {
1342
+ try handle.seekToEnd()
1343
+ try handle.write(contentsOf: dataToWrite)
1344
+ // Update total size state
1345
+ self.totalDataSize += Int64(dataToWrite.count)
1346
+ } catch {
1347
+ Logger.debug("BG Write Error: Failed to seek/write: \(error.localizedDescription)")
1348
+ }
1500
1349
  }
1501
-
1502
- // Write to file
1503
- fileHandle.seekToEndOfFile()
1504
- fileHandle.write(data)
1505
-
1506
- // Update total size and accumulated data
1507
- totalDataSize += Int64(data.count)
1508
- accumulatedData.append(data)
1509
- accumulatedAnalysisData.append(data)
1510
-
1511
- // Handle notifications if enabled
1350
+
1351
+ // --- Event Emission & Analysis ---
1352
+ accumulatedData.append(dataToWrite)
1353
+ accumulatedAnalysisData.append(dataToWrite)
1354
+
1512
1355
  if recordingSettings?.showNotification == true {
1513
1356
  updateNotificationDuration()
1514
1357
  }
1515
-
1516
- // Emit data based on interval
1358
+
1517
1359
  let currentTime = Date()
1518
- if let lastEmissionTime = lastEmissionTime,
1519
- let startTime = startTime,
1520
- currentTime.timeIntervalSince(lastEmissionTime) >= emissionInterval {
1521
-
1522
- let recordingTime = currentTime.timeIntervalSince(startTime)
1523
- let dataToProcess = accumulatedData
1524
-
1525
- // Prepare compression info if enabled
1526
- var compressionInfo: [String: Any]? = nil
1527
- if settings.enableCompressedOutput, let compressedURL = compressedFileURL {
1528
- do {
1529
- // Ensure file exists and has data
1530
- if FileManager.default.fileExists(atPath: compressedURL.path) {
1531
- let compressedAttributes = try FileManager.default.attributesOfItem(atPath: compressedURL.path)
1532
- if let compressedSize = compressedAttributes[.size] as? Int64 {
1533
- let eventDataSize = compressedSize - lastEmittedCompressedSize
1534
-
1535
- Logger.debug("Compressed file status - Total size: \(compressedSize), New data size: \(eventDataSize)")
1536
-
1537
- // Read the new compressed data if there's new data
1538
- var compressedData: String? = nil
1539
- if eventDataSize > 0 {
1540
- do {
1541
- let fileHandle = try FileHandle(forReadingFrom: compressedURL)
1542
- defer { fileHandle.closeFile() }
1543
-
1544
- fileHandle.seek(toFileOffset: UInt64(lastEmittedCompressedSize))
1545
- let data = fileHandle.readData(ofLength: Int(eventDataSize))
1546
- compressedData = data.base64EncodedString()
1547
-
1548
- Logger.debug("Read compressed data of size: \(data.count)")
1549
- } catch {
1550
- Logger.debug("Error reading compressed data: \(error)")
1551
- }
1552
- }
1553
-
1554
- lastEmittedCompressedSize = compressedSize
1555
-
1556
- compressionInfo = [
1557
- "position": recordingTime * 1000, // Convert to milliseconds
1558
- "fileUri": compressedURL.absoluteString,
1559
- "eventDataSize": eventDataSize,
1560
- "totalSize": compressedSize,
1561
- "data": compressedData ?? ""
1562
- ]
1563
-
1564
- Logger.debug("Compression info prepared: \(String(describing: compressionInfo))")
1565
- } else {
1566
- Logger.debug("Could not get compressed file size")
1567
- }
1568
- } else {
1569
- Logger.debug("Compressed file does not exist at path: \(compressedURL.path)")
1570
- }
1571
- } catch {
1572
- Logger.debug("Error preparing compression info: \(error)")
1573
- }
1574
- }
1575
-
1576
- // Emit the audio data with compression info
1360
+ let currentTotalSize = self.totalDataSize // Use the most up-to-date size for events
1361
+
1362
+ // 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
1577
1373
  delegate?.audioStreamManager(
1578
1374
  self,
1579
- didReceiveAudioData: dataToProcess,
1375
+ didReceiveAudioData: dataToEmit,
1580
1376
  recordingTime: recordingTime,
1581
- totalDataSize: totalDataSize,
1377
+ totalDataSize: currentTotalSize,
1582
1378
  compressionInfo: compressionInfo
1583
1379
  )
1584
-
1585
- // Update state after emission
1586
- self.lastEmissionTime = currentTime
1587
- self.lastEmittedSize = totalDataSize
1588
- accumulatedData.removeAll()
1380
+ // Logger.debug("Emitted didReceiveAudioData event.") // Optional: Re-enable if needed
1589
1381
  }
1590
1382
 
1383
+ // Dispatch analysis task
1384
+ if let lastEmissionAnalysis = self.lastEmissionTimeAnalysis,
1385
+ currentTime.timeIntervalSince(lastEmissionAnalysis) >= emissionIntervalAnalysis,
1386
+ settings.enableProcessing,
1387
+ let processor = self.audioProcessor,
1388
+ !accumulatedAnalysisData.isEmpty {
1389
+ let dataToAnalyze = accumulatedAnalysisData
1390
+ self.lastEmissionTimeAnalysis = currentTime
1391
+ accumulatedAnalysisData.removeAll()
1591
1392
 
1592
- if let lastEmissionTimeAnalysis = lastEmissionTimeAnalysis,
1593
- let startTime = startTime,
1594
- currentTime.timeIntervalSince(lastEmissionTimeAnalysis) >= emissionIntervalAnalysis {
1595
-
1596
- let recordingTime = currentTime.timeIntervalSince(startTime)
1597
- let dataToProcess = accumulatedAnalysisData
1598
-
1599
- // Process audio if enabled
1600
- if settings.enableProcessing {
1601
- DispatchQueue.global().async { [weak self] in
1602
- guard let self = self else { return }
1603
- if let processor = self.audioProcessor {
1604
- Logger.debug("Processing audio buffer of size: \(dataToProcess.count)")
1605
-
1606
- // Strip WAV header from the first buffer to avoid false amplitude detection
1607
- let dataToAnalyze: Data
1608
- if self.totalDataSizeAnalysis == 0 && dataToProcess.count > Int(WAV_HEADER_SIZE) {
1609
- // This is the first buffer and may contain the WAV header
1610
- dataToAnalyze = dataToProcess.subdata(in: Int(WAV_HEADER_SIZE)..<dataToProcess.count)
1611
- Logger.debug("Removed WAV header (\(WAV_HEADER_SIZE) bytes) from first buffer for analysis")
1612
- } else {
1613
- dataToAnalyze = dataToProcess
1614
- }
1615
-
1616
- let processingResult = processor.processAudioBuffer(
1617
- data: dataToAnalyze,
1618
- sampleRate: Float(settings.sampleRate),
1619
- segmentDurationMs: settings.segmentDurationMs,
1620
- featureOptions: settings.featureOptions ?? [:],
1621
- bitDepth: settings.bitDepth,
1622
- numberOfChannels: settings.numberOfChannels
1623
- )
1624
-
1625
- DispatchQueue.main.async {
1626
- if let result = processingResult {
1627
- self.delegate?.audioStreamManager(self, didReceiveProcessingResult: result)
1628
- }
1629
- }
1393
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
1394
+ guard let self = self, let processor = self.audioProcessor, let settings = self.recordingSettings else {
1395
+ // Logger.debug("Analysis Dispatch SKIP: self, processor, or settings nil")
1396
+ return
1397
+ }
1398
+ guard !dataToAnalyze.isEmpty else {
1399
+ // Logger.debug("Analysis Dispatch SKIP: dataToAnalyze is empty")
1400
+ return
1401
+ }
1402
+
1403
+ // Logger.debug("Analysis Dispatch: Processing \(dataToAnalyze.count) bytes...")
1404
+ let processingResult = processor.processAudioBuffer(
1405
+ data: dataToAnalyze,
1406
+ sampleRate: Float(settings.sampleRate),
1407
+ segmentDurationMs: settings.segmentDurationMs,
1408
+ featureOptions: settings.featureOptions ?? [:],
1409
+ bitDepth: settings.bitDepth,
1410
+ numberOfChannels: settings.numberOfChannels
1411
+ )
1630
1412
 
1631
- // Update state after emission
1632
- self.lastEmissionTimeAnalysis = currentTime
1633
- // Update the total analysis data size to mark that we've processed data
1634
- self.totalDataSizeAnalysis = self.totalDataSize
1635
- accumulatedAnalysisData.removeAll()
1413
+ // Dispatch result back to main thread
1414
+ DispatchQueue.main.async {
1415
+ if let result = processingResult {
1416
+ // Logger.debug("Analysis Dispatch: Success, calling delegate.")
1417
+ self.delegate?.audioStreamManager(self, didReceiveProcessingResult: result)
1418
+ } else {
1419
+ Logger.debug("Analysis Dispatch FAIL: processor.processAudioBuffer returned nil")
1636
1420
  }
1637
1421
  }
1638
1422
  }
1423
+ // Logger.debug("Dispatched analysis task.") // Optional: Re-enable if needed
1639
1424
  }
1640
1425
  }
1641
1426
 
@@ -1666,7 +1451,7 @@ class AudioStreamManager: NSObject {
1666
1451
  let outputBuffer = AVAudioPCMBuffer(
1667
1452
  pcmFormat: targetFormat,
1668
1453
  frameCapacity: buffer.frameLength
1669
- ) else {
1454
+ ) else {
1670
1455
  return nil
1671
1456
  }
1672
1457
 
@@ -1685,6 +1470,425 @@ class AudioStreamManager: NSObject {
1685
1470
 
1686
1471
  return outputBuffer
1687
1472
  }
1473
+
1474
+ /// Attempts to update the audio session with the preferred input device from current settings.
1475
+ /// Called externally when the device selection changes.
1476
+ /// Note: Avoids changing sample rate or buffer duration while engine might be running.
1477
+ public func updateAudioSessionWithCurrentSettings() {
1478
+ guard let settings = self.recordingSettings, let deviceId = settings.deviceId else {
1479
+ Logger.debug("Cannot update audio session preference, settings or deviceId missing")
1480
+ return
1481
+ }
1482
+
1483
+ let session = AVAudioSession.sharedInstance()
1484
+
1485
+ // Find the requested device port
1486
+ let selectedPort = session.availableInputs?.first { port in
1487
+ // Normalize IDs for comparison, especially for Bluetooth
1488
+ let portNormalizedId = deviceManager.normalizeBluetoothDeviceId(port.uid)
1489
+ let requestedNormalizedId = deviceManager.normalizeBluetoothDeviceId(deviceId)
1490
+ return portNormalizedId == requestedNormalizedId
1491
+ }
1492
+
1493
+ if let portToSet = selectedPort {
1494
+ do {
1495
+ try session.setPreferredInput(portToSet)
1496
+ Logger.debug("Attempted to set preferred input to: \(portToSet.portName) (ID: \(portToSet.uid))")
1497
+ // We add a small delay hoping the system applies the change before potential next operations
1498
+ Thread.sleep(forTimeInterval: 0.1)
1499
+ } catch {
1500
+ Logger.debug("Failed to set preferred input device \(portToSet.portName): \(error.localizedDescription)")
1501
+ }
1502
+ } else {
1503
+ Logger.debug("Could not find device with ID \(deviceId) to set as preferred input.")
1504
+ }
1505
+ }
1506
+
1507
+ /// Stops the current audio recording.
1508
+ /// - Returns: A RecordingResult object if the recording stopped successfully, or nil otherwise.
1509
+ func stopRecording() -> RecordingResult? {
1510
+ guard isRecording || isPrepared else { return nil }
1511
+
1512
+ Logger.debug("Stopping recording...")
1513
+
1514
+ // IMPORTANT: Emit any remaining audio data before stopping the engine
1515
+ if isRecording && !accumulatedData.isEmpty {
1516
+ Logger.debug("Emitting final audio chunk of \(accumulatedData.count) bytes before stopping")
1517
+ let recordingTime = currentRecordingDuration()
1518
+ let finalTotalSize = self.totalDataSize // Use current total size
1519
+
1520
+ // Create a copy of accumulated data to avoid race conditions
1521
+ let finalData = accumulatedData
1522
+ accumulatedData.removeAll()
1523
+
1524
+ // Notify delegate with final audio data
1525
+ delegate?.audioStreamManager(
1526
+ self,
1527
+ didReceiveAudioData: finalData,
1528
+ recordingTime: recordingTime,
1529
+ totalDataSize: finalTotalSize,
1530
+ compressionInfo: nil
1531
+ )
1532
+ }
1533
+
1534
+ disableWakeLock()
1535
+ audioEngine.stop()
1536
+ audioEngine.inputNode.removeTap(onBus: 0)
1537
+
1538
+ // Stop compressed recording if active
1539
+ compressedRecorder?.stop()
1540
+
1541
+ // Get the final duration before changing state
1542
+ let finalDuration = currentRecordingDuration()
1543
+
1544
+ let wasRecording = isRecording
1545
+ isRecording = false
1546
+ isPaused = false
1547
+ isPrepared = false // Reset preparation state
1548
+
1549
+ // If we were only prepared but never started recording, clean up and return nil
1550
+ if !wasRecording {
1551
+ cleanupPreparation()
1552
+ return nil
1553
+ }
1554
+
1555
+ if recordingSettings?.showNotification == true {
1556
+ // Stop and clean up timer
1557
+ mediaInfoUpdateTimer?.invalidate()
1558
+ mediaInfoUpdateTimer = nil
1559
+
1560
+ // Clean up notification manager
1561
+ notificationManager?.stopUpdates()
1562
+ notificationManager = nil
1563
+
1564
+ // Clean up media controls
1565
+ DispatchQueue.main.async {
1566
+ UIApplication.shared.endReceivingRemoteControlEvents()
1567
+ self.remoteCommandCenter?.pauseCommand.isEnabled = false
1568
+ self.remoteCommandCenter?.playCommand.isEnabled = false
1569
+ self.notificationView?.nowPlayingInfo = nil
1570
+ }
1571
+ }
1572
+
1573
+ // Reset audio session
1574
+ do {
1575
+ try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
1576
+ } catch {
1577
+ Logger.debug("Error deactivating audio session: \(error)")
1578
+ }
1579
+
1580
+ // Reset audio engine
1581
+ audioEngine.reset()
1582
+
1583
+ guard let fileURL = recordingFileURL,
1584
+ let settings = recordingSettings else {
1585
+ Logger.debug("Recording or file URL is nil.")
1586
+ return nil
1587
+ }
1588
+
1589
+ // Validate WAV file
1590
+ let wavPath = fileURL.path
1591
+ do {
1592
+ // Check if WAV file exists
1593
+ let wavFileAttributes = try FileManager.default.attributesOfItem(atPath: wavPath)
1594
+ let wavFileSize = wavFileAttributes[FileAttributeKey.size] as? Int64 ?? 0
1595
+
1596
+ Logger.debug("""
1597
+ WAV File validation:
1598
+ - Path: \(wavPath)
1599
+ - Exists: true
1600
+ - Size: \(wavFileSize) bytes
1601
+ - Duration: \(finalDuration) seconds
1602
+ - Expected minimum size: \(WAV_HEADER_SIZE) bytes (WAV header)
1603
+ """)
1604
+
1605
+ // Use the final totalDataSize tracked by the background queue
1606
+ let finalDataChunkSize = self.totalDataSize - Int64(WAV_HEADER_SIZE)
1607
+ if finalDataChunkSize <= 0 {
1608
+ Logger.debug("Recording file data chunk size is zero or negative (\(finalDataChunkSize) bytes), likely no audio data was recorded successfully after header")
1609
+ // Optionally delete the empty file?
1610
+ // try? FileManager.default.removeItem(at: fileURL)
1611
+ return nil
1612
+ }
1613
+
1614
+ // Update the WAV header with the correct final file size
1615
+ updateWavHeader(fileURL: fileURL, totalDataSize: finalDataChunkSize)
1616
+ Logger.debug("Final WAV header updated. Data chunk size: \(finalDataChunkSize)")
1617
+
1618
+ // Validate compressed file if enabled
1619
+ var compression: CompressedRecordingInfo?
1620
+ if let compressedURL = compressedFileURL {
1621
+ let compressedPath = compressedURL.path
1622
+ if FileManager.default.fileExists(atPath: compressedPath) {
1623
+ let compressedAttributes = try FileManager.default.attributesOfItem(atPath: compressedPath)
1624
+ let compressedSize = compressedAttributes[FileAttributeKey.size] as? Int64 ?? 0
1625
+
1626
+ Logger.debug("""
1627
+ Compressed File validation:
1628
+ - Path: \(compressedPath)
1629
+ - Format: \(compressedFormat)
1630
+ - Size: \(compressedSize) bytes
1631
+ - Bitrate: \(compressedBitRate) bps
1632
+ """)
1633
+
1634
+ if compressedSize > 0 {
1635
+ compression = CompressedRecordingInfo(
1636
+ compressedFileUri: compressedURL.absoluteString,
1637
+ mimeType: compressedFormat == "aac" ? "audio/aac" : "audio/opus",
1638
+ bitrate: compressedBitRate,
1639
+ format: compressedFormat,
1640
+ size: compressedSize
1641
+ )
1642
+ } else {
1643
+ Logger.debug("Warning: Compressed file exists but is empty")
1644
+ }
1645
+ } else {
1646
+ Logger.debug("Warning: Compressed file not found at path: \(compressedPath)")
1647
+ }
1648
+ }
1649
+
1650
+ let durationMs = Int64(finalDuration * 1000)
1651
+
1652
+ let result = RecordingResult(
1653
+ fileUri: fileURL.absoluteString,
1654
+ filename: fileURL.lastPathComponent,
1655
+ mimeType: mimeType,
1656
+ duration: durationMs,
1657
+ size: wavFileSize,
1658
+ channels: settings.numberOfChannels,
1659
+ bitDepth: settings.bitDepth,
1660
+ sampleRate: settings.sampleRate,
1661
+ compression: compression
1662
+ )
1663
+
1664
+ Logger.debug("""
1665
+ Recording completed successfully:
1666
+ - WAV file: \(fileURL.lastPathComponent)
1667
+ - Size: \(wavFileSize) bytes
1668
+ - Duration: \(durationMs)ms
1669
+ - Sample rate: \(settings.sampleRate)Hz
1670
+ - Bit depth: \(settings.bitDepth)-bit
1671
+ - Channels: \(settings.numberOfChannels)
1672
+ - Compressed: \(compression != nil ? "yes" : "no")
1673
+ """)
1674
+
1675
+ // Additional cleanup
1676
+ recordingFileURL = nil
1677
+ lastBufferTime = nil
1678
+ lastValidDuration = nil
1679
+ compressedRecorder = nil
1680
+ compressedFileURL = nil
1681
+ recordingSettings = nil
1682
+ startTime = nil
1683
+ totalPausedDuration = 0
1684
+ currentPauseStart = nil
1685
+ lastEmissionTime = nil
1686
+ lastEmissionTimeAnalysis = nil
1687
+ lastEmittedSize = 0
1688
+ lastEmittedSizeAnalysis = 0
1689
+ lastEmittedCompressedSize = 0
1690
+ accumulatedData.removeAll()
1691
+ accumulatedAnalysisData.removeAll()
1692
+ recordingUUID = nil
1693
+
1694
+ return result
1695
+
1696
+ } catch {
1697
+ Logger.debug("Failed to validate recording files: \(error)")
1698
+ return nil
1699
+ }
1700
+ }
1701
+
1702
+ // MARK: - AudioDeviceManagerDelegate Implementation
1703
+
1704
+ func audioDeviceManager(_ manager: AudioDeviceManager, didDetectDisconnectionOfDevice disconnectedDeviceId: String) {
1705
+ // This method will be called by AudioDeviceManager when a disconnection occurs
1706
+ // Run on main thread to safely interact with AVAudioEngine and state
1707
+ DispatchQueue.main.async {
1708
+ self.handleDeviceDisconnection(disconnectedDeviceId: disconnectedDeviceId)
1709
+ }
1710
+ }
1711
+
1712
+ // MARK: - Device Disconnection Handling
1713
+
1714
+ // Define interruption reasons matching ExpoAudioStream.types.ts
1715
+ enum RecordingInterruptionReason: String {
1716
+ case deviceDisconnected = "deviceDisconnected"
1717
+ case deviceFallback = "deviceFallback"
1718
+ case deviceSwitchFailed = "deviceSwitchFailed"
1719
+ // Add other reasons if needed (e.g., from handleAudioSessionInterruption)
1720
+ case audioFocusLoss = "audioFocusLoss"
1721
+ case audioFocusGain = "audioFocusGain"
1722
+ case phoneCall = "phoneCall"
1723
+ case phoneCallEnded = "phoneCallEnded"
1724
+ case recordingStopped = "recordingStopped"
1725
+ case deviceConnected = "deviceConnected"
1726
+ }
1727
+
1728
+ private func handleDeviceDisconnection(disconnectedDeviceId: String) {
1729
+ Logger.debug("handleDeviceDisconnection entered. isRecording: \(isRecording), settingsExist: \(recordingSettings != nil), deviceIdExists: \(recordingSettings?.deviceId != nil), currentDeviceId: \(recordingSettings?.deviceId ?? "nil")")
1730
+
1731
+ // --- Modify Guard: Only require settings object, handle nil deviceId later ---
1732
+ guard let settings = recordingSettings else {
1733
+ // If settings are nil, we truly can't determine behavior, so pause.
1734
+ Logger.debug("Device disconnected (\(disconnectedDeviceId)), but recordingSettings object is missing. Pausing.")
1735
+ performPauseAction(reason: .deviceDisconnected)
1736
+ return
1737
+ }
1738
+ // We now have settings, proceed even if deviceId might be nil inside it.
1739
+ let currentRecordingDeviceId = settings.deviceId // This might be nil, handle below
1740
+ // --- End Modify Guard ---
1741
+
1742
+ // Normalize BOTH IDs for reliable comparison
1743
+ // Use "nil" if currentRecordingDeviceId is actually nil
1744
+ let normalizedCurrentId = deviceManager.normalizeBluetoothDeviceId(currentRecordingDeviceId ?? "nil")
1745
+ let normalizedDisconnectedId = deviceManager.normalizeBluetoothDeviceId(disconnectedDeviceId)
1746
+
1747
+ Logger.debug("Handling disconnection. Current device: \(normalizedCurrentId), Disconnected device: \(normalizedDisconnectedId)")
1748
+
1749
+ // Check if the disconnected device is the one we *thought* we were recording from
1750
+ if normalizedCurrentId == normalizedDisconnectedId || currentRecordingDeviceId == nil {
1751
+ // If the IDs match OR if the stored deviceId was nil (meaning we lost track),
1752
+ // assume this disconnection applies to our current recording session.
1753
+
1754
+ Logger.debug("Disconnection event matches current recording session (or session deviceId was lost). Applying behavior...")
1755
+
1756
+ // Get the string value from settings using the correct property name
1757
+ // The property in RecordingSettings likely matches the TS interface: deviceDisconnectionBehavior
1758
+ let behaviorString = settings.deviceDisconnectionBehavior ?? "pause" // Use the correct property name
1759
+ let behavior = DeviceDisconnectionBehavior(rawValue: behaviorString) ?? .PAUSE // Convert to enum, default to .PAUSE
1760
+
1761
+ Logger.debug("Recording device disconnected! Applying behavior: \(behavior.rawValue)")
1762
+
1763
+ delegate?.audioStreamManager(self, didReceiveInterruption: [
1764
+ "reason": RecordingInterruptionReason.deviceDisconnected.rawValue,
1765
+ "isPaused": isPaused
1766
+ ])
1767
+
1768
+ // Switch on the *enum* value
1769
+ switch behavior {
1770
+ case .PAUSE:
1771
+ performPauseAction(reason: .deviceDisconnected)
1772
+
1773
+ case .FALLBACK:
1774
+ Task {
1775
+ await performFallbackAction()
1776
+ }
1777
+ }
1778
+ } else {
1779
+ Logger.debug("A different device disconnected (\(normalizedDisconnectedId)). Current recording device (\(normalizedCurrentId)) is still active. Ignoring.")
1780
+ }
1781
+ }
1782
+
1783
+ private func performPauseAction(reason: RecordingInterruptionReason) {
1784
+ if !isPaused { // Only pause if not already paused
1785
+ Logger.debug("Pausing recording due to \(reason.rawValue)")
1786
+ pauseRecording() // Use existing pause function
1787
+ } else {
1788
+ Logger.debug("Recording was already paused when \(reason.rawValue) occurred.")
1789
+ }
1790
+ // Note: pauseRecording already notifies the delegate about the pause state change.
1791
+ // Send an additional interruption notification specifically for the reason
1792
+ delegate?.audioStreamManager(self, didReceiveInterruption: [
1793
+ "reason": reason.rawValue,
1794
+ "isPaused": true // Since we are pausing or were already paused
1795
+ ])
1796
+ }
1797
+
1798
+ private func performFallbackAction() async {
1799
+ Logger.debug("Attempting to fallback to default device...")
1800
+
1801
+ do {
1802
+ // 1. Get the new default device (using the async version)
1803
+ guard let defaultDevice = await deviceManager.getDefaultInputDevice() else {
1804
+ Logger.debug("Fallback failed: Could not get default input device. Pausing.")
1805
+ performPauseAction(reason: .deviceSwitchFailed) // Fallback to pause if no default
1806
+ return
1807
+ }
1808
+ Logger.debug("Found default device for fallback: \(defaultDevice.name) (ID: \(defaultDevice.id))")
1809
+
1810
+ // 2. Stop engine temporarily & Remove existing tap
1811
+ let wasManuallyPaused = isPaused
1812
+ if audioEngine.isRunning {
1813
+ audioEngine.pause()
1814
+ }
1815
+ audioEngine.inputNode.removeTap(onBus: 0)
1816
+ Logger.debug("Fallback: Paused engine and removed existing tap.")
1817
+
1818
+ // 3. Update settings and select the new device in the session
1819
+ recordingSettings?.deviceId = defaultDevice.id // Update setting
1820
+ let selectionSuccess = await deviceManager.selectDevice(defaultDevice.id)
1821
+ if !selectionSuccess {
1822
+ Logger.debug("Fallback failed: Could not select default device in session. Pausing.")
1823
+ performPauseAction(reason: .deviceSwitchFailed)
1824
+ return
1825
+ }
1826
+ 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
+
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))")
1847
+
1848
+ // 5. Install the new tap
1849
+ audioEngine.inputNode.installTap(onBus: 0, bufferSize: 1024, format: newTapFormat) { [weak self] (buffer, time) in
1850
+ guard let self = self, self.isRecording else { return }
1851
+ self.processAudioBuffer(buffer, fileURL: self.recordingFileURL!)
1852
+ self.lastBufferTime = time
1853
+ }
1854
+ Logger.debug("Fallback: Re-installed tap with new format.")
1855
+
1856
+ // 6. Prepare and Restart engine if it wasn't manually paused before
1857
+ audioEngine.prepare()
1858
+ Logger.debug("Fallback: Prepared audio engine.")
1859
+
1860
+ 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 {
1872
+ 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
+ }
1877
+
1878
+ // 7. Notify JS about successful fallback
1879
+ delegate?.audioStreamManager(self, didReceiveInterruption: [
1880
+ "reason": RecordingInterruptionReason.deviceFallback.rawValue,
1881
+ "newDeviceId": defaultDevice.id, // Include new device ID
1882
+ "isPaused": isPaused // Report current state
1883
+ ])
1884
+ Logger.debug("Fallback to device \(defaultDevice.id) successful.")
1885
+
1886
+ } catch {
1887
+ Logger.debug("Fallback failed with error: \(error). Pausing.")
1888
+ performPauseAction(reason: .deviceSwitchFailed)
1889
+ }
1890
+ }
1891
+
1688
1892
  }
1689
1893
 
1690
1894
  extension AudioStreamManager: UNUserNotificationCenterDelegate {