You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Swift AVFoundation AudioEngine模拟器切换AirPods时崩溃问题排查

问题:切换AirPods时音频录制程序崩溃,报错required condition is false: format.sampleRate == hwFormat.sampleRate

我正在开发一款通过WebSocket传输数据的类Siri应用,但在模拟器中切换使用/不使用AirPods时,程序会崩溃,报错信息如下:

Thread 1: "required condition is false: format.sampleRate == hwFormat.sampleRate"

我尝试调整commonFormat(如将.pcmFormatFloat32改为其他格式)以及总线设置,但仍未找到崩溃的根本原因,希望能得到排查思路。

实现代码

final class AudioRecorder: Recorder, ObservableObject {
    enum RecordingState {
        case recording, paused, stopped
    }
    
    private var engine: AVAudioEngine!
    private var mixerNode: AVAudioMixerNode!
    @Published private(set) var state: RecordingState = .stopped
    
    private let audioBus = 0
    private let bufferSize: AVAudioFrameCount = 1024
    private let sampleRate = 44100.0
    
    private var audioBuffers = [AVAudioPCMBuffer]()
    @Published private(set) var combinedBuffer: AudioRecordResult?
    
    func createEngine() {
        setupSession()
        setupEngine()
    }
    
    fileprivate func setupSession() {
        let session = AVAudioSession.sharedInstance()
        try? session.setCategory(.record)
        try? session.setActive(true, options: .notifyOthersOnDeactivation)
    }
    
    fileprivate func setupEngine() {
        engine = AVAudioEngine()
        mixerNode = AVAudioMixerNode()
        mixerNode.volume = 0
        engine.attach(mixerNode)
        makeConnections()
        engine.prepare()
    }
    
    fileprivate func makeConnections() {
        let inputNode = engine.inputNode
        let inputFormat: AVAudioFormat = inputNode.outputFormat(forBus: 0)
        engine.connect(inputNode, to: mixerNode, format: inputFormat)
        
        let mainMixerNode = engine.mainMixerNode
        let mixerFormat = AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: inputFormat.sampleRate, channels: 1, interleaved: false)
        engine.connect(mixerNode, to: mainMixerNode, format: mixerFormat)
    }
    
    func startRecording() throws {
        let tapNode: AVAudioNode = mixerNode
        let format = tapNode.outputFormat(forBus: 0)
        
        let startTime = DispatchTime.now()
        
        DispatchQueue.global(qos: .background).async {
            tapNode.installTap(onBus: 0, bufferSize: 4096, format: format, block: { [weak self]
                (buffer, time) in
                guard let self = self else { return }
                self.audioBuffers.append(buffer)
                let currentTime = DispatchTime.now()
                let elapsedTime = currentTime.uptimeNanoseconds - startTime.uptimeNanoseconds
                
                let isFinal = elapsedTime > 5 * 1_000_000_000
                self.combineBuffers(isFinal: isFinal)
                if isFinal {
                    stopRecording()
                }
            })
        }
        try engine.start()
        state = .recording
    }
    
    private func combineBuffers(isFinal: Bool) {
        guard !audioBuffers.isEmpty else { return }
        
        let format = audioBuffers[0].format
        let totalFrameLength = audioBuffers.reduce(0) { $0 + $1.frameLength }
        
        guard let combinedBuffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: totalFrameLength) else { return }
        combinedBuffer.frameLength = totalFrameLength
        
        var frameOffset: AVAudioFrameCount = 0
        for buffer in audioBuffers {
            for channel in 0..<Int(buffer.format.channelCount) {
                let src = buffer.floatChannelData![channel]
                let dst = combinedBuffer.floatChannelData![channel] + Int(frameOffset)
                memcpy(dst, src, Int(buffer.frameLength) * MemoryLayout<Float>.size)
            }
            frameOffset += buffer.frameLength
        }
        self.combinedBuffer = .success(buffer: combinedBuffer, final: isFinal)
    }
    
    func stopRecording() {
        mixerNode.removeTap(onBus: 0)
        engine.stop()
        if case .success(let buffer, _) = self.combinedBuffer {
            self.combinedBuffer = .success(buffer: buffer, final: true)
        }
        self.audioBuffers = []
        state = .stopped
    }
}

排查思路与修复方案

核心原因

切换AirPods时,音频硬件设备发生变更,AVAudioEngine的输入格式(采样率、通道数等)会随之改变,但当前代码未处理设备变更事件,原有的引擎连接和Tap仍绑定旧硬件格式,新旧格式不匹配触发断言崩溃。

具体修复步骤

  • 监听音频路由变更:注册AVAudioSession.routeChangeNotification通知,在设备切换时销毁并重建音频引擎
  • 避免缓存旧格式:每次启动录制时,获取节点最新的输出格式,不要提前固定格式
  • 严格清理资源:设备变更时先停止引擎、移除Tap,再重新初始化引擎和连接

修改后的关键代码

  1. 添加路由变更监听
init() {
    NotificationCenter.default.addObserver(
        self,
        selector: #selector(handleAudioRouteChange),
        name: AVAudioSession.routeChangeNotification,
        object: nil
    )
}

@objc private func handleAudioRouteChange(notification: Notification) {
    if state == .recording {
        stopRecording()
    }
    // 销毁旧引擎资源
    engine = nil
    mixerNode = nil
    // 重建引擎
    createEngine()
}
  1. 完善会话设置的错误处理
fileprivate func setupSession() {
    let session = AVAudioSession.sharedInstance()
    do {
        try session.setCategory(.record)
        try session.setActive(true, options: .notifyOthersOnDeactivation)
    } catch {
        print("音频会话配置失败: \(error.localizedDescription)")
    }
}
  1. 修正录制时的格式获取与Tap安装
func startRecording() throws {
    guard let tapNode = mixerNode else { return }
    // 每次启动都获取最新格式,避免使用旧设备的格式
    let format = tapNode.outputFormat(forBus: 0)
    
    let startTime = DispatchTime.now()
    
    DispatchQueue.global(qos: .background).async {
        tapNode.installTap(onBus: 0, bufferSize: 4096, format: format, block: { [weak self]
            (buffer, time) in
            guard let self = self else { return }
            self.audioBuffers.append(buffer)
            let currentTime = DispatchTime.now()
            let elapsedTime = currentTime.uptimeNanoseconds - startTime.uptimeNanoseconds
            
            let isFinal = elapsedTime > 5 * 1_000_000_000
            self.combineBuffers(isFinal: isFinal)
            if isFinal {
                self.stopRecording()
            }
        })
    }
    try engine.start()
    state = .recording
}

额外注意事项

  • 模拟器的音频设备切换逻辑和真机存在差异,修复后必须在真机上验证
  • 不要忽略AVAudioSession的错误返回,这些信息能快速定位权限或配置问题
  • 录制过程中切换设备时,建议给用户显示状态提示,提升体验

内容的提问来源于stack exchange,提问作者Farhandika

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.14 23:19:59