Audio

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.

By BG1SB  ·   ·  ~8 min read

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:

LinkPCM bandwidthOpus bandwidthCompression
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:

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:

StageValue
Opus frame20 ms
WebSocket jitter buffer, prime10 frames = 200 ms
WebSocket jitter buffer, max60 frames = 1200 ms
TX mic jitter buffer, prime60 packets ≈ 307 ms
TX mic jitter buffer, re-prime8 packets ≈ 41 ms
Steady-state TX queue~80 packets ≈ 410 ms
TX modulation pre-settle17 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:

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.