The Operator's Cookbook, Part 8: Radios Far Away — SoapyRemote, rtl_tcp & ka9q

Part 8 of The Operator’s Cookbook, a 14-part series of complete, copy-paste GopherTrunk builds — one working rig per part, antenna to browser. Part 7 filled one box with dongles; this part cuts the USB cable. The best antenna spot — attic, roof, a shed on a hill — is rarely where you want a computer, and the best decode box is rarely worth hauling up a ladder. GopherTrunk mounts three kinds of network radio as first-class virtual tuners: rtl_tcp for cheap dongles, SoapyRemote for everything up to a USRP, and ka9q-radio for multicast channel feeds. Same pool, same decoders, same web console — just with an Ethernet run where the coax used to be.

TL;DR: A Pi at the antenna runs rtl_tcp or SoapySDRServer; the rack box lists it under sdr.rtl_tcp: / sdr.soapy_remote: / sdr.ka9q_radio: and it joins the pool like local hardware — roles, voice, everything. Budget the wire first: 2.4 MS/s costs ~38 Mbit/s as rtl_tcp’s 8-bit stream and ~77 Mbit/s as SoapyRemote CS16. USRP antenna ports are set with antenna: [RX2] — validated against the device’s port list and read back after setting, a hard-won behavior. And when SDR overruns … host_drops appears, the network is innocent: the decode side stopped draining. All three transports are plaintext — trusted networks or a tunnel only.

Key takeaways

  • A remote radio is a pool citizen, not a special case. Each entry mounts as a virtual tuner with a serial and a role; every recipe in this series works unchanged on top of one.
  • Format sets the bill. rtl_tcp is hardcoded 8-bit; SoapyRemote carries CS16 (default) or CF32 at 2× the bytes; ka9q-radio ships narrow per-channel streams that barely register. Do the multiplication before blaming the network.
  • Antenna selection is verified, not hoped. The port name is checked against what the device advertises, set, and read back — because for a long time the “set antenna” call silently invoked the wrong remote function and nobody’s config did anything.
  • host_drops points downstream. The driver sheds samples only when the consumer stops draining its buffer — pair it with the ccdecoder’s can’t-keep-up WARN and fix CPU, not cables.

Cheat sheet

Concern What it does Where it lives
Cheap remote dongle 8-bit rtl_tcp stream as a virtual tuner sdr.rtl_tcp[] (rtl_tcp)
High-end remote radio CS16/CF32, full tuning control sdr.soapy_remote[] (SoapySDR)
Multicast channels radiod RTP channels, one entry per SSRC sdr.ka9q_radio[] (ka9q-radio)
Antenna port per-channel RX port, validated + read back soapy_remote[].antenna: [RX2]
Link tuning MTU / flow-control window for fat or slow links stream_mtu, stream_window
Exact USRP rates make sample_rate an integer clock division master_clock_rate (USRP B210)
RPC forensics log every control call + hex dump verbose_debug: true (needs log.level: debug)

In this post

  • What you’re building — antenna in the attic, decoder in the rack.
  • The shopping list — a Pi, a dongle, and honest Ethernet.
  • The network budget — bytes per second, before anything else.
  • The config — all three transports, every key verified.
  • First run — what healthy looks like — three connected lines and a read-back.
  • When it doesn’t work — the heaviest troubleshooting table in the series.

What you’re building

The finished rig is two boxes. At the antenna: a Raspberry Pi with the radio on a short, low-loss coax run — physics happy. In the rack: the GopherTrunk daemon from Parts 1–7, CPU and disk happy, with the remote radio in its pool next to any local dongles. Decoders, voice pool, recordings, web console — none of them know the IQ crossed a wire.

Which transport fits which build:

Transport Hardware Stream Best for
rtl_tcp RTL-SDR dongles 8-bit, fixed the $35 attic dongle
soapy_remote USRP, LimeSDR, Airspy, HackRF, bladeRF, SDRplay, RTL CS16 / CF32, full control serious remote front ends, diversity rigs
ka9q_radio radiod + RX888/Airspy/RTL per-channel RTP multicast one shared receiver feeding many consumers on a LAN

All three are plaintext with no authentication — keep them on a trusted LAN or inside an SSH/WireGuard tunnel, the same posture logic as the API auth deep dive.

antenna site (attic / roof / shed) antenna short coax radio Pi rtl_tcp / SoapySDRServer / radiod Ethernet u8 ~38 Mbit/s CS16 ~77 Mbit/s @ 2.4 MS/s rack box — GopherTrunk daemon network drivers rtltcp: connected soapyremote: connected ka9qradio: connected → virtual tuners, by serial SDR pool (Part 7) roles, watchdog, voice decoders → recordings → web console plaintext transports — trusted LAN or tunnel only
The coax run shrinks to inches and the network carries the IQ instead: each remote stream mounts as a virtual tuner in the same pool every other recipe uses.

