160 lines
6.0 KiB
Swift
160 lines
6.0 KiB
Swift
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
|
||
}
|
||
}
|
||
}
|
||
}
|