@siteed/expo-audio-stream 2.1.0 → 2.2.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 (187) hide show
  1. package/README.md +40 -222
  2. package/build/index.d.ts +11 -15
  3. package/build/index.js +44 -14
  4. package/package.json +49 -110
  5. package/src/index.ts +18 -32
  6. package/CHANGELOG.md +0 -206
  7. package/android/build.gradle +0 -105
  8. package/android/src/main/AndroidManifest.xml +0 -27
  9. package/android/src/main/java/net/siteed/audiostream/AudioAnalysisData.kt +0 -166
  10. package/android/src/main/java/net/siteed/audiostream/AudioDataEncoder.kt +0 -9
  11. package/android/src/main/java/net/siteed/audiostream/AudioFileHandler.kt +0 -131
  12. package/android/src/main/java/net/siteed/audiostream/AudioFormatUtils.kt +0 -103
  13. package/android/src/main/java/net/siteed/audiostream/AudioNotificationsManager.kt +0 -435
  14. package/android/src/main/java/net/siteed/audiostream/AudioProcessor.kt +0 -2235
  15. package/android/src/main/java/net/siteed/audiostream/AudioRecorderManager.kt +0 -1437
  16. package/android/src/main/java/net/siteed/audiostream/AudioRecordingService.kt +0 -152
  17. package/android/src/main/java/net/siteed/audiostream/AudioTrimmer.kt +0 -1099
  18. package/android/src/main/java/net/siteed/audiostream/Constants.kt +0 -21
  19. package/android/src/main/java/net/siteed/audiostream/EventSender.kt +0 -7
  20. package/android/src/main/java/net/siteed/audiostream/ExpoAudioStreamModule.kt +0 -739
  21. package/android/src/main/java/net/siteed/audiostream/FFT.kt +0 -99
  22. package/android/src/main/java/net/siteed/audiostream/Features.kt +0 -98
  23. package/android/src/main/java/net/siteed/audiostream/NotificationConfig.kt +0 -70
  24. package/android/src/main/java/net/siteed/audiostream/PermissionUtils.kt +0 -59
  25. package/android/src/main/java/net/siteed/audiostream/RecordingActionReceiver.kt +0 -59
  26. package/android/src/main/java/net/siteed/audiostream/RecordingConfig.kt +0 -205
  27. package/android/src/main/java/net/siteed/audiostream/WaveformConfig.kt +0 -19
  28. package/android/src/main/java/net/siteed/audiostream/WaveformRenderer.kt +0 -159
  29. package/android/src/main/res/drawable/ic_default_action_icon.xml +0 -16
  30. package/android/src/main/res/drawable/ic_microphone.xml +0 -13
  31. package/android/src/main/res/drawable/ic_pause.xml +0 -10
  32. package/android/src/main/res/drawable/ic_play.xml +0 -10
  33. package/android/src/main/res/drawable/ic_stop.xml +0 -10
  34. package/android/src/main/res/layout/notification_recording.xml +0 -37
  35. package/android/src/main/test/java/net/siteed/audiostream/AudioProcessorTest.kt +0 -56
  36. package/app.plugin.js +0 -1
  37. package/build/AudioAnalysis/AudioAnalysis.types.d.ts +0 -179
  38. package/build/AudioAnalysis/AudioAnalysis.types.d.ts.map +0 -1
  39. package/build/AudioAnalysis/AudioAnalysis.types.js +0 -3
  40. package/build/AudioAnalysis/AudioAnalysis.types.js.map +0 -1
  41. package/build/AudioAnalysis/extractAudioAnalysis.d.ts +0 -68
  42. package/build/AudioAnalysis/extractAudioAnalysis.d.ts.map +0 -1
  43. package/build/AudioAnalysis/extractAudioAnalysis.js +0 -203
  44. package/build/AudioAnalysis/extractAudioAnalysis.js.map +0 -1
  45. package/build/AudioAnalysis/extractAudioData.d.ts +0 -3
  46. package/build/AudioAnalysis/extractAudioData.d.ts.map +0 -1
  47. package/build/AudioAnalysis/extractAudioData.js +0 -5
  48. package/build/AudioAnalysis/extractAudioData.js.map +0 -1
  49. package/build/AudioAnalysis/extractMelSpectrogram.d.ts +0 -14
  50. package/build/AudioAnalysis/extractMelSpectrogram.d.ts.map +0 -1
  51. package/build/AudioAnalysis/extractMelSpectrogram.js +0 -85
  52. package/build/AudioAnalysis/extractMelSpectrogram.js.map +0 -1
  53. package/build/AudioAnalysis/extractPreview.d.ts +0 -11
  54. package/build/AudioAnalysis/extractPreview.d.ts.map +0 -1
  55. package/build/AudioAnalysis/extractPreview.js +0 -25
  56. package/build/AudioAnalysis/extractPreview.js.map +0 -1
  57. package/build/AudioAnalysis/extractWaveform.d.ts +0 -8
  58. package/build/AudioAnalysis/extractWaveform.d.ts.map +0 -1
  59. package/build/AudioAnalysis/extractWaveform.js +0 -11
  60. package/build/AudioAnalysis/extractWaveform.js.map +0 -1
  61. package/build/AudioRecorder.provider.d.ts +0 -11
  62. package/build/AudioRecorder.provider.d.ts.map +0 -1
  63. package/build/AudioRecorder.provider.js +0 -37
  64. package/build/AudioRecorder.provider.js.map +0 -1
  65. package/build/ExpoAudioStream.native.d.ts +0 -3
  66. package/build/ExpoAudioStream.native.d.ts.map +0 -1
  67. package/build/ExpoAudioStream.native.js +0 -6
  68. package/build/ExpoAudioStream.native.js.map +0 -1
  69. package/build/ExpoAudioStream.types.d.ts +0 -532
  70. package/build/ExpoAudioStream.types.d.ts.map +0 -1
  71. package/build/ExpoAudioStream.types.js +0 -2
  72. package/build/ExpoAudioStream.types.js.map +0 -1
  73. package/build/ExpoAudioStream.web.d.ts +0 -59
  74. package/build/ExpoAudioStream.web.d.ts.map +0 -1
  75. package/build/ExpoAudioStream.web.js +0 -285
  76. package/build/ExpoAudioStream.web.js.map +0 -1
  77. package/build/ExpoAudioStreamModule.d.ts +0 -3
  78. package/build/ExpoAudioStreamModule.d.ts.map +0 -1
  79. package/build/ExpoAudioStreamModule.js +0 -693
  80. package/build/ExpoAudioStreamModule.js.map +0 -1
  81. package/build/WebRecorder.web.d.ts +0 -119
  82. package/build/WebRecorder.web.d.ts.map +0 -1
  83. package/build/WebRecorder.web.js +0 -436
  84. package/build/WebRecorder.web.js.map +0 -1
  85. package/build/constants.d.ts +0 -11
  86. package/build/constants.d.ts.map +0 -1
  87. package/build/constants.js +0 -14
  88. package/build/constants.js.map +0 -1
  89. package/build/events.d.ts +0 -26
  90. package/build/events.d.ts.map +0 -1
  91. package/build/events.js +0 -21
  92. package/build/events.js.map +0 -1
  93. package/build/index.d.ts.map +0 -1
  94. package/build/index.js.map +0 -1
  95. package/build/trimAudio.d.ts +0 -25
  96. package/build/trimAudio.d.ts.map +0 -1
  97. package/build/trimAudio.js +0 -67
  98. package/build/trimAudio.js.map +0 -1
  99. package/build/useAudioRecorder.d.ts +0 -21
  100. package/build/useAudioRecorder.d.ts.map +0 -1
  101. package/build/useAudioRecorder.js +0 -427
  102. package/build/useAudioRecorder.js.map +0 -1
  103. package/build/utils/BlobFix.d.ts +0 -9
  104. package/build/utils/BlobFix.d.ts.map +0 -1
  105. package/build/utils/BlobFix.js +0 -498
  106. package/build/utils/BlobFix.js.map +0 -1
  107. package/build/utils/audioProcessing.d.ts +0 -24
  108. package/build/utils/audioProcessing.d.ts.map +0 -1
  109. package/build/utils/audioProcessing.js +0 -133
  110. package/build/utils/audioProcessing.js.map +0 -1
  111. package/build/utils/concatenateBuffers.d.ts +0 -8
  112. package/build/utils/concatenateBuffers.d.ts.map +0 -1
  113. package/build/utils/concatenateBuffers.js +0 -21
  114. package/build/utils/concatenateBuffers.js.map +0 -1
  115. package/build/utils/convertPCMToFloat32.d.ts +0 -13
  116. package/build/utils/convertPCMToFloat32.d.ts.map +0 -1
  117. package/build/utils/convertPCMToFloat32.js +0 -120
  118. package/build/utils/convertPCMToFloat32.js.map +0 -1
  119. package/build/utils/encodingToBitDepth.d.ts +0 -5
  120. package/build/utils/encodingToBitDepth.d.ts.map +0 -1
  121. package/build/utils/encodingToBitDepth.js +0 -13
  122. package/build/utils/encodingToBitDepth.js.map +0 -1
  123. package/build/utils/getWavFileInfo.d.ts +0 -26
  124. package/build/utils/getWavFileInfo.d.ts.map +0 -1
  125. package/build/utils/getWavFileInfo.js +0 -92
  126. package/build/utils/getWavFileInfo.js.map +0 -1
  127. package/build/utils/writeWavHeader.d.ts +0 -49
  128. package/build/utils/writeWavHeader.d.ts.map +0 -1
  129. package/build/utils/writeWavHeader.js +0 -91
  130. package/build/utils/writeWavHeader.js.map +0 -1
  131. package/build/workers/InlineFeaturesExtractor.web.d.ts +0 -2
  132. package/build/workers/InlineFeaturesExtractor.web.d.ts.map +0 -1
  133. package/build/workers/InlineFeaturesExtractor.web.js +0 -828
  134. package/build/workers/InlineFeaturesExtractor.web.js.map +0 -1
  135. package/build/workers/inlineAudioWebWorker.web.d.ts +0 -2
  136. package/build/workers/inlineAudioWebWorker.web.d.ts.map +0 -1
  137. package/build/workers/inlineAudioWebWorker.web.js +0 -157
  138. package/build/workers/inlineAudioWebWorker.web.js.map +0 -1
  139. package/expo-module.config.json +0 -9
  140. package/ios/AudioAnalysisData.swift +0 -74
  141. package/ios/AudioNotificationManager.swift +0 -135
  142. package/ios/AudioProcessingHelpers.swift +0 -743
  143. package/ios/AudioProcessor.swift +0 -1313
  144. package/ios/AudioStreamError.swift +0 -7
  145. package/ios/AudioStreamManager.swift +0 -1708
  146. package/ios/AudioStreamManagerDelegate.swift +0 -16
  147. package/ios/DataPoint.swift +0 -54
  148. package/ios/DecodingConfig.swift +0 -47
  149. package/ios/ExpoAudioStream.podspec +0 -27
  150. package/ios/ExpoAudioStreamModule.swift +0 -805
  151. package/ios/FFT.swift +0 -62
  152. package/ios/Features.swift +0 -95
  153. package/ios/Logger.swift +0 -7
  154. package/ios/NotificationExtension.swift +0 -15
  155. package/ios/RecordingResult.swift +0 -22
  156. package/ios/RecordingSettings.swift +0 -265
  157. package/ios/WaveformExtractor.swift +0 -105
  158. package/plugin/build/index.d.ts +0 -21
  159. package/plugin/build/index.js +0 -191
  160. package/plugin/src/index.ts +0 -278
  161. package/plugin/tsconfig.json +0 -10
  162. package/plugin/tsconfig.tsbuildinfo +0 -1
  163. package/src/AudioAnalysis/AudioAnalysis.types.ts +0 -202
  164. package/src/AudioAnalysis/extractAudioAnalysis.ts +0 -333
  165. package/src/AudioAnalysis/extractAudioData.ts +0 -6
  166. package/src/AudioAnalysis/extractMelSpectrogram.ts +0 -144
  167. package/src/AudioAnalysis/extractPreview.ts +0 -34
  168. package/src/AudioAnalysis/extractWaveform.ts +0 -22
  169. package/src/AudioRecorder.provider.tsx +0 -54
  170. package/src/ExpoAudioStream.native.ts +0 -6
  171. package/src/ExpoAudioStream.types.ts +0 -641
  172. package/src/ExpoAudioStream.web.ts +0 -359
  173. package/src/ExpoAudioStreamModule.ts +0 -967
  174. package/src/WebRecorder.web.ts +0 -580
  175. package/src/constants.ts +0 -18
  176. package/src/events.ts +0 -60
  177. package/src/trimAudio.ts +0 -90
  178. package/src/useAudioRecorder.tsx +0 -620
  179. package/src/utils/BlobFix.ts +0 -559
  180. package/src/utils/audioProcessing.ts +0 -205
  181. package/src/utils/concatenateBuffers.ts +0 -24
  182. package/src/utils/convertPCMToFloat32.ts +0 -170
  183. package/src/utils/encodingToBitDepth.ts +0 -18
  184. package/src/utils/getWavFileInfo.ts +0 -132
  185. package/src/utils/writeWavHeader.ts +0 -114
  186. package/src/workers/InlineFeaturesExtractor.web.tsx +0 -827
  187. package/src/workers/inlineAudioWebWorker.web.tsx +0 -156
