麦克风把声音变成 PCM,扬声器把 PCM 变回声音。对一个实时通话系统来说,这两句话之间还有很多事情:选择设备、协商格式、启动音频流、适应设备回调的节奏,以及在耳机拔出、来电中断后恢复工作。

WebRTC 把这些工作集中在 Audio Device Module,简称 ADM。理解它,最好先建立整体位置,再沿着音频数据走一遍,最后进入代码和平台实现。否则很容易把“设备缓冲”“网络抖动缓冲”和“回声消除”混成一件事。

本文以本地 webrtc_src 的提交 3dec7d05377fb690e84f503db865edde982ab2a5 为源码基线,提交日期为 2026-10-23。正文主要讨论使用内置设备模块的 native 音频路径;浏览器或业务方可以注入自己的 ADM,复用这里的接口而采用不同的数据通路。文中的源码链接固定到该提交,代码片段会标明是否经过简化。

一、整体视角:ADM 是 WebRTC 与操作系统音频 I/O 的边界

1.1 它在整个系统的什么位置

可以把 WebRTC 的音频部分分成三个层次:最下面连接操作系统和设备,中间处理 PCM,上面处理编码后的数据和网络传输。

flowchart TB APP["应用与 PeerConnection:通话、轨道和配置"] NET["网络传输:RTP / RTCP / 安全传输"] CODEC["编解码与接收缓冲:编码器、解码器、NetEq"] PCM["PCM 处理:APM、发送分发、接收混音"] ADM["ADM:设备控制与双向 PCM 交接"] OS["操作系统音频 API 与驱动"] HW["麦克风、扬声器、耳机"] APP -.->|"配置与生命周期"| ADM APP -.-> CODEC NET <-->|"压缩音频"| CODEC CODEC <-->|"PCM"| PCM PCM <-->|"PCM 与设备侧时序信息"| ADM ADM <--> OS OS <--> HW

这张图表达的是职责边界,不代表所有对象都按这个顺序相互持有。

从上向下看,ADM 提供“准备并启停输入输出、获取和交付 PCM”的能力;从下向上看,它把不同系统的音频 API 适配成 WebRTC 能使用的接口。上层不必分别理解 WASAPI、CoreAudio、AudioRecord 或 AudioUnit。

1.2 ADM 管理哪些事情

职责解决的问题典型例子
设备与能力管理用哪个输入输出,支持什么能力枚举设备、选择默认设备、查询立体声和音量能力
生命周期管理什么时候准备、开始、停止 I/O初始化模块,准备录音流,启动采集,停止播放
PCM 交接与适配如何把系统缓冲交给 WebRTC格式转换、缓冲拼接、交付采集 PCM、请求播放 PCM
设备侧时序与状态音频在设备侧停留多久,流是否正常播放/采集延迟、可选采集时间戳、欠载或中断处理

这里的“ADM”有两种常见含义:狭义上指公共的 AudioDeviceModule 接口;广义上指接口实现、平台后端和缓冲适配组成的整个设备模块。后面说“ADM 适配设备回调”时,通常采用广义含义。

ADM 交付的是 PCM。降噪、回声消除等算法主要属于 APM(Audio Processing Module);网络包的乱序、抖动与丢包补偿属于接收侧 NetEq;编码器负责把 PCM 压缩成待发送的音频。某些平台后端可以启用系统语音处理,但这不改变这些职责边界。

二、数据流视角:一条向上推,一条向下拉

这一节暂时忽略平台差异,只看各平台共同需要完成的事情。

2.1 采集数据:由麦克风驱动,向发送链路推送

用户说话时,操作系统不断提供新采集的音频。设备模块整理这些数据,向上层报告“这一段录音已经可用”,上层再进行处理和发送。

flowchart TB MIC["麦克风 / 系统输入缓冲"] CAP["ADM:读取、格式适配、整理音频块"] TRANS["音频运输桥接:接收采集 PCM"] APM["APM:回声消除、降噪、增益等"] DIST["向各个发送流分发处理后的 PCM"] ENC["编码队列与编码器"] RTP["RTP 发送链路"] MIC -->|"新采集的数据"| CAP CAP -->|"PCM、格式、延迟、可选时间戳"| TRANS TRANS --> APM --> DIST --> ENC --> RTP

这是一种 push 模型:采集端产生数据,主动交付给上层。设备不会等编码器询问“你有没有新录音”。

在本文的默认路径中,同一份采集数据先经过公共 APM 处理,再向已注册的发送流分发;各发送流拥有自己的编码与发送过程。因此,“有多个发送流”不意味着每个流都重新打开一个麦克风。

2.2 播放数据:由设备需求驱动,向接收链路拉取

播放的起点容易理解反了:网络包到达时,并不直接写进扬声器。网络到包是有抖动的,而扬声器需要按设备时钟持续消费音频。

接收链路先把到来的压缩音频放入 NetEq;当设备需要下一段播放数据时,再由播放调用链拉取音频、完成接收处理并混音。

