3、音频采集与播放:使用AVAudioEngine进行音频采集、音频播放与混音、音频格式与编码

好,咱们进入第三章。音频采集与播放,这是音视频开发里最基础也最核心的一环。你想想看,不管你是做直播、录播课,还是做个语音聊天App,第一步永远是把声音拿进来,再放出去。AVAudioEngine 就是苹果给咱们准备好的利器。我个人习惯,所有音频相关的项目,第一件事就是先把它玩熟。

3.1 AVAudioEngine 基础架构

AVAudioEngine 说白了就是一个音频节点图。它把各种音频处理单元(节点)串起来,形成一个处理链。核心节点就三个:输入节点(inputNode)、输出节点(outputNode)、主混音器节点(mainMixerNode)。

我刚开始接触时,总觉得这玩意儿很复杂。后来发现,你只要记住一句话:声音从输入节点进来,经过混音器处理,最后从输出节点出去。就这么简单。

核心节点关系:

  • AVAudioInputNode:从麦克风采集音频数据
  • AVAudioOutputNode:将音频数据播放到扬声器或耳机
  • AVAudioMixerNode:混音器,可以调节音量、连接多个输入源

嗯,这里要注意:引擎启动前,必须先把节点连接好。就像搭积木,顺序错了就出不来声。

// 创建引擎
let engine = AVAudioEngine()

// 获取节点
let inputNode = engine.inputNode
let outputNode = engine.outputNode
let mixerNode = engine.mainMixerNode

// 连接节点:输入 -> 混音器 -> 输出
engine.connect(inputNode, to: mixerNode, format: inputNode.outputFormat(forBus: 0))
engine.connect(mixerNode, to: outputNode, format: nil)

// 启动引擎
try engine.start()

这段代码跑起来,你就能听到自己的声音从扬声器里出来了。是不是很简单?但别高兴太早,这里面有个坑——回声。我曾经在项目里直接这么干,结果测试时差点把自己震聋。所以,实际开发中一定要做回声消除,这个后面会讲。

3.2 音频采集实战

音频采集,就是从麦克风拿到原始音频数据。AVAudioEngine 的 inputNode 自带了一个 tap 方法,可以实时回调音频数据。我个人习惯用这个方法来获取 PCM 数据。

// 安装音频采集回调
inputNode.installTap(onBus: 0, bufferSize: 1024, format: inputNode.outputFormat(forBus: 0)) { (buffer, time) in
    // buffer 就是音频数据,类型为 AVAudioPCMBuffer
    // 你可以在这里处理数据,比如写入文件、发送网络等
    let channelData = buffer.floatChannelData?[0]
    let frameLength = Int(buffer.frameLength)
    
    // 举个栗子:计算音量
    var sum: Float = 0.0
    for i in 0..<frameLength {
        sum += channelData?[i] ?? 0.0
    }
    let average = sum / Float(frameLength)
    print("当前音量:\(average)")
}

小技巧:bufferSize 一般设 1024 或 512。设太小回调太频繁,CPU 扛不住;设太大延迟会变高。直播场景我建议 1024,录制场景可以 2048。

为什么用 floatChannelData?因为 AVAudioPCMBuffer 默认是浮点格式。如果你需要整数格式,可以手动转换。我在做语音识别项目时,就踩过这个坑——第三方SDK只认 Int16,结果传了 Float 过去,识别率直接掉到 30%。

3.3 音频播放与混音

播放音频,最直接的方式就是给引擎挂一个音频文件节点。但实际项目中,我们经常需要同时播放多个声音,比如背景音乐 + 人声 + 音效。这时候混音器就派上用场了。

// 创建多个播放器节点
let playerNode1 = AVAudioPlayerNode()
let playerNode2 = AVAudioPlayerNode()

// 连接到混音器
engine.attach(playerNode1)
engine.attach(playerNode2)
engine.connect(playerNode1, to: mixerNode, format: nil)
engine.connect(playerNode2, to: mixerNode, format: nil)

// 加载音频文件
let file1 = try AVAudioFile(forReading: musicURL)
let file2 = try AVAudioFile(forReading: voiceURL)

// 播放
playerNode1.scheduleFile(file1, at: nil)
playerNode2.scheduleFile(file2, at: nil)
playerNode1.play()
playerNode2.play()

// 调节音量
playerNode1.volume = 0.5  // 背景音乐小一点
playerNode2.volume = 1.0  // 人声大一点

