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