flowchart TB PKT["远端 RTP 到包"] NQ["各接收流的 NetEq:缓冲、按需解码与丢包补偿"] SRC["各接收流提供 PCM"] MIX["混音器:合成当前要播放的音频"] OUT["音频运输桥接:播放侧处理与重采样"] DEV["ADM:适配系统输出缓冲"] SPK["系统输出 / 扬声器"] PKT -->|"压缩载荷"| NQ NQ -->|"被拉取时输出 PCM"| SRC SRC --> MIX --> OUT --> DEV --> SPK DEV -.->|"设备需要音频,向上请求"| OUT OUT -.->|"调用混音"| MIX MIX -.->|"逐个请求接收流"| SRC SRC -.->|"请求下一段音频"| NQ

实线表示数据的交付方向,虚线表示播放需求的传播方向。播放数据向设备流动,播放请求向接收链路传播。

这里的 pull 模型 是逻辑关系:有的平台由系统回调直接触发拉取,有的平台由后端工作线程提前填充本地缓冲。共同点是播放节奏最终受设备消费速度约束,而不是受网络到包时刻支配。

2.3 为什么采集与播放还会在 APM 相遇

假设扬声器播放远端声音,麦克风又把它录了进去。若原样发送,远端就会听到自己的回声。AEC(Acoustic Echo Cancellation,声学回声消除)需要同时观察两类信号:

  • 采集信号:麦克风录下的近端语音、噪声和回声。
  • 播放参考信号:本机准备播放的远端音频。
flowchart TB MIX["远端音频混音结果"] REF["APM 播放参考:reverse stream"] SPK["设备输出 → 扬声器"] ROOM["房间中的声学传播"] MIC["麦克风采集:近端语音 + 回声 + 噪声"] PROC["APM 采集处理:capture stream"] SEND["处理后的语音 → 发送编码"] MIX --> SPK --> ROOM --> MIC --> PROC --> SEND MIX --> REF REF -.->|"AEC 参考"| PROC DELAY["ADM 提供设备侧延迟估计"] -.-> PROC

因此,播放链路除了把音频送到设备,还会把混音结果送给 APM 的 reverse stream。采集侧再把设备侧延迟传给 APM,辅助对齐参考与录音。

这里的参考是设备输出之前的数字音频,并非从扬声器重新录回来的声音;ADM 提供的延迟也只是播放和采集的设备侧估计,不包含全部网络、NetEq 和编解码延迟。

2.4 共同的数据契约:16-bit PCM 与 10 ms

在本文所讨论的 AudioDeviceBuffer → AudioTransportImpl 路径中,上下层主要交换 16-bit、交错存储的 PCM,每次处理 10 ms。交错双声道的排列是 L0, R0, L1, R1, ...。

以 48 kHz 为例:

概念单声道的 10 ms双声道的 10 ms
每声道样本数 / PCM frame 数480480
所有声道合计的标量样本数480960
数据字节数,S169601920

这里一个 PCM frame 包含同一时刻所有声道的样本;它与源码中表示一整段音频的 AudioFrame 对象是两个概念。换成 16 kHz,10 ms 就是每声道 160 个样本,因此 10 ms 也不等于固定的 480 个样本。

10 ms 是逻辑处理粒度,设备回调周期和编码包时长可以不同。 设备可能每次给 128、192 或其他数量的 PCM frame;编码器也可能积累两个 10 ms 输入,才产出一个 20 ms 的编码包。

以“48 kHz、单声道、每次设备回调 192 frame”为例,采集适配的过程可以是:

flowchart TB C1["回调 1:192 frame"] --> B1["累计 192:暂不交付"] B1 --> C2["回调 2:再来 192"] C2 --> B2["累计 384:暂不交付"] B2 --> C3["回调 3:再来 192"] C3 --> B3["累计 576"] B3 --> U["向上交付 480 frame = 10 ms"] B3 --> R["保留 96 frame,供下一轮拼接"]

播放侧执行相反操作:先按 10 ms 向上层取数,再按设备本次需要的大小切出,保留余量。FineAudioBuffer 就是完成这种块长适配的工具之一;另一些后端自己实现累计、拆分或环形缓冲。

三、源码视角:对象如何连接,方法如何调用

3.1 先分清六个核心角色

对象 / 接口主要责任需要避免的误解
AudioDeviceModule面向上层的设备公共接口它不等于某一个平台实现类
平台后端调用 OS API、操作设备流、完成本地缓冲适配各平台的线程和缓冲结构可以不同
AudioDeviceBuffer,ADB保存 PCM、采样率、声道、延迟等,调用运输回调它不会自动把任意输入拼成 10 ms
FineAudioBuffer在任意设备块长与 10 ms 之间拆分、拼接它既不做网络抖动控制,也不负责重采样
AudioTransport / AudioTransportImpl前者定义双向 PCM 回调,后者连接 APM、发送流和混音器这里的 Transport 不是网络传输
AudioState协调 ADM、APM、混音器和收发流的公共状态音频 PCM 回调实际由其内部的 AudioTransportImpl 承接

以通用 AudioDeviceModuleImpl 路径为例,对象关系如下。节点中合并标出接口与实现,箭头表示持有或调用关系,不表示单向音频流。