@@ -1,1708 +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
- // Helper to convert to little-endian byte array
16
- extension UInt32 {
17
- var littleEndianBytes: [UInt8] {
18
- let value = self.littleEndian
19
- return [UInt8(value & 0xff), UInt8((value >> 8) & 0xff), UInt8((value >> 16) & 0xff), UInt8((value >> 24) & 0xff)]
20
- }
21
- }
22
-
23
- extension UInt16 {
24
- var littleEndianBytes: [UInt8] {
25
- let value = self.littleEndian
26
- return [UInt8(value & 0xff), UInt8((value >> 8) & 0xff)]
27
- }
28
- }
29
-
30
- class AudioStreamManager: NSObject {
31
- private let audioEngine = AVAudioEngine()
32
- private var inputNode: AVAudioInputNode {
33
- return audioEngine.inputNode
34
- }
35
- internal var recordingFileURL: URL?
36
- private var audioProcessor: AudioProcessor?
37
- private var startTime: Date?
38
- private var totalPausedDuration: TimeInterval = 0 // Track total paused time
39
- private var currentPauseStart: Date? // Track current pause start
40
- private var isRecording = false
41
- private var isPaused = false
42
-
43
- // Wake lock related properties
44
- private var wasIdleTimerDisabled: Bool = false // Track previous idle timer state
45
- private var isWakeLockEnabled: Bool = false // Track current wake lock state
46
-
47
-
48
- // Data emission for onAudioStream
49
- internal var lastEmissionTime: Date?
50
- internal var lastEmittedSize: Int64 = 0
51
- internal var lastEmittedCompressedSize: Int64 = 0
52
- private var totalDataSize: Int64 = 0
53
- private var lastBufferTime: AVAudioTime?
54
- private var accumulatedData = Data()
55
-
56
- // Data emission for onAudioAnalysis
57
- internal var lastEmissionTimeAnalysis: Date?
58
- internal var lastEmittedSizeAnalysis: Int64 = 0
59
- internal var lastEmittedCompressedSizeAnalysis: Int64 = 0
60
- private var totalDataSizeAnalysis: Int64 = 0
61
- private var lastBufferTimeAnalysis: AVAudioTime?
62
- private var accumulatedAnalysisData = Data()
63
-
64
-
65
-
66
- private var fileManager = FileManager.default
67
- internal var recordingSettings: RecordingSettings?
68
- internal var recordingUUID: UUID?
69
- internal var mimeType: String = "audio/wav"
70
- private var recentData = [Float]() // This property stores the recent audio data
71
- private var notificationUpdateTimer: Timer?
72
-
73
- private var notificationManager: AudioNotificationManager?
74
- private var notificationView: MPNowPlayingInfoCenter?
75
- private var audioSession: AVAudioSession?
76
- private var notificationObserver: Any?
77
- private var mediaInfoUpdateTimer: Timer?
78
- private var remoteCommandCenter: MPRemoteCommandCenter?
79
-
80
- weak var delegate: AudioStreamManagerDelegate? // Define the delegate here
81
-
82
- private var lastValidDuration: TimeInterval? // Add this property
83
-
84
- private var compressedRecorder: AVAudioRecorder?
85
- private var compressedFileURL: URL?
86
- private var compressedFormat: String = "aac"
87
- private var compressedBitRate: Int = 128000
88
-
89
- // Add property to track auto-resume preference
90
- private var autoResumeAfterInterruption: Bool = false
91
-
92
- // Add these properties
93
- private var emissionInterval: TimeInterval = 1.0 // Default 1 second
94
- private var emissionIntervalAnalysis: TimeInterval = 0.5 // Default 0.5 seconds
95
-
96
- /// Initializes the AudioStreamManager
97
- override init() {
98
- super.init()
99
- // Only keep audio session interruption observer here
100
- NotificationCenter.default.addObserver(
101
- self,
102
- selector: #selector(handleAudioSessionInterruption),
103
- name: AVAudioSession.interruptionNotification,
104
- object: nil
105
- )
106
- NotificationCenter.default.addObserver(
107
- self,
108
- selector: #selector(handleAppDidEnterBackground),
109
- name: UIApplication.didEnterBackgroundNotification,
110
- object: nil
111
- )
112
- NotificationCenter.default.addObserver(
113
- self,
114
- selector: #selector(handleAppWillEnterForeground),
115
- name: UIApplication.willEnterForegroundNotification,
116
- object: nil
117
- )
118
-
119
- }
120
-
121
- deinit {
122
- // Ensure wake lock is disabled when the manager is deallocated
123
- disableWakeLock()
124
- if let observer = notificationObserver {
125
- NotificationCenter.default.removeObserver(observer)
126
- }
127
- }
128
-
129
- /// Handles an audio session interruption.
130
- @objc private func handleAudioSessionInterruption(_ notification: Notification) {
131
- guard let userInfo = notification.userInfo,
132
- let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
133
- let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
134
- return
135
- }
136
-
137
- let wasSuspended = isPaused
138
-
139
- switch type {
140
- case .began:
141
- Logger.debug("Audio session interruption began")
142
- // Store the pause start time if not already paused
143
- if !wasSuspended {
144
- currentPauseStart = Date()
145
- pauseRecording()
146
- }
147
-
148
- // Always notify delegate of interruption
149
- delegate?.audioStreamManager(
150
- self,
151
- didReceiveInterruption: [
152
- "type": "began",
153
- "wasSuspended": wasSuspended
154
- ]
155
- )
156
-
157
- case .ended:
158
- Logger.debug("Audio session interruption ended - autoResume: \(autoResumeAfterInterruption), wasSuspended: \(wasSuspended)")
159
- if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt {
160
- let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
161
- Logger.debug("Interruption options - shouldResume: \(options.contains(.shouldResume))")
162
-
163
- // Calculate pause duration if we have a pause start time
164
- if let pauseStart = currentPauseStart {
165
- let pauseDuration = Date().timeIntervalSince(pauseStart)
166
- totalPausedDuration += pauseDuration
167
- currentPauseStart = nil
168
- Logger.debug("Added interruption pause duration: \(pauseDuration), total paused: \(totalPausedDuration)")
169
- }
170
-
171
- // For phone calls, we should auto-resume if enabled, regardless of previous pause state
172
- if autoResumeAfterInterruption && isRecording {
173
- // Add a longer delay for phone calls and ensure proper session setup
174
- DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
175
- guard let self = self else { return }
176
- Logger.debug("Attempting to auto-resume recording after phone call")
177
-
178
- // Configure audio session
179
- do {
180
- let session = AVAudioSession.sharedInstance()
181
- try session.setCategory(.playAndRecord, mode: .default, options: [.allowBluetooth, .mixWithOthers])
182
- try session.setActive(true, options: .notifyOthersOnDeactivation)
183
-
184
- // Resume if we're still recording and paused
185
- if self.isRecording && self.isPaused {
186
- Logger.debug("Resuming recording after phone call interruption")
187
- self.audioEngine.prepare()
188
- self.resumeRecording()
189
- } else {
190
- Logger.debug("Cannot resume - recording state invalid: isRecording=\(self.isRecording), isPaused=\(self.isPaused)")
191
- }
192
- } catch {
193
- Logger.debug("Failed to reactivate audio session: \(error)")
194
- self.delegate?.audioStreamManager(self, didFailWithError: "Failed to auto-resume: \(error.localizedDescription)")
195
- }
196
- }
197
- }
198
-
199
- // Always notify delegate of interruption end
200
- delegate?.audioStreamManager(
201
- self,
202
- didReceiveInterruption: [
203
- "type": "ended",
204
- "wasSuspended": wasSuspended,
205
- "shouldResume": options.contains(.shouldResume)
206
- ]
207
- )
208
- }
209
- @unknown default:
210
- break
211
- }
212
- }
213
-
214
- private func setupNowPlayingInfo() {
215
- // Configure audio session for background audio
216
- audioSession = AVAudioSession.sharedInstance()
217
- do {
218
- try audioSession?.setCategory(.playAndRecord, mode: .default, options: [.allowBluetooth, .mixWithOthers])
219
- try audioSession?.setActive(true)
220
- } catch {
221
- Logger.debug("Failed to configure audio session: \(error)")
222
- }
223
-
224
- // Setup Now Playing info
225
- notificationView = MPNowPlayingInfoCenter.default()
226
- updateNowPlayingInfo(isPaused: false)
227
-
228
- // Configure Remote Command Center
229
- setupRemoteCommandCenter()
230
-
231
- // Enable remote control events on main thread
232
- DispatchQueue.main.async {
233
- UIApplication.shared.beginReceivingRemoteControlEvents()
234
- }
235
- }
236
-
237
- private func setupRemoteCommandCenter() {
238
- remoteCommandCenter = MPRemoteCommandCenter.shared()
239
-
240
- // Remove any existing handlers
241
- remoteCommandCenter?.pauseCommand.removeTarget(nil)
242
- remoteCommandCenter?.playCommand.removeTarget(nil)
243
-
244
- // Add pause command handler
245
- remoteCommandCenter?.pauseCommand.addTarget { [weak self] _ in
246
- guard let self = self, self.isRecording && !self.isPaused else {
247
- return .commandFailed
248
- }
249
- self.pauseRecording()
250
- return .success
251
- }
252
-
253
- // Add play/resume command handler
254
- remoteCommandCenter?.playCommand.addTarget { [weak self] _ in
255
- guard let self = self, self.isRecording && self.isPaused else {
256
- return .commandFailed
257
- }
258
- self.resumeRecording()
259
- return .success
260
- }
261
-
262
- // Enable the commands
263
- remoteCommandCenter?.pauseCommand.isEnabled = true
264
- remoteCommandCenter?.playCommand.isEnabled = true
265
-
266
- // Disable unused commands
267
- remoteCommandCenter?.nextTrackCommand.isEnabled = false
268
- remoteCommandCenter?.previousTrackCommand.isEnabled = false
269
- remoteCommandCenter?.changePlaybackRateCommand.isEnabled = false
270
- remoteCommandCenter?.seekBackwardCommand.isEnabled = false
271
- remoteCommandCenter?.seekForwardCommand.isEnabled = false
272
- }
273
-
274
- private func updateNowPlayingInfo(isPaused: Bool) {
275
- var nowPlayingInfo = [String: Any]()
276
-
277
- // Set media title and artist
278
- nowPlayingInfo[MPMediaItemPropertyTitle] = recordingSettings?.notification?.title ?? "Recording in Progress"
279
- nowPlayingInfo[MPMediaItemPropertyArtist] = "Audio Stream"
280
-
281
- // Set playback state
282
- nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = isPaused ? 0.0 : 1.0
283
- nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentRecordingDuration()
284
-
285
- // Add placeholder image if available
286
- if let image = UIImage(named: "recording_icon") {
287
- nowPlayingInfo[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: image.size) { size in
288
- return image
289
- }
290
- }
291
-
292
- // Update the info on main thread
293
- DispatchQueue.main.async {
294
- self.notificationView?.nowPlayingInfo = nowPlayingInfo
295
- }
296
- }
297
-
298
- func currentRecordingDuration() -> TimeInterval {
299
- // If we're paused, return the last valid duration
300
- if isPaused, let lastDuration = lastValidDuration {
301
- return lastDuration
302
- }
303
-
304
- guard let settings = recordingSettings,
305
- let startTime = self.startTime else { return 0 }
306
-
307
- let now = Date()
308
- var duration = now.timeIntervalSince(startTime)
309
-
310
- // Subtract total paused duration
311
- duration -= TimeInterval(totalPausedDuration)
312
-
313
- // If we're currently in a pause (including background pause for !keepAwake), subtract that too
314
- if let pauseStart = currentPauseStart {
315
- duration -= now.timeIntervalSince(pauseStart)
316
- }
317
-
318
- return duration
319
- }
320
-
321
- private func cleanupNotificationObservers() {
322
- NotificationCenter.default.removeObserver(self)
323
- }
324
-
325
- @objc private func handlePauseNotification(_ notification: Notification) {
326
- // Only handle if recording and notifications are enabled
327
- guard isRecording, recordingSettings?.showNotification == true else { return }
328
- pauseRecording()
329
- }
330
-
331
- @objc private func handleResumeNotification(_ notification: Notification) {
332
- // Only handle if recording and notifications are enabled
333
- guard isRecording, recordingSettings?.showNotification == true else { return }
334
- resumeRecording()
335
- }
336
-
337
- @objc private func handlePauseAction() {
338
- pauseRecording()
339
- updateNotificationState(isPaused: true)
340
- }
341
-
342
- @objc private func handleResumeAction() {
343
- resumeRecording()
344
- updateNotificationState(isPaused: false)
345
- }
346
-
347
- @objc private func handleAppDidEnterBackground(_ notification: Notification) {
348
- if isRecording {
349
- // If keepAwake is false, we should track this as a pause
350
- if let settings = recordingSettings, !settings.keepAwake {
351
- currentPauseStart = Date()
352
- }
353
-
354
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
355
- self?.notificationManager?.showInitialNotification()
356
- }
357
- }
358
- }
359
-
360
- @objc private func handleAppWillEnterForeground(_ notification: Notification) {
361
- if isRecording {
362
- // If we were paused due to background and keepAwake was false, calculate pause duration
363
- if let settings = recordingSettings, !settings.keepAwake, let pauseStart = currentPauseStart {
364
- let pauseDuration = Date().timeIntervalSince(pauseStart)
365
- totalPausedDuration += pauseDuration
366
- currentPauseStart = nil
367
- Logger.debug("Added background pause duration: \(pauseDuration), total paused: \(totalPausedDuration)")
368
- }
369
-
370
- notificationManager?.stopUpdates()
371
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
372
- guard let self = self else { return }
373
- self.notificationManager?.startUpdates(startTime: self.startTime ?? Date())
374
- }
375
- }
376
- }
377
-
378
- private func updateNotificationState(isPaused: Bool) {
379
- // Calculate current duration
380
- let currentDuration: TimeInterval
381
- if let startTime = startTime {
382
- currentDuration = Date().timeIntervalSince(startTime) - TimeInterval(totalPausedDuration)
383
- } else {
384
- currentDuration = 0
385
- }
386
-
387
- // Update Now Playing info
388
- var nowPlayingInfo = notificationView?.nowPlayingInfo ?? [:]
389
- nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = isPaused ? 0.0 : 1.0
390
- nowPlayingInfo[MPMediaItemPropertyTitle] = isPaused ?
391
- "Recording Paused" :
392
- (recordingSettings?.notification?.title ?? "Recording in Progress")
393
- nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentDuration
394
- notificationView?.nowPlayingInfo = nowPlayingInfo
395
-
396
- // Delegate notification update to AudioNotificationManager
397
- notificationManager?.updateState(isPaused: isPaused)
398
- }
399
-
400
- private func updateMediaInfo() {
401
- guard let startTime = startTime else { return }
402
-
403
- let currentDuration = Date().timeIntervalSince(startTime) - TimeInterval(totalPausedDuration)
404
-
405
- var nowPlayingInfo = notificationView?.nowPlayingInfo ?? [:]
406
- nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentDuration
407
- nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = isPaused ? 0.0 : 1.0
408
- notificationView?.nowPlayingInfo = nowPlayingInfo
409
- }
410
-
411
- /// Enables the wake lock to prevent screen dimming
412
- private func enableWakeLock() {
413
- guard let settings = recordingSettings,
414
- settings.keepAwake, // Only proceed if keepAwake is true
415
- !isWakeLockEnabled // Only proceed if wake lock isn't already enabled
416
- else { return }
417
-
418
- DispatchQueue.main.async {
419
- self.wasIdleTimerDisabled = UIApplication.shared.isIdleTimerDisabled
420
- UIApplication.shared.isIdleTimerDisabled = true
421
- self.isWakeLockEnabled = true
422
- Logger.debug("Wake lock enabled")
423
- }
424
- }
425
-
426
- /// Disables the wake lock and restores previous screen dimming state
427
- private func disableWakeLock() {
428
- guard let settings = recordingSettings,
429
- settings.keepAwake, // Only proceed if keepAwake is true
430
- isWakeLockEnabled // Only proceed if wake lock is currently enabled
431
- else { return }
432
-
433
- DispatchQueue.main.async {
434
- UIApplication.shared.isIdleTimerDisabled = self.wasIdleTimerDisabled
435
- self.isWakeLockEnabled = false
436
- Logger.debug("Wake lock disabled")
437
- }
438
- }
439
-
440
- /// Creates a new recording file.
441
- /// - Returns: The URL of the newly created recording file, or nil if creation failed.
442
- private func createRecordingFile(isCompressed: Bool = false) -> URL? {
443
- // Add debug logging
444
- Logger.debug("Creating recording file - settings filename: \(recordingSettings?.filename ?? "nil")")
445
-
446
- // Get base directory - use default if no custom directory provided
447
- let baseDirectory: URL
448
- if let customDir = recordingSettings?.outputDirectory {
449
- baseDirectory = URL(fileURLWithPath: customDir)
450
- Logger.debug("Using custom directory: \(customDir)")
451
- } else {
452
- // Use existing default behavior
453
- baseDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
454
- Logger.debug("Using default directory: \(baseDirectory.path)")
455
- }
456
-
457
- // Generate or reuse UUID for filename
458
- let baseFilename: String
459
- if let existingFilename = recordingSettings?.filename {
460
- baseFilename = existingFilename
461
- } else if let existingUUID = recordingUUID {
462
- baseFilename = existingUUID.uuidString
463
- } else {
464
- let newUUID = UUID()
465
- recordingUUID = newUUID
466
- baseFilename = newUUID.uuidString
467
- }
468
- Logger.debug("Using base filename: \(baseFilename)")
469
-
470
- // Remove any existing extension from the filename
471
- let filenameWithoutExtension = baseFilename.replacingOccurrences(
472
- of: "\\.[^\\.]+$",
473
- with: "",
474
- options: .regularExpression
475
- )
476
-
477
- // Choose extension based on whether this is a compressed file
478
- let fileExtension: String
479
- if isCompressed {
480
- fileExtension = recordingSettings?.compressedFormat.lowercased() ?? "aac"
481
- } else {
482
- fileExtension = "wav"
483
- }
484
-
485
- let fullFilename = "\(filenameWithoutExtension).\(fileExtension)"
486
- Logger.debug("Full filename: \(fullFilename)")
487
-
488
- let fileURL = baseDirectory.appendingPathComponent(fullFilename)
489
- Logger.debug("Final file URL: \(fileURL.path)")
490
-
491
- // Check if file already exists
492
- if fileManager.fileExists(atPath: fileURL.path) {
493
- Logger.debug("File already exists at: \(fileURL.path)")
494
- return nil
495
- }
496
-
497
- if !fileManager.createFile(atPath: fileURL.path, contents: nil, attributes: nil) {
498
- Logger.debug("Failed to create file at: \(fileURL.path)")
499
- return nil
500
- }
501
- return fileURL
502
- }
503
-
504
- /// Creates a WAV header for the given data size.
505
- /// - Parameter dataSize: The size of the audio data.
506
- /// - Returns: A Data object containing the WAV header.
507
- private func createWavHeader(dataSize: Int) -> Data {
508
- var header = Data()
509
-
510
- let sampleRate = UInt32(recordingSettings!.sampleRate)
511
- let channels = UInt32(recordingSettings!.numberOfChannels)
512
- let bitDepth = UInt32(recordingSettings!.bitDepth)
513
-
514
- let blockAlign = channels * (bitDepth / 8)
515
- let byteRate = sampleRate * blockAlign
516
-
517
- // "RIFF" chunk descriptor
518
- header.append(contentsOf: "RIFF".utf8)
519
- header.append(contentsOf: UInt32(36 + dataSize).littleEndianBytes)
520
- header.append(contentsOf: "WAVE".utf8)
521
-
522
- // "fmt " sub-chunk
523
- header.append(contentsOf: "fmt ".utf8)
524
- header.append(contentsOf: UInt32(16).littleEndianBytes) // PCM format requires 16 bytes for the fmt sub-chunk
525
- header.append(contentsOf: UInt16(1).littleEndianBytes) // Audio format 1 for PCM
526
- header.append(contentsOf: UInt16(channels).littleEndianBytes)
527
- header.append(contentsOf: sampleRate.littleEndianBytes)
528
- header.append(contentsOf: byteRate.littleEndianBytes) // byteRate
529
- header.append(contentsOf: UInt16(blockAlign).littleEndianBytes) // blockAlign
530
- header.append(contentsOf: UInt16(bitDepth).littleEndianBytes) // bits per sample
531
-
532
- // "data" sub-chunk
533
- header.append(contentsOf: "data".utf8)
534
- header.append(contentsOf: UInt32(dataSize).littleEndianBytes) // Sub-chunk data size
535
-
536
- return header
537
- }
538
-
539
- /// Gets the current status of the recording.
540
- /// - Returns: A dictionary containing the recording status information.
541
- func getStatus() -> [String: Any] {
542
- guard let settings = recordingSettings else {
543
- print("Recording settings are not available.")
544
- return [:]
545
- }
546
-
547
- let durationInSeconds = currentRecordingDuration()
548
- let durationInMilliseconds = Int(durationInSeconds * 1000)
549
-
550
- var status: [String: Any] = [
551
- "durationMs": durationInMilliseconds,
552
- "isRecording": isRecording,
553
- "isPaused": isPaused,
554
- "mimeType": mimeType,
555
- "size": totalDataSize,
556
- "interval": settings.interval,
557
- "intervalAnalysis": settings.intervalAnalysis
558
- ]
559
-
560
- // Add compression info if enabled
561
- if settings.enableCompressedOutput,
562
- let compressedURL = compressedFileURL,
563
- FileManager.default.fileExists(atPath: compressedURL.path) {
564
- do {
565
- let compressedAttributes = try FileManager.default.attributesOfItem(atPath: compressedURL.path)
566
- if let compressedSize = compressedAttributes[.size] as? Int64 {
567
- Logger.debug("Compressed file status - Size: \(compressedSize)")
568
- let compressionBundle: [String: Any] = [
569
- "fileUri": compressedURL.absoluteString,
570
- "mimeType": compressedFormat == "aac" ? "audio/aac" : "audio/opus",
571
- "size": compressedSize,
572
- "format": compressedFormat,
573
- "bitrate": compressedBitRate
574
- ]
575
- status["compression"] = compressionBundle
576
- }
577
- } catch {
578
- Logger.debug("Error getting compressed file attributes: \(error)")
579
- }
580
- }
581
-
582
- return status
583
- }
584
-
585
- /// Detects if a phone call is active without using CallKit.
586
- /// We avoid CallKit because its usage prevents apps from being available in China's App Store.
587
- /// This is a workaround that uses AVAudioSession to detect phone calls instead.
588
- private func isPhoneCallActive() -> Bool {
589
- let audioSession = AVAudioSession.sharedInstance()
590
- return audioSession.isOtherAudioPlaying &&
591
- audioSession.secondaryAudioShouldBeSilencedHint &&
592
- audioSession.currentRoute.outputs.contains { $0.portType == .builtInReceiver }
593
- }
594
-
595
- /// Starts a new audio recording with the specified settings.
596
- /// - Parameters:
597
- /// - settings: The recording settings to use.
598
- /// - Returns: A StartRecordingResult object if recording starts successfully, or nil otherwise.
599
- func startRecording(settings: RecordingSettings) -> StartRecordingResult? {
600
- // Check for active call using the new method
601
- if isPhoneCallActive() {
602
- Logger.debug("Cannot start recording during an active phone call")
603
- delegate?.audioStreamManager(self, didFailWithError: "Cannot start recording during an active phone call")
604
- return nil
605
- }
606
-
607
- // Store settings first before doing anything else
608
- recordingSettings = settings
609
-
610
- // Reset audio session before starting new recording
611
- do {
612
- let session = AVAudioSession.sharedInstance()
613
- try session.setActive(false, options: .notifyOthersOnDeactivation)
614
- Thread.sleep(forTimeInterval: 0.1) // Brief pause to ensure clean state
615
- try session.setActive(true, options: .notifyOthersOnDeactivation)
616
- } catch {
617
- Logger.debug("Failed to reset audio session: \(error)")
618
- delegate?.audioStreamManager(self, didFailWithError: "Failed to reset audio session: \(error.localizedDescription)")
619
- return nil
620
- }
621
-
622
- // Update auto-resume preference from settings
623
- autoResumeAfterInterruption = settings.autoResumeAfterInterruption
624
-
625
- guard !isRecording else {
626
- Logger.debug("Debug: Recording is already in progress.")
627
- return nil
628
- }
629
-
630
- guard !audioEngine.isRunning else {
631
- Logger.debug("Debug: Audio engine already running.")
632
- return nil
633
- }
634
-
635
- let session = AVAudioSession.sharedInstance()
636
- var newSettings = settings
637
-
638
- emissionInterval = max(100.0, Double(settings.interval ?? 1000)) / 1000.0
639
- emissionIntervalAnalysis = max(100.0, Double(settings.intervalAnalysis ?? 500)) / 1000.0
640
- lastEmissionTime = Date()
641
- lastEmissionTimeAnalysis = Date()
642
- accumulatedData.removeAll()
643
- accumulatedAnalysisData.removeAll()
644
- totalDataSize = 0
645
- totalDataSizeAnalysis = 0
646
- totalPausedDuration = 0
647
- lastEmittedSize = 0
648
- lastEmittedCompressedSizeAnalysis = 0
649
- isPaused = false
650
-
651
-
652
- // Create recording file first
653
- recordingFileURL = createRecordingFile()
654
- if recordingFileURL == nil {
655
- Logger.debug("Error: Failed to create recording file.")
656
- return nil
657
- }
658
-
659
- // Then set up audio session and tap
660
- do {
661
- Logger.debug("Configuring audio session with sample rate: \(settings.sampleRate) Hz")
662
-
663
- if let currentRoute = session.currentRoute.outputs.first {
664
- Logger.debug("Current audio output: \(currentRoute.portType)")
665
- newSettings.sampleRate = settings.sampleRate // Keep original sample rate
666
- }
667
-
668
- // Get base configuration from user settings or defaults
669
- var category: AVAudioSession.Category = .playAndRecord
670
- var mode: AVAudioSession.Mode = .default
671
- var options: AVAudioSession.CategoryOptions = [.allowBluetooth, .mixWithOthers]
672
-
673
- if let audioSessionConfig = settings.ios?.audioSession {
674
- category = audioSessionConfig.category
675
- mode = audioSessionConfig.mode
676
- options = audioSessionConfig.categoryOptions
677
- }
678
-
679
- // Append necessary options for background recording if keepAwake is enabled
680
- if settings.keepAwake {
681
- Logger.debug("keepAwake enabled - configuring for background recording")
682
- // Add background audio option
683
- options.insert(.mixWithOthers)
684
- try session.setActive(true, options: .notifyOthersOnDeactivation)
685
- } else {
686
- Logger.debug("keepAwake disabled - using standard session configuration")
687
- // If keepAwake is false, don't add background audio options
688
- try session.setActive(true)
689
- }
690
-
691
- // Apply the final configuration
692
- try session.setCategory(category, mode: mode, options: options)
693
- try session.setActive(true, options: .notifyOthersOnDeactivation)
694
-
695
- Logger.debug("""
696
- Audio session configured:
697
- - category: \(category)
698
- - mode: \(mode)
699
- - options: \(options)
700
- - keepAwake: \(settings.keepAwake)
701
- - emission interval: \(emissionInterval * 1000)ms
702
- - analysis interval: \(emissionIntervalAnalysis * 1000)ms
703
- - sample rate: \(settings.sampleRate)Hz
704
- - channels: \(settings.numberOfChannels)
705
- - bit depth: \(settings.bitDepth)-bit
706
- - compression enabled: \(settings.enableCompressedOutput)
707
- """)
708
-
709
- try session.setPreferredSampleRate(settings.sampleRate)
710
- try session.setPreferredIOBufferDuration(1024 / Double(settings.sampleRate))
711
- try session.setActive(true)
712
- Logger.debug("Audio session activated successfully.")
713
-
714
- let actualSampleRate = session.sampleRate
715
- if actualSampleRate != settings.sampleRate {
716
- Logger.debug("Hardware using sample rate \(actualSampleRate)Hz, will resample to \(settings.sampleRate)Hz")
717
- }
718
-
719
- recordingSettings = newSettings // Keep original settings with desired sample rate
720
- enableWakeLock()
721
-
722
- // Create format matching hardware capabilities
723
- guard let hardwareFormat = AVAudioFormat(
724
- commonFormat: .pcmFormatFloat32,
725
- sampleRate: actualSampleRate,
726
- channels: AVAudioChannelCount(settings.numberOfChannels),
727
- interleaved: true
728
- ) else {
729
- Logger.debug("Failed to create hardware format")
730
- return nil
731
- }
732
-
733
- Logger.debug("""
734
- Audio format configuration:
735
- - Hardware format: \(describeAudioFormat(hardwareFormat))
736
- - Target format: \(describeCommonFormat(hardwareFormat.commonFormat)) at \(actualSampleRate)Hz
737
- - Bit depth: \(settings.bitDepth)-bit
738
- - Channels: \(settings.numberOfChannels)
739
- """)
740
-
741
- audioEngine.inputNode.installTap(onBus: 0, bufferSize: 1024, format: hardwareFormat) { [weak self] (buffer, time) in
742
- guard let self = self,
743
- let fileURL = self.recordingFileURL else {
744
- Logger.debug("Error: File URL or self is nil during buffer processing.")
745
- return
746
- }
747
- self.processAudioBuffer(buffer, fileURL: fileURL)
748
- self.lastBufferTime = time
749
- }
750
-
751
- // Setup compressed recording if enabled
752
- if settings.enableCompressedOutput {
753
- do {
754
- let compressedSettings: [String: Any] = [
755
- AVFormatIDKey: settings.compressedFormat == "aac" ? kAudioFormatMPEG4AAC : kAudioFormatOpus,
756
- AVSampleRateKey: Float64(settings.sampleRate),
757
- AVNumberOfChannelsKey: settings.numberOfChannels,
758
- AVEncoderBitRateKey: settings.compressedBitRate,
759
- AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue,
760
- AVEncoderBitDepthHintKey: settings.bitDepth
761
- ]
762
-
763
- Logger.debug("Initializing compressed recording with settings: \(compressedSettings)")
764
-
765
- compressedFileURL = createRecordingFile(isCompressed: true)
766
-
767
- if let url = compressedFileURL {
768
- Logger.debug("Using compressed file URL: \(url.path)")
769
-
770
- // Initialize recorder with proper error handling
771
- do {
772
- compressedRecorder = try AVAudioRecorder(url: url, settings: compressedSettings)
773
- if let recorder = compressedRecorder {
774
- recorder.delegate = self
775
-
776
- if !recorder.prepareToRecord() {
777
- Logger.debug("Failed to prepare recorder")
778
- throw NSError(domain: "AudioStreamManager", code: -1,
779
- userInfo: [NSLocalizedDescriptionKey: "Failed to prepare recorder"])
780
- }
781
-
782
- if !recorder.record() {
783
- Logger.debug("Failed to start recorder")
784
- throw NSError(domain: "AudioStreamManager", code: -2,
785
- userInfo: [NSLocalizedDescriptionKey: "Failed to start recorder"])
786
- }
787
-
788
- Logger.debug("Compressed recording started successfully")
789
- compressedFormat = settings.compressedFormat
790
- compressedBitRate = settings.compressedBitRate
791
- }
792
- } catch {
793
- Logger.debug("Failed to initialize compressed recorder: \(error)")
794
- compressedFileURL = nil
795
- compressedRecorder = nil
796
- }
797
- }
798
- } catch {
799
- Logger.debug("Failed to setup compressed recording: \(error)")
800
- compressedFileURL = nil
801
- compressedRecorder = nil
802
- }
803
- }
804
-
805
- } catch {
806
- Logger.debug("Error: Failed to set up audio session with preferred settings: \(error.localizedDescription)")
807
- return nil
808
- }
809
-
810
- NotificationCenter.default.addObserver(self, selector: #selector(handleAudioSessionInterruption), name: AVAudioSession.interruptionNotification, object: nil)
811
-
812
- // Create audio format based on recording settings
813
- let commonFormat: AVAudioCommonFormat
814
- switch newSettings.bitDepth {
815
- case 16:
816
- commonFormat = .pcmFormatInt16
817
- case 32:
818
- commonFormat = .pcmFormatFloat32
819
- default:
820
- Logger.debug("Unsupported bit depth: \(newSettings.bitDepth), falling back to 16-bit")
821
- commonFormat = .pcmFormatInt16
822
- }
823
-
824
- guard let audioFormat = AVAudioFormat(
825
- commonFormat: commonFormat,
826
- sampleRate: newSettings.sampleRate,
827
- channels: UInt32(newSettings.numberOfChannels),
828
- interleaved: true
829
- ) else {
830
- Logger.debug("Error: Failed to create audio format with bit depth: \(newSettings.bitDepth)")
831
- return nil
832
- }
833
-
834
- if newSettings.enableProcessing == true {
835
- // Initialize the AudioProcessor for buffer-based processing
836
- self.audioProcessor = AudioProcessor(resolve: { result in
837
- // Handle the result here if needed
838
- }, reject: { code, message in
839
- // Handle the rejection here if needed
840
- })
841
- Logger.debug("AudioProcessor activated successfully.")
842
- }
843
-
844
- if settings.showNotification {
845
- initializeNotifications()
846
- }
847
-
848
- do {
849
- startTime = Date()
850
- totalPausedDuration = 0 // Reset pause tracking
851
- currentPauseStart = nil
852
- Logger.debug("Starting new recording - Reset pause tracking")
853
-
854
- try audioEngine.start()
855
- isRecording = true
856
- isPaused = false
857
- Logger.debug("Debug: Recording started successfully.")
858
-
859
- var compression = compressedRecorder != nil ? CompressedRecordingInfo(
860
- compressedFileUri: compressedFileURL?.absoluteString ?? "",
861
- mimeType: compressedFormat == "aac" ? "audio/aac" : "audio/opus",
862
- bitrate: compressedBitRate,
863
- format: compressedFormat
864
- ) : nil
865
-
866
- // Get the size separately since it's not part of the initializer
867
- if let compressedPath = compressedFileURL?.path,
868
- let attributes = try? FileManager.default.attributesOfItem(atPath: compressedPath),
869
- let fileSize = attributes[.size] as? Int64 {
870
- compression?.size = fileSize
871
- }
872
-
873
- return StartRecordingResult(
874
- fileUri: recordingFileURL!.path,
875
- mimeType: mimeType,
876
- channels: settings.numberOfChannels,
877
- bitDepth: settings.bitDepth,
878
- sampleRate: settings.sampleRate,
879
- compression: compression
880
- )
881
-
882
- } catch {
883
- Logger.debug("Error: Could not start the audio engine: \(error.localizedDescription)")
884
- isRecording = false
885
- return nil
886
- }
887
- }
888
-
889
- /// Pauses the current audio recording.
890
- public func pauseRecording() {
891
- guard isRecording && !isPaused else { return }
892
-
893
- // Store the current duration when pausing
894
- lastValidDuration = currentRecordingDuration()
895
- Logger.debug("Storing duration at pause: \(lastValidDuration ?? 0)")
896
-
897
- disableWakeLock()
898
- audioEngine.pause()
899
- isPaused = true
900
-
901
- updateNowPlayingInfo(isPaused: true)
902
- notificationManager?.updateState(isPaused: true)
903
- delegate?.audioStreamManager(self, didPauseRecording: Date())
904
- delegate?.audioStreamManager(self, didUpdateNotificationState: true)
905
-
906
- // Pause compressed recording if active
907
- compressedRecorder?.pause()
908
- }
909
-
910
- private func initializeNotifications() {
911
- guard recordingSettings?.showNotification == true else { return }
912
-
913
- // Setup notification manager if not already initialized
914
- if notificationManager == nil {
915
- UNUserNotificationCenter.current().delegate = self
916
-
917
- notificationManager = AudioNotificationManager()
918
-
919
- // Request permissions first
920
- UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
921
- if granted {
922
- DispatchQueue.main.async {
923
- self.notificationManager?.initialize(with: self.recordingSettings?.notification)
924
- self.setupNowPlayingInfo()
925
-
926
- // Start media info update timer
927
- self.mediaInfoUpdateTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
928
- self?.updateMediaInfo()
929
- }
930
-
931
- // Setup notification observers
932
- NotificationCenter.default.addObserver(
933
- self,
934
- selector: #selector(self.handlePauseNotification),
935
- name: .pauseRecording,
936
- object: nil
937
- )
938
-
939
- NotificationCenter.default.addObserver(
940
- self,
941
- selector: #selector(self.handleResumeNotification),
942
- name: .resumeRecording,
943
- object: nil
944
- )
945
-
946
- // Start updates if recording is already in progress
947
- if let startTime = self.startTime {
948
- self.notificationManager?.startUpdates(startTime: startTime)
949
- }
950
- }
951
- } else if let error = error {
952
- Logger.debug("Failed to get notification permission: \(error.localizedDescription)")
953
- }
954
- }
955
- }
956
- }
957
-
958
- /// Resumes the current audio recording.
959
- public func resumeRecording() {
960
- // Check for active phone call
961
- if isPhoneCallActive() {
962
- Logger.debug("Cannot resume recording during an active phone call")
963
- delegate?.audioStreamManager(self, didFailWithError: "Cannot resume recording during an active phone call")
964
- return
965
- }
966
-
967
- guard isRecording && isPaused else { return }
968
-
969
- lastValidDuration = nil // Clear the stored duration when resuming
970
-
971
- enableWakeLock()
972
- audioEngine.prepare()
973
- do {
974
- try audioEngine.start()
975
-
976
- // Add the completed pause duration to total
977
- if let pauseStart = currentPauseStart {
978
- let currentPauseDuration = Date().timeIntervalSince(pauseStart)
979
- totalPausedDuration += currentPauseDuration
980
- currentPauseStart = nil
981
-
982
- Logger.debug("""
983
- Resume completed:
984
- - Added pause duration: \(currentPauseDuration)
985
- - New total pause duration: \(totalPausedDuration)
986
- """)
987
- }
988
-
989
- isPaused = false
990
-
991
- updateNowPlayingInfo(isPaused: false)
992
- notificationManager?.updateState(isPaused: false)
993
- delegate?.audioStreamManager(self, didResumeRecording: Date())
994
- delegate?.audioStreamManager(self, didUpdateNotificationState: false)
995
-
996
- // Resume compressed recording if active
997
- compressedRecorder?.record()
998
-
999
- } catch {
1000
- Logger.debug("Error: Failed to resume recording: \(error.localizedDescription)")
1001
- }
1002
- }
1003
-
1004
- /// Describes the format of the given audio format.
1005
- /// - Parameter format: The AVAudioFormat object to describe.
1006
- /// - Returns: A string description of the audio format.
1007
- func describeAudioFormat(_ format: AVAudioFormat) -> String {
1008
- let formatDescription = """
1009
- - Sample rate: \(format.sampleRate)Hz
1010
- - Channels: \(format.channelCount)
1011
- - Interleaved: \(format.isInterleaved)
1012
- - Common format: \(describeCommonFormat(format.commonFormat))
1013
- """
1014
- return formatDescription
1015
- }
1016
-
1017
- func describeCommonFormat(_ format: AVAudioCommonFormat) -> String {
1018
- switch format {
1019
- case .pcmFormatFloat32:
1020
- return "32-bit float"
1021
- case .pcmFormatFloat64:
1022
- return "64-bit float"
1023
- case .pcmFormatInt16:
1024
- return "16-bit int"
1025
- case .pcmFormatInt32:
1026
- return "32-bit int"
1027
- default:
1028
- return "Unknown format"
1029
- }
1030
- }
1031
-
1032
- /// Stops the current audio recording.
1033
- /// - Returns: A RecordingResult object if the recording stopped successfully, or nil otherwise.
1034
- func stopRecording() -> RecordingResult? {
1035
- guard isRecording else { return nil }
1036
-
1037
- Logger.debug("Stopping recording...")
1038
-
1039
- disableWakeLock()
1040
- audioEngine.stop()
1041
- audioEngine.inputNode.removeTap(onBus: 0)
1042
-
1043
- // Stop compressed recording if active
1044
- compressedRecorder?.stop()
1045
-
1046
- // Get the final duration before changing state
1047
- let finalDuration = currentRecordingDuration()
1048
-
1049
- isRecording = false
1050
- isPaused = false
1051
-
1052
- if recordingSettings?.showNotification == true {
1053
- // Stop and clean up timer
1054
- mediaInfoUpdateTimer?.invalidate()
1055
- mediaInfoUpdateTimer = nil
1056
-
1057
- // Clean up notification manager
1058
- notificationManager?.stopUpdates()
1059
- notificationManager = nil
1060
-
1061
- // Clean up media controls
1062
- DispatchQueue.main.async {
1063
- UIApplication.shared.endReceivingRemoteControlEvents()
1064
- self.remoteCommandCenter?.pauseCommand.isEnabled = false
1065
- self.remoteCommandCenter?.playCommand.isEnabled = false
1066
- self.notificationView?.nowPlayingInfo = nil
1067
- }
1068
- }
1069
-
1070
- // Reset audio session
1071
- do {
1072
- try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
1073
- } catch {
1074
- Logger.debug("Error deactivating audio session: \(error)")
1075
- }
1076
-
1077
- // Reset audio engine
1078
- audioEngine.reset()
1079
-
1080
- guard let fileURL = recordingFileURL,
1081
- let settings = recordingSettings else {
1082
- Logger.debug("Recording or file URL is nil.")
1083
- return nil
1084
- }
1085
-
1086
- // Validate WAV file
1087
- let wavPath = fileURL.path
1088
- do {
1089
- // Check if WAV file exists
1090
- let wavFileAttributes = try FileManager.default.attributesOfItem(atPath: wavPath)
1091
- let wavFileSize = wavFileAttributes[FileAttributeKey.size] as? Int64 ?? 0
1092
-
1093
- Logger.debug("""
1094
- WAV File validation:
1095
- - Path: \(wavPath)
1096
- - Exists: true
1097
- - Size: \(wavFileSize) bytes
1098
- - Duration: \(finalDuration) seconds
1099
- - Expected minimum size: 44 bytes (WAV header)
1100
- """)
1101
-
1102
- // Return nil if the file is too small
1103
- if wavFileSize <= 44 {
1104
- Logger.debug("Recording file is too small (≤ 44 bytes), likely no audio data was recorded")
1105
- return nil
1106
- }
1107
-
1108
- // Update the WAV header with the correct file size
1109
- updateWavHeader(fileURL: fileURL, totalDataSize: wavFileSize - 44)
1110
-
1111
- // Validate compressed file if enabled
1112
- var compression: CompressedRecordingInfo?
1113
- if let compressedURL = compressedFileURL {
1114
- let compressedPath = compressedURL.path
1115
- if FileManager.default.fileExists(atPath: compressedPath) {
1116
- let compressedAttributes = try FileManager.default.attributesOfItem(atPath: compressedPath)
1117
- let compressedSize = compressedAttributes[FileAttributeKey.size] as? Int64 ?? 0
1118
-
1119
- Logger.debug("""
1120
- Compressed File validation:
1121
- - Path: \(compressedPath)
1122
- - Format: \(compressedFormat ?? "unknown")
1123
- - Size: \(compressedSize) bytes
1124
- - Bitrate: \(compressedBitRate ?? 0) bps
1125
- """)
1126
-
1127
- if compressedSize > 0 {
1128
- compression = CompressedRecordingInfo(
1129
- compressedFileUri: compressedURL.absoluteString,
1130
- mimeType: compressedFormat == "aac" ? "audio/aac" : "audio/opus",
1131
- bitrate: compressedBitRate,
1132
- format: compressedFormat,
1133
- size: compressedSize
1134
- )
1135
- } else {
1136
- Logger.debug("Warning: Compressed file exists but is empty")
1137
- }
1138
- } else {
1139
- Logger.debug("Warning: Compressed file not found at path: \(compressedPath)")
1140
- }
1141
- }
1142
-
1143
- let durationMs = Int64(finalDuration * 1000)
1144
-
1145
- let result = RecordingResult(
1146
- fileUri: fileURL.absoluteString,
1147
- filename: fileURL.lastPathComponent,
1148
- mimeType: mimeType,
1149
- duration: durationMs,
1150
- size: wavFileSize,
1151
- channels: settings.numberOfChannels,
1152
- bitDepth: settings.bitDepth,
1153
- sampleRate: settings.sampleRate,
1154
- compression: compression
1155
- )
1156
-
1157
- Logger.debug("""
1158
- Recording completed successfully:
1159
- - WAV file: \(fileURL.lastPathComponent)
1160
- - Size: \(wavFileSize) bytes
1161
- - Duration: \(durationMs)ms
1162
- - Sample rate: \(settings.sampleRate)Hz
1163
- - Bit depth: \(settings.bitDepth)-bit
1164
- - Channels: \(settings.numberOfChannels)
1165
- - Compressed: \(compression != nil ? "yes" : "no")
1166
- """)
1167
-
1168
- // Additional cleanup
1169
- recordingFileURL = nil
1170
- lastBufferTime = nil
1171
- lastValidDuration = nil
1172
- compressedRecorder = nil
1173
- compressedFileURL = nil
1174
- recordingSettings = nil
1175
- startTime = nil
1176
- totalPausedDuration = 0
1177
- currentPauseStart = nil
1178
- lastEmissionTime = nil
1179
- lastEmissionTimeAnalysis = nil
1180
- lastEmittedSize = 0
1181
- lastEmittedSizeAnalysis = 0
1182
- lastEmittedCompressedSize = 0
1183
- accumulatedData.removeAll()
1184
- accumulatedAnalysisData.removeAll()
1185
- recordingUUID = nil
1186
-
1187
- return result
1188
-
1189
- } catch {
1190
- Logger.debug("Failed to validate recording files: \(error)")
1191
- return nil
1192
- }
1193
- }
1194
-
1195
- /// Resamples the audio buffer using vDSP. If it fails, falls back to manual resampling.
1196
- /// - Parameters:
1197
- /// - buffer: The original audio buffer to be resampled.
1198
- /// - originalSampleRate: The sample rate of the original audio buffer.
1199
- /// - targetSampleRate: The desired sample rate to resample to.
1200
- /// - Returns: A new audio buffer resampled to the target sample rate, or nil if resampling fails.
1201
- private func resampleAudioBuffer(_ buffer: AVAudioPCMBuffer, from originalSampleRate: Double, to targetSampleRate: Double) -> AVAudioPCMBuffer? {
1202
- guard let settings = recordingSettings else {
1203
- Logger.debug("Recording settings not available")
1204
- return nil
1205
- }
1206
-
1207
- Logger.debug("""
1208
- Starting resampling:
1209
- - Original format: \(describeAudioFormat(buffer.format))
1210
- - Original frames: \(buffer.frameLength)
1211
- - Target settings:
1212
- • Sample rate: \(targetSampleRate)Hz
1213
- • Bit depth: \(settings.bitDepth)
1214
- • Channels: \(settings.numberOfChannels)
1215
- """)
1216
-
1217
- // Use settings bit depth for output format
1218
- let targetFormat: AVAudioCommonFormat = settings.bitDepth == 32 ? .pcmFormatFloat32 : .pcmFormatInt16
1219
-
1220
- // Create output format matching recording settings exactly
1221
- guard let outputFormat = AVAudioFormat(
1222
- commonFormat: targetFormat,
1223
- sampleRate: targetSampleRate,
1224
- channels: AVAudioChannelCount(settings.numberOfChannels),
1225
- interleaved: true
1226
- ) else {
1227
- Logger.debug("Failed to create output format")
1228
- return nil
1229
- }
1230
-
1231
- // Calculate new buffer size
1232
- let ratio = targetSampleRate / originalSampleRate
1233
- let newFrameCount = AVAudioFrameCount(Double(buffer.frameLength) * ratio)
1234
-
1235
- // Create output buffer
1236
- guard let outputBuffer = AVAudioPCMBuffer(
1237
- pcmFormat: outputFormat,
1238
- frameCapacity: newFrameCount
1239
- ) else {
1240
- Logger.debug("Failed to create output buffer")
1241
- return nil
1242
- }
1243
- outputBuffer.frameLength = newFrameCount
1244
-
1245
- // Create intermediate format for high-quality conversion if needed
1246
- let needsIntermediate = buffer.format.commonFormat != outputFormat.commonFormat
1247
- if needsIntermediate {
1248
- Logger.debug("Using intermediate Float32 format for high-quality conversion")
1249
- guard let intermediateFormat = AVAudioFormat(
1250
- commonFormat: .pcmFormatFloat32,
1251
- sampleRate: targetSampleRate,
1252
- channels: AVAudioChannelCount(settings.numberOfChannels),
1253
- interleaved: true
1254
- ) else {
1255
- Logger.debug("Failed to create intermediate format")
1256
- return nil
1257
- }
1258
-
1259
- // First convert to intermediate float format
1260
- guard let converter = AVAudioConverter(from: buffer.format, to: intermediateFormat),
1261
- let intermediateBuffer = AVAudioPCMBuffer(
1262
- pcmFormat: intermediateFormat,
1263
- frameCapacity: newFrameCount
1264
- ) else {
1265
- Logger.debug("Failed to create converter or intermediate buffer")
1266
- return nil
1267
- }
1268
- intermediateBuffer.frameLength = newFrameCount
1269
-
1270
- var error: NSError?
1271
- let inputBlock: AVAudioConverterInputBlock = { inNumPackets, outStatus in
1272
- outStatus.pointee = AVAudioConverterInputStatus.haveData
1273
- return buffer
1274
- }
1275
-
1276
- converter.convert(to: intermediateBuffer, error: &error, withInputFrom: inputBlock)
1277
-
1278
- if let error = error {
1279
- Logger.debug("Intermediate conversion failed: \(error.localizedDescription)")
1280
- return nil
1281
- }
1282
-
1283
- // Then convert to final format
1284
- guard let finalConverter = AVAudioConverter(from: intermediateFormat, to: outputFormat) else {
1285
- Logger.debug("Failed to create final converter")
1286
- return nil
1287
- }
1288
-
1289
- finalConverter.convert(to: outputBuffer, error: &error) { inNumPackets, outStatus in
1290
- outStatus.pointee = AVAudioConverterInputStatus.haveData
1291
- return intermediateBuffer
1292
- }
1293
-
1294
- if let error = error {
1295
- Logger.debug("Final conversion failed: \(error.localizedDescription)")
1296
- return nil
1297
- }
1298
- } else {
1299
- // Direct conversion if formats are compatible
1300
- guard let converter = AVAudioConverter(from: buffer.format, to: outputFormat) else {
1301
- Logger.debug("Failed to create converter")
1302
- return nil
1303
- }
1304
-
1305
- var error: NSError?
1306
- let inputBlock: AVAudioConverterInputBlock = { inNumPackets, outStatus in
1307
- outStatus.pointee = AVAudioConverterInputStatus.haveData
1308
- return buffer
1309
- }
1310
-
1311
- converter.convert(to: outputBuffer, error: &error, withInputFrom: inputBlock)
1312
-
1313
- if let error = error {
1314
- Logger.debug("Conversion failed: \(error.localizedDescription)")
1315
- return nil
1316
- }
1317
- }
1318
-
1319
- Logger.debug("""
1320
- Resampling completed:
1321
- - Final format: \(describeAudioFormat(outputBuffer.format))
1322
- - Final frames: \(outputBuffer.frameLength)
1323
- - Conversion path: \(needsIntermediate ? "With intermediate Float32" : "Direct")
1324
- """)
1325
-
1326
- return outputBuffer
1327
- }
1328
-
1329
- /// Manually resamples the audio buffer using linear interpolation.
1330
- /// - Parameters:
1331
- /// - buffer: The original audio buffer to be resampled.
1332
- /// - originalSampleRate: The sample rate of the original audio buffer.
1333
- /// - targetSampleRate: The desired sample rate to resample to.
1334
- /// - Returns: A new audio buffer resampled to the target sample rate, or nil if resampling fails.
1335
- private func manualResampleAudioBuffer(_ buffer: AVAudioPCMBuffer, from originalSampleRate: Double, to targetSampleRate: Double) -> AVAudioPCMBuffer? {
1336
- guard let channelData = buffer.floatChannelData else { return nil }
1337
-
1338
- let sourceFrameCount = Int(buffer.frameLength)
1339
- let sourceChannels = Int(buffer.format.channelCount)
1340
- let targetFrameCount = Int(Double(sourceFrameCount) * targetSampleRate / originalSampleRate)
1341
-
1342
- guard let targetBuffer = AVAudioPCMBuffer(pcmFormat: buffer.format, frameCapacity: AVAudioFrameCount(targetFrameCount)) else { return nil }
1343
- targetBuffer.frameLength = AVAudioFrameCount(targetFrameCount)
1344
-
1345
- let resamplingFactor = Float(targetSampleRate / originalSampleRate)
1346
-
1347
- for channel in 0..<sourceChannels {
1348
- let input = UnsafeBufferPointer(start: channelData[channel], count: sourceFrameCount)
1349
- let output = UnsafeMutableBufferPointer(start: targetBuffer.floatChannelData![channel], count: targetFrameCount)
1350
-
1351
- var y = Array(repeating: Float(0), count: targetFrameCount)
1352
- for i in 0..<targetFrameCount {
1353
- let index = Float(i) / resamplingFactor
1354
- let low = Int(floor(index))
1355
- let high = min(low + 1, sourceFrameCount - 1)
1356
- let weight = index - Float(low)
1357
- y[i] = (1 - weight) * input[low] + weight * input[high]
1358
- }
1359
-
1360
- for i in 0..<targetFrameCount {
1361
- output[i] = y[i]
1362
- }
1363
- }
1364
-
1365
- return targetBuffer
1366
- }
1367
-
1368
-
1369
-
1370
- /// Updates the WAV header with the correct file size.
1371
- /// - Parameters:
1372
- /// - fileURL: The URL of the WAV file.
1373
- /// - totalDataSize: The total size of the audio data.
1374
- private func updateWavHeader(fileURL: URL, totalDataSize: Int64) {
1375
- // Prevent negative values - minimum WAV file size should be at least the header size (44 bytes)
1376
- guard totalDataSize >= 0 else {
1377
- Logger.debug("Invalid file size: total data size is negative")
1378
- return
1379
- }
1380
-
1381
- do {
1382
- let fileHandle = try FileHandle(forUpdating: fileURL)
1383
- defer { fileHandle.closeFile() }
1384
-
1385
- // Calculate sizes
1386
- let fileSize = totalDataSize + 44 - 8 // Total file size minus 8 bytes for 'RIFF' and size field itself
1387
- let dataSize = totalDataSize // Size of the 'data' sub-chunk
1388
-
1389
- // Update RIFF chunk size at offset 4
1390
- fileHandle.seek(toFileOffset: 4)
1391
- let fileSizeBytes = UInt32(fileSize).littleEndianBytes
1392
- fileHandle.write(Data(fileSizeBytes))
1393
-
1394
- // Update data chunk size at offset 40
1395
- fileHandle.seek(toFileOffset: 40)
1396
- let dataSizeBytes = UInt32(dataSize).littleEndianBytes
1397
- fileHandle.write(Data(dataSizeBytes))
1398
-
1399
- } catch let error {
1400
- Logger.debug("Error updating WAV header: \(error)")
1401
- }
1402
- }
1403
-
1404
- private func updateNotificationDuration() {
1405
- guard let startTime = startTime,
1406
- recordingSettings?.showNotification == true else { return }
1407
-
1408
- let currentDuration = Date().timeIntervalSince(startTime) - TimeInterval(totalPausedDuration)
1409
-
1410
- // Update both notification manager and media player
1411
- notificationManager?.updateDuration(currentDuration)
1412
-
1413
- if let notificationView = notificationView {
1414
- var nowPlayingInfo = notificationView.nowPlayingInfo ?? [:]
1415
- nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentDuration
1416
- notificationView.nowPlayingInfo = nowPlayingInfo
1417
- }
1418
- }
1419
-
1420
- /// Processes the audio buffer and writes data to the file. Also handles audio processing if enabled.
1421
- /// - Parameters:
1422
- /// - buffer: The audio buffer to process.
1423
- /// - fileURL: The URL of the file to write the data to.
1424
- private func processAudioBuffer(_ buffer: AVAudioPCMBuffer, fileURL: URL) {
1425
- guard let settings = recordingSettings else {
1426
- Logger.debug("Recording settings not available")
1427
- return
1428
- }
1429
-
1430
- guard let fileHandle = try? FileHandle(forWritingTo: fileURL) else {
1431
- Logger.debug("Failed to open file handle for URL: \(fileURL)")
1432
- return
1433
- }
1434
- defer {
1435
- fileHandle.closeFile() // Ensure file is always closed
1436
- }
1437
-
1438
- let targetSampleRate = Double(settings.sampleRate)
1439
- let targetFormat: AVAudioCommonFormat = settings.bitDepth == 32 ? .pcmFormatFloat32 : .pcmFormatInt16
1440
-
1441
- // First handle resampling if needed
1442
- let resampledBuffer: AVAudioPCMBuffer
1443
- if buffer.format.sampleRate != targetSampleRate {
1444
- if let resampled = resampleAudioBuffer(buffer, from: buffer.format.sampleRate, to: targetSampleRate) {
1445
- resampledBuffer = resampled
1446
- } else {
1447
- Logger.debug("Resampling failed")
1448
- return
1449
- }
1450
- } else {
1451
- resampledBuffer = buffer
1452
- }
1453
-
1454
- // Then ensure format matches user settings
1455
- let finalBuffer: AVAudioPCMBuffer
1456
- if resampledBuffer.format.commonFormat != targetFormat {
1457
- guard let converted = convertBufferFormat(resampledBuffer, to: AVAudioFormat(
1458
- commonFormat: targetFormat,
1459
- sampleRate: targetSampleRate,
1460
- channels: AVAudioChannelCount(settings.numberOfChannels),
1461
- interleaved: true
1462
- )!) else {
1463
- Logger.debug("Format conversion failed")
1464
- return
1465
- }
1466
- finalBuffer = converted
1467
- } else {
1468
- finalBuffer = resampledBuffer
1469
- }
1470
-
1471
- let audioData = finalBuffer.audioBufferList.pointee.mBuffers
1472
- guard let bufferData = audioData.mData else {
1473
- Logger.debug("Buffer data is nil.")
1474
- return
1475
- }
1476
-
1477
- var data = Data(bytes: bufferData, count: Int(audioData.mDataByteSize))
1478
-
1479
- // Check if this is the first buffer to process
1480
- if totalDataSize == 0 {
1481
- let header = createWavHeader(dataSize: 0)
1482
- data.insert(contentsOf: header, at: 0)
1483
- }
1484
-
1485
- // Write to file
1486
- fileHandle.seekToEndOfFile()
1487
- fileHandle.write(data)
1488
-
1489
- // Update total size and accumulated data
1490
- totalDataSize += Int64(data.count)
1491
- accumulatedData.append(data)
1492
- accumulatedAnalysisData.append(data)
1493
-
1494
- // Handle notifications if enabled
1495
- if recordingSettings?.showNotification == true {
1496
- updateNotificationDuration()
1497
- }
1498
-
1499
- // Emit data based on interval
1500
- let currentTime = Date()
1501
- if let lastEmissionTime = lastEmissionTime,
1502
- let startTime = startTime,
1503
- currentTime.timeIntervalSince(lastEmissionTime) >= emissionInterval {
1504
-
1505
- let recordingTime = currentTime.timeIntervalSince(startTime)
1506
- let dataToProcess = accumulatedData
1507
-
1508
- // Prepare compression info if enabled
1509
- var compressionInfo: [String: Any]? = nil
1510
- if settings.enableCompressedOutput, let compressedURL = compressedFileURL {
1511
- do {
1512
- // Ensure file exists and has data
1513
- if FileManager.default.fileExists(atPath: compressedURL.path) {
1514
- let compressedAttributes = try FileManager.default.attributesOfItem(atPath: compressedURL.path)
1515
- if let compressedSize = compressedAttributes[.size] as? Int64 {
1516
- let eventDataSize = compressedSize - lastEmittedCompressedSize
1517
-
1518
- Logger.debug("Compressed file status - Total size: \(compressedSize), New data size: \(eventDataSize)")
1519
-
1520
- // Read the new compressed data if there's new data
1521
- var compressedData: String? = nil
1522
- if eventDataSize > 0 {
1523
- do {
1524
- let fileHandle = try FileHandle(forReadingFrom: compressedURL)
1525
- defer { fileHandle.closeFile() }
1526
-
1527
- fileHandle.seek(toFileOffset: UInt64(lastEmittedCompressedSize))
1528
- let data = fileHandle.readData(ofLength: Int(eventDataSize))
1529
- compressedData = data.base64EncodedString()
1530
-
1531
- Logger.debug("Read compressed data of size: \(data.count)")
1532
- } catch {
1533
- Logger.debug("Error reading compressed data: \(error)")
1534
- }
1535
- }
1536
-
1537
- lastEmittedCompressedSize = compressedSize
1538
-
1539
- compressionInfo = [
1540
- "position": recordingTime * 1000, // Convert to milliseconds
1541
- "fileUri": compressedURL.absoluteString,
1542
- "eventDataSize": eventDataSize,
1543
- "totalSize": compressedSize,
1544
- "data": compressedData ?? ""
1545
- ]
1546
-
1547
- Logger.debug("Compression info prepared: \(String(describing: compressionInfo))")
1548
- } else {
1549
- Logger.debug("Could not get compressed file size")
1550
- }
1551
- } else {
1552
- Logger.debug("Compressed file does not exist at path: \(compressedURL.path)")
1553
- }
1554
- } catch {
1555
- Logger.debug("Error preparing compression info: \(error)")
1556
- }
1557
- }
1558
-
1559
- // Emit the audio data with compression info
1560
- delegate?.audioStreamManager(
1561
- self,
1562
- didReceiveAudioData: dataToProcess,
1563
- recordingTime: recordingTime,
1564
- totalDataSize: totalDataSize,
1565
- compressionInfo: compressionInfo
1566
- )
1567
-
1568
- // Update state after emission
1569
- self.lastEmissionTime = currentTime
1570
- self.lastEmittedSize = totalDataSize
1571
- accumulatedData.removeAll()
1572
- }
1573
-
1574
-
1575
- if let lastEmissionTimeAnalysis = lastEmissionTimeAnalysis,
1576
- let startTime = startTime,
1577
- currentTime.timeIntervalSince(lastEmissionTimeAnalysis) >= emissionIntervalAnalysis {
1578
-
1579
- let recordingTime = currentTime.timeIntervalSince(startTime)
1580
- let dataToProcess = accumulatedAnalysisData
1581
-
1582
- // Process audio if enabled
1583
- if settings.enableProcessing {
1584
- DispatchQueue.global().async { [weak self] in
1585
- guard let self = self else { return }
1586
- if let processor = self.audioProcessor {
1587
- Logger.debug("Processing audio buffer of size: \(dataToProcess.count)")
1588
- let processingResult = processor.processAudioBuffer(
1589
- data: dataToProcess,
1590
- sampleRate: Float(settings.sampleRate),
1591
- segmentDurationMs: settings.segmentDurationMs,
1592
- featureOptions: settings.featureOptions ?? [:],
1593
- bitDepth: settings.bitDepth,
1594
- numberOfChannels: settings.numberOfChannels
1595
- )
1596
-
1597
- DispatchQueue.main.async {
1598
- if let result = processingResult {
1599
- self.delegate?.audioStreamManager(self, didReceiveProcessingResult: result)
1600
- }
1601
- }
1602
-
1603
- // Update state after emission
1604
- self.lastEmissionTimeAnalysis = currentTime
1605
- self.lastEmittedSizeAnalysis = totalDataSizeAnalysis
1606
- accumulatedAnalysisData.removeAll()
1607
- }
1608
- }
1609
- }
1610
- }
1611
- }
1612
-
1613
- // Add helper function to calculate average amplitude
1614
- private func calculateAverageAmplitude(_ data: UnsafePointer<Float>, count: Int) -> Float {
1615
- var sum: Float = 0
1616
- vDSP_meanv(data, 1, &sum, vDSP_Length(count))
1617
- return sum
1618
- }
1619
-
1620
- // Add helper function to calculate RMS
1621
- private func calculateRMS(_ data: UnsafePointer<Float>, count: Int) -> Float {
1622
- var sum: Float = 0
1623
- var squaredSum: Float = 0
1624
- for i in 0..<count {
1625
- let value = data[i]
1626
- sum += value
1627
- squaredSum += value * value
1628
- }
1629
- let average = sum / Float(count)
1630
- let variance = squaredSum / Float(count) - average * average
1631
- return sqrt(variance)
1632
- }
1633
-
1634
- // Helper function for format conversion
1635
- private func convertBufferFormat(_ buffer: AVAudioPCMBuffer, to targetFormat: AVAudioFormat) -> AVAudioPCMBuffer? {
1636
- guard let converter = AVAudioConverter(from: buffer.format, to: targetFormat),
1637
- let outputBuffer = AVAudioPCMBuffer(
1638
- pcmFormat: targetFormat,
1639
- frameCapacity: buffer.frameLength
1640
- ) else {
1641
- return nil
1642
- }
1643
-
1644
- outputBuffer.frameLength = buffer.frameLength
1645
- var error: NSError?
1646
-
1647
- converter.convert(to: outputBuffer, error: &error) { inNumPackets, outStatus in
1648
- outStatus.pointee = AVAudioConverterInputStatus.haveData
1649
- return buffer
1650
- }
1651
-
1652
- if let error = error {
1653
- Logger.debug("Format conversion failed: \(error.localizedDescription)")
1654
- return nil
1655
- }
1656
-
1657
- return outputBuffer
1658
- }
1659
- }
1660
-
1661
- extension AudioStreamManager: UNUserNotificationCenterDelegate {
1662
- func userNotificationCenter(
1663
- _ center: UNUserNotificationCenter,
1664
- didReceive response: UNNotificationResponse,
1665
- withCompletionHandler completionHandler: @escaping () -> Void
1666
- ) {
1667
- switch response.actionIdentifier {
1668
- case "PAUSE_RECORDING":
1669
- pauseRecording()
1670
- case "RESUME_RECORDING":
1671
- resumeRecording()
1672
- default:
1673
- break
1674
- }
1675
- completionHandler()
1676
- }
1677
-
1678
- // This is needed to show notifications when app is in foreground
1679
- func userNotificationCenter(
1680
- _ center: UNUserNotificationCenter,
1681
- willPresent notification: UNNotification,
1682
- withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
1683
- ) {
1684
- if #available(iOS 14.0, *) {
1685
- completionHandler([.banner, .sound])
1686
- } else {
1687
- // For iOS 13 and earlier
1688
- completionHandler([.alert, .sound])
1689
- }
1690
- }
1691
- }
1692
-
1693
- // Add AVAudioRecorderDelegate conformance
1694
- extension AudioStreamManager: AVAudioRecorderDelegate {
1695
- func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully flag: Bool) {
1696
- Logger.debug("Compressed recording finished - success: \(flag)")
1697
- if !flag {
1698
- delegate?.audioStreamManager(self, didFailWithError: "Compressed recording failed to complete")
1699
- }
1700
- }
1701
-
1702
- func audioRecorderEncodeErrorDidOccur(_ recorder: AVAudioRecorder, error: Error?) {
1703
- if let error = error {
1704
- Logger.debug("Compressed recording encode error: \(error)")
1705
- delegate?.audioStreamManager(self, didFailWithError: "Compressed recording encode error: \(error.localizedDescription)")
1706
- }
1707
- }
1708
- }