fixed some bugs on ios
This commit is contained in:
159
ios/OralTrainer/Services/AudioRecorderSession.swift
Normal file
159
ios/OralTrainer/Services/AudioRecorderSession.swift
Normal file
@@ -0,0 +1,159 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,61 +2,153 @@ import AVFoundation
|
||||
import Foundation
|
||||
|
||||
enum DubbingCompositorError: LocalizedError {
|
||||
case noAudioTracks
|
||||
case cannotReadDuration
|
||||
case inputReadFailed(String?)
|
||||
case outputWriteFailed(String?)
|
||||
case convertFailed(String?)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noAudioTracks:
|
||||
return "录音没有音频轨道"
|
||||
case .cannotReadDuration:
|
||||
return "无法读取录音时长"
|
||||
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 {
|
||||
try await mergeSync(urls: urls, outputURL: outputURL)
|
||||
do {
|
||||
try mergeSync(urls: urls, outputURL: outputURL)
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: outputURL)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private static func mergeSync(urls: [URL], outputURL: URL) async throws {
|
||||
private static func mergeSync(urls: [URL], outputURL: URL) throws {
|
||||
try? FileManager.default.removeItem(at: outputURL)
|
||||
let composition = AVMutableComposition()
|
||||
var currentTime = CMTime.zero
|
||||
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 source = AVURLAsset(url: url)
|
||||
let tracks = try await source.loadTracks(withMediaType: .audio)
|
||||
guard let track = tracks.first else {
|
||||
throw DubbingCompositorError.noAudioTracks
|
||||
let inputFile: AVAudioFile
|
||||
do {
|
||||
inputFile = try AVAudioFile(forReading: url)
|
||||
} catch {
|
||||
throw DubbingCompositorError.inputReadFailed(error.localizedDescription)
|
||||
}
|
||||
let duration = try await source.load(.duration)
|
||||
guard duration.seconds.isFinite, duration.seconds > 0 else {
|
||||
throw DubbingCompositorError.cannotReadDuration
|
||||
guard inputFile.length > 0 else {
|
||||
throw DubbingCompositorError.inputReadFailed("录音为空")
|
||||
}
|
||||
let timeRange = CMTimeRange(start: .zero, duration: duration)
|
||||
let compositionTrack = composition.addMutableTrack(
|
||||
withMediaType: .audio,
|
||||
preferredTrackID: kCMPersistentTrackID_Invalid
|
||||
)
|
||||
guard let compositionTrack else {
|
||||
throw DubbingCompositorError.noAudioTracks
|
||||
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)
|
||||
}
|
||||
try compositionTrack.insertTimeRange(timeRange, of: track, at: currentTime)
|
||||
currentTime = currentTime + duration
|
||||
}
|
||||
}
|
||||
|
||||
guard let exporter = AVAssetExportSession(
|
||||
asset: composition,
|
||||
presetName: AVAssetExportPresetAppleM4A
|
||||
) else {
|
||||
throw DubbingCompositorError.noAudioTracks
|
||||
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)
|
||||
}
|
||||
exporter.outputURL = outputURL
|
||||
exporter.outputFileType = .m4a
|
||||
await exporter.export()
|
||||
guard exporter.status == .completed else {
|
||||
throw exporter.error ?? DubbingCompositorError.noAudioTracks
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user