flowchart TB VE["WebRtcVoiceEngine"] STATE["AudioState"] IMPL["AudioDeviceModule 接口<br/>实现示例:AudioDeviceModuleImpl"] BACK["平台后端<br/>AudioDeviceGeneric 的实现"] ADB["AudioDeviceBuffer"] FINE["FineAudioBuffer<br/>部分后端使用"] TRANS["AudioTransportImpl<br/>实现 AudioTransport 接口"] APM["AudioProcessing"] MIX["AudioMixer"] SEND["AudioSender / AudioSendStream"] VE -->|"持有"| STATE VE -->|"持有 ADM"| IMPL STATE -.->|"配置中共享 ADM"| IMPL STATE -->|"内含"| TRANS IMPL -->|"持有"| BACK IMPL -->|"内含"| ADB BACK -->|"直接使用或经适配"| ADB BACK --> FINE --> ADB ADB -->|"注册的回调指针"| TRANS TRANS -->|"调用"| APM TRANS -->|"拉取"| MIX TRANS -->|"分发"| SEND

这张图只展开一种 ADM 的内部组织。Android 的组合式 ADM、iOS SDK 的 AudioDeviceModuleIOS、Windows 的另一套 Core Audio ADM,都可以直接实现同一个公共接口,不需要继承 AudioDeviceModuleImpl。

对应源码:ADM 公共接口、通用 ADM 实现、AudioState。

3.2 建立连接:后端接入 ADB,再注册运输回调

第一处连接发生在设备模块内部。通用工厂创建后端后,调用:

1
2
// AudioDeviceModuleImpl::AttachAudioBuffer(),保留核心语句。
audio_device_->AttachAudioBuffer(&audio_device_buffer_);

这让平台后端知道应该向哪个 ADB 交付录音、从哪个 ADB 获取播放数据。

第二处连接发生在设备模块与音频引擎之间。WebRtcVoiceEngine::Init() 完成设备初始化和选项配置后,执行:

1
adm()->RegisterAudioCallback(audio_state()->audio_transport());

audio_transport() 返回内部 AudioTransportImpl 的接口指针;通用 ADM 再把这个指针交给 AudioDeviceBuffer::RegisterAudioCallback()。至此,后端 ↔ ADB ↔ AudioTransportImpl 才连通。

AudioTransport 是上层实现、下层调用的接口。尤其要注意:NeedMorePlayData() 虽然是“回调”,行为却是同步请求,上层要在返回前填好输出 PCM。

初始化路径见 WebRtcVoiceEngine::Init 和 adm_helpers::Init。允许内部设备的构建中,未注入 ADM 时会尝试创建平台默认实现;调用方也可以提供自己的 ADM。

3.3 控制调用链:初始化与真正运行分开

Init()、InitMicrophone() 和 StartRecording() 不是同一件事。公共接口把模块初始化、设备控制能力准备、I/O 流准备与运行分开;各平台实际创建资源的时机可能有所不同。

阶段录音侧的代表方法播放侧的代表方法
模块初始化Init()同一个 Init()
选择设备、准备控制能力SetRecordingDevice()、InitMicrophone()SetPlayoutDevice()、InitSpeaker()
准备 I/O 流InitRecording()InitPlayout()
开始 / 停止数据流StartRecording() / StopRecording()StartPlayout() / StopPlayout()

在 AudioState 中,运行还与收发流是否存在、录音/播放开关是否启用有关:

flowchart TB INIT["引擎 Init<br/>ADM 初始化、设备与能力配置、注册回调"] INIT --> SEND["加入发送流 / 启用录音"] INIT --> RECV["加入接收流 / 启用播放"] SEND --> SC{"有发送流且录音已启用?"} RECV --> RC{"有接收流且播放已启用?"} SC -->|"是,且尚未录音"| SI["InitRecording → StartRecording"] RC -->|"是,且尚未播放"| RI["InitPlayout → StartPlayout"] SI --> SS["最后一个发送流移除 / 关闭录音<br/>StopRecording"] RI --> RS["最后一个接收流移除 / 关闭播放<br/>StopPlayout"]

发送流注册时,AudioState::UpdateAudioTransportWithSendingStreams() 还会更新 AudioTransportImpl 中的发送者列表及所需采样率、声道数;接收流注册时,则通过 AudioMixer::AddSource(stream->source()) 加入混音器。生命周期控制和 PCM 流动由此衔接起来。

另一个细节是:当接收流仍存在、只是 AudioState 的 playout 开关被关闭时,UpdateNullAudioPollerState() 会启用 10 ms 定时拉取,让接收音频继续被消费。这和把接收流自身停止是不同的操作,不能把所有“静音”都理解成同一种状态。

3.4 采集调用链上半段:后端 → ADB → APM

无论系统通过回调、事件还是读线程提供录音,后端最终需要把合适大小的 PCM 交给 ADB。以下为调用顺序示意,块长适配已由后端或 FineAudioBuffer 完成:

1
2
3
4
// 伪代码:每次交付 10 ms,每声道样本数为 sample_rate / 100。
adb->SetRecordedBuffer(pcm, samples_per_channel, capture_timestamp_ns);
adb->SetVQEData(playout_delay_ms, recording_delay_ms);
adb->DeliverRecordedData();

