Make spectrum affordable over a slow link #51

Merged
sjg merged 1 commits from feat/spectrum-transport into main 2026-08-07 00:23:33 +02:00
Owner

Spectrum dominates the server↔client connection — everything else on it is a few
hundred bytes, a frame is a few kilobytes — and all three things that govern its
cost were working against a poor link.

1. It was polled, one round trip per frame

trx-client asked for a frame every 50 ms on a dedicated connection and waited
for the reply, so the achievable rate was 1/RTT: on a 200 ms link, five frames a
second however often the client asked.

SubscribeSpectrum joins the existing SubscribeMeter: the connection becomes
a one-way flow of frames, pushed from a per-rig broadcast that rig_task fills
only while somebody is subscribed (receiver_count() > 0 — no work when nobody
is watching). A server too old to know the command answers with an error and
leaves the connection usable, so the client falls back to polling on the same
connection without reconnecting. Switching rigs ends the stream and rebuilds it.

2. Bins were JSON floats

1024 bins spelled out as decimal text is around 10 KB per frame, ~200 KB/s at
full rate. Meanwhile the very next hop — client to browser — already sent the
same information as base64 i8 in about 1.4 KB (api/sse.rs). The slow link
was carrying roughly eight times what the fast one did, for identical data.

Bins now travel base64-encoded whole dBFS, the resolution the display draws at
anyway. Decoding still accepts the old array form, so a new client can read an
older server. The TypeScript type is unchanged: the browser still receives an
array after the SSE hop decodes it.

3. Nothing was tunable

Knob Default What it does
[sdr].spectrum_fft_size 1024 FFT bins; power of two, 128–8192
[sdr].spectrum_interval_ms 50 How often the server pushes
[[remotes]].spectrum_interval_ms 50 How often the client wants frames

All three were compile-time constants. For a link that struggles:

[trx-server.sdr]
spectrum_fft_size = 512
spectrum_interval_ms = 200

[[trx-client.remotes]]
name = "remote-site"
url = "radio.example.com:4530"
spectrum_interval_ms = 200

≈ 0.7 KB per frame at 5 frames/s — about 3.5 KB/s, against roughly 200 KB/s for
1024 float bins at 20 frames/s.

Verification

  • subscribe_spectrum_pushes_frames drives the real listener end to end: it
    subscribes, the server pushes, and the test asserts both the decoded bins and
    that they are not spelled out on the wire.
  • Client-side tests cover the stream publishing to the watch channel the UI
    reads, and an error response reading as "fall back to polling" rather than as
    a dead connection.
  • Codec tests cover the round trip, clamping of out-of-range and non-finite
    values, the legacy array form, and the size reduction.
  • Config tests cover the power-of-two and range rules and the new intervals.

Full workspace: all tests pass, cargo clippy --all-targets and
cargo fmt --check clean.

Note on defaults

Defaults are unchanged (1024 bins, 50 ms), so an existing deployment behaves as
before except that frames are ~8× smaller and arrive without a round trip. The
knobs are there for when that is still too much.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7

