Files
mediaplayer/ios/OralTrainer/Services/AudioRecorderSession.swift
2026-08-26 18:56:04 +08:00

160 lines
6.0 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}
}
}