crabidy/architecture/spectrum.md

4.3 KiB
Raw Permalink Blame History

Frequency spectrum visualizer

A row of frequency bars under the track progress in the TUI, on by default, turn-offable in the client config. The reference the user gave, BeSpec, is a standalone egui/wgpu app that captures local system-audio loopback and runs a 2048-point realfft — we borrow its DSP shape, not its capture model.

The core problem: where does the audio live?

The audio is decoded and played on the server (the audio-player crate, rodio). The TUI — and the web client — are gRPC clients that may run on another machine (the stated setup: cbd local, cbd-tui pointed at a Raspberry Pi, architecture/client-configs.md). A local loopback capture in the client, BeSpec-style, would therefore show nothing (or the wrong machine's audio) for a remote client.

So the spectrum must be produced where the samples are — the server — and streamed to clients like every other bit of live state.

Decisions

D1 — server taps the samples, computes the FFT, streams bins

  • Tap (audio-player): the decoded source is wrapped in a TappingSource that copies each played frame (downmixed to mono) into a fixed lock-free ring (SpectrumTap, 2048 f32 slots, atomic write index). It runs on rodio's audio thread, so it does the absolute minimum — one store per sample, no locks, no allocation — and benign read/write races are fine for a visualizer.
  • FFT (crabidy-server): a task ticks at ~20 fps, snapshots the ring, applies a Hann window + realfft, folds the magnitude spectrum into a small number of log-spaced bins (musically even), normalizes to 0..1, and broadcasts them.
  • Stream: a new SpectrumFrame { bins } on the existing GetUpdateStream (oneof variant), at a handful of bins × ~20 fps — ~2 KB/s, negligible next to the audio it describes.

Rejected: client-side loopback capture (breaks the remote client, the whole reason clients exist); sending raw PCM to clients (orders of magnitude more bandwidth, and every client would re-run the FFT).

D2 — idle detection without touching the control path

The tap increments a frame counter on every write. The FFT task compares the counter between ticks: advancing ⇒ audio is flowing, emit bins; unchanged ⇒ paused/stopped/between tracks, emit a single all-zero frame (bars fall to the floor) and then stay quiet until it moves again. No extra is_playing round-trips onto the player command channel, and no frozen bars on pause.

D3 — the config toggle is client-side display; the server always offers it

spectrum = true (default) in cbd-tui.toml / cbd.toml shows the bars; false hides them. The toggle is a display choice — the server always computes and streams when something is playing. At household scale one small FFT task at 20 fps is not worth gating on a per-client preference, and keeping the server unconditional means any client (TUI, web) can show bars without a negotiation. The FFT only runs while audio is actually flowing (D2), so an idle server is idle.

D4 — rendering

The TUI draws the bars in the now-playing pane, directly under the progress gauge, as a single row of vertical block glyphs (▁▂▃▄▅▆▇█) whose heights track the bins, in the accent color. The web client renders the same bins as CSS-height bars for parity. Both simply consume the latest SpectrumFrame; neither computes anything.

Structure

direction: right
audio: "audio-player (server)" {
  dec: decoder
  tap: "TappingSource\n→ SpectrumTap ring"
  sink: rodio sink
  dec -> tap -> sink
}
fft: "spectrum task\nHann + realfft →\nlog bins, ~20fps"
stream: "GetUpdateStream\nSpectrumFrame{bins}"
tui: "TUI now-playing\nblock-glyph bars"
web: "web client\nCSS bars"
audio.tap -> fft: snapshot
fft -> stream
stream -> tui
stream -> web

Risks

  • Audio-thread cost: the tap must stay trivial; anything more than a store per sample risks underruns. No locks, no allocation, no logging on that path.
  • Torn reads: the FFT reads the ring while the audio thread writes it. Accepted — a visualizer tolerates the occasional stale/mixed sample; correctness of playback is never affected (the tap only observes).
  • realfft is pure Rust (no system libs), so it does not complicate packaging.