这三步的作用分别是:复制 PCM 到 rec_buffer_;保存播放与采集延迟;通过已注册的 AudioTransport 把 PCM 和元数据交给上层。SetRecordedBuffer() 本身只保存本次给它的数据,不负责累计不足 10 ms 的输入。

DeliverRecordedData() 的关键调用可概括为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 简化自 AudioDeviceBuffer::DeliverRecordedData(),省略错误处理。
const size_t frames = rec_buffer_.size() / rec_channels_;
const size_t bytes_per_frame = rec_channels_ * sizeof(int16_t);
uint32_t ignored_new_mic_level = 0;

audio_transport_cb_->RecordedDataIsAvailable(
    rec_buffer_.data(), frames, bytes_per_frame,
    rec_channels_, rec_sample_rate_,
    play_delay_ms_ + rec_delay_ms_,
    0, 0, typing_status_, ignored_new_mic_level,
    capture_timestamp_ns_);

接口中的参数名 nBytesPerSample 容易误导:在这条 ADB 调用路径里,传入的是每个 PCM frame 的字节数,即 声道数 × sizeof(int16_t)。双声道为 4,不能直接按“一个标量样本 2 字节”去填。AudioTransportImpl 中也有相应的检查。

进入 AudioTransportImpl::RecordedDataIsAvailable() 后,主要顺序是:

  1. 检查格式和长度,包括 number_of_frames * 100 == sample_rate,明确要求本次为 10 ms。
  2. InitializeCaptureFrame() 选择处理格式,voe::RemixAndResample() 调整声道与采样率。
  3. ProcessCaptureFrame() 传入设备延迟和按键状态,并调用 ProcessAudioFrame()。
  4. ProcessAudioFrame() 转到 AudioProcessing::ProcessStream(),执行已配置的采集处理。
  5. 设置可选的绝对采集时间,之后直接或经可选异步处理器进入 SendProcessedData()。

如果后端提供采集时间戳,ADB 会利用 TimestampAligner 将它映射到 WebRTC 使用的时钟域;没有时间戳时,接口允许传入 std::nullopt。不要因为参数中还有 clockDrift、currentMicLevel 等历史字段,就假定当前 ADB 会提供有效数值:上面的实现对它们传入的是 0。

实现见 AudioDeviceBuffer、AudioTransportImpl 和 APM 的 AudioFrame 适配函数。

3.5 采集调用链下半段:发送流 → 编码队列 → RTP

AudioTransportImpl::SendProcessedData() 遍历已注册的 AudioSender,调用各发送流的 SendAudioData()。多个发送流需要数据时,为其中的流复制 AudioFrame,最后把原始对象移动给一个流。

%%{init: {"sequence": {"wrap": true, "width": 90, "actorMargin": 12, "diagramMarginX": 8, "mirrorActors": false}}}%% sequenceDiagram participant D as 后端 participant B as ADB participant T as Transport participant P as APM participant S as 发送链路 participant Q as 编码队列 D->>B: SetRecordedBuffer<br/>SetVQEData D->>B: DeliverRecordedData B->>T: RecordedDataIsAvailable T->>T: RemixAndResample T->>P: ProcessStream P-->>T: 处理后的 PCM Note over T,S: 默认同步路径;可配置额外异步处理 T->>S: SendProcessedData<br/>→ SendAudioData S->>S: ProcessAndEncodeAudio S-)Q: PostTask:移交 AudioFrame Q->>Q: Add10MsData<br/>→ Encode opt 编码器已积累足够数据 Q->>S: AudioPacketizationCallback::SendData S->>S: RTPSenderAudio::SendAudio<br/>→ RTP 发送链路 end

图中 Transport 指 AudioTransportImpl,“发送链路”合并表示 AudioSendStream 和 ChannelSend。源码中的主链可按下面的顺序阅读:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
AudioTransportImpl::SendProcessedData
  → AudioSendStream::SendAudioData
  → ChannelSend::ProcessAndEncodeAudio
      → encoder_queue_->PostTask(...)
          → AudioCodingModuleImpl::Add10MsData
          → AudioCodingModuleImpl::Encode
          → encoder_stack_->Encode
          → AudioPacketizationCallback::SendData
              → ChannelSend::SendData
              → RTPSenderAudio::SendAudio
              → 后续 RTP 发送链路

明确的线程交接发生在 ChannelSend 投递编码任务时。 默认同步路径中的格式调整、APM 以及发送流分发,会先在调用采集交付函数的线程上执行;启用额外异步处理时,中间还会增加交接点。

另外,Add10MsData() 每次接受 10 ms,并不保证每次都回调 SendData()。编码器是否已经攒够一个包、是否启用 DTX 等都会影响输出。把设备帧、APM 处理帧和 RTP 包时长分开看,才能正确分析延迟。

对应源码:AudioSendStream、ChannelSend、AudioCodingModuleImpl。

3.6 播放调用链:设备请求 → 混音器 → 接收流 → NetEq

