Add a server-streamed frequency spectrum visualizer
A row of frequency bars under the track progress, on by default and toggleable with the client spectrum config option. Because the audio plays on the server and clients may be remote, the spectrum is produced server-side, not captured locally: audio-player taps its own output into a lock-free ring on the audio thread (one store per sample, no locks), crabidy-server runs a Hann + realfft over 2048 samples at 20fps, folds it into log-spaced bars, and broadcasts them as a new SpectrumFrame on the update stream. The task idles when nothing is playing or no client is listening. The TUI renders block-glyph bars in the now-playing pane; the web client renders the same bins as CSS bars. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
21fc4dc15a
commit
572be04206
|
|
@ -1086,6 +1086,7 @@ dependencies = [
|
||||||
"http",
|
"http",
|
||||||
"include_dir",
|
"include_dir",
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
|
"realfft",
|
||||||
"reqwest 0.13.1",
|
"reqwest 0.13.1",
|
||||||
"serde",
|
"serde",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
|
|
@ -2905,6 +2906,15 @@ dependencies = [
|
||||||
"num-traits",
|
"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]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
|
|
@ -3418,6 +3428,15 @@ dependencies = [
|
||||||
"syn 2.0.119",
|
"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]]
|
[[package]]
|
||||||
name = "proc-macro-crate"
|
name = "proc-macro-crate"
|
||||||
version = "3.5.0"
|
version = "3.5.0"
|
||||||
|
|
@ -3889,6 +3908,15 @@ dependencies = [
|
||||||
"syn 2.0.119",
|
"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]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
|
|
@ -4121,6 +4149,20 @@ dependencies = [
|
||||||
"semver",
|
"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]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
|
|
@ -4657,6 +4699,12 @@ dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strength_reduce"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "strsim"
|
name = "strsim"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
|
|
@ -5582,6 +5630,16 @@ dependencies = [
|
||||||
"tracing-log",
|
"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]]
|
[[package]]
|
||||||
name = "try-lock"
|
name = "try-lock"
|
||||||
version = "0.2.5"
|
version = "0.2.5"
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ notify-rust = "4"
|
||||||
percent-encoding = "2"
|
percent-encoding = "2"
|
||||||
prost = "0.14"
|
prost = "0.14"
|
||||||
rand = "0.10"
|
rand = "0.10"
|
||||||
|
realfft = "3"
|
||||||
ratatui = "0.30"
|
ratatui = "0.30"
|
||||||
reqwest = { version = "0.13", default-features = false, features = [
|
reqwest = { version = "0.13", default-features = false, features = [
|
||||||
"json",
|
"json",
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,9 @@ address = "http://127.0.0.1:50051"
|
||||||
# The password is stored in plaintext — keep this file private.
|
# The password is stored in plaintext — keep this file private.
|
||||||
user = ""
|
user = ""
|
||||||
password = ""
|
password = ""
|
||||||
|
|
||||||
|
# Show the frequency-spectrum bars under the track progress. Default true.
|
||||||
|
spectrum = true
|
||||||
```
|
```
|
||||||
|
|
||||||
Every option is also available as a command-line flag
|
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.
|
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
|
## Web client
|
||||||
|
|
||||||
`crabidy-server` serves a browser client with the same functionality as
|
`crabidy-server` serves a browser client with the same functionality as
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
mod player;
|
mod player;
|
||||||
mod player_engine;
|
mod player_engine;
|
||||||
|
mod spectrum_tap;
|
||||||
pub mod windowed_http;
|
pub mod windowed_http;
|
||||||
|
|
||||||
pub use player::{Player, PlayerError};
|
pub use player::{Player, PlayerError};
|
||||||
pub use player_engine::{MediaInfo, PlayerMessage};
|
pub use player_engine::{MediaInfo, PlayerMessage};
|
||||||
|
pub use spectrum_tap::{SpectrumTap, SPECTRUM_WINDOW};
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,19 @@ use anyhow::Result;
|
||||||
use flume::{Receiver, Sender};
|
use flume::{Receiver, Sender};
|
||||||
use tracing::error;
|
use tracing::error;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::player_engine::{MediaInfo, PlayerEngine, PlayerEngineCommand, PlayerMessage};
|
use crate::player_engine::{MediaInfo, PlayerEngine, PlayerEngineCommand, PlayerMessage};
|
||||||
|
use crate::spectrum_tap::SpectrumTap;
|
||||||
|
|
||||||
pub enum PlayerError {}
|
pub enum PlayerError {}
|
||||||
|
|
||||||
pub struct Player {
|
pub struct Player {
|
||||||
pub messages: Receiver<PlayerMessage>,
|
pub messages: Receiver<PlayerMessage>,
|
||||||
tx_engine: Sender<PlayerEngineCommand>,
|
tx_engine: Sender<PlayerEngineCommand>,
|
||||||
|
/// The spectrum tap, shared with the engine thread. The server's FFT
|
||||||
|
/// task reads it (architecture/spectrum.md).
|
||||||
|
spectrum: Arc<SpectrumTap>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Player {
|
impl Default for Player {
|
||||||
|
|
@ -25,8 +31,12 @@ impl Default for Player {
|
||||||
// tokio context but needs one to create http streams.
|
// tokio context but needs one to create http streams.
|
||||||
let runtime = tokio::runtime::Handle::try_current().ok();
|
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 || {
|
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) => {
|
Err(e) => {
|
||||||
error!("Could not initialize player: {}", e);
|
error!("Could not initialize player: {}", e);
|
||||||
return;
|
return;
|
||||||
|
|
@ -39,11 +49,17 @@ impl Default for Player {
|
||||||
Self {
|
Self {
|
||||||
messages,
|
messages,
|
||||||
tx_engine,
|
tx_engine,
|
||||||
|
spectrum,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Player {
|
impl Player {
|
||||||
|
/// The spectrum tap the played audio is mirrored into.
|
||||||
|
pub fn spectrum_tap(&self) -> Arc<SpectrumTap> {
|
||||||
|
self.spectrum.clone()
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn play(&self, source_str: &str) -> Result<MediaInfo> {
|
pub async fn play(&self, source_str: &str) -> Result<MediaInfo> {
|
||||||
let (tx, rx) = flume::bounded(1);
|
let (tx, rx) = flume::bounded(1);
|
||||||
self.tx_engine
|
self.tx_engine
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,9 @@ use rodio::{Decoder, Source};
|
||||||
use stream_download::storage::temp::TempStorageProvider;
|
use stream_download::storage::temp::TempStorageProvider;
|
||||||
use stream_download::{Settings, StreamDownload};
|
use stream_download::{Settings, StreamDownload};
|
||||||
|
|
||||||
|
use crate::spectrum_tap::{SpectrumTap, TappingSource};
|
||||||
use crate::windowed_http::{WindowedHttpParams, WindowedHttpStream};
|
use crate::windowed_http::{WindowedHttpParams, WindowedHttpStream};
|
||||||
|
use std::sync::Arc;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tracing::{debug, info, instrument, trace, warn};
|
use tracing::{debug, info, instrument, trace, warn};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
@ -94,6 +96,10 @@ pub struct PlayerEngine {
|
||||||
_owned_runtime: Option<tokio::runtime::Runtime>,
|
_owned_runtime: Option<tokio::runtime::Runtime>,
|
||||||
/// Shared client for windowed network streams.
|
/// Shared client for windowed network streams.
|
||||||
http: reqwest::Client,
|
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<SpectrumTap>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PlayerEngine {
|
impl PlayerEngine {
|
||||||
|
|
@ -101,6 +107,7 @@ impl PlayerEngine {
|
||||||
tx_engine: Sender<PlayerEngineCommand>,
|
tx_engine: Sender<PlayerEngineCommand>,
|
||||||
tx_player: Sender<PlayerMessage>,
|
tx_player: Sender<PlayerMessage>,
|
||||||
runtime: Option<tokio::runtime::Handle>,
|
runtime: Option<tokio::runtime::Handle>,
|
||||||
|
spectrum: Arc<SpectrumTap>,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let stream =
|
let stream =
|
||||||
DeviceSinkBuilder::open_default_sink().context("failed to open audio output device")?;
|
DeviceSinkBuilder::open_default_sink().context("failed to open audio output device")?;
|
||||||
|
|
@ -132,6 +139,7 @@ impl PlayerEngine {
|
||||||
runtime,
|
runtime,
|
||||||
_owned_runtime: owned_runtime,
|
_owned_runtime: owned_runtime,
|
||||||
http,
|
http,
|
||||||
|
spectrum,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -251,7 +259,10 @@ impl PlayerEngine {
|
||||||
}
|
}
|
||||||
let decoder = builder.build().context("failed to decode http stream")?;
|
let decoder = builder.build().context("failed to decode http stream")?;
|
||||||
let duration = decoder.total_duration();
|
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
|
duration
|
||||||
}
|
}
|
||||||
Ok(url) => return Err(anyhow!("Not a valid URL scheme: {}", url.scheme())),
|
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 decoder = builder.build().context("failed to decode file")?;
|
||||||
let duration = decoder.total_duration();
|
let duration = decoder.total_duration();
|
||||||
self.sink.append(decoder);
|
self.sink
|
||||||
|
.append(TappingSource::new(decoder, self.spectrum.clone()));
|
||||||
duration
|
duration
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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<Self> {
|
||||||
|
let ring = (0..SPECTRUM_WINDOW)
|
||||||
|
.map(|_| AtomicU32::new(0))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.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<f32> {
|
||||||
|
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<S> {
|
||||||
|
inner: S,
|
||||||
|
tap: Arc<SpectrumTap>,
|
||||||
|
/// 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<S: Source> TappingSource<S> {
|
||||||
|
pub fn new(inner: S, tap: Arc<SpectrumTap>) -> Self {
|
||||||
|
let channels = inner.channels().get();
|
||||||
|
Self {
|
||||||
|
inner,
|
||||||
|
tap,
|
||||||
|
channels,
|
||||||
|
channel_index: 0,
|
||||||
|
frame_sum: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S: Source> Iterator for TappingSource<S> {
|
||||||
|
type Item = Sample;
|
||||||
|
|
||||||
|
fn next(&mut self) -> Option<Sample> {
|
||||||
|
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<usize>) {
|
||||||
|
self.inner.size_hint()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S: Source> Source for TappingSource<S> {
|
||||||
|
fn current_span_len(&self) -> Option<usize> {
|
||||||
|
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<Duration> {
|
||||||
|
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<f32> = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,16 @@ use ratatui::{
|
||||||
Frame,
|
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 {
|
pub struct NowPlaying {
|
||||||
play_state: PlayState,
|
play_state: PlayState,
|
||||||
|
|
@ -20,6 +29,11 @@ pub struct NowPlaying {
|
||||||
modifiers: QueueModifiers,
|
modifiers: QueueModifiers,
|
||||||
position: Option<Duration>,
|
position: Option<Duration>,
|
||||||
track: Option<Track>,
|
track: Option<Track>,
|
||||||
|
/// Latest frequency-spectrum bars (architecture/spectrum.md), empty
|
||||||
|
/// until the first frame arrives.
|
||||||
|
spectrum: Vec<f32>,
|
||||||
|
/// Whether to draw the spectrum row (config `spectrum`, default on).
|
||||||
|
spectrum_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for NowPlaying {
|
impl Default for NowPlaying {
|
||||||
|
|
@ -30,6 +44,8 @@ impl Default for NowPlaying {
|
||||||
modifiers: QueueModifiers::default(),
|
modifiers: QueueModifiers::default(),
|
||||||
position: None,
|
position: None,
|
||||||
track: None,
|
track: None,
|
||||||
|
spectrum: Vec::new(),
|
||||||
|
spectrum_enabled: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -74,11 +90,25 @@ impl NowPlaying {
|
||||||
pub fn update_modifiers(&mut self, mods: &QueueModifiers) {
|
pub fn update_modifiers(&mut self, mods: &QueueModifiers) {
|
||||||
self.modifiers = *mods;
|
self.modifiers = *mods;
|
||||||
}
|
}
|
||||||
|
/// Applies a spectrum frame from the server (already normalized).
|
||||||
|
pub fn update_spectrum(&mut self, bins: Vec<f32>) {
|
||||||
|
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) {
|
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()
|
let now_playing_layout = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([Constraint::Max(8), Constraint::Max(1)])
|
.constraints([
|
||||||
|
Constraint::Min(6),
|
||||||
|
Constraint::Max(1),
|
||||||
|
Constraint::Max(spectrum_row),
|
||||||
|
])
|
||||||
.split(area);
|
.split(area);
|
||||||
|
|
||||||
let media_info_text = if let Some(track) = &self.track {
|
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));
|
let time_p = Paragraph::new(Line::from(time_text));
|
||||||
f.render_widget(time_p, elapsed_layout[1]);
|
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,
|
album: None,
|
||||||
is_skipped: false,
|
is_skipped: false,
|
||||||
}),
|
}),
|
||||||
|
spectrum: Vec::new(),
|
||||||
|
spectrum_enabled: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -223,6 +274,54 @@ mod tests {
|
||||||
.expect("draw must not panic");
|
.expect("draw must not panic");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Renders and returns the buffer rows as strings.
|
||||||
|
fn rendered_rows(pane: &NowPlaying) -> Vec<String> {
|
||||||
|
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::<String>()
|
||||||
|
})
|
||||||
|
.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,
|
/// The position can overrun a stale or wrong duration (streams,
|
||||||
/// hand-written track files); the gauge must clamp instead of hitting
|
/// hand-written track files); the gauge must clamp instead of hitting
|
||||||
/// ratatui's `ratio should be between 0 and 1` panic.
|
/// ratatui's `ratio should be between 0 and 1` panic.
|
||||||
|
|
|
||||||
|
|
@ -27,4 +27,10 @@ pub struct ServerConfig {
|
||||||
#[default(String::new())]
|
#[default(String::new())]
|
||||||
#[clap(short, long)]
|
#[clap(short, long)]
|
||||||
pub password: String,
|
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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,8 +42,9 @@ pub async fn run(config: &'static Config) -> Result<(), Box<dyn Error>> {
|
||||||
// FIXME: unwrap
|
// FIXME: unwrap
|
||||||
tokio::spawn(async move { orchestrate(config, (tx, rx)).await.unwrap() });
|
tokio::spawn(async move { orchestrate(config, (tx, rx)).await.unwrap() });
|
||||||
|
|
||||||
tokio::task::spawn_blocking(|| {
|
let spectrum_enabled = config.server.spectrum;
|
||||||
run_ui(ui_tx, ui_rx);
|
tokio::task::spawn_blocking(move || {
|
||||||
|
run_ui(ui_tx, ui_rx, spectrum_enabled);
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|
@ -205,7 +206,7 @@ async fn poll(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_ui(tx: Sender<MessageFromUi>, rx: Receiver<MessageToUi>) {
|
fn run_ui(tx: Sender<MessageFromUi>, rx: Receiver<MessageToUi>, spectrum_enabled: bool) {
|
||||||
// setup terminal
|
// setup terminal
|
||||||
enable_raw_mode().unwrap();
|
enable_raw_mode().unwrap();
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
|
|
@ -215,6 +216,7 @@ fn run_ui(tx: Sender<MessageFromUi>, rx: Receiver<MessageToUi>) {
|
||||||
|
|
||||||
// create app and run it
|
// create app and run it
|
||||||
let mut app = App::new(tx);
|
let mut app = App::new(tx);
|
||||||
|
app.now_playing.set_spectrum_enabled(spectrum_enabled);
|
||||||
let tick_rate = Duration::from_millis(100);
|
let tick_rate = Duration::from_millis(100);
|
||||||
let mut last_tick = Instant::now();
|
let mut last_tick = Instant::now();
|
||||||
|
|
||||||
|
|
@ -261,6 +263,9 @@ fn run_ui(tx: Sender<MessageFromUi>, rx: Receiver<MessageToUi>) {
|
||||||
StreamUpdate::CaptureProgress(progress) => {
|
StreamUpdate::CaptureProgress(progress) => {
|
||||||
app.captures.apply(progress);
|
app.captures.apply(progress);
|
||||||
}
|
}
|
||||||
|
StreamUpdate::Spectrum(frame) => {
|
||||||
|
app.now_playing.update_spectrum(frame.bins);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,7 @@ struct Store {
|
||||||
mute: RwSignal<bool>,
|
mute: RwSignal<bool>,
|
||||||
mods: RwSignal<QueueModifiers>,
|
mods: RwSignal<QueueModifiers>,
|
||||||
position: RwSignal<TrackPosition>,
|
position: RwSignal<TrackPosition>,
|
||||||
|
spectrum: RwSignal<Vec<f32>>,
|
||||||
capture_lines: RwSignal<Vec<(String, bool)>>,
|
capture_lines: RwSignal<Vec<(String, bool)>>,
|
||||||
library: RwSignal<LibraryPane>,
|
library: RwSignal<LibraryPane>,
|
||||||
queue_cursor: RwSignal<QueueCursor>,
|
queue_cursor: RwSignal<QueueCursor>,
|
||||||
|
|
@ -109,6 +110,7 @@ impl Store {
|
||||||
mute: RwSignal::new(false),
|
mute: RwSignal::new(false),
|
||||||
mods: RwSignal::new(QueueModifiers::default()),
|
mods: RwSignal::new(QueueModifiers::default()),
|
||||||
position: RwSignal::new(TrackPosition::default()),
|
position: RwSignal::new(TrackPosition::default()),
|
||||||
|
spectrum: RwSignal::new(Vec::new()),
|
||||||
capture_lines: RwSignal::new(Vec::new()),
|
capture_lines: RwSignal::new(Vec::new()),
|
||||||
library: RwSignal::new(LibraryPane::default()),
|
library: RwSignal::new(LibraryPane::default()),
|
||||||
queue_cursor: RwSignal::new(QueueCursor::default()),
|
queue_cursor: RwSignal::new(QueueCursor::default()),
|
||||||
|
|
@ -174,6 +176,7 @@ impl Store {
|
||||||
self.board.update_value(|b| b.apply(progress, now_ms()));
|
self.board.update_value(|b| b.apply(progress, now_ms()));
|
||||||
self.refresh_capture_lines();
|
self.refresh_capture_lines();
|
||||||
}
|
}
|
||||||
|
StreamUpdate::Spectrum(frame) => self.spectrum.set(frame.bins),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -939,6 +942,19 @@ fn Transport(store: Store) -> impl IntoView {
|
||||||
>"↻"</button>
|
>"↻"</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="now-playing">
|
<div class="now-playing">
|
||||||
|
<div class="spectrum" aria-hidden="true">
|
||||||
|
{move || {
|
||||||
|
store
|
||||||
|
.spectrum
|
||||||
|
.get()
|
||||||
|
.into_iter()
|
||||||
|
.map(|level| {
|
||||||
|
let pct = format!("{:.0}%", level.clamp(0.0, 1.0) * 100.0);
|
||||||
|
view! { <span class="bar" style:height=pct></span> }
|
||||||
|
})
|
||||||
|
.collect_view()
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
<span class="np-title">
|
<span class="np-title">
|
||||||
{move || store.now_playing.get().map(|t| track_label(&t)).unwrap_or_default()}
|
{move || store.now_playing.get().map(|t| track_label(&t)).unwrap_or_default()}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -301,6 +301,24 @@ input {
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
font-weight: 600;
|
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 {
|
& .progress {
|
||||||
|
|
|
||||||
|
|
@ -181,9 +181,20 @@ message GetUpdateStreamResponse {
|
||||||
bool mute = 6;
|
bool mute = 6;
|
||||||
TrackPosition position = 7;
|
TrackPosition position = 7;
|
||||||
CaptureProgress capture_progress = 8;
|
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
|
// Progress of a running capture (CaptureLibraryNode). Broadcast after each
|
||||||
// processed track; exactly one event per capture sets `finished` (with
|
// processed track; exactly one event per capture sets `finished` (with
|
||||||
// `error` on failure).
|
// `error` on failure).
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ base64.workspace = true
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
http.workspace = true
|
http.workspace = true
|
||||||
include_dir = { workspace = true, optional = true }
|
include_dir = { workspace = true, optional = true }
|
||||||
|
realfft.workspace = true
|
||||||
tonic-web = { workspace = true, optional = true }
|
tonic-web = { workspace = true, optional = true }
|
||||||
tower.workspace = true
|
tower.workspace = true
|
||||||
audio-player.workspace = true
|
audio-player.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ pub mod provider;
|
||||||
pub mod queue_store;
|
pub mod queue_store;
|
||||||
pub mod rpc;
|
pub mod rpc;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
pub mod spectrum;
|
||||||
|
|
||||||
use audio_player::PlayerMessage;
|
use audio_player::PlayerMessage;
|
||||||
use crabidy_core::proto::crabidy::{
|
use crabidy_core::proto::crabidy::{
|
||||||
|
|
@ -91,6 +92,8 @@ pub async fn serve(
|
||||||
});
|
});
|
||||||
info!("player message forwarder started");
|
info!("player message forwarder started");
|
||||||
|
|
||||||
|
spawn_spectrum_task(playback.player.spectrum_tap(), update_tx.clone());
|
||||||
|
|
||||||
let crabidy_service = rpc::RpcService::new(
|
let crabidy_service = rpc::RpcService::new(
|
||||||
update_tx,
|
update_tx,
|
||||||
playback.playback_tx.clone(),
|
playback.playback_tx.clone(),
|
||||||
|
|
@ -140,6 +143,49 @@ pub fn build_router(
|
||||||
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<audio_player::SpectrumTap>,
|
||||||
|
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.
|
/// Forwards player engine events into the playback message loop.
|
||||||
#[instrument(skip(rx, tx))]
|
#[instrument(skip(rx, tx))]
|
||||||
fn poll_play_bus(rx: flume::Receiver<PlayerMessage>, tx: flume::Sender<PlaybackMessage>) {
|
fn poll_play_bus(rx: flume::Receiver<PlayerMessage>, tx: flume::Sender<PlaybackMessage>) {
|
||||||
|
|
|
||||||
|
|
@ -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<dyn RealToComplex<f32>>,
|
||||||
|
/// Hann window applied before the transform to cut spectral leakage.
|
||||||
|
window: Vec<f32>,
|
||||||
|
input: Vec<f32>,
|
||||||
|
output: Vec<Complex<f32>>,
|
||||||
|
/// `SPECTRUM_BINS + 1` boundaries into the magnitude array, spaced
|
||||||
|
/// logarithmically so the bars are roughly musically even.
|
||||||
|
edges: Vec<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SpectrumAnalyzer {
|
||||||
|
pub fn new(window_len: usize) -> Self {
|
||||||
|
let fft = RealFftPlanner::<f32>::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<f32> {
|
||||||
|
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<f32>| 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::<f32>() / (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<usize> {
|
||||||
|
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<f32> {
|
||||||
|
(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:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -737,3 +737,41 @@ follow-up.
|
||||||
default, so `cbd` (local, self-contained) and a remote-pointed
|
default, so `cbd` (local, self-contained) and a remote-pointed
|
||||||
`cbd-tui` coexist on one machine without their `address` settings
|
`cbd-tui` coexist on one machine without their `address` settings
|
||||||
colliding. README config table + client-config section updated.
|
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.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue