@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.
Files changed (375) hide show
  1. package/CHANGELOG.md +535 -0
  2. package/LICENSE +21 -0
  3. package/README.md +167 -0
  4. package/android/build.gradle +143 -0
  5. package/android/src/androidTest/assets/chorus.wav +0 -0
  6. package/android/src/androidTest/assets/jfk.wav +0 -0
  7. package/android/src/androidTest/assets/osr_us_000_0010_8k.wav +0 -0
  8. package/android/src/androidTest/assets/recorder_hello_world.wav +0 -0
  9. package/android/src/androidTest/java/net/siteed/audiostudio/AudioProcessorInstrumentedTest.kt +197 -0
  10. package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderInstrumentedTest.kt +541 -0
  11. package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderPerformanceInstrumentedTest.kt +234 -0
  12. package/android/src/androidTest/java/net/siteed/audiostudio/integration/AudioFocusStrategyIntegrationTest.kt +332 -0
  13. package/android/src/androidTest/java/net/siteed/audiostudio/integration/BufferDurationIntegrationTest.kt +324 -0
  14. package/android/src/androidTest/java/net/siteed/audiostudio/integration/CompressedOnlyOutputTest.kt +253 -0
  15. package/android/src/androidTest/java/net/siteed/audiostudio/integration/DeviceDisconnectionFallbackTest.kt +218 -0
  16. package/android/src/androidTest/java/net/siteed/audiostudio/integration/EventEmissionIntervalTest.kt +120 -0
  17. package/android/src/androidTest/java/net/siteed/audiostudio/integration/M4aFormatTest.kt +345 -0
  18. package/android/src/androidTest/java/net/siteed/audiostudio/integration/OutputControlIntegrationTest.kt +340 -0
  19. package/android/src/androidTest/java/net/siteed/audiostudio/integration/PcmStreamingDurationTest.kt +252 -0
  20. package/android/src/androidTest/java/net/siteed/audiostudio/integration/README.md +95 -0
  21. package/android/src/androidTest/java/net/siteed/audiostudio/integration/run_integration_tests.sh +43 -0
  22. package/android/src/main/AndroidManifest.xml +30 -0
  23. package/android/src/main/CMakeLists.txt +29 -0
  24. package/android/src/main/java/net/siteed/audiostudio/AudioAnalysisData.kt +188 -0
  25. package/android/src/main/java/net/siteed/audiostudio/AudioDataEncoder.kt +9 -0
  26. package/android/src/main/java/net/siteed/audiostudio/AudioDeviceManager.kt +1741 -0
  27. package/android/src/main/java/net/siteed/audiostudio/AudioFeaturesNative.kt +26 -0
  28. package/android/src/main/java/net/siteed/audiostudio/AudioFileHandler.kt +136 -0
  29. package/android/src/main/java/net/siteed/audiostudio/AudioFormatUtils.kt +354 -0
  30. package/android/src/main/java/net/siteed/audiostudio/AudioNotificationsManager.kt +439 -0
  31. package/android/src/main/java/net/siteed/audiostudio/AudioProcessor.kt +2237 -0
  32. package/android/src/main/java/net/siteed/audiostudio/AudioRecorderManager.kt +2163 -0
  33. package/android/src/main/java/net/siteed/audiostudio/AudioRecordingService.kt +167 -0
  34. package/android/src/main/java/net/siteed/audiostudio/AudioStudioModule.kt +1112 -0
  35. package/android/src/main/java/net/siteed/audiostudio/AudioTrimmer.kt +1099 -0
  36. package/android/src/main/java/net/siteed/audiostudio/Constants.kt +37 -0
  37. package/android/src/main/java/net/siteed/audiostudio/EventSender.kt +7 -0
  38. package/android/src/main/java/net/siteed/audiostudio/FFT.kt +100 -0
  39. package/android/src/main/java/net/siteed/audiostudio/Features.kt +98 -0
  40. package/android/src/main/java/net/siteed/audiostudio/LogUtils.kt +93 -0
  41. package/android/src/main/java/net/siteed/audiostudio/MelSpectrogramNative.kt +36 -0
  42. package/android/src/main/java/net/siteed/audiostudio/NotificationConfig.kt +72 -0
  43. package/android/src/main/java/net/siteed/audiostudio/PermissionUtils.kt +68 -0
  44. package/android/src/main/java/net/siteed/audiostudio/RecordingActionReceiver.kt +59 -0
  45. package/android/src/main/java/net/siteed/audiostudio/RecordingConfig.kt +259 -0
  46. package/android/src/main/java/net/siteed/audiostudio/WaveformConfig.kt +19 -0
  47. package/android/src/main/java/net/siteed/audiostudio/WaveformRenderer.kt +159 -0
  48. package/android/src/main/jni/AudioFeaturesJNI.cpp +152 -0
  49. package/android/src/main/jni/MelSpectrogramJNI.cpp +165 -0
  50. package/android/src/main/res/drawable/ic_default_action_icon.xml +16 -0
  51. package/android/src/main/res/drawable/ic_microphone.xml +13 -0
  52. package/android/src/main/res/drawable/ic_pause.xml +10 -0
  53. package/android/src/main/res/drawable/ic_play.xml +10 -0
  54. package/android/src/main/res/drawable/ic_stop.xml +10 -0
  55. package/android/src/main/res/layout/notification_recording.xml +37 -0
  56. package/android/src/test/java/net/siteed/audiostudio/AudioFileHandlerTest.kt +279 -0
  57. package/android/src/test/java/net/siteed/audiostudio/AudioFocusStrategyTest.kt +249 -0
  58. package/android/src/test/java/net/siteed/audiostudio/AudioFormatTest.kt +151 -0
  59. package/android/src/test/java/net/siteed/audiostudio/AudioFormatUtilsTest.kt +273 -0
  60. package/android/src/test/java/net/siteed/audiostudio/DeviceDisconnectionFallbackUnitTest.kt +140 -0
  61. package/android/src/test/resources/chorus.wav +0 -0
  62. package/android/src/test/resources/generate_test_audio.py +94 -0
  63. package/android/src/test/resources/jfk.wav +0 -0
  64. package/android/src/test/resources/osr_us_000_0010_8k.wav +0 -0
  65. package/android/src/test/resources/recorder_hello_world.wav +0 -0
  66. package/app.plugin.js +3 -0
  67. package/build/cjs/AudioAnalysis/AudioAnalysis.types.js +4 -0
  68. package/build/cjs/AudioAnalysis/AudioAnalysis.types.js.map +1 -0
  69. package/build/cjs/AudioAnalysis/audioFeaturesWasm.js +164 -0
  70. package/build/cjs/AudioAnalysis/audioFeaturesWasm.js.map +1 -0
  71. package/build/cjs/AudioAnalysis/extractAudioAnalysis.js +213 -0
  72. package/build/cjs/AudioAnalysis/extractAudioAnalysis.js.map +1 -0
  73. package/build/cjs/AudioAnalysis/extractAudioData.js +21 -0
  74. package/build/cjs/AudioAnalysis/extractAudioData.js.map +1 -0
  75. package/build/cjs/AudioAnalysis/extractMelSpectrogram.js +90 -0
  76. package/build/cjs/AudioAnalysis/extractMelSpectrogram.js.map +1 -0
  77. package/build/cjs/AudioAnalysis/extractPreview.js +28 -0
  78. package/build/cjs/AudioAnalysis/extractPreview.js.map +1 -0
  79. package/build/cjs/AudioAnalysis/extractWaveform.js +18 -0
  80. package/build/cjs/AudioAnalysis/extractWaveform.js.map +1 -0
  81. package/build/cjs/AudioAnalysis/melSpectrogramWasm.js +149 -0
  82. package/build/cjs/AudioAnalysis/melSpectrogramWasm.js.map +1 -0
  83. package/build/cjs/AudioDeviceManager.js +688 -0
  84. package/build/cjs/AudioDeviceManager.js.map +1 -0
  85. package/build/cjs/AudioRecorder.provider.js +78 -0
  86. package/build/cjs/AudioRecorder.provider.js.map +1 -0
  87. package/build/cjs/AudioStudio.native.js +8 -0
  88. package/build/cjs/AudioStudio.native.js.map +1 -0
  89. package/build/cjs/AudioStudio.types.js +11 -0
  90. package/build/cjs/AudioStudio.types.js.map +1 -0
  91. package/build/cjs/AudioStudio.web.js +708 -0
  92. package/build/cjs/AudioStudio.web.js.map +1 -0
  93. package/build/cjs/AudioStudioModule.js +718 -0
  94. package/build/cjs/AudioStudioModule.js.map +1 -0
  95. package/build/cjs/WebRecorder.web.js +865 -0
  96. package/build/cjs/WebRecorder.web.js.map +1 -0
  97. package/build/cjs/constants/platformLimitations.js +99 -0
  98. package/build/cjs/constants/platformLimitations.js.map +1 -0
  99. package/build/cjs/constants.js +20 -0
  100. package/build/cjs/constants.js.map +1 -0
  101. package/build/cjs/events.js +29 -0
  102. package/build/cjs/events.js.map +1 -0
  103. package/build/cjs/hooks/useAudioDevices.js +179 -0
  104. package/build/cjs/hooks/useAudioDevices.js.map +1 -0
  105. package/build/cjs/index.js +64 -0
  106. package/build/cjs/index.js.map +1 -0
  107. package/build/cjs/trimAudio.js +76 -0
  108. package/build/cjs/trimAudio.js.map +1 -0
  109. package/build/cjs/useAudioRecorder.js +535 -0
  110. package/build/cjs/useAudioRecorder.js.map +1 -0
  111. package/build/cjs/utils/BlobFix.js +502 -0
  112. package/build/cjs/utils/BlobFix.js.map +1 -0
  113. package/build/cjs/utils/audioProcessing.js +136 -0
  114. package/build/cjs/utils/audioProcessing.js.map +1 -0
  115. package/build/cjs/utils/cleanNativeOptions.js +22 -0
  116. package/build/cjs/utils/cleanNativeOptions.js.map +1 -0
  117. package/build/cjs/utils/concatenateBuffers.js +25 -0
  118. package/build/cjs/utils/concatenateBuffers.js.map +1 -0
  119. package/build/cjs/utils/convertPCMToFloat32.js +124 -0
  120. package/build/cjs/utils/convertPCMToFloat32.js.map +1 -0
  121. package/build/cjs/utils/crc32.js +52 -0
  122. package/build/cjs/utils/crc32.js.map +1 -0
  123. package/build/cjs/utils/encodingToBitDepth.js +17 -0
  124. package/build/cjs/utils/encodingToBitDepth.js.map +1 -0
  125. package/build/cjs/utils/getWavFileInfo.js +96 -0
  126. package/build/cjs/utils/getWavFileInfo.js.map +1 -0
  127. package/build/cjs/utils/writeWavHeader.js +88 -0
  128. package/build/cjs/utils/writeWavHeader.js.map +1 -0
  129. package/build/cjs/workers/InlineFeaturesExtractor.web.js +294 -0
  130. package/build/cjs/workers/InlineFeaturesExtractor.web.js.map +1 -0
  131. package/build/cjs/workers/inlineAudioWebWorker.web.js +190 -0
  132. package/build/cjs/workers/inlineAudioWebWorker.web.js.map +1 -0
  133. package/build/cjs/workers/wasmGlueString.web.js +27 -0
  134. package/build/cjs/workers/wasmGlueString.web.js.map +1 -0
  135. package/build/esm/AudioAnalysis/AudioAnalysis.types.js +3 -0
  136. package/build/esm/AudioAnalysis/AudioAnalysis.types.js.map +1 -0
  137. package/build/esm/AudioAnalysis/audioFeaturesWasm.js +126 -0
  138. package/build/esm/AudioAnalysis/audioFeaturesWasm.js.map +1 -0
  139. package/build/esm/AudioAnalysis/extractAudioAnalysis.js +205 -0
  140. package/build/esm/AudioAnalysis/extractAudioAnalysis.js.map +1 -0
  141. package/build/esm/AudioAnalysis/extractAudioData.js +14 -0
  142. package/build/esm/AudioAnalysis/extractAudioData.js.map +1 -0
  143. package/build/esm/AudioAnalysis/extractMelSpectrogram.js +86 -0
  144. package/build/esm/AudioAnalysis/extractMelSpectrogram.js.map +1 -0
  145. package/build/esm/AudioAnalysis/extractPreview.js +25 -0
  146. package/build/esm/AudioAnalysis/extractPreview.js.map +1 -0
  147. package/build/esm/AudioAnalysis/extractWaveform.js +11 -0
  148. package/build/esm/AudioAnalysis/extractWaveform.js.map +1 -0
  149. package/build/esm/AudioAnalysis/melSpectrogramWasm.js +111 -0
  150. package/build/esm/AudioAnalysis/melSpectrogramWasm.js.map +1 -0
  151. package/build/esm/AudioDeviceManager.js +681 -0
  152. package/build/esm/AudioDeviceManager.js.map +1 -0
  153. package/build/esm/AudioRecorder.provider.js +40 -0
  154. package/build/esm/AudioRecorder.provider.js.map +1 -0
  155. package/build/esm/AudioStudio.native.js +6 -0
  156. package/build/esm/AudioStudio.native.js.map +1 -0
  157. package/build/esm/AudioStudio.types.js +8 -0
  158. package/build/esm/AudioStudio.types.js.map +1 -0
  159. package/build/esm/AudioStudio.web.js +704 -0
  160. package/build/esm/AudioStudio.web.js.map +1 -0
  161. package/build/esm/AudioStudioModule.js +713 -0
  162. package/build/esm/AudioStudioModule.js.map +1 -0
  163. package/build/esm/WebRecorder.web.js +861 -0
  164. package/build/esm/WebRecorder.web.js.map +1 -0
  165. package/build/esm/constants/platformLimitations.js +90 -0
  166. package/build/esm/constants/platformLimitations.js.map +1 -0
  167. package/build/esm/constants.js +17 -0
  168. package/build/esm/constants.js.map +1 -0
  169. package/build/esm/events.js +21 -0
  170. package/build/esm/events.js.map +1 -0
  171. package/build/esm/hooks/useAudioDevices.js +176 -0
  172. package/build/esm/hooks/useAudioDevices.js.map +1 -0
  173. package/build/esm/index.js +23 -0
  174. package/build/esm/index.js.map +1 -0
  175. package/build/esm/trimAudio.js +69 -0
  176. package/build/esm/trimAudio.js.map +1 -0
  177. package/build/esm/useAudioRecorder.js +529 -0
  178. package/build/esm/useAudioRecorder.js.map +1 -0
  179. package/build/esm/utils/BlobFix.js +498 -0
  180. package/build/esm/utils/BlobFix.js.map +1 -0
  181. package/build/esm/utils/audioProcessing.js +133 -0
  182. package/build/esm/utils/audioProcessing.js.map +1 -0
  183. package/build/esm/utils/cleanNativeOptions.js +19 -0
  184. package/build/esm/utils/cleanNativeOptions.js.map +1 -0
  185. package/build/esm/utils/concatenateBuffers.js +21 -0
  186. package/build/esm/utils/concatenateBuffers.js.map +1 -0
  187. package/build/esm/utils/convertPCMToFloat32.js +120 -0
  188. package/build/esm/utils/convertPCMToFloat32.js.map +1 -0
  189. package/build/esm/utils/crc32.js +50 -0
  190. package/build/esm/utils/crc32.js.map +1 -0
  191. package/build/esm/utils/encodingToBitDepth.js +13 -0
  192. package/build/esm/utils/encodingToBitDepth.js.map +1 -0
  193. package/build/esm/utils/getWavFileInfo.js +92 -0
  194. package/build/esm/utils/getWavFileInfo.js.map +1 -0
  195. package/build/esm/utils/writeWavHeader.js +84 -0
  196. package/build/esm/utils/writeWavHeader.js.map +1 -0
  197. package/build/esm/workers/InlineFeaturesExtractor.web.js +291 -0
  198. package/build/esm/workers/InlineFeaturesExtractor.web.js.map +1 -0
  199. package/build/esm/workers/inlineAudioWebWorker.web.js +187 -0
  200. package/build/esm/workers/inlineAudioWebWorker.web.js.map +1 -0
  201. package/build/esm/workers/wasmGlueString.web.js +24 -0
  202. package/build/esm/workers/wasmGlueString.web.js.map +1 -0
  203. package/build/types/AudioAnalysis/AudioAnalysis.types.d.ts +198 -0
  204. package/build/types/AudioAnalysis/AudioAnalysis.types.d.ts.map +1 -0
  205. package/build/types/AudioAnalysis/audioFeaturesWasm.d.ts +24 -0
  206. package/build/types/AudioAnalysis/audioFeaturesWasm.d.ts.map +1 -0
  207. package/build/types/AudioAnalysis/extractAudioAnalysis.d.ts +74 -0
  208. package/build/types/AudioAnalysis/extractAudioAnalysis.d.ts.map +1 -0
  209. package/build/types/AudioAnalysis/extractAudioData.d.ts +3 -0
  210. package/build/types/AudioAnalysis/extractAudioData.d.ts.map +1 -0
  211. package/build/types/AudioAnalysis/extractMelSpectrogram.d.ts +20 -0
  212. package/build/types/AudioAnalysis/extractMelSpectrogram.d.ts.map +1 -0
  213. package/build/types/AudioAnalysis/extractPreview.d.ts +11 -0
  214. package/build/types/AudioAnalysis/extractPreview.d.ts.map +1 -0
  215. package/build/types/AudioAnalysis/extractWaveform.d.ts +8 -0
  216. package/build/types/AudioAnalysis/extractWaveform.d.ts.map +1 -0
  217. package/build/types/AudioAnalysis/melSpectrogramWasm.d.ts +16 -0
  218. package/build/types/AudioAnalysis/melSpectrogramWasm.d.ts.map +1 -0
  219. package/build/types/AudioDeviceManager.d.ts +187 -0
  220. package/build/types/AudioDeviceManager.d.ts.map +1 -0
  221. package/build/types/AudioRecorder.provider.d.ts +11 -0
  222. package/build/types/AudioRecorder.provider.d.ts.map +1 -0
  223. package/build/types/AudioStudio.native.d.ts +3 -0
  224. package/build/types/AudioStudio.native.d.ts.map +1 -0
  225. package/build/types/AudioStudio.types.d.ts +760 -0
  226. package/build/types/AudioStudio.types.d.ts.map +1 -0
  227. package/build/types/AudioStudio.web.d.ts +96 -0
  228. package/build/types/AudioStudio.web.d.ts.map +1 -0
  229. package/build/types/AudioStudioModule.d.ts +3 -0
  230. package/build/types/AudioStudioModule.d.ts.map +1 -0
  231. package/build/types/WebRecorder.web.d.ts +208 -0
  232. package/build/types/WebRecorder.web.d.ts.map +1 -0
  233. package/build/types/constants/platformLimitations.d.ts +40 -0
  234. package/build/types/constants/platformLimitations.d.ts.map +1 -0
  235. package/build/types/constants.d.ts +14 -0
  236. package/build/types/constants.d.ts.map +1 -0
  237. package/build/types/events.d.ts +29 -0
  238. package/build/types/events.d.ts.map +1 -0
  239. package/build/types/hooks/useAudioDevices.d.ts +15 -0
  240. package/build/types/hooks/useAudioDevices.d.ts.map +1 -0
  241. package/build/types/index.d.ts +21 -0
  242. package/build/types/index.d.ts.map +1 -0
  243. package/build/types/trimAudio.d.ts +25 -0
  244. package/build/types/trimAudio.d.ts.map +1 -0
  245. package/build/types/useAudioRecorder.d.ts +22 -0
  246. package/build/types/useAudioRecorder.d.ts.map +1 -0
  247. package/build/types/utils/BlobFix.d.ts +9 -0
  248. package/build/types/utils/BlobFix.d.ts.map +1 -0
  249. package/build/types/utils/audioProcessing.d.ts +24 -0
  250. package/build/types/utils/audioProcessing.d.ts.map +1 -0
  251. package/build/types/utils/cleanNativeOptions.d.ts +15 -0
  252. package/build/types/utils/cleanNativeOptions.d.ts.map +1 -0
  253. package/build/types/utils/concatenateBuffers.d.ts +8 -0
  254. package/build/types/utils/concatenateBuffers.d.ts.map +1 -0
  255. package/build/types/utils/convertPCMToFloat32.d.ts +13 -0
  256. package/build/types/utils/convertPCMToFloat32.d.ts.map +1 -0
  257. package/build/types/utils/crc32.d.ts +7 -0
  258. package/build/types/utils/crc32.d.ts.map +1 -0
  259. package/build/types/utils/encodingToBitDepth.d.ts +5 -0
  260. package/build/types/utils/encodingToBitDepth.d.ts.map +1 -0
  261. package/build/types/utils/getWavFileInfo.d.ts +26 -0
  262. package/build/types/utils/getWavFileInfo.d.ts.map +1 -0
  263. package/build/types/utils/writeWavHeader.d.ts +34 -0
  264. package/build/types/utils/writeWavHeader.d.ts.map +1 -0
  265. package/build/types/workers/InlineFeaturesExtractor.web.d.ts +2 -0
  266. package/build/types/workers/InlineFeaturesExtractor.web.d.ts.map +1 -0
  267. package/build/types/workers/inlineAudioWebWorker.web.d.ts +2 -0
  268. package/build/types/workers/inlineAudioWebWorker.web.d.ts.map +1 -0
  269. package/build/types/workers/wasmGlueString.web.d.ts +2 -0
  270. package/build/types/workers/wasmGlueString.web.d.ts.map +1 -0
  271. package/cpp/AudioFeatures.cpp +274 -0
  272. package/cpp/AudioFeatures.h +85 -0
  273. package/cpp/AudioFeaturesBridge.cpp +146 -0
  274. package/cpp/AudioFeaturesBridge.h +47 -0
  275. package/cpp/MelSpectrogram.cpp +227 -0
  276. package/cpp/MelSpectrogram.h +82 -0
  277. package/cpp/MelSpectrogramBridge.cpp +112 -0
  278. package/cpp/MelSpectrogramBridge.h +33 -0
  279. package/cpp/kiss_fft/COPYING +11 -0
  280. package/cpp/kiss_fft/_kiss_fft_guts.h +167 -0
  281. package/cpp/kiss_fft/kiss_fft.c +424 -0
  282. package/cpp/kiss_fft/kiss_fft.h +160 -0
  283. package/cpp/kiss_fft/kiss_fft_log.h +36 -0
  284. package/cpp/kiss_fft/kiss_fftr.c +155 -0
  285. package/cpp/kiss_fft/kiss_fftr.h +54 -0
  286. package/expo-module.config.json +10 -0
  287. package/ios/AudioAnalysisData.swift +74 -0
  288. package/ios/AudioDeviceManager.swift +670 -0
  289. package/ios/AudioFeaturesWrapper.h +21 -0
  290. package/ios/AudioFeaturesWrapper.mm +63 -0
  291. package/ios/AudioNotificationManager.swift +154 -0
  292. package/ios/AudioProcessingHelpers.swift +797 -0
  293. package/ios/AudioProcessor.swift +1191 -0
  294. package/ios/AudioStreamError.swift +7 -0
  295. package/ios/AudioStreamManager.swift +2369 -0
  296. package/ios/AudioStreamManagerDelegate.swift +16 -0
  297. package/ios/AudioStudio.podspec +39 -0
  298. package/ios/AudioStudioModule.swift +1111 -0
  299. package/ios/AudioStudioTests/AudioFileHandlerTests.swift +338 -0
  300. package/ios/AudioStudioTests/AudioFormatUtilsTests.swift +331 -0
  301. package/ios/AudioStudioTests/AudioTestHelpers.swift +130 -0
  302. package/ios/AudioStudioTests/CompressedOnlyOutputTests.swift +294 -0
  303. package/ios/AudioStudioTests/EventEmissionIntervalTests.swift +105 -0
  304. package/ios/AudioStudioTests/Info.plist +22 -0
  305. package/ios/AudioStudioTests/README.md +39 -0
  306. package/ios/AudioStudioTests/SimpleAudioTest.swift +98 -0
  307. package/ios/AudioStudioTests/TestAudioGenerator.swift +75 -0
  308. package/ios/DataPoint.swift +54 -0
  309. package/ios/DecodingConfig.swift +59 -0
  310. package/ios/FFT.swift +62 -0
  311. package/ios/Features.swift +95 -0
  312. package/ios/ISSUE_IOS.md +68 -0
  313. package/ios/Logger.swift +39 -0
  314. package/ios/MelSpectrogramWrapper.h +30 -0
  315. package/ios/MelSpectrogramWrapper.mm +97 -0
  316. package/ios/NotificationExtension.swift +15 -0
  317. package/ios/RecordingResult.swift +22 -0
  318. package/ios/RecordingSettings.swift +311 -0
  319. package/ios/WaveformExtractor.swift +105 -0
  320. package/ios/tests/README.md +41 -0
  321. package/ios/tests/integration/buffer_and_fallback_test.swift +178 -0
  322. package/ios/tests/integration/buffer_duration_test.swift +185 -0
  323. package/ios/tests/integration/compressed_only_output_test.swift +271 -0
  324. package/ios/tests/integration/output_control_test.swift +322 -0
  325. package/ios/tests/integration/run_integration_tests.sh +37 -0
  326. package/ios/tests/opus_support_test_macos.swift +154 -0
  327. package/ios/tests/standalone/audio_processing_test.swift +144 -0
  328. package/ios/tests/standalone/audio_recording_test.swift +277 -0
  329. package/ios/tests/standalone/audio_streaming_test.swift +249 -0
  330. package/ios/tests/standalone/standalone_test.swift +144 -0
  331. package/package.json +146 -0
  332. package/plugin/build/index.cjs +194 -0
  333. package/plugin/build/index.d.cts +22 -0
  334. package/plugin/build/index.js +194 -0
  335. package/plugin/src/index.ts +285 -0
  336. package/plugin/tsconfig.json +10 -0
  337. package/plugin/tsconfig.tsbuildinfo +1 -0
  338. package/prebuilt/wasm/mel-spectrogram.js +18 -0
  339. package/src/AudioAnalysis/AudioAnalysis.types.ts +226 -0
  340. package/src/AudioAnalysis/audio-features-wasm.d.ts +37 -0
  341. package/src/AudioAnalysis/audioFeaturesWasm.ts +200 -0
  342. package/src/AudioAnalysis/extractAudioAnalysis.ts +350 -0
  343. package/src/AudioAnalysis/extractAudioData.ts +17 -0
  344. package/src/AudioAnalysis/extractMelSpectrogram.ts +140 -0
  345. package/src/AudioAnalysis/extractPreview.ts +34 -0
  346. package/src/AudioAnalysis/extractWaveform.ts +22 -0
  347. package/src/AudioAnalysis/mel-spectrogram-wasm.d.ts +48 -0
  348. package/src/AudioAnalysis/melSpectrogramWasm.ts +179 -0
  349. package/src/AudioDeviceManager.ts +800 -0
  350. package/src/AudioRecorder.provider.tsx +57 -0
  351. package/src/AudioStudio.native.ts +6 -0
  352. package/src/AudioStudio.types.ts +899 -0
  353. package/src/AudioStudio.web.ts +911 -0
  354. package/src/AudioStudioModule.ts +984 -0
  355. package/src/WebRecorder.web.ts +1114 -0
  356. package/src/constants/platformLimitations.ts +118 -0
  357. package/src/constants.ts +21 -0
  358. package/src/events.ts +63 -0
  359. package/src/hooks/useAudioDevices.ts +213 -0
  360. package/src/index.ts +67 -0
  361. package/src/trimAudio.ts +94 -0
  362. package/src/types/crc-32.d.ts +9 -0
  363. package/src/useAudioRecorder.tsx +784 -0
  364. package/src/utils/BlobFix.ts +561 -0
  365. package/src/utils/audioProcessing.ts +205 -0
  366. package/src/utils/cleanNativeOptions.ts +18 -0
  367. package/src/utils/concatenateBuffers.ts +24 -0
  368. package/src/utils/convertPCMToFloat32.ts +170 -0
  369. package/src/utils/crc32.ts +59 -0
  370. package/src/utils/encodingToBitDepth.ts +18 -0
  371. package/src/utils/getWavFileInfo.ts +132 -0
  372. package/src/utils/writeWavHeader.ts +115 -0
  373. package/src/workers/InlineFeaturesExtractor.web.tsx +291 -0
  374. package/src/workers/inlineAudioWebWorker.web.tsx +186 -0
  375. 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
+ }