先看网络到包这一侧。ChannelReceive::OnRtpPacket() 解析和处理 RTP,在接收流处于播放状态时,经过 OnReceivedPayloadData() 把载荷插入 NetEq::InsertPacket()。这一步提供的是压缩音频,不会直接触发设备播放。

真正消费音频的入口在设备侧。后端执行:

1
2
3
// 伪代码:后端已经确定采样率、声道与 10 ms 块长。
adb->RequestPlayoutData(samples_per_channel);  // 向上层请求并保存 PCM。
adb->GetPlayoutData(device_buffer);           // 复制到后端提供的缓冲。

RequestPlayoutData() 同步调用 AudioTransport::NeedMorePlayData()。进入 AudioTransportImpl 后,它先调用混音器;混音器逐个向接收流请求音频;各流最终向自己的 NetEq 取出下一段 10 ms PCM。

%%{init: {"sequence": {"wrap": true, "width": 80, "actorMargin": 10, "diagramMarginX": 8, "mirrorActors": false}}}%% sequenceDiagram participant D as 后端 participant B as ADB participant T as Transport participant M as Mixer participant R as 接收链路 participant N as NetEq participant P as APM Note over R,N: 网络到包独立执行 InsertPacket D->>B: RequestPlayoutData B->>T: NeedMorePlayData T->>M: Mix loop 每个已注册的接收源 M->>R: GetAudioFrameWithInfo R->>N: GetAudio N-->>R: 下一段 10 ms PCM R-->>M: 接收处理后的 AudioFrame end M-->>T: 混音结果 T->>P: ProcessReverseAudioFrame → ProcessReverseStream T->>T: Resample 到设备请求的采样率 T-->>B: 填好播放 PCM 与输出长度 B-->>D: RequestPlayoutData 返回 D->>B: GetPlayoutData B-->>D: 复制 PCM 到设备侧缓冲

图中 Transport 指 AudioTransportImpl,Mixer 指 AudioMixer,“接收链路”合并表示接收流和 ChannelReceive。展开方法名之后,主链是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
平台输出回调 / 后端播放工作线程
  → [可选 FineAudioBuffer::GetPlayoutData]
  → AudioDeviceBuffer::RequestPlayoutData
  → AudioTransportImpl::NeedMorePlayData
      → AudioMixerImpl::Mix
          → AudioMixerImpl::GetAudioFromSources
          → AudioReceiveStreamImpl::GetAudioFrameWithInfo
          → ChannelReceive::GetAudioFrameWithInfo
          → NetEq::GetAudio
      ← 各接收源的 PCM 经混音后返回
      → ProcessReverseAudioFrame
          → AudioProcessing::ProcessReverseStream
      → Resample
  ← PCM 写入 ADB 的 play_buffer_
  → AudioDeviceBuffer::GetPlayoutData
  → 后端缓冲 / 格式适配 → OS 输出

这里有三个关键点:

  • NetEq 是由播放需求拉动的。 它结合已有包和当前状态生成音频,可能解码新包、使用已解码的缓存,也可能执行丢包补偿;不是每次 GetAudio() 都恰好解码一个网络包。
  • 混音之后才进入 APM 播放参考路径。 AEC 需要观察本机准备输出的合成声音,而不只是某一条远端流。
  • 重采样可能不止一次。 接收流先适配混音器要求的格式,混音结果再适配设备要求的采样率;设备侧仍可能存在原生格式转换。

对应源码:混音器、AudioReceiveStreamImpl、ChannelReceive。

AudioTransport 还定义了 PullRenderData()。本基线中的注释说明,Chromium 使用这个入口并拥有自己的 APM,因此该方法没有在内部调用 ProcessReverseStream();采集侧 RecordedDataIsAvailable() 的默认处理路径也注明不用于 Chromium。分析 Chrome 时,应继续追踪浏览器自己的采集与处理装配,不能把上面的 native 调用链直接套过去。

3.7 ADB、FineAudioBuffer 与 NetEq:三种缓冲分别解决什么

组件缓冲的是什么面对哪种差异典型行为
AudioDeviceBuffer本次采集 / 播放的 PCM后端与运输回调的交接保存 PCM、格式和元数据,调用双向接口
FineAudioBuffer设备块与 10 ms 块之间的 PCM 余量本地设备回调块长不一致采集攒够 10 ms 才交付,播放取 10 ms 后按需切出
NetEq接收包及解码、时伸缩等所需的音频状态网络到包与稳定播放之间的时间差抖动控制、按需解码、丢包补偿

FineAudioBuffer::DeliverRecordedData() 中的循环体现了这个区别:追加本次设备数据,只要缓存达到一个 10 ms 块,就调用 ADB 的 SetRecordedBuffer()、SetVQEData()、DeliverRecordedData(),剩余数据保留到下次。

反向的 GetPlayoutData() 则不断请求 10 ms,直到足够满足设备这次的需求,然后复制所需部分并保留余量。它会缓存后端传入的播放延迟,供后续采集交付时一起上报。完整实现见 fine_audio_buffer.cc。

四、平台视角:共用 PCM 边界,底层采用不同的节奏

4.1 差异集中在哪几层

上层的 AudioTransport 契约可以复用,但设备 API、后端组织、回调线程、设备路由和系统音效能力都存在差异。

flowchart LR T["共同上层<br/>AudioTransport / APM<br/>混音 / 收发流"] B["AudioDeviceBuffer<br/>共同的 PCM 交接点"] T <--> B B <--> W["Windows<br/>WASAPI 事件与线程"] B <--> M["macOS<br/>HAL、IOProc、环形缓冲与 worker"] B <--> L["Linux<br/>PulseAudio 或 ALSA"] B <--> A["Android<br/>Java / OpenSL ES / AAudio"] B <--> I["iOS<br/>AVAudioSession 与 VoiceProcessingIO"]
平台 / 路径系统 I/O 方式块长适配的主要位置设备选择 / 路由
Windows 传统 Core Audio 后端WASAPI 共享模式,事件唤醒采集/播放线程后端的同步缓冲与 10 ms 循环endpoint 枚举、设备 ID、默认设备角色
macOSCoreAudio HAL 的 IOProc环形缓冲、工作线程与 AudioConverterHAL 设备及属性监听
Linux PulseAudio服务端音频流、threaded mainloop 与后端线程后端按 10 ms 整理,与服务端缓冲衔接Pulse server 的 source / sink
Linux ALSAPCM 读写与等待后端读写线程、本地缓冲ALSA PCM 设备
Android JavaAudioRecord / AudioTrack 高优先级线程Java/JNI 按 10 ms 交接Android 系统与应用的路由管理
Android OpenSL ES / AAudiobuffer queue / data callbackFineAudioBuffer受系统路由和具体后端能力影响
iOSVoiceProcessingIO AudioUnit 回调FineAudioBufferAVAudioSession

以下“默认”都指这份源码及对应构建条件下的选择。SDK 的创建入口、应用注入的 ADM 和构建开关,都会影响真正使用的后端。

4.2 Windows:WASAPI 事件驱动,注意两套 ADM 入口

通用 AudioDeviceModuleImpl 的 Windows 默认路径使用 AudioDeviceWindowsCore。它主要通过以下对象工作:

  • IMMDevice 等接口枚举和取得输入/输出 endpoint。
  • IAudioClient 协商格式并创建共享模式流。
  • IAudioCaptureClient 读取采集包,IAudioRenderClient 写入输出缓冲。
  • 采集与播放各有事件等待和工作线程,并使用 MMCSS 改善调度。

播放线程的主过程是等待事件,通过 GetCurrentPadding() 确定输出缓冲还能容纳多少 frame,再按 10 ms 向 ADB 请求 PCM,最后提交到 WASAPI。采集线程从 GetBuffer() 取得数据,放入同步缓冲,凑足 10 ms 后交给 ADB。

因此,即使一次系统事件对应的可读写数据不是 10 ms 的整倍数,上层仍可按 10 ms 工作。格式也由设备探测与协商确定,不能假定任何端点都接受同一个采样率和声道组合。

本基线还保留另一套 kWindowsCoreAudio2 路径,通过 CoreAudioInput / CoreAudioOutput 组合成 ADM。它需要调用专用工厂 CreateWindowsCoreAudioAudioDeviceModule();直接把这个枚举值传给通用 ADM 工厂会被拒绝。专用入口要求正确初始化 COM MTA,并提供 automatic_restart 参数控制断连后的自动重启。

源码入口:audio_device_core_win.cc、专用工厂与约束、组合式 Windows ADM。

4.3 macOS:IOProc 与 WebRTC 处理之间隔着环形缓冲

AudioDeviceMac 直接使用 CoreAudio HAL。系统在 IOProc 中提供输入、请求输出,而 WebRTC 上层的调用主要发生在后端工作线程中。环形缓冲把两边连接起来。

flowchart TB subgraph CAP["采集方向"] CI["CoreAudio 输入 IOProc"] --> CR["输入环形缓冲"] CR --> CW["CaptureWorkerThread / AudioConverter"] CW --> CB["ADB:交付 10 ms"] end subgraph PLAY["播放方向"] PB["ADB:请求 10 ms"] --> PW["RenderWorkerThread"] PW --> PR["输出环形缓冲"] PR --> PO["CoreAudio 输出 IOProc / 格式转换"] end

这样做可以把上层处理与 IOProc 的实时节奏隔开。播放 worker 观察环形缓冲的可写空间,持续补充音频;采集 worker 从输入缓冲取数并做格式转换,再交付 ADB。它增加了本地缓冲,也需要在延迟估计中考虑这些缓冲。

当前后端把 ADB 的默认格式设为采集 48 kHz 单声道、播放 48 kHz 双声道,并结合设备能力和配置调整声道;设备原生格式与内部格式之间由 AudioConverter 等逻辑适配。后端还监听设备、格式等属性变化。

macOS 和 iOS 都属于 Apple 平台,但这份 macOS 实现采用 HAL、IOProc 与 worker 的组织方式,不能照搬下一节 iOS 的 AudioUnit 模型。源码见 AudioDeviceMac。

4.4 Linux:PulseAudio 服务端路径与 ALSA PCM 路径

PulseAudio 路径。 当构建启用 WEBRTC_ENABLE_LINUX_PULSE 时,通用 ADM 的 kPlatformDefaultAudio 选择 AudioDeviceLinuxPulse。后端加载 libpulse,创建 pa_threaded_mainloop、pa_context,连接服务端,再建立采集与播放的 pa_stream。读写回调与后端音频线程协作,完成本地 10 ms PCM 和服务端缓冲之间的交换。

设备列表与默认 source / sink 来自服务端,因此系统混音和路由也是时序与设备行为的一部分。

ALSA 路径。 未启用 PulseAudio 时,平台默认走 ALSA;启用 PulseAudio 的构建也可显式选择 kLinuxAlsaAudio。AudioDeviceLinuxALSA 通过 snd_pcm_readi()、snd_pcm_writei() 读写 PCM,配合 snd_pcm_wait() 等待,以及 snd_pcm_delay() 获取延迟信息。

两条路径都要处理“暂时没有足够数据”和异常恢复。ALSA 的 ErrorRecovery() 会针对 underrun 等错误恢复 PCM 状态;PulseAudio 后端通过服务端流状态和缓冲回调协调。它们共同向 ADB 提供 10 ms 音频,但缓冲层次、设备命名和路由来源不同。

源码见 PulseAudio 后端、ALSA 后端。这里列的是 WebRTC 后端使用的 API,不能仅凭桌面系统采用了什么音频服务,就推断源码中存在同名的直接后端。

4.5 Android:输入与输出可以采用不同 API

Android 需要先问清楚“通过哪个工厂、选择哪种 AudioLayer 创建 ADM”。native API 的 CreateAndroidAudioDeviceModule() 支持这些组合:

AudioLayer采集端播放端设计考虑
kAndroidJavaAudioAudioRecordAudioTrackJava API 路径
kAndroidOpenSLESAudioOpenSL ESOpenSL ESnative buffer queue 路径
kAndroidJavaInputAndOpenSLESOutputAudioAudioRecordOpenSL ESJava 输入音效能力与 native 输出结合
kAndroidAAudioAudioAAudioAAudioAAudio data callback 路径,需构建支持
kAndroidJavaInputAndAAudioOutputAudioAudioRecordAAudioJava 输入与 AAudio 输出组合,需构建支持

在这个工厂中,请求 kPlatformDefaultAudio 时,若定义了 WEBRTC_AUDIO_DEVICE_INCLUDE_ANDROID_AAUDIO,选择 AAudio 双向路径;否则根据系统报告的低延迟输入/输出能力,在 OpenSL ES、混合路径和 Java 路径之间选择。这是该工厂的选择逻辑,不代表所有 Android SDK 接入都采用同样的默认值。

Java 路径的数据交接很适合作为一个具体例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
采集:WebRtcAudioRecord 的录音线程
  → AudioRecord.read 写入 direct ByteBuffer
  → JNI → AudioRecordJni::DataIsRecorded
  → ADB::SetRecordedBuffer / SetVQEData / DeliverRecordedData

播放:WebRtcAudioTrack 的播放线程
  → JNI → AudioTrackJni::GetPlayoutData
  → ADB::RequestPlayoutData / GetPlayoutData
  → PCM 写入 direct ByteBuffer
  → AudioTrack.write

direct ByteBuffer 让 JNI 可以访问已缓存的内存地址,减少 Java 与 C++ 交接的额外复制;不过 ADB 自身的保存和取数仍有复制,不能把整条链路理解为零拷贝。

OpenSL ES 使用缓冲队列回调,AAudio 使用带 num_frames 的 data callback。它们通过 FineAudioBuffer 衔接设备块长与 10 ms。AAudio 播放端还会观察 xrun:欠载次数增加时,扩大一个 burst 的输出缓冲,以延迟换稳定性;断连时把重启工作投递到另一线程,而不是在错误回调中直接关闭、重开流。

另一项差异是设备管理:这份组合式 Android ADM 的枚举接口返回一个逻辑输入和一个逻辑输出,不能据此得到全部手机麦克风、蓝牙和扬声器列表。路由需要结合 Android 系统音频管理与应用配置处理。Java 输入还可能使用系统 AEC/NS/AGC,启用时应与 APM 选项协调。

源码入口:Android 工厂与选择逻辑、AndroidAudioDeviceModule、AudioRecordJni、AudioTrackJni、AAudioPlayer。

4.6 iOS:音频会话决定环境,一个 AudioUnit 承担双向 I/O

iOS 的核心设备实现是 AudioDeviceIOS,SDK 可以通过 AudioDeviceModuleIOS 包装它。控制环境主要来自 AVAudioSession,实际 I/O 使用 Voice-Processing I/O AudioUnit。

它与前面的独立输入/输出线程模型有一个明显区别:同一个全双工 AudioUnit 组织录音与播放,系统语音处理可以同时观察两侧信号。默认创建路径不旁路 voice processing;SDK 也提供 bypass_voice_processing 配置,因此应以实际创建选项判断系统处理是否启用。

