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

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