音频 · 编解码记录

让耳朵挑不出毛病的那个编解码

每个远程电台系统都藏着同一个看不见的问题:音频链路。射频那一侧可以慢慢调,音频这一侧是你的耳朵在当场判分。

带宽压到 1/12 20 毫秒帧 默认 64 kbps 1 字节切换编码

BG1SB  ·   ·  约 8 分钟

每个远程电台系统都藏着同一个看不见的问题:音频链路。射频那一侧,IQ 采样、频谱、CAT 指令,都可以按链路能力去设计。音频这一侧不一样,因为当场判分的是你的耳朵。直接送原始 PCM,手机在 LTE 上就会卡成一片;压得太狠,一个强信号就糊成一团。这篇讲 SunMRRC 服务端与 SunsdrMobile 客户端里的编解码决策,也通用遥控 MRRC 用的是同一套 Opus 方案:为什么是 Opus、花了什么代价,以及那些让它真正可靠的实现细节。

TL;DR — 四条结论

  • 事实16 位 PCM 的接收音频约 768 kbit/s;64 kbps 的 Opus 比它小十二倍以上。
  • 事实帧长 20 毫秒(48 kHz 下 960 个采样),编码器跑的是 OPUS_APPLICATION_AUDIO,不是语音档。
  • 推断那个 ctypes 变参 ABI 缺陷要紧,就因为它是静默的:编码器悄悄换了码率控制路径,而不是报错。
  • 事实PCM 与 Opus 的切换靠载荷里的 1 个字节,没有握手要谈。

带宽算术:PCM 为什么在移动网络上不行

16 位 PCM 对网络很残忍,Opus 不残忍。

未压缩的 Int16 PCM 对网络非常残忍。SunMRRC 服务端以 48 kHz 单声道广播接收音频,以 16 kHz 采集发射麦克风:

链路PCM 带宽Opus 带宽压缩比
接收音频(服务端 → 客户端)约 768 kbit/s(48 kHz,16 位单声道)约 18–24 kbit/s(目标 64 kbps)>10×
发射音频(客户端 → 服务端)约 256 kbit/s(16 kHz)约 18–24 kbit/s>10×

768 kbit/s 的持续接收音频,对大多数移动上行链路来说是直接出局。就算管道够粗,拥挤 LTE 小区里的丢包也会把 PCM 流变成一阵一阵可听见的卡顿。项目自己关于编解码的记录说得更直白:64 kbps 单声道是远程宽带调频收听的最优点,在广播音乐上接近透明,而码率只有 768 kbps Int16 PCM 的 1/12,于是远程链路不再欠载,那种 PCM 卡顿也就消失了。

Opus 编码器设置

完整配置,以及决定音质的那两个选择。

这些设置集中在一个模块里(opus_rx.py):

RX rate:         48 kHz mono        (raised from 16 kHz so WFM keeps its ~15 kHz band)
TX rate:         16 kHz mono        (phone mic)
Frame:           20 ms              (= 960 samples @ 48 kHz)
Bitrate:         64 kbps default    (range 8–128 kbps)
Application:     OPUS_APPLICATION_AUDIO (2049)  ← not OPUS_APPLICATION_VOIP
Max packet:      4000 bytes         (TX worst-case 120 ms frame = 5760 samples)

这里有两个选择值得单独点出来。接收采样率从 16 kHz 提到 48 kHz,是为了让宽带调频广播保住它完整的约 15 kHz 音频带宽;16 kHz 的流会把调频广播听得发闷。编码器用 OPUS_APPLICATION_AUDIO 而不是语音档,这个标志告诉 Opus 优先保真度而不是电话可懂度,对调频音乐和清晰的单边带都合适。

一个真实的 ctypes 缺陷:变参 ABI

一个 64 位 ABI 细节,静默改掉了码率的控制方式。

这是那种会吃掉一个周末的缺陷。Opus 通过 opus_encoder_ctl() 控制码率,而这是一个 C 变参函数。在 Apple Silicon(arm64)上,变参 ABI 把尾部参数放在栈上,而固定了 argtypes 的 ctypes 把它们放进寄存器,于是每一次 SET 调用都返回 OPUS_BAD_ARG 并静默变成空操作。你以为配置好的码率,其实从来没有被配置过。

绕开的办法很干净:完全跳过控制 API,改用 opus_encode()max_data_bytes 参数给输出封顶:

