Opus vs PCM for HF Remote Audio: Latency, Bandwidth, and Trade-offs
The codec decision behind remote radio that works on a phone — a >10× bandwidth cut for a 20 ms frame of latency, and a surprising ctypes bug that changed how bitrate is controlled.
Every remote radio system has the same invisible problem: the audio link. The RF path — IQ samples, spectrum, CAT commands — can all be engineered to fit a link. Audio is where it gets personal, because your ear is the one judging it. Ship raw PCM and a phone on LTE will stutter; compress it badly and a loud signal turns into mush. This article is about the codec decision in the SunMRRC remote server and the SunsdrMobile client — the same Opus approach the universal MRRC remote uses: why Opus, what it costs, and the implementation details that make it reliable.
The bandwidth math: why PCM fails on mobile
Uncompressed Int16 PCM is brutal on a network. The SunMRRC server broadcasts receive audio at 48 kHz mono and captures the transmit microphone at 16 kHz:
| Link | PCM bandwidth | Opus bandwidth | Compression |
|---|---|---|---|
| RX audio (server → client) | ~768 kbit/s (48 kHz, 16-bit mono) | ~18–24 kbit/s (64 kbps target) | >10× |
| TX audio (client → server) | ~256 kbit/s (16 kHz) | ~18–24 kbit/s | >10× |
768 kbit/s of continuous receive audio is a non-starter for most mobile uplinks — and even where the pipe is big enough, packet loss on a congested LTE cell turns a PCM stream into audible stutter. The team's own note on the codec puts it plainly: 64 kbps mono is "the sweet spot for remote WFM listening: near-transparent on broadcast music yet only 1/12 the 768 kbps Int16 PCM rate, so a remote link stops underrunning (the PCM stutter)."
The Opus encoder settings
The settings live in one module (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)
Two choices here are worth calling out. The RX rate was raised from 16 kHz to 48 kHz specifically so WFM broadcast keeps its full ~15 kHz audio band — a 16 kHz stream would make FM broadcast sound dull. And the encoder uses OPUS_APPLICATION_AUDIO rather than the voice profile: that's the flag that tells Opus to favor full-bandwidth fidelity over telephony intelligibility, which suits both WFM music and clear SSB.
A real-world ctypes quirk: the variadic ABI bug
This is the kind of bug that eats a weekend. Opus controls its bitrate through opus_encoder_ctl(), a C variadic function. On Apple Silicon (arm64), the variadic ABI passes trailing arguments on the stack, but ctypes with pinned argtypes passes them in a register — so every SET control call returns OPUS_BAD_ARG and silently no-ops. The bitrate you thought you configured was never configured.
The workaround is elegant: skip the control API entirely and cap the output with the max_data_bytes argument to opus_encode():
cap = max(16, min(4000, bitrate * 20 // 1000 // 8))
# 64 kbps → 160 bytes per frame
Verified in practice: a 40-byte cap yields ~13 kbps, 60 bytes ~20 kbps, both with clean decode round-trips. Forcing the encoder to fit a byte budget turns out to be just as controllable as the proper API — and it works on every architecture.
PCM ↔ Opus switching without a handshake
The stream can flip codecs mid-session without any control-channel negotiation, thanks to a 1-byte codec tag prefix on every audio frame (SDD AD-004):
0x00 = raw Int16 PCM
0x01 = Opus packet
Each /WSaudioRX frame is [tag][payload]. The client inspects the tag and decodes accordingly, so PCM and Opus can interleave freely — no handshake, no race. That lets the system degrade gracefully in both directions:
- If
libopusis missing or fails on the server, it transparently falls back to Int16 PCM. - The user can toggle codec live from the audio menu (
setOpus:, plussetOpusBitrate:for 16–128 kbps). - An encode failure mid-stream logs a warning and sends the PCM fallback for that frame instead of dropping audio.
The latency budget
The honest question about Opus is always "how much delay does it add?" The encoder frame is 20 ms — a rounding error against the real contributors, which are the jitter buffers:
| Stage | Value |
|---|---|
| Opus frame | 20 ms |
| WebSocket jitter buffer, prime | 10 frames = 200 ms |
| WebSocket jitter buffer, max | 60 frames = 1200 ms |
| TX mic jitter buffer, prime | 60 packets ≈ 307 ms |
| TX mic jitter buffer, re-prime | 8 packets ≈ 41 ms |
| Steady-state TX queue | ~80 packets ≈ 410 ms |
| TX modulation pre-settle | 17 zero-IQ packets ≈ 87 ms + 200-sample ramp ≈ 5.1 ms |
The lesson: Opus is not the latency problem. The buffer strategy is. The 200 ms WebSocket prime and ~307 ms mic prime exist to soak up network jitter so the audio never clicks — and for a hobby remote-radio session, hundreds of milliseconds of buffering is the right trade against a single dropout.
The WebSocket audio transport
Four WebSocket endpoints carry the whole remote session:
/WSCTRX— control channel, text commands, PING/PONG for round-trip measurement/WSaudioRX— server → client, tagged binary audio frames/WSaudioTX— client → server, same tagged format, decoded byTxOpusDecoder/WSspectrum— 512-byte uint8 rows at a configurable 1–38 fps
The TX path has one more trick (SDD AD-014): a lock-free ring buffer in a SharedArrayBuffer passes audio samples between an AudioWorklet (producer, real-time priority) and a dedicated Web Worker (consumer, which runs the Opus WASM encoder and owns its own WebSocket). The main browser thread never touches audio samples, so a garbage-collection pause can't cause a dropout. That requires Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: credentialless headers — worth knowing if you self-host.
Sample rates and the signal path
The SunSDR2 DX streams IQ at rates that are multiples of 5⁷ = 78,125 Hz (39k/78k/156k/312k). The base 39,062.5 Hz rate gives a ~19.5 kHz Nyquist, enough for WFM; the demodulator then resamples to 48 kHz with Catmull-Rom cubic interpolation for the Opus encoder and the browser's AudioContext.
On TX, the client's 16 kHz mic audio is Opus-encoded in the Worker, decoded server-side, then run through a 300 Hz 4th-order Butterworth high-pass filter (AD-015) before SSB modulation. That filter is a measurable win: it drops sub-300 Hz envelope power from 30.4% to 3.7% of the total, pushing 96% of PA power into the 300–2800 Hz voice band. The modulation chain ends with a tanh soft-limiter and a linear ramp, so the transmitted envelope never slams into the PA.
This article pulls from the SunMRRC server and its SunsdrMobile iOS client, both open source. The architecture decisions (AD-004 dual-codec transport, AD-014 ring buffer, AD-015 TX filter) are documented in the SunMRRC Software Design Document.