开始采集:

private var engine:AVAudioEngine?

    private var eqNode = AVAudioUnitEQ(numberOfBands: 3)

    private var audioConverter: AVAudioConverter?

    private var isPushingAudio = false

    private var audioFile:AVAudioFile?

    private var recordedBuffers: [AVAudioPCMBuffer] = [] //收集每次采集的音频数据(后续实现转WAV数据写入文件,可发送的音频文件)

    

    private let rtcSampleRate: Int32 = 48000

    private let rtcChannels: Int32 = 2

    private let rtcSamplesPer10ms = 480          // 每个声道 10ms 采样点个数

    private var pcmStereoCache = [Int16]()       // 缓存交错后的双声道 Int16 数据

    

    // 120ms 预缓冲

    private let prebufferMs = 120

    private var hasStartedPush = false

var audioFilename:URL?

    

    // 并发 + 定时器

    private let pcmQueue = DispatchQueue(label: "rtc.pcm.queue")

    private var pushTimer: DispatchSourceTimer?

func startAudioCapture() {

        let engine = AVAudioEngine()

        self.engine = engine

        let inputNode = engine.inputNode

        let inputFormat = inputNode.outputFormat(forBus: 0)

        

        // ✅ 目标格式:48000 Hz、2 声道、Float32(后面再自己转 Int16)

        let targetFormat = AVAudioFormat(commonFormat: .pcmFormatFloat32,

                                         sampleRate: 48000,

                                         channels: 2,

                                         interleaved: false)!

        

        // 创建文件用于保存录音(这块你原来怎么录就怎么录)

        do {

            let url = getAudioFileURL()

            audioFilename = url

            audioFile = try AVAudioFile(forWriting: url, settings: inputFormat.settings)

            print("✅ 录音文件路径:\(url.path)")

        } catch {

            print("❌ 创建音频文件失败: \(error.localizedDescription)")

            return

        }

        

        recordedBuffers.removeAll()    //清空缓存数据

        

        pcmQueue.sync {

            self.pcmStereoCache.removeAll()

            self.hasStartedPush = false

        }

        

        // bufferSize 可以 480 或 960,更接近 10ms/20ms

        let bufferSize: AVAudioFrameCount = 480

        

        inputNode.installTap(onBus: 0, bufferSize: bufferSize, format: targetFormat) { [weak self] buffer, _ in

            guard let self = self, self.isPushingAudio else { return }

            

            self.addRecordedBuffers(buffer: buffer) //添加每次采集的音频数据

            

            guard let floatData = buffer.floatChannelData else { return }

            let frameCount = Int(buffer.frameLength)

            if frameCount == 0 { return }

            

            let channelCount = Int(buffer.format.channelCount)

            let left = floatData[0]

            let right = channelCount > 1 ? floatData[1] : floatData[0]   // 实际只有1声道就复制

            

            // Float32 [-1,1] → Int16,交错 L0,R0,L1,R1,...

            var pcm16 = [Int16](repeating: 0, count: frameCount * 2)

            for i in 0..<frameCount {

                let lf = max(-1.0, min(1.0, left[i]))

                let rf = max(-1.0, min(1.0, right[i]))

                pcm16[2 * i]     = Int16(lf * Float(Int16.max))

                pcm16[2 * i + 1] = Int16(rf * Float(Int16.max))

            }

            

            // 👉 只负责往缓存里塞

            self.pcmQueue.async {

                self.pcmStereoCache.append(contentsOf: pcm16)

            }

        }

        

        do {

            try engine.start()

            print("🎙 音频采集已启动")

        } catch {

            print("❌ 启动音频引擎失败: \(error)")

            return

        }

        

        // ✅ 启动 10ms 定时器

        startPushTimer()

    }