The network budget

Do this arithmetic before anything else: bytes/s = sample_rate × bytes-per-complex-sample.

Stream @ 2.4 MS/s @ 6.25 MS/s (USRP)
rtl_tcp (u8, 2 B/sample) ~4.8 MB/s ≈ 38 Mbit/s
SoapyRemote CS16 (4 B) ~9.6 MB/s ≈ 77 Mbit/s ~25 MB/s ≈ 200 Mbit/s
SoapyRemote CF32 (8 B) ~19 MB/s ≈ 154 Mbit/s ~50 MB/s ≈ 400 Mbit/s
ka9q-radio channel kilobits–a few Mbit/s each (per channel, not per band)

Wired gigabit Ethernet carries all of it; Wi-Fi carries the rtl_tcp row on a good day and nothing above it reliably. CS16 is the right SoapyRemote default — CF32 doubles the bill for dynamic range a trunking decode doesn’t need. And the sample rate is your knob: a remote USRP needn’t stream its whole capability.

The shopping list

Item Price (rough) Notes
Raspberry Pi (or any SBC/old laptop) ~$50 runs the server end at the antenna
Radio ~$35 and up whatever the recipe you’re remoting called for
Ethernet run / PoE ~$20 wired; a PoE splitter powers the Pi over the same cable

The config

One entry per transport, to show all three (use the ones you need):

sdr:
  sample_rate: 2_400_000

  rtl_tcp:
    - addr: "192.168.1.50:1234"
      serial: "antenna-pi"
      role: control
      ppm: 0
      gain: "auto"
      connect_timeout_ms: 3000

  soapy_remote:
    - addr: "192.168.1.60:55132"   # bare host gets :55132 appended
      driver: "uhd"
      serial: "usrp-roof"
      role: control
      format: "CS16"               # or CF32 at 2x the bandwidth
      antenna: [RX2]               # validated against the device, read back
      master_clock_rate: 0         # see note below for exact rates
      stream_mtu: 0                # raise (e.g. 8192) on jumbo-frame links
      stream_window: 0             # raise on high-latency links
      gain: "auto"
      connect_timeout_ms: 3000

  ka9q_radio:
    - addr: "hf.local"             # radiod status group (mDNS or 239.x.x.x:5006)
      ssrc: 162550                 # the channel's RTP SSRC
      serial: "ka9q-wx"
      role: control
      connect_timeout_ms: 3000

Notes that earn their place. antenna: is the real key, not args. An antenna= inside the args kwargs only reaches device construction and never selects the per-channel port — GopherTrunk rejects it there. Port names are device-specific (a B210 has TX/RX and RX2; a TwinRX has RX1 and RX2), so a config moved between rigs should fail loudly. master_clock_rate exists because a USRP only streams integer divisions of its clock: a B210 set to 61_440_000 makes a 6.144 MS/s sample_rate exact instead of letting UHD coerce to a nearby rate. ka9q entries consume existing radiod channels — configure the channel in radiod@.conf first; GopherTrunk discovers its multicast group, rate and encoding from the status stream.

First run — what healthy looks like

Each transport announces itself once the stream is up:

INF rtltcp: connected addr=192.168.1.50:1234 tuner=R820T gain_count=29
INF soapyremote: connected addr=192.168.1.60:55132 format=CS16 proto=tcp diversity=none
INF soapyremote: rx antenna set addr=192.168.1.60:55132 channel=0 antenna=RX2
INF ka9qradio: connected status=239.1.2.3:5006 data=239.1.2.4:5004 ssrc=162550 samprate=48000

The third line deserves a pause. For a long time the “set antenna” RPC used the wrong opcode — the SoapyRemote wire carries no schema, so the call silently invoked a different remote function whose first arguments happened to fit. Every antennas: config did nothing, and the only trace was a cryptic ~SoapyRPCUnpacker: Unconsumed payload bytes 9 on the server. The unit tests couldn’t catch it — the fake test server switched on the same wrong constant, the classic self-consistent trap. Today the driver validates your port name against the device’s advertised list, sets it, reads it back, and logs what the device itself reports — the full saga is in From the Issue Tracker Part 18. When you see rx antenna set, it happened.

From here, everything is Parts 1–7: control locks, grants, calls, recordings. One more line matters on remote rigs specifically — the overrun WARN:

WRN soapyremote: SDR overruns — the host can't keep up with the configured sample rate, so samples are being dropped and decoded audio will glitch. Lower sdr.sample_rate or reduce the channel/tap count. addr=... device_overflows=0 host_drops=412

