@siteed/expo-audio-stream 2.1.0 → 2.2.1-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -260
- package/build/index.d.ts +11 -15
- package/build/index.js +54 -14
- package/build/src/index.d.ts +11 -0
- package/build/src/index.js +54 -0
- package/package.json +49 -110
- package/src/index.ts +18 -32
- package/CHANGELOG.md +0 -206
- package/android/build.gradle +0 -105
- package/android/src/main/AndroidManifest.xml +0 -27
- package/android/src/main/java/net/siteed/audiostream/AudioAnalysisData.kt +0 -166
- package/android/src/main/java/net/siteed/audiostream/AudioDataEncoder.kt +0 -9
- package/android/src/main/java/net/siteed/audiostream/AudioFileHandler.kt +0 -131
- package/android/src/main/java/net/siteed/audiostream/AudioFormatUtils.kt +0 -103
- package/android/src/main/java/net/siteed/audiostream/AudioNotificationsManager.kt +0 -435
- package/android/src/main/java/net/siteed/audiostream/AudioProcessor.kt +0 -2235
- package/android/src/main/java/net/siteed/audiostream/AudioRecorderManager.kt +0 -1437
- package/android/src/main/java/net/siteed/audiostream/AudioRecordingService.kt +0 -152
- package/android/src/main/java/net/siteed/audiostream/AudioTrimmer.kt +0 -1099
- package/android/src/main/java/net/siteed/audiostream/Constants.kt +0 -21
- package/android/src/main/java/net/siteed/audiostream/EventSender.kt +0 -7
- package/android/src/main/java/net/siteed/audiostream/ExpoAudioStreamModule.kt +0 -739
- package/android/src/main/java/net/siteed/audiostream/FFT.kt +0 -99
- package/android/src/main/java/net/siteed/audiostream/Features.kt +0 -98
- package/android/src/main/java/net/siteed/audiostream/NotificationConfig.kt +0 -70
- package/android/src/main/java/net/siteed/audiostream/PermissionUtils.kt +0 -59
- package/android/src/main/java/net/siteed/audiostream/RecordingActionReceiver.kt +0 -59
- package/android/src/main/java/net/siteed/audiostream/RecordingConfig.kt +0 -205
- package/android/src/main/java/net/siteed/audiostream/WaveformConfig.kt +0 -19
- package/android/src/main/java/net/siteed/audiostream/WaveformRenderer.kt +0 -159
- package/android/src/main/res/drawable/ic_default_action_icon.xml +0 -16
- package/android/src/main/res/drawable/ic_microphone.xml +0 -13
- package/android/src/main/res/drawable/ic_pause.xml +0 -10
- package/android/src/main/res/drawable/ic_play.xml +0 -10
- package/android/src/main/res/drawable/ic_stop.xml +0 -10
- package/android/src/main/res/layout/notification_recording.xml +0 -37
- package/android/src/main/test/java/net/siteed/audiostream/AudioProcessorTest.kt +0 -56
- package/app.plugin.js +0 -1
- package/build/AudioAnalysis/AudioAnalysis.types.d.ts +0 -179
- package/build/AudioAnalysis/AudioAnalysis.types.d.ts.map +0 -1
- package/build/AudioAnalysis/AudioAnalysis.types.js +0 -3
- package/build/AudioAnalysis/AudioAnalysis.types.js.map +0 -1
- package/build/AudioAnalysis/extractAudioAnalysis.d.ts +0 -68
- package/build/AudioAnalysis/extractAudioAnalysis.d.ts.map +0 -1
- package/build/AudioAnalysis/extractAudioAnalysis.js +0 -203
- package/build/AudioAnalysis/extractAudioAnalysis.js.map +0 -1
- package/build/AudioAnalysis/extractAudioData.d.ts +0 -3
- package/build/AudioAnalysis/extractAudioData.d.ts.map +0 -1
- package/build/AudioAnalysis/extractAudioData.js +0 -5
- package/build/AudioAnalysis/extractAudioData.js.map +0 -1
- package/build/AudioAnalysis/extractMelSpectrogram.d.ts +0 -14
- package/build/AudioAnalysis/extractMelSpectrogram.d.ts.map +0 -1
- package/build/AudioAnalysis/extractMelSpectrogram.js +0 -85
- package/build/AudioAnalysis/extractMelSpectrogram.js.map +0 -1
- package/build/AudioAnalysis/extractPreview.d.ts +0 -11
- package/build/AudioAnalysis/extractPreview.d.ts.map +0 -1
- package/build/AudioAnalysis/extractPreview.js +0 -25
- package/build/AudioAnalysis/extractPreview.js.map +0 -1
- package/build/AudioAnalysis/extractWaveform.d.ts +0 -8
- package/build/AudioAnalysis/extractWaveform.d.ts.map +0 -1
- package/build/AudioAnalysis/extractWaveform.js +0 -11
- package/build/AudioAnalysis/extractWaveform.js.map +0 -1
- package/build/AudioRecorder.provider.d.ts +0 -11
- package/build/AudioRecorder.provider.d.ts.map +0 -1
- package/build/AudioRecorder.provider.js +0 -37
- package/build/AudioRecorder.provider.js.map +0 -1
- package/build/ExpoAudioStream.native.d.ts +0 -3
- package/build/ExpoAudioStream.native.d.ts.map +0 -1
- package/build/ExpoAudioStream.native.js +0 -6
- package/build/ExpoAudioStream.native.js.map +0 -1
- package/build/ExpoAudioStream.types.d.ts +0 -532
- package/build/ExpoAudioStream.types.d.ts.map +0 -1
- package/build/ExpoAudioStream.types.js +0 -2
- package/build/ExpoAudioStream.types.js.map +0 -1
- package/build/ExpoAudioStream.web.d.ts +0 -59
- package/build/ExpoAudioStream.web.d.ts.map +0 -1
- package/build/ExpoAudioStream.web.js +0 -285
- package/build/ExpoAudioStream.web.js.map +0 -1
- package/build/ExpoAudioStreamModule.d.ts +0 -3
- package/build/ExpoAudioStreamModule.d.ts.map +0 -1
- package/build/ExpoAudioStreamModule.js +0 -693
- package/build/ExpoAudioStreamModule.js.map +0 -1
- package/build/WebRecorder.web.d.ts +0 -119
- package/build/WebRecorder.web.d.ts.map +0 -1
- package/build/WebRecorder.web.js +0 -436
- package/build/WebRecorder.web.js.map +0 -1
- package/build/constants.d.ts +0 -11
- package/build/constants.d.ts.map +0 -1
- package/build/constants.js +0 -14
- package/build/constants.js.map +0 -1
- package/build/events.d.ts +0 -26
- package/build/events.d.ts.map +0 -1
- package/build/events.js +0 -21
- package/build/events.js.map +0 -1
- package/build/index.d.ts.map +0 -1
- package/build/index.js.map +0 -1
- package/build/trimAudio.d.ts +0 -25
- package/build/trimAudio.d.ts.map +0 -1
- package/build/trimAudio.js +0 -67
- package/build/trimAudio.js.map +0 -1
- package/build/useAudioRecorder.d.ts +0 -21
- package/build/useAudioRecorder.d.ts.map +0 -1
- package/build/useAudioRecorder.js +0 -427
- package/build/useAudioRecorder.js.map +0 -1
- package/build/utils/BlobFix.d.ts +0 -9
- package/build/utils/BlobFix.d.ts.map +0 -1
- package/build/utils/BlobFix.js +0 -498
- package/build/utils/BlobFix.js.map +0 -1
- package/build/utils/audioProcessing.d.ts +0 -24
- package/build/utils/audioProcessing.d.ts.map +0 -1
- package/build/utils/audioProcessing.js +0 -133
- package/build/utils/audioProcessing.js.map +0 -1
- package/build/utils/concatenateBuffers.d.ts +0 -8
- package/build/utils/concatenateBuffers.d.ts.map +0 -1
- package/build/utils/concatenateBuffers.js +0 -21
- package/build/utils/concatenateBuffers.js.map +0 -1
- package/build/utils/convertPCMToFloat32.d.ts +0 -13
- package/build/utils/convertPCMToFloat32.d.ts.map +0 -1
- package/build/utils/convertPCMToFloat32.js +0 -120
- package/build/utils/convertPCMToFloat32.js.map +0 -1
- package/build/utils/encodingToBitDepth.d.ts +0 -5
- package/build/utils/encodingToBitDepth.d.ts.map +0 -1
- package/build/utils/encodingToBitDepth.js +0 -13
- package/build/utils/encodingToBitDepth.js.map +0 -1
- package/build/utils/getWavFileInfo.d.ts +0 -26
- package/build/utils/getWavFileInfo.d.ts.map +0 -1
- package/build/utils/getWavFileInfo.js +0 -92
- package/build/utils/getWavFileInfo.js.map +0 -1
- package/build/utils/writeWavHeader.d.ts +0 -49
- package/build/utils/writeWavHeader.d.ts.map +0 -1
- package/build/utils/writeWavHeader.js +0 -91
- package/build/utils/writeWavHeader.js.map +0 -1
- package/build/workers/InlineFeaturesExtractor.web.d.ts +0 -2
- package/build/workers/InlineFeaturesExtractor.web.d.ts.map +0 -1
- package/build/workers/InlineFeaturesExtractor.web.js +0 -828
- package/build/workers/InlineFeaturesExtractor.web.js.map +0 -1
- package/build/workers/inlineAudioWebWorker.web.d.ts +0 -2
- package/build/workers/inlineAudioWebWorker.web.d.ts.map +0 -1
- package/build/workers/inlineAudioWebWorker.web.js +0 -157
- package/build/workers/inlineAudioWebWorker.web.js.map +0 -1
- package/expo-module.config.json +0 -9
- package/ios/AudioAnalysisData.swift +0 -74
- package/ios/AudioNotificationManager.swift +0 -135
- package/ios/AudioProcessingHelpers.swift +0 -743
- package/ios/AudioProcessor.swift +0 -1313
- package/ios/AudioStreamError.swift +0 -7
- package/ios/AudioStreamManager.swift +0 -1708
- package/ios/AudioStreamManagerDelegate.swift +0 -16
- package/ios/DataPoint.swift +0 -54
- package/ios/DecodingConfig.swift +0 -47
- package/ios/ExpoAudioStream.podspec +0 -27
- package/ios/ExpoAudioStreamModule.swift +0 -805
- package/ios/FFT.swift +0 -62
- package/ios/Features.swift +0 -95
- package/ios/Logger.swift +0 -7
- package/ios/NotificationExtension.swift +0 -15
- package/ios/RecordingResult.swift +0 -22
- package/ios/RecordingSettings.swift +0 -265
- package/ios/WaveformExtractor.swift +0 -105
- package/plugin/build/index.d.ts +0 -21
- package/plugin/build/index.js +0 -191
- package/plugin/src/index.ts +0 -278
- package/plugin/tsconfig.json +0 -10
- package/plugin/tsconfig.tsbuildinfo +0 -1
- package/src/AudioAnalysis/AudioAnalysis.types.ts +0 -202
- package/src/AudioAnalysis/extractAudioAnalysis.ts +0 -333
- package/src/AudioAnalysis/extractAudioData.ts +0 -6
- package/src/AudioAnalysis/extractMelSpectrogram.ts +0 -144
- package/src/AudioAnalysis/extractPreview.ts +0 -34
- package/src/AudioAnalysis/extractWaveform.ts +0 -22
- package/src/AudioRecorder.provider.tsx +0 -54
- package/src/ExpoAudioStream.native.ts +0 -6
- package/src/ExpoAudioStream.types.ts +0 -641
- package/src/ExpoAudioStream.web.ts +0 -359
- package/src/ExpoAudioStreamModule.ts +0 -967
- package/src/WebRecorder.web.ts +0 -580
- package/src/constants.ts +0 -18
- package/src/events.ts +0 -60
- package/src/trimAudio.ts +0 -90
- package/src/useAudioRecorder.tsx +0 -620
- package/src/utils/BlobFix.ts +0 -559
- package/src/utils/audioProcessing.ts +0 -205
- package/src/utils/concatenateBuffers.ts +0 -24
- package/src/utils/convertPCMToFloat32.ts +0 -170
- package/src/utils/encodingToBitDepth.ts +0 -18
- package/src/utils/getWavFileInfo.ts +0 -132
- package/src/utils/writeWavHeader.ts +0 -114
- package/src/workers/InlineFeaturesExtractor.web.tsx +0 -827
- 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
|
-
}
|