Spectrum dominates the server↔client connection — everything else on it is a few hundred bytes, a frame is a few kilobytes — and all three things that govern its cost were working against a poor link. ## 1. It was polled, one round trip per frame `trx-client` asked for a frame every 50 ms on a dedicated connection and waited for the reply, so the achievable rate was 1/RTT: on a 200 ms link, five frames a second however often the client asked. `SubscribeSpectrum` joins the existing `SubscribeMeter`: the connection becomes a one-way flow of frames, pushed from a per-rig broadcast that `rig_task` fills only while somebody is subscribed (`receiver_count() > 0` — no work when nobody is watching). A server too old to know the command answers with an error and leaves the connection usable, so the client falls back to polling on the same connection without reconnecting. Switching rigs ends the stream and rebuilds it. ## 2. Bins were JSON floats 1024 bins spelled out as decimal text is around 10 KB per frame, ~200 KB/s at full rate. Meanwhile the very next hop — client to browser — already sent the same information as base64 `i8` in about 1.4 KB (`api/sse.rs`). The slow link was carrying roughly eight times what the fast one did, for identical data. Bins now travel base64-encoded whole dBFS, the resolution the display draws at anyway. Decoding still accepts the old array form, so a new client can read an older server. The TypeScript type is unchanged: the browser still receives an array after the SSE hop decodes it. ## 3. Nothing was tunable | Knob | Default | What it does | |------|---------|--------------| | `[sdr].spectrum_fft_size` | 1024 | FFT bins; power of two, 128–8192 | | `[sdr].spectrum_interval_ms` | 50 | How often the server pushes | | `[[remotes]].spectrum_interval_ms` | 50 | How often the client wants frames | All three were compile-time constants. For a link that struggles: ```toml [trx-server.sdr] spectrum_fft_size = 512 spectrum_interval_ms = 200 [[trx-client.remotes]] name = "remote-site" url = "radio.example.com:4530" spectrum_interval_ms = 200 ``` ≈ 0.7 KB per frame at 5 frames/s — about 3.5 KB/s, against roughly 200 KB/s for 1024 float bins at 20 frames/s. ## Verification - `subscribe_spectrum_pushes_frames` drives the real listener end to end: it subscribes, the server pushes, and the test asserts both the decoded bins and that they are *not* spelled out on the wire. - Client-side tests cover the stream publishing to the watch channel the UI reads, and an error response reading as "fall back to polling" rather than as a dead connection. - Codec tests cover the round trip, clamping of out-of-range and non-finite values, the legacy array form, and the size reduction. - Config tests cover the power-of-two and range rules and the new intervals. Full workspace: all tests pass, `cargo clippy --all-targets` and `cargo fmt --check` clean. ## Note on defaults Defaults are unchanged (1024 bins, 50 ms), so an existing deployment behaves as before except that frames are ~8× smaller and arrive without a round trip. The knobs are there for when that is still too much. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
sjg force-pushed feat/spectrum-transport from dbab4c5b99 to 4727233d8e 2026-08-07 00:13:41 +02:00 Compare
sjg added 1 commit 2026-08-07 00:15:54 +02:00
[feat](trx-rs): make spectrum affordable over a slow link
CI / lint (pull_request) Successful in 2m22s
CI / test (pull_request) Successful in 8m36s
CI / frontend (push) Successful in 3m38s
CI / reuse (push) Successful in 6s
CI / frontend (pull_request) Successful in 4m27s
CI / reuse (pull_request) Successful in 6s
CI / lint (push) Successful in 2m21s
CI / test (push) Successful in 7m48s
b78c4a4dd4
Spectrum dominates the server↔client connection, and all three things that
govern its cost were working against a poor link.

**It was polled, one round trip per frame.** The client asked for a frame every
50 ms on a dedicated connection and waited for the reply, so the frame rate was
capped at 1/RTT — on a 200 ms link, five frames a second no matter what was
configured.  Add SubscribeSpectrum alongside the existing SubscribeMeter: the
server pushes frames from a per-rig broadcast that rig_task fills only while
somebody is subscribed.  A server too old to know the command answers with an
error and leaves the connection usable, so the client falls back to polling on
the same connection without reconnecting.

**Bins were JSON floats.** 1024 bins spelled out as decimal text is around
10 KB a frame, ~200 KB/s at full rate — while the very next hop, client to
browser, already sends the same information as base64 i8 in about 1.4 KB.  Bins
now travel base64-encoded whole dBFS, the resolution the display draws at
anyway.  Decoding still accepts the old array form.

**Nothing was tunable.** [sdr].spectrum_fft_size and [sdr].spectrum_interval_ms
replace the compile-time FFT size and cadence; [[remotes]].spectrum_interval_ms
lets the client ask for less.  512 bins at 5 frames/s is roughly 3.5 KB/s
against roughly 200 KB/s before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
sjg force-pushed feat/spectrum-transport from 4727233d8e to b78c4a4dd4 2026-08-07 00:15:54 +02:00 Compare
sjg merged commit b78c4a4dd4 into main 2026-08-07 00:23:33 +02:00
sjg deleted branch feat/spectrum-transport 2026-08-07 00:23:33 +02:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sjg/trx-rs#51