// // LNVoiceRecorder.swift // Lanu // // Created by OneeChan on 2025/12/4. // import Foundation import AVFAudio enum LNIMChatVoiceRecorderState { case ready case recording case pausing } protocol LNVoiceRecorderNotify { func onRecordTaskDurationChanged(taskId: String, duration: Double, volumeRatio: Double) func onRecordTaskRecording(taskId: String) func onRecordTaskPause(taskId: String) func onRecordTaskStop(taskId: String) func onRecordTaskReachMaxDuration(taskId: String, fileUrl: URL?, duration: Double) } extension LNVoiceRecorderNotify { func onRecordTaskDurationChanged(taskId: String, duration: Double, volumeRatio: Double) { } func onRecordTaskRecording(taskId: String) { } func onRecordTaskPause(taskId: String) { } func onRecordTaskStop(taskId: String) { } func onRecordTaskReachMaxDuration(taskId: String, fileUrl: URL?, duration: Double) { } } class LNVoiceRecorder { static let shared = LNVoiceRecorder() private(set) var curState: LNIMChatVoiceRecorderState = .ready var isRecording: Bool { curState == .recording } private var recorder: AVAudioRecorder? private var durationTimer: Timer? private let timerInterval = 0.1 private var curDuration: Double = 0.0 private var curTaskId: String = "" private var maxDuration: Double = -1 private init() { } func startRecord(_ maxDuration: Double? = nil, handler: @escaping (String?) -> Void) { guard curState == .ready, setupNewRecorder(), recorder?.record() == true else { handler(nil) return } if let maxDuration { self.maxDuration = maxDuration } else { self.maxDuration = -1 } startDurationTimer() curState = .recording curTaskId = "\(curTime)" DispatchQueue.main.async { [weak self] in guard let self else { return } notifyTaskStart() } handler(curTaskId) } func pauseRecord() { guard curState == .recording else { return } recorder?.pause() stopDurationTimer() curState = .pausing notifyTaskPause() } func continueRecord() { guard curState == .pausing else { return } if maxDuration != -1, maxDuration <= curDuration { let result = stopRecord() notifyTaskReachMaxDuration(file: result.0, duration: result.1) return } recorder?.record() startDurationTimer(true) curState = .recording notifyTaskStart() } @discardableResult func stopRecord() -> (URL?, Double) { recorder?.stop() try? AVAudioSession.sharedInstance().setActive(false) stopDurationTimer() curState = .ready let filePath = recorder?.url notifyTaskStop() curTaskId = "" return (filePath, curDuration) } } extension LNVoiceRecorder { private func setupNewRecorder() -> Bool { recorder?.stop() try? AVAudioSession.sharedInstance().setActive(false) recorder?.deleteRecording() do { try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .default) try AVAudioSession.sharedInstance().setActive(true) // 录音参数配置(M4A 格式,高质量) let recordSettings: [String: Any] = [ AVFormatIDKey: Int(kAudioFormatMPEG4AAC), // 音频格式(AAC) AVSampleRateKey: 16000.0, // 采样率 AVNumberOfChannelsKey: 1, // 声道数(立体声) AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue, // 音质 AVLinearPCMBitDepthKey: 16 ] // 创建录音器 let recorder = try AVAudioRecorder(url: newVoicePath(), settings: recordSettings) recorder.isMeteringEnabled = true // 开启音量监测 recorder.prepareToRecord() self.recorder = recorder curState = .ready return true } catch { Log.e("录音器初始化失败:\(error.localizedDescription)") } return false } func newVoicePath() -> URL { URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("\(UUID().uuidString)__voice_record.m4a") } } extension LNVoiceRecorder { private func startDurationTimer(_ isContinue: Bool = false) { stopDurationTimer() if !isContinue { curDuration = 0 notifyTaskProgress(duration: curDuration, volumeRatio: 0) } let timer = Timer.scheduledTimer(withTimeInterval: timerInterval, repeats: true, block: { [weak self] _ in guard let self else { return } curDuration = recorder?.currentTime ?? 0 var volumeRatio: Double = 0 if let recorder { recorder.updateMeters() let decibel = Double(recorder.averagePower(forChannel: 0)) volumeRatio = max(0.0, min(1.0, (decibel + 160.0) / 160.0)) } notifyTaskProgress(duration: curDuration, volumeRatio: volumeRatio) if maxDuration != -1, maxDuration <= curDuration { let result = stopRecord() notifyTaskReachMaxDuration(file: result.0, duration: result.1) } }) timer.tolerance = 0.1 // 允许轻微延迟,提升性能 RunLoop.current.add(timer, forMode: .common) durationTimer = timer } private func stopDurationTimer() { durationTimer?.invalidate() durationTimer = nil } } extension LNVoiceRecorder { private func notifyTaskProgress(duration: Double, volumeRatio: Double) { let taskId = curTaskId LNEventDeliver.notifyEvent { ($0 as? LNVoiceRecorderNotify)?.onRecordTaskDurationChanged(taskId: taskId, duration: duration, volumeRatio: volumeRatio) } } private func notifyTaskStop() { let taskId = curTaskId LNEventDeliver.notifyEvent { ($0 as? LNVoiceRecorderNotify)?.onRecordTaskStop(taskId: taskId) } } private func notifyTaskStart() { let taskId = curTaskId LNEventDeliver.notifyEvent { ($0 as? LNVoiceRecorderNotify)?.onRecordTaskRecording(taskId: taskId) } } private func notifyTaskPause() { let taskId = curTaskId LNEventDeliver.notifyEvent { ($0 as? LNVoiceRecorderNotify)?.onRecordTaskPause(taskId: taskId) } } private func notifyTaskReachMaxDuration(file: URL?, duration: Double) { let taskId = curTaskId LNEventDeliver.notifyEvent { ($0 as? LNVoiceRecorderNotify)?.onRecordTaskReachMaxDuration(taskId: taskId, fileUrl: file, duration: duration) } } }