diff --git a/Cargo.lock b/Cargo.lock index 0523685..711e9f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1086,6 +1086,7 @@ dependencies = [ "http", "include_dir", "rand 0.10.2", + "realfft", "reqwest 0.13.1", "serde", "tempfile", @@ -2905,6 +2906,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -3418,6 +3428,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "primal-check" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" +dependencies = [ + "num-integer", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -3889,6 +3908,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "realfft" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f821338fddb99d089116342c46e9f1fbf3828dba077674613e734e01d6ea8677" +dependencies = [ + "rustfft", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -4121,6 +4149,20 @@ dependencies = [ "semver", ] +[[package]] +name = "rustfft" +version = "6.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "primal-check", + "strength_reduce", + "transpose", +] + [[package]] name = "rustix" version = "1.1.4" @@ -4657,6 +4699,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "strength_reduce" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" + [[package]] name = "strsim" version = "0.11.1" @@ -5582,6 +5630,16 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "transpose" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" +dependencies = [ + "num-integer", + "strength_reduce", +] + [[package]] name = "try-lock" version = "0.2.5" diff --git a/Cargo.toml b/Cargo.toml index 1152768..240ce4e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ notify-rust = "4" percent-encoding = "2" prost = "0.14" rand = "0.10" +realfft = "3" ratatui = "0.30" reqwest = { version = "0.13", default-features = false, features = [ "json", diff --git a/README.md b/README.md index c1cc915..2375898 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,9 @@ address = "http://127.0.0.1:50051" # The password is stored in plaintext — keep this file private. user = "" password = "" + +# Show the frequency-spectrum bars under the track progress. Default true. +spectrum = true ``` Every option is also available as a command-line flag @@ -140,6 +143,11 @@ live as you type — `Enter` keeps the filter, `Esc` clears it. Press `?` for the full binding table. +A row of frequency-spectrum bars is drawn under the track progress +while audio plays (the server taps its own output, runs the FFT, and +streams the bars, so it works whether the server is local or remote). +Turn it off with `spectrum = false` in the client config. + ## Web client `crabidy-server` serves a browser client with the same functionality as diff --git a/architecture/spectrum.md b/architecture/spectrum.md new file mode 100644 index 0000000..f7989e3 --- /dev/null +++ b/architecture/spectrum.md @@ -0,0 +1,100 @@ +# 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](https://github.com/BeSpec-Dev/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 + +```d2 +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. diff --git a/audio-player/src/lib.rs b/audio-player/src/lib.rs index 65943ad..0b86753 100644 --- a/audio-player/src/lib.rs +++ b/audio-player/src/lib.rs @@ -1,6 +1,8 @@ mod player; mod player_engine; +mod spectrum_tap; pub mod windowed_http; pub use player::{Player, PlayerError}; pub use player_engine::{MediaInfo, PlayerMessage}; +pub use spectrum_tap::{SpectrumTap, SPECTRUM_WINDOW}; diff --git a/audio-player/src/player.rs b/audio-player/src/player.rs index 9b7e239..94ef8b2 100644 --- a/audio-player/src/player.rs +++ b/audio-player/src/player.rs @@ -5,13 +5,19 @@ use anyhow::Result; use flume::{Receiver, Sender}; use tracing::error; +use std::sync::Arc; + use crate::player_engine::{MediaInfo, PlayerEngine, PlayerEngineCommand, PlayerMessage}; +use crate::spectrum_tap::SpectrumTap; pub enum PlayerError {} pub struct Player { pub messages: Receiver, tx_engine: Sender, + /// The spectrum tap, shared with the engine thread. The server's FFT + /// task reads it (architecture/spectrum.md). + spectrum: Arc, } impl Default for Player { @@ -25,8 +31,12 @@ impl Default for Player { // tokio context but needs one to create http streams. let runtime = tokio::runtime::Handle::try_current().ok(); + // Created here and shared into the engine thread so callers can + // read it without reaching across the thread boundary. + let spectrum = SpectrumTap::new(); + let engine_tap = spectrum.clone(); thread::spawn(move || { - let engine = match PlayerEngine::init(tx_callbacks, tx_player, runtime) { + let engine = match PlayerEngine::init(tx_callbacks, tx_player, runtime, engine_tap) { Err(e) => { error!("Could not initialize player: {}", e); return; @@ -39,11 +49,17 @@ impl Default for Player { Self { messages, tx_engine, + spectrum, } } } impl Player { + /// The spectrum tap the played audio is mirrored into. + pub fn spectrum_tap(&self) -> Arc { + self.spectrum.clone() + } + pub async fn play(&self, source_str: &str) -> Result { let (tx, rx) = flume::bounded(1); self.tx_engine diff --git a/audio-player/src/player_engine.rs b/audio-player/src/player_engine.rs index 3a9da48..835d3fb 100644 --- a/audio-player/src/player_engine.rs +++ b/audio-player/src/player_engine.rs @@ -11,7 +11,9 @@ use rodio::{Decoder, Source}; use stream_download::storage::temp::TempStorageProvider; use stream_download::{Settings, StreamDownload}; +use crate::spectrum_tap::{SpectrumTap, TappingSource}; use crate::windowed_http::{WindowedHttpParams, WindowedHttpStream}; +use std::sync::Arc; use thiserror::Error; use tracing::{debug, info, instrument, trace, warn}; use url::Url; @@ -94,6 +96,10 @@ pub struct PlayerEngine { _owned_runtime: Option, /// Shared client for windowed network streams. http: reqwest::Client, + /// Ring the played audio is mirrored into for the spectrum + /// visualizer (architecture/spectrum.md). Handed out via + /// [`Self::spectrum_tap`] so the server's FFT task can read it. + spectrum: Arc, } impl PlayerEngine { @@ -101,6 +107,7 @@ impl PlayerEngine { tx_engine: Sender, tx_player: Sender, runtime: Option, + spectrum: Arc, ) -> Result { let stream = DeviceSinkBuilder::open_default_sink().context("failed to open audio output device")?; @@ -132,6 +139,7 @@ impl PlayerEngine { runtime, _owned_runtime: owned_runtime, http, + spectrum, }) } @@ -251,7 +259,10 @@ impl PlayerEngine { } let decoder = builder.build().context("failed to decode http stream")?; let duration = decoder.total_duration(); - self.sink.append(decoder); + // Mirror the played audio into the spectrum tap (it only + // observes; playback is unaffected). + self.sink + .append(TappingSource::new(decoder, self.spectrum.clone())); duration } Ok(url) => return Err(anyhow!("Not a valid URL scheme: {}", url.scheme())), @@ -272,7 +283,8 @@ impl PlayerEngine { } let decoder = builder.build().context("failed to decode file")?; let duration = decoder.total_duration(); - self.sink.append(decoder); + self.sink + .append(TappingSource::new(decoder, self.spectrum.clone())); duration } }; diff --git a/audio-player/src/spectrum_tap.rs b/audio-player/src/spectrum_tap.rs new file mode 100644 index 0000000..1a6ae66 --- /dev/null +++ b/audio-player/src/spectrum_tap.rs @@ -0,0 +1,180 @@ +//! A near-zero-cost tap on the audio the player is playing, feeding the +//! frequency-spectrum visualizer (architecture/spectrum.md). +//! +//! [`TappingSource`] wraps the decoded rodio source and, as the mixer +//! pulls samples on the audio thread, copies each frame (downmixed to +//! mono) into a fixed lock-free ring, [`SpectrumTap`]. The server's FFT +//! task reads snapshots of that ring off the audio thread. The tap only +//! *observes*: it never blocks, allocates, or logs on the audio path, +//! and benign read/write races are acceptable for a visualizer. + +use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use rodio::source::SeekError; +use rodio::{ChannelCount, Sample, SampleRate, Source}; + +/// Ring length in mono frames — BeSpec's 2048-point FFT window. +pub const SPECTRUM_WINDOW: usize = 2048; + +/// A fixed ring of the most recent mono samples plus a monotonically +/// increasing frame counter. Single-producer (the audio thread via +/// [`TappingSource`]) / multi-consumer (the FFT task). Samples are +/// stored as `f32` bit patterns in `AtomicU32`; races are benign. +pub struct SpectrumTap { + ring: Box<[AtomicU32]>, + /// Total frames ever written; `% SPECTRUM_WINDOW` is the next slot, + /// and the value doubles as the idle-detection counter. + written: AtomicU64, +} + +impl SpectrumTap { + pub fn new() -> Arc { + let ring = (0..SPECTRUM_WINDOW) + .map(|_| AtomicU32::new(0)) + .collect::>() + .into_boxed_slice(); + Arc::new(Self { + ring, + written: AtomicU64::new(0), + }) + } + + /// Audio thread: record one mono frame. One relaxed store plus a + /// counter bump — nothing else. + fn push(&self, sample: f32) { + let n = self.written.fetch_add(1, Ordering::Relaxed); + let slot = (n as usize) % SPECTRUM_WINDOW; + self.ring[slot].store(sample.to_bits(), Ordering::Relaxed); + } + + /// Frames written so far. The FFT task diffs this between ticks to + /// tell "audio flowing" from "idle" without touching the player's + /// command channel (architecture/spectrum.md D2). + pub fn frame_count(&self) -> u64 { + self.written.load(Ordering::Relaxed) + } + + /// A chronological snapshot (oldest first) of the ring, for the FFT. + /// May momentarily mix samples from an in-flight write — harmless + /// for visualization. + pub fn snapshot(&self) -> Vec { + let written = self.written.load(Ordering::Relaxed) as usize; + (0..SPECTRUM_WINDOW) + .map(|k| { + let idx = written.wrapping_add(k) % SPECTRUM_WINDOW; + f32::from_bits(self.ring[idx].load(Ordering::Relaxed)) + }) + .collect() + } +} + +/// Wraps a rodio source, mirroring each played frame into a +/// [`SpectrumTap`]. Every `Source`/`Iterator` method delegates to the +/// inner source unchanged (including `try_seek`, so track seeking keeps +/// working) — the only addition is the per-frame mono downmix pushed to +/// the tap. +pub struct TappingSource { + inner: S, + tap: Arc, + /// Channels of the current span; re-read at each frame boundary so a + /// mid-stream channel change cannot desync the downmix. + channels: u16, + channel_index: u16, + frame_sum: f32, +} + +impl TappingSource { + pub fn new(inner: S, tap: Arc) -> Self { + let channels = inner.channels().get(); + Self { + inner, + tap, + channels, + channel_index: 0, + frame_sum: 0.0, + } + } +} + +impl Iterator for TappingSource { + type Item = Sample; + + fn next(&mut self) -> Option { + let sample = self.inner.next()?; + self.frame_sum += sample; + self.channel_index += 1; + if self.channel_index >= self.channels { + let mono = self.frame_sum / f32::from(self.channels.max(1)); + self.tap.push(mono); + self.frame_sum = 0.0; + self.channel_index = 0; + // Track channel-count changes between spans. + self.channels = self.inner.channels().get(); + } + Some(sample) + } + + fn size_hint(&self) -> (usize, Option) { + self.inner.size_hint() + } +} + +impl Source for TappingSource { + fn current_span_len(&self) -> Option { + self.inner.current_span_len() + } + fn channels(&self) -> ChannelCount { + self.inner.channels() + } + fn sample_rate(&self) -> SampleRate { + self.inner.sample_rate() + } + fn total_duration(&self) -> Option { + self.inner.total_duration() + } + fn try_seek(&mut self, pos: Duration) -> Result<(), SeekError> { + self.inner.try_seek(pos) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rodio::buffer::SamplesBuffer; + + #[test] + fn snapshot_returns_the_window_oldest_first() { + let tap = SpectrumTap::new(); + // Write one full window plus a bit, so the ring has wrapped. + for i in 0..(SPECTRUM_WINDOW + 3) { + tap.push(i as f32); + } + let snap = tap.snapshot(); + assert_eq!(snap.len(), SPECTRUM_WINDOW); + // Oldest retained frame is (total - window); newest is total-1. + let total = (SPECTRUM_WINDOW + 3) as f32; + assert_eq!(*snap.first().unwrap(), total - SPECTRUM_WINDOW as f32); + assert_eq!(*snap.last().unwrap(), total - 1.0); + } + + #[test] + fn tapping_source_downmixes_and_passes_samples_through() { + // Stereo: [L,R, L,R] = frames (1,3) and (5,7) → mono 2 and 6. + let tap = SpectrumTap::new(); + let buf = SamplesBuffer::new( + ChannelCount::new(2).unwrap(), + SampleRate::new(44_100).unwrap(), + vec![1.0f32, 3.0, 5.0, 7.0], + ); + let tapped: Vec = TappingSource::new(buf, tap.clone()).collect(); + // Playback is untouched: every sample passes through verbatim. + assert_eq!(tapped, vec![1.0, 3.0, 5.0, 7.0]); + // Two mono frames were tapped. + assert_eq!(tap.frame_count(), 2); + let snap = tap.snapshot(); + assert_eq!(snap[SPECTRUM_WINDOW - 2], 2.0); + assert_eq!(snap[SPECTRUM_WINDOW - 1], 6.0); + } +} diff --git a/cbd-tui/src/app/now_playing.rs b/cbd-tui/src/app/now_playing.rs index 0277024..1bc2615 100644 --- a/cbd-tui/src/app/now_playing.rs +++ b/cbd-tui/src/app/now_playing.rs @@ -12,7 +12,16 @@ use ratatui::{ Frame, }; -use super::COLOR_SECONDARY; +use super::{COLOR_PRIMARY, COLOR_SECONDARY}; + +/// Vertical block glyphs by eighths, index 0 = empty. Maps a bar level +/// in `[0, 1]` to a height. +const BLOCKS: [char; 9] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + +fn bar_glyph(level: f32) -> char { + let idx = (level.clamp(0.0, 1.0) * 8.0).round() as usize; + BLOCKS[idx.min(8)] +} pub struct NowPlaying { play_state: PlayState, @@ -20,6 +29,11 @@ pub struct NowPlaying { modifiers: QueueModifiers, position: Option, track: Option, + /// Latest frequency-spectrum bars (architecture/spectrum.md), empty + /// until the first frame arrives. + spectrum: Vec, + /// Whether to draw the spectrum row (config `spectrum`, default on). + spectrum_enabled: bool, } impl Default for NowPlaying { @@ -30,6 +44,8 @@ impl Default for NowPlaying { modifiers: QueueModifiers::default(), position: None, track: None, + spectrum: Vec::new(), + spectrum_enabled: true, } } } @@ -74,11 +90,25 @@ impl NowPlaying { pub fn update_modifiers(&mut self, mods: &QueueModifiers) { self.modifiers = *mods; } + /// Applies a spectrum frame from the server (already normalized). + pub fn update_spectrum(&mut self, bins: Vec) { + self.spectrum = bins; + } + /// Enables/disables the spectrum row (from client config). + pub fn set_spectrum_enabled(&mut self, enabled: bool) { + self.spectrum_enabled = enabled; + } pub fn render(&self, f: &mut Frame, area: Rect) { + // Info block, progress row, then (when enabled) a spectrum row. + let spectrum_row = if self.spectrum_enabled { 1 } else { 0 }; let now_playing_layout = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Max(8), Constraint::Max(1)]) + .constraints([ + Constraint::Min(6), + Constraint::Max(1), + Constraint::Max(spectrum_row), + ]) .split(area); let media_info_text = if let Some(track) = &self.track { @@ -188,6 +218,25 @@ impl NowPlaying { let time_p = Paragraph::new(Line::from(time_text)); f.render_widget(time_p, elapsed_layout[1]); } + + // The spectrum row: one block glyph per bar, in the accent color. + // Stretched to the pane width so it fills the row regardless of + // the server's bin count. + if self.spectrum_enabled && !self.spectrum.is_empty() { + let width = now_playing_layout[2].width as usize; + let bars: String = (0..width) + .map(|col| { + // Nearest-neighbor map from column to bin. + let bin = col * self.spectrum.len() / width.max(1); + bar_glyph(self.spectrum[bin.min(self.spectrum.len() - 1)]) + }) + .collect(); + let spectrum_p = Paragraph::new(Line::from(Span::styled( + bars, + Style::default().fg(COLOR_PRIMARY), + ))); + f.render_widget(spectrum_p, now_playing_layout[2]); + } } } @@ -212,6 +261,8 @@ mod tests { album: None, is_skipped: false, }), + spectrum: Vec::new(), + spectrum_enabled: true, } } @@ -223,6 +274,54 @@ mod tests { .expect("draw must not panic"); } + /// Renders and returns the buffer rows as strings. + fn rendered_rows(pane: &NowPlaying) -> Vec { + let backend = TestBackend::new(60, 12); + let mut terminal = Terminal::new(backend).expect("test terminal"); + terminal.draw(|f| pane.render(f, f.area())).expect("draw"); + let buffer = terminal.backend().buffer().clone(); + (0..buffer.area.height) + .map(|y| { + (0..buffer.area.width) + .map(|x| buffer[(x, y)].symbol().to_string()) + .collect::() + }) + .collect() + } + + #[test] + fn bar_glyph_maps_level_to_height() { + assert_eq!(bar_glyph(0.0), ' '); + assert_eq!(bar_glyph(1.0), '█'); + assert_eq!(bar_glyph(-5.0), ' ', "clamped"); + assert_eq!(bar_glyph(2.0), '█', "clamped"); + assert!(BLOCKS.contains(&bar_glyph(0.5))); + } + + #[test] + fn the_spectrum_row_renders_block_glyphs_when_enabled() { + let mut pane = now_playing(10_000, 60_000); + pane.update_spectrum(vec![1.0; 24]); + let rows = rendered_rows(&pane); + // A full-height frame draws the tallest block somewhere. + assert!( + rows.iter().any(|r| r.contains('█')), + "expected spectrum bars, got: {rows:?}" + ); + } + + #[test] + fn the_spectrum_row_is_hidden_when_disabled() { + let mut pane = now_playing(10_000, 60_000); + pane.set_spectrum_enabled(false); + pane.update_spectrum(vec![1.0; 24]); + let rows = rendered_rows(&pane); + assert!( + !rows.iter().any(|r| r.contains('█')), + "disabled spectrum must not draw bars: {rows:?}" + ); + } + /// The position can overrun a stale or wrong duration (streams, /// hand-written track files); the gauge must clamp instead of hitting /// ratatui's `ratio should be between 0 and 1` panic. diff --git a/cbd-tui/src/config.rs b/cbd-tui/src/config.rs index 1d81bf3..f07ed91 100644 --- a/cbd-tui/src/config.rs +++ b/cbd-tui/src/config.rs @@ -27,4 +27,10 @@ pub struct ServerConfig { #[default(String::new())] #[clap(short, long)] pub password: String, + + /// Show the frequency-spectrum bars under the track progress + /// (architecture/spectrum.md). On by default; set false to hide. + #[default(true)] + #[clap(long)] + pub spectrum: bool, } diff --git a/cbd-tui/src/lib.rs b/cbd-tui/src/lib.rs index 8c4590c..69fa887 100644 --- a/cbd-tui/src/lib.rs +++ b/cbd-tui/src/lib.rs @@ -42,8 +42,9 @@ pub async fn run(config: &'static Config) -> Result<(), Box> { // FIXME: unwrap tokio::spawn(async move { orchestrate(config, (tx, rx)).await.unwrap() }); - tokio::task::spawn_blocking(|| { - run_ui(ui_tx, ui_rx); + let spectrum_enabled = config.server.spectrum; + tokio::task::spawn_blocking(move || { + run_ui(ui_tx, ui_rx, spectrum_enabled); }) .await?; @@ -205,7 +206,7 @@ async fn poll( Ok(()) } -fn run_ui(tx: Sender, rx: Receiver) { +fn run_ui(tx: Sender, rx: Receiver, spectrum_enabled: bool) { // setup terminal enable_raw_mode().unwrap(); let mut stdout = io::stdout(); @@ -215,6 +216,7 @@ fn run_ui(tx: Sender, rx: Receiver) { // create app and run it let mut app = App::new(tx); + app.now_playing.set_spectrum_enabled(spectrum_enabled); let tick_rate = Duration::from_millis(100); let mut last_tick = Instant::now(); @@ -261,6 +263,9 @@ fn run_ui(tx: Sender, rx: Receiver) { StreamUpdate::CaptureProgress(progress) => { app.captures.apply(progress); } + StreamUpdate::Spectrum(frame) => { + app.now_playing.update_spectrum(frame.bins); + } }, } } diff --git a/cbd-web/src/app.rs b/cbd-web/src/app.rs index 51607af..1f0bff7 100644 --- a/cbd-web/src/app.rs +++ b/cbd-web/src/app.rs @@ -82,6 +82,7 @@ struct Store { mute: RwSignal, mods: RwSignal, position: RwSignal, + spectrum: RwSignal>, capture_lines: RwSignal>, library: RwSignal, queue_cursor: RwSignal, @@ -109,6 +110,7 @@ impl Store { mute: RwSignal::new(false), mods: RwSignal::new(QueueModifiers::default()), position: RwSignal::new(TrackPosition::default()), + spectrum: RwSignal::new(Vec::new()), capture_lines: RwSignal::new(Vec::new()), library: RwSignal::new(LibraryPane::default()), queue_cursor: RwSignal::new(QueueCursor::default()), @@ -174,6 +176,7 @@ impl Store { self.board.update_value(|b| b.apply(progress, now_ms())); self.refresh_capture_lines(); } + StreamUpdate::Spectrum(frame) => self.spectrum.set(frame.bins), } } @@ -939,6 +942,19 @@ fn Transport(store: Store) -> impl IntoView { >"↻"
+ {move || store.now_playing.get().map(|t| track_label(&t)).unwrap_or_default()} diff --git a/cbd-web/style.css b/cbd-web/style.css index 68c44a0..a06b25f 100644 --- a/cbd-web/style.css +++ b/cbd-web/style.css @@ -301,6 +301,24 @@ input { text-overflow: ellipsis; font-weight: 600; } + + /* Frequency-spectrum bars (architecture/spectrum.md): a row of + accent-colored columns whose heights track the streamed bins. */ + & .spectrum { + display: flex; + align-items: flex-end; + gap: 1px; + block-size: 1.5rem; + margin-block-end: 0.15rem; + + & .bar { + flex: 1; + min-block-size: 1px; + background: var(--accent); + border-radius: 1px; + transition: height 0.08s linear; + } + } } & .progress { diff --git a/crabidy-core/crabidy/v1/crabidy.proto b/crabidy-core/crabidy/v1/crabidy.proto index 02f1706..fa70670 100644 --- a/crabidy-core/crabidy/v1/crabidy.proto +++ b/crabidy-core/crabidy/v1/crabidy.proto @@ -181,9 +181,20 @@ message GetUpdateStreamResponse { bool mute = 6; TrackPosition position = 7; CaptureProgress capture_progress = 8; + SpectrumFrame spectrum = 9; } } +// One frame of the audio frequency spectrum (architecture/spectrum.md). +// Broadcast by the server at a low frame rate while audio is playing; an +// all-zero frame signals silence. Clients render `bins` as bars; they do +// no signal processing themselves. +message SpectrumFrame { + // Normalized magnitudes in [0, 1], low frequency first, log-spaced. + // The count is the server's bin resolution (a handful of bars). + repeated float bins = 1; +} + // Progress of a running capture (CaptureLibraryNode). Broadcast after each // processed track; exactly one event per capture sets `finished` (with // `error` on failure). diff --git a/crabidy-server/Cargo.toml b/crabidy-server/Cargo.toml index 0b91bc3..8eab9a5 100644 --- a/crabidy-server/Cargo.toml +++ b/crabidy-server/Cargo.toml @@ -22,6 +22,7 @@ base64.workspace = true clap.workspace = true http.workspace = true include_dir = { workspace = true, optional = true } +realfft.workspace = true tonic-web = { workspace = true, optional = true } tower.workspace = true audio-player.workspace = true diff --git a/crabidy-server/src/lib.rs b/crabidy-server/src/lib.rs index 76ea8a5..5fee402 100644 --- a/crabidy-server/src/lib.rs +++ b/crabidy-server/src/lib.rs @@ -10,6 +10,7 @@ pub mod provider; pub mod queue_store; pub mod rpc; pub mod settings; +pub mod spectrum; use audio_player::PlayerMessage; use crabidy_core::proto::crabidy::{ @@ -91,6 +92,8 @@ pub async fn serve( }); info!("player message forwarder started"); + spawn_spectrum_task(playback.player.spectrum_tap(), update_tx.clone()); + let crabidy_service = rpc::RpcService::new( update_tx, playback.playback_tx.clone(), @@ -140,6 +143,49 @@ pub fn build_router( router } +/// The spectrum FFT loop (architecture/spectrum.md): ~20 fps, snapshots +/// the player's sample tap, folds it into frequency bars, and +/// broadcasts them. Cheap and gated: it skips ticks with no stream +/// subscribers, and only recomputes when the tap advanced since the +/// last tick (audio is flowing), emitting a single zero frame when +/// playback goes idle so the bars fall rather than freeze. +fn spawn_spectrum_task( + tap: std::sync::Arc, + update_tx: tokio::sync::broadcast::Sender< + crabidy_core::proto::crabidy::get_update_stream_response::Update, + >, +) { + use crabidy_core::proto::crabidy::{get_update_stream_response::Update, SpectrumFrame}; + + const FPS: u64 = 20; + tokio::spawn(async move { + let mut analyzer = spectrum::SpectrumAnalyzer::new(audio_player::SPECTRUM_WINDOW); + let mut last_count = tap.frame_count(); + let mut was_active = false; + let mut interval = tokio::time::interval(std::time::Duration::from_millis(1000 / FPS)); + loop { + interval.tick().await; + // Nobody watching: do no work. + if update_tx.receiver_count() == 0 { + continue; + } + let count = tap.frame_count(); + if count != last_count { + last_count = count; + was_active = true; + let bins = analyzer.analyze(&tap.snapshot()); + let _ = update_tx.send(Update::Spectrum(SpectrumFrame { bins })); + } else if was_active { + // Playback just went idle: drop the bars to the floor once. + was_active = false; + let _ = update_tx.send(Update::Spectrum(SpectrumFrame { + bins: vec![0.0; spectrum::SPECTRUM_BINS], + })); + } + } + }); +} + /// Forwards player engine events into the playback message loop. #[instrument(skip(rx, tx))] fn poll_play_bus(rx: flume::Receiver, tx: flume::Sender) { diff --git a/crabidy-server/src/spectrum.rs b/crabidy-server/src/spectrum.rs new file mode 100644 index 0000000..3263cb1 --- /dev/null +++ b/crabidy-server/src/spectrum.rs @@ -0,0 +1,154 @@ +//! Turns a window of audio samples into a handful of normalized +//! frequency bars (architecture/spectrum.md). Pure DSP — the tap +//! (`audio-player`) supplies samples, the stream carries the result; +//! this only does the maths. + +use realfft::num_complex::Complex; +use realfft::{RealFftPlanner, RealToComplex}; +use std::sync::Arc; + +/// Number of bars the visualizer shows. +pub const SPECTRUM_BINS: usize = 24; + +/// Magnitudes below this (dBFS) map to an empty bar; 0 dB maps to full. +const MIN_DB: f32 = -60.0; + +/// A reusable forward-FFT + log-bin folder for a fixed window length. +pub struct SpectrumAnalyzer { + fft: Arc>, + /// Hann window applied before the transform to cut spectral leakage. + window: Vec, + input: Vec, + output: Vec>, + /// `SPECTRUM_BINS + 1` boundaries into the magnitude array, spaced + /// logarithmically so the bars are roughly musically even. + edges: Vec, +} + +impl SpectrumAnalyzer { + pub fn new(window_len: usize) -> Self { + let fft = RealFftPlanner::::new().plan_fft_forward(window_len); + let input = fft.make_input_vec(); + let output = fft.make_output_vec(); + let window = (0..window_len).map(|i| hann(i, window_len)).collect(); + let edges = log_bin_edges(output.len(), SPECTRUM_BINS); + Self { + fft, + window, + input, + output, + edges, + } + } + + /// Folds `samples` (exactly the window length) into `SPECTRUM_BINS` + /// normalized `[0, 1]` magnitudes, low frequency first. A processing + /// error or a wrong-length input yields all-zero bars rather than a + /// panic. + pub fn analyze(&mut self, samples: &[f32]) -> Vec { + if samples.len() != self.input.len() { + return vec![0.0; SPECTRUM_BINS]; + } + for (dst, (sample, w)) in self + .input + .iter_mut() + .zip(samples.iter().zip(self.window.iter())) + { + *dst = sample * w; + } + if self.fft.process(&mut self.input, &mut self.output).is_err() { + return vec![0.0; SPECTRUM_BINS]; + } + let n = self.window.len() as f32; + let magnitude = |c: &Complex| c.norm() / n; + (0..SPECTRUM_BINS) + .map(|bar| { + let lo = self.edges[bar]; + let hi = self.edges[bar + 1].max(lo + 1).min(self.output.len()); + let avg = self.output[lo..hi].iter().map(magnitude).sum::() / (hi - lo) as f32; + // Log-compress: dBFS mapped onto [0, 1]. + let db = 20.0 * (avg + 1e-9).log10(); + ((db - MIN_DB) / -MIN_DB).clamp(0.0, 1.0) + }) + .collect() + } +} + +/// A Hann window coefficient for sample `i` of `len`. +fn hann(i: usize, len: usize) -> f32 { + let x = std::f32::consts::PI * i as f32 / (len - 1) as f32; + x.sin().powi(2) +} + +/// Geometrically-spaced boundaries into a magnitude array of `mag_len` +/// (skipping the DC bin at 0), giving `bins` groups. Monotonic +/// non-decreasing; callers widen any empty group to at least one bin. +fn log_bin_edges(mag_len: usize, bins: usize) -> Vec { + let lo = 1.0_f32; + let hi = (mag_len - 1).max(2) as f32; + (0..=bins) + .map(|b| { + let t = b as f32 / bins as f32; + (lo * (hi / lo).powf(t)).round() as usize + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + const WINDOW: usize = 2048; + + fn sine(freq: f32, sample_rate: f32, len: usize) -> Vec { + (0..len) + .map(|i| (2.0 * std::f32::consts::PI * freq * i as f32 / sample_rate).sin()) + .collect() + } + + #[test] + fn silence_is_all_zero_bars() { + let mut a = SpectrumAnalyzer::new(WINDOW); + let bars = a.analyze(&vec![0.0; WINDOW]); + assert_eq!(bars.len(), SPECTRUM_BINS); + assert!(bars.iter().all(|&b| b == 0.0), "{bars:?}"); + } + + #[test] + fn a_tone_lights_one_region_and_stays_normalized() { + let mut a = SpectrumAnalyzer::new(WINDOW); + // ~1 kHz at 44.1 kHz lands in the upper-middle of the log bars. + let bars = a.analyze(&sine(1000.0, 44_100.0, WINDOW)); + assert_eq!(bars.len(), SPECTRUM_BINS); + assert!(bars.iter().all(|&b| (0.0..=1.0).contains(&b)), "{bars:?}"); + let peak = bars + .iter() + .cloned() + .enumerate() + .max_by(|a, b| a.1.total_cmp(&b.1)) + .unwrap(); + assert!( + peak.1 > 0.5, + "a pure tone should drive its bar high: {bars:?}" + ); + // Energy is concentrated: most bars stay well below the peak. + let loud = bars.iter().filter(|&&b| b > peak.1 * 0.5).count(); + assert!( + loud <= 4, + "a tone should not light the whole spectrum: {bars:?}" + ); + } + + #[test] + fn wrong_length_input_is_zero_not_a_panic() { + let mut a = SpectrumAnalyzer::new(WINDOW); + assert!(a.analyze(&[0.1, 0.2, 0.3]).iter().all(|&b| b == 0.0)); + } + + #[test] + fn bin_edges_are_monotonic() { + let edges = log_bin_edges(WINDOW / 2 + 1, SPECTRUM_BINS); + assert_eq!(edges.len(), SPECTRUM_BINS + 1); + assert!(edges.windows(2).all(|w| w[1] >= w[0]), "{edges:?}"); + } +} diff --git a/plan/summary.md b/plan/summary.md index 4fd300c..e227fd1 100644 --- a/plan/summary.md +++ b/plan/summary.md @@ -737,3 +737,41 @@ follow-up. default, so `cbd` (local, self-contained) and a remote-pointed `cbd-tui` coexist on one machine without their `address` settings colliding. README config table + client-config section updated. + +## spectrum (2026-07-21) + +A frequency-spectrum bar row under the track progress +(`architecture/spectrum.md`). Because the audio plays on the server and +clients may be remote, the spectrum is produced server-side and +streamed — a local loopback capture (BeSpec's model) could not serve a +remote `cbd-tui`. + +- **audio-player**: `SpectrumTap` (a fixed lock-free ring of 2048 `f32` + slots, atomic write index doubling as an idle counter) and + `TappingSource`, which wraps the decoded rodio source and mirrors + each played frame (downmixed to mono) into the tap on the audio + thread — one store per sample, no locks/alloc/logging; `try_seek` + delegated so seeking still works. Exposed via `Player::spectrum_tap`. +- **crabidy-server**: `spectrum::SpectrumAnalyzer` (Hann window + + realfft, log-spaced bins, dBFS→[0,1]) and a ~20 fps task that skips + when no stream subscribers, snapshots the tap, and broadcasts a new + `StreamUpdate::Spectrum(SpectrumFrame{bins})`; it diffs the tap's + frame counter to emit a single zero frame on going idle (bars fall, + don't freeze) without touching the player command path. +- **proto**: `SpectrumFrame` + oneof field 9 on `GetUpdateStream`. +- **cbd-tui**: a bar row (block glyphs `▁..█`, accent color) under the + progress gauge in the now-playing pane; `spectrum` config option + (default true) on both `cbd-tui.toml` and `cbd.toml`. +- **cbd-web**: the same bins rendered as CSS-height accent bars + (parity). + +Tests: 2 tap tests (downmix + snapshot ordering), 4 DSP tests (silence, +tone concentrates in one region + stays normalized, wrong-length is +zero-not-panic, monotonic bin edges), 3 TUI render tests (glyph +mapping, bars shown when enabled, hidden when disabled). All workspace +tests green; clippy (native + wasm) and fmt clean. + +**Not exercised**: the end-to-end audio→FFT→stream path needs a real +audio output device, unavailable in this headless environment. The +components are unit-tested and the wiring compiles and starts; the live +path should be sanity-checked on a machine with audio.