Read the counters, not the vibes. host_drops means the decode side stalled: the driver sheds the oldest queued chunk only when its consumer stops draining a ~400 ms buffer, so a climbing host_drops says something downstream got slow — check for the companion ccdecoder: decode can't keep up with real time WARN, which confirms CPU rather than network. device_overflows climbing instead points at the remote host or the wire. When this fires, ask what you recently made slower.

When it doesn’t work

Symptom Likely cause Fix
Daemon starts cleanly, but no radio and no error remote-only config on an old build — the pool gate once required a local device list, so a config with only soapy_remote/ka9q_radio registered no driver at all Fixed (pinned by TestRemoteOnlySDRConfigsRegisterTheirDriver); on current builds grep for the connected line — absent with no error means the block isn’t under sdr: where you think
No connected line, then timeouts server not running / wrong port / firewall SoapySDRServer --bind on the radio host, rtl_tcp on 1234; test with nc host port; raise connect_timeout_ms on slow links
channel 0 antenna "RX9" is not a port on this device (available: RX1, RX2) port name from a different radio model Use a name from the error’s own list — this failing loudly is the feature
antenna set to "RX2" but device reports "RX1" server-side driver ignored the set Read-back caught it; check the remote SoapySDR module/version for that hardware
Audio glitches, host_drops climbing, device_overflows ~0 decode box CPU exhausted — consumer stalled Shed taps/systems, lower sdr.sample_rate, bigger box. The network is innocent on this signature
Glitches with device_overflows climbing remote host or link can’t sustain the rate Check the budget table; wired link; lower rate; on jumbo/high-latency links raise stream_mtu / stream_window
USRP runs at a slightly different rate than configured UHD coerced to an integer clock division Set master_clock_rate so sample_rate divides it exactly
~SoapyRPCUnpacker: Unconsumed payload bytes N on the server a mis-shaped RPC — the wire has no schema verbose_debug: true + log.level: debug logs every call with a hex dump; file it with that output
ka9q entry mounts but decodes nothing wrong ssrc, or channel not in raw IQ mode Match the SSRC from radiod@.conf; the channel needs output_channels = 2 — the channel is not 2-channel IQ WARN is the tell
Works on the bench, dies over Wi-Fi the budget table Wire it. Raw IQ and Wi-Fi are enemies at trunking rates

How this recipe shapes operator practice

  • Budget the wire like you budget the feedline. Format × rate is the network’s version of coax loss — decided before any software runs.
  • Trust read-backs over acknowledgements. The antenna lesson generalizes: a setting the device reports is real; a setting that merely didn’t error may be fiction.
  • Blame by counter, not by topology. “It’s remote, so it’s the network” is exactly what host_drops exists to refute.

Where this goes next

The rig now hears everything from anywhere. Part 9 turns it outward: Broadcastify Calls, RdioScanner, OpenMHz, Icecast and webhooks — the broadcast config that shares your calls with the world, each backend’s quirks, and the key hygiene that keeps your API secrets out of your config file.

FAQ

Can GopherTrunk use an SDR on another computer? Yes, three ways: sdr.rtl_tcp for RTL-SDR dongles behind rtl_tcp, sdr.soapy_remote for anything SoapySDR serves, and sdr.ka9q_radio for radiod multicast channels. Each mounts as a virtual tuner with a serial and role, indistinguishable to the decoders from local USB hardware.

How much network bandwidth does a remote SDR need? Sample rate times bytes per sample: at 2.4 MS/s, rtl_tcp’s 8-bit stream is ~38 Mbit/s and SoapyRemote CS16 is ~77 Mbit/s; a USRP at 6.25 MS/s in CS16 is ~200 Mbit/s. Wired gigabit handles all of it; Wi-Fi doesn’t.

What does the host_drops counter mean in the SDR overruns warning? That GopherTrunk’s own decode side stopped draining the driver’s buffer — the oldest queued IQ was shed to keep the stream live. It signals CPU or a stalled consumer on the decode box, not a network problem; device_overflows is the counter that points at the remote end.

How do I select the RX antenna port on a remote USRP? Set antenna: [RX2] (or [RX1, RX2] per channel under diversity) in the soapy_remote entry — not antenna= inside args, which is rejected. The name is validated against the ports the device advertises and read back after setting, so a wrong name fails at startup instead of silently doing nothing.

Is rtl_tcp or SoapyRemote traffic encrypted? No — both are plaintext with no authentication, as is ka9q-radio’s multicast. Keep them on a trusted LAN, or carry them through an SSH or WireGuard tunnel; never expose the ports to the internet.

Series navigation

Part 8 of 14 · ← Part 7: Many Systems, One Box — The SDR Pool · Next → Part 9: Sharing the Feed — Broadcastify, OpenMHz & Friends