@siteed/audio-studio 3.0.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 +535 -0
- package/LICENSE +21 -0
- package/README.md +167 -0
- package/android/build.gradle +143 -0
- package/android/src/androidTest/assets/chorus.wav +0 -0
- package/android/src/androidTest/assets/jfk.wav +0 -0
- package/android/src/androidTest/assets/osr_us_000_0010_8k.wav +0 -0
- package/android/src/androidTest/assets/recorder_hello_world.wav +0 -0
- package/android/src/androidTest/java/net/siteed/audiostudio/AudioProcessorInstrumentedTest.kt +197 -0
- package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderInstrumentedTest.kt +541 -0
- package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderPerformanceInstrumentedTest.kt +234 -0
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/AudioFocusStrategyIntegrationTest.kt +332 -0
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/BufferDurationIntegrationTest.kt +324 -0
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/CompressedOnlyOutputTest.kt +253 -0
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/DeviceDisconnectionFallbackTest.kt +218 -0
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/EventEmissionIntervalTest.kt +120 -0
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/M4aFormatTest.kt +345 -0
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/OutputControlIntegrationTest.kt +340 -0
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/PcmStreamingDurationTest.kt +252 -0
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/README.md +95 -0
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/run_integration_tests.sh +43 -0
- package/android/src/main/AndroidManifest.xml +30 -0
- package/android/src/main/CMakeLists.txt +29 -0
- package/android/src/main/java/net/siteed/audiostudio/AudioAnalysisData.kt +188 -0
- package/android/src/main/java/net/siteed/audiostudio/AudioDataEncoder.kt +9 -0
- package/android/src/main/java/net/siteed/audiostudio/AudioDeviceManager.kt +1741 -0
- package/android/src/main/java/net/siteed/audiostudio/AudioFeaturesNative.kt +26 -0
- package/android/src/main/java/net/siteed/audiostudio/AudioFileHandler.kt +136 -0
- package/android/src/main/java/net/siteed/audiostudio/AudioFormatUtils.kt +354 -0
- package/android/src/main/java/net/siteed/audiostudio/AudioNotificationsManager.kt +439 -0
- package/android/src/main/java/net/siteed/audiostudio/AudioProcessor.kt +2237 -0
- package/android/src/main/java/net/siteed/audiostudio/AudioRecorderManager.kt +2163 -0
- package/android/src/main/java/net/siteed/audiostudio/AudioRecordingService.kt +167 -0
- package/android/src/main/java/net/siteed/audiostudio/AudioStudioModule.kt +1112 -0
- package/android/src/main/java/net/siteed/audiostudio/AudioTrimmer.kt +1099 -0
- package/android/src/main/java/net/siteed/audiostudio/Constants.kt +37 -0
- package/android/src/main/java/net/siteed/audiostudio/EventSender.kt +7 -0
- package/android/src/main/java/net/siteed/audiostudio/FFT.kt +100 -0
- package/android/src/main/java/net/siteed/audiostudio/Features.kt +98 -0
- package/android/src/main/java/net/siteed/audiostudio/LogUtils.kt +93 -0
- package/android/src/main/java/net/siteed/audiostudio/MelSpectrogramNative.kt +36 -0
- package/android/src/main/java/net/siteed/audiostudio/NotificationConfig.kt +72 -0
- package/android/src/main/java/net/siteed/audiostudio/PermissionUtils.kt +68 -0
- package/android/src/main/java/net/siteed/audiostudio/RecordingActionReceiver.kt +59 -0
- package/android/src/main/java/net/siteed/audiostudio/RecordingConfig.kt +259 -0
- package/android/src/main/java/net/siteed/audiostudio/WaveformConfig.kt +19 -0
- package/android/src/main/java/net/siteed/audiostudio/WaveformRenderer.kt +159 -0
- package/android/src/main/jni/AudioFeaturesJNI.cpp +152 -0
- package/android/src/main/jni/MelSpectrogramJNI.cpp +165 -0
- package/android/src/main/res/drawable/ic_default_action_icon.xml +16 -0
- package/android/src/main/res/drawable/ic_microphone.xml +13 -0
- package/android/src/main/res/drawable/ic_pause.xml +10 -0
- package/android/src/main/res/drawable/ic_play.xml +10 -0
- package/android/src/main/res/drawable/ic_stop.xml +10 -0
- package/android/src/main/res/layout/notification_recording.xml +37 -0
- package/android/src/test/java/net/siteed/audiostudio/AudioFileHandlerTest.kt +279 -0
- package/android/src/test/java/net/siteed/audiostudio/AudioFocusStrategyTest.kt +249 -0
- package/android/src/test/java/net/siteed/audiostudio/AudioFormatTest.kt +151 -0
- package/android/src/test/java/net/siteed/audiostudio/AudioFormatUtilsTest.kt +273 -0
- package/android/src/test/java/net/siteed/audiostudio/DeviceDisconnectionFallbackUnitTest.kt +140 -0
- package/android/src/test/resources/chorus.wav +0 -0
- package/android/src/test/resources/generate_test_audio.py +94 -0
- package/android/src/test/resources/jfk.wav +0 -0
- package/android/src/test/resources/osr_us_000_0010_8k.wav +0 -0
- package/android/src/test/resources/recorder_hello_world.wav +0 -0
- package/app.plugin.js +3 -0
- package/build/cjs/AudioAnalysis/AudioAnalysis.types.js +4 -0
- package/build/cjs/AudioAnalysis/AudioAnalysis.types.js.map +1 -0
- package/build/cjs/AudioAnalysis/audioFeaturesWasm.js +164 -0
- package/build/cjs/AudioAnalysis/audioFeaturesWasm.js.map +1 -0
- package/build/cjs/AudioAnalysis/extractAudioAnalysis.js +213 -0
- package/build/cjs/AudioAnalysis/extractAudioAnalysis.js.map +1 -0
- package/build/cjs/AudioAnalysis/extractAudioData.js +21 -0
- package/build/cjs/AudioAnalysis/extractAudioData.js.map +1 -0
- package/build/cjs/AudioAnalysis/extractMelSpectrogram.js +90 -0
- package/build/cjs/AudioAnalysis/extractMelSpectrogram.js.map +1 -0
- package/build/cjs/AudioAnalysis/extractPreview.js +28 -0
- package/build/cjs/AudioAnalysis/extractPreview.js.map +1 -0
- package/build/cjs/AudioAnalysis/extractWaveform.js +18 -0
- package/build/cjs/AudioAnalysis/extractWaveform.js.map +1 -0
- package/build/cjs/AudioAnalysis/melSpectrogramWasm.js +149 -0
- package/build/cjs/AudioAnalysis/melSpectrogramWasm.js.map +1 -0
- package/build/cjs/AudioDeviceManager.js +688 -0
- package/build/cjs/AudioDeviceManager.js.map +1 -0
- package/build/cjs/AudioRecorder.provider.js +78 -0
- package/build/cjs/AudioRecorder.provider.js.map +1 -0
- package/build/cjs/AudioStudio.native.js +8 -0
- package/build/cjs/AudioStudio.native.js.map +1 -0
- package/build/cjs/AudioStudio.types.js +11 -0
- package/build/cjs/AudioStudio.types.js.map +1 -0
- package/build/cjs/AudioStudio.web.js +708 -0
- package/build/cjs/AudioStudio.web.js.map +1 -0
- package/build/cjs/AudioStudioModule.js +718 -0
- package/build/cjs/AudioStudioModule.js.map +1 -0
- package/build/cjs/WebRecorder.web.js +865 -0
- package/build/cjs/WebRecorder.web.js.map +1 -0
- package/build/cjs/constants/platformLimitations.js +99 -0
- package/build/cjs/constants/platformLimitations.js.map +1 -0
- package/build/cjs/constants.js +20 -0
- package/build/cjs/constants.js.map +1 -0
- package/build/cjs/events.js +29 -0
- package/build/cjs/events.js.map +1 -0
- package/build/cjs/hooks/useAudioDevices.js +179 -0
- package/build/cjs/hooks/useAudioDevices.js.map +1 -0
- package/build/cjs/index.js +64 -0
- package/build/cjs/index.js.map +1 -0
- package/build/cjs/trimAudio.js +76 -0
- package/build/cjs/trimAudio.js.map +1 -0
- package/build/cjs/useAudioRecorder.js +535 -0
- package/build/cjs/useAudioRecorder.js.map +1 -0
- package/build/cjs/utils/BlobFix.js +502 -0
- package/build/cjs/utils/BlobFix.js.map +1 -0
- package/build/cjs/utils/audioProcessing.js +136 -0
- package/build/cjs/utils/audioProcessing.js.map +1 -0
- package/build/cjs/utils/cleanNativeOptions.js +22 -0
- package/build/cjs/utils/cleanNativeOptions.js.map +1 -0
- package/build/cjs/utils/concatenateBuffers.js +25 -0
- package/build/cjs/utils/concatenateBuffers.js.map +1 -0
- package/build/cjs/utils/convertPCMToFloat32.js +124 -0
- package/build/cjs/utils/convertPCMToFloat32.js.map +1 -0
- package/build/cjs/utils/crc32.js +52 -0
- package/build/cjs/utils/crc32.js.map +1 -0
- package/build/cjs/utils/encodingToBitDepth.js +17 -0
- package/build/cjs/utils/encodingToBitDepth.js.map +1 -0
- package/build/cjs/utils/getWavFileInfo.js +96 -0
- package/build/cjs/utils/getWavFileInfo.js.map +1 -0
- package/build/cjs/utils/writeWavHeader.js +88 -0
- package/build/cjs/utils/writeWavHeader.js.map +1 -0
- package/build/cjs/workers/InlineFeaturesExtractor.web.js +294 -0
- package/build/cjs/workers/InlineFeaturesExtractor.web.js.map +1 -0
- package/build/cjs/workers/inlineAudioWebWorker.web.js +190 -0
- package/build/cjs/workers/inlineAudioWebWorker.web.js.map +1 -0
- package/build/cjs/workers/wasmGlueString.web.js +27 -0
- package/build/cjs/workers/wasmGlueString.web.js.map +1 -0
- package/build/esm/AudioAnalysis/AudioAnalysis.types.js +3 -0
- package/build/esm/AudioAnalysis/AudioAnalysis.types.js.map +1 -0
- package/build/esm/AudioAnalysis/audioFeaturesWasm.js +126 -0
- package/build/esm/AudioAnalysis/audioFeaturesWasm.js.map +1 -0
- package/build/esm/AudioAnalysis/extractAudioAnalysis.js +205 -0
- package/build/esm/AudioAnalysis/extractAudioAnalysis.js.map +1 -0
- package/build/esm/AudioAnalysis/extractAudioData.js +14 -0
- package/build/esm/AudioAnalysis/extractAudioData.js.map +1 -0
- package/build/esm/AudioAnalysis/extractMelSpectrogram.js +86 -0
- package/build/esm/AudioAnalysis/extractMelSpectrogram.js.map +1 -0
- package/build/esm/AudioAnalysis/extractPreview.js +25 -0
- package/build/esm/AudioAnalysis/extractPreview.js.map +1 -0
- package/build/esm/AudioAnalysis/extractWaveform.js +11 -0
- package/build/esm/AudioAnalysis/extractWaveform.js.map +1 -0
- package/build/esm/AudioAnalysis/melSpectrogramWasm.js +111 -0
- package/build/esm/AudioAnalysis/melSpectrogramWasm.js.map +1 -0
- package/build/esm/AudioDeviceManager.js +681 -0
- package/build/esm/AudioDeviceManager.js.map +1 -0
- package/build/esm/AudioRecorder.provider.js +40 -0
- package/build/esm/AudioRecorder.provider.js.map +1 -0
- package/build/esm/AudioStudio.native.js +6 -0
- package/build/esm/AudioStudio.native.js.map +1 -0
- package/build/esm/AudioStudio.types.js +8 -0
- package/build/esm/AudioStudio.types.js.map +1 -0
- package/build/esm/AudioStudio.web.js +704 -0
- package/build/esm/AudioStudio.web.js.map +1 -0
- package/build/esm/AudioStudioModule.js +713 -0
- package/build/esm/AudioStudioModule.js.map +1 -0
- package/build/esm/WebRecorder.web.js +861 -0
- package/build/esm/WebRecorder.web.js.map +1 -0
- package/build/esm/constants/platformLimitations.js +90 -0
- package/build/esm/constants/platformLimitations.js.map +1 -0
- package/build/esm/constants.js +17 -0
- package/build/esm/constants.js.map +1 -0
- package/build/esm/events.js +21 -0
- package/build/esm/events.js.map +1 -0
- package/build/esm/hooks/useAudioDevices.js +176 -0
- package/build/esm/hooks/useAudioDevices.js.map +1 -0
- package/build/esm/index.js +23 -0
- package/build/esm/index.js.map +1 -0
- package/build/esm/trimAudio.js +69 -0
- package/build/esm/trimAudio.js.map +1 -0
- package/build/esm/useAudioRecorder.js +529 -0
- package/build/esm/useAudioRecorder.js.map +1 -0
- package/build/esm/utils/BlobFix.js +498 -0
- package/build/esm/utils/BlobFix.js.map +1 -0
- package/build/esm/utils/audioProcessing.js +133 -0
- package/build/esm/utils/audioProcessing.js.map +1 -0
- package/build/esm/utils/cleanNativeOptions.js +19 -0
- package/build/esm/utils/cleanNativeOptions.js.map +1 -0
- package/build/esm/utils/concatenateBuffers.js +21 -0
- package/build/esm/utils/concatenateBuffers.js.map +1 -0
- package/build/esm/utils/convertPCMToFloat32.js +120 -0
- package/build/esm/utils/convertPCMToFloat32.js.map +1 -0
- package/build/esm/utils/crc32.js +50 -0
- package/build/esm/utils/crc32.js.map +1 -0
- package/build/esm/utils/encodingToBitDepth.js +13 -0
- package/build/esm/utils/encodingToBitDepth.js.map +1 -0
- package/build/esm/utils/getWavFileInfo.js +92 -0
- package/build/esm/utils/getWavFileInfo.js.map +1 -0
- package/build/esm/utils/writeWavHeader.js +84 -0
- package/build/esm/utils/writeWavHeader.js.map +1 -0
- package/build/esm/workers/InlineFeaturesExtractor.web.js +291 -0
- package/build/esm/workers/InlineFeaturesExtractor.web.js.map +1 -0
- package/build/esm/workers/inlineAudioWebWorker.web.js +187 -0
- package/build/esm/workers/inlineAudioWebWorker.web.js.map +1 -0
- package/build/esm/workers/wasmGlueString.web.js +24 -0
- package/build/esm/workers/wasmGlueString.web.js.map +1 -0
- package/build/types/AudioAnalysis/AudioAnalysis.types.d.ts +198 -0
- package/build/types/AudioAnalysis/AudioAnalysis.types.d.ts.map +1 -0
- package/build/types/AudioAnalysis/audioFeaturesWasm.d.ts +24 -0
- package/build/types/AudioAnalysis/audioFeaturesWasm.d.ts.map +1 -0
- package/build/types/AudioAnalysis/extractAudioAnalysis.d.ts +74 -0
- package/build/types/AudioAnalysis/extractAudioAnalysis.d.ts.map +1 -0
- package/build/types/AudioAnalysis/extractAudioData.d.ts +3 -0
- package/build/types/AudioAnalysis/extractAudioData.d.ts.map +1 -0
- package/build/types/AudioAnalysis/extractMelSpectrogram.d.ts +20 -0
- package/build/types/AudioAnalysis/extractMelSpectrogram.d.ts.map +1 -0
- package/build/types/AudioAnalysis/extractPreview.d.ts +11 -0
- package/build/types/AudioAnalysis/extractPreview.d.ts.map +1 -0
- package/build/types/AudioAnalysis/extractWaveform.d.ts +8 -0
- package/build/types/AudioAnalysis/extractWaveform.d.ts.map +1 -0
- package/build/types/AudioAnalysis/melSpectrogramWasm.d.ts +16 -0
- package/build/types/AudioAnalysis/melSpectrogramWasm.d.ts.map +1 -0
- package/build/types/AudioDeviceManager.d.ts +187 -0
- package/build/types/AudioDeviceManager.d.ts.map +1 -0
- package/build/types/AudioRecorder.provider.d.ts +11 -0
- package/build/types/AudioRecorder.provider.d.ts.map +1 -0
- package/build/types/AudioStudio.native.d.ts +3 -0
- package/build/types/AudioStudio.native.d.ts.map +1 -0
- package/build/types/AudioStudio.types.d.ts +760 -0
- package/build/types/AudioStudio.types.d.ts.map +1 -0
- package/build/types/AudioStudio.web.d.ts +96 -0
- package/build/types/AudioStudio.web.d.ts.map +1 -0
- package/build/types/AudioStudioModule.d.ts +3 -0
- package/build/types/AudioStudioModule.d.ts.map +1 -0
- package/build/types/WebRecorder.web.d.ts +208 -0
- package/build/types/WebRecorder.web.d.ts.map +1 -0
- package/build/types/constants/platformLimitations.d.ts +40 -0
- package/build/types/constants/platformLimitations.d.ts.map +1 -0
- package/build/types/constants.d.ts +14 -0
- package/build/types/constants.d.ts.map +1 -0
- package/build/types/events.d.ts +29 -0
- package/build/types/events.d.ts.map +1 -0
- package/build/types/hooks/useAudioDevices.d.ts +15 -0
- package/build/types/hooks/useAudioDevices.d.ts.map +1 -0
- package/build/types/index.d.ts +21 -0
- package/build/types/index.d.ts.map +1 -0
- package/build/types/trimAudio.d.ts +25 -0
- package/build/types/trimAudio.d.ts.map +1 -0
- package/build/types/useAudioRecorder.d.ts +22 -0
- package/build/types/useAudioRecorder.d.ts.map +1 -0
- package/build/types/utils/BlobFix.d.ts +9 -0
- package/build/types/utils/BlobFix.d.ts.map +1 -0
- package/build/types/utils/audioProcessing.d.ts +24 -0
- package/build/types/utils/audioProcessing.d.ts.map +1 -0
- package/build/types/utils/cleanNativeOptions.d.ts +15 -0
- package/build/types/utils/cleanNativeOptions.d.ts.map +1 -0
- package/build/types/utils/concatenateBuffers.d.ts +8 -0
- package/build/types/utils/concatenateBuffers.d.ts.map +1 -0
- package/build/types/utils/convertPCMToFloat32.d.ts +13 -0
- package/build/types/utils/convertPCMToFloat32.d.ts.map +1 -0
- package/build/types/utils/crc32.d.ts +7 -0
- package/build/types/utils/crc32.d.ts.map +1 -0
- package/build/types/utils/encodingToBitDepth.d.ts +5 -0
- package/build/types/utils/encodingToBitDepth.d.ts.map +1 -0
- package/build/types/utils/getWavFileInfo.d.ts +26 -0
- package/build/types/utils/getWavFileInfo.d.ts.map +1 -0
- package/build/types/utils/writeWavHeader.d.ts +34 -0
- package/build/types/utils/writeWavHeader.d.ts.map +1 -0
- package/build/types/workers/InlineFeaturesExtractor.web.d.ts +2 -0
- package/build/types/workers/InlineFeaturesExtractor.web.d.ts.map +1 -0
- package/build/types/workers/inlineAudioWebWorker.web.d.ts +2 -0
- package/build/types/workers/inlineAudioWebWorker.web.d.ts.map +1 -0
- package/build/types/workers/wasmGlueString.web.d.ts +2 -0
- package/build/types/workers/wasmGlueString.web.d.ts.map +1 -0
- package/cpp/AudioFeatures.cpp +274 -0
- package/cpp/AudioFeatures.h +85 -0
- package/cpp/AudioFeaturesBridge.cpp +146 -0
- package/cpp/AudioFeaturesBridge.h +47 -0
- package/cpp/MelSpectrogram.cpp +227 -0
- package/cpp/MelSpectrogram.h +82 -0
- package/cpp/MelSpectrogramBridge.cpp +112 -0
- package/cpp/MelSpectrogramBridge.h +33 -0
- package/cpp/kiss_fft/COPYING +11 -0
- package/cpp/kiss_fft/_kiss_fft_guts.h +167 -0
- package/cpp/kiss_fft/kiss_fft.c +424 -0
- package/cpp/kiss_fft/kiss_fft.h +160 -0
- package/cpp/kiss_fft/kiss_fft_log.h +36 -0
- package/cpp/kiss_fft/kiss_fftr.c +155 -0
- package/cpp/kiss_fft/kiss_fftr.h +54 -0
- package/expo-module.config.json +10 -0
- package/ios/AudioAnalysisData.swift +74 -0
- package/ios/AudioDeviceManager.swift +670 -0
- package/ios/AudioFeaturesWrapper.h +21 -0
- package/ios/AudioFeaturesWrapper.mm +63 -0
- package/ios/AudioNotificationManager.swift +154 -0
- package/ios/AudioProcessingHelpers.swift +797 -0
- package/ios/AudioProcessor.swift +1191 -0
- package/ios/AudioStreamError.swift +7 -0
- package/ios/AudioStreamManager.swift +2369 -0
- package/ios/AudioStreamManagerDelegate.swift +16 -0
- package/ios/AudioStudio.podspec +39 -0
- package/ios/AudioStudioModule.swift +1111 -0
- package/ios/AudioStudioTests/AudioFileHandlerTests.swift +338 -0
- package/ios/AudioStudioTests/AudioFormatUtilsTests.swift +331 -0
- package/ios/AudioStudioTests/AudioTestHelpers.swift +130 -0
- package/ios/AudioStudioTests/CompressedOnlyOutputTests.swift +294 -0
- package/ios/AudioStudioTests/EventEmissionIntervalTests.swift +105 -0
- package/ios/AudioStudioTests/Info.plist +22 -0
- package/ios/AudioStudioTests/README.md +39 -0
- package/ios/AudioStudioTests/SimpleAudioTest.swift +98 -0
- package/ios/AudioStudioTests/TestAudioGenerator.swift +75 -0
- package/ios/DataPoint.swift +54 -0
- package/ios/DecodingConfig.swift +59 -0
- package/ios/FFT.swift +62 -0
- package/ios/Features.swift +95 -0
- package/ios/ISSUE_IOS.md +68 -0
- package/ios/Logger.swift +39 -0
- package/ios/MelSpectrogramWrapper.h +30 -0
- package/ios/MelSpectrogramWrapper.mm +97 -0
- package/ios/NotificationExtension.swift +15 -0
- package/ios/RecordingResult.swift +22 -0
- package/ios/RecordingSettings.swift +311 -0
- package/ios/WaveformExtractor.swift +105 -0
- package/ios/tests/README.md +41 -0
- package/ios/tests/integration/buffer_and_fallback_test.swift +178 -0
- package/ios/tests/integration/buffer_duration_test.swift +185 -0
- package/ios/tests/integration/compressed_only_output_test.swift +271 -0
- package/ios/tests/integration/output_control_test.swift +322 -0
- package/ios/tests/integration/run_integration_tests.sh +37 -0
- package/ios/tests/opus_support_test_macos.swift +154 -0
- package/ios/tests/standalone/audio_processing_test.swift +144 -0
- package/ios/tests/standalone/audio_recording_test.swift +277 -0
- package/ios/tests/standalone/audio_streaming_test.swift +249 -0
- package/ios/tests/standalone/standalone_test.swift +144 -0
- package/package.json +146 -0
- package/plugin/build/index.cjs +194 -0
- package/plugin/build/index.d.cts +22 -0
- package/plugin/build/index.js +194 -0
- package/plugin/src/index.ts +285 -0
- package/plugin/tsconfig.json +10 -0
- package/plugin/tsconfig.tsbuildinfo +1 -0
- package/prebuilt/wasm/mel-spectrogram.js +18 -0
- package/src/AudioAnalysis/AudioAnalysis.types.ts +226 -0
- package/src/AudioAnalysis/audio-features-wasm.d.ts +37 -0
- package/src/AudioAnalysis/audioFeaturesWasm.ts +200 -0
- package/src/AudioAnalysis/extractAudioAnalysis.ts +350 -0
- package/src/AudioAnalysis/extractAudioData.ts +17 -0
- package/src/AudioAnalysis/extractMelSpectrogram.ts +140 -0
- package/src/AudioAnalysis/extractPreview.ts +34 -0
- package/src/AudioAnalysis/extractWaveform.ts +22 -0
- package/src/AudioAnalysis/mel-spectrogram-wasm.d.ts +48 -0
- package/src/AudioAnalysis/melSpectrogramWasm.ts +179 -0
- package/src/AudioDeviceManager.ts +800 -0
- package/src/AudioRecorder.provider.tsx +57 -0
- package/src/AudioStudio.native.ts +6 -0
- package/src/AudioStudio.types.ts +899 -0
- package/src/AudioStudio.web.ts +911 -0
- package/src/AudioStudioModule.ts +984 -0
- package/src/WebRecorder.web.ts +1114 -0
- package/src/constants/platformLimitations.ts +118 -0
- package/src/constants.ts +21 -0
- package/src/events.ts +63 -0
- package/src/hooks/useAudioDevices.ts +213 -0
- package/src/index.ts +67 -0
- package/src/trimAudio.ts +94 -0
- package/src/types/crc-32.d.ts +9 -0
- package/src/useAudioRecorder.tsx +784 -0
- package/src/utils/BlobFix.ts +561 -0
- package/src/utils/audioProcessing.ts +205 -0
- package/src/utils/cleanNativeOptions.ts +18 -0
- package/src/utils/concatenateBuffers.ts +24 -0
- package/src/utils/convertPCMToFloat32.ts +170 -0
- package/src/utils/crc32.ts +59 -0
- package/src/utils/encodingToBitDepth.ts +18 -0
- package/src/utils/getWavFileInfo.ts +132 -0
- package/src/utils/writeWavHeader.ts +115 -0
- package/src/workers/InlineFeaturesExtractor.web.tsx +291 -0
- package/src/workers/inlineAudioWebWorker.web.tsx +186 -0
- package/src/workers/wasmGlueString.web.ts +23 -0
|
@@ -0,0 +1,2369 @@
|
|
|
1
|
+
//
|
|
2
|
+
// AudioStreamManager.swift
|
|
3
|
+
// AudioStudio
|
|
4
|
+
//
|
|
5
|
+
// Created by Arthur Breton on 21/4/2024.
|
|
6
|
+
//
|
|
7
|
+
|
|
8
|
+
import Foundation
|
|
9
|
+
import AVFoundation
|
|
10
|
+
import Accelerate
|
|
11
|
+
import UIKit
|
|
12
|
+
import MediaPlayer
|
|
13
|
+
import UserNotifications
|
|
14
|
+
|
|
15
|
+
// Constants
|
|
16
|
+
internal let WAV_HEADER_SIZE: Int64 = 44 // Standard WAV header is 44 bytes
|
|
17
|
+
|
|
18
|
+
// Helper to convert to little-endian byte array
|
|
19
|
+
extension UInt32 {
|
|
20
|
+
var littleEndianBytes: [UInt8] {
|
|
21
|
+
let value = self.littleEndian
|
|
22
|
+
return [UInt8(value & 0xff), UInt8((value >> 8) & 0xff), UInt8((value >> 16) & 0xff), UInt8((value >> 24) & 0xff)]
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
extension UInt16 {
|
|
27
|
+
var littleEndianBytes: [UInt8] {
|
|
28
|
+
let value = self.littleEndian
|
|
29
|
+
return [UInt8(value & 0xff), UInt8((value >> 8) & 0xff)]
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Define DeviceDisconnectionBehavior enum mirroring AudioStudio.types.ts
|
|
34
|
+
enum DeviceDisconnectionBehavior: String {
|
|
35
|
+
case PAUSE = "pause"
|
|
36
|
+
case FALLBACK = "fallback"
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
|
|
40
|
+
private let audioEngine = AVAudioEngine()
|
|
41
|
+
private var inputNode: AVAudioInputNode {
|
|
42
|
+
return audioEngine.inputNode
|
|
43
|
+
}
|
|
44
|
+
internal var recordingFileURL: URL?
|
|
45
|
+
private var audioProcessor: AudioProcessor?
|
|
46
|
+
private var fileHandle: FileHandle?
|
|
47
|
+
private var startTime: Date?
|
|
48
|
+
private var totalPausedDuration: TimeInterval = 0 // Track total paused time
|
|
49
|
+
private var currentPauseStart: Date? // Track current pause start
|
|
50
|
+
var isRecording = false
|
|
51
|
+
var isPaused = false
|
|
52
|
+
var isPrepared = false // Add this new state flag
|
|
53
|
+
|
|
54
|
+
// Move static variables to class level
|
|
55
|
+
private var debugBufferCounter = 0
|
|
56
|
+
private var tapCallCount = 0
|
|
57
|
+
|
|
58
|
+
// Wake lock related properties
|
|
59
|
+
private var wasIdleTimerDisabled: Bool = false // Track previous idle timer state
|
|
60
|
+
private var isWakeLockEnabled: Bool = false // Track current wake lock state
|
|
61
|
+
|
|
62
|
+
// Data emission for onAudioStream
|
|
63
|
+
internal var lastEmissionTime: Date?
|
|
64
|
+
internal var lastEmittedSize: Int64 = 0
|
|
65
|
+
internal var lastEmittedCompressedSize: Int64 = 0
|
|
66
|
+
private var totalDataSize: Int64 = 0
|
|
67
|
+
private var lastBufferTime: AVAudioTime?
|
|
68
|
+
private var accumulatedData = Data()
|
|
69
|
+
|
|
70
|
+
// Data emission for onAudioAnalysis
|
|
71
|
+
internal var lastEmissionTimeAnalysis: Date?
|
|
72
|
+
internal var lastEmittedSizeAnalysis: Int64 = 0
|
|
73
|
+
internal var lastEmittedCompressedSizeAnalysis: Int64 = 0
|
|
74
|
+
private var totalDataSizeAnalysis: Int64 = 0
|
|
75
|
+
private var lastBufferTimeAnalysis: AVAudioTime?
|
|
76
|
+
private var accumulatedAnalysisData = Data()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
private var fileManager = FileManager.default
|
|
81
|
+
internal var recordingSettings: RecordingSettings?
|
|
82
|
+
internal var recordingUUID: UUID?
|
|
83
|
+
internal var mimeType: String = "audio/wav"
|
|
84
|
+
private var recentData = [Float]() // This property stores the recent audio data
|
|
85
|
+
private var notificationUpdateTimer: Timer?
|
|
86
|
+
|
|
87
|
+
private var notificationManager: AudioNotificationManager?
|
|
88
|
+
private var notificationView: MPNowPlayingInfoCenter?
|
|
89
|
+
private var audioSession: AVAudioSession?
|
|
90
|
+
private var notificationObserver: Any?
|
|
91
|
+
private var mediaInfoUpdateTimer: Timer?
|
|
92
|
+
private var remoteCommandCenter: MPRemoteCommandCenter?
|
|
93
|
+
|
|
94
|
+
weak var delegate: AudioStreamManagerDelegate? // Define the delegate here
|
|
95
|
+
|
|
96
|
+
private var lastValidDuration: TimeInterval? // Add this property
|
|
97
|
+
|
|
98
|
+
private var compressedRecorder: AVAudioRecorder?
|
|
99
|
+
private var compressedFileURL: URL?
|
|
100
|
+
private var compressedFormat: String = "aac"
|
|
101
|
+
private var compressedBitRate: Int = 128000
|
|
102
|
+
|
|
103
|
+
// Add property to track auto-resume preference
|
|
104
|
+
private var autoResumeAfterInterruption: Bool = false
|
|
105
|
+
|
|
106
|
+
// Add these properties
|
|
107
|
+
private var emissionInterval: TimeInterval = 1.0 // Default 1 second
|
|
108
|
+
private var emissionIntervalAnalysis: TimeInterval = 0.5 // Default 0.5 seconds
|
|
109
|
+
|
|
110
|
+
// ---> ADD BACK deviceManager PROPERTY <---
|
|
111
|
+
private let deviceManager = AudioDeviceManager()
|
|
112
|
+
|
|
113
|
+
// Add the stopping flag to the class properties
|
|
114
|
+
private var stopping: Bool = false
|
|
115
|
+
|
|
116
|
+
// Performance optimization: Cache file sizes during recording
|
|
117
|
+
private var cachedWavFileSize: Int64 = 0
|
|
118
|
+
private var cachedCompressedFileSize: Int64 = 0
|
|
119
|
+
|
|
120
|
+
/// Initializes the AudioStreamManager
|
|
121
|
+
override init() {
|
|
122
|
+
super.init()
|
|
123
|
+
deviceManager.delegate = self // Set the delegate
|
|
124
|
+
// Only keep audio session interruption observer here
|
|
125
|
+
NotificationCenter.default.addObserver(
|
|
126
|
+
self,
|
|
127
|
+
selector: #selector(handleAudioSessionInterruption),
|
|
128
|
+
name: AVAudioSession.interruptionNotification,
|
|
129
|
+
object: nil
|
|
130
|
+
)
|
|
131
|
+
NotificationCenter.default.addObserver(
|
|
132
|
+
self,
|
|
133
|
+
selector: #selector(handleAppDidEnterBackground),
|
|
134
|
+
name: UIApplication.didEnterBackgroundNotification,
|
|
135
|
+
object: nil
|
|
136
|
+
)
|
|
137
|
+
NotificationCenter.default.addObserver(
|
|
138
|
+
self,
|
|
139
|
+
selector: #selector(handleAppWillEnterForeground),
|
|
140
|
+
name: UIApplication.willEnterForegroundNotification,
|
|
141
|
+
object: nil
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
deinit {
|
|
147
|
+
// Ensure wake lock is disabled when the manager is deallocated
|
|
148
|
+
disableWakeLock()
|
|
149
|
+
|
|
150
|
+
// Stop any active recording to properly release resources
|
|
151
|
+
if isRecording {
|
|
152
|
+
audioEngine.stop()
|
|
153
|
+
audioEngine.reset()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Remove ALL notification observers properly
|
|
157
|
+
NotificationCenter.default.removeObserver(self)
|
|
158
|
+
|
|
159
|
+
// Clean up notification manager
|
|
160
|
+
notificationManager?.stopUpdates()
|
|
161
|
+
notificationManager = nil
|
|
162
|
+
|
|
163
|
+
// Cleanup media timer
|
|
164
|
+
mediaInfoUpdateTimer?.invalidate()
|
|
165
|
+
mediaInfoUpdateTimer = nil
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/// Handles an audio session interruption.
|
|
169
|
+
@objc private func handleAudioSessionInterruption(_ notification: Notification) {
|
|
170
|
+
guard let userInfo = notification.userInfo,
|
|
171
|
+
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
|
|
172
|
+
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let wasSuspended = isPaused
|
|
177
|
+
|
|
178
|
+
switch type {
|
|
179
|
+
case .began:
|
|
180
|
+
Logger.debug("AudioStreamManager", "Audio session interruption began")
|
|
181
|
+
// Store the pause start time if not already paused
|
|
182
|
+
if !wasSuspended {
|
|
183
|
+
currentPauseStart = Date()
|
|
184
|
+
pauseRecording()
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Always notify delegate of interruption
|
|
188
|
+
delegate?.audioStreamManager(
|
|
189
|
+
self,
|
|
190
|
+
didReceiveInterruption: [
|
|
191
|
+
"type": "began",
|
|
192
|
+
"wasSuspended": wasSuspended
|
|
193
|
+
]
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
case .ended:
|
|
197
|
+
Logger.debug("AudioStreamManager", "Audio session interruption ended - autoResume: \(autoResumeAfterInterruption), wasSuspended: \(wasSuspended)")
|
|
198
|
+
if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt {
|
|
199
|
+
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
|
|
200
|
+
Logger.debug("AudioStreamManager", "Interruption options - shouldResume: \(options.contains(.shouldResume))")
|
|
201
|
+
|
|
202
|
+
// Calculate pause duration if we have a pause start time
|
|
203
|
+
if let pauseStart = currentPauseStart {
|
|
204
|
+
let pauseDuration = Date().timeIntervalSince(pauseStart)
|
|
205
|
+
totalPausedDuration += pauseDuration
|
|
206
|
+
currentPauseStart = nil
|
|
207
|
+
Logger.debug("AudioStreamManager", "Added interruption pause duration: \(pauseDuration), total paused: \(totalPausedDuration)")
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// For phone calls, we should auto-resume if enabled, regardless of previous pause state
|
|
211
|
+
if autoResumeAfterInterruption && isRecording {
|
|
212
|
+
// Add a longer delay for phone calls and ensure proper session setup
|
|
213
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
|
|
214
|
+
guard let self = self else { return }
|
|
215
|
+
Logger.debug("AudioStreamManager", "Attempting to auto-resume recording after phone call")
|
|
216
|
+
|
|
217
|
+
// Configure audio session
|
|
218
|
+
do {
|
|
219
|
+
let session = AVAudioSession.sharedInstance()
|
|
220
|
+
let resumeOptions: AVAudioSession.CategoryOptions = self.recordingSettings?.ios?.audioSession?.categoryOptions ?? [.allowBluetooth, .mixWithOthers]
|
|
221
|
+
try session.setCategory(.playAndRecord, mode: .default, options: resumeOptions)
|
|
222
|
+
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
|
223
|
+
|
|
224
|
+
// Resume if we're still recording and paused
|
|
225
|
+
if self.isRecording && self.isPaused {
|
|
226
|
+
Logger.debug("AudioStreamManager", "Resuming recording after phone call interruption")
|
|
227
|
+
self.audioEngine.prepare()
|
|
228
|
+
self.resumeRecording()
|
|
229
|
+
} else {
|
|
230
|
+
Logger.debug("AudioStreamManager", "Cannot resume - recording state invalid: isRecording=\(self.isRecording), isPaused=\(self.isPaused)")
|
|
231
|
+
}
|
|
232
|
+
} catch {
|
|
233
|
+
Logger.debug("AudioStreamManager", "Failed to reactivate audio session: \(error)")
|
|
234
|
+
self.delegate?.audioStreamManager(self, didFailWithError: "Failed to auto-resume: \(error.localizedDescription)")
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Always notify delegate of interruption end
|
|
240
|
+
delegate?.audioStreamManager(
|
|
241
|
+
self,
|
|
242
|
+
didReceiveInterruption: [
|
|
243
|
+
"type": "ended",
|
|
244
|
+
"wasSuspended": wasSuspended,
|
|
245
|
+
"shouldResume": options.contains(.shouldResume)
|
|
246
|
+
]
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
@unknown default:
|
|
250
|
+
break
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private func setupNowPlayingInfo() {
|
|
255
|
+
// Session is already configured by configureAudioSession(); do not override it here.
|
|
256
|
+
audioSession = AVAudioSession.sharedInstance()
|
|
257
|
+
|
|
258
|
+
// Setup Now Playing info
|
|
259
|
+
notificationView = MPNowPlayingInfoCenter.default()
|
|
260
|
+
updateNowPlayingInfo(isPaused: false)
|
|
261
|
+
|
|
262
|
+
// Configure Remote Command Center
|
|
263
|
+
setupRemoteCommandCenter()
|
|
264
|
+
|
|
265
|
+
// Enable remote control events on main thread
|
|
266
|
+
DispatchQueue.main.async {
|
|
267
|
+
UIApplication.shared.beginReceivingRemoteControlEvents()
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private func setupRemoteCommandCenter() {
|
|
272
|
+
remoteCommandCenter = MPRemoteCommandCenter.shared()
|
|
273
|
+
|
|
274
|
+
// Remove any existing handlers
|
|
275
|
+
remoteCommandCenter?.pauseCommand.removeTarget(nil)
|
|
276
|
+
remoteCommandCenter?.playCommand.removeTarget(nil)
|
|
277
|
+
|
|
278
|
+
// Add pause command handler
|
|
279
|
+
remoteCommandCenter?.pauseCommand.addTarget { [weak self] _ in
|
|
280
|
+
guard let self = self, self.isRecording && !self.isPaused else {
|
|
281
|
+
return .commandFailed
|
|
282
|
+
}
|
|
283
|
+
self.pauseRecording()
|
|
284
|
+
return .success
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Add play/resume command handler
|
|
288
|
+
remoteCommandCenter?.playCommand.addTarget { [weak self] _ in
|
|
289
|
+
guard let self = self, self.isRecording && self.isPaused else {
|
|
290
|
+
return .commandFailed
|
|
291
|
+
}
|
|
292
|
+
self.resumeRecording()
|
|
293
|
+
return .success
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Enable the commands
|
|
297
|
+
remoteCommandCenter?.pauseCommand.isEnabled = true
|
|
298
|
+
remoteCommandCenter?.playCommand.isEnabled = true
|
|
299
|
+
|
|
300
|
+
// Disable unused commands
|
|
301
|
+
remoteCommandCenter?.nextTrackCommand.isEnabled = false
|
|
302
|
+
remoteCommandCenter?.previousTrackCommand.isEnabled = false
|
|
303
|
+
remoteCommandCenter?.changePlaybackRateCommand.isEnabled = false
|
|
304
|
+
remoteCommandCenter?.seekBackwardCommand.isEnabled = false
|
|
305
|
+
remoteCommandCenter?.seekForwardCommand.isEnabled = false
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private func updateNowPlayingInfo(isPaused: Bool) {
|
|
309
|
+
var nowPlayingInfo = [String: Any]()
|
|
310
|
+
|
|
311
|
+
// Set media title and artist
|
|
312
|
+
nowPlayingInfo[MPMediaItemPropertyTitle] = recordingSettings?.notification?.title ?? "Recording in Progress"
|
|
313
|
+
nowPlayingInfo[MPMediaItemPropertyArtist] = "Audio Stream"
|
|
314
|
+
|
|
315
|
+
// Set playback state
|
|
316
|
+
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = isPaused ? 0.0 : 1.0
|
|
317
|
+
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentRecordingDuration()
|
|
318
|
+
|
|
319
|
+
// Add placeholder image if available
|
|
320
|
+
if let image = UIImage(named: "recording_icon") {
|
|
321
|
+
nowPlayingInfo[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: image.size) { size in
|
|
322
|
+
return image
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Update the info on main thread
|
|
327
|
+
DispatchQueue.main.async {
|
|
328
|
+
self.notificationView?.nowPlayingInfo = nowPlayingInfo
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
func currentRecordingDuration() -> TimeInterval {
|
|
333
|
+
// If we're paused, return the last valid duration
|
|
334
|
+
if isPaused, let lastDuration = lastValidDuration {
|
|
335
|
+
return lastDuration
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Safety check: if recording but no start time, use current time
|
|
339
|
+
if isRecording && startTime == nil {
|
|
340
|
+
Logger.debug("AudioStreamManager", "WARNING: Recording active but startTime is nil, setting to current time")
|
|
341
|
+
startTime = Date()
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
guard let startTime = self.startTime else { return 0 }
|
|
345
|
+
|
|
346
|
+
let now = Date()
|
|
347
|
+
var duration = now.timeIntervalSince(startTime)
|
|
348
|
+
|
|
349
|
+
// Subtract total paused duration
|
|
350
|
+
duration -= TimeInterval(totalPausedDuration)
|
|
351
|
+
|
|
352
|
+
// If we're currently in a pause (including background pause for !keepAwake), subtract that too
|
|
353
|
+
if let pauseStart = currentPauseStart {
|
|
354
|
+
duration -= now.timeIntervalSince(pauseStart)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return duration
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private func cleanupNotificationObservers() {
|
|
361
|
+
NotificationCenter.default.removeObserver(self)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
@objc private func handlePauseNotification(_ notification: Notification) {
|
|
365
|
+
// Only handle if recording and notifications are enabled
|
|
366
|
+
guard isRecording, recordingSettings?.showNotification == true else { return }
|
|
367
|
+
pauseRecording()
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
@objc private func handleResumeNotification(_ notification: Notification) {
|
|
371
|
+
// Only handle if recording and notifications are enabled
|
|
372
|
+
guard isRecording, recordingSettings?.showNotification == true else { return }
|
|
373
|
+
resumeRecording()
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
@objc private func handlePauseAction() {
|
|
377
|
+
pauseRecording()
|
|
378
|
+
updateNotificationState(isPaused: true)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
@objc private func handleResumeAction() {
|
|
382
|
+
resumeRecording()
|
|
383
|
+
updateNotificationState(isPaused: false)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
@objc private func handleAppDidEnterBackground(_ notification: Notification) {
|
|
387
|
+
// Skip if we're in the process of stopping - this prevents race conditions
|
|
388
|
+
if !isRecording || stopping {
|
|
389
|
+
return
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// If keepAwake is false, we should track this as a pause and actually pause the engine
|
|
393
|
+
if let settings = recordingSettings, !settings.keepAwake {
|
|
394
|
+
Logger.debug("AudioStreamManager", "App entering background with keepAwake=false, pausing recording")
|
|
395
|
+
currentPauseStart = Date()
|
|
396
|
+
// Explicitly pause the engine but don't change isPaused state
|
|
397
|
+
// so we can automatically resume when returning to foreground
|
|
398
|
+
audioEngine.pause()
|
|
399
|
+
} else {
|
|
400
|
+
Logger.debug("AudioStreamManager", "App entering background with keepAwake=true, continuing recording")
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Use a strong reference to notificationManager to avoid potential null reference
|
|
404
|
+
if let manager = notificationManager {
|
|
405
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
406
|
+
guard let self = self, self.isRecording, !self.stopping else { return }
|
|
407
|
+
manager.showInitialNotification()
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
@objc private func handleAppWillEnterForeground(_ notification: Notification) {
|
|
413
|
+
// Skip if we're in the process of stopping
|
|
414
|
+
if !isRecording || stopping {
|
|
415
|
+
return
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// If we were paused due to background and keepAwake was false, calculate pause duration
|
|
419
|
+
if let settings = recordingSettings, !settings.keepAwake, let pauseStart = currentPauseStart {
|
|
420
|
+
let pauseDuration = Date().timeIntervalSince(pauseStart)
|
|
421
|
+
totalPausedDuration += pauseDuration
|
|
422
|
+
currentPauseStart = nil
|
|
423
|
+
Logger.debug("AudioStreamManager", "Added background pause duration: \(pauseDuration), total paused: \(totalPausedDuration)")
|
|
424
|
+
|
|
425
|
+
// Now restart the engine if it was paused due to background
|
|
426
|
+
do {
|
|
427
|
+
// Reinstall tap with hardware format to ensure we have good input
|
|
428
|
+
_ = installTapWithHardwareFormat()
|
|
429
|
+
// Restart the engine
|
|
430
|
+
try audioEngine.start()
|
|
431
|
+
Logger.debug("AudioStreamManager", "Successfully restarted audio engine after returning from background")
|
|
432
|
+
} catch {
|
|
433
|
+
Logger.debug("AudioStreamManager", "Failed to restart audio engine after returning from background: \(error)")
|
|
434
|
+
// If we can't restart, officially pause the recording
|
|
435
|
+
if !isPaused {
|
|
436
|
+
isPaused = true
|
|
437
|
+
// Notify delegate
|
|
438
|
+
delegate?.audioStreamManager(self, didPauseRecording: Date())
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Safely access notificationManager
|
|
444
|
+
if let manager = notificationManager {
|
|
445
|
+
manager.stopUpdates()
|
|
446
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
447
|
+
guard let self = self, self.isRecording, !self.stopping else { return }
|
|
448
|
+
manager.startUpdates(startTime: self.startTime ?? Date())
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
private func updateNotificationState(isPaused: Bool) {
|
|
454
|
+
// Calculate current duration
|
|
455
|
+
let currentDuration: TimeInterval
|
|
456
|
+
if let startTime = startTime {
|
|
457
|
+
currentDuration = Date().timeIntervalSince(startTime) - TimeInterval(totalPausedDuration)
|
|
458
|
+
} else {
|
|
459
|
+
currentDuration = 0
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Update Now Playing info
|
|
463
|
+
var nowPlayingInfo = notificationView?.nowPlayingInfo ?? [:]
|
|
464
|
+
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = isPaused ? 0.0 : 1.0
|
|
465
|
+
nowPlayingInfo[MPMediaItemPropertyTitle] = isPaused ?
|
|
466
|
+
"Recording Paused" :
|
|
467
|
+
(recordingSettings?.notification?.title ?? "Recording in Progress")
|
|
468
|
+
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentDuration
|
|
469
|
+
notificationView?.nowPlayingInfo = nowPlayingInfo
|
|
470
|
+
|
|
471
|
+
// Delegate notification update to AudioNotificationManager
|
|
472
|
+
notificationManager?.updateState(isPaused: isPaused)
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
private func updateMediaInfo() {
|
|
476
|
+
guard let startTime = startTime else { return }
|
|
477
|
+
|
|
478
|
+
let currentDuration = Date().timeIntervalSince(startTime) - TimeInterval(totalPausedDuration)
|
|
479
|
+
|
|
480
|
+
var nowPlayingInfo = notificationView?.nowPlayingInfo ?? [:]
|
|
481
|
+
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentDuration
|
|
482
|
+
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = isPaused ? 0.0 : 1.0
|
|
483
|
+
notificationView?.nowPlayingInfo = nowPlayingInfo
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/// Enables the wake lock to prevent screen dimming
|
|
487
|
+
private func enableWakeLock() {
|
|
488
|
+
guard let settings = recordingSettings,
|
|
489
|
+
settings.keepAwake, // Only proceed if keepAwake is true
|
|
490
|
+
!isWakeLockEnabled // Only proceed if wake lock isn't already enabled
|
|
491
|
+
else { return }
|
|
492
|
+
|
|
493
|
+
DispatchQueue.main.async {
|
|
494
|
+
self.wasIdleTimerDisabled = UIApplication.shared.isIdleTimerDisabled
|
|
495
|
+
UIApplication.shared.isIdleTimerDisabled = true
|
|
496
|
+
self.isWakeLockEnabled = true
|
|
497
|
+
Logger.debug("AudioStreamManager", "Wake lock enabled")
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/// Disables the wake lock and restores previous screen dimming state
|
|
502
|
+
private func disableWakeLock() {
|
|
503
|
+
guard let settings = recordingSettings,
|
|
504
|
+
settings.keepAwake, // Only proceed if keepAwake is true
|
|
505
|
+
isWakeLockEnabled // Only proceed if wake lock is currently enabled
|
|
506
|
+
else { return }
|
|
507
|
+
|
|
508
|
+
DispatchQueue.main.async {
|
|
509
|
+
UIApplication.shared.isIdleTimerDisabled = self.wasIdleTimerDisabled
|
|
510
|
+
self.isWakeLockEnabled = false
|
|
511
|
+
Logger.debug("AudioStreamManager", "Wake lock disabled")
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/// Creates a new recording file.
|
|
516
|
+
/// - Returns: The URL of the newly created recording file, or nil if creation failed.
|
|
517
|
+
private func createRecordingFile(isCompressed: Bool = false) -> URL? {
|
|
518
|
+
// Add debug logging
|
|
519
|
+
Logger.debug("AudioStreamManager", "Creating recording file - settings filename: \(recordingSettings?.filename ?? "nil")")
|
|
520
|
+
|
|
521
|
+
// Get base directory - use default if no custom directory provided
|
|
522
|
+
let baseDirectory: URL
|
|
523
|
+
if let customDir = recordingSettings?.outputDirectory {
|
|
524
|
+
baseDirectory = URL(fileURLWithPath: customDir)
|
|
525
|
+
Logger.debug("AudioStreamManager", "Using custom directory: \(customDir)")
|
|
526
|
+
} else {
|
|
527
|
+
// Use existing default behavior
|
|
528
|
+
baseDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
|
|
529
|
+
Logger.debug("AudioStreamManager", "Using default directory: \(baseDirectory.path)")
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Generate or reuse UUID for filename
|
|
533
|
+
let baseFilename: String
|
|
534
|
+
if let existingFilename = recordingSettings?.filename {
|
|
535
|
+
baseFilename = existingFilename
|
|
536
|
+
} else {
|
|
537
|
+
// Always create a new UUID for recording unless a filename is provided
|
|
538
|
+
let newUUID = UUID()
|
|
539
|
+
recordingUUID = newUUID
|
|
540
|
+
baseFilename = newUUID.uuidString
|
|
541
|
+
}
|
|
542
|
+
Logger.debug("AudioStreamManager", "Using base filename: \(baseFilename)")
|
|
543
|
+
|
|
544
|
+
// Remove any existing extension from the filename
|
|
545
|
+
let filenameWithoutExtension = baseFilename.replacingOccurrences(
|
|
546
|
+
of: "\\.[^\\.]+$",
|
|
547
|
+
with: "",
|
|
548
|
+
options: .regularExpression
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
// Choose extension based on whether this is a compressed file
|
|
552
|
+
let fileExtension: String
|
|
553
|
+
if isCompressed {
|
|
554
|
+
// iOS always produces M4A container when using AAC or Opus formats
|
|
555
|
+
// Opus falls back to AAC in M4A container on iOS
|
|
556
|
+
fileExtension = "m4a"
|
|
557
|
+
} else {
|
|
558
|
+
fileExtension = "wav"
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
let fullFilename = "\(filenameWithoutExtension).\(fileExtension)"
|
|
562
|
+
Logger.debug("AudioStreamManager", "Full filename: \(fullFilename)")
|
|
563
|
+
|
|
564
|
+
let fileURL = baseDirectory.appendingPathComponent(fullFilename)
|
|
565
|
+
Logger.debug("AudioStreamManager", "Final file URL: \(fileURL.path)")
|
|
566
|
+
|
|
567
|
+
// Check if file already exists
|
|
568
|
+
if fileManager.fileExists(atPath: fileURL.path) {
|
|
569
|
+
Logger.debug("AudioStreamManager", "File already exists at: \(fileURL.path)")
|
|
570
|
+
return nil
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if !fileManager.createFile(atPath: fileURL.path, contents: nil, attributes: nil) {
|
|
574
|
+
Logger.debug("AudioStreamManager", "Failed to create file at: \(fileURL.path)")
|
|
575
|
+
return nil
|
|
576
|
+
}
|
|
577
|
+
return fileURL
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/// Creates a WAV header for the given data size.
|
|
581
|
+
/// - Parameter dataSize: The size of the audio data.
|
|
582
|
+
/// - Returns: A Data object containing the WAV header.
|
|
583
|
+
private func createWavHeader(dataSize: Int) -> Data {
|
|
584
|
+
var header = Data()
|
|
585
|
+
|
|
586
|
+
let sampleRate = UInt32(recordingSettings!.sampleRate)
|
|
587
|
+
let channels = UInt32(recordingSettings!.numberOfChannels)
|
|
588
|
+
let bitDepth = UInt32(recordingSettings!.bitDepth)
|
|
589
|
+
|
|
590
|
+
let blockAlign = channels * (bitDepth / 8)
|
|
591
|
+
let byteRate = sampleRate * blockAlign
|
|
592
|
+
|
|
593
|
+
// "RIFF" chunk descriptor
|
|
594
|
+
header.append(contentsOf: "RIFF".utf8)
|
|
595
|
+
header.append(contentsOf: UInt32(36 + dataSize).littleEndianBytes)
|
|
596
|
+
header.append(contentsOf: "WAVE".utf8)
|
|
597
|
+
|
|
598
|
+
// "fmt " sub-chunk
|
|
599
|
+
header.append(contentsOf: "fmt ".utf8)
|
|
600
|
+
header.append(contentsOf: UInt32(16).littleEndianBytes) // PCM format requires 16 bytes for the fmt sub-chunk
|
|
601
|
+
header.append(contentsOf: UInt16(1).littleEndianBytes) // Audio format 1 for PCM
|
|
602
|
+
header.append(contentsOf: UInt16(channels).littleEndianBytes)
|
|
603
|
+
header.append(contentsOf: sampleRate.littleEndianBytes)
|
|
604
|
+
header.append(contentsOf: byteRate.littleEndianBytes) // byteRate
|
|
605
|
+
header.append(contentsOf: UInt16(blockAlign).littleEndianBytes) // blockAlign
|
|
606
|
+
header.append(contentsOf: UInt16(bitDepth).littleEndianBytes) // bits per sample
|
|
607
|
+
|
|
608
|
+
// "data" sub-chunk
|
|
609
|
+
header.append(contentsOf: "data".utf8)
|
|
610
|
+
header.append(contentsOf: UInt32(dataSize).littleEndianBytes) // Sub-chunk data size
|
|
611
|
+
|
|
612
|
+
return header
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/// Gets the current status of the recording.
|
|
616
|
+
/// - Returns: A dictionary containing the recording status information.
|
|
617
|
+
func getStatus() -> [String: Any] {
|
|
618
|
+
guard let settings = recordingSettings else {
|
|
619
|
+
print("Recording settings are not available.")
|
|
620
|
+
return [:]
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
let durationInSeconds = currentRecordingDuration()
|
|
624
|
+
let durationInMilliseconds = Int(durationInSeconds * 1000)
|
|
625
|
+
|
|
626
|
+
var status: [String: Any] = [
|
|
627
|
+
"durationMs": durationInMilliseconds,
|
|
628
|
+
"isRecording": isRecording,
|
|
629
|
+
"isPaused": isPaused,
|
|
630
|
+
"mimeType": mimeType,
|
|
631
|
+
"size": totalDataSize
|
|
632
|
+
]
|
|
633
|
+
|
|
634
|
+
// Safely handle optional interval values
|
|
635
|
+
if let interval = settings.interval {
|
|
636
|
+
status["interval"] = interval
|
|
637
|
+
} else {
|
|
638
|
+
status["interval"] = 1000 // Default value
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
if let intervalAnalysis = settings.intervalAnalysis {
|
|
642
|
+
status["intervalAnalysis"] = intervalAnalysis
|
|
643
|
+
} else {
|
|
644
|
+
status["intervalAnalysis"] = 500 // Default value
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Add compression info if enabled
|
|
648
|
+
if settings.output.compressed.enabled,
|
|
649
|
+
let compressedURL = compressedFileURL,
|
|
650
|
+
FileManager.default.fileExists(atPath: compressedURL.path) {
|
|
651
|
+
do {
|
|
652
|
+
let compressedAttributes = try FileManager.default.attributesOfItem(atPath: compressedURL.path)
|
|
653
|
+
if let compressedSize = compressedAttributes[.size] as? Int64 {
|
|
654
|
+
Logger.debug("AudioStreamManager", "Compressed file status - Size: \(compressedSize)")
|
|
655
|
+
let compressionBundle: [String: Any] = [
|
|
656
|
+
"fileUri": compressedURL.absoluteString,
|
|
657
|
+
"mimeType": compressedFormat == "aac" ? "audio/aac" : "audio/opus",
|
|
658
|
+
"size": compressedSize,
|
|
659
|
+
"format": compressedFormat,
|
|
660
|
+
"bitrate": compressedBitRate
|
|
661
|
+
]
|
|
662
|
+
status["compression"] = compressionBundle
|
|
663
|
+
}
|
|
664
|
+
} catch {
|
|
665
|
+
Logger.debug("AudioStreamManager", "Error getting compressed file attributes: \(error)")
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
return status
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/// Builds compression info dictionary with incremental compressed data for streaming events.
|
|
673
|
+
/// Mirrors the Android pattern (AudioRecorderManager.kt) for consistent cross-platform behavior.
|
|
674
|
+
/// - Returns: A dictionary containing compression metadata and base64-encoded data chunk, or nil if compression is disabled.
|
|
675
|
+
private func buildCompressionInfo() -> [String: Any]? {
|
|
676
|
+
guard let settings = recordingSettings,
|
|
677
|
+
settings.output.compressed.enabled,
|
|
678
|
+
let compressedURL = compressedFileURL,
|
|
679
|
+
FileManager.default.fileExists(atPath: compressedURL.path) else {
|
|
680
|
+
return nil
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
do {
|
|
684
|
+
let attributes = try FileManager.default.attributesOfItem(atPath: compressedURL.path)
|
|
685
|
+
guard let compressedSize = attributes[.size] as? Int64 else { return nil }
|
|
686
|
+
|
|
687
|
+
let eventDataSize = compressedSize - lastEmittedCompressedSize
|
|
688
|
+
|
|
689
|
+
var info: [String: Any] = [
|
|
690
|
+
"position": lastEmittedCompressedSize,
|
|
691
|
+
"fileUri": compressedURL.absoluteString,
|
|
692
|
+
"eventDataSize": eventDataSize,
|
|
693
|
+
"totalSize": compressedSize,
|
|
694
|
+
"size": compressedSize,
|
|
695
|
+
"mimeType": compressedFormat == "aac" ? "audio/aac" : "audio/opus",
|
|
696
|
+
"bitrate": compressedBitRate,
|
|
697
|
+
"format": compressedFormat
|
|
698
|
+
]
|
|
699
|
+
|
|
700
|
+
// Read incremental chunk if there is new data
|
|
701
|
+
if eventDataSize > 0 {
|
|
702
|
+
let fileHandle = try FileHandle(forReadingFrom: compressedURL)
|
|
703
|
+
defer { fileHandle.closeFile() }
|
|
704
|
+
fileHandle.seek(toFileOffset: UInt64(lastEmittedCompressedSize))
|
|
705
|
+
let chunkData = fileHandle.readData(ofLength: Int(eventDataSize))
|
|
706
|
+
info["data"] = chunkData.base64EncodedString()
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
lastEmittedCompressedSize = compressedSize
|
|
710
|
+
cachedCompressedFileSize = compressedSize
|
|
711
|
+
|
|
712
|
+
return info
|
|
713
|
+
} catch {
|
|
714
|
+
Logger.debug("AudioStreamManager", "Error building compression info: \(error)")
|
|
715
|
+
return nil
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/// Detects if a phone call is active without using CallKit.
|
|
720
|
+
/// We avoid CallKit because its usage prevents apps from being available in China's App Store.
|
|
721
|
+
/// This is a workaround that uses AVAudioSession to detect phone calls instead.
|
|
722
|
+
private func isPhoneCallActive() -> Bool {
|
|
723
|
+
let audioSession = AVAudioSession.sharedInstance()
|
|
724
|
+
return audioSession.isOtherAudioPlaying &&
|
|
725
|
+
audioSession.secondaryAudioShouldBeSilencedHint &&
|
|
726
|
+
audioSession.currentRoute.outputs.contains { $0.portType == .builtInReceiver }
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/// Installs the audio tap with the hardware-compatible format
|
|
730
|
+
/// - Parameters:
|
|
731
|
+
/// - customTapBlock: Optional custom tap block for specialized processing (like in fallback)
|
|
732
|
+
/// - prepareEngine: Whether to call prepare() on the engine after installing the tap (default: true)
|
|
733
|
+
/// - Returns: The hardware input format that was used for the tap
|
|
734
|
+
private func installTapWithHardwareFormat(
|
|
735
|
+
customTapBlock: ((AVAudioPCMBuffer, AVAudioTime) -> Void)? = nil,
|
|
736
|
+
prepareEngine: Bool = true
|
|
737
|
+
) -> AVAudioFormat {
|
|
738
|
+
// Get the hardware input format
|
|
739
|
+
let inputNode = audioEngine.inputNode
|
|
740
|
+
let inputHardwareFormat = inputNode.inputFormat(forBus: 0)
|
|
741
|
+
let nodeOutputFormat = inputNode.outputFormat(forBus: 0)
|
|
742
|
+
|
|
743
|
+
// Log format information for diagnostic purposes
|
|
744
|
+
Logger.debug("AudioStreamManager", "Installing tap - Hardware input format: \(describeAudioFormat(inputHardwareFormat))")
|
|
745
|
+
Logger.debug("AudioStreamManager", "Node output format: \(describeAudioFormat(nodeOutputFormat))")
|
|
746
|
+
|
|
747
|
+
// Remove any existing tap
|
|
748
|
+
inputNode.removeTap(onBus: 0)
|
|
749
|
+
|
|
750
|
+
// Create the default tap block if none provided
|
|
751
|
+
let tapBlock = customTapBlock ?? { [weak self] (buffer, time) in
|
|
752
|
+
guard let self = self,
|
|
753
|
+
self.isRecording else {
|
|
754
|
+
return
|
|
755
|
+
}
|
|
756
|
+
// Process audio buffer for streaming, analysis, and optional file writing
|
|
757
|
+
// Note: Audio streaming works regardless of primary output settings (consistent with Web/Android)
|
|
758
|
+
self.processAudioBuffer(buffer)
|
|
759
|
+
self.lastBufferTime = time
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// Calculate buffer size from duration if specified
|
|
763
|
+
let bufferSize: AVAudioFrameCount
|
|
764
|
+
if let duration = recordingSettings?.bufferDurationSeconds {
|
|
765
|
+
// Use target sample rate from settings for calculation
|
|
766
|
+
let targetSampleRate = Double(recordingSettings?.sampleRate ?? 16000)
|
|
767
|
+
let calculatedSize = AVAudioFrameCount(duration * targetSampleRate)
|
|
768
|
+
|
|
769
|
+
// iOS enforces minimum buffer size of ~4800 frames
|
|
770
|
+
if calculatedSize < 4800 {
|
|
771
|
+
Logger.debug("AudioStreamManager", "Requested buffer size \(calculatedSize) frames (from \(duration)s at \(targetSampleRate)Hz) is below iOS minimum of ~4800 frames")
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// Apply safety clamping
|
|
775
|
+
bufferSize = max(256, min(calculatedSize, 16384))
|
|
776
|
+
Logger.debug("AudioStreamManager", "Buffer size: requested=\(calculatedSize), clamped=\(bufferSize) frames")
|
|
777
|
+
} else {
|
|
778
|
+
bufferSize = 1024 // Default
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// Validate hardware format before installing tap (#223)
|
|
782
|
+
if inputHardwareFormat.channelCount == 0 || inputHardwareFormat.sampleRate == 0 {
|
|
783
|
+
Logger.debug("AudioStreamManager", "Invalid hardware format: channels=\(inputHardwareFormat.channelCount), sampleRate=\(inputHardwareFormat.sampleRate). Using fallback.")
|
|
784
|
+
let fallbackSampleRate = Double(recordingSettings?.sampleRate ?? 16000)
|
|
785
|
+
let fallbackChannels = AVAudioChannelCount(recordingSettings?.numberOfChannels ?? 1)
|
|
786
|
+
guard let fallbackFormat = AVAudioFormat(standardFormatWithSampleRate: fallbackSampleRate, channels: fallbackChannels) else {
|
|
787
|
+
Logger.debug("AudioStreamManager", "Failed to create fallback format")
|
|
788
|
+
return inputHardwareFormat
|
|
789
|
+
}
|
|
790
|
+
inputNode.installTap(onBus: 0, bufferSize: bufferSize, format: fallbackFormat, block: tapBlock)
|
|
791
|
+
Logger.debug("AudioStreamManager", "Tap installed with fallback format")
|
|
792
|
+
if prepareEngine {
|
|
793
|
+
audioEngine.prepare()
|
|
794
|
+
}
|
|
795
|
+
return fallbackFormat
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Install the tap with hardware format
|
|
799
|
+
inputNode.installTap(onBus: 0, bufferSize: bufferSize, format: inputHardwareFormat, block: tapBlock)
|
|
800
|
+
Logger.debug("AudioStreamManager", "Tap installed with hardware-compatible format")
|
|
801
|
+
|
|
802
|
+
// Prepare the engine if requested
|
|
803
|
+
if prepareEngine {
|
|
804
|
+
audioEngine.prepare()
|
|
805
|
+
Logger.debug("AudioStreamManager", "Engine prepared after tap installation")
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
return inputHardwareFormat
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/// Prepares the audio recording with the specified settings without starting it.
|
|
812
|
+
/// This reduces latency when startRecording is called later.
|
|
813
|
+
/// - Parameters:
|
|
814
|
+
/// - settings: The recording settings to use.
|
|
815
|
+
/// - Returns: A boolean indicating if preparation was successful.
|
|
816
|
+
func prepareRecording(settings: RecordingSettings) -> Bool {
|
|
817
|
+
// Store settings first before doing anything else
|
|
818
|
+
recordingSettings = settings
|
|
819
|
+
|
|
820
|
+
// Skip if already prepared or recording
|
|
821
|
+
guard !isPrepared && !isRecording else {
|
|
822
|
+
Logger.debug("AudioStreamManager", "Already prepared or recording in progress.")
|
|
823
|
+
return isPrepared
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// Check for active call using the new method
|
|
827
|
+
if isPhoneCallActive() {
|
|
828
|
+
Logger.debug("AudioStreamManager", "Cannot prepare recording during an active phone call")
|
|
829
|
+
delegate?.audioStreamManager(self, didFailWithError: "Cannot prepare recording during an active phone call")
|
|
830
|
+
return false
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// Reset audio session before preparing new recording
|
|
834
|
+
do {
|
|
835
|
+
let session = AVAudioSession.sharedInstance()
|
|
836
|
+
try session.setActive(false, options: .notifyOthersOnDeactivation)
|
|
837
|
+
Thread.sleep(forTimeInterval: 0.1) // Brief pause to ensure clean state
|
|
838
|
+
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
|
839
|
+
} catch {
|
|
840
|
+
Logger.debug("AudioStreamManager", "Failed to reset audio session: \(error)")
|
|
841
|
+
delegate?.audioStreamManager(self, didFailWithError: "Failed to reset audio session: \(error.localizedDescription)")
|
|
842
|
+
return false
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// Update auto-resume preference from settings
|
|
846
|
+
autoResumeAfterInterruption = settings.autoResumeAfterInterruption
|
|
847
|
+
|
|
848
|
+
// Enforce minimum interval to prevent excessive CPU usage
|
|
849
|
+
emissionInterval = max(10.0, Double(settings.interval ?? 1000)) / 1000.0
|
|
850
|
+
emissionIntervalAnalysis = max(10.0, Double(settings.intervalAnalysis ?? 500)) / 1000.0
|
|
851
|
+
lastEmissionTime = nil // Will be set when recording starts
|
|
852
|
+
lastEmissionTimeAnalysis = nil // Will be set when recording starts
|
|
853
|
+
accumulatedData.removeAll()
|
|
854
|
+
accumulatedAnalysisData.removeAll()
|
|
855
|
+
totalDataSize = 0
|
|
856
|
+
totalDataSizeAnalysis = 0
|
|
857
|
+
totalPausedDuration = 0
|
|
858
|
+
lastEmittedSize = 0
|
|
859
|
+
lastEmittedCompressedSize = 0
|
|
860
|
+
lastEmittedCompressedSizeAnalysis = 0
|
|
861
|
+
isPaused = false
|
|
862
|
+
|
|
863
|
+
// Create recording file first (unless primary output is disabled)
|
|
864
|
+
if settings.output.primary.enabled {
|
|
865
|
+
recordingFileURL = createRecordingFile()
|
|
866
|
+
if let url = recordingFileURL {
|
|
867
|
+
do {
|
|
868
|
+
// Ensure directory exists if needed (createRecordingFile should handle this, but belt-and-suspenders)
|
|
869
|
+
try fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
|
|
870
|
+
// Create the file if it doesn't exist (createRecordingFile should also handle this)
|
|
871
|
+
if !fileManager.fileExists(atPath: url.path) {
|
|
872
|
+
fileManager.createFile(atPath: url.path, contents: nil, attributes: nil)
|
|
873
|
+
}
|
|
874
|
+
// Open the handle for writing
|
|
875
|
+
self.fileHandle = try FileHandle(forWritingTo: url)
|
|
876
|
+
// Write initial dummy header immediately
|
|
877
|
+
let header = createWavHeader(dataSize: 0)
|
|
878
|
+
self.fileHandle?.write(header)
|
|
879
|
+
self.totalDataSize = Int64(WAV_HEADER_SIZE) // Initialize size with header size
|
|
880
|
+
self.cachedWavFileSize = Int64(WAV_HEADER_SIZE) // Initialize cached size
|
|
881
|
+
Logger.debug("AudioStreamManager", "File handle opened and initial header written for \(url.path). Initial size: \(self.totalDataSize)")
|
|
882
|
+
} catch {
|
|
883
|
+
Logger.debug("AudioStreamManager", "Error creating/opening file handle: \(error.localizedDescription)")
|
|
884
|
+
// No need to call cleanupPreparation here, return false will handle it
|
|
885
|
+
return false
|
|
886
|
+
}
|
|
887
|
+
} else {
|
|
888
|
+
Logger.debug("AudioStreamManager", "Error: Failed to create recording file URL.")
|
|
889
|
+
return false
|
|
890
|
+
}
|
|
891
|
+
} else {
|
|
892
|
+
// Skip file writing mode
|
|
893
|
+
recordingFileURL = nil
|
|
894
|
+
fileHandle = nil
|
|
895
|
+
totalDataSize = 0
|
|
896
|
+
Logger.debug("AudioStreamManager", "Skip file writing mode enabled - no file will be created")
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
var newSettings = settings
|
|
900
|
+
|
|
901
|
+
// Then set up audio session and tap
|
|
902
|
+
do {
|
|
903
|
+
Logger.debug("AudioStreamManager", "Configuring audio session with sample rate: \(settings.sampleRate) Hz")
|
|
904
|
+
|
|
905
|
+
let session = AVAudioSession.sharedInstance()
|
|
906
|
+
if let currentRoute = session.currentRoute.outputs.first {
|
|
907
|
+
Logger.debug("AudioStreamManager", "Current audio output: \(currentRoute.portType)")
|
|
908
|
+
newSettings.sampleRate = settings.sampleRate // Keep original sample rate
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// Configure audio session based on audio focus strategy
|
|
912
|
+
try configureAudioSession(for: settings)
|
|
913
|
+
// NOTE: We intentionally DO NOT call session.setPreferredSampleRate().
|
|
914
|
+
// Trying to force a sample rate different from the hardware's actual rate
|
|
915
|
+
// often prevents the input node's tap from receiving any buffers.
|
|
916
|
+
// Instead, we let the session negotiate the rate.
|
|
917
|
+
// Resampling to the desired settings.sampleRate happens later in processAudioBuffer.
|
|
918
|
+
try session.setPreferredIOBufferDuration(1024 / Double(settings.sampleRate)) // Use desired rate for buffer duration hint
|
|
919
|
+
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
|
920
|
+
|
|
921
|
+
// Log session config details as single lines for clarity
|
|
922
|
+
Logger.debug("AudioStreamManager", "Audio session configured:")
|
|
923
|
+
Logger.debug("AudioStreamManager", " - category: \(session.category)")
|
|
924
|
+
Logger.debug("AudioStreamManager", " - mode: \(session.mode)")
|
|
925
|
+
Logger.debug("AudioStreamManager", " - options: \(session.categoryOptions)")
|
|
926
|
+
Logger.debug("AudioStreamManager", " - keepAwake: \(settings.keepAwake)")
|
|
927
|
+
Logger.debug("AudioStreamManager", " - emission interval: \(emissionInterval * 1000)ms")
|
|
928
|
+
Logger.debug("AudioStreamManager", " - analysis interval: \(emissionIntervalAnalysis * 1000)ms")
|
|
929
|
+
Logger.debug("AudioStreamManager", " - requested sample rate: \(settings.sampleRate)Hz")
|
|
930
|
+
Logger.debug("AudioStreamManager", " - actual session sample rate: \(session.sampleRate)Hz") // Log actual rate
|
|
931
|
+
Logger.debug("AudioStreamManager", " - channels: \(settings.numberOfChannels)")
|
|
932
|
+
Logger.debug("AudioStreamManager", " - bit depth: \(settings.bitDepth)-bit")
|
|
933
|
+
Logger.debug("AudioStreamManager", " - compression enabled: \(settings.output.compressed.enabled)")
|
|
934
|
+
|
|
935
|
+
// Use our shared tap installation method
|
|
936
|
+
let tapFormat = installTapWithHardwareFormat()
|
|
937
|
+
|
|
938
|
+
// Log tap configuration
|
|
939
|
+
Logger.debug("AudioStreamManager", "Final Tap Configuration (Using Hardware Format):")
|
|
940
|
+
Logger.debug("AudioStreamManager", " - Tap Format: \(describeAudioFormat(tapFormat))")
|
|
941
|
+
Logger.debug("AudioStreamManager", " - Session Rate: \(session.sampleRate) Hz")
|
|
942
|
+
Logger.debug("AudioStreamManager", " - Requested Output Format: \(settings.bitDepth)-bit at \(settings.sampleRate)Hz")
|
|
943
|
+
|
|
944
|
+
recordingSettings = newSettings // Keep original settings with desired sample rate
|
|
945
|
+
|
|
946
|
+
audioEngine.prepare() // Prepare the engine without starting it
|
|
947
|
+
|
|
948
|
+
// Setup compressed recording if enabled
|
|
949
|
+
if settings.output.compressed.enabled {
|
|
950
|
+
// Create compressed settings
|
|
951
|
+
let compressedSettings: [String: Any] = [
|
|
952
|
+
AVFormatIDKey: settings.output.compressed.format == "aac" ? kAudioFormatMPEG4AAC : kAudioFormatOpus,
|
|
953
|
+
AVSampleRateKey: Float64(settings.sampleRate),
|
|
954
|
+
AVNumberOfChannelsKey: settings.numberOfChannels,
|
|
955
|
+
AVEncoderBitRateKey: settings.output.compressed.bitrate,
|
|
956
|
+
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue,
|
|
957
|
+
AVEncoderBitDepthHintKey: settings.bitDepth
|
|
958
|
+
]
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
Logger.debug("AudioStreamManager", "Initializing compressed recording with settings: \(compressedSettings)")
|
|
962
|
+
|
|
963
|
+
// Create file for compressed recording
|
|
964
|
+
compressedFileURL = createRecordingFile(isCompressed: true)
|
|
965
|
+
|
|
966
|
+
if let url = compressedFileURL {
|
|
967
|
+
Logger.debug("AudioStreamManager", "Using compressed file URL: \(url.path)")
|
|
968
|
+
|
|
969
|
+
// Initialize recorder with proper error handling
|
|
970
|
+
do {
|
|
971
|
+
compressedRecorder = try AVAudioRecorder(url: url, settings: compressedSettings)
|
|
972
|
+
if let recorder = compressedRecorder {
|
|
973
|
+
recorder.delegate = self
|
|
974
|
+
|
|
975
|
+
if !recorder.prepareToRecord() {
|
|
976
|
+
Logger.debug("AudioStreamManager", "Failed to prepare recorder")
|
|
977
|
+
compressedFileURL = nil
|
|
978
|
+
compressedRecorder = nil
|
|
979
|
+
} else {
|
|
980
|
+
// Note: We don't start the recorder yet, just prepare it
|
|
981
|
+
Logger.debug("AudioStreamManager", "Compressed recording prepared successfully")
|
|
982
|
+
compressedFormat = settings.output.compressed.format
|
|
983
|
+
compressedBitRate = settings.output.compressed.bitrate
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
} catch {
|
|
987
|
+
Logger.debug("AudioStreamManager", "Failed to initialize compressed recorder: \(error)")
|
|
988
|
+
compressedFileURL = nil
|
|
989
|
+
compressedRecorder = nil
|
|
990
|
+
}
|
|
991
|
+
} else {
|
|
992
|
+
Logger.debug("AudioStreamManager", "Failed to create compressed recording file")
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
} catch {
|
|
997
|
+
Logger.debug("AudioStreamManager", "Error: Failed to set up audio session with preferred settings: \(error.localizedDescription)")
|
|
998
|
+
return false
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
NotificationCenter.default.addObserver(self, selector: #selector(handleAudioSessionInterruption), name: AVAudioSession.interruptionNotification, object: nil)
|
|
1002
|
+
|
|
1003
|
+
if settings.enableProcessing == true {
|
|
1004
|
+
// Initialize the AudioProcessor for buffer-based processing
|
|
1005
|
+
self.audioProcessor = AudioProcessor(resolve: { result in
|
|
1006
|
+
// Handle the result here if needed
|
|
1007
|
+
}, reject: { code, message in
|
|
1008
|
+
// Handle the rejection here if needed
|
|
1009
|
+
})
|
|
1010
|
+
Logger.debug("AudioStreamManager", "AudioProcessor activated successfully.")
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// Prepare notifications if enabled but don't show yet
|
|
1014
|
+
if settings.showNotification {
|
|
1015
|
+
initializeNotifications()
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// Mark preparation as complete
|
|
1019
|
+
isPrepared = true
|
|
1020
|
+
Logger.debug("Recording prepared successfully. Ready to start.")
|
|
1021
|
+
|
|
1022
|
+
return true
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/// Starts a new audio recording with the specified settings.
|
|
1026
|
+
/// - Parameters:
|
|
1027
|
+
/// - settings: The recording settings to use.
|
|
1028
|
+
/// - Returns: A StartRecordingResult object if recording starts successfully, or nil otherwise.
|
|
1029
|
+
func startRecording(settings: RecordingSettings) -> StartRecordingResult? {
|
|
1030
|
+
// If already prepared, use the prepared state
|
|
1031
|
+
if isPrepared {
|
|
1032
|
+
Logger.debug("Using prepared recording state")
|
|
1033
|
+
|
|
1034
|
+
// Install tap with hardware format
|
|
1035
|
+
_ = installTapWithHardwareFormat()
|
|
1036
|
+
Logger.debug("Tap was reinstalled during recording start")
|
|
1037
|
+
} else {
|
|
1038
|
+
// If not prepared, prepare now
|
|
1039
|
+
Logger.debug("Not prepared, preparing recording first")
|
|
1040
|
+
if !prepareRecording(settings: settings) {
|
|
1041
|
+
Logger.debug("Failed to prepare recording")
|
|
1042
|
+
return nil
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// Rest of the method remains unchanged
|
|
1047
|
+
// Check for active phone call again, in case one started after preparation
|
|
1048
|
+
if isPhoneCallActive() {
|
|
1049
|
+
Logger.debug("Cannot start recording during an active phone call")
|
|
1050
|
+
delegate?.audioStreamManager(self, didFailWithError: "Cannot start recording during an active phone call")
|
|
1051
|
+
cleanupPreparation()
|
|
1052
|
+
return nil
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
guard !isRecording else {
|
|
1056
|
+
Logger.debug("Recording already in progress")
|
|
1057
|
+
return nil
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
guard let settings = recordingSettings else {
|
|
1061
|
+
Logger.debug("Missing settings")
|
|
1062
|
+
return nil
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// File URI is optional when primary output is disabled
|
|
1066
|
+
let fileUri = recordingFileURL?.absoluteString ?? ""
|
|
1067
|
+
|
|
1068
|
+
do {
|
|
1069
|
+
enableWakeLock()
|
|
1070
|
+
|
|
1071
|
+
// Set recording state *before* starting engine to avoid race condition
|
|
1072
|
+
// Always reset startTime to now — ensures duration reflects actual recording,
|
|
1073
|
+
// not time since prepareRecording() was called (#298)
|
|
1074
|
+
startTime = Date()
|
|
1075
|
+
totalPausedDuration = 0
|
|
1076
|
+
currentPauseStart = nil
|
|
1077
|
+
lastEmissionTime = Date()
|
|
1078
|
+
lastEmissionTimeAnalysis = Date()
|
|
1079
|
+
isRecording = true
|
|
1080
|
+
isPaused = false
|
|
1081
|
+
|
|
1082
|
+
// Start the audio engine
|
|
1083
|
+
try audioEngine.start()
|
|
1084
|
+
|
|
1085
|
+
// Start the compressed recorder if prepared
|
|
1086
|
+
compressedRecorder?.record()
|
|
1087
|
+
|
|
1088
|
+
// Show notifications if enabled
|
|
1089
|
+
if settings.showNotification {
|
|
1090
|
+
notificationManager?.startUpdates(startTime: startTime ?? Date())
|
|
1091
|
+
updateNowPlayingInfo(isPaused: false)
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
Logger.debug("Recording started successfully")
|
|
1095
|
+
|
|
1096
|
+
var compression = compressedRecorder != nil ? CompressedRecordingInfo(
|
|
1097
|
+
compressedFileUri: compressedFileURL?.absoluteString ?? "",
|
|
1098
|
+
mimeType: compressedFormat == "aac" ? "audio/aac" : "audio/opus",
|
|
1099
|
+
bitrate: compressedBitRate,
|
|
1100
|
+
format: compressedFormat
|
|
1101
|
+
) : nil
|
|
1102
|
+
|
|
1103
|
+
// Get the size separately since it's not part of the initializer
|
|
1104
|
+
if let compressedPath = compressedFileURL?.path,
|
|
1105
|
+
let attributes = try? FileManager.default.attributesOfItem(atPath: compressedPath),
|
|
1106
|
+
let fileSize = attributes[.size] as? Int64 {
|
|
1107
|
+
compression?.size = fileSize
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
return StartRecordingResult(
|
|
1111
|
+
fileUri: fileUri,
|
|
1112
|
+
mimeType: mimeType,
|
|
1113
|
+
channels: settings.numberOfChannels,
|
|
1114
|
+
bitDepth: settings.bitDepth,
|
|
1115
|
+
sampleRate: settings.sampleRate,
|
|
1116
|
+
compression: compression
|
|
1117
|
+
)
|
|
1118
|
+
|
|
1119
|
+
} catch {
|
|
1120
|
+
Logger.debug("Error starting audio engine: \(error.localizedDescription)")
|
|
1121
|
+
isRecording = false
|
|
1122
|
+
cleanupPreparation()
|
|
1123
|
+
return nil
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/// Cleans up resources if preparation was done but recording didn't start.
|
|
1128
|
+
private func cleanupPreparation() {
|
|
1129
|
+
// Only run if prepared but not recording
|
|
1130
|
+
guard isPrepared && !isRecording else { return }
|
|
1131
|
+
|
|
1132
|
+
Logger.debug("Cleaning up prepared resources that weren't used")
|
|
1133
|
+
|
|
1134
|
+
// Remove input tap
|
|
1135
|
+
audioEngine.inputNode.removeTap(onBus: 0)
|
|
1136
|
+
|
|
1137
|
+
// Stop compressed recorder if created but not started
|
|
1138
|
+
compressedRecorder?.stop()
|
|
1139
|
+
compressedRecorder = nil
|
|
1140
|
+
|
|
1141
|
+
// Delete created files that weren't used
|
|
1142
|
+
if let fileURL = recordingFileURL, FileManager.default.fileExists(atPath: fileURL.path) {
|
|
1143
|
+
try? FileManager.default.removeItem(at: fileURL)
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
if let compressedURL = compressedFileURL, FileManager.default.fileExists(atPath: compressedURL.path) {
|
|
1147
|
+
try? FileManager.default.removeItem(at: compressedURL)
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// Reset audio session
|
|
1151
|
+
do {
|
|
1152
|
+
try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
|
1153
|
+
} catch {
|
|
1154
|
+
Logger.debug("Error deactivating audio session: \(error)")
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// Clear notification setup if it was initialized
|
|
1158
|
+
notificationManager?.stopUpdates()
|
|
1159
|
+
notificationManager = nil
|
|
1160
|
+
|
|
1161
|
+
// --- Restore missing cleanup lines and remove log ---
|
|
1162
|
+
// Logger.debug("cleanupPreparation: Clearing recordingSettings. Current deviceId: \(recordingSettings?.deviceId ?? \"nil\")")
|
|
1163
|
+
recordingFileURL = nil // Restore
|
|
1164
|
+
compressedFileURL = nil // Restore
|
|
1165
|
+
audioProcessor = nil // Restore
|
|
1166
|
+
recordingSettings = nil
|
|
1167
|
+
isPrepared = false // Restore
|
|
1168
|
+
// --- End restored lines and removed log ---
|
|
1169
|
+
|
|
1170
|
+
Logger.debug("Preparation cleanup completed")
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/// Pauses the current audio recording.
|
|
1174
|
+
func pauseRecording() {
|
|
1175
|
+
guard isRecording, !isPaused else { return }
|
|
1176
|
+
|
|
1177
|
+
Logger.debug("Pausing recording...")
|
|
1178
|
+
|
|
1179
|
+
// Emit any remaining audio data before pausing
|
|
1180
|
+
if !accumulatedData.isEmpty {
|
|
1181
|
+
Logger.debug("Emitting final audio chunk of \(accumulatedData.count) bytes before pausing")
|
|
1182
|
+
let recordingTime = currentRecordingDuration()
|
|
1183
|
+
let finalTotalSize = self.totalDataSize
|
|
1184
|
+
|
|
1185
|
+
// Create a copy of accumulated data to avoid race conditions
|
|
1186
|
+
let finalData = accumulatedData
|
|
1187
|
+
accumulatedData.removeAll()
|
|
1188
|
+
|
|
1189
|
+
// Notify delegate with final audio data
|
|
1190
|
+
delegate?.audioStreamManager(
|
|
1191
|
+
self,
|
|
1192
|
+
didReceiveAudioData: finalData,
|
|
1193
|
+
recordingTime: recordingTime,
|
|
1194
|
+
totalDataSize: finalTotalSize,
|
|
1195
|
+
compressionInfo: buildCompressionInfo()
|
|
1196
|
+
)
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// Store when we paused
|
|
1200
|
+
currentPauseStart = Date()
|
|
1201
|
+
|
|
1202
|
+
// Update state
|
|
1203
|
+
isPaused = true
|
|
1204
|
+
|
|
1205
|
+
// Stop the engine but don't remove the tap
|
|
1206
|
+
audioEngine.pause()
|
|
1207
|
+
|
|
1208
|
+
// Pause the compressed recorder if active
|
|
1209
|
+
compressedRecorder?.pause()
|
|
1210
|
+
|
|
1211
|
+
// Update notification state if enabled
|
|
1212
|
+
if recordingSettings?.showNotification == true {
|
|
1213
|
+
updateNotificationState(isPaused: true)
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// Store valid duration for notifications
|
|
1217
|
+
lastValidDuration = currentRecordingDuration()
|
|
1218
|
+
|
|
1219
|
+
// Notify delegate
|
|
1220
|
+
delegate?.audioStreamManager(self, didPauseRecording: Date())
|
|
1221
|
+
|
|
1222
|
+
Logger.debug("Recording paused")
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/// Resumes a paused recording.
|
|
1226
|
+
func resumeRecording() {
|
|
1227
|
+
guard isRecording, isPaused else { return }
|
|
1228
|
+
|
|
1229
|
+
Logger.debug("Resuming recording...")
|
|
1230
|
+
|
|
1231
|
+
// Calculate and add the pause duration if we have a pause start time
|
|
1232
|
+
if let pauseStart = currentPauseStart {
|
|
1233
|
+
let pauseDuration = Date().timeIntervalSince(pauseStart)
|
|
1234
|
+
totalPausedDuration += pauseDuration
|
|
1235
|
+
currentPauseStart = nil // Reset pause start time
|
|
1236
|
+
Logger.debug("Added pause duration: \(pauseDuration), total paused: \(totalPausedDuration)")
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
do {
|
|
1240
|
+
// Check and reinstall tap with hardware format
|
|
1241
|
+
_ = installTapWithHardwareFormat()
|
|
1242
|
+
Logger.debug("Tap reinstalled for resume")
|
|
1243
|
+
|
|
1244
|
+
// Try to restart the engine
|
|
1245
|
+
try audioEngine.start()
|
|
1246
|
+
|
|
1247
|
+
// Resume the compressed recorder if active
|
|
1248
|
+
compressedRecorder?.record()
|
|
1249
|
+
|
|
1250
|
+
// Update state
|
|
1251
|
+
isPaused = false
|
|
1252
|
+
|
|
1253
|
+
// Update notification state if enabled
|
|
1254
|
+
if recordingSettings?.showNotification == true {
|
|
1255
|
+
updateNotificationState(isPaused: false)
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// Clear the stored valid duration
|
|
1259
|
+
lastValidDuration = nil
|
|
1260
|
+
|
|
1261
|
+
// Reset emission timers to ensure emission starts immediately after resume
|
|
1262
|
+
lastEmissionTime = Date()
|
|
1263
|
+
lastEmissionTimeAnalysis = Date()
|
|
1264
|
+
|
|
1265
|
+
// Notify delegate
|
|
1266
|
+
delegate?.audioStreamManager(self, didResumeRecording: Date())
|
|
1267
|
+
|
|
1268
|
+
Logger.debug("Recording resumed successfully")
|
|
1269
|
+
|
|
1270
|
+
} catch {
|
|
1271
|
+
Logger.debug("Failed to resume recording: \(error.localizedDescription)")
|
|
1272
|
+
delegate?.audioStreamManager(self, didFailWithError: "Failed to resume recording: \(error.localizedDescription)")
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
/// Initializes the notification manager to show recording notifications.
|
|
1277
|
+
private func initializeNotifications() {
|
|
1278
|
+
guard let settings = recordingSettings else { return }
|
|
1279
|
+
|
|
1280
|
+
// Create notification manager
|
|
1281
|
+
notificationManager = AudioNotificationManager()
|
|
1282
|
+
notificationManager?.initialize(with: settings.notification)
|
|
1283
|
+
|
|
1284
|
+
// Add pause/resume handlers via notification observers
|
|
1285
|
+
NotificationCenter.default.addObserver(
|
|
1286
|
+
self,
|
|
1287
|
+
selector: #selector(handlePauseNotification),
|
|
1288
|
+
name: Notification.Name("PAUSE_RECORDING"),
|
|
1289
|
+
object: nil
|
|
1290
|
+
)
|
|
1291
|
+
|
|
1292
|
+
NotificationCenter.default.addObserver(
|
|
1293
|
+
self,
|
|
1294
|
+
selector: #selector(handleResumeNotification),
|
|
1295
|
+
name: Notification.Name("RESUME_RECORDING"),
|
|
1296
|
+
object: nil
|
|
1297
|
+
)
|
|
1298
|
+
|
|
1299
|
+
// Setup media controls (iOS control center) if enabled
|
|
1300
|
+
setupNowPlayingInfo()
|
|
1301
|
+
|
|
1302
|
+
// Set up timer to update media info
|
|
1303
|
+
mediaInfoUpdateTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
|
1304
|
+
self?.updateMediaInfo()
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
/// Resample an audio buffer to a different sample rate.
|
|
1309
|
+
/// - Parameters:
|
|
1310
|
+
/// - buffer: The source audio buffer.
|
|
1311
|
+
/// - sourceRate: The source sample rate.
|
|
1312
|
+
/// - targetRate: The target sample rate.
|
|
1313
|
+
/// - Returns: The resampled audio buffer or nil if resampling failed.
|
|
1314
|
+
private func resampleAudioBuffer(_ buffer: AVAudioPCMBuffer, from sourceRate: Double, to targetRate: Double) -> AVAudioPCMBuffer? {
|
|
1315
|
+
// If the rates are the same, no need to resample
|
|
1316
|
+
if sourceRate == targetRate {
|
|
1317
|
+
return buffer
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
// Create source and target formats
|
|
1321
|
+
guard let outputFormat = AVAudioFormat(
|
|
1322
|
+
commonFormat: buffer.format.commonFormat,
|
|
1323
|
+
sampleRate: targetRate,
|
|
1324
|
+
channels: buffer.format.channelCount,
|
|
1325
|
+
interleaved: buffer.format.isInterleaved
|
|
1326
|
+
) else {
|
|
1327
|
+
Logger.debug("Failed to create output format for resampling")
|
|
1328
|
+
return nil
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
// Create a converter
|
|
1332
|
+
guard let converter = AVAudioConverter(from: buffer.format, to: outputFormat) else {
|
|
1333
|
+
Logger.debug("Failed to create audio converter")
|
|
1334
|
+
return nil
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
// Calculate new buffer size
|
|
1338
|
+
let ratio = targetRate / sourceRate
|
|
1339
|
+
let estimatedFrames = AVAudioFrameCount(Double(buffer.frameLength) * ratio)
|
|
1340
|
+
|
|
1341
|
+
// Create output buffer
|
|
1342
|
+
guard let outputBuffer = AVAudioPCMBuffer(
|
|
1343
|
+
pcmFormat: outputFormat,
|
|
1344
|
+
frameCapacity: estimatedFrames
|
|
1345
|
+
) else {
|
|
1346
|
+
Logger.debug("Failed to create output buffer")
|
|
1347
|
+
return nil
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
// Perform conversion
|
|
1351
|
+
var error: NSError?
|
|
1352
|
+
converter.convert(to: outputBuffer, error: &error) { inNumPackets, outStatus in
|
|
1353
|
+
outStatus.pointee = .haveData
|
|
1354
|
+
return buffer
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
if let error = error {
|
|
1358
|
+
Logger.debug("Error resampling audio: \(error.localizedDescription)")
|
|
1359
|
+
return nil
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
return outputBuffer
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
/// Describes the format of the given audio format.
|
|
1366
|
+
/// - Parameter format: The AVAudioFormat object to describe.
|
|
1367
|
+
/// - Returns: A string description of the audio format.
|
|
1368
|
+
func describeAudioFormat(_ format: AVAudioFormat) -> String {
|
|
1369
|
+
let formatDescription = """
|
|
1370
|
+
- Sample rate: \(format.sampleRate)Hz
|
|
1371
|
+
- Channels: \(format.channelCount)
|
|
1372
|
+
- Interleaved: \(format.isInterleaved)
|
|
1373
|
+
- Common format: \(describeCommonFormat(format.commonFormat))
|
|
1374
|
+
"""
|
|
1375
|
+
return formatDescription
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
func describeCommonFormat(_ format: AVAudioCommonFormat) -> String {
|
|
1379
|
+
switch format {
|
|
1380
|
+
case .pcmFormatFloat32:
|
|
1381
|
+
return "32-bit float"
|
|
1382
|
+
case .pcmFormatFloat64:
|
|
1383
|
+
return "64-bit float"
|
|
1384
|
+
case .pcmFormatInt16:
|
|
1385
|
+
return "16-bit int"
|
|
1386
|
+
case .pcmFormatInt32:
|
|
1387
|
+
return "32-bit int"
|
|
1388
|
+
default:
|
|
1389
|
+
return "Unknown format"
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
/// Updates the WAV header with the correct file size.
|
|
1394
|
+
/// - Parameters:
|
|
1395
|
+
/// - fileURL: The URL of the WAV file.
|
|
1396
|
+
/// - totalDataSize: The total size of the audio data.
|
|
1397
|
+
private func updateWavHeader(fileURL: URL, totalDataSize: Int64) {
|
|
1398
|
+
// Prevent negative values - minimum WAV file size should be at least the header size (WAV_HEADER_SIZE bytes)
|
|
1399
|
+
guard totalDataSize >= 0 else {
|
|
1400
|
+
Logger.debug("Invalid file size: total data size is negative")
|
|
1401
|
+
return
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
do {
|
|
1405
|
+
let fileHandle = try FileHandle(forUpdating: fileURL)
|
|
1406
|
+
defer { fileHandle.closeFile() }
|
|
1407
|
+
|
|
1408
|
+
// Calculate sizes
|
|
1409
|
+
let fileSize = totalDataSize + WAV_HEADER_SIZE - 8 // Total file size minus 8 bytes for 'RIFF' and size field itself
|
|
1410
|
+
let dataSize = totalDataSize // Size of the 'data' sub-chunk
|
|
1411
|
+
|
|
1412
|
+
// Update RIFF chunk size at offset 4
|
|
1413
|
+
fileHandle.seek(toFileOffset: 4)
|
|
1414
|
+
let fileSizeBytes = UInt32(fileSize).littleEndianBytes
|
|
1415
|
+
fileHandle.write(Data(fileSizeBytes))
|
|
1416
|
+
|
|
1417
|
+
// Update data chunk size at offset 40
|
|
1418
|
+
fileHandle.seek(toFileOffset: 40)
|
|
1419
|
+
let dataSizeBytes = UInt32(dataSize).littleEndianBytes
|
|
1420
|
+
fileHandle.write(Data(dataSizeBytes))
|
|
1421
|
+
|
|
1422
|
+
} catch let error {
|
|
1423
|
+
Logger.debug("Error updating WAV header: \(error)")
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
private func updateNotificationDuration() {
|
|
1428
|
+
guard let startTime = startTime,
|
|
1429
|
+
recordingSettings?.showNotification == true else { return }
|
|
1430
|
+
|
|
1431
|
+
let currentDuration = Date().timeIntervalSince(startTime) - TimeInterval(totalPausedDuration)
|
|
1432
|
+
|
|
1433
|
+
// Update both notification manager and media player
|
|
1434
|
+
notificationManager?.updateDuration(currentDuration)
|
|
1435
|
+
|
|
1436
|
+
if let notificationView = notificationView {
|
|
1437
|
+
var nowPlayingInfo = notificationView.nowPlayingInfo ?? [:]
|
|
1438
|
+
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentDuration
|
|
1439
|
+
notificationView.nowPlayingInfo = nowPlayingInfo
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
/// Processes the audio buffer: handles resampling/format conversion if necessary,
|
|
1444
|
+
/// optionally writes the result to the WAV file on a background thread (if primary output is enabled),
|
|
1445
|
+
/// and triggers analysis processing and event emission based on intervals.
|
|
1446
|
+
/// Audio streaming happens regardless of file output settings.
|
|
1447
|
+
/// - Parameters:
|
|
1448
|
+
/// - buffer: The audio buffer received from the input node tap.
|
|
1449
|
+
private func processAudioBuffer(_ buffer: AVAudioPCMBuffer) {
|
|
1450
|
+
guard let settings = recordingSettings else {
|
|
1451
|
+
Logger.debug("processAudioBuffer: Recording settings not available")
|
|
1452
|
+
return
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
// DEBUG: Add tap and buffer info
|
|
1456
|
+
debugBufferCounter += 1
|
|
1457
|
+
|
|
1458
|
+
// Log every 10th buffer to avoid excessive logs
|
|
1459
|
+
if debugBufferCounter % 10 == 0 {
|
|
1460
|
+
Logger.debug("BUFFER DEBUG: Processing buffer #\(debugBufferCounter), channelCount: \(buffer.format.channelCount), frameLength: \(buffer.frameLength)")
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
// targetSampleRate and targetFormat remain the user's requested final format
|
|
1464
|
+
let targetSampleRate = Double(settings.sampleRate)
|
|
1465
|
+
let targetFormat: AVAudioCommonFormat = settings.bitDepth == 32 ? .pcmFormatFloat32 : .pcmFormatInt16
|
|
1466
|
+
|
|
1467
|
+
// Log bit depth information
|
|
1468
|
+
Logger.debug("""
|
|
1469
|
+
BIT DEPTH DEBUG:
|
|
1470
|
+
- Settings bitDepth: \(settings.bitDepth)
|
|
1471
|
+
- Target format: \(targetFormat == .pcmFormatFloat32 ? "pcmFormatFloat32" : "pcmFormatInt16")
|
|
1472
|
+
- Buffer format: \(buffer.format.commonFormat.rawValue)
|
|
1473
|
+
- Buffer sample rate: \(buffer.format.sampleRate)
|
|
1474
|
+
- Target sample rate: \(targetSampleRate)
|
|
1475
|
+
""")
|
|
1476
|
+
|
|
1477
|
+
// Buffer to be processed - initially the input buffer
|
|
1478
|
+
var bufferToProcess: AVAudioPCMBuffer = buffer
|
|
1479
|
+
|
|
1480
|
+
// 1. Resample if the buffer's sample rate doesn't match the target
|
|
1481
|
+
if buffer.format.sampleRate != targetSampleRate {
|
|
1482
|
+
if let resampled = resampleAudioBuffer(buffer, from: buffer.format.sampleRate, to: targetSampleRate) {
|
|
1483
|
+
bufferToProcess = resampled
|
|
1484
|
+
} else {
|
|
1485
|
+
Logger.debug("processAudioBuffer: Resampling FAILED")
|
|
1486
|
+
return
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
// 2. Convert format if the (potentially resampled) buffer's format doesn't match the target
|
|
1491
|
+
if bufferToProcess.format.commonFormat != targetFormat {
|
|
1492
|
+
guard let targetAVFormat = AVAudioFormat(
|
|
1493
|
+
commonFormat: targetFormat,
|
|
1494
|
+
sampleRate: targetSampleRate, // Use target rate for final format
|
|
1495
|
+
channels: AVAudioChannelCount(settings.numberOfChannels),
|
|
1496
|
+
interleaved: bufferToProcess.format.isInterleaved // Match interleaving of current buffer
|
|
1497
|
+
) else {
|
|
1498
|
+
Logger.debug("processAudioBuffer: Failed to create target AVAudioFormat for conversion.")
|
|
1499
|
+
return
|
|
1500
|
+
}
|
|
1501
|
+
if let converted = convertBufferFormat(bufferToProcess, to: targetAVFormat) {
|
|
1502
|
+
bufferToProcess = converted
|
|
1503
|
+
} else {
|
|
1504
|
+
Logger.debug("processAudioBuffer: Format conversion FAILED")
|
|
1505
|
+
return
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
// Now bufferToProcess contains the audio data in the desired sample rate and format
|
|
1510
|
+
let audioData = bufferToProcess.audioBufferList.pointee.mBuffers
|
|
1511
|
+
guard let bufferData = audioData.mData else {
|
|
1512
|
+
Logger.debug("Buffer data is nil after processing.")
|
|
1513
|
+
return
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
// Create an immutable copy for background/event emission
|
|
1517
|
+
let dataToWrite = Data(bytes: bufferData, count: Int(audioData.mDataByteSize))
|
|
1518
|
+
|
|
1519
|
+
// --- Background File Writing (Optional) ---
|
|
1520
|
+
// Only write to file if primary output is enabled
|
|
1521
|
+
if settings.output.primary.enabled {
|
|
1522
|
+
// Use the persistent fileHandle opened during preparation.
|
|
1523
|
+
DispatchQueue.global(qos: .utility).async { [weak self] in
|
|
1524
|
+
guard let self = self, let handle = self.fileHandle else {
|
|
1525
|
+
Logger.debug("BG Write Error: File handle is nil.")
|
|
1526
|
+
return
|
|
1527
|
+
}
|
|
1528
|
+
do {
|
|
1529
|
+
try handle.seekToEnd()
|
|
1530
|
+
try handle.write(contentsOf: dataToWrite)
|
|
1531
|
+
// Update total size state
|
|
1532
|
+
self.totalDataSize += Int64(dataToWrite.count)
|
|
1533
|
+
// Cache WAV file size for performance
|
|
1534
|
+
self.cachedWavFileSize = self.totalDataSize
|
|
1535
|
+
} catch {
|
|
1536
|
+
Logger.debug("BG Write Error: Failed to seek/write: \(error.localizedDescription)")
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
} else {
|
|
1540
|
+
// Still track total size for statistics even without file writing
|
|
1541
|
+
self.totalDataSize += Int64(dataToWrite.count)
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
// --- Event Emission & Analysis (Always Happens) ---
|
|
1545
|
+
// Audio streaming is independent of file output settings
|
|
1546
|
+
accumulatedData.append(dataToWrite)
|
|
1547
|
+
accumulatedAnalysisData.append(dataToWrite)
|
|
1548
|
+
|
|
1549
|
+
if recordingSettings?.showNotification == true {
|
|
1550
|
+
updateNotificationDuration()
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
let currentTime = Date()
|
|
1554
|
+
let currentTotalSize = self.totalDataSize // Use the most up-to-date size for events
|
|
1555
|
+
|
|
1556
|
+
// Emit AudioData event
|
|
1557
|
+
if let lastEmission = self.lastEmissionTime {
|
|
1558
|
+
// Log emission evaluation every 10th buffer
|
|
1559
|
+
if debugBufferCounter % 10 == 0 {
|
|
1560
|
+
let timeGap = currentTime.timeIntervalSince(lastEmission)
|
|
1561
|
+
let isTimeReady = timeGap >= emissionInterval
|
|
1562
|
+
Logger.debug("EMISSION DEBUG: Time since last: \(timeGap)s, Threshold: \(emissionInterval)s, Ready: \(isTimeReady), DataSize: \(accumulatedData.count) bytes")
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
if currentTime.timeIntervalSince(lastEmission) >= emissionInterval,
|
|
1566
|
+
!accumulatedData.isEmpty {
|
|
1567
|
+
let dataToEmit = accumulatedData
|
|
1568
|
+
let recordingTime = currentRecordingDuration()
|
|
1569
|
+
self.lastEmissionTime = currentTime
|
|
1570
|
+
self.lastEmittedSize = currentTotalSize
|
|
1571
|
+
accumulatedData.removeAll()
|
|
1572
|
+
let compressionInfo = buildCompressionInfo()
|
|
1573
|
+
|
|
1574
|
+
Logger.debug("EMISSION SUCCESS: Emitting \(dataToEmit.count) bytes at recording time \(recordingTime)s")
|
|
1575
|
+
|
|
1576
|
+
delegate?.audioStreamManager(
|
|
1577
|
+
self,
|
|
1578
|
+
didReceiveAudioData: dataToEmit,
|
|
1579
|
+
recordingTime: recordingTime,
|
|
1580
|
+
totalDataSize: currentTotalSize,
|
|
1581
|
+
compressionInfo: compressionInfo
|
|
1582
|
+
)
|
|
1583
|
+
}
|
|
1584
|
+
} else {
|
|
1585
|
+
// This case occurs when lastEmissionTime is nil (either first run or after reset)
|
|
1586
|
+
Logger.debug("EMISSION DEBUG: lastEmissionTime is nil, setting to current time")
|
|
1587
|
+
lastEmissionTime = currentTime
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
// Dispatch analysis task
|
|
1591
|
+
if let lastEmissionAnalysis = self.lastEmissionTimeAnalysis,
|
|
1592
|
+
currentTime.timeIntervalSince(lastEmissionAnalysis) >= emissionIntervalAnalysis,
|
|
1593
|
+
settings.enableProcessing,
|
|
1594
|
+
let _ = self.audioProcessor,
|
|
1595
|
+
!accumulatedAnalysisData.isEmpty {
|
|
1596
|
+
let dataToAnalyze = accumulatedAnalysisData
|
|
1597
|
+
self.lastEmissionTimeAnalysis = currentTime
|
|
1598
|
+
accumulatedAnalysisData.removeAll()
|
|
1599
|
+
|
|
1600
|
+
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
|
1601
|
+
guard let self = self, let processor = self.audioProcessor, let settings = self.recordingSettings else {
|
|
1602
|
+
// Logger.debug("Analysis Dispatch SKIP: self, processor, or settings nil")
|
|
1603
|
+
return
|
|
1604
|
+
}
|
|
1605
|
+
guard !dataToAnalyze.isEmpty else {
|
|
1606
|
+
// Logger.debug("Analysis Dispatch SKIP: dataToAnalyze is empty")
|
|
1607
|
+
return
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
// Logger.debug("Analysis Dispatch: Processing \(dataToAnalyze.count) bytes...")
|
|
1611
|
+
let processingResult = processor.processAudioBuffer(
|
|
1612
|
+
data: dataToAnalyze,
|
|
1613
|
+
sampleRate: Float(settings.sampleRate),
|
|
1614
|
+
segmentDurationMs: settings.segmentDurationMs,
|
|
1615
|
+
featureOptions: settings.featureOptions ?? [:],
|
|
1616
|
+
bitDepth: settings.bitDepth,
|
|
1617
|
+
numberOfChannels: settings.numberOfChannels
|
|
1618
|
+
)
|
|
1619
|
+
|
|
1620
|
+
// Dispatch result back to main thread
|
|
1621
|
+
DispatchQueue.main.async {
|
|
1622
|
+
if let result = processingResult {
|
|
1623
|
+
// Logger.debug("Analysis Dispatch: Success, calling delegate.")
|
|
1624
|
+
self.delegate?.audioStreamManager(self, didReceiveProcessingResult: result)
|
|
1625
|
+
} else {
|
|
1626
|
+
Logger.debug("Analysis Dispatch FAIL: processor.processAudioBuffer returned nil")
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
// Logger.debug("Dispatched analysis task.") // Optional: Re-enable if needed
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
// Add helper function to calculate average amplitude
|
|
1635
|
+
private func calculateAverageAmplitude(_ data: UnsafePointer<Float>, count: Int) -> Float {
|
|
1636
|
+
var sum: Float = 0
|
|
1637
|
+
vDSP_meanv(data, 1, &sum, vDSP_Length(count))
|
|
1638
|
+
return sum
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
// Add helper function to calculate RMS
|
|
1642
|
+
private func calculateRMS(_ data: UnsafePointer<Float>, count: Int) -> Float {
|
|
1643
|
+
var sum: Float = 0
|
|
1644
|
+
var squaredSum: Float = 0
|
|
1645
|
+
for i in 0..<count {
|
|
1646
|
+
let value = data[i]
|
|
1647
|
+
sum += value
|
|
1648
|
+
squaredSum += value * value
|
|
1649
|
+
}
|
|
1650
|
+
let average = sum / Float(count)
|
|
1651
|
+
let variance = squaredSum / Float(count) - average * average
|
|
1652
|
+
return sqrt(variance)
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
// Helper function for format conversion
|
|
1656
|
+
private func convertBufferFormat(_ buffer: AVAudioPCMBuffer, to targetFormat: AVAudioFormat) -> AVAudioPCMBuffer? {
|
|
1657
|
+
guard let converter = AVAudioConverter(from: buffer.format, to: targetFormat),
|
|
1658
|
+
let outputBuffer = AVAudioPCMBuffer(
|
|
1659
|
+
pcmFormat: targetFormat,
|
|
1660
|
+
frameCapacity: buffer.frameLength
|
|
1661
|
+
) else {
|
|
1662
|
+
return nil
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
outputBuffer.frameLength = buffer.frameLength
|
|
1666
|
+
var error: NSError?
|
|
1667
|
+
|
|
1668
|
+
converter.convert(to: outputBuffer, error: &error) { inNumPackets, outStatus in
|
|
1669
|
+
outStatus.pointee = AVAudioConverterInputStatus.haveData
|
|
1670
|
+
return buffer
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
if let error = error {
|
|
1674
|
+
Logger.debug("Format conversion failed: \(error.localizedDescription)")
|
|
1675
|
+
return nil
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
return outputBuffer
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
/// Performs the full engine stop → reset → reinstall tap → restart cycle for a device switch.
|
|
1682
|
+
/// Pass `nil` to revert to the system-default input.
|
|
1683
|
+
/// This is the single source of truth for all device-switch paths (Bug 1 + Bug 2 fixes).
|
|
1684
|
+
public func performDeviceSwitch(port: AVAudioSessionPortDescription?) {
|
|
1685
|
+
let wasRunning = audioEngine.isRunning
|
|
1686
|
+
do {
|
|
1687
|
+
if wasRunning { audioEngine.stop() }
|
|
1688
|
+
audioEngine.inputNode.removeTap(onBus: 0)
|
|
1689
|
+
try AVAudioSession.sharedInstance().setPreferredInput(port)
|
|
1690
|
+
Thread.sleep(forTimeInterval: 0.15)
|
|
1691
|
+
audioEngine.reset()
|
|
1692
|
+
_ = installTapWithHardwareFormat()
|
|
1693
|
+
if wasRunning {
|
|
1694
|
+
audioEngine.prepare()
|
|
1695
|
+
try audioEngine.start()
|
|
1696
|
+
lastEmissionTime = Date()
|
|
1697
|
+
lastEmissionTimeAnalysis = Date()
|
|
1698
|
+
Logger.debug("AudioStreamManager", "Device switch complete; engine restarted (port: \(port?.portName ?? "default"))")
|
|
1699
|
+
}
|
|
1700
|
+
} catch {
|
|
1701
|
+
Logger.debug("AudioStreamManager", "Device switch failed: \(error.localizedDescription)")
|
|
1702
|
+
if wasRunning {
|
|
1703
|
+
do {
|
|
1704
|
+
_ = installTapWithHardwareFormat() // Bug 2 fix: reinstall tap in recovery path
|
|
1705
|
+
audioEngine.prepare()
|
|
1706
|
+
try audioEngine.start()
|
|
1707
|
+
Logger.debug("AudioStreamManager", "Engine recovery after device switch succeeded")
|
|
1708
|
+
} catch {
|
|
1709
|
+
Logger.debug("AudioStreamManager", "Engine recovery failed: \(error.localizedDescription)")
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
/// Attempts to update the audio session with the preferred input device from current settings.
|
|
1716
|
+
/// Called externally when the device selection changes.
|
|
1717
|
+
public func updateAudioSessionWithCurrentSettings() {
|
|
1718
|
+
guard let settings = self.recordingSettings, let deviceId = settings.deviceId else {
|
|
1719
|
+
Logger.debug("Cannot update audio session preference, settings or deviceId missing")
|
|
1720
|
+
return
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
let session = AVAudioSession.sharedInstance()
|
|
1724
|
+
let selectedPort = session.availableInputs?.first { port in
|
|
1725
|
+
deviceManager.normalizeBluetoothDeviceId(port.uid) == deviceManager.normalizeBluetoothDeviceId(deviceId)
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
guard let portToSet = selectedPort else {
|
|
1729
|
+
Logger.debug("Could not find device with ID \(deviceId)")
|
|
1730
|
+
return
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
performDeviceSwitch(port: portToSet)
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
/// Stops the current audio recording.
|
|
1737
|
+
/// - Returns: A RecordingResult object if the recording stopped successfully, or nil otherwise.
|
|
1738
|
+
/// - Throws: An error if recording stops with a problem.
|
|
1739
|
+
func stopRecording() -> RecordingResult? {
|
|
1740
|
+
guard isRecording || isPrepared else { return nil }
|
|
1741
|
+
|
|
1742
|
+
// Set stopping flag to prevent race conditions with background/foreground transitions
|
|
1743
|
+
stopping = true
|
|
1744
|
+
|
|
1745
|
+
Logger.debug("Stopping recording...")
|
|
1746
|
+
|
|
1747
|
+
// IMPORTANT: Emit any remaining audio data before stopping the engine
|
|
1748
|
+
if isRecording && !accumulatedData.isEmpty {
|
|
1749
|
+
Logger.debug("Emitting final audio chunk of \(accumulatedData.count) bytes before stopping")
|
|
1750
|
+
let recordingTime = currentRecordingDuration()
|
|
1751
|
+
let finalTotalSize = self.totalDataSize // Use current total size
|
|
1752
|
+
|
|
1753
|
+
// Create a copy of accumulated data to avoid race conditions
|
|
1754
|
+
let finalData = accumulatedData
|
|
1755
|
+
accumulatedData.removeAll()
|
|
1756
|
+
|
|
1757
|
+
// Notify delegate with final audio data
|
|
1758
|
+
delegate?.audioStreamManager(
|
|
1759
|
+
self,
|
|
1760
|
+
didReceiveAudioData: finalData,
|
|
1761
|
+
recordingTime: recordingTime,
|
|
1762
|
+
totalDataSize: finalTotalSize,
|
|
1763
|
+
compressionInfo: buildCompressionInfo()
|
|
1764
|
+
)
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
disableWakeLock()
|
|
1768
|
+
|
|
1769
|
+
// Handle audio engine operations directly - no need for try-catch
|
|
1770
|
+
if audioEngine.isRunning {
|
|
1771
|
+
audioEngine.stop()
|
|
1772
|
+
}
|
|
1773
|
+
audioEngine.inputNode.removeTap(onBus: 0)
|
|
1774
|
+
|
|
1775
|
+
// Stop compressed recording if active and update cached size
|
|
1776
|
+
if let recorder = compressedRecorder {
|
|
1777
|
+
recorder.stop()
|
|
1778
|
+
|
|
1779
|
+
// Update cached compressed file size after stopping
|
|
1780
|
+
if let compressedURL = compressedFileURL {
|
|
1781
|
+
do {
|
|
1782
|
+
let attributes = try FileManager.default.attributesOfItem(atPath: compressedURL.path)
|
|
1783
|
+
if let size = attributes[.size] as? Int64 {
|
|
1784
|
+
cachedCompressedFileSize = size
|
|
1785
|
+
Logger.debug("Updated compressed file size after stop: \(size) bytes")
|
|
1786
|
+
}
|
|
1787
|
+
} catch {
|
|
1788
|
+
Logger.debug("Failed to update compressed file size: \(error)")
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
// Get the final duration before changing state
|
|
1794
|
+
let finalDuration = currentRecordingDuration()
|
|
1795
|
+
|
|
1796
|
+
let wasRecording = isRecording
|
|
1797
|
+
isRecording = false
|
|
1798
|
+
isPaused = false
|
|
1799
|
+
isPrepared = false // Reset preparation state
|
|
1800
|
+
|
|
1801
|
+
// If we were only prepared but never started recording, clean up and return nil
|
|
1802
|
+
if !wasRecording {
|
|
1803
|
+
cleanupPreparation()
|
|
1804
|
+
stopping = false // Reset stopping flag
|
|
1805
|
+
return nil
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
// PERFORMANCE OPTIMIZATION: Capture current state for immediate return
|
|
1809
|
+
let capturedFileURL = recordingFileURL
|
|
1810
|
+
let capturedSettings = recordingSettings
|
|
1811
|
+
let capturedWavFileSize = cachedWavFileSize
|
|
1812
|
+
let capturedCompressedFileSize = cachedCompressedFileSize
|
|
1813
|
+
let capturedTotalDataSize = totalDataSize
|
|
1814
|
+
let capturedCompressedURL = compressedFileURL
|
|
1815
|
+
|
|
1816
|
+
// PERFORMANCE OPTIMIZATION: Move all slow operations to background
|
|
1817
|
+
let capturedShowNotification = recordingSettings?.showNotification == true
|
|
1818
|
+
|
|
1819
|
+
// Queue notification and audio session cleanup for background
|
|
1820
|
+
DispatchQueue.global(qos: .utility).async { [weak self] in
|
|
1821
|
+
guard let self = self else { return }
|
|
1822
|
+
|
|
1823
|
+
if capturedShowNotification {
|
|
1824
|
+
// Clean up notifications on main queue but don't wait
|
|
1825
|
+
DispatchQueue.main.async {
|
|
1826
|
+
self.mediaInfoUpdateTimer?.invalidate()
|
|
1827
|
+
self.mediaInfoUpdateTimer = nil
|
|
1828
|
+
|
|
1829
|
+
// Clean up notification manager
|
|
1830
|
+
self.notificationManager?.stopUpdates()
|
|
1831
|
+
self.notificationManager = nil
|
|
1832
|
+
|
|
1833
|
+
// Clean up media controls
|
|
1834
|
+
UIApplication.shared.endReceivingRemoteControlEvents()
|
|
1835
|
+
self.remoteCommandCenter?.pauseCommand.isEnabled = false
|
|
1836
|
+
self.remoteCommandCenter?.playCommand.isEnabled = false
|
|
1837
|
+
self.notificationView?.nowPlayingInfo = nil
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
// Reset audio session in background
|
|
1842
|
+
do {
|
|
1843
|
+
try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
|
1844
|
+
} catch {
|
|
1845
|
+
Logger.debug("Background: Error deactivating audio session: \(error)")
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
// Reset audio engine in background
|
|
1849
|
+
DispatchQueue.main.async {
|
|
1850
|
+
self.audioEngine.reset()
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
guard let settings = recordingSettings else {
|
|
1855
|
+
Logger.debug("Recording settings is nil.")
|
|
1856
|
+
stopping = false // Reset stopping flag before returning nil
|
|
1857
|
+
return nil
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
// For streaming-only mode (no primary output), create a result without file validation
|
|
1861
|
+
if !settings.output.primary.enabled {
|
|
1862
|
+
let durationMs = Int64(finalDuration * 1000)
|
|
1863
|
+
|
|
1864
|
+
// Check for compressed output using cached size
|
|
1865
|
+
var compression: CompressedRecordingInfo?
|
|
1866
|
+
if settings.output.compressed.enabled,
|
|
1867
|
+
let compressedURL = capturedCompressedURL,
|
|
1868
|
+
capturedCompressedFileSize > 0 {
|
|
1869
|
+
compression = CompressedRecordingInfo(
|
|
1870
|
+
compressedFileUri: compressedURL.absoluteString,
|
|
1871
|
+
mimeType: compressedFormat == "aac" ? "audio/aac" : "audio/opus",
|
|
1872
|
+
bitrate: compressedBitRate,
|
|
1873
|
+
format: compressedFormat,
|
|
1874
|
+
size: capturedCompressedFileSize
|
|
1875
|
+
)
|
|
1876
|
+
|
|
1877
|
+
Logger.debug("""
|
|
1878
|
+
Compressed File (cached - primary disabled):
|
|
1879
|
+
- Format: \(compressedFormat)
|
|
1880
|
+
- Size: \(capturedCompressedFileSize) bytes
|
|
1881
|
+
- Bitrate: \(compressedBitRate) bps
|
|
1882
|
+
""")
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
let result = RecordingResult(
|
|
1886
|
+
fileUri: compression?.compressedFileUri ?? "", // Use compressed URI if available
|
|
1887
|
+
filename: compression != nil ? (compressedFileURL?.lastPathComponent ?? "compressed-audio") : "stream-only",
|
|
1888
|
+
mimeType: compression?.mimeType ?? mimeType,
|
|
1889
|
+
duration: durationMs,
|
|
1890
|
+
size: compression?.size ?? totalDataSize,
|
|
1891
|
+
channels: settings.numberOfChannels,
|
|
1892
|
+
bitDepth: settings.bitDepth,
|
|
1893
|
+
sampleRate: settings.sampleRate,
|
|
1894
|
+
compression: compression
|
|
1895
|
+
)
|
|
1896
|
+
|
|
1897
|
+
// Cleanup
|
|
1898
|
+
recordingSettings = nil
|
|
1899
|
+
startTime = nil
|
|
1900
|
+
totalPausedDuration = 0
|
|
1901
|
+
currentPauseStart = nil
|
|
1902
|
+
lastEmissionTime = nil
|
|
1903
|
+
lastEmissionTimeAnalysis = nil
|
|
1904
|
+
lastEmittedSize = 0
|
|
1905
|
+
lastEmittedSizeAnalysis = 0
|
|
1906
|
+
lastEmittedCompressedSize = 0
|
|
1907
|
+
accumulatedData.removeAll()
|
|
1908
|
+
accumulatedAnalysisData.removeAll()
|
|
1909
|
+
recordingUUID = nil
|
|
1910
|
+
totalDataSize = 0
|
|
1911
|
+
|
|
1912
|
+
stopping = false
|
|
1913
|
+
return result
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
guard let fileURL = capturedFileURL else {
|
|
1917
|
+
Logger.debug("Recording file URL is nil.")
|
|
1918
|
+
stopping = false // Reset stopping flag before returning nil
|
|
1919
|
+
return nil
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
// PERFORMANCE OPTIMIZATION: Create result immediately with cached values
|
|
1923
|
+
let durationMs = Int64(finalDuration * 1000)
|
|
1924
|
+
|
|
1925
|
+
// Check compressed output
|
|
1926
|
+
var compression: CompressedRecordingInfo?
|
|
1927
|
+
if capturedSettings?.output.compressed.enabled == true,
|
|
1928
|
+
let compressedURL = capturedCompressedURL,
|
|
1929
|
+
capturedCompressedFileSize > 0 {
|
|
1930
|
+
compression = CompressedRecordingInfo(
|
|
1931
|
+
compressedFileUri: compressedURL.absoluteString,
|
|
1932
|
+
mimeType: compressedFormat == "aac" ? "audio/aac" : "audio/opus",
|
|
1933
|
+
bitrate: compressedBitRate,
|
|
1934
|
+
format: compressedFormat,
|
|
1935
|
+
size: capturedCompressedFileSize
|
|
1936
|
+
)
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
// Create result with cached values - no file system access
|
|
1940
|
+
let result = RecordingResult(
|
|
1941
|
+
fileUri: fileURL.absoluteString,
|
|
1942
|
+
filename: fileURL.lastPathComponent,
|
|
1943
|
+
mimeType: mimeType,
|
|
1944
|
+
duration: durationMs,
|
|
1945
|
+
size: capturedWavFileSize,
|
|
1946
|
+
channels: capturedSettings?.numberOfChannels ?? 1,
|
|
1947
|
+
bitDepth: capturedSettings?.bitDepth ?? 16,
|
|
1948
|
+
sampleRate: capturedSettings?.sampleRate ?? 44100,
|
|
1949
|
+
compression: compression
|
|
1950
|
+
)
|
|
1951
|
+
|
|
1952
|
+
// Perform file operations asynchronously after returning result
|
|
1953
|
+
DispatchQueue.global(qos: .utility).async { [weak self] in
|
|
1954
|
+
guard let self = self else { return }
|
|
1955
|
+
|
|
1956
|
+
// Update WAV header in background
|
|
1957
|
+
let finalDataChunkSize = capturedTotalDataSize - Int64(WAV_HEADER_SIZE)
|
|
1958
|
+
if finalDataChunkSize > 0 {
|
|
1959
|
+
self.updateWavHeader(fileURL: fileURL, totalDataSize: finalDataChunkSize)
|
|
1960
|
+
Logger.debug("Background: WAV header updated. Data chunk size: \(finalDataChunkSize)")
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
// Cleanup
|
|
1964
|
+
self.recordingSettings = nil
|
|
1965
|
+
self.startTime = nil
|
|
1966
|
+
self.totalPausedDuration = 0
|
|
1967
|
+
self.currentPauseStart = nil
|
|
1968
|
+
self.lastEmissionTime = nil
|
|
1969
|
+
self.lastEmissionTimeAnalysis = nil
|
|
1970
|
+
self.lastEmittedSize = 0
|
|
1971
|
+
self.lastEmittedSizeAnalysis = 0
|
|
1972
|
+
self.lastEmittedCompressedSize = 0
|
|
1973
|
+
self.accumulatedData.removeAll()
|
|
1974
|
+
self.accumulatedAnalysisData.removeAll()
|
|
1975
|
+
self.recordingUUID = nil
|
|
1976
|
+
self.totalDataSize = 0
|
|
1977
|
+
self.cachedWavFileSize = 0
|
|
1978
|
+
self.cachedCompressedFileSize = 0
|
|
1979
|
+
self.recordingFileURL = nil
|
|
1980
|
+
self.compressedFileURL = nil
|
|
1981
|
+
self.fileHandle = nil
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
stopping = false
|
|
1985
|
+
return result
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
|
|
1989
|
+
// MARK: - AudioDeviceManagerDelegate Implementation
|
|
1990
|
+
|
|
1991
|
+
func audioDeviceManager(_ manager: AudioDeviceManager, didDetectDisconnectionOfDevice disconnectedDeviceId: String) {
|
|
1992
|
+
// This method will be called by AudioDeviceManager when a disconnection occurs
|
|
1993
|
+
// Run on main thread to safely interact with AVAudioEngine and state
|
|
1994
|
+
DispatchQueue.main.async {
|
|
1995
|
+
self.handleDeviceDisconnection(disconnectedDeviceId: disconnectedDeviceId)
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
// MARK: - Device Disconnection Handling
|
|
2000
|
+
|
|
2001
|
+
// Define interruption reasons matching AudioStudio.types.ts
|
|
2002
|
+
enum RecordingInterruptionReason: String {
|
|
2003
|
+
case deviceDisconnected = "deviceDisconnected"
|
|
2004
|
+
case deviceFallback = "deviceFallback"
|
|
2005
|
+
case deviceSwitchFailed = "deviceSwitchFailed"
|
|
2006
|
+
// Add other reasons if needed (e.g., from handleAudioSessionInterruption)
|
|
2007
|
+
case audioFocusLoss = "audioFocusLoss"
|
|
2008
|
+
case audioFocusGain = "audioFocusGain"
|
|
2009
|
+
case phoneCall = "phoneCall"
|
|
2010
|
+
case phoneCallEnded = "phoneCallEnded"
|
|
2011
|
+
case recordingStopped = "recordingStopped"
|
|
2012
|
+
case deviceConnected = "deviceConnected"
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
private func handleDeviceDisconnection(disconnectedDeviceId: String) {
|
|
2016
|
+
Logger.debug("handleDeviceDisconnection entered. isRecording: \(isRecording), settingsExist: \(recordingSettings != nil), deviceIdExists: \(recordingSettings?.deviceId != nil), currentDeviceId: \(recordingSettings?.deviceId ?? "nil")")
|
|
2017
|
+
|
|
2018
|
+
// --- Modify Guard: Only require settings object, handle nil deviceId later ---
|
|
2019
|
+
guard let settings = recordingSettings else {
|
|
2020
|
+
// If settings are nil, we truly can't determine behavior, so pause.
|
|
2021
|
+
Logger.debug("Device disconnected (\(disconnectedDeviceId)), but recordingSettings object is missing. Pausing.")
|
|
2022
|
+
performPauseAction(reason: .deviceDisconnected)
|
|
2023
|
+
return
|
|
2024
|
+
}
|
|
2025
|
+
// We now have settings, proceed even if deviceId might be nil inside it.
|
|
2026
|
+
let currentRecordingDeviceId = settings.deviceId // This might be nil, handle below
|
|
2027
|
+
// --- End Modify Guard ---
|
|
2028
|
+
|
|
2029
|
+
// Normalize BOTH IDs for reliable comparison
|
|
2030
|
+
// Use "nil" if currentRecordingDeviceId is actually nil
|
|
2031
|
+
let normalizedCurrentId = deviceManager.normalizeBluetoothDeviceId(currentRecordingDeviceId ?? "nil")
|
|
2032
|
+
let normalizedDisconnectedId = deviceManager.normalizeBluetoothDeviceId(disconnectedDeviceId)
|
|
2033
|
+
|
|
2034
|
+
Logger.debug("Handling disconnection. Current device: \(normalizedCurrentId), Disconnected device: \(normalizedDisconnectedId)")
|
|
2035
|
+
|
|
2036
|
+
// Check if the disconnected device is the one we *thought* we were recording from
|
|
2037
|
+
if normalizedCurrentId == normalizedDisconnectedId || currentRecordingDeviceId == nil {
|
|
2038
|
+
// If the IDs match OR if the stored deviceId was nil (meaning we lost track),
|
|
2039
|
+
// assume this disconnection applies to our current recording session.
|
|
2040
|
+
|
|
2041
|
+
Logger.debug("Disconnection event matches current recording session (or session deviceId was lost). Applying behavior...")
|
|
2042
|
+
|
|
2043
|
+
// Get the string value from settings using the correct property name
|
|
2044
|
+
// The property in RecordingSettings likely matches the TS interface: deviceDisconnectionBehavior
|
|
2045
|
+
let behaviorString = settings.deviceDisconnectionBehavior.rawValue // Get the raw value from the enum
|
|
2046
|
+
let behavior = DeviceDisconnectionBehavior(rawValue: behaviorString) ?? .PAUSE // Convert to enum, default to .PAUSE
|
|
2047
|
+
|
|
2048
|
+
Logger.debug("Recording device disconnected! Applying behavior: \(behavior.rawValue)")
|
|
2049
|
+
|
|
2050
|
+
delegate?.audioStreamManager(self, didReceiveInterruption: [
|
|
2051
|
+
"reason": RecordingInterruptionReason.deviceDisconnected.rawValue,
|
|
2052
|
+
"isPaused": isPaused
|
|
2053
|
+
])
|
|
2054
|
+
|
|
2055
|
+
// Switch on the *enum* value
|
|
2056
|
+
switch behavior {
|
|
2057
|
+
case .PAUSE:
|
|
2058
|
+
Logger.debug("Device disconnect behavior set to PAUSE. Pausing recording.")
|
|
2059
|
+
performPauseAction(reason: .deviceDisconnected)
|
|
2060
|
+
|
|
2061
|
+
case .FALLBACK:
|
|
2062
|
+
Logger.debug("Device disconnect behavior set to FALLBACK. Attempting to switch to default device.")
|
|
2063
|
+
Task {
|
|
2064
|
+
await performFallbackAction()
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
} else {
|
|
2068
|
+
Logger.debug("A different device disconnected (\(normalizedDisconnectedId)). Current recording device (\(normalizedCurrentId)) is still active. Ignoring.")
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
private func performPauseAction(reason: RecordingInterruptionReason) {
|
|
2073
|
+
if !isPaused { // Only pause if not already paused
|
|
2074
|
+
Logger.debug("Pausing recording due to \(reason.rawValue)")
|
|
2075
|
+
pauseRecording() // Use existing pause function
|
|
2076
|
+
} else {
|
|
2077
|
+
Logger.debug("Recording was already paused when \(reason.rawValue) occurred.")
|
|
2078
|
+
}
|
|
2079
|
+
// Note: pauseRecording already notifies the delegate about the pause state change.
|
|
2080
|
+
// Send an additional interruption notification specifically for the reason
|
|
2081
|
+
delegate?.audioStreamManager(self, didReceiveInterruption: [
|
|
2082
|
+
"reason": reason.rawValue,
|
|
2083
|
+
"isPaused": true // Since we are pausing or were already paused
|
|
2084
|
+
])
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
private func performFallbackAction() async {
|
|
2088
|
+
Logger.debug("Attempting to fallback to default device...")
|
|
2089
|
+
|
|
2090
|
+
do {
|
|
2091
|
+
// 1. Get the new default device (using the async version)
|
|
2092
|
+
guard let defaultDevice = await deviceManager.getDefaultInputDevice() else {
|
|
2093
|
+
Logger.debug("Fallback failed: Could not get default input device. Pausing.")
|
|
2094
|
+
performPauseAction(reason: .deviceSwitchFailed) // Fallback to pause if no default
|
|
2095
|
+
return
|
|
2096
|
+
}
|
|
2097
|
+
Logger.debug("Found default device for fallback: \(defaultDevice.name) (ID: \(defaultDevice.id))")
|
|
2098
|
+
|
|
2099
|
+
// CRITICAL: Complete engine reset - stronger than just pausing
|
|
2100
|
+
audioEngine.stop()
|
|
2101
|
+
audioEngine.reset() // Reset the entire engine
|
|
2102
|
+
audioEngine.inputNode.removeTap(onBus: 0)
|
|
2103
|
+
|
|
2104
|
+
// More aggressive session reset
|
|
2105
|
+
do {
|
|
2106
|
+
let session = AVAudioSession.sharedInstance()
|
|
2107
|
+
try session.setActive(false, options: .notifyOthersOnDeactivation)
|
|
2108
|
+
try await Task.sleep(nanoseconds: 200_000_000) // Give system time to release resources
|
|
2109
|
+
|
|
2110
|
+
// Reconfigure the session completely
|
|
2111
|
+
try session.setCategory(.playAndRecord, mode: .default, options: [.allowBluetooth, .mixWithOthers])
|
|
2112
|
+
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
|
2113
|
+
try await Task.sleep(nanoseconds: 100_000_000) // Allow the session to activate fully
|
|
2114
|
+
} catch {
|
|
2115
|
+
Logger.debug("Session reset error: \(error.localizedDescription)")
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
let wasManuallyPaused = isPaused
|
|
2119
|
+
|
|
2120
|
+
// 3. Update settings and select the new device in the session
|
|
2121
|
+
recordingSettings?.deviceId = defaultDevice.id // Update setting
|
|
2122
|
+
let selectionSuccess = await deviceManager.selectDevice(defaultDevice.id)
|
|
2123
|
+
if !selectionSuccess {
|
|
2124
|
+
Logger.debug("Fallback failed: Could not select default device in session. Pausing.")
|
|
2125
|
+
performPauseAction(reason: .deviceSwitchFailed)
|
|
2126
|
+
return
|
|
2127
|
+
}
|
|
2128
|
+
Logger.debug("Successfully selected default device \(defaultDevice.id) in session.")
|
|
2129
|
+
|
|
2130
|
+
// Additional forced reset of engine to ensure clean state
|
|
2131
|
+
audioEngine.reset()
|
|
2132
|
+
audioEngine.prepare()
|
|
2133
|
+
|
|
2134
|
+
// Create a simplified tap block for fallback - rely on processAudioBuffer for proper emission
|
|
2135
|
+
let fallbackTapBlock = { [weak self] (buffer: AVAudioPCMBuffer, time: AVAudioTime) -> Void in
|
|
2136
|
+
guard let self = self, self.isRecording else { return }
|
|
2137
|
+
|
|
2138
|
+
// Process the buffer normally - processAudioBuffer handles all emission logic
|
|
2139
|
+
self.processAudioBuffer(buffer)
|
|
2140
|
+
self.lastBufferTime = time
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
// Use our shared tap installation method with the custom block
|
|
2144
|
+
_ = installTapWithHardwareFormat(customTapBlock: fallbackTapBlock)
|
|
2145
|
+
Logger.debug("Fallback: Re-installed tap with simplified emission handling")
|
|
2146
|
+
|
|
2147
|
+
// Force prepare engine again to ensure it's ready
|
|
2148
|
+
audioEngine.prepare()
|
|
2149
|
+
Logger.debug("Fallback: Prepared audio engine.")
|
|
2150
|
+
|
|
2151
|
+
if !wasManuallyPaused {
|
|
2152
|
+
// Only start if it's not running (it should have been paused earlier)
|
|
2153
|
+
if !audioEngine.isRunning {
|
|
2154
|
+
do {
|
|
2155
|
+
try audioEngine.start()
|
|
2156
|
+
Logger.debug("Audio engine restarted for fallback.")
|
|
2157
|
+
} catch {
|
|
2158
|
+
// Try ONE more time with delay
|
|
2159
|
+
try await Task.sleep(nanoseconds: 200_000_000)
|
|
2160
|
+
do {
|
|
2161
|
+
try audioEngine.start()
|
|
2162
|
+
Logger.debug("Audio engine restarted on second attempt after fallback.")
|
|
2163
|
+
} catch {
|
|
2164
|
+
Logger.debug("Fallback failed: Could not restart audio engine after tap reinstall. Pausing. Error: \(error)")
|
|
2165
|
+
performPauseAction(reason: .deviceSwitchFailed)
|
|
2166
|
+
return
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
} else {
|
|
2170
|
+
Logger.debug("Audio engine was already running during fallback attempt? Unexpected state.")
|
|
2171
|
+
}
|
|
2172
|
+
} else {
|
|
2173
|
+
Logger.debug("Recording was manually paused, leaving engine paused after fallback.")
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
// Emit any remaining audio data from the previous device before resetting timers
|
|
2177
|
+
if !accumulatedData.isEmpty {
|
|
2178
|
+
Logger.debug("Emitting final audio chunk of \(accumulatedData.count) bytes from previous device")
|
|
2179
|
+
let recordingTime = currentRecordingDuration()
|
|
2180
|
+
let finalTotalSize = self.totalDataSize
|
|
2181
|
+
|
|
2182
|
+
// Create a copy of accumulated data to avoid race conditions
|
|
2183
|
+
let finalData = accumulatedData
|
|
2184
|
+
|
|
2185
|
+
// Notify delegate with final audio data from previous device
|
|
2186
|
+
delegate?.audioStreamManager(
|
|
2187
|
+
self,
|
|
2188
|
+
didReceiveAudioData: finalData,
|
|
2189
|
+
recordingTime: recordingTime,
|
|
2190
|
+
totalDataSize: finalTotalSize,
|
|
2191
|
+
compressionInfo: buildCompressionInfo()
|
|
2192
|
+
)
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
// Reset emission timers to force new data emission with the fallback device
|
|
2196
|
+
lastEmissionTime = Date() // Reset to force immediate emission
|
|
2197
|
+
lastEmissionTimeAnalysis = Date() // Reset analysis timer too
|
|
2198
|
+
|
|
2199
|
+
// Important: Do not reset totalDataSize here - it needs to be maintained
|
|
2200
|
+
// We only clear the buffers to start accumulating new data from the fallback device
|
|
2201
|
+
accumulatedData.removeAll() // Clear any partial data from previous device
|
|
2202
|
+
accumulatedAnalysisData.removeAll() // Clear analysis data buffer
|
|
2203
|
+
Logger.debug("Emission timers reset. Current totalDataSize: \(totalDataSize)")
|
|
2204
|
+
|
|
2205
|
+
// CRITICAL: Multiple scheduled recovery attempts
|
|
2206
|
+
for delaySeconds in [0.5, 1.0, 2.0, 3.0] {
|
|
2207
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
|
|
2208
|
+
guard let self = self, self.isRecording, !self.isPaused else { return }
|
|
2209
|
+
Logger.debug("FALLBACK RECOVERY: Checking for data at \(delaySeconds)s")
|
|
2210
|
+
|
|
2211
|
+
// Force an immediate emission if data is being received but not emitted
|
|
2212
|
+
if !self.accumulatedData.isEmpty {
|
|
2213
|
+
Logger.debug("FALLBACK RECOVERY: Forcing emission from accumulated data after \(delaySeconds)s (total size: \(self.totalDataSize))")
|
|
2214
|
+
let dataToEmit = self.accumulatedData
|
|
2215
|
+
let recordingTime = self.currentRecordingDuration()
|
|
2216
|
+
let totalSize = self.totalDataSize
|
|
2217
|
+
|
|
2218
|
+
self.lastEmissionTime = Date() // Reset the emission timer
|
|
2219
|
+
self.accumulatedData.removeAll() // Clear the buffer
|
|
2220
|
+
|
|
2221
|
+
// Direct delegate call with accumulated data
|
|
2222
|
+
self.delegate?.audioStreamManager(
|
|
2223
|
+
self,
|
|
2224
|
+
didReceiveAudioData: dataToEmit,
|
|
2225
|
+
recordingTime: recordingTime,
|
|
2226
|
+
totalDataSize: totalSize,
|
|
2227
|
+
compressionInfo: self.buildCompressionInfo()
|
|
2228
|
+
)
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
// If we're at the 3-second mark and the engine appears to not be running, attempt restart
|
|
2232
|
+
if delaySeconds >= 3.0 && (!self.audioEngine.isRunning || self.lastEmissionTime!.timeIntervalSinceNow < -3) {
|
|
2233
|
+
Logger.debug("FALLBACK RECOVERY: Emergency engine restart attempt")
|
|
2234
|
+
do {
|
|
2235
|
+
self.audioEngine.reset()
|
|
2236
|
+
self.audioEngine.prepare()
|
|
2237
|
+
try self.audioEngine.start()
|
|
2238
|
+
self.lastEmissionTime = Date()
|
|
2239
|
+
} catch {
|
|
2240
|
+
Logger.debug("Emergency restart failed: \(error)")
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
// 7. Notify JS about successful fallback
|
|
2247
|
+
delegate?.audioStreamManager(self, didReceiveInterruption: [
|
|
2248
|
+
"reason": RecordingInterruptionReason.deviceFallback.rawValue,
|
|
2249
|
+
"newDeviceId": defaultDevice.id, // Include new device ID
|
|
2250
|
+
"isPaused": isPaused // Report current state
|
|
2251
|
+
])
|
|
2252
|
+
Logger.debug("Fallback to device \(defaultDevice.id) successful.")
|
|
2253
|
+
|
|
2254
|
+
// Make the catch block reachable by throwing an error unconditionally
|
|
2255
|
+
// This is required to fix a compiler warning about unreachable catch block
|
|
2256
|
+
throw NSError(domain: "AudioStreamManager", code: 1, userInfo: [NSLocalizedDescriptionKey: "Intentional error to make catch block reachable"])
|
|
2257
|
+
|
|
2258
|
+
} catch {
|
|
2259
|
+
Logger.debug("Fallback failed with error: \(error). Pausing.")
|
|
2260
|
+
performPauseAction(reason: .deviceSwitchFailed)
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
private func configureAudioSession(for settings: RecordingSettings) throws {
|
|
2265
|
+
let session = AVAudioSession.sharedInstance()
|
|
2266
|
+
|
|
2267
|
+
// Get base configuration from user settings or defaults
|
|
2268
|
+
var category: AVAudioSession.Category = .playAndRecord
|
|
2269
|
+
var mode: AVAudioSession.Mode = .default
|
|
2270
|
+
var options: AVAudioSession.CategoryOptions = [.allowBluetooth, .mixWithOthers]
|
|
2271
|
+
|
|
2272
|
+
if let audioSessionConfig = settings.ios?.audioSession {
|
|
2273
|
+
category = audioSessionConfig.category
|
|
2274
|
+
mode = audioSessionConfig.mode
|
|
2275
|
+
options = audioSessionConfig.categoryOptions
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
// Append necessary options for background recording if keepAwake is enabled
|
|
2279
|
+
if settings.keepAwake {
|
|
2280
|
+
Logger.debug("AudioStreamManager", "keepAwake enabled - configuring for background recording")
|
|
2281
|
+
// Set the category to PlayAndRecord with proper background options
|
|
2282
|
+
options.insert(.mixWithOthers)
|
|
2283
|
+
// Add duckOthers to reduce volume of other apps instead of stopping them
|
|
2284
|
+
options.insert(.duckOthers)
|
|
2285
|
+
|
|
2286
|
+
// Configure audio session for background audio
|
|
2287
|
+
do {
|
|
2288
|
+
try session.setCategory(.playAndRecord, mode: .default, options: options)
|
|
2289
|
+
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
|
2290
|
+
// Ensure the app has appropriate Info.plist settings for background audio
|
|
2291
|
+
Logger.debug("AudioStreamManager", "Audio session configured for background recording with options: \(options)")
|
|
2292
|
+
} catch {
|
|
2293
|
+
Logger.debug("AudioStreamManager", "Failed to configure audio session for background: \(error)")
|
|
2294
|
+
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
|
2295
|
+
}
|
|
2296
|
+
} else {
|
|
2297
|
+
Logger.debug("AudioStreamManager", "keepAwake disabled - using standard session configuration")
|
|
2298
|
+
// If keepAwake is false, don't add background audio options
|
|
2299
|
+
try session.setActive(true)
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
// Apply the final configuration
|
|
2303
|
+
try session.setCategory(category, mode: mode, options: options)
|
|
2304
|
+
|
|
2305
|
+
Logger.debug("AudioStreamManager", "Audio session configured with category: \(category), mode: \(mode), options: \(options)")
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
extension AudioStreamManager: UNUserNotificationCenterDelegate {
|
|
2310
|
+
func userNotificationCenter(
|
|
2311
|
+
_ center: UNUserNotificationCenter,
|
|
2312
|
+
didReceive response: UNNotificationResponse,
|
|
2313
|
+
withCompletionHandler completionHandler: @escaping () -> Void
|
|
2314
|
+
) {
|
|
2315
|
+
switch response.actionIdentifier {
|
|
2316
|
+
case "PAUSE_RECORDING":
|
|
2317
|
+
pauseRecording()
|
|
2318
|
+
case "RESUME_RECORDING":
|
|
2319
|
+
resumeRecording()
|
|
2320
|
+
default:
|
|
2321
|
+
break
|
|
2322
|
+
}
|
|
2323
|
+
completionHandler()
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
// This is needed to show notifications when app is in foreground
|
|
2327
|
+
func userNotificationCenter(
|
|
2328
|
+
_ center: UNUserNotificationCenter,
|
|
2329
|
+
willPresent notification: UNNotification,
|
|
2330
|
+
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
|
2331
|
+
) {
|
|
2332
|
+
if #available(iOS 14.0, *) {
|
|
2333
|
+
completionHandler([.banner, .sound])
|
|
2334
|
+
} else {
|
|
2335
|
+
// For iOS 13 and earlier
|
|
2336
|
+
completionHandler([.alert, .sound])
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
// Add AVAudioRecorderDelegate conformance
|
|
2342
|
+
extension AudioStreamManager: AVAudioRecorderDelegate {
|
|
2343
|
+
func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully flag: Bool) {
|
|
2344
|
+
Logger.debug("Compressed recording finished - success: \(flag)")
|
|
2345
|
+
if !flag {
|
|
2346
|
+
delegate?.audioStreamManager(self, didFailWithError: "Compressed recording failed to complete")
|
|
2347
|
+
} else {
|
|
2348
|
+
// Update cached compressed file size when recording finishes
|
|
2349
|
+
if let compressedURL = compressedFileURL {
|
|
2350
|
+
do {
|
|
2351
|
+
let attributes = try FileManager.default.attributesOfItem(atPath: compressedURL.path)
|
|
2352
|
+
if let size = attributes[.size] as? Int64 {
|
|
2353
|
+
cachedCompressedFileSize = size
|
|
2354
|
+
Logger.debug("Cached compressed file size: \(size) bytes")
|
|
2355
|
+
}
|
|
2356
|
+
} catch {
|
|
2357
|
+
Logger.debug("Failed to cache compressed file size: \(error)")
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
func audioRecorderEncodeErrorDidOccur(_ recorder: AVAudioRecorder, error: Error?) {
|
|
2364
|
+
if let error = error {
|
|
2365
|
+
Logger.debug("Compressed recording encode error: \(error)")
|
|
2366
|
+
delegate?.audioStreamManager(self, didFailWithError: "Compressed recording encode error: \(error.localizedDescription)")
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
}
|