import AVFoundation import Foundation enum DubbingCompositorError: LocalizedError { case inputReadFailed(String?) case outputWriteFailed(String?) case convertFailed(String?) var errorDescription: String? { switch self { case .inputReadFailed(let detail): return detail.map { "无法读取录音音频(\($0))" } ?? "无法读取录音音频" case .outputWriteFailed(let detail): return detail.map { "无法写入合成音频(\($0))" } ?? "无法写入合成音频" case .convertFailed(let detail): return detail.map { "无法转换音频格式(\($0))" } ?? "无法转换音频格式" } } } enum DubbingCompositor { /// 合并多段录音为一段 16kHz 单声道 AAC(m4a)。 /// /// 不使用 AVMutableComposition + AVAssetExportSession:iOS 26 / macOS 26 上 /// AVAssetExportSession 对纯音频资源导出会失败(AVError -11800,底层 'fmt?'), /// AVAudioFile 读回 AAC 也会失败(CheckClientFormatSet / 'fmt?')。 /// 因此传入录音时保留的原始 PCM(caf),解码拼接后直接编码 AAC, /// 与录音转码走同一条已验证可用的链路。 static func merge(urls: [URL], outputURL: URL) async throws { do { try mergeSync(urls: urls, outputURL: outputURL) } catch { try? FileManager.default.removeItem(at: outputURL) throw error } } private static func mergeSync(urls: [URL], outputURL: URL) throws { try? FileManager.default.removeItem(at: outputURL) let settings: [String: Any] = [ AVFormatIDKey: kAudioFormatMPEG4AAC, AVSampleRateKey: 16_000, AVNumberOfChannelsKey: 1, ] let outputFile: AVAudioFile do { outputFile = try AVAudioFile(forWriting: outputURL, settings: settings) } catch { throw DubbingCompositorError.outputWriteFailed(error.localizedDescription) } let targetFormat = outputFile.processingFormat for url in urls { let inputFile: AVAudioFile do { inputFile = try AVAudioFile(forReading: url) } catch { throw DubbingCompositorError.inputReadFailed(error.localizedDescription) } guard inputFile.length > 0 else { throw DubbingCompositorError.inputReadFailed("录音为空") } let inputFormat = inputFile.processingFormat if inputFormat.sampleRate == targetFormat.sampleRate, inputFormat.channelCount == targetFormat.channelCount { try appendStream(from: inputFile, to: outputFile) } else { try appendConverted(from: inputFile, to: outputFile, targetFormat: targetFormat) } } } private static func appendStream(from inputFile: AVAudioFile, to outputFile: AVAudioFile) throws { var remaining = inputFile.length while remaining > 0 { let frames = AVAudioFrameCount(min(remaining, 8192)) guard let buffer = AVAudioPCMBuffer(pcmFormat: inputFile.processingFormat, frameCapacity: frames) else { throw DubbingCompositorError.convertFailed("无法分配解码缓冲") } do { try inputFile.read(into: buffer) } catch { throw DubbingCompositorError.inputReadFailed(error.localizedDescription) } guard buffer.frameLength > 0 else { throw DubbingCompositorError.inputReadFailed("录音文件读取出错(读取到空帧)") } do { try outputFile.write(from: buffer) } catch { throw DubbingCompositorError.outputWriteFailed(error.localizedDescription) } remaining -= AVAudioFramePosition(buffer.frameLength) } } private static func appendConverted( from inputFile: AVAudioFile, to outputFile: AVAudioFile, targetFormat: AVAudioFormat ) throws { guard let converter = AVAudioConverter(from: inputFile.processingFormat, to: targetFormat) else { throw DubbingCompositorError.convertFailed("无法创建格式转换器") } 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) guard buffer.frameLength > 0 else { outStatus.pointee = .endOfStream return nil } outStatus.pointee = .haveData return buffer } catch { outStatus.pointee = .noDataNow return nil } } guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: 8192) else { throw DubbingCompositorError.convertFailed("无法分配转码缓冲") } while true { var error: NSError? let status = converter.convert(to: outputBuffer, error: &error, withInputFrom: inputBlock) switch status { case .haveData: do { try outputFile.write(from: outputBuffer) } catch { throw DubbingCompositorError.outputWriteFailed(error.localizedDescription) } case .inputRanDry, .endOfStream: return case .error: if let error { throw DubbingCompositorError.convertFailed(error.localizedDescription) } throw DubbingCompositorError.convertFailed(nil) default: return } } } }