让耳朵挑不出毛病的那个编解码。
每个远程电台系统都藏着同一个看不见的问题:音频链路。射频那一侧可以慢慢调,音频这一侧是你的耳朵在当场判分。
每个远程电台系统都藏着同一个看不见的问题:音频链路。射频那一侧,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 PCM0x01= 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-origin 与 Cross-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 的软件设计文档里。