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

iOS VoIP应用获取20ms麦克风PCM数据的技术问题求助

解决VoIP应用中PCM音频采集的两个核心问题

我之前开发VoIP通话应用时也碰到过几乎一模一样的问题,结合实践经验给你拆解下解决方案:

问题1:无法设置精确的20ms IO Buffer时长

首先得明确:iOS的音频硬件不支持任意时长的IO Buffer,setPreferredIOBufferDuration只是向系统发起请求,系统会返回硬件最接近的支持值(比如你日志里的0.016s,也就是16ms,对应8000Hz下的128帧)。这是硬件层面的限制,没法强制改成20ms,但我们可以用**环形缓冲区(Circular Buffer)**来凑够20ms的数据再传给你的WebRTC栈。

解决方案:用环形缓冲区累积数据

20ms/8000Hz的PCM数据对应160帧(8000 * 0.02 = 160),每次硬件回调会给你128帧,我们把这些帧先存到环形缓冲区,当缓冲区里的帧数≥160时,就取出160帧传给你的WebRTC栈,剩下的留在缓冲区等待下一次回调补充。

示例环形缓冲区实现(针对Int16格式PCM):

class CircularBuffer {
    private var buffer: [Int16]
    private var readIndex: Int = 0
    private var writeIndex: Int = 0
    private let capacity: Int
    
    init(capacity: Int) {
        self.capacity = capacity
        self.buffer = Array(repeating: 0, count: capacity)
    }
    
    // 写入数据
    func write(_ data: [Int16]) {
        let count = data.count
        guard writeIndex + count <= capacity else {
            // 处理缓冲区溢出(按需调整,比如覆盖旧数据或丢弃新数据)
            let overflow = (writeIndex + count) - capacity
            buffer.removeFirst(overflow)
            readIndex = max(readIndex - overflow, 0)
            writeIndex = capacity - count
        }
        buffer.replaceSubrange(writeIndex..<writeIndex+count, with: data)
        writeIndex += count
    }
    
    // 读取指定数量的数据
    func read(_ count: Int) -> [Int16]? {
        guard writeIndex - readIndex >= count else {
            return nil // 数据不够,返回nil
        }
        let data = buffer[readIndex..<readIndex+count]
        readIndex += count
        // 重置索引,避免缓冲区无限增长
        if readIndex == writeIndex {
            readIndex = 0
            writeIndex = 0
        }
        return Array(data)
    }
    
    // 获取当前缓冲区的可用帧数
    func availableFrames() -> Int {
        return writeIndex - readIndex
    }
}

然后在你的音频回调里,把采集到的单声道数据写入缓冲区,再检查是否能取出160帧:

// 初始化环形缓冲区,容量设为2倍目标帧数(避免溢出)
let circularBuffer = CircularBuffer(capacity: 320)

func recordMicrophoneInputSamples(inputDataList: UnsafeMutableAudioBufferListPointer, frameCount: UInt32) {
    // 先转单声道(看问题2的处理)
    let monoSamples = convertToMono(inputDataList: inputDataList, frameCount: frameCount)
    
    // 写入环形缓冲区
    circularBuffer.write(monoSamples)
    
    // 检查是否有足够的20ms数据(160帧)
    while let samplesForWebRTC = circularBuffer.read(160) {
        // 把samplesForWebRTC传给你的WebRTC栈
        yourWebRTCStack.pushAudioSamples(samplesForWebRTC)
    }
}

问题2:双声道转单声道&适配AVAudioPlayer

双声道转单声道

UnsafeMutableAudioBufferListPointer里的两个元素对应左右声道,你可以选择两种方式转单声道:

  1. 直接取其中一个声道的 data(比如左声道,第一个元素)
  2. 混合左右声道(取左右样本的平均值,音质更均衡)

示例转换函数:

func convertToMono(inputDataList: UnsafeMutableAudioBufferListPointer, frameCount: UInt32) -> [Int16] {
    let frameCountInt = Int(frameCount)
    var monoSamples = [Int16](repeating: 0, count: frameCountInt)
    
    // 方式1:取左声道数据
    let leftBuffer = inputDataList[0]
    let leftSamples = UnsafeMutableBufferPointer<Int16>(start: leftBuffer.mData?.assumingMemoryBound(to: Int16.self), count: frameCountInt)
    monoSamples = Array(leftSamples)
    
    // 方式2:混合左右声道(可选)
    /*
    let leftBuffer = inputDataList[0]
    let rightBuffer = inputDataList[1]
    let leftSamples = UnsafeMutableBufferPointer<Int16>(start: leftBuffer.mData?.assumingMemoryBound(to: Int16.self), count: frameCountInt)
    let rightSamples = UnsafeMutableBufferPointer<Int16>(start: rightBuffer.mData?.assumingMemoryBound(to: Int16.self), count: frameCountInt)
    
    for i in 0..<frameCountInt {
        let mixed = (Int(leftSamples[i]) + Int(rightSamples[i])) / 2
        monoSamples[i] = Int16(clamping: mixed)
    }
    */
    
    return monoSamples
}

适配AVAudioPlayer播放

AVAudioPlayer不能直接播放 raw PCM 数据,它需要带文件头的音频格式(比如WAV)。你需要把采集到的Int16单声道PCM数据加上WAV文件头,转换成Data后再给AVAudioPlayer播放。

示例生成WAV Data的函数:

func pcmToWav(pcmData: [Int16], sampleRate: Int, channels: Int) -> Data {
    let bitsPerSample = 16
    let byteRate = sampleRate * channels * bitsPerSample / 8
    let blockAlign = channels * bitsPerSample / 8
    let dataSize = pcmData.count * 2 // Int16是2字节
    
    var wavData = Data()
    
    // WAV文件头
    wavData.append(contentsOf: "RIFF".utf8)
    wavData.append(intToByteArray(value: 36 + dataSize)) // ChunkSize
    wavData.append(contentsOf: "WAVE".utf8)
    
    // fmt子块
    wavData.append(contentsOf: "fmt ".utf8)
    wavData.append(intToByteArray(value: 16)) // Subchunk1Size(PCM格式固定为16)
    wavData.append(shortToByteArray(value: 1)) // AudioFormat(PCM=1)
    wavData.append(shortToByteArray(value: Int16(channels))) // NumChannels
    wavData.append(intToByteArray(value: sampleRate)) // SampleRate
    wavData.append(intToByteArray(value: byteRate)) // ByteRate
    wavData.append(shortToByteArray(value: Int16(blockAlign))) // BlockAlign
    wavData.append(shortToByteArray(value: Int16(bitsPerSample))) // BitsPerSample
    
    // data子块
    wavData.append(contentsOf: "data".utf8)
    wavData.append(intToByteArray(value: dataSize)) // Subchunk2Size
    // 把Int16数组转成Data
    let pcmBytes = pcmData.withUnsafeBytes { Data($0) }
    wavData.append(pcmBytes)
    
    return wavData
}

// 辅助函数:Int转4字节大端
private func intToByteArray(value: Int) -> [UInt8] {
    let data = withUnsafeBytes(of: value.bigEndian) { Array($0) }
    return data
}

// 辅助函数:Short转2字节大端
private func shortToByteArray(value: Int16) -> [UInt8] {
    let data = withUnsafeBytes(of: value.bigEndian) { Array($0) }
    return data
}

使用方式:

// 假设你已经有了单声道PCM数据数组monoSamples
let wavData = pcmToWav(pcmData: monoSamples, sampleRate: 8000, channels: 1)
// 保存到临时文件(AVAudioPlayer需要文件URL)
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("temp.wav")
try? wavData.write(to: tempURL)

// 初始化AVAudioPlayer
do {
    let player = try AVAudioPlayer(contentsOf: tempURL)
    player.play()
} catch {
    print("播放失败:\(error)")
}

额外注意事项

  • 确保你的AVAudioSession类别设置正确:try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: .defaultToSpeaker),VoIP应用推荐用.voiceChat模式,会自动优化语音采集。
  • 在设置采样率前,先检查硬件是否支持8000Hz:audioSession.isSupportedSampleRate(8000),避免设置失败。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.05.13 09:28:59