混音的核心就是 音量平衡。我记得有一次做直播项目,主播说话声音被背景音乐完全盖住了。排查了半天,发现是混音器里两个节点的音量都没调。后来我加了个自动增益控制(AGC),问题就解决了。

注意:多个播放器同时播放时,一定要确保它们的采样率一致。否则混音器会报错,或者出现奇怪的杂音。我建议统一用 44100Hz 或 48000Hz。

3.4 音频格式与编码

音频格式,说白了就是声音的「编码方式」和「存储结构」。iOS 开发中,你主要会碰到两种格式:

格式 特点 适用场景
PCM(原始音频) 无损、数据量大、处理简单 实时处理、音频编辑
AAC(压缩音频) 有损、数据量小、音质不错 直播推流、文件存储
MP3(压缩音频) 兼容性好、有损 音乐播放、老旧设备

我个人习惯,采集用 PCM,传输用 AAC。为什么?因为 PCM 处理起来最直接,没有编解码延迟。但你要直接拿 PCM 去推流,带宽根本扛不住。所以一般流程是:

  1. 麦克风采集 PCM 数据
  2. 用 AudioConverter 或 AudioCodec 编码成 AAC
  3. 通过网络发送
  4. 接收端解码回 PCM
  5. 播放
// 设置音频格式
let pcmFormat = AVAudioFormat(commonFormat: .pcmFormatFloat32,
                               sampleRate: 44100,
                               channels: 1,
                               interleaved: false)

let aacFormat = AVAudioFormat(commonFormat: .pcmFormatFloat32,
                              sampleRate: 44100,
                              channels: 1,
                              interleaved: false)

// 创建编码器
guard let converter = AVAudioConverter(from: pcmFormat, to: aacFormat) else {
    print("创建编码器失败")
    return
}

// 编码过程(简化)
var error: NSError?
converter.convert(to: outputBuffer, error: &error) { (inNumPackets, outStatus) in
    outStatus.pointee = .haveData
    return inputBuffer
}

避坑指南:我曾经在编码 AAC 时,发现输出数据全是静音。查了两天,最后发现是采样率不匹配——采集是 48000Hz,编码器设成了 44100Hz。所以,格式转换前一定要确认参数一致

3.5 实战:做一个简易录音机

把上面这些知识点串起来,咱们写一个完整的录音功能。这个代码我在好几个项目里复用,基本改改就能用。

class AudioRecorder {
    let engine = AVAudioEngine()
    var audioFile: AVAudioFile?
    
    func startRecording() throws {
        let inputNode = engine.inputNode
        let format = inputNode.outputFormat(forBus: 0)
        
        // 创建音频文件,保存为 CAF 格式(PCM)
        let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
        let fileURL = documentsPath.appendingPathComponent("recording.caf")
        audioFile = try AVAudioFile(forWriting: fileURL, settings: format.settings)
        
        // 安装 tap 并写入文件
        inputNode.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self] (buffer, time) in
            try? self?.audioFile?.write(from: buffer)
        }
        
        // 启动引擎
        engine.prepare()
        try engine.start()
    }
    
    func stopRecording() {
        engine.inputNode.removeTap(onBus: 0)
        engine.stop()
        audioFile = nil
    }
}

这段代码跑起来,你就能在 Documents 目录下找到录音文件了。但注意,这是 PCM 格式,文件会很大。如果你要上传服务器,记得转成 AAC。

3.6 性能优化与常见问题

最后,分享几个我在项目中踩过的坑:

  • 启动引擎失败:检查麦克风权限。iOS 14 以后,权限请求变了,别忘了在 Info.plist 里加 NSMicrophoneUsageDescription。
  • 音频卡顿:tap 回调里别做耗时操作。我见过有人直接在回调里写数据库,结果音频一卡一卡的。正确的做法是把数据丢到串行队列里处理。
  • 回声问题:如果你同时采集和播放,一定要做回声消除。iOS 的 AVAudioEngine 没有内置 AEC,需要自己集成 WebRTC 的 AEC 模块。

总结一下:

  • AVAudioEngine 是音频处理的基石,掌握节点连接是关键
  • 采集用 tap 回调,播放用 playerNode,混音用 mixerNode
  • 格式转换用 AVAudioConverter,注意参数一致性
  • 性能优化:别在主线程做音频处理,权限检查别忘

嗯,这一章的内容就到这里。音频这块东西很多,但核心就是「采集-处理-播放」这条链。你只要把 AVAudioEngine 玩透了,后面做直播、录播、语音聊天都会轻松很多。下一章咱们聊聊视频采集,那又是另一个世界了。