private func startPushTimer() {

        if pushTimer != nil { return }  // 防止重复创建

        

        let timer = DispatchSource.makeTimerSource(queue: pcmQueue)

        timer.schedule(deadline: .now(),

                       repeating: .milliseconds(10),

                       leeway: .milliseconds(1))

        

        timer.setEventHandler { [weak self] in

            guard let self = self, self.isPushingAudio else { return }

            

            let intsPerFrame = self.rtcSamplesPer10ms * Int(self.rtcChannels) // 每帧 Int16 个数

            

            // 1️⃣ 还没开始推 → 先判断有没有攒够 120ms

            if !self.hasStartedPush {

                let framesForPrebuffer = self.prebufferMs / 10      // 120ms / 10ms = 12 帧

                let needInts = framesForPrebuffer * intsPerFrame    // 12 帧的数据量

                

                if self.pcmStereoCache.count >= needInts {

                    self.hasStartedPush = true

                    print("🚀 预缓冲完成,开始按 10ms 推送")

                } else {

                    // 数据还不够 120ms,先不推,继续攒

                    return

                }

            }

            

            // 2️⃣ 已经开始推 → 每 10ms 推 1 帧

            if self.pcmStereoCache.count >= intsPerFrame {

                var frameSlice = Array(self.pcmStereoCache[0..<intsPerFrame])

                self.pcmStereoCache.removeFirst(intsPerFrame)

                self.pushFrame(int16Frame: &frameSlice)

            } else {

                // ⚠️ 数据暂时不够一帧,可以选择:

                

                // 方案 A:啥也不推,直接 return,会出现短暂断音(简单)

                // return

                

                // 方案 B:补零,保持时间轴连续(推荐)

                var zeros = [Int16](repeating: 0, count: intsPerFrame)

                self.pushFrame(int16Frame: &zeros)

            }

        }

        

        timer.resume()

        pushTimer = timer

    }

    

    private func stopPushTimer() {

        pcmQueue.sync {

            pushTimer?.cancel()

            pushTimer = nil

            pcmStereoCache.removeAll()

            hasStartedPush = false

        }

    }

    

    private func pushFrame(int16Frame: inout [Int16]) {

        let data = int16Frame.withUnsafeBufferPointer { ptr in

            Data(buffer: ptr)

        }

        

        let audioFrame = ByteRTCAudioFrame()

        audioFrame.channel = (rtcChannels == 2) ? .stereo : .mono

        audioFrame.sampleRate = (rtcSampleRate == 48000) ? .rate48000 : .rate16000

        audioFrame.samples = Int32(rtcSamplesPer10ms)   // 每声道 10ms 样本数

        audioFrame.buffer = data                        // 字节数= samples × channels × 2

        

        RTCManager.shared.pushExternalAudioFrame(audioFrame: audioFrame)

    }

创建录音文件路径:(RTC录音文件后缀只支持.wav和.aac)

func getAudioFileURL(convert:String = ".wav") -> URL {

        var documentsPath = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask)[0]

        documentsPath = documentsPath.appendingPathComponent("Caches")

        let fileName = "Voice_\(Int(Date().timeIntervalSince1970*1000))\(convert)"

        return documentsPath.appendingPathComponent(fileName)

    }

// MARK: - 原始音频数据转WAV