flowchart TB SESSION["AVAudioSession:路由、激活、中断、实际格式"] UNIT["VoiceProcessingIO AudioUnit:双向 I/O"] IN["输入回调:AudioUnitRender 获取 PCM"] OUT["输出回调:填充待播放 PCM"] FINE["FineAudioBuffer:设备块长 ↔ 10 ms"] ADB["AudioDeviceBuffer"] SESSION -.->|"配置与状态变化"| UNIT UNIT --> IN --> FINE FINE --> OUT --> UNIT FINE <--> ADB

初始化时要区分偏好参数与真实参数:Init() 先保存偏好采样率和声道;真正准备 I/O、激活 session 后,SetupAudioBuffersForActiveAudioSession() 再读取实际 sampleRate 和 IOBufferDuration,更新 ADB,并重建 FineAudioBuffer。

具体的采集入口是 AudioDeviceIOS::OnDeliverRecordedData():它调用 AudioUnit 的 render 取得输入,再通过 FineAudioBuffer::DeliverRecordedData() 累积交付。播放入口是 OnGetPlayoutData():它通过 FineAudioBuffer::GetPlayoutData() 取得当前回调需要的 PCM,不播放时填零。

耳机路由变化、电话中断和采样率变化,都可能要求重新配置。后端把相关事件转交到拥有对象的线程,在需要时停止 AudioUnit、反初始化、读取新的 session 参数、重建缓冲并恢复运行。这里的设备选择主要属于 session 路由管理,不能套用桌面 ADM 的 index/GUID 操作方式。

源码入口:iOS SDK 工厂、AudioDeviceIOS、VoiceProcessingAudioUnit。

4.7 哪些能力不能假定跨平台一致

问题共同接口背后的实际差异
能否枚举并选择任意设备?桌面后端一般支持设备枚举;移动端更多依赖系统路由,部分 ADM 方法只提供逻辑设备或未实现
能否设置音量、静音、立体声?要查询相应 IsAvailable 能力并检查返回值,不能由接口存在推断设备支持
是否启用系统 AEC?取决于平台、后端与创建配置;Android Java 输入和 iOS voice processing 需要与软件 APM 配合
延迟是不是精确测量?后端可能使用系统时间戳、缓冲查询或固定估计,精度不同
换设备能否自动恢复?取决于后端、选项和应用策略,公共接口并不保证所有平台自动恢复

例如,ALSA 可以从 snd_pcm_delay() 换算延迟;AAudio 可以根据流的时间与帧位置估计;iOS 的 PlayoutDelay() 在此基线中返回固定估计。这些值可以辅助音频处理和诊断,但不能当成同等精度的端到端延迟测量。

真实设备之外还可以使用 dummy、文件或测试 ADM。它们证明了这个边界的可替换性:上层需要的是符合契约的音频源和输出端,不一定是 WebRTC 亲自打开的物理麦克风与扬声器。

五、带着问题回到源码

5.1 建议的阅读顺序

要回答的问题优先阅读的文件与方法
上层能要求设备做什么?api/audio/audio_device.h、audio_device_defines.h
设备与上层在哪里接通?webrtc_voice_engine.cc 的 Init(),ADM 的 RegisterAudioCallback()
谁控制什么时候开麦、播放?audio/audio_state.cc 的 Add*Stream()、SetRecording()、SetPlayout()
PCM 怎样过设备边界?audio_device_buffer.cc 的两组交付/请求方法
为什么上层都是 10 ms?fine_audio_buffer.cc 与各后端的缓冲循环
APM 和收发流在哪里连接?audio/audio_transport_impl.cc 的两个主回调
编码在哪里切换线程?audio/channel_send.cc 的 ProcessAndEncodeAudio()
播放怎样到达 NetEq?audio_mixer_impl.cc、audio_receive_stream.cc、channel_receive.cc
某个平台为什么有额外延迟或重启?实际选中的平台后端、工厂和构建开关

5.2 用数据断点定位“有设备,但没有声音”

比起一开始钻进驱动,更有效的办法是沿公共边界逐段检查:

  • 录音没有进入发送链路:先确认录音流运行且有非零 PCM,再看 DeliverRecordedData() 是否调用到正确的 RecordedDataIsAvailable(),随后检查 APM 输出、发送者列表与编码队列状态。
  • 网络有包但扬声器没声音:先确认接收流已进入播放状态且载荷插入 NetEq,再看设备侧是否持续请求、混音器是否注册了接收源、NeedMorePlayData() 是否返回有效 PCM。
  • 只有某个平台周期性卡音:同时观察设备回调间隔、处理耗时、缓冲余量和 underrun/xrun;10 ms 逻辑帧并不保证操作系统线程每 10 ms 准时运行。
  • 切换耳机后失声或变速:检查实际路由、采样率、声道和设备块长是否变化,ADB 参数及块长适配器是否随流重建而更新。
  • 声音正常但回声明显:检查播放参考是否进入对应的 APM、设备延迟是否合理,以及系统语音处理与软件 APM 的启用配置是否匹配。

这些检查贯穿全文的四个层次:先确认 ADM 是否接入并运行,再确认数据流是否连续,然后定位到具体的方法与线程交接,最后分析平台设备与路由行为。这样读代码,平台细节才有清晰的落点。