fixed some bugs on ios

This commit is contained in:
2026-08-26 18:56:04 +08:00
parent bb677ea8cf
commit f93bb8d1ff
6 changed files with 361 additions and 57 deletions

View 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 AACm4a
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 AACAVAudioFile '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
}
}
}
}

View File

@@ -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 AACm4a
///
/// 使 AVMutableComposition + AVAssetExportSessioniOS 26 / macOS 26
/// AVAssetExportSession AVError -11800 'fmt?'
/// AVAudioFile AAC CheckClientFormatSet / 'fmt?'
/// PCMcaf 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
}
}
}
}