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