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

>12× bandwidth cut 20 ms frames 64 kbps default 1-byte codec switch

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.

Four questions this piece answers

  • FactHow much bandwidth does 16-bit PCM receive audio take?
  • FactWhich Opus profile is in use?
  • InferenceWhy does that ctypes defect matter?
  • FactHow do you switch between the two codecs?

The bandwidth math: why PCM fails on mobile

16-bit PCM is brutal on a network. Opus is not.

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 exact configuration, and the two choices that decide how it sounds.

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

A 64-bit ABI detail that silently changed how bitrate was controlled.

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

One byte in the payload is the whole protocol.

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 libopus is missing or fails on the server, it transparently falls back to Int16 PCM.
  • The user can toggle codec live from the audio menu (setOpus:, plus setOpusBitrate: 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

Where the 20 ms frame goes, and what it costs.

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

Binary frames, no base64, and a text channel for control.

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 by TxOpusDecoder
  • /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

Why receive runs at 48 kHz and transmit at 16 kHz.

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.