@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,2163 @@
|
|
|
1
|
+
// net/siteed/audiostream/AudioRecorderManager.kt
|
|
2
|
+
package net.siteed.audiostudio
|
|
3
|
+
|
|
4
|
+
import android.Manifest
|
|
5
|
+
import android.annotation.SuppressLint
|
|
6
|
+
import android.content.Context
|
|
7
|
+
import android.media.AudioDeviceInfo
|
|
8
|
+
import android.media.AudioFormat
|
|
9
|
+
import android.media.AudioRecord
|
|
10
|
+
import android.media.MediaRecorder
|
|
11
|
+
import android.os.Build
|
|
12
|
+
import android.os.Bundle
|
|
13
|
+
import android.os.Handler
|
|
14
|
+
import android.os.Looper
|
|
15
|
+
import android.os.PowerManager
|
|
16
|
+
import android.os.SystemClock
|
|
17
|
+
import android.util.Log
|
|
18
|
+
import androidx.annotation.RequiresApi
|
|
19
|
+
import androidx.core.os.bundleOf
|
|
20
|
+
import expo.modules.kotlin.Promise
|
|
21
|
+
import java.io.ByteArrayOutputStream
|
|
22
|
+
import java.io.File
|
|
23
|
+
import java.io.FileOutputStream
|
|
24
|
+
import java.io.IOException
|
|
25
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
26
|
+
import java.nio.ByteBuffer
|
|
27
|
+
import java.nio.ByteOrder
|
|
28
|
+
import android.media.AudioManager
|
|
29
|
+
import android.media.AudioAttributes
|
|
30
|
+
import android.media.AudioFocusRequest
|
|
31
|
+
import android.telephony.PhoneStateListener
|
|
32
|
+
import android.telephony.TelephonyCallback
|
|
33
|
+
import android.telephony.TelephonyManager
|
|
34
|
+
import android.app.ActivityManager
|
|
35
|
+
import java.util.UUID
|
|
36
|
+
import net.siteed.audiostudio.LogUtils
|
|
37
|
+
|
|
38
|
+
class AudioRecorderManager(
|
|
39
|
+
private val context: Context,
|
|
40
|
+
private val filesDir: File,
|
|
41
|
+
private val permissionUtils: PermissionUtils,
|
|
42
|
+
private val audioDataEncoder: AudioDataEncoder,
|
|
43
|
+
private val eventSender: EventSender,
|
|
44
|
+
private val enablePhoneStateHandling: Boolean = true,
|
|
45
|
+
private val enableBackgroundAudio: Boolean = true
|
|
46
|
+
) {
|
|
47
|
+
companion object {
|
|
48
|
+
private const val CLASS_NAME = "AudioRecorderManager"
|
|
49
|
+
|
|
50
|
+
@SuppressLint("StaticFieldLeak")
|
|
51
|
+
@Volatile
|
|
52
|
+
private var instance: AudioRecorderManager? = null
|
|
53
|
+
|
|
54
|
+
fun getInstance(): AudioRecorderManager? = instance
|
|
55
|
+
|
|
56
|
+
fun initialize(
|
|
57
|
+
context: Context,
|
|
58
|
+
filesDir: File,
|
|
59
|
+
permissionUtils: PermissionUtils,
|
|
60
|
+
audioDataEncoder: AudioDataEncoder,
|
|
61
|
+
eventSender: EventSender,
|
|
62
|
+
enablePhoneStateHandling: Boolean = true,
|
|
63
|
+
enableBackgroundAudio: Boolean = true
|
|
64
|
+
): AudioRecorderManager {
|
|
65
|
+
return instance ?: synchronized(this) {
|
|
66
|
+
instance ?: AudioRecorderManager(
|
|
67
|
+
context, filesDir, permissionUtils, audioDataEncoder, eventSender,
|
|
68
|
+
enablePhoneStateHandling, enableBackgroundAudio
|
|
69
|
+
).also { instance = it }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
fun destroy() {
|
|
74
|
+
instance?.cleanup()
|
|
75
|
+
instance = null
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Maximum size for analysis buffer to prevent OOM on low-RAM devices with extreme configs
|
|
80
|
+
private val MAX_ANALYSIS_BUFFER_SIZE = 20 * 1024 * 1024 // 20MB
|
|
81
|
+
|
|
82
|
+
private var audioRecord: AudioRecord? = null
|
|
83
|
+
private var bufferSizeInBytes = 0
|
|
84
|
+
private val _isRecording = AtomicBoolean(false)
|
|
85
|
+
private val isPaused = AtomicBoolean(false)
|
|
86
|
+
private var streamUuid: String? = null
|
|
87
|
+
private var audioFile: File? = null
|
|
88
|
+
private var recordingThread: Thread? = null
|
|
89
|
+
private var recordingStartTime: Long = 0
|
|
90
|
+
private var totalRecordedTime: Long = 0
|
|
91
|
+
private var totalDataSize = 0
|
|
92
|
+
private var lastEmitTime = SystemClock.elapsedRealtime()
|
|
93
|
+
private var lastPauseTime = 0L
|
|
94
|
+
private var pausedDuration = 0L
|
|
95
|
+
private var lastEmittedSize = 0L
|
|
96
|
+
private var lastEmittedCompressedSize = 0L
|
|
97
|
+
private var streamPosition = 0L // Track total bytes processed in the stream
|
|
98
|
+
private var accumulatedAudioData: ByteArrayOutputStream? = null
|
|
99
|
+
private val mainHandler = Handler(Looper.getMainLooper())
|
|
100
|
+
private val audioRecordLock = Any()
|
|
101
|
+
private var audioFileHandler: AudioFileHandler = AudioFileHandler(filesDir)
|
|
102
|
+
|
|
103
|
+
private lateinit var recordingConfig: RecordingConfig
|
|
104
|
+
private var mimeType = "audio/wav"
|
|
105
|
+
private var audioFormat: Int = AudioFormat.ENCODING_PCM_16BIT
|
|
106
|
+
private var audioProcessor: AudioProcessor = AudioProcessor(filesDir)
|
|
107
|
+
private var isFirstChunk = true
|
|
108
|
+
|
|
109
|
+
private var wakeLock: PowerManager.WakeLock? = null
|
|
110
|
+
private var wasWakeLockEnabled = false
|
|
111
|
+
private val notificationManager = AudioNotificationManager.getInstance(context)
|
|
112
|
+
|
|
113
|
+
private var compressedRecorder: MediaRecorder? = null
|
|
114
|
+
private var compressedFile: File? = null
|
|
115
|
+
|
|
116
|
+
private var audioManager: AudioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
|
117
|
+
private var audioFocusChangeListener: AudioManager.OnAudioFocusChangeListener? = null
|
|
118
|
+
private var audioFocusRequest: Any? = null // Type Any to handle both old and new APIs
|
|
119
|
+
private var phoneStateListener: PhoneStateListener? = null
|
|
120
|
+
private var telephonyCallback: Any? = null // TelephonyCallback for API 31+, typed as Any to avoid class verification issues on older APIs
|
|
121
|
+
private var telephonyManager: TelephonyManager? = null
|
|
122
|
+
get() {
|
|
123
|
+
if (field == null) {
|
|
124
|
+
try {
|
|
125
|
+
field = context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager
|
|
126
|
+
if (field == null) {
|
|
127
|
+
LogUtils.w(CLASS_NAME, "TelephonyManager is null - device may not have telephony service (tablet/emulator)")
|
|
128
|
+
} else {
|
|
129
|
+
LogUtils.d(CLASS_NAME, "TelephonyManager initialization: successful")
|
|
130
|
+
}
|
|
131
|
+
} catch (e: Exception) {
|
|
132
|
+
LogUtils.w(CLASS_NAME, "Failed to initialize TelephonyManager: ${e.message}")
|
|
133
|
+
field = null
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return field
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private var lastEmissionTimeAnalysis = 0L
|
|
140
|
+
private val analysisBuffer = ByteArrayOutputStream()
|
|
141
|
+
private var isFirstAnalysis = true
|
|
142
|
+
|
|
143
|
+
// Properties for device disconnection handling
|
|
144
|
+
var isPrepared = false
|
|
145
|
+
private var selectedDeviceId: String? = null
|
|
146
|
+
private var deviceDisconnectionBehavior: String? = null
|
|
147
|
+
|
|
148
|
+
// Cache file sizes to avoid file system calls during stop
|
|
149
|
+
private var cachedPrimaryFileSize: Long = 44L // Start with WAV header size
|
|
150
|
+
private var cachedCompressedFileSize: Long = 0L
|
|
151
|
+
|
|
152
|
+
// Add a method to handle device changes
|
|
153
|
+
fun handleDeviceChange() {
|
|
154
|
+
LogUtils.d(CLASS_NAME, "🔄 handleDeviceChange called - isRecording=${_isRecording.get()}, isPaused=${isPaused.get()}")
|
|
155
|
+
if (!_isRecording.get()) {
|
|
156
|
+
LogUtils.d(CLASS_NAME, "🔄 handleDeviceChange: Not recording, no action needed")
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (isPaused.get()) {
|
|
161
|
+
LogUtils.d(CLASS_NAME, "🔄 handleDeviceChange: Recording is paused, marking for restart with new device when resumed")
|
|
162
|
+
|
|
163
|
+
// When paused after device disconnection, we need to release the existing AudioRecord
|
|
164
|
+
// so that it can be properly reinitialized when resumed
|
|
165
|
+
synchronized(audioRecordLock) {
|
|
166
|
+
if (audioRecord != null) {
|
|
167
|
+
LogUtils.d(CLASS_NAME, "🔄 Releasing current AudioRecord while paused to allow proper reinitialization")
|
|
168
|
+
audioRecord?.release()
|
|
169
|
+
audioRecord = null
|
|
170
|
+
LogUtils.d(CLASS_NAME, "🔄 AudioRecord released successfully")
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
LogUtils.d(CLASS_NAME, "🔄 handleDeviceChange: Restarting recording with new device")
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
// Log current device configuration for debugging
|
|
181
|
+
val deviceInfo = getAudioDeviceInfo()
|
|
182
|
+
LogUtils.d(CLASS_NAME, "🔄 Current device info: ${deviceInfo["id"] ?: "unknown"} (${deviceInfo["type"] ?: "unknown"})")
|
|
183
|
+
|
|
184
|
+
// Make a copy of current recording settings
|
|
185
|
+
if (!::recordingConfig.isInitialized) {
|
|
186
|
+
LogUtils.w(CLASS_NAME, "recordingConfig not initialized in handleDeviceChange")
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
val currentSettings = recordingConfig
|
|
190
|
+
|
|
191
|
+
// Pause the current recording
|
|
192
|
+
synchronized(audioRecordLock) {
|
|
193
|
+
if (audioRecord != null && audioRecord!!.state == AudioRecord.STATE_INITIALIZED) {
|
|
194
|
+
LogUtils.d(CLASS_NAME, "🔄 Stopping current AudioRecord")
|
|
195
|
+
audioRecord!!.stop()
|
|
196
|
+
LogUtils.d(CLASS_NAME, "🔄 AudioRecord stopped")
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (compressedRecorder != null) {
|
|
200
|
+
LogUtils.d(CLASS_NAME, "🔄 Pausing compressed recorder")
|
|
201
|
+
compressedRecorder!!.pause()
|
|
202
|
+
LogUtils.d(CLASS_NAME, "🔄 Compressed recorder paused")
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Release the current audio record resources
|
|
207
|
+
synchronized(audioRecordLock) {
|
|
208
|
+
LogUtils.d(CLASS_NAME, "🔄 Releasing current AudioRecord")
|
|
209
|
+
audioRecord?.release()
|
|
210
|
+
audioRecord = null
|
|
211
|
+
LogUtils.d(CLASS_NAME, "🔄 AudioRecord resources released")
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Log available devices
|
|
215
|
+
logAvailableDevices()
|
|
216
|
+
|
|
217
|
+
// Give a small delay for the system to fully complete device transition
|
|
218
|
+
LogUtils.d(CLASS_NAME, "🔄 Waiting for device transition to complete")
|
|
219
|
+
Thread.sleep(200)
|
|
220
|
+
|
|
221
|
+
// Initialize a new audio record with the same settings
|
|
222
|
+
LogUtils.d(CLASS_NAME, "🔄 Reinitializing AudioRecord with new device")
|
|
223
|
+
if (!initializeAudioRecord(object : Promise {
|
|
224
|
+
override fun resolve(value: Any?) {
|
|
225
|
+
LogUtils.d(CLASS_NAME, "🔄 Successfully reinitialized AudioRecord with new device")
|
|
226
|
+
}
|
|
227
|
+
override fun reject(code: String, message: String?, cause: Throwable?) {
|
|
228
|
+
LogUtils.e(CLASS_NAME, "🔄 Failed to reinitialize AudioRecord: $message")
|
|
229
|
+
}
|
|
230
|
+
})) {
|
|
231
|
+
LogUtils.e(CLASS_NAME, "🔄 Failed to reinitialize audio record, stopping recording")
|
|
232
|
+
stopRecording(object : Promise {
|
|
233
|
+
override fun resolve(value: Any?) {
|
|
234
|
+
eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
|
|
235
|
+
"reason" to "deviceSwitchFailed",
|
|
236
|
+
"isPaused" to true
|
|
237
|
+
))
|
|
238
|
+
}
|
|
239
|
+
override fun reject(code: String, message: String?, cause: Throwable?) {}
|
|
240
|
+
})
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Re-verify recording state
|
|
245
|
+
synchronized(audioRecordLock) {
|
|
246
|
+
if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) {
|
|
247
|
+
LogUtils.e(CLASS_NAME, "🔄 AudioRecord not properly initialized after device change")
|
|
248
|
+
stopRecording(object : Promise {
|
|
249
|
+
override fun resolve(value: Any?) {
|
|
250
|
+
eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
|
|
251
|
+
"reason" to "deviceSwitchFailed",
|
|
252
|
+
"isPaused" to true
|
|
253
|
+
))
|
|
254
|
+
}
|
|
255
|
+
override fun reject(code: String, message: String?, cause: Throwable?) {}
|
|
256
|
+
})
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Restart the audio record
|
|
262
|
+
synchronized(audioRecordLock) {
|
|
263
|
+
LogUtils.d(CLASS_NAME, "🔄 Starting recording with new device")
|
|
264
|
+
audioRecord?.startRecording()
|
|
265
|
+
LogUtils.d(CLASS_NAME, "🔄 AudioRecord started recording")
|
|
266
|
+
|
|
267
|
+
// Resume compressed recorder if it was active
|
|
268
|
+
if (compressedRecorder != null) {
|
|
269
|
+
LogUtils.d(CLASS_NAME, "🔄 Resuming compressed recorder")
|
|
270
|
+
compressedRecorder!!.resume()
|
|
271
|
+
LogUtils.d(CLASS_NAME, "🔄 Compressed recorder resumed")
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Get new device info
|
|
276
|
+
val newDeviceInfo = getAudioDeviceInfo()
|
|
277
|
+
LogUtils.d(CLASS_NAME, "🔄 New device info: ${newDeviceInfo["id"] ?: "unknown"} (${newDeviceInfo["type"] ?: "unknown"})")
|
|
278
|
+
|
|
279
|
+
// Notify JavaScript
|
|
280
|
+
LogUtils.d(CLASS_NAME, "🔄 Sending device changed event to JavaScript")
|
|
281
|
+
eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
|
|
282
|
+
"reason" to "deviceChanged",
|
|
283
|
+
"isPaused" to false,
|
|
284
|
+
"deviceInfo" to newDeviceInfo
|
|
285
|
+
))
|
|
286
|
+
LogUtils.d(CLASS_NAME, "🔄 Device change handling completed successfully")
|
|
287
|
+
|
|
288
|
+
} catch (e: Exception) {
|
|
289
|
+
LogUtils.e(CLASS_NAME, "🔄 Error handling device change: ${e.message}", e)
|
|
290
|
+
// If something went wrong, try to pause recording
|
|
291
|
+
pauseRecording(object : Promise {
|
|
292
|
+
override fun resolve(value: Any?) {
|
|
293
|
+
eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
|
|
294
|
+
"reason" to "deviceSwitchFailed",
|
|
295
|
+
"isPaused" to true,
|
|
296
|
+
"error" to e.message
|
|
297
|
+
))
|
|
298
|
+
}
|
|
299
|
+
override fun reject(code: String, message: String?, cause: Throwable?) {}
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Helper to get info about current audio device
|
|
305
|
+
private fun getAudioDeviceInfo(): Map<String, Any> {
|
|
306
|
+
return try {
|
|
307
|
+
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
|
308
|
+
|
|
309
|
+
// Check if using Bluetooth SCO
|
|
310
|
+
if (audioManager.isBluetoothScoOn) {
|
|
311
|
+
mapOf(
|
|
312
|
+
"id" to (selectedDeviceId ?: "unknown"),
|
|
313
|
+
"type" to "bluetooth",
|
|
314
|
+
"name" to "Bluetooth Headset",
|
|
315
|
+
"isDefault" to false
|
|
316
|
+
)
|
|
317
|
+
}
|
|
318
|
+
// Check if using wired headset
|
|
319
|
+
else if (audioManager.isWiredHeadsetOn) {
|
|
320
|
+
mapOf(
|
|
321
|
+
"id" to (selectedDeviceId ?: "unknown"),
|
|
322
|
+
"type" to "wired",
|
|
323
|
+
"name" to "Wired Headset",
|
|
324
|
+
"isDefault" to false
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
// Default to built-in mic
|
|
328
|
+
else {
|
|
329
|
+
mapOf(
|
|
330
|
+
"id" to (selectedDeviceId ?: "unknown"),
|
|
331
|
+
"type" to "builtin_mic",
|
|
332
|
+
"name" to "Built-in Microphone",
|
|
333
|
+
"isDefault" to true
|
|
334
|
+
)
|
|
335
|
+
}
|
|
336
|
+
} catch (e: Exception) {
|
|
337
|
+
LogUtils.e(CLASS_NAME, "Error getting audio device info: ${e.message}", e)
|
|
338
|
+
mapOf(
|
|
339
|
+
"id" to "unknown",
|
|
340
|
+
"type" to "unknown",
|
|
341
|
+
"name" to "Unknown Device",
|
|
342
|
+
"isDefault" to false
|
|
343
|
+
)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Log available audio devices for debugging
|
|
348
|
+
private fun logAvailableDevices() {
|
|
349
|
+
try {
|
|
350
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
|
351
|
+
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
|
352
|
+
val devices = audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS)
|
|
353
|
+
|
|
354
|
+
LogUtils.d(CLASS_NAME, "Available audio devices (${devices.size}):")
|
|
355
|
+
devices.forEachIndexed { index, device ->
|
|
356
|
+
val name = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
357
|
+
device.productName?.toString() ?: "Unknown"
|
|
358
|
+
} else {
|
|
359
|
+
when (device.type) {
|
|
360
|
+
AudioDeviceInfo.TYPE_BUILTIN_MIC -> "Built-in Microphone"
|
|
361
|
+
AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "Bluetooth Headset"
|
|
362
|
+
AudioDeviceInfo.TYPE_WIRED_HEADSET -> "Wired Headset"
|
|
363
|
+
AudioDeviceInfo.TYPE_USB_DEVICE -> "USB Audio Device"
|
|
364
|
+
AudioDeviceInfo.TYPE_USB_HEADSET -> "USB Headset"
|
|
365
|
+
else -> "Unknown Device Type (${device.type})"
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
LogUtils.d(CLASS_NAME, "Device $index: $name (ID: ${device.id})")
|
|
370
|
+
}
|
|
371
|
+
} else {
|
|
372
|
+
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
|
373
|
+
LogUtils.d(CLASS_NAME, "Device info on pre-M Android:")
|
|
374
|
+
LogUtils.d(CLASS_NAME, "- Bluetooth SCO: ${audioManager.isBluetoothScoOn}")
|
|
375
|
+
LogUtils.d(CLASS_NAME, "- Wired Headset: ${audioManager.isWiredHeadsetOn}")
|
|
376
|
+
LogUtils.d(CLASS_NAME, "- Selected Device ID: $selectedDeviceId")
|
|
377
|
+
}
|
|
378
|
+
} catch (e: Exception) {
|
|
379
|
+
LogUtils.e(CLASS_NAME, "Error logging available devices: ${e.message}", e)
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Get the device disconnection behavior
|
|
384
|
+
fun getDeviceDisconnectionBehavior(): String {
|
|
385
|
+
return deviceDisconnectionBehavior ?: "pause" // Default to pause if not specified
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Public property to check if recording is active
|
|
389
|
+
val isRecording: Boolean
|
|
390
|
+
get() = _isRecording.get()
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Shared handler for call state changes, used by both the modern TelephonyCallback (API 31+)
|
|
394
|
+
* and the legacy PhoneStateListener (API < 31).
|
|
395
|
+
*/
|
|
396
|
+
private fun handleCallStateChanged(state: Int) {
|
|
397
|
+
val stateStr = when (state) {
|
|
398
|
+
TelephonyManager.CALL_STATE_RINGING -> "RINGING"
|
|
399
|
+
TelephonyManager.CALL_STATE_OFFHOOK -> "OFFHOOK"
|
|
400
|
+
TelephonyManager.CALL_STATE_IDLE -> "IDLE"
|
|
401
|
+
else -> "UNKNOWN"
|
|
402
|
+
}
|
|
403
|
+
LogUtils.d(CLASS_NAME, "Phone state changed to: $stateStr")
|
|
404
|
+
|
|
405
|
+
when (state) {
|
|
406
|
+
TelephonyManager.CALL_STATE_RINGING,
|
|
407
|
+
TelephonyManager.CALL_STATE_OFFHOOK -> {
|
|
408
|
+
if (_isRecording.get() && !isPaused.get()) {
|
|
409
|
+
LogUtils.d(CLASS_NAME, "Pausing recording due to incoming/ongoing call")
|
|
410
|
+
mainHandler.post {
|
|
411
|
+
pauseRecording(object : Promise {
|
|
412
|
+
override fun resolve(value: Any?) {
|
|
413
|
+
LogUtils.d(CLASS_NAME, "Successfully paused recording due to call")
|
|
414
|
+
eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
|
|
415
|
+
"reason" to "phoneCall",
|
|
416
|
+
"isPaused" to true
|
|
417
|
+
))
|
|
418
|
+
}
|
|
419
|
+
override fun reject(code: String, message: String?, cause: Throwable?) {
|
|
420
|
+
LogUtils.e(CLASS_NAME, "Failed to pause recording on phone call", cause)
|
|
421
|
+
}
|
|
422
|
+
})
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
TelephonyManager.CALL_STATE_IDLE -> {
|
|
427
|
+
if (_isRecording.get() && isPaused.get()) {
|
|
428
|
+
val autoResume = if (::recordingConfig.isInitialized) recordingConfig.autoResumeAfterInterruption else false
|
|
429
|
+
LogUtils.d(CLASS_NAME, "Call ended, handling auto-resume (enabled: $autoResume)")
|
|
430
|
+
if (autoResume) {
|
|
431
|
+
mainHandler.post {
|
|
432
|
+
resumeRecording(object : Promise {
|
|
433
|
+
override fun resolve(value: Any?) {
|
|
434
|
+
LogUtils.d(CLASS_NAME, "Successfully resumed recording after call")
|
|
435
|
+
eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
|
|
436
|
+
"reason" to "phoneCallEnded",
|
|
437
|
+
"isPaused" to false
|
|
438
|
+
))
|
|
439
|
+
}
|
|
440
|
+
override fun reject(code: String, message: String?, cause: Throwable?) {
|
|
441
|
+
LogUtils.e(CLASS_NAME, "Failed to resume recording after phone call", cause)
|
|
442
|
+
}
|
|
443
|
+
})
|
|
444
|
+
}
|
|
445
|
+
} else {
|
|
446
|
+
LogUtils.d(CLASS_NAME, "Auto-resume disabled, staying paused")
|
|
447
|
+
eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
|
|
448
|
+
"reason" to "phoneCallEnded",
|
|
449
|
+
"isPaused" to true
|
|
450
|
+
))
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private fun initializePhoneStateListener() {
|
|
458
|
+
try {
|
|
459
|
+
LogUtils.d(CLASS_NAME, "Initializing phone state listener...")
|
|
460
|
+
|
|
461
|
+
if (permissionUtils.checkPhoneStatePermission()) {
|
|
462
|
+
LogUtils.d(CLASS_NAME, "Phone state permission granted")
|
|
463
|
+
|
|
464
|
+
val localTelephonyManager = telephonyManager
|
|
465
|
+
if (localTelephonyManager != null) {
|
|
466
|
+
try {
|
|
467
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
468
|
+
// API 31+: Use modern TelephonyCallback which reliably fires on Android 12+
|
|
469
|
+
val callback = object : TelephonyCallback(), TelephonyCallback.CallStateListener {
|
|
470
|
+
override fun onCallStateChanged(state: Int) {
|
|
471
|
+
handleCallStateChanged(state)
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
telephonyCallback = callback
|
|
475
|
+
localTelephonyManager.registerTelephonyCallback(context.mainExecutor, callback)
|
|
476
|
+
LogUtils.d(CLASS_NAME, "Successfully registered TelephonyCallback (API 31+)")
|
|
477
|
+
} else {
|
|
478
|
+
// Legacy: PhoneStateListener for API < 31
|
|
479
|
+
phoneStateListener = object : PhoneStateListener() {
|
|
480
|
+
@Deprecated("Deprecated in API 31")
|
|
481
|
+
override fun onCallStateChanged(state: Int, phoneNumber: String?) {
|
|
482
|
+
handleCallStateChanged(state)
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
localTelephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_CALL_STATE)
|
|
486
|
+
LogUtils.d(CLASS_NAME, "Successfully registered PhoneStateListener (legacy)")
|
|
487
|
+
}
|
|
488
|
+
} catch (e: SecurityException) {
|
|
489
|
+
LogUtils.w(CLASS_NAME, "Missing permission for phone state listener: ${e.message}")
|
|
490
|
+
} catch (e: Exception) {
|
|
491
|
+
LogUtils.e(CLASS_NAME, "Failed to register phone state listener", e)
|
|
492
|
+
}
|
|
493
|
+
} else {
|
|
494
|
+
LogUtils.w(CLASS_NAME, "TelephonyManager is null, phone call interruption handling disabled (device may not have telephony service)")
|
|
495
|
+
}
|
|
496
|
+
} else {
|
|
497
|
+
LogUtils.w(CLASS_NAME, "READ_PHONE_STATE permission not granted, phone call interruption handling disabled")
|
|
498
|
+
}
|
|
499
|
+
} catch (e: Exception) {
|
|
500
|
+
LogUtils.e(CLASS_NAME, "Failed to initialize phone state listener", e)
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Unregisters the phone state listener/callback, using the appropriate API for the device.
|
|
506
|
+
*/
|
|
507
|
+
private fun unregisterPhoneStateListener() {
|
|
508
|
+
try {
|
|
509
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
510
|
+
val callback = telephonyCallback
|
|
511
|
+
if (callback != null) {
|
|
512
|
+
telephonyManager?.unregisterTelephonyCallback(callback as TelephonyCallback)
|
|
513
|
+
telephonyCallback = null
|
|
514
|
+
LogUtils.d(CLASS_NAME, "Unregistered TelephonyCallback (API 31+)")
|
|
515
|
+
}
|
|
516
|
+
} else {
|
|
517
|
+
if (phoneStateListener != null) {
|
|
518
|
+
telephonyManager?.listen(phoneStateListener, PhoneStateListener.LISTEN_NONE)
|
|
519
|
+
phoneStateListener = null
|
|
520
|
+
LogUtils.d(CLASS_NAME, "Unregistered PhoneStateListener (legacy)")
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
} catch (e: Exception) {
|
|
524
|
+
LogUtils.w(CLASS_NAME, "Failed to unregister phone state listener: ${e.message}")
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
@RequiresApi(Build.VERSION_CODES.R)
|
|
530
|
+
fun startRecording(options: Map<String, Any?>, promise: Promise) {
|
|
531
|
+
try {
|
|
532
|
+
// Check if already recording
|
|
533
|
+
if (_isRecording.get() && !isPaused.get()) {
|
|
534
|
+
promise.reject("ALREADY_RECORDING", "Recording is already in progress", null)
|
|
535
|
+
return
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// If already prepared, we can skip initialization
|
|
539
|
+
if (!isPrepared) {
|
|
540
|
+
LogUtils.d(CLASS_NAME, "Not prepared, preparing recording first")
|
|
541
|
+
|
|
542
|
+
// Initialize phone state listener only if enabled
|
|
543
|
+
if (enablePhoneStateHandling) {
|
|
544
|
+
initializePhoneStateListener()
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
LogUtils.d(CLASS_NAME, "Starting recording with options: $options")
|
|
548
|
+
|
|
549
|
+
// Check permissions
|
|
550
|
+
if (!checkPermissions(options, promise)) return
|
|
551
|
+
|
|
552
|
+
// Parse recording configuration FIRST
|
|
553
|
+
val configResult = RecordingConfig.fromMap(options)
|
|
554
|
+
if (configResult.isFailure) {
|
|
555
|
+
promise.reject(
|
|
556
|
+
"INVALID_CONFIG",
|
|
557
|
+
configResult.exceptionOrNull()?.message ?: "Invalid configuration",
|
|
558
|
+
configResult.exceptionOrNull()
|
|
559
|
+
)
|
|
560
|
+
return
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
val (tempRecordingConfig, audioFormatInfo) = configResult.getOrNull()!!
|
|
564
|
+
|
|
565
|
+
recordingConfig = tempRecordingConfig
|
|
566
|
+
|
|
567
|
+
// Request audio focus AFTER config is parsed so strategy is correct
|
|
568
|
+
if (!requestAudioFocus()) {
|
|
569
|
+
promise.reject("AUDIO_FOCUS_ERROR", "Failed to obtain audio focus", null)
|
|
570
|
+
return
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Store device-related settings
|
|
574
|
+
selectedDeviceId = recordingConfig.deviceId
|
|
575
|
+
deviceDisconnectionBehavior = recordingConfig.deviceDisconnectionBehavior ?: "pause"
|
|
576
|
+
|
|
577
|
+
audioFormat = audioFormatInfo.format
|
|
578
|
+
mimeType = audioFormatInfo.mimeType
|
|
579
|
+
|
|
580
|
+
if (!initializeAudioFormat(promise)) return
|
|
581
|
+
|
|
582
|
+
if (!initializeBufferSize(promise)) return
|
|
583
|
+
|
|
584
|
+
if (!initializeAudioRecord(promise)) return
|
|
585
|
+
|
|
586
|
+
if (recordingConfig.output.compressed.enabled && !initializeCompressedRecorder(
|
|
587
|
+
if (recordingConfig.output.compressed.format == "aac") "aac" else "opus",
|
|
588
|
+
promise
|
|
589
|
+
)) return
|
|
590
|
+
|
|
591
|
+
if (!initializeRecordingResources(audioFormatInfo.fileExtension, promise)) return
|
|
592
|
+
} else {
|
|
593
|
+
LogUtils.d(CLASS_NAME, "Using prepared recording state")
|
|
594
|
+
|
|
595
|
+
// Even when prepared, update device settings from the new options
|
|
596
|
+
val configResult = RecordingConfig.fromMap(options)
|
|
597
|
+
if (configResult.isSuccess) {
|
|
598
|
+
val (tempRecordingConfig, _) = configResult.getOrNull()!!
|
|
599
|
+
// Update device-related settings
|
|
600
|
+
selectedDeviceId = tempRecordingConfig.deviceId ?: selectedDeviceId
|
|
601
|
+
deviceDisconnectionBehavior = tempRecordingConfig.deviceDisconnectionBehavior
|
|
602
|
+
?: deviceDisconnectionBehavior
|
|
603
|
+
?: "pause"
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Request audio focus with current config
|
|
607
|
+
if (!requestAudioFocus()) {
|
|
608
|
+
promise.reject("AUDIO_FOCUS_ERROR", "Failed to obtain audio focus", null)
|
|
609
|
+
return
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (!startRecordingProcess(promise)) return
|
|
614
|
+
|
|
615
|
+
// Start compressed recording if enabled
|
|
616
|
+
try {
|
|
617
|
+
compressedRecorder?.start()
|
|
618
|
+
} catch (e: Exception) {
|
|
619
|
+
LogUtils.e(CLASS_NAME, "Failed to start compressed recording", e)
|
|
620
|
+
cleanup()
|
|
621
|
+
promise.reject("COMPRESSED_START_FAILED", "Failed to start compressed recording", e)
|
|
622
|
+
return
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Return success result with both file URIs
|
|
626
|
+
val result = bundleOf(
|
|
627
|
+
"fileUri" to audioFile?.toURI().toString(),
|
|
628
|
+
"channels" to recordingConfig.channels,
|
|
629
|
+
"bitDepth" to AudioFormatUtils.getBitDepth(recordingConfig.encoding),
|
|
630
|
+
"sampleRate" to recordingConfig.sampleRate,
|
|
631
|
+
"mimeType" to mimeType,
|
|
632
|
+
"compression" to if (compressedFile != null) bundleOf(
|
|
633
|
+
"mimeType" to if (recordingConfig.output.compressed.format == "aac") "audio/aac" else "audio/opus",
|
|
634
|
+
"bitrate" to recordingConfig.output.compressed.bitrate,
|
|
635
|
+
"format" to recordingConfig.output.compressed.format,
|
|
636
|
+
"size" to 0,
|
|
637
|
+
"compressedFileUri" to compressedFile?.toURI().toString()
|
|
638
|
+
) else null
|
|
639
|
+
)
|
|
640
|
+
promise.resolve(result)
|
|
641
|
+
|
|
642
|
+
} catch (e: Exception) {
|
|
643
|
+
releaseAudioFocus()
|
|
644
|
+
unregisterPhoneStateListener()
|
|
645
|
+
promise.reject("UNEXPECTED_ERROR", "Unexpected error: ${e.message}", e)
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
private fun isAudioFormatSupported(sampleRate: Int, channels: Int, format: Int): Boolean {
|
|
650
|
+
if (!permissionUtils.checkRecordingPermission(enableBackgroundAudio)) {
|
|
651
|
+
throw SecurityException("Recording permission has not been granted")
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
val channelConfig =
|
|
655
|
+
if (channels == 1) AudioFormat.CHANNEL_IN_MONO else AudioFormat.CHANNEL_IN_STEREO
|
|
656
|
+
val bufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, format)
|
|
657
|
+
|
|
658
|
+
if (bufferSize <= 0) {
|
|
659
|
+
return false
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
val audioRecord = AudioRecord(
|
|
663
|
+
MediaRecorder.AudioSource.MIC,
|
|
664
|
+
sampleRate,
|
|
665
|
+
channelConfig,
|
|
666
|
+
format,
|
|
667
|
+
bufferSize
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
val isSupported = audioRecord.state == AudioRecord.STATE_INITIALIZED
|
|
671
|
+
if (isSupported) {
|
|
672
|
+
val testBuffer = ByteArray(bufferSize)
|
|
673
|
+
audioRecord.startRecording()
|
|
674
|
+
val testRead = audioRecord.read(testBuffer, 0, bufferSize)
|
|
675
|
+
audioRecord.stop()
|
|
676
|
+
if (testRead < 0) {
|
|
677
|
+
return false
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
audioRecord.release()
|
|
682
|
+
return isSupported
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
private fun checkPermissions(options: Map<String, Any?>, promise: Promise): Boolean {
|
|
686
|
+
if (!permissionUtils.checkRecordingPermission(enableBackgroundAudio)) {
|
|
687
|
+
promise.reject(
|
|
688
|
+
"PERMISSION_DENIED",
|
|
689
|
+
"Recording permission has not been granted",
|
|
690
|
+
null
|
|
691
|
+
)
|
|
692
|
+
return false
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Only check phone state permission if enabled
|
|
696
|
+
if (enablePhoneStateHandling && !permissionUtils.checkPhoneStatePermission()) {
|
|
697
|
+
LogUtils.w(CLASS_NAME, "READ_PHONE_STATE permission not granted, phone call interruption handling will be disabled")
|
|
698
|
+
// Don't reject here, just log warning as this is optional
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// Only check notification permission if enabled
|
|
702
|
+
if (options["showNotification"] as? Boolean == true &&
|
|
703
|
+
!permissionUtils.checkNotificationPermission()) {
|
|
704
|
+
promise.reject(
|
|
705
|
+
"NOTIFICATION_PERMISSION_DENIED",
|
|
706
|
+
"Notification permission has not been granted",
|
|
707
|
+
null
|
|
708
|
+
)
|
|
709
|
+
return false
|
|
710
|
+
}
|
|
711
|
+
return true
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
private fun initializeAudioFormat(promise: Promise): Boolean {
|
|
716
|
+
if (!isAudioFormatSupported(
|
|
717
|
+
recordingConfig.sampleRate,
|
|
718
|
+
recordingConfig.channels,
|
|
719
|
+
audioFormat
|
|
720
|
+
)
|
|
721
|
+
) {
|
|
722
|
+
LogUtils.e(CLASS_NAME, "Selected audio format not supported, falling back to 16-bit PCM")
|
|
723
|
+
audioFormat = AudioFormat.ENCODING_PCM_16BIT
|
|
724
|
+
|
|
725
|
+
if (!isAudioFormatSupported(
|
|
726
|
+
recordingConfig.sampleRate,
|
|
727
|
+
recordingConfig.channels,
|
|
728
|
+
audioFormat
|
|
729
|
+
)
|
|
730
|
+
) {
|
|
731
|
+
promise.reject(
|
|
732
|
+
"INITIALIZATION_FAILED",
|
|
733
|
+
"Failed to initialize audio recorder with any supported format",
|
|
734
|
+
null
|
|
735
|
+
)
|
|
736
|
+
return false
|
|
737
|
+
}
|
|
738
|
+
recordingConfig = recordingConfig.copy(encoding = "pcm_16bit")
|
|
739
|
+
mimeType = "audio/wav"
|
|
740
|
+
}
|
|
741
|
+
return true
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
private fun initializeBufferSize(promise: Promise): Boolean {
|
|
745
|
+
try {
|
|
746
|
+
val channelConfig = if (recordingConfig.channels == 1) {
|
|
747
|
+
AudioFormat.CHANNEL_IN_MONO
|
|
748
|
+
} else {
|
|
749
|
+
AudioFormat.CHANNEL_IN_STEREO
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
val minBufferSize = AudioRecord.getMinBufferSize(
|
|
753
|
+
recordingConfig.sampleRate,
|
|
754
|
+
channelConfig,
|
|
755
|
+
audioFormat
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
// Calculate buffer size based on bufferDurationSeconds if provided
|
|
759
|
+
var requestedBufferSize = recordingConfig.bufferDurationSeconds?.let { bufferDuration ->
|
|
760
|
+
val bytesPerSample = when (recordingConfig.encoding) {
|
|
761
|
+
"pcm_8bit" -> 1
|
|
762
|
+
"pcm_16bit" -> 2
|
|
763
|
+
"pcm_32bit" -> 4
|
|
764
|
+
else -> 2
|
|
765
|
+
}
|
|
766
|
+
(bufferDuration * recordingConfig.sampleRate * bytesPerSample * recordingConfig.channels).toInt()
|
|
767
|
+
} ?: minBufferSize
|
|
768
|
+
|
|
769
|
+
LogUtils.d(CLASS_NAME, "Calculated minBufferSize: $minBufferSize bytes")
|
|
770
|
+
LogUtils.d(CLASS_NAME, "Requested buffer size: $requestedBufferSize bytes")
|
|
771
|
+
|
|
772
|
+
// Cap the buffer size to prevent OOM
|
|
773
|
+
val MAX_BUFFER_SIZE = 10485760 // 10MB
|
|
774
|
+
if (requestedBufferSize > MAX_BUFFER_SIZE) {
|
|
775
|
+
LogUtils.w(CLASS_NAME, "Requested buffer size $requestedBufferSize exceeds max limit of $MAX_BUFFER_SIZE, capping to max")
|
|
776
|
+
requestedBufferSize = MAX_BUFFER_SIZE
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
bufferSizeInBytes = maxOf(requestedBufferSize, minBufferSize)
|
|
780
|
+
LogUtils.d(CLASS_NAME, "Final bufferSizeInBytes: $bufferSizeInBytes (after capping and min check)")
|
|
781
|
+
|
|
782
|
+
when {
|
|
783
|
+
bufferSizeInBytes == AudioRecord.ERROR -> {
|
|
784
|
+
LogUtils.e(CLASS_NAME, "Error getting minimum buffer size: ERROR")
|
|
785
|
+
promise.reject(
|
|
786
|
+
"BUFFER_SIZE_ERROR",
|
|
787
|
+
"Failed to get minimum buffer size: generic error",
|
|
788
|
+
null
|
|
789
|
+
)
|
|
790
|
+
return false
|
|
791
|
+
}
|
|
792
|
+
bufferSizeInBytes == AudioRecord.ERROR_BAD_VALUE -> {
|
|
793
|
+
LogUtils.e(CLASS_NAME, "Error getting minimum buffer size: BAD_VALUE")
|
|
794
|
+
promise.reject(
|
|
795
|
+
"BUFFER_SIZE_ERROR",
|
|
796
|
+
"Failed to get minimum buffer size: invalid parameters",
|
|
797
|
+
null
|
|
798
|
+
)
|
|
799
|
+
return false
|
|
800
|
+
}
|
|
801
|
+
bufferSizeInBytes <= 0 -> {
|
|
802
|
+
LogUtils.e(CLASS_NAME, "Invalid buffer size: $bufferSizeInBytes")
|
|
803
|
+
promise.reject(
|
|
804
|
+
"BUFFER_SIZE_ERROR",
|
|
805
|
+
"Failed to get valid buffer size",
|
|
806
|
+
null
|
|
807
|
+
)
|
|
808
|
+
return false
|
|
809
|
+
}
|
|
810
|
+
else -> {
|
|
811
|
+
LogUtils.d(CLASS_NAME, "AudioFormat: $audioFormat, BufferSize: $bufferSizeInBytes")
|
|
812
|
+
return true
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
} catch (e: Exception) {
|
|
816
|
+
LogUtils.e(CLASS_NAME, "Failed to initialize buffer size", e)
|
|
817
|
+
promise.reject(
|
|
818
|
+
"BUFFER_SIZE_ERROR",
|
|
819
|
+
"Failed to initialize buffer size: ${e.message}",
|
|
820
|
+
e
|
|
821
|
+
)
|
|
822
|
+
return false
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
private fun initializeAudioRecord(promise: Promise): Boolean {
|
|
828
|
+
if (!permissionUtils.checkRecordingPermission(enableBackgroundAudio)) {
|
|
829
|
+
promise.reject(
|
|
830
|
+
"PERMISSION_DENIED",
|
|
831
|
+
"Recording permission has not been granted",
|
|
832
|
+
null
|
|
833
|
+
)
|
|
834
|
+
return false
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
try {
|
|
838
|
+
if (audioRecord == null || !isPaused.get()) {
|
|
839
|
+
LogUtils.d(CLASS_NAME, "Initializing AudioRecord with format: $audioFormat, BufferSize: $bufferSizeInBytes")
|
|
840
|
+
|
|
841
|
+
audioRecord = AudioRecord(
|
|
842
|
+
MediaRecorder.AudioSource.MIC,
|
|
843
|
+
recordingConfig.sampleRate,
|
|
844
|
+
if (recordingConfig.channels == 1) AudioFormat.CHANNEL_IN_MONO else AudioFormat.CHANNEL_IN_STEREO,
|
|
845
|
+
audioFormat,
|
|
846
|
+
bufferSizeInBytes
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) {
|
|
850
|
+
promise.reject(
|
|
851
|
+
"INITIALIZATION_FAILED",
|
|
852
|
+
"Failed to initialize the audio recorder",
|
|
853
|
+
null
|
|
854
|
+
)
|
|
855
|
+
return false
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
return true
|
|
859
|
+
|
|
860
|
+
} catch (e: SecurityException) {
|
|
861
|
+
LogUtils.e(CLASS_NAME, "Security exception while initializing AudioRecord", e)
|
|
862
|
+
promise.reject(
|
|
863
|
+
"PERMISSION_DENIED",
|
|
864
|
+
"Recording permission denied: ${e.message}",
|
|
865
|
+
e
|
|
866
|
+
)
|
|
867
|
+
return false
|
|
868
|
+
} catch (e: Exception) {
|
|
869
|
+
LogUtils.e(CLASS_NAME, "Failed to initialize AudioRecord", e)
|
|
870
|
+
promise.reject(
|
|
871
|
+
"INITIALIZATION_FAILED",
|
|
872
|
+
"Failed to initialize the audio recorder: ${e.message}",
|
|
873
|
+
e
|
|
874
|
+
)
|
|
875
|
+
return false
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
private fun initializeRecordingResources(fileExtension: String, promise: Promise): Boolean {
|
|
880
|
+
try {
|
|
881
|
+
streamUuid = java.util.UUID.randomUUID().toString()
|
|
882
|
+
totalDataSize = 0
|
|
883
|
+
|
|
884
|
+
// Reset cached file sizes
|
|
885
|
+
cachedPrimaryFileSize = 44L // WAV header size
|
|
886
|
+
cachedCompressedFileSize = 0L
|
|
887
|
+
|
|
888
|
+
// Only create file if primary output is enabled
|
|
889
|
+
if (recordingConfig.output.primary.enabled) {
|
|
890
|
+
audioFile = createRecordingFile(recordingConfig)
|
|
891
|
+
|
|
892
|
+
FileOutputStream(audioFile, true).use { fos ->
|
|
893
|
+
audioFileHandler.writeWavHeader(
|
|
894
|
+
fos,
|
|
895
|
+
recordingConfig.sampleRate,
|
|
896
|
+
recordingConfig.channels,
|
|
897
|
+
AudioFormatUtils.getBitDepth(recordingConfig.encoding)
|
|
898
|
+
)
|
|
899
|
+
}
|
|
900
|
+
} else {
|
|
901
|
+
// Set audioFile to null when primary output is disabled
|
|
902
|
+
audioFile = null
|
|
903
|
+
LogUtils.d(CLASS_NAME, "Skipping primary file creation - primary output is disabled")
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
if (recordingConfig.showNotification && enableBackgroundAudio) {
|
|
907
|
+
notificationManager.initialize(recordingConfig)
|
|
908
|
+
notificationManager.startUpdates(System.currentTimeMillis())
|
|
909
|
+
AudioRecordingService.startService(context)
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
acquireWakeLock()
|
|
913
|
+
audioProcessor.resetCumulativeAmplitudeRange()
|
|
914
|
+
return true
|
|
915
|
+
|
|
916
|
+
} catch (e: IOException) {
|
|
917
|
+
releaseWakeLock()
|
|
918
|
+
promise.reject("FILE_CREATION_FAILED", "Failed to create the audio file", e)
|
|
919
|
+
return false
|
|
920
|
+
} catch (e: Exception) {
|
|
921
|
+
releaseWakeLock()
|
|
922
|
+
LogUtils.e(CLASS_NAME, "Unexpected error in startRecording", e)
|
|
923
|
+
promise.reject("UNEXPECTED_ERROR", "Unexpected error: ${e.message}", e)
|
|
924
|
+
return false
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
private fun startRecordingProcess(promise: Promise): Boolean {
|
|
929
|
+
try {
|
|
930
|
+
// Add detailed logging of recording configuration
|
|
931
|
+
LogUtils.d(CLASS_NAME, """
|
|
932
|
+
Starting audio recording with configuration:
|
|
933
|
+
- Sample Rate: ${recordingConfig.sampleRate} Hz
|
|
934
|
+
- Channels: ${recordingConfig.channels}
|
|
935
|
+
- Encoding: ${recordingConfig.encoding}
|
|
936
|
+
- Buffer Duration: ${recordingConfig.bufferDurationSeconds?.let { "${it}s" } ?: "default"}
|
|
937
|
+
- Primary Output: ${recordingConfig.output.primary.enabled}
|
|
938
|
+
- Data Emission Interval: ${recordingConfig.interval}ms
|
|
939
|
+
- Analysis Interval: ${recordingConfig.intervalAnalysis}ms
|
|
940
|
+
- Processing Enabled: ${recordingConfig.enableProcessing}
|
|
941
|
+
- Keep Awake: ${recordingConfig.keepAwake}
|
|
942
|
+
- Show Notification: ${recordingConfig.showNotification}
|
|
943
|
+
- Show Waveform: ${recordingConfig.showWaveformInNotification}
|
|
944
|
+
- Compressed Output: ${recordingConfig.output.compressed.enabled}
|
|
945
|
+
${if (recordingConfig.output.compressed.enabled) """
|
|
946
|
+
- Compressed Format: ${recordingConfig.output.compressed.format}
|
|
947
|
+
- Compressed Bitrate: ${recordingConfig.output.compressed.bitrate}
|
|
948
|
+
""".trimIndent() else ""}
|
|
949
|
+
- Auto Resume: ${recordingConfig.autoResumeAfterInterruption}
|
|
950
|
+
- Output Directory: ${recordingConfig.outputDirectory ?: "default"}
|
|
951
|
+
- Filename: ${recordingConfig.filename ?: "auto-generated"}
|
|
952
|
+
- Features: ${recordingConfig.features.entries.joinToString { "${it.key}=${it.value}" }}
|
|
953
|
+
""".trimIndent())
|
|
954
|
+
|
|
955
|
+
audioRecord?.startRecording()
|
|
956
|
+
isPaused.set(false)
|
|
957
|
+
_isRecording.set(true)
|
|
958
|
+
isFirstChunk = true
|
|
959
|
+
|
|
960
|
+
if (!isPaused.get()) {
|
|
961
|
+
recordingStartTime = System.currentTimeMillis()
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
recordingThread = Thread { recordingProcess() }.apply { start() }
|
|
965
|
+
|
|
966
|
+
// Start service if keepAwake is true, but only if background audio is enabled (#288)
|
|
967
|
+
if (recordingConfig.keepAwake && enableBackgroundAudio) {
|
|
968
|
+
AudioRecordingService.startService(context)
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
return true
|
|
972
|
+
|
|
973
|
+
} catch (e: Exception) {
|
|
974
|
+
LogUtils.e(CLASS_NAME, "Failed to start recording", e)
|
|
975
|
+
cleanup()
|
|
976
|
+
promise.reject("START_FAILED", "Failed to start recording: ${e.message}", e)
|
|
977
|
+
return false
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
fun stopRecording(promise: Promise) {
|
|
982
|
+
val stopStartTime = System.currentTimeMillis()
|
|
983
|
+
|
|
984
|
+
synchronized(audioRecordLock) {
|
|
985
|
+
if (!_isRecording.get()) {
|
|
986
|
+
LogUtils.e(CLASS_NAME, "Recording is not active")
|
|
987
|
+
promise.reject("NOT_RECORDING", "Recording is not active", null)
|
|
988
|
+
return
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// Declare variables at the synchronized block level to ensure they're accessible in both try blocks
|
|
992
|
+
var duration: Long = 0
|
|
993
|
+
var fileSize: Long = 0
|
|
994
|
+
|
|
995
|
+
try {
|
|
996
|
+
|
|
997
|
+
if (isPaused.get()) {
|
|
998
|
+
val readStartTime = System.currentTimeMillis()
|
|
999
|
+
val remainingData = ByteArray(bufferSizeInBytes)
|
|
1000
|
+
val bytesRead = audioRecord?.read(remainingData, 0, bufferSizeInBytes) ?: -1
|
|
1001
|
+
if (bytesRead > 0) {
|
|
1002
|
+
emitAudioData(remainingData.copyOfRange(0, bytesRead), bytesRead)
|
|
1003
|
+
streamPosition += bytesRead // Update stream position for final data
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
if (recordingConfig.showNotification) {
|
|
1008
|
+
val notificationStartTime = System.currentTimeMillis()
|
|
1009
|
+
notificationManager.stopUpdates()
|
|
1010
|
+
AudioRecordingService.stopService(context)
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
_isRecording.set(false)
|
|
1014
|
+
isPrepared = false // Reset preparation state
|
|
1015
|
+
|
|
1016
|
+
// Use a reasonable fixed timeout for all cases
|
|
1017
|
+
// The recording thread should exit quickly with non-blocking read
|
|
1018
|
+
val timeoutMs = 2000L // 2 seconds should be more than enough
|
|
1019
|
+
val threadJoinStartTime = System.currentTimeMillis()
|
|
1020
|
+
recordingThread?.join(timeoutMs)
|
|
1021
|
+
|
|
1022
|
+
// This ensures complete audio data is captured even when stopped before interval threshold
|
|
1023
|
+
accumulatedAudioData?.let { audioData ->
|
|
1024
|
+
if (audioData.size() > 0) {
|
|
1025
|
+
LogUtils.d(CLASS_NAME, "Emitting final accumulated audio chunk of ${audioData.size()} bytes before stopping")
|
|
1026
|
+
emitAudioData(audioData.toByteArray(), audioData.size())
|
|
1027
|
+
streamPosition += audioData.size() // Update stream position for final data
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
LogUtils.d(CLASS_NAME, "Stopping recording state = ${audioRecord?.state}")
|
|
1032
|
+
if (audioRecord != null && audioRecord!!.state == AudioRecord.STATE_INITIALIZED) {
|
|
1033
|
+
val audioStopStartTime = System.currentTimeMillis()
|
|
1034
|
+
LogUtils.d(CLASS_NAME, "Stopping AudioRecord")
|
|
1035
|
+
audioRecord!!.stop()
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// Calculate duration BEFORE cleanup (which resets recordingStartTime)
|
|
1039
|
+
fileSize = if (recordingConfig.output.primary.enabled) cachedPrimaryFileSize else 0L
|
|
1040
|
+
LogUtils.d(CLASS_NAME, "WAV File validation - Size: $fileSize bytes (cached), Path: ${audioFile?.absolutePath}")
|
|
1041
|
+
|
|
1042
|
+
duration = if (!recordingConfig.output.primary.enabled) {
|
|
1043
|
+
// For streaming-only mode, calculate duration from actual recording time
|
|
1044
|
+
val actualRecordingTime = if (recordingStartTime > 0) {
|
|
1045
|
+
System.currentTimeMillis() - recordingStartTime - pausedDuration
|
|
1046
|
+
} else {
|
|
1047
|
+
0L
|
|
1048
|
+
}
|
|
1049
|
+
LogUtils.d(CLASS_NAME, "Streaming-only mode: Using actual recording time: ${actualRecordingTime}ms")
|
|
1050
|
+
actualRecordingTime
|
|
1051
|
+
} else {
|
|
1052
|
+
// For file-based recording, calculate duration from file size
|
|
1053
|
+
val dataFileSize = fileSize - 44 // Subtract header size
|
|
1054
|
+
val byteRate =
|
|
1055
|
+
recordingConfig.sampleRate * recordingConfig.channels * when (recordingConfig.encoding) {
|
|
1056
|
+
"pcm_8bit" -> 1
|
|
1057
|
+
"pcm_16bit" -> 2
|
|
1058
|
+
"pcm_32bit" -> 4
|
|
1059
|
+
else -> 2 // Default to 2 bytes per sample if the encoding is not recognized
|
|
1060
|
+
}
|
|
1061
|
+
val fileDuration = if (byteRate > 0) (dataFileSize * 1000 / byteRate) else 0
|
|
1062
|
+
LogUtils.d(CLASS_NAME, "File-based mode: Using file size duration: ${fileDuration}ms")
|
|
1063
|
+
fileDuration
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
val cleanupStartTime = System.currentTimeMillis()
|
|
1067
|
+
cleanup()
|
|
1068
|
+
} catch (e: IllegalStateException) {
|
|
1069
|
+
LogUtils.e(CLASS_NAME, "Error reading from AudioRecord", e)
|
|
1070
|
+
} finally {
|
|
1071
|
+
releaseWakeLock()
|
|
1072
|
+
audioRecord?.release()
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
try {
|
|
1076
|
+
AudioProcessor.resetUniqueIdCounter()
|
|
1077
|
+
audioProcessor.resetCumulativeAmplitudeRange()
|
|
1078
|
+
|
|
1079
|
+
if (compressedRecorder != null) {
|
|
1080
|
+
val compressedStopStartTime = System.currentTimeMillis()
|
|
1081
|
+
try {
|
|
1082
|
+
compressedRecorder?.stop()
|
|
1083
|
+
|
|
1084
|
+
val compressedReleaseStartTime = System.currentTimeMillis()
|
|
1085
|
+
compressedRecorder?.release()
|
|
1086
|
+
} catch (e: Exception) {
|
|
1087
|
+
LogUtils.e(CLASS_NAME, "Error stopping MediaRecorder: ${e.message}")
|
|
1088
|
+
}
|
|
1089
|
+
compressedRecorder = null
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// Log compressed file status if enabled - use actual file size for validation
|
|
1093
|
+
if (recordingConfig.output.compressed.enabled) {
|
|
1094
|
+
val fileSizeStartTime = System.currentTimeMillis()
|
|
1095
|
+
// Note: For compressed files, we need to get actual size as MediaRecorder handles the writing
|
|
1096
|
+
// Use actual file size here for validation purposes only
|
|
1097
|
+
val compressedSizeStartTime = System.currentTimeMillis()
|
|
1098
|
+
val compressedSize = compressedFile?.length() ?: 0
|
|
1099
|
+
cachedCompressedFileSize = compressedSize // Update cache with final size
|
|
1100
|
+
LogUtils.d(CLASS_NAME, "Compressed File validation - Size: $compressedSize bytes, Path: ${compressedFile?.absolutePath}")
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
// Log bit depth information for debugging
|
|
1104
|
+
val configBitDepth = AudioFormatUtils.getBitDepth(recordingConfig.encoding)
|
|
1105
|
+
LogUtils.d(CLASS_NAME, """
|
|
1106
|
+
Bit Depth Debug Info:
|
|
1107
|
+
- Config encoding: ${recordingConfig.encoding}
|
|
1108
|
+
- Config bit depth: $configBitDepth
|
|
1109
|
+
- Audio format: $audioFormat
|
|
1110
|
+
""".trimIndent())
|
|
1111
|
+
|
|
1112
|
+
val result = if (!recordingConfig.output.primary.enabled) {
|
|
1113
|
+
// When primary output is disabled, still include compression info if available
|
|
1114
|
+
val localCompressedFile = compressedFile // Create local copy to avoid smart cast issues
|
|
1115
|
+
val compressionBundle = if (recordingConfig.output.compressed.enabled && localCompressedFile != null) {
|
|
1116
|
+
bundleOf(
|
|
1117
|
+
"size" to cachedCompressedFileSize, // Use cached size
|
|
1118
|
+
"mimeType" to if (recordingConfig.output.compressed.format == "aac") "audio/aac" else "audio/opus",
|
|
1119
|
+
"bitrate" to recordingConfig.output.compressed.bitrate,
|
|
1120
|
+
"format" to recordingConfig.output.compressed.format,
|
|
1121
|
+
"compressedFileUri" to localCompressedFile.toURI().toString()
|
|
1122
|
+
)
|
|
1123
|
+
} else null
|
|
1124
|
+
|
|
1125
|
+
bundleOf(
|
|
1126
|
+
"fileUri" to (compressionBundle?.getString("compressedFileUri") ?: ""),
|
|
1127
|
+
"filename" to (localCompressedFile?.name ?: "stream-only"),
|
|
1128
|
+
"durationMs" to duration,
|
|
1129
|
+
"channels" to recordingConfig.channels,
|
|
1130
|
+
"bitDepth" to AudioFormatUtils.getBitDepth(recordingConfig.encoding),
|
|
1131
|
+
"sampleRate" to recordingConfig.sampleRate,
|
|
1132
|
+
"size" to (compressionBundle?.getLong("size") ?: totalDataSize),
|
|
1133
|
+
"mimeType" to (compressionBundle?.getString("mimeType") ?: mimeType),
|
|
1134
|
+
"createdAt" to System.currentTimeMillis(),
|
|
1135
|
+
"compression" to compressionBundle
|
|
1136
|
+
)
|
|
1137
|
+
} else {
|
|
1138
|
+
bundleOf(
|
|
1139
|
+
"fileUri" to audioFile?.toURI().toString(),
|
|
1140
|
+
"filename" to audioFile?.name,
|
|
1141
|
+
"durationMs" to duration,
|
|
1142
|
+
"channels" to recordingConfig.channels,
|
|
1143
|
+
"bitDepth" to AudioFormatUtils.getBitDepth(recordingConfig.encoding),
|
|
1144
|
+
"sampleRate" to recordingConfig.sampleRate,
|
|
1145
|
+
"size" to fileSize,
|
|
1146
|
+
"mimeType" to mimeType,
|
|
1147
|
+
"createdAt" to System.currentTimeMillis(),
|
|
1148
|
+
"compression" to if (compressedFile != null) bundleOf(
|
|
1149
|
+
"size" to cachedCompressedFileSize, // Use cached size
|
|
1150
|
+
"mimeType" to if (recordingConfig.output.compressed.format == "aac") "audio/aac" else "audio/opus",
|
|
1151
|
+
"bitrate" to recordingConfig.output.compressed.bitrate,
|
|
1152
|
+
"format" to recordingConfig.output.compressed.format,
|
|
1153
|
+
"compressedFileUri" to compressedFile?.toURI().toString()
|
|
1154
|
+
) else null
|
|
1155
|
+
)
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// Log total stop duration if it's slow
|
|
1159
|
+
val stopDuration = System.currentTimeMillis() - stopStartTime
|
|
1160
|
+
if (stopDuration > 200) {
|
|
1161
|
+
LogUtils.w(CLASS_NAME, "Stop recording took ${stopDuration}ms - consider investigating")
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
promise.resolve(result)
|
|
1165
|
+
|
|
1166
|
+
// Reset the timing variables
|
|
1167
|
+
_isRecording.set(false)
|
|
1168
|
+
isPaused.set(false)
|
|
1169
|
+
totalRecordedTime = 0
|
|
1170
|
+
pausedDuration = 0
|
|
1171
|
+
} catch (e: Exception) {
|
|
1172
|
+
LogUtils.e(CLASS_NAME, "Failed to stop recording: ${e.message}")
|
|
1173
|
+
promise.reject("STOP_FAILED", "Failed to stop recording", e)
|
|
1174
|
+
} finally {
|
|
1175
|
+
audioRecord = null
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
fun resumeRecording(promise: Promise) {
|
|
1181
|
+
LogUtils.d(CLASS_NAME, "⏺️ resumeRecording method entered - isPaused=${isPaused.get()}, isRecording=${_isRecording.get()}")
|
|
1182
|
+
if (!isPaused.get()) {
|
|
1183
|
+
LogUtils.e(CLASS_NAME, "⏺️ Cannot resume recording: not paused")
|
|
1184
|
+
promise.reject("NOT_PAUSED", "Recording is not paused", null)
|
|
1185
|
+
return
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
if (isOngoingCall()) {
|
|
1189
|
+
LogUtils.e(CLASS_NAME, "⏺️ Cannot resume recording: ongoing call detected")
|
|
1190
|
+
promise.reject("ONGOING_CALL", "Cannot resume recording during an ongoing call", null)
|
|
1191
|
+
return
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
try {
|
|
1195
|
+
// Check if audioRecord needs reinitializing
|
|
1196
|
+
var needsReinitialize = false
|
|
1197
|
+
synchronized(audioRecordLock) {
|
|
1198
|
+
LogUtils.d(CLASS_NAME, "⏺️ Checking audioRecord state: ${audioRecord?.state ?: "null"}")
|
|
1199
|
+
if (audioRecord == null || audioRecord?.state != AudioRecord.STATE_INITIALIZED) {
|
|
1200
|
+
LogUtils.d(CLASS_NAME, "⏺️ AudioRecord is null or not properly initialized, will reinitialize")
|
|
1201
|
+
needsReinitialize = true
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
// Reinitialize audioRecord if needed (like after device disconnection)
|
|
1206
|
+
if (needsReinitialize) {
|
|
1207
|
+
LogUtils.d(CLASS_NAME, "⏺️ Starting reinitialization of AudioRecord for resumption after disconnection")
|
|
1208
|
+
if (!initializeAudioRecord(object : Promise {
|
|
1209
|
+
override fun resolve(value: Any?) {
|
|
1210
|
+
LogUtils.d(CLASS_NAME, "⏺️ Successfully reinitialized AudioRecord for resumption")
|
|
1211
|
+
}
|
|
1212
|
+
override fun reject(code: String, message: String?, cause: Throwable?) {
|
|
1213
|
+
LogUtils.e(CLASS_NAME, "⏺️ Failed to reinitialize AudioRecord: $message")
|
|
1214
|
+
// We'll let the main try-catch handle this error
|
|
1215
|
+
throw IllegalStateException("Failed to reinitialize AudioRecord: $message")
|
|
1216
|
+
}
|
|
1217
|
+
})) {
|
|
1218
|
+
LogUtils.e(CLASS_NAME, "⏺️ Failed to reinitialize AudioRecord")
|
|
1219
|
+
throw IllegalStateException("Failed to reinitialize AudioRecord for resumption")
|
|
1220
|
+
}
|
|
1221
|
+
LogUtils.d(CLASS_NAME, "⏺️ Reinitialization completed successfully")
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
if (recordingConfig.showNotification) {
|
|
1225
|
+
LogUtils.d(CLASS_NAME, "⏺️ Resuming notification updates")
|
|
1226
|
+
notificationManager.resumeUpdates()
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
acquireWakeLock()
|
|
1230
|
+
pausedDuration += System.currentTimeMillis() - lastPauseTime
|
|
1231
|
+
isPaused.set(false)
|
|
1232
|
+
|
|
1233
|
+
synchronized(audioRecordLock) {
|
|
1234
|
+
// Double-check audioRecord is valid after potential reinitialization
|
|
1235
|
+
LogUtils.d(CLASS_NAME, "⏺️ Final check of audioRecord state: ${audioRecord?.state ?: "null"}")
|
|
1236
|
+
if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) {
|
|
1237
|
+
LogUtils.e(CLASS_NAME, "⏺️ AudioRecord is not properly initialized")
|
|
1238
|
+
throw IllegalStateException("AudioRecord is not properly initialized")
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
LogUtils.d(CLASS_NAME, "⏺️ Starting AudioRecord recording")
|
|
1242
|
+
audioRecord?.startRecording()
|
|
1243
|
+
LogUtils.d(CLASS_NAME, "⏺️ AudioRecord.startRecording called")
|
|
1244
|
+
|
|
1245
|
+
if (compressedRecorder != null) {
|
|
1246
|
+
LogUtils.d(CLASS_NAME, "⏺️ Resuming compressed recorder")
|
|
1247
|
+
compressedRecorder?.resume()
|
|
1248
|
+
LogUtils.d(CLASS_NAME, "⏺️ Compressed recorder resumed")
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
LogUtils.d(CLASS_NAME, "⏺️ Recording resumed successfully")
|
|
1253
|
+
promise.resolve("Recording resumed")
|
|
1254
|
+
} catch (e: Exception) {
|
|
1255
|
+
LogUtils.e(CLASS_NAME, "⏺️ Failed to resume recording: ${e.message}", e)
|
|
1256
|
+
releaseWakeLock()
|
|
1257
|
+
promise.reject("RESUME_FAILED", "Failed to resume recording: ${e.message}", e)
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
fun pauseRecording(promise: Promise) {
|
|
1262
|
+
if (_isRecording.get() && !isPaused.get()) {
|
|
1263
|
+
audioRecord?.stop()
|
|
1264
|
+
compressedRecorder?.pause()
|
|
1265
|
+
|
|
1266
|
+
lastPauseTime = System.currentTimeMillis()
|
|
1267
|
+
isPaused.set(true)
|
|
1268
|
+
|
|
1269
|
+
if (recordingConfig.showNotification) {
|
|
1270
|
+
notificationManager.pauseUpdates()
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
releaseWakeLock()
|
|
1274
|
+
promise.resolve("Recording paused")
|
|
1275
|
+
} else {
|
|
1276
|
+
promise.reject(
|
|
1277
|
+
"NOT_RECORDING_OR_ALREADY_PAUSED",
|
|
1278
|
+
"Recording is either not active or already paused",
|
|
1279
|
+
null
|
|
1280
|
+
)
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
fun getStatus(): Bundle {
|
|
1285
|
+
synchronized(audioRecordLock) {
|
|
1286
|
+
// Check if service is actually running
|
|
1287
|
+
val isServiceRunning = context.let { ctx ->
|
|
1288
|
+
val manager = ctx.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
|
|
1289
|
+
manager?.getRunningServices(Integer.MAX_VALUE)
|
|
1290
|
+
?.any { it.service.className == AudioRecordingService::class.java.name }
|
|
1291
|
+
} ?: false
|
|
1292
|
+
|
|
1293
|
+
// If service is running but we think we're not recording, clean up
|
|
1294
|
+
if (isServiceRunning && !_isRecording.get()) {
|
|
1295
|
+
LogUtils.d(CLASS_NAME, "Detected orphaned recording service, cleaning up...")
|
|
1296
|
+
cleanup()
|
|
1297
|
+
AudioRecordingService.stopService(context)
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
if (!_isRecording.get()) {
|
|
1301
|
+
LogUtils.d(CLASS_NAME, "Not recording --- skip status with default values")
|
|
1302
|
+
return bundleOf(
|
|
1303
|
+
"isRecording" to false,
|
|
1304
|
+
"isPaused" to false,
|
|
1305
|
+
"mime" to mimeType,
|
|
1306
|
+
"size" to 0,
|
|
1307
|
+
"interval" to if (::recordingConfig.isInitialized) recordingConfig.interval else 0
|
|
1308
|
+
)
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
// Use cached file size instead of file system call
|
|
1312
|
+
val fileSize = if (recordingConfig.output.primary.enabled) cachedPrimaryFileSize else 0L
|
|
1313
|
+
val duration = if (isPaused.get()) {
|
|
1314
|
+
// Return frozen duration when paused using lastPauseTime
|
|
1315
|
+
if (lastPauseTime > 0) {
|
|
1316
|
+
lastPauseTime - recordingStartTime - pausedDuration
|
|
1317
|
+
} else {
|
|
1318
|
+
0L
|
|
1319
|
+
}
|
|
1320
|
+
} else if (!recordingConfig.output.primary.enabled) {
|
|
1321
|
+
// For streaming-only mode, calculate duration from actual recording time
|
|
1322
|
+
val actualRecordingTime = if (recordingStartTime > 0) {
|
|
1323
|
+
System.currentTimeMillis() - recordingStartTime - pausedDuration
|
|
1324
|
+
} else {
|
|
1325
|
+
0L
|
|
1326
|
+
}
|
|
1327
|
+
actualRecordingTime
|
|
1328
|
+
} else {
|
|
1329
|
+
// For file-based recording, calculate duration from file size
|
|
1330
|
+
when (mimeType) {
|
|
1331
|
+
"audio/wav" -> {
|
|
1332
|
+
val dataFileSize = fileSize - Constants.WAV_HEADER_SIZE
|
|
1333
|
+
val byteRate = recordingConfig.sampleRate * recordingConfig.channels *
|
|
1334
|
+
(if (recordingConfig.encoding == "pcm_8bit") 8 else 16) / 8
|
|
1335
|
+
if (byteRate > 0) dataFileSize * 1000 / byteRate else 0
|
|
1336
|
+
}
|
|
1337
|
+
else -> totalRecordedTime
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
val compressionBundle = if (recordingConfig.output.compressed.enabled) {
|
|
1342
|
+
bundleOf(
|
|
1343
|
+
"size" to cachedCompressedFileSize, // Use cached size
|
|
1344
|
+
"mimeType" to if (recordingConfig.output.compressed.format == "aac") "audio/aac" else "audio/opus",
|
|
1345
|
+
"bitrate" to recordingConfig.output.compressed.bitrate,
|
|
1346
|
+
"format" to recordingConfig.output.compressed.format
|
|
1347
|
+
)
|
|
1348
|
+
} else null
|
|
1349
|
+
|
|
1350
|
+
return bundleOf(
|
|
1351
|
+
"durationMs" to duration,
|
|
1352
|
+
"isRecording" to _isRecording.get(),
|
|
1353
|
+
"isPaused" to isPaused.get(),
|
|
1354
|
+
"mimeType" to mimeType,
|
|
1355
|
+
"size" to totalDataSize,
|
|
1356
|
+
"interval" to recordingConfig.interval,
|
|
1357
|
+
"compression" to compressionBundle
|
|
1358
|
+
)
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
private fun acquireWakeLock() {
|
|
1363
|
+
if (recordingConfig.keepAwake && wakeLock == null) {
|
|
1364
|
+
try {
|
|
1365
|
+
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
|
1366
|
+
wakeLock = powerManager.newWakeLock(
|
|
1367
|
+
PowerManager.PARTIAL_WAKE_LOCK,
|
|
1368
|
+
"AudioRecorderManager::RecordingWakeLock"
|
|
1369
|
+
).apply {
|
|
1370
|
+
setReferenceCounted(false)
|
|
1371
|
+
acquire()
|
|
1372
|
+
}
|
|
1373
|
+
wasWakeLockEnabled = true
|
|
1374
|
+
LogUtils.d(CLASS_NAME, "Wake lock acquired")
|
|
1375
|
+
} catch (e: Exception) {
|
|
1376
|
+
LogUtils.e(CLASS_NAME, "Failed to acquire wake lock", e)
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
|
|
1382
|
+
private fun releaseWakeLock() {
|
|
1383
|
+
try {
|
|
1384
|
+
wakeLock?.let {
|
|
1385
|
+
if (it.isHeld) {
|
|
1386
|
+
it.release()
|
|
1387
|
+
LogUtils.d(CLASS_NAME, "Wake lock released")
|
|
1388
|
+
}
|
|
1389
|
+
wakeLock = null
|
|
1390
|
+
wasWakeLockEnabled = false
|
|
1391
|
+
}
|
|
1392
|
+
} catch (e: Exception) {
|
|
1393
|
+
LogUtils.e(CLASS_NAME, "Failed to release wake lock", e)
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
/**
|
|
1398
|
+
* Checks if there is an ongoing call that would interfere with recording
|
|
1399
|
+
*/
|
|
1400
|
+
private fun isOngoingCall(): Boolean {
|
|
1401
|
+
try {
|
|
1402
|
+
if (telephonyManager == null) return false
|
|
1403
|
+
|
|
1404
|
+
// Get phone call state directly from telephonyManager instead of
|
|
1405
|
+
// relying on audio manager state which could be misleading after device disconnection
|
|
1406
|
+
val callState = telephonyManager?.callState
|
|
1407
|
+
|
|
1408
|
+
LogUtils.d(CLASS_NAME, "Call state check: callState=${callState}, " +
|
|
1409
|
+
"audioManager.mode=${audioManager.mode}, " +
|
|
1410
|
+
"audioManager.isBluetoothScoOn=${audioManager.isBluetoothScoOn}")
|
|
1411
|
+
|
|
1412
|
+
// Trust phone state more than audio manager state
|
|
1413
|
+
if (callState == TelephonyManager.CALL_STATE_RINGING ||
|
|
1414
|
+
callState == TelephonyManager.CALL_STATE_OFFHOOK) {
|
|
1415
|
+
return true
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
// Only check audio manager mode as secondary indicator
|
|
1419
|
+
return audioManager.mode == AudioManager.MODE_IN_CALL ||
|
|
1420
|
+
audioManager.mode == AudioManager.MODE_IN_COMMUNICATION
|
|
1421
|
+
|
|
1422
|
+
// Remove audioManager.isBluetoothScoOn check as it can be erroneously true after disconnection
|
|
1423
|
+
} catch (e: Exception) {
|
|
1424
|
+
LogUtils.e(CLASS_NAME, "Error checking call state: ${e.message}")
|
|
1425
|
+
return false
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
fun listAudioFiles(promise: Promise) {
|
|
1430
|
+
val fileList =
|
|
1431
|
+
filesDir.list()?.filter { it.endsWith(".wav") }?.map { File(filesDir, it).absolutePath }
|
|
1432
|
+
?: listOf()
|
|
1433
|
+
promise.resolve(fileList)
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
fun clearAudioStorage() {
|
|
1437
|
+
audioFileHandler.clearAudioStorage()
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
private fun recordingProcess() {
|
|
1441
|
+
try {
|
|
1442
|
+
LogUtils.i(CLASS_NAME, "Starting recording process...")
|
|
1443
|
+
|
|
1444
|
+
// Only use FileOutputStream if primary output is enabled
|
|
1445
|
+
val fos = if (recordingConfig.output.primary.enabled && audioFile != null) {
|
|
1446
|
+
FileOutputStream(audioFile, true)
|
|
1447
|
+
} else {
|
|
1448
|
+
null
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
try {
|
|
1452
|
+
// Write audio data directly to the file (if not skipping)
|
|
1453
|
+
val audioData = ByteArray(bufferSizeInBytes)
|
|
1454
|
+
LogUtils.d(CLASS_NAME, "Entering recording loop")
|
|
1455
|
+
|
|
1456
|
+
// Buffer to accumulate data
|
|
1457
|
+
accumulatedAudioData = ByteArrayOutputStream()
|
|
1458
|
+
val accumulatedAnalysisData = ByteArrayOutputStream() // Separate buffer for analysis
|
|
1459
|
+
audioFileHandler.writeWavHeader(
|
|
1460
|
+
accumulatedAudioData!!,
|
|
1461
|
+
recordingConfig.sampleRate,
|
|
1462
|
+
recordingConfig.channels,
|
|
1463
|
+
when (recordingConfig.encoding) {
|
|
1464
|
+
"pcm_8bit" -> 8
|
|
1465
|
+
"pcm_16bit" -> 16
|
|
1466
|
+
"pcm_32bit" -> 32
|
|
1467
|
+
else -> 16 // Default to 16 if the encoding is not recognized
|
|
1468
|
+
}
|
|
1469
|
+
)
|
|
1470
|
+
|
|
1471
|
+
// Initialize timing variables
|
|
1472
|
+
var lastEmitTime = System.currentTimeMillis()
|
|
1473
|
+
lastEmissionTimeAnalysis = System.currentTimeMillis() // Use the class-level variable
|
|
1474
|
+
isFirstAnalysis = true // Use the class-level variable
|
|
1475
|
+
var shouldProcessAnalysis = false
|
|
1476
|
+
|
|
1477
|
+
// Debug log for intervals
|
|
1478
|
+
LogUtils.d(CLASS_NAME, """
|
|
1479
|
+
Recording process started with intervals:
|
|
1480
|
+
- Data emission interval: ${recordingConfig.interval}ms
|
|
1481
|
+
- Analysis interval: ${recordingConfig.intervalAnalysis}ms
|
|
1482
|
+
- Buffer size: $bufferSizeInBytes bytes
|
|
1483
|
+
""".trimIndent())
|
|
1484
|
+
|
|
1485
|
+
// Recording loop
|
|
1486
|
+
var loopCount = 0
|
|
1487
|
+
while (_isRecording.get() && !Thread.currentThread().isInterrupted) {
|
|
1488
|
+
loopCount++
|
|
1489
|
+
if (loopCount % 100 == 0) {
|
|
1490
|
+
LogUtils.d(CLASS_NAME, "Recording loop iteration $loopCount, isRecording: ${_isRecording.get()}, accumulatedAudioSize: ${accumulatedAudioData?.size() ?: 0}, accumulatedAnalysisSize: ${accumulatedAnalysisData.size()}")
|
|
1491
|
+
}
|
|
1492
|
+
if (isPaused.get()) {
|
|
1493
|
+
Thread.sleep(100) // Add small delay when paused
|
|
1494
|
+
continue
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
val currentTime = System.currentTimeMillis()
|
|
1498
|
+
val timeSinceLastAnalysis = currentTime - lastEmissionTimeAnalysis
|
|
1499
|
+
shouldProcessAnalysis = recordingConfig.enableProcessing &&
|
|
1500
|
+
(isFirstAnalysis || timeSinceLastAnalysis >= recordingConfig.intervalAnalysis)
|
|
1501
|
+
|
|
1502
|
+
val bytesRead = synchronized(audioRecordLock) {
|
|
1503
|
+
audioRecord?.let {
|
|
1504
|
+
if (it.state != AudioRecord.STATE_INITIALIZED) {
|
|
1505
|
+
LogUtils.e(CLASS_NAME, "AudioRecord not initialized")
|
|
1506
|
+
return@let -1
|
|
1507
|
+
}
|
|
1508
|
+
// Use non-blocking read mode to allow quick thread exit
|
|
1509
|
+
it.read(audioData, 0, bufferSizeInBytes, AudioRecord.READ_NON_BLOCKING).also { bytes ->
|
|
1510
|
+
if (bytes < 0) {
|
|
1511
|
+
LogUtils.e(CLASS_NAME, "AudioRecord read error: $bytes")
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
} ?: -1 // Handle null case
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
if (bytesRead > 0) {
|
|
1518
|
+
// Only write to file if primary output is enabled
|
|
1519
|
+
if (fos != null) {
|
|
1520
|
+
fos.write(audioData, 0, bytesRead)
|
|
1521
|
+
cachedPrimaryFileSize += bytesRead // Update cached file size
|
|
1522
|
+
}
|
|
1523
|
+
totalDataSize += bytesRead
|
|
1524
|
+
|
|
1525
|
+
accumulatedAudioData?.write(audioData, 0, bytesRead)
|
|
1526
|
+
|
|
1527
|
+
// Always accumulate data for analysis if enabled (moved outside shouldProcessAnalysis check)
|
|
1528
|
+
if (recordingConfig.enableProcessing) {
|
|
1529
|
+
// Check buffer size to prevent OOM on low-RAM devices with extreme configs
|
|
1530
|
+
if (accumulatedAnalysisData.size() + bytesRead <= MAX_ANALYSIS_BUFFER_SIZE) {
|
|
1531
|
+
accumulatedAnalysisData.write(audioData, 0, bytesRead)
|
|
1532
|
+
} else {
|
|
1533
|
+
LogUtils.w(CLASS_NAME, "Analysis buffer size limit reached (${accumulatedAnalysisData.size()} bytes). Skipping data to prevent OOM.")
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
// Handle regular audio data emission
|
|
1538
|
+
if (currentTime - lastEmitTime >= recordingConfig.interval) {
|
|
1539
|
+
accumulatedAudioData?.let { audioData ->
|
|
1540
|
+
emitAudioData(
|
|
1541
|
+
audioData.toByteArray(),
|
|
1542
|
+
audioData.size()
|
|
1543
|
+
)
|
|
1544
|
+
streamPosition += audioData.size() // Update stream position
|
|
1545
|
+
lastEmitTime = currentTime
|
|
1546
|
+
audioData.reset() // Clear the accumulator
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
// Handle analysis emission separately
|
|
1551
|
+
if (shouldProcessAnalysis) {
|
|
1552
|
+
val analysisDataSize = accumulatedAnalysisData.size()
|
|
1553
|
+
LogUtils.d(CLASS_NAME, """
|
|
1554
|
+
Processing analysis data:
|
|
1555
|
+
- Time since last: ${currentTime - lastEmissionTimeAnalysis}ms
|
|
1556
|
+
- Configured interval: ${recordingConfig.intervalAnalysis}ms
|
|
1557
|
+
- Accumulated size: $analysisDataSize bytes
|
|
1558
|
+
- Is first analysis: $isFirstAnalysis
|
|
1559
|
+
""".trimIndent())
|
|
1560
|
+
|
|
1561
|
+
if (analysisDataSize > 0) {
|
|
1562
|
+
// Add this check to enforce minimum interval
|
|
1563
|
+
if (isFirstAnalysis || (currentTime - lastEmissionTimeAnalysis) >= recordingConfig.intervalAnalysis) {
|
|
1564
|
+
try {
|
|
1565
|
+
// Process and emit analysis data
|
|
1566
|
+
val analysisData = audioProcessor.processAudioData(
|
|
1567
|
+
accumulatedAnalysisData.toByteArray(),
|
|
1568
|
+
recordingConfig
|
|
1569
|
+
)
|
|
1570
|
+
|
|
1571
|
+
LogUtils.d(CLASS_NAME, """
|
|
1572
|
+
Analysis data details:
|
|
1573
|
+
- Raw data size: ${accumulatedAnalysisData.size()} bytes
|
|
1574
|
+
""".trimIndent())
|
|
1575
|
+
|
|
1576
|
+
mainHandler.post {
|
|
1577
|
+
try {
|
|
1578
|
+
eventSender.sendExpoEvent(
|
|
1579
|
+
Constants.AUDIO_ANALYSIS_EVENT_NAME,
|
|
1580
|
+
analysisData.toBundle()
|
|
1581
|
+
)
|
|
1582
|
+
} catch (e: Exception) {
|
|
1583
|
+
LogUtils.e(CLASS_NAME, "Failed to send audio analysis event", e)
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
lastEmissionTimeAnalysis = currentTime
|
|
1588
|
+
isFirstAnalysis = false
|
|
1589
|
+
} catch (e: Exception) {
|
|
1590
|
+
LogUtils.e(CLASS_NAME, "Failed to process audio analysis data", e)
|
|
1591
|
+
} finally {
|
|
1592
|
+
// Always reset the buffer to prevent unbounded growth
|
|
1593
|
+
accumulatedAnalysisData.reset()
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
} else if (bytesRead == 0) {
|
|
1599
|
+
// No data available yet, sleep briefly to avoid busy-waiting
|
|
1600
|
+
Thread.sleep(10)
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
} finally {
|
|
1604
|
+
// Flush and close the file output stream if it was opened
|
|
1605
|
+
try {
|
|
1606
|
+
fos?.flush()
|
|
1607
|
+
LogUtils.d(CLASS_NAME, "FileOutputStream flushed successfully")
|
|
1608
|
+
} catch (e: Exception) {
|
|
1609
|
+
LogUtils.e(CLASS_NAME, "Error flushing FileOutputStream", e)
|
|
1610
|
+
}
|
|
1611
|
+
fos?.close()
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
// WAV header update is already handled in cleanup(), no need to duplicate here
|
|
1615
|
+
|
|
1616
|
+
} catch (e: Exception) {
|
|
1617
|
+
// Ensure wake lock is released if the thread is interrupted
|
|
1618
|
+
if (!isPaused.get()) {
|
|
1619
|
+
releaseWakeLock()
|
|
1620
|
+
}
|
|
1621
|
+
LogUtils.e(CLASS_NAME, "Error in recording process", e)
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
private fun emitAudioData(audioData: ByteArray, length: Int) {
|
|
1626
|
+
val isFloat32Stream = recordingConfig.streamFormat == "float32"
|
|
1627
|
+
|
|
1628
|
+
// Use cached file size instead of file system call
|
|
1629
|
+
val fileSize = if (recordingConfig.output.primary.enabled) cachedPrimaryFileSize else 0L
|
|
1630
|
+
val from = lastEmittedSize
|
|
1631
|
+
lastEmittedSize = fileSize
|
|
1632
|
+
|
|
1633
|
+
// Calculate position in milliseconds using stream position
|
|
1634
|
+
val bytesPerSample = when (recordingConfig.encoding) {
|
|
1635
|
+
"pcm_8bit" -> 1
|
|
1636
|
+
"pcm_16bit" -> 2
|
|
1637
|
+
"pcm_32bit" -> 4
|
|
1638
|
+
else -> 2
|
|
1639
|
+
}
|
|
1640
|
+
val byteRate = recordingConfig.sampleRate * recordingConfig.channels * bytesPerSample
|
|
1641
|
+
val positionInMs = (streamPosition * 1000) / byteRate
|
|
1642
|
+
|
|
1643
|
+
val compressionBundle = if (recordingConfig.output.compressed.enabled) {
|
|
1644
|
+
// For compressed files, we need to get actual size as MediaRecorder handles the writing
|
|
1645
|
+
// Only update cache periodically to avoid frequent file system calls
|
|
1646
|
+
val currentTime = System.currentTimeMillis()
|
|
1647
|
+
if (cachedCompressedFileSize == 0L || (currentTime - lastEmittedCompressedSize) > 5000) {
|
|
1648
|
+
cachedCompressedFileSize = compressedFile?.length() ?: 0
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
val compressedSize = cachedCompressedFileSize
|
|
1652
|
+
val eventDataSize = compressedSize - lastEmittedCompressedSize
|
|
1653
|
+
|
|
1654
|
+
// Read the new compressed data
|
|
1655
|
+
val compressedData = if (eventDataSize > 0) {
|
|
1656
|
+
try {
|
|
1657
|
+
compressedFile?.inputStream()?.use { input ->
|
|
1658
|
+
input.skip(lastEmittedCompressedSize)
|
|
1659
|
+
val buffer = ByteArray(eventDataSize.toInt())
|
|
1660
|
+
input.read(buffer)
|
|
1661
|
+
audioDataEncoder.encodeToBase64(buffer)
|
|
1662
|
+
}
|
|
1663
|
+
} catch (e: Exception) {
|
|
1664
|
+
LogUtils.e(CLASS_NAME, "Failed to read compressed data", e)
|
|
1665
|
+
null
|
|
1666
|
+
}
|
|
1667
|
+
} else null
|
|
1668
|
+
|
|
1669
|
+
lastEmittedCompressedSize = compressedSize
|
|
1670
|
+
|
|
1671
|
+
bundleOf(
|
|
1672
|
+
"position" to positionInMs,
|
|
1673
|
+
"fileUri" to compressedFile?.toURI().toString(),
|
|
1674
|
+
"eventDataSize" to eventDataSize,
|
|
1675
|
+
"totalSize" to compressedSize,
|
|
1676
|
+
"data" to compressedData
|
|
1677
|
+
)
|
|
1678
|
+
} else null
|
|
1679
|
+
|
|
1680
|
+
val baseBundle = if (isFloat32Stream) {
|
|
1681
|
+
val sampleCount = length / 2
|
|
1682
|
+
val float32 = FloatArray(sampleCount)
|
|
1683
|
+
for (i in 0 until sampleCount) {
|
|
1684
|
+
val lo = audioData[i * 2].toInt() and 0xFF
|
|
1685
|
+
val hi = audioData[i * 2 + 1].toInt() and 0xFF
|
|
1686
|
+
float32[i] = ((hi shl 8) or lo).toShort() / 32768f
|
|
1687
|
+
}
|
|
1688
|
+
bundleOf(
|
|
1689
|
+
"fileUri" to audioFile?.toURI().toString(),
|
|
1690
|
+
"lastEmittedSize" to from,
|
|
1691
|
+
"pcmFloat32" to float32,
|
|
1692
|
+
"deltaSize" to length,
|
|
1693
|
+
"position" to positionInMs,
|
|
1694
|
+
"mimeType" to mimeType,
|
|
1695
|
+
"totalSize" to fileSize,
|
|
1696
|
+
"streamUuid" to streamUuid,
|
|
1697
|
+
"compression" to compressionBundle
|
|
1698
|
+
)
|
|
1699
|
+
} else {
|
|
1700
|
+
val encodedBuffer = audioDataEncoder.encodeToBase64(audioData)
|
|
1701
|
+
bundleOf(
|
|
1702
|
+
"fileUri" to audioFile?.toURI().toString(),
|
|
1703
|
+
"lastEmittedSize" to from,
|
|
1704
|
+
"encoded" to encodedBuffer,
|
|
1705
|
+
"deltaSize" to length,
|
|
1706
|
+
"position" to positionInMs,
|
|
1707
|
+
"mimeType" to mimeType,
|
|
1708
|
+
"totalSize" to fileSize,
|
|
1709
|
+
"streamUuid" to streamUuid,
|
|
1710
|
+
"compression" to compressionBundle
|
|
1711
|
+
)
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
mainHandler.post {
|
|
1715
|
+
try {
|
|
1716
|
+
eventSender.sendExpoEvent(Constants.AUDIO_EVENT_NAME, baseBundle)
|
|
1717
|
+
} catch (e: Exception) {
|
|
1718
|
+
LogUtils.e(CLASS_NAME, "Failed to send event", e)
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
// Analysis is already handled in recordingProcess method to avoid duplicate processing
|
|
1723
|
+
// and prevent memory issues from accumulating data in multiple buffers
|
|
1724
|
+
|
|
1725
|
+
// Update notification waveform if needed (moved from processAudioData)
|
|
1726
|
+
if (recordingConfig.showNotification && recordingConfig.showWaveformInNotification) {
|
|
1727
|
+
val floatArray = convertByteArrayToFloatArray(audioData)
|
|
1728
|
+
notificationManager.updateNotification(floatArray)
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
private fun convertByteArrayToFloatArray(audioData: ByteArray): FloatArray {
|
|
1733
|
+
val floatArray = FloatArray(audioData.size / 2) // Assuming 16-bit PCM
|
|
1734
|
+
val buffer = ByteBuffer.wrap(audioData).order(ByteOrder.LITTLE_ENDIAN)
|
|
1735
|
+
for (i in floatArray.indices) {
|
|
1736
|
+
floatArray[i] = buffer.short.toFloat()
|
|
1737
|
+
}
|
|
1738
|
+
return floatArray
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
fun cleanup() {
|
|
1742
|
+
synchronized(audioRecordLock) {
|
|
1743
|
+
try {
|
|
1744
|
+
if (_isRecording.get()) {
|
|
1745
|
+
audioRecord?.stop()
|
|
1746
|
+
compressedRecorder?.stop()
|
|
1747
|
+
compressedRecorder?.release()
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
_isRecording.set(false)
|
|
1751
|
+
isPaused.set(false)
|
|
1752
|
+
isPrepared = false // Reset prepared state
|
|
1753
|
+
|
|
1754
|
+
if (::recordingConfig.isInitialized && recordingConfig.showNotification) {
|
|
1755
|
+
notificationManager.stopUpdates()
|
|
1756
|
+
AudioRecordingService.stopService(context)
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
releaseWakeLock()
|
|
1760
|
+
releaseAudioFocus()
|
|
1761
|
+
unregisterPhoneStateListener()
|
|
1762
|
+
audioRecord?.release()
|
|
1763
|
+
audioRecord = null
|
|
1764
|
+
|
|
1765
|
+
// Reset all state
|
|
1766
|
+
totalRecordedTime = 0
|
|
1767
|
+
pausedDuration = 0
|
|
1768
|
+
lastEmittedSize = 0
|
|
1769
|
+
streamPosition = 0
|
|
1770
|
+
recordingStartTime = 0
|
|
1771
|
+
|
|
1772
|
+
// Clean up accumulated audio data
|
|
1773
|
+
accumulatedAudioData?.close()
|
|
1774
|
+
accumulatedAudioData = null
|
|
1775
|
+
|
|
1776
|
+
// Update the WAV header if needed
|
|
1777
|
+
audioFile?.let { file ->
|
|
1778
|
+
// Skip WAV header update if we're only doing compressed output
|
|
1779
|
+
if (::recordingConfig.isInitialized &&
|
|
1780
|
+
!recordingConfig.output.primary.enabled &&
|
|
1781
|
+
recordingConfig.output.compressed.enabled) {
|
|
1782
|
+
// Skip WAV header update for compressed-only recording
|
|
1783
|
+
} else {
|
|
1784
|
+
audioFileHandler.updateWavHeader(file)
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
// Send event to notify that recording was stopped
|
|
1789
|
+
eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
|
|
1790
|
+
"reason" to "recordingStopped",
|
|
1791
|
+
"isPaused" to false
|
|
1792
|
+
))
|
|
1793
|
+
} catch (e: Exception) {
|
|
1794
|
+
LogUtils.e(CLASS_NAME, "Error during cleanup", e)
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
@RequiresApi(Build.VERSION_CODES.Q)
|
|
1800
|
+
private fun initializeCompressedRecorder(fileExtension: String, promise: Promise): Boolean {
|
|
1801
|
+
// Skip compressed recording if compressed output is not enabled
|
|
1802
|
+
if (!recordingConfig.output.compressed.enabled) {
|
|
1803
|
+
LogUtils.d(CLASS_NAME, "Skipping compressed recorder initialization - compressed output is disabled")
|
|
1804
|
+
return true
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
try {
|
|
1808
|
+
// Pass true to indicate this is a compressed file
|
|
1809
|
+
compressedFile = createRecordingFile(recordingConfig, isCompressed = true)
|
|
1810
|
+
|
|
1811
|
+
compressedRecorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
1812
|
+
MediaRecorder(context)
|
|
1813
|
+
} else {
|
|
1814
|
+
@Suppress("DEPRECATION")
|
|
1815
|
+
MediaRecorder()
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
compressedRecorder?.apply {
|
|
1819
|
+
setAudioSource(MediaRecorder.AudioSource.MIC)
|
|
1820
|
+
|
|
1821
|
+
// Choose output format based on codec and preferRawStream flag
|
|
1822
|
+
val outputFormat = when (recordingConfig.output.compressed.format) {
|
|
1823
|
+
"aac" -> {
|
|
1824
|
+
if (recordingConfig.output.compressed.preferRawStream) {
|
|
1825
|
+
MediaRecorder.OutputFormat.AAC_ADTS // Raw AAC stream
|
|
1826
|
+
} else {
|
|
1827
|
+
MediaRecorder.OutputFormat.MPEG_4 // M4A container (new default)
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
else -> MediaRecorder.OutputFormat.OGG // Opus uses OGG container
|
|
1831
|
+
}
|
|
1832
|
+
setOutputFormat(outputFormat)
|
|
1833
|
+
|
|
1834
|
+
setAudioEncoder(if (recordingConfig.output.compressed.format == "aac")
|
|
1835
|
+
MediaRecorder.AudioEncoder.AAC
|
|
1836
|
+
else MediaRecorder.AudioEncoder.OPUS)
|
|
1837
|
+
setAudioChannels(recordingConfig.channels)
|
|
1838
|
+
setAudioSamplingRate(recordingConfig.sampleRate)
|
|
1839
|
+
setAudioEncodingBitRate(recordingConfig.output.compressed.bitrate)
|
|
1840
|
+
setOutputFile(compressedFile?.absolutePath)
|
|
1841
|
+
prepare()
|
|
1842
|
+
}
|
|
1843
|
+
return true
|
|
1844
|
+
} catch (e: Exception) {
|
|
1845
|
+
LogUtils.e(CLASS_NAME, "Failed to initialize compressed recorder", e)
|
|
1846
|
+
promise.reject("COMPRESSED_INIT_FAILED", "Failed to initialize compressed recorder", e)
|
|
1847
|
+
return false
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
@SuppressLint("NewApi")
|
|
1852
|
+
private fun requestAudioFocus(): Boolean {
|
|
1853
|
+
val strategy = getAudioFocusStrategy()
|
|
1854
|
+
|
|
1855
|
+
when (strategy) {
|
|
1856
|
+
"none" -> {
|
|
1857
|
+
LogUtils.d(CLASS_NAME, "Skipping audio focus request (strategy: none)")
|
|
1858
|
+
return true
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
"background" -> {
|
|
1862
|
+
LogUtils.d(CLASS_NAME, "Background recording - minimal audio focus")
|
|
1863
|
+
// For true background recording, we don't request audio focus
|
|
1864
|
+
// This allows recording to continue uninterrupted when users switch apps
|
|
1865
|
+
return true
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
"communication" -> {
|
|
1869
|
+
return requestCommunicationAudioFocus()
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
"interactive" -> {
|
|
1873
|
+
return requestInteractiveAudioFocus()
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
else -> {
|
|
1877
|
+
LogUtils.w(CLASS_NAME, "Unknown audio focus strategy: $strategy, using interactive")
|
|
1878
|
+
return requestInteractiveAudioFocus()
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
private fun getAudioFocusStrategy(): String {
|
|
1884
|
+
// Use explicit strategy if provided
|
|
1885
|
+
if (::recordingConfig.isInitialized) {
|
|
1886
|
+
recordingConfig.audioFocusStrategy?.let {
|
|
1887
|
+
LogUtils.d(CLASS_NAME, "Using explicit audio focus strategy: $it")
|
|
1888
|
+
return it
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
// Smart defaults based on other config
|
|
1892
|
+
val defaultStrategy = if (recordingConfig.keepAwake && enableBackgroundAudio) {
|
|
1893
|
+
"background"
|
|
1894
|
+
} else {
|
|
1895
|
+
"interactive"
|
|
1896
|
+
}
|
|
1897
|
+
LogUtils.d(CLASS_NAME, "Using default audio focus strategy: $defaultStrategy (keepAwake=${recordingConfig.keepAwake}, enableBackgroundAudio=$enableBackgroundAudio)")
|
|
1898
|
+
return defaultStrategy
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
// Default strategy if recordingConfig is not initialized
|
|
1902
|
+
LogUtils.d(CLASS_NAME, "Using fallback audio focus strategy: interactive")
|
|
1903
|
+
return "interactive"
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
@SuppressLint("NewApi")
|
|
1907
|
+
private fun requestInteractiveAudioFocus(): Boolean {
|
|
1908
|
+
audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
|
|
1909
|
+
when (focusChange) {
|
|
1910
|
+
AudioManager.AUDIOFOCUS_LOSS,
|
|
1911
|
+
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
|
1912
|
+
if (_isRecording.get() && !isPaused.get()) {
|
|
1913
|
+
mainHandler.post {
|
|
1914
|
+
pauseRecording(object : Promise {
|
|
1915
|
+
override fun resolve(value: Any?) {
|
|
1916
|
+
isPaused.set(true)
|
|
1917
|
+
eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
|
|
1918
|
+
"reason" to "audioFocusLoss",
|
|
1919
|
+
"isPaused" to true
|
|
1920
|
+
))
|
|
1921
|
+
}
|
|
1922
|
+
override fun reject(code: String, message: String?, cause: Throwable?) {
|
|
1923
|
+
LogUtils.e(CLASS_NAME, "Failed to pause recording on audio focus loss")
|
|
1924
|
+
}
|
|
1925
|
+
})
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
AudioManager.AUDIOFOCUS_GAIN -> {
|
|
1930
|
+
val autoResume = if (::recordingConfig.isInitialized) recordingConfig.autoResumeAfterInterruption else false
|
|
1931
|
+
if (_isRecording.get() && isPaused.get() && autoResume) {
|
|
1932
|
+
mainHandler.post {
|
|
1933
|
+
resumeRecording(object : Promise {
|
|
1934
|
+
override fun resolve(value: Any?) {
|
|
1935
|
+
eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
|
|
1936
|
+
"reason" to "audioFocusGain",
|
|
1937
|
+
"isPaused" to false
|
|
1938
|
+
))
|
|
1939
|
+
}
|
|
1940
|
+
override fun reject(code: String, message: String?, cause: Throwable?) {
|
|
1941
|
+
LogUtils.e(CLASS_NAME, "Failed to resume recording on audio focus gain")
|
|
1942
|
+
}
|
|
1943
|
+
})
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
val result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
1951
|
+
val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
|
|
1952
|
+
.setAudioAttributes(AudioAttributes.Builder()
|
|
1953
|
+
.setUsage(AudioAttributes.USAGE_MEDIA)
|
|
1954
|
+
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
|
1955
|
+
.build())
|
|
1956
|
+
.setOnAudioFocusChangeListener(audioFocusChangeListener!!)
|
|
1957
|
+
.build()
|
|
1958
|
+
audioFocusRequest = focusRequest
|
|
1959
|
+
audioManager.requestAudioFocus(focusRequest)
|
|
1960
|
+
} else {
|
|
1961
|
+
@Suppress("DEPRECATION")
|
|
1962
|
+
audioManager.requestAudioFocus(
|
|
1963
|
+
audioFocusChangeListener,
|
|
1964
|
+
AudioManager.STREAM_MUSIC,
|
|
1965
|
+
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT
|
|
1966
|
+
)
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
return result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
@SuppressLint("NewApi")
|
|
1973
|
+
private fun requestCommunicationAudioFocus(): Boolean {
|
|
1974
|
+
audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
|
|
1975
|
+
when (focusChange) {
|
|
1976
|
+
AudioManager.AUDIOFOCUS_LOSS -> {
|
|
1977
|
+
// Only pause for permanent focus loss (like phone calls)
|
|
1978
|
+
if (_isRecording.get() && !isPaused.get()) {
|
|
1979
|
+
mainHandler.post {
|
|
1980
|
+
pauseRecording(object : Promise {
|
|
1981
|
+
override fun resolve(value: Any?) {
|
|
1982
|
+
isPaused.set(true)
|
|
1983
|
+
eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
|
|
1984
|
+
"reason" to "audioFocusLoss",
|
|
1985
|
+
"isPaused" to true
|
|
1986
|
+
))
|
|
1987
|
+
}
|
|
1988
|
+
override fun reject(code: String, message: String?, cause: Throwable?) {
|
|
1989
|
+
LogUtils.e(CLASS_NAME, "Failed to pause recording on audio focus loss")
|
|
1990
|
+
}
|
|
1991
|
+
})
|
|
1992
|
+
}
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
|
1996
|
+
// Don't pause for temporary loss in communication mode
|
|
1997
|
+
LogUtils.d(CLASS_NAME, "Ignoring transient audio focus loss in communication mode")
|
|
1998
|
+
}
|
|
1999
|
+
AudioManager.AUDIOFOCUS_GAIN -> {
|
|
2000
|
+
val autoResume = if (::recordingConfig.isInitialized) recordingConfig.autoResumeAfterInterruption else false
|
|
2001
|
+
if (_isRecording.get() && isPaused.get() && autoResume) {
|
|
2002
|
+
mainHandler.post {
|
|
2003
|
+
resumeRecording(object : Promise {
|
|
2004
|
+
override fun resolve(value: Any?) {
|
|
2005
|
+
eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
|
|
2006
|
+
"reason" to "audioFocusGain",
|
|
2007
|
+
"isPaused" to false
|
|
2008
|
+
))
|
|
2009
|
+
}
|
|
2010
|
+
override fun reject(code: String, message: String?, cause: Throwable?) {
|
|
2011
|
+
LogUtils.e(CLASS_NAME, "Failed to resume recording on audio focus gain")
|
|
2012
|
+
}
|
|
2013
|
+
})
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
val result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
2021
|
+
val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
|
2022
|
+
.setAudioAttributes(AudioAttributes.Builder()
|
|
2023
|
+
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
|
|
2024
|
+
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
|
2025
|
+
.build())
|
|
2026
|
+
.setAcceptsDelayedFocusGain(false)
|
|
2027
|
+
.setWillPauseWhenDucked(false)
|
|
2028
|
+
.setOnAudioFocusChangeListener(audioFocusChangeListener!!)
|
|
2029
|
+
.build()
|
|
2030
|
+
audioFocusRequest = focusRequest
|
|
2031
|
+
audioManager.requestAudioFocus(focusRequest)
|
|
2032
|
+
} else {
|
|
2033
|
+
@Suppress("DEPRECATION")
|
|
2034
|
+
audioManager.requestAudioFocus(
|
|
2035
|
+
audioFocusChangeListener,
|
|
2036
|
+
AudioManager.STREAM_VOICE_CALL,
|
|
2037
|
+
AudioManager.AUDIOFOCUS_GAIN
|
|
2038
|
+
)
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
return result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
private fun releaseAudioFocus() {
|
|
2045
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
2046
|
+
(audioFocusRequest as? AudioFocusRequest)?.let { request ->
|
|
2047
|
+
audioManager.abandonAudioFocusRequest(request)
|
|
2048
|
+
}
|
|
2049
|
+
} else {
|
|
2050
|
+
@Suppress("DEPRECATION")
|
|
2051
|
+
audioFocusChangeListener?.let { listener ->
|
|
2052
|
+
audioManager.abandonAudioFocus(listener)
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
audioFocusRequest = null
|
|
2056
|
+
audioFocusChangeListener = null
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
private fun createRecordingFile(config: RecordingConfig, isCompressed: Boolean = false): File {
|
|
2060
|
+
// Use custom directory or default to existing behavior
|
|
2061
|
+
val baseDir = config.outputDirectory?.let { File(it) } ?: filesDir
|
|
2062
|
+
|
|
2063
|
+
// Get base filename and remove any existing extension
|
|
2064
|
+
val baseFilename = config.filename?.let {
|
|
2065
|
+
it.substringBeforeLast('.', it) // Remove extension if present
|
|
2066
|
+
} ?: UUID.randomUUID().toString()
|
|
2067
|
+
|
|
2068
|
+
// Choose extension based on whether this is a compressed file
|
|
2069
|
+
val extension = if (isCompressed) {
|
|
2070
|
+
when (config.output.compressed.format.lowercase()) {
|
|
2071
|
+
"aac" -> {
|
|
2072
|
+
if (config.output.compressed.preferRawStream) {
|
|
2073
|
+
"aac" // Raw AAC stream
|
|
2074
|
+
} else {
|
|
2075
|
+
"m4a" // M4A container (new default)
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
"opus" -> "opus" // Opus in OGG container
|
|
2079
|
+
else -> config.output.compressed.format.lowercase()
|
|
2080
|
+
}
|
|
2081
|
+
} else {
|
|
2082
|
+
"wav"
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
return File(baseDir, "$baseFilename.$extension")
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
fun getKeepAwakeStatus(): Boolean {
|
|
2089
|
+
return recordingConfig?.keepAwake ?: true
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
/**
|
|
2093
|
+
* Prepares audio recording with all initial setup but without starting.
|
|
2094
|
+
* This reuses the existing validation and setup functions for compatibility.
|
|
2095
|
+
*/
|
|
2096
|
+
fun prepareRecording(options: Map<String, Any?>): Boolean {
|
|
2097
|
+
if (_isRecording.get()) {
|
|
2098
|
+
LogUtils.d(CLASS_NAME, "Cannot prepare recording - already recording")
|
|
2099
|
+
return false
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
if (isPrepared) {
|
|
2103
|
+
LogUtils.d(CLASS_NAME, "Already prepared")
|
|
2104
|
+
return true
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
try {
|
|
2108
|
+
// Initialize phone state listener only if enabled
|
|
2109
|
+
if (enablePhoneStateHandling) {
|
|
2110
|
+
initializePhoneStateListener()
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
// Check permissions - create a dummy promise to avoid rejections
|
|
2114
|
+
val dummyPromise = object : Promise {
|
|
2115
|
+
override fun resolve(value: Any?) {}
|
|
2116
|
+
override fun reject(code: String, message: String?, cause: Throwable?) {
|
|
2117
|
+
LogUtils.e(CLASS_NAME, "Preparation error: $code - $message", cause)
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
if (!checkPermissions(options, dummyPromise)) return false
|
|
2122
|
+
|
|
2123
|
+
// Parse recording configuration - reuse existing code
|
|
2124
|
+
val configResult = RecordingConfig.fromMap(options)
|
|
2125
|
+
if (configResult.isFailure) {
|
|
2126
|
+
LogUtils.e(CLASS_NAME, "Invalid configuration: ${configResult.exceptionOrNull()?.message}")
|
|
2127
|
+
return false
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
val (tempRecordingConfig, audioFormatInfo) = configResult.getOrNull()!!
|
|
2131
|
+
recordingConfig = tempRecordingConfig
|
|
2132
|
+
|
|
2133
|
+
// Store device-related settings
|
|
2134
|
+
selectedDeviceId = recordingConfig.deviceId
|
|
2135
|
+
deviceDisconnectionBehavior = recordingConfig.deviceDisconnectionBehavior ?: "pause"
|
|
2136
|
+
|
|
2137
|
+
audioFormat = audioFormatInfo.format
|
|
2138
|
+
mimeType = audioFormatInfo.mimeType
|
|
2139
|
+
|
|
2140
|
+
// Use all the existing validation functions with our dummy promise
|
|
2141
|
+
if (!initializeAudioFormat(dummyPromise)) return false
|
|
2142
|
+
if (!initializeBufferSize(dummyPromise)) return false
|
|
2143
|
+
if (!initializeAudioRecord(dummyPromise)) return false
|
|
2144
|
+
|
|
2145
|
+
if (recordingConfig.output.compressed.enabled && !initializeCompressedRecorder(
|
|
2146
|
+
if (recordingConfig.output.compressed.format == "aac") "aac" else "opus",
|
|
2147
|
+
dummyPromise
|
|
2148
|
+
)) return false
|
|
2149
|
+
|
|
2150
|
+
if (!initializeRecordingResources(audioFormatInfo.fileExtension, dummyPromise)) return false
|
|
2151
|
+
|
|
2152
|
+
// Everything is ready, mark as prepared
|
|
2153
|
+
isPrepared = true
|
|
2154
|
+
LogUtils.d(CLASS_NAME, "Recording prepared successfully")
|
|
2155
|
+
return true
|
|
2156
|
+
} catch (e: Exception) {
|
|
2157
|
+
LogUtils.e(CLASS_NAME, "Error during preparation: ${e.message}", e)
|
|
2158
|
+
cleanup()
|
|
2159
|
+
isPrepared = false
|
|
2160
|
+
return false
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
}
|