iOS 火山引擎RTC自定义采集(音频采集)
开始采集:
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方法进行切换)
火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。
更多推荐
所有评论(0)