@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.
- package/CHANGELOG.md +14 -1
- package/README.md +25 -0
- package/android/src/main/java/net/siteed/audiostream/AudioAnalysisData.kt +22 -0
- package/android/src/main/java/net/siteed/audiostream/AudioDeviceManager.kt +1501 -0
- package/android/src/main/java/net/siteed/audiostream/AudioFileHandler.kt +10 -5
- package/android/src/main/java/net/siteed/audiostream/AudioNotificationsManager.kt +27 -25
- package/android/src/main/java/net/siteed/audiostream/AudioProcessor.kt +73 -71
- package/android/src/main/java/net/siteed/audiostream/AudioRecorderManager.kt +581 -255
- package/android/src/main/java/net/siteed/audiostream/Constants.kt +17 -1
- package/android/src/main/java/net/siteed/audiostream/ExpoAudioStreamModule.kt +435 -158
- package/android/src/main/java/net/siteed/audiostream/LogUtils.kt +65 -0
- package/android/src/main/java/net/siteed/audiostream/PermissionUtils.kt +14 -5
- package/android/src/main/java/net/siteed/audiostream/RecordingConfig.kt +9 -1
- package/build/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
- package/build/AudioDeviceManager.d.ts +107 -0
- package/build/AudioDeviceManager.d.ts.map +1 -0
- package/build/AudioDeviceManager.js +493 -0
- package/build/AudioDeviceManager.js.map +1 -0
- package/build/AudioRecorder.provider.d.ts.map +1 -1
- package/build/AudioRecorder.provider.js +3 -0
- package/build/AudioRecorder.provider.js.map +1 -1
- package/build/ExpoAudioStream.types.d.ts +90 -1
- package/build/ExpoAudioStream.types.d.ts.map +1 -1
- package/build/ExpoAudioStream.types.js +7 -1
- package/build/ExpoAudioStream.types.js.map +1 -1
- package/build/ExpoAudioStream.web.d.ts +37 -0
- package/build/ExpoAudioStream.web.d.ts.map +1 -1
- package/build/ExpoAudioStream.web.js +399 -54
- package/build/ExpoAudioStream.web.js.map +1 -1
- package/build/ExpoAudioStreamModule.d.ts.map +1 -1
- package/build/ExpoAudioStreamModule.js +20 -0
- package/build/ExpoAudioStreamModule.js.map +1 -1
- package/build/WebRecorder.web.d.ts +63 -10
- package/build/WebRecorder.web.d.ts.map +1 -1
- package/build/WebRecorder.web.js +277 -68
- package/build/WebRecorder.web.js.map +1 -1
- package/build/hooks/useAudioDevices.d.ts +14 -0
- package/build/hooks/useAudioDevices.d.ts.map +1 -0
- package/build/hooks/useAudioDevices.js +151 -0
- package/build/hooks/useAudioDevices.js.map +1 -0
- package/build/index.d.ts +2 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +4 -0
- package/build/index.js.map +1 -1
- package/build/useAudioRecorder.d.ts +1 -0
- package/build/useAudioRecorder.d.ts.map +1 -1
- package/build/useAudioRecorder.js +20 -1
- package/build/useAudioRecorder.js.map +1 -1
- package/build/utils/BlobFix.d.ts.map +1 -1
- package/build/utils/BlobFix.js +2 -2
- package/build/utils/BlobFix.js.map +1 -1
- package/build/workers/InlineFeaturesExtractor.web.d.ts +1 -1
- package/build/workers/InlineFeaturesExtractor.web.d.ts.map +1 -1
- package/build/workers/InlineFeaturesExtractor.web.js +27 -26
- package/build/workers/InlineFeaturesExtractor.web.js.map +1 -1
- package/build/workers/inlineAudioWebWorker.web.d.ts +1 -1
- package/build/workers/inlineAudioWebWorker.web.d.ts.map +1 -1
- package/build/workers/inlineAudioWebWorker.web.js +25 -1
- package/build/workers/inlineAudioWebWorker.web.js.map +1 -1
- package/ios/AudioDeviceManager.swift +654 -0
- package/ios/AudioStreamManager.swift +964 -760
- package/ios/ExpoAudioStreamModule.swift +174 -19
- package/ios/Features.swift +1 -1
- package/ios/ISSUE_IOS.md +45 -0
- package/ios/Logger.swift +13 -1
- package/ios/RecordingSettings.swift +12 -0
- package/package.json +2 -2
- package/src/AudioAnalysis/AudioAnalysis.types.ts +2 -2
- package/src/AudioDeviceManager.ts +571 -0
- package/src/AudioRecorder.provider.tsx +3 -0
- package/src/ExpoAudioStream.types.ts +97 -1
- package/src/ExpoAudioStream.web.ts +513 -63
- package/src/ExpoAudioStreamModule.ts +23 -0
- package/src/WebRecorder.web.ts +346 -81
- package/src/hooks/useAudioDevices.ts +180 -0
- package/src/index.ts +6 -0
- package/src/types/crc-32.d.ts +6 -6
- package/src/useAudioRecorder.tsx +27 -1
- package/src/utils/BlobFix.ts +6 -4
- package/src/workers/InlineFeaturesExtractor.web.tsx +27 -26
- package/src/workers/inlineAudioWebWorker.web.tsx +25 -1
|
@@ -30,18 +30,26 @@ extension UInt16 {
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
|
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
|
-
///
|
|
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
|
|
601
|
-
func
|
|
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
|
|
605
|
-
delegate?.audioStreamManager(self, didFailWithError: "Cannot
|
|
606
|
-
return
|
|
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
|
-
//
|
|
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
|
|
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 =
|
|
643
|
-
lastEmissionTimeAnalysis =
|
|
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
|
|
657
|
-
|
|
658
|
-
|
|
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
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
""
|
|
710
|
-
|
|
711
|
-
//
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
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
|
|
744
|
-
|
|
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
|
-
//
|
|
756
|
-
|
|
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
|
|
759
|
-
|
|
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
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
if
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
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
|
-
}
|
|
814
|
-
Logger.debug("Failed to
|
|
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
|
|
848
|
+
return false
|
|
823
849
|
}
|
|
824
850
|
|
|
825
851
|
NotificationCenter.default.addObserver(self, selector: #selector(handleAudioSessionInterruption), name: AVAudioSession.interruptionNotification, object: nil)
|
|
826
852
|
|
|
827
|
-
|
|
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
|
|
916
|
+
totalPausedDuration = 0
|
|
866
917
|
currentPauseStart = nil
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
try audioEngine.start()
|
|
918
|
+
lastEmissionTime = Date()
|
|
919
|
+
lastEmissionTimeAnalysis = Date()
|
|
870
920
|
isRecording = true
|
|
871
921
|
isPaused = false
|
|
872
|
-
|
|
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:
|
|
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
|
|
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
|
-
///
|
|
905
|
-
|
|
906
|
-
|
|
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
|
-
|
|
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
|
-
|
|
913
|
-
audioEngine.
|
|
914
|
-
isPaused = true
|
|
975
|
+
// Remove input tap
|
|
976
|
+
audioEngine.inputNode.removeTap(onBus: 0)
|
|
915
977
|
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
delegate?.audioStreamManager(self, didUpdateNotificationState: true)
|
|
978
|
+
// Stop compressed recorder if created but not started
|
|
979
|
+
compressedRecorder?.stop()
|
|
980
|
+
compressedRecorder = nil
|
|
920
981
|
|
|
921
|
-
//
|
|
922
|
-
|
|
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
|
-
|
|
926
|
-
|
|
1013
|
+
|
|
1014
|
+
/// Pauses the current audio recording.
|
|
1015
|
+
func pauseRecording() {
|
|
1016
|
+
guard isRecording, !isPaused else { return }
|
|
927
1017
|
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
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
|
-
|
|
1026
|
+
// Create a copy of accumulated data to avoid race conditions
|
|
1027
|
+
let finalData = accumulatedData
|
|
1028
|
+
accumulatedData.removeAll()
|
|
933
1029
|
|
|
934
|
-
//
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
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
|
|
974
|
-
|
|
975
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
992
|
-
|
|
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
|
-
|
|
1007
|
-
|
|
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
|
-
|
|
1012
|
-
compressedRecorder?.record()
|
|
1101
|
+
Logger.debug("Recording resumed")
|
|
1013
1102
|
|
|
1014
1103
|
} catch {
|
|
1015
|
-
Logger.debug("
|
|
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
|
-
///
|
|
1048
|
-
|
|
1049
|
-
|
|
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
|
-
|
|
1113
|
+
// Create notification manager
|
|
1114
|
+
notificationManager = AudioNotificationManager()
|
|
1115
|
+
notificationManager?.initialize(with: settings.notification)
|
|
1053
1116
|
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
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
|
-
|
|
1059
|
-
|
|
1125
|
+
NotificationCenter.default.addObserver(
|
|
1126
|
+
self,
|
|
1127
|
+
selector: #selector(handleResumeNotification),
|
|
1128
|
+
name: Notification.Name("RESUME_RECORDING"),
|
|
1129
|
+
object: nil
|
|
1130
|
+
)
|
|
1060
1131
|
|
|
1061
|
-
//
|
|
1062
|
-
|
|
1132
|
+
// Setup media controls (iOS control center) if enabled
|
|
1133
|
+
setupNowPlayingInfo()
|
|
1063
1134
|
|
|
1064
|
-
|
|
1065
|
-
|
|
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
|
-
///
|
|
1141
|
+
/// Resample an audio buffer to a different sample rate.
|
|
1213
1142
|
/// - Parameters:
|
|
1214
|
-
/// - buffer: The
|
|
1215
|
-
/// -
|
|
1216
|
-
/// -
|
|
1217
|
-
/// - Returns:
|
|
1218
|
-
private func resampleAudioBuffer(_ buffer: AVAudioPCMBuffer, from
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
return
|
|
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
|
-
|
|
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:
|
|
1240
|
-
sampleRate:
|
|
1241
|
-
channels:
|
|
1242
|
-
interleaved:
|
|
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 =
|
|
1250
|
-
let
|
|
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:
|
|
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
|
-
//
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
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
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
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
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1452
|
-
|
|
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
|
-
//
|
|
1459
|
-
|
|
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
|
-
|
|
1298
|
+
bufferToProcess = resampled
|
|
1463
1299
|
} else {
|
|
1464
|
-
Logger.debug("Resampling
|
|
1300
|
+
Logger.debug("processAudioBuffer: Resampling FAILED")
|
|
1465
1301
|
return
|
|
1466
1302
|
}
|
|
1467
|
-
} else {
|
|
1468
|
-
resampledBuffer = buffer
|
|
1469
1303
|
}
|
|
1470
|
-
|
|
1471
|
-
//
|
|
1472
|
-
|
|
1473
|
-
|
|
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:
|
|
1479
|
-
)
|
|
1480
|
-
Logger.debug("
|
|
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
|
-
|
|
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
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
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
|
-
//
|
|
1503
|
-
|
|
1504
|
-
|
|
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
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
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:
|
|
1375
|
+
didReceiveAudioData: dataToEmit,
|
|
1580
1376
|
recordingTime: recordingTime,
|
|
1581
|
-
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
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
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
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
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
|
-
|
|
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 {
|