func addRecordedBuffers(buffer:AVAudioPCMBuffer){

        // ⭐️ 一定要 copy 一份,否则 AVAudioEngine 会复用 buffer 内存

        guard let copy = AVAudioPCMBuffer(pcmFormat: buffer.format,

                                          frameCapacity: buffer.frameCapacity) else { return }

        copy.frameLength = buffer.frameLength

        

        if let src = buffer.floatChannelData,

           let dst = copy.floatChannelData {

            let channels = Int(buffer.format.channelCount)

            let frames   = Int(buffer.frameLength)

            for ch in 0..<channels {

                memcpy(dst[ch], src[ch], frames * MemoryLayout<Float>.size)

            }

        }

        

        self.recordedBuffers.append(copy)

    }

    

    func mergeBuffers(_ buffers: [AVAudioPCMBuffer]) -> AVAudioPCMBuffer? {

        guard let first = buffers.first else { return nil }

        

        let format = first.format

        let totalFrames = buffers.reduce(0) { $0 + Int($1.frameLength) }

        

        guard let out = AVAudioPCMBuffer(pcmFormat: format,

                                         frameCapacity: AVAudioFrameCount(totalFrames)) else { return nil }

        out.frameLength = AVAudioFrameCount(totalFrames)

        

        let channels = Int(format.channelCount)

        

        for ch in 0..<channels {

            guard let dst = out.floatChannelData?[ch] else { continue }

            var offset = 0

            

            for buf in buffers {

                let frames = Int(buf.frameLength)

                if let src = buf.floatChannelData?[ch] {

                    memcpy(dst + offset, src, frames * MemoryLayout<Float>.size)

                    offset += frames

                }

            }

        }

        

        return out

    }

    /// 从 AVAudioPCMBuffer 生成标准 16-bit PCM WAV

    /// - buffer: 录音合并后的 Float32 PCM

    /// - gain: 音量增益(1.0 不变,1.5 稍微大一点,2.0 更大)

    func makeWavData(from buffer: AVAudioPCMBuffer, gain: Float = 1.0) -> Data {

        let format = buffer.format

        let sampleRate = Int(format.sampleRate)

        let channels = Int(format.channelCount)

        let frameCount = Int(buffer.frameLength)

        

        // 1️⃣ Float32 PCM -> Int16 PCM(同时做音量增益)

        var pcmData = Data()

        pcmData.reserveCapacity(frameCount * channels * 2) // 2 bytes per sample

        

        if let channelData = buffer.floatChannelData {

            for frame in 0..<frameCount {

                for ch in 0..<channels {

                    var v = channelData[ch][frame] * gain

                    // clip 防止爆音

                    if v > 1.0 { v = 1.0 }

                    if v < -1.0 { v = -1.0 }

                    let s = Int16(v * Float(Int16.max)).littleEndian

                    withUnsafeBytes(of: s.littleEndian) { rawBuf in

                        pcmData.append(contentsOf: rawBuf)

                    }

                }

            }

        }

        

        // 2️⃣ 写 WAV 头(16-bit PCM)

        let bitsPerSample = 16

        let byteRate   = sampleRate * channels * (bitsPerSample / 8)

        let blockAlign = channels * (bitsPerSample / 8)

        let dataSize   = pcmData.count

        let fileSize   = 36 + dataSize

        

        var data = Data()

        

        // "RIFF"

        data.append(contentsOf: "RIFF".utf8)

        data.append(UInt32(fileSize).littleEndianData)

        

        // "WAVE"

        data.append(contentsOf: "WAVE".utf8)

        

        // "fmt "

        data.append(contentsOf: "fmt ".utf8)

        data.append(UInt32(16).littleEndianData)              // SubChunk1Size

        data.append(UInt16(1).littleEndianData)               // AudioFormat = 1 (PCM)

        data.append(UInt16(channels).littleEndianData)        // NumChannels

        data.append(UInt32(sampleRate).littleEndianData)      // SampleRate

        data.append(UInt32(byteRate).littleEndianData)        // ByteRate

        data.append(UInt16(blockAlign).littleEndianData)      // BlockAlign

        data.append(UInt16(bitsPerSample).littleEndianData)   // BitsPerSample

        

        // "data"

        data.append(contentsOf: "data".utf8)

        data.append(UInt32(dataSize).littleEndianData)

        

        // PCM 数据

        data.append(pcmData)

        

        return data

    }

//结束录音

func stopAudioCapture() {

        if let engine = self.engine {

            let input = engine.inputNode

            input.removeTap(onBus: 0)

            // 2. 再停 engine

            engine.stop()

        }

        self.audioConverter = nil

        cs_recordingStartTime = nil

        stopPushTimer()

        RTCManager.shared.setAudioSourceType(type: .internal)

        guard let url = audioFilename else {

            print("❌ 没有录音文件路径")

            return

        }

        

        // 1️⃣ 合并所有 buffer 成一条完整的 PCM(Float32)

        guard let fullBuffer = mergeBuffers(recordedBuffers) else {

            print("❌ 没有录音数据")

            return

        }

        recordedBuffers.removeAll()

        

        // 2️⃣ 把合并后的 buffer 转成 WAV Data(内部会做音量增益)

        let wavData = makeWavData(from: fullBuffer, gain: 1.5) // ⭐️ gain 可调

        

        do {

            try wavData.write(to: url)

            print("🎉 WAV 写入成功:\(url.path)")

        } catch {

            print("❌ 写入 WAV 失败:\(error)")

        }

        print("🛑 音频采集已停止")

    }

!!!!!逻辑是采集音频数据。缓存起来,先缓存120ms的数据,后续再按照10ms每次进行RTC推送。针对安卓端个别播放器无法直接播放iOS原始音频数据,后续做了原始音频数据转WAV处理。(音色,音量 都已经调整到比较👌的状态)

!!!!!注意  通过pushExternalAudioFrame方法进行数据插入(注意采集模式的切换 内部采集和自定义采集。需要调用setAudioSourceType方法进行切换)

Logo

火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。

更多推荐