import AVFoundation import Foundation enum AudioRecorderSessionError: LocalizedError { case inputUnavailable case startFailed case conversionFailed var errorDescription: String? { switch self { case .inputUnavailable: return "未检测到麦克风输入" case .startFailed: return "无法开始录制:请确认麦克风未被其他应用占用后重试" case .conversionFailed: return "录音文件转码失败" } } } /// 基于 AVAudioEngine 的录音会话。 /// /// AVAudioRecorder 在 iOS 26 真机上存在 record() 恒返回 false 的已知问题 /// (Apple 开发者论坛多个报告),因此这里改用 AVAudioEngine 采集 PCM, /// 停止录音时再转码为 16kHz 单声道 AAC(m4a),与服务端评测接口保持一致。 final class AudioRecorderSession { private let engine = AVAudioEngine() private let outputURL: URL private let pcmURL: URL private var inputFile: AVAudioFile? private var tapInstalled = false private(set) var isRunning = false private(set) var recordDuration: TimeInterval = 0 /// 本次录音的原始 PCM 文件(caf),停止录音后仍保留, /// 供配音合成直接合并,避免在 iOS 26 上读回 AAC(AVAudioFile 存在 'fmt?' 问题)。 var rawRecordingURL: URL { pcmURL } init(outputURL: URL) { self.outputURL = outputURL let stamp = Int(Date().timeIntervalSince1970 * 1000) self.pcmURL = FileManager.default.temporaryDirectory .appendingPathComponent("raw-\(stamp).caf") } /// 激活音频会话并开始采集。失败时抛出错误,可在重新激活会话后重试。 func start() throws { let session = AVAudioSession.sharedInstance() try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker]) try session.setActive(true) guard session.isInputAvailable else { throw AudioRecorderSessionError.inputUnavailable } let input = engine.inputNode if !tapInstalled { let inputFormat = input.outputFormat(forBus: 0) guard inputFormat.sampleRate > 0, inputFormat.channelCount > 0 else { throw AudioRecorderSessionError.inputUnavailable } let file = try AVAudioFile(forWriting: pcmURL, settings: inputFormat.settings) inputFile = file input.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { [weak self] buffer, _ in guard let self = self else { return } do { try self.inputFile?.write(from: buffer) } catch { // 单个缓冲写入失败时忽略,继续录音 } } tapInstalled = true } engine.prepare() do { try engine.start() } catch { throw AudioRecorderSessionError.startFailed } isRunning = true } /// 停止录音,将 PCM 转码为 m4a 写入 outputURL。 func stop() throws { guard isRunning else { return } engine.inputNode.removeTap(onBus: 0) engine.stop() isRunning = false tapInstalled = false inputFile = nil try finalizeToAAC() } /// 放弃本次录音并清理临时文件。 func cancel() { if isRunning { engine.inputNode.removeTap(onBus: 0) engine.stop() isRunning = false tapInstalled = false inputFile = nil } try? FileManager.default.removeItem(at: pcmURL) try? FileManager.default.removeItem(at: outputURL) } private func finalizeToAAC() throws { let inputFile = try AVAudioFile(forReading: pcmURL) recordDuration = Double(inputFile.length) / inputFile.processingFormat.sampleRate let outputSettings: [String: Any] = [ AVFormatIDKey: kAudioFormatMPEG4AAC, AVSampleRateKey: 16_000, AVNumberOfChannelsKey: 1, ] let outputFile = try AVAudioFile(forWriting: outputURL, settings: outputSettings) guard let converter = AVAudioConverter(from: inputFile.processingFormat, to: outputFile.processingFormat) else { throw AudioRecorderSessionError.conversionFailed } let inputBlock: AVAudioConverterInputBlock = { inNumPackets, outStatus in if inputFile.framePosition >= inputFile.length { outStatus.pointee = .endOfStream return nil } let remaining = inputFile.length - inputFile.framePosition let frames = AVAudioFrameCount(min(inNumPackets, AVAudioFrameCount(remaining))) guard frames > 0, let buffer = AVAudioPCMBuffer(pcmFormat: inputFile.processingFormat, frameCapacity: frames) else { outStatus.pointee = .noDataNow return nil } do { try inputFile.read(into: buffer) outStatus.pointee = .haveData return buffer } catch { outStatus.pointee = .noDataNow return nil } } let capacity = AVAudioFrameCount(min(4096, max(1, Int(inputFile.length)))) guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: outputFile.processingFormat, frameCapacity: capacity) else { throw AudioRecorderSessionError.conversionFailed } while true { var conversionError: NSError? let status = converter.convert(to: outputBuffer, error: &conversionError, withInputFrom: inputBlock) switch status { case .haveData: try outputFile.write(from: outputBuffer) case .inputRanDry, .endOfStream: return case .error: throw conversionError ?? AudioRecorderSessionError.conversionFailed default: return } } } }