155 lines
6.3 KiB
Swift
155 lines
6.3 KiB
Swift
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
|
||
}
|
||
}
|
||
}
|
||
}
|