cap = max(16, min(4000, bitrate * 20 // 1000 // 8))   # 64 kbps → 160 bytes per frame

实测有效:40 字节的上限得到约 13 kbps,60 字节约 20 kbps,两条路径的解码往返都干净。让编码器去适配一个字节预算,事实证明和用正式 API 一样可控,而且在所有架构上都成立。

没有握手的 PCM ↔ Opus 切换

载荷里的 1 个字节,就是整个协议。

会话中途可以随时切换编解码,不需要任何控制通道协商,靠的是每个音频帧前面那 1 个字节的编码标记(SDD AD-004):

  • 0x00 = 原始 Int16 PCM
  • 0x01 = Opus 包

每个 /WSaudioRX 帧就是 [标记][载荷]。客户端看标记决定怎么解,于是 PCM 与 Opus 可以自由交错,没有握手,也没有竞态。这让系统在两个方向上都能优雅降级:服务端缺 libopus 或编码失败时,透明退回 Int16 PCM;用户可以在音频菜单里实时切换编码(setOpus:,以及 16–128 kbps 的 setOpusBitrate:);流中途某次编码失败,就记一条警告并为那一帧发送 PCM 回退,而不是把音频丢掉。

延迟预算

那 20 毫秒的帧花在哪儿了,代价又是什么。

关于 Opus,真正该问的问题是它到底加了多少延迟。编码帧是 20 毫秒,对着真正的延迟来源几乎是个舍入误差,后者是抖动缓冲:

环节取值
Opus 帧20 毫秒
WebSocket 抖动缓冲,预充10 帧 = 200 毫秒
WebSocket 抖动缓冲,上限60 帧 = 1200 毫秒
发射麦克风抖动缓冲,预充60 包 ≈ 307 毫秒
发射麦克风抖动缓冲,重新预充8 包 ≈ 41 毫秒
稳态发射队列约 80 包 ≈ 410 毫秒
调制前静默17 个零 IQ 包 ≈ 87 毫秒,加 200 采样斜坡 ≈ 5.1 毫秒

结论是:Opus 不是延迟问题,缓冲策略才是。200 毫秒的 WebSocket 预充和约 307 毫秒的麦克风预充,存在意义是把网络抖动吸收掉,让音频永远不出现咔哒声。对一次业余远程通联来说,用几百毫秒的缓冲换掉一次爆音,是正确的交换。

WebSocket 音频通路

二进制帧、不用 base64,控制走文本通道。

整个远程会话由四条 WebSocket 承载:

  • /WSCTRX:控制通道,文本命令,用 PING/PONG 测往返。
  • /WSaudioRX:服务端 → 客户端,带标记的二进制音频帧。
  • /WSaudioTX:客户端 → 服务端,同样的带标记格式,由 TxOpusDecoder 解码。
  • /WSspectrum:512 字节的 uint8 行,帧率 1–38 fps 可配。

发射路径还有一个技巧(SDD AD-014):在一段 SharedArrayBuffer 上放一个无锁环形缓冲,把音频采样在 AudioWorklet(生产者,实时优先级)与一个专用 Web Worker(消费者,跑 Opus WASM 编码器并独占自己的 WebSocket)之间传递。浏览器主线程从不碰音频采样,所以一次垃圾回收停顿不会造成断音。代价是需要 Cross-Origin-Opener-Policy: same-originCross-Origin-Embedder-Policy: credentialless 两个响应头,自建部署时需要知道这一点。

采样率与信号链

为什么接收跑 48 kHz、发射跑 16 kHz。

SunSDR2 DX 送 IQ 的采样率是 5⁷ = 78,125 Hz 的倍数(39k / 78k / 156k / 312k)。基础的 39,062.5 Hz 给出约 19.5 kHz 的奈奎斯特带宽,够宽带调频用;随后解调器用 Catmull-Rom 三次插值重采样到 48 kHz,供 Opus 编码器和浏览器的 AudioContext 使用。

发射侧,客户端的 16 kHz 麦克风音频在 Worker 里 Opus 编码,服务端解码后,先过一只 300 Hz 四阶巴特沃斯高通(AD-015)再进单边带调制。这只滤波器是能测出来的收益:300 Hz 以下的包络功率占比从 30.4% 降到 3.7%,把 96% 的功放功率推进 300–2800 Hz 话音带。调制链末端是 tanh 软限幅加线性斜坡,所以发射包络不会一头撞进功放。

这篇取材于 SunMRRC 服务端与它的 SunsdrMobile iOS 客户端,两者都开源。相关架构决策(AD-004 双编码传输、AD-014 环形缓冲、AD-015 发射滤波器)记录在 SunMRRC 的软件设计文档里。