cbd-tui: shade the spectrum bars and hold their peaks

Modelled on BeSpec's LED mode. Bars are now shaded by height rather than
drawn in one flat color, and a peak-hold marker rides above each column.

Shading is a function of the row alone, so it stays cheap: the color is
computed once per row and the row's cells still coalesce into a couple of
spans. `gradient_color` ramps the configured base from 55% brightness at
the floor to 30% toward white at the top, so a loud bar reads as hot and
not merely tall. Only an `Rgb` base can be interpolated -- a color name or
a palette index is a reference into the terminal's own theme, whose RGB
value is not ours to know -- so those render flat, whatever
`spectrum_gradient` says.

The peaks are per-bin state advanced in `update_spectrum`: a bin at or
above its peak raises it at once, otherwise the peak falls 0.03 a frame,
about 1.7s from full scale at the server's 20 fps. The server keeps
streaming zeroed frames while the audio is idle, so the markers fall away
on pause instead of freezing on screen. A marker is drawn only in a row
the bar does not reach: a cell holds one glyph, so one inside the bar's
own top cell would eat the bar to repeat what its top edge already shows.

Config gains `spectrum_gradient` (default true) and `spectrum_peak_color`
(default the primary blue, `"none"`/`"off"` to draw none), resolved with
`spectrum_color` into one `SpectrumStyle` in place of the old
`set_spectrum_color`. Existing config files take the defaults.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Test User 2026-07-27 13:08:43 +02:00
parent 430c87451b
commit d5b35a4da2
7 changed files with 551 additions and 60 deletions

View File

@ -25,6 +25,7 @@ pub use list::{Filter, StatefulList};
use bindings::Action;
use library::Library;
use now_playing::NowPlaying;
pub use now_playing::SpectrumStyle;
use queue::Queue;
pub use register::Register;
@ -65,6 +66,10 @@ pub(crate) struct UiItem {
}
pub const COLOR_PRIMARY: Color = Color::Rgb(129, 161, 193);
/// [`COLOR_PRIMARY`] as a config-file string — the default
/// `spectrum_peak_color`. The pair is checked by
/// `the_default_peak_color_is_the_primary_blue`.
pub const COLOR_PRIMARY_HEX: &str = "#81a1c1";
// const COLOR_PRIMARY_DARK: Color = Color::Rgb(94, 129, 172);
pub const COLOR_PRIMARY_DARK: Color = Color::Rgb(59, 66, 82);
pub const COLOR_SECONDARY: Color = Color::Rgb(180, 142, 173);

View File

@ -13,34 +13,152 @@ use ratatui::{
Frame,
};
use super::{COLOR_RED, COLOR_SECONDARY};
use super::{COLOR_PRIMARY, COLOR_RED, COLOR_SECONDARY};
/// Vertical block glyphs by eighths, index 0 = empty, 8 = full cell.
const BLOCKS: [char; 9] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
/// The peak-hold marker: a thin rule above the bar, the width of a cell
/// (U+2594 upper one-eighth block).
const PEAK_MARK: char = '▔';
/// Brightness of the bottom gradient row, as a fraction of the configured
/// color. Dim enough to read as a floor, light enough to stay visible on a
/// dark background.
const GRADIENT_FLOOR: f32 = 0.55;
/// How far the top gradient row is blended toward white, so a loud bar
/// reads as hot rather than merely tall.
const GRADIENT_HIGHLIGHT: f32 = 0.3;
/// Fall of a peak marker per spectrum frame. The server streams ~20 fps, so
/// this drops a full-scale peak to the floor in a bit under two seconds:
/// long enough to see what a transient reached, short enough to track the
/// music. Frames keep arriving (zeroed) while audio is idle, so the
/// markers fall away on pause rather than freezing on screen.
const PEAK_DECAY: f32 = 0.03;
/// How the spectrum is painted, resolved from the client config once at
/// startup (see `config::spectrum_style`).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SpectrumStyle {
/// Bar color, and the base the gradient is derived from.
pub color: Color,
/// Shade the bars from dim at the floor to bright at the top.
pub gradient: bool,
/// Peak-hold marker color, or `None` to draw no markers.
pub peak: Option<Color>,
}
impl Default for SpectrumStyle {
fn default() -> Self {
SpectrumStyle {
color: COLOR_RED,
gradient: true,
peak: Some(COLOR_PRIMARY),
}
}
}
/// A bin level off the wire, clamped into `[0, 1]`. The bins are `float`s
/// from a peer, so NaN and infinity are possible; both read as silence
/// rather than propagating into the glyph arithmetic.
fn level_of(raw: f32) -> f32 {
if raw.is_finite() {
raw.clamp(0.0, 1.0)
} else {
0.0
}
}
/// The bin a column samples: the bins are stretched across the full width,
/// whatever the server's bin count.
fn bin_for(col: usize, width: usize, bins: usize) -> usize {
if bins == 0 {
return 0;
}
(col * bins / width.max(1)).min(bins - 1)
}
/// The color of the bar row `from_bottom` rows up in a `height`-row area:
/// [`GRADIENT_FLOOR`]-dimmed at the bottom, blended toward white by
/// [`GRADIENT_HIGHLIGHT`] at the top.
///
/// Only an `Rgb` base can be interpolated. A color name or a palette index
/// is a reference into the terminal's own theme — its RGB value is not ours
/// to know — so those are returned unchanged and render flat.
fn gradient_color(base: Color, from_bottom: usize, height: usize) -> Color {
let Color::Rgb(r, g, b) = base else {
return base;
};
let t = if height <= 1 {
1.0
} else {
from_bottom as f32 / (height - 1) as f32
};
let ramp = |channel: u8| {
let channel = f32::from(channel);
let low = channel * GRADIENT_FLOOR;
let high = channel + (255.0 - channel) * GRADIENT_HIGHLIGHT;
(low + (high - low) * t).round().clamp(0.0, 255.0) as u8
};
Color::Rgb(ramp(r), ramp(g), ramp(b))
}
/// Renders `bins` as full-height vertical bars filling a `width`×`height`
/// area: one `Line` per row, top row first. Each column maps to a bin;
/// its level in `[0, 1]` fills from the bottom, using partial block
/// glyphs for the topmost fractional cell. Pure, so it is unit-tested.
fn spectrum_lines(bins: &[f32], width: usize, height: usize, color: Color) -> Vec<Line<'static>> {
///
/// `peaks` holds the decaying per-bin maxima (see [`NowPlaying::update_spectrum`]).
/// A peak is marked only in a row the bar does not reach at all: a cell
/// holds one glyph, and a marker inside the bar's own top cell would eat
/// up to seven eighths of the bar to say something the bar top already says.
fn spectrum_lines(
bins: &[f32],
peaks: &[f32],
width: usize,
height: usize,
style: SpectrumStyle,
) -> Vec<Line<'static>> {
(0..height)
.map(|row| {
// Row 0 is the top; count cells up from the bottom.
let from_bottom = height - 1 - row;
let cells: String = (0..width)
.map(|col| {
let bin = if bins.is_empty() {
0
let bar_color = if style.gradient {
gradient_color(style.color, from_bottom, height)
} else {
(col * bins.len() / width.max(1)).min(bins.len() - 1)
style.color
};
let level = bins.get(bin).copied().unwrap_or(0.0).clamp(0.0, 1.0);
let total_eighths = (level * height as f32 * 8.0).round() as usize;
let cell = total_eighths.saturating_sub(from_bottom * 8).min(8);
BLOCKS[cell]
})
.collect();
Line::from(Span::styled(cells, Style::default().fg(color)))
// Runs of same-colored cells, so a row is a handful of spans
// rather than one per column.
let mut runs: Vec<(Color, String)> = Vec::new();
for col in 0..width {
let bin = bin_for(col, width, bins.len());
let level = level_of(bins.get(bin).copied().unwrap_or(0.0));
let eighths = (level * height as f32 * 8.0).round() as usize;
let cell = eighths.saturating_sub(from_bottom * 8).min(8);
let peak = style.peak.filter(|_| {
let peak = level_of(peaks.get(bin).copied().unwrap_or(0.0));
let peak_eighths = (peak * height as f32 * 8.0).round() as usize;
// The row holding the peak's topmost eighth, and only
// where the bar has left this cell empty.
cell == 0 && peak_eighths > 0 && (peak_eighths - 1) / 8 == from_bottom
});
let (glyph, color) = match peak {
Some(peak_color) => (PEAK_MARK, peak_color),
None => (BLOCKS[cell], bar_color),
};
match runs.last_mut() {
Some((run_color, text)) if *run_color == color => text.push(glyph),
_ => runs.push((color, glyph.to_string())),
}
}
Line::from(
runs.into_iter()
.map(|(color, text)| Span::styled(text, Style::default().fg(color)))
.collect::<Vec<_>>(),
)
})
.collect()
}
@ -76,11 +194,15 @@ pub struct NowPlaying {
/// Latest frequency-spectrum bars (architecture/spectrum.md), empty
/// until the first frame arrives.
spectrum: Vec<f32>,
/// Per-bin peak hold: the highest level each bin has reached lately,
/// decayed by [`PEAK_DECAY`] a frame. Drawn as the marker line above
/// the bars.
spectrum_peaks: Vec<f32>,
/// Whether to draw the spectrum row (config `spectrum`, default on).
spectrum_enabled: bool,
/// Bar color (config `spectrum_color`), defaulting to the red the
/// queue marks the playing track with.
spectrum_color: Color,
/// Bar color, gradient and peak color (config `spectrum_color`,
/// `spectrum_gradient`, `spectrum_peak_color`).
spectrum_style: SpectrumStyle,
/// Whether the server output is muted.
muted: bool,
/// The server's output level, `1.0` = 100%. This is the level playback
@ -97,8 +219,9 @@ impl Default for NowPlaying {
position: None,
track: None,
spectrum: Vec::new(),
spectrum_peaks: Vec::new(),
spectrum_enabled: true,
spectrum_color: COLOR_RED,
spectrum_style: SpectrumStyle::default(),
muted: false,
volume: 1.0,
}
@ -122,18 +245,31 @@ impl NowPlaying {
pub fn update_modifiers(&mut self, mods: &QueueModifiers) {
self.modifiers = *mods;
}
/// Applies a spectrum frame from the server (already normalized).
/// Applies a spectrum frame from the server (already normalized) and
/// advances the peak hold: a bin at or above its peak raises it
/// instantly, otherwise the peak falls by [`PEAK_DECAY`]. The bin count
/// comes off the wire, so the peaks follow whatever arrives.
pub fn update_spectrum(&mut self, bins: Vec<f32>) {
self.spectrum_peaks.resize(bins.len(), 0.0);
for (peak, level) in self.spectrum_peaks.iter_mut().zip(&bins) {
let level = level_of(*level);
*peak = if level >= *peak {
level
} else {
(*peak - PEAK_DECAY).max(0.0)
};
}
self.spectrum = bins;
}
/// Enables/disables the spectrum row (from client config).
pub fn set_spectrum_enabled(&mut self, enabled: bool) {
self.spectrum_enabled = enabled;
}
/// Sets the bar color (from client config). Parsing the config string
/// is the caller's job, so an unusable value never reaches here.
pub fn set_spectrum_color(&mut self, color: Color) {
self.spectrum_color = color;
/// Sets how the bars are painted (from client config). Parsing the
/// config strings is the caller's job, so an unusable value never
/// reaches here.
pub fn set_spectrum_style(&mut self, style: SpectrumStyle) {
self.spectrum_style = style;
}
/// Shows or hides the spectrum row (the `v` keybinding). The server
/// keeps streaming the bars; this only gates rendering.
@ -287,13 +423,20 @@ impl NowPlaying {
}
// The spectrum: full-height bars filling the region left below the
// progress, in the configured color. Columns stretch across the
// pane width regardless of the server's bin count.
// progress, in the configured color, under a peak-hold marker line.
// Columns stretch across the pane width regardless of the server's
// bin count.
if self.spectrum_enabled && !self.spectrum.is_empty() {
let area = now_playing_layout[2];
let (width, height) = (area.width as usize, area.height as usize);
if width > 0 && height > 0 {
let lines = spectrum_lines(&self.spectrum, width, height, self.spectrum_color);
let lines = spectrum_lines(
&self.spectrum,
&self.spectrum_peaks,
width,
height,
self.spectrum_style,
);
f.render_widget(Paragraph::new(lines), area);
}
}
@ -357,13 +500,24 @@ mod tests {
is_captured: false,
}),
spectrum: Vec::new(),
spectrum_peaks: Vec::new(),
spectrum_enabled: true,
spectrum_color: COLOR_RED,
spectrum_style: SpectrumStyle::default(),
muted: false,
volume: 1.0,
}
}
/// One flat color, no markers: the glyph tests are about the bars'
/// geometry, so they pin the paint down and let the color tests own it.
fn flat() -> SpectrumStyle {
SpectrumStyle {
color: COLOR_RED,
gradient: false,
peak: None,
}
}
fn render(pane: &NowPlaying) {
let backend = TestBackend::new(60, 12);
let mut terminal = Terminal::new(backend).expect("test terminal");
@ -391,7 +545,7 @@ mod tests {
fn spectrum_lines_fill_full_height_columns() {
// A full-level bin fills every row of its column with the full
// block; a zero bin leaves every row blank.
let lines = spectrum_lines(&[1.0, 0.0], 2, 4, COLOR_RED);
let lines = spectrum_lines(&[1.0, 0.0], &[], 2, 4, flat());
assert_eq!(lines.len(), 4, "one line per row");
let text: Vec<String> = lines
.iter()
@ -405,7 +559,7 @@ mod tests {
#[test]
fn spectrum_lines_grow_from_the_bottom() {
// Half level over 4 rows ≈ 16 eighths → the bottom two rows fill.
let lines = spectrum_lines(&[0.5], 1, 4, COLOR_RED);
let lines = spectrum_lines(&[0.5], &[], 1, 4, flat());
let col: Vec<char> = lines
.iter()
.map(|l| l.spans[0].content.chars().next().unwrap())
@ -438,19 +592,189 @@ mod tests {
.and_then(|(x, y)| buffer[(x, y)].style().fg)
}
#[test]
fn the_bars_default_to_the_queues_playing_red() {
let mut pane = now_playing(10_000, 60_000);
pane.update_spectrum(vec![1.0; 24]);
assert_eq!(first_bar_color(&pane), Some(super::super::COLOR_RED));
/// Every distinct foreground the full-block cells are drawn in.
fn bar_colors(pane: &NowPlaying) -> Vec<Color> {
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();
let mut colors: Vec<Color> = Vec::new();
for y in 0..buffer.area.height {
for x in 0..buffer.area.width {
if buffer[(x, y)].symbol() != "" {
continue;
}
if let Some(fg) = buffer[(x, y)].style().fg {
if !colors.contains(&fg) {
colors.push(fg);
}
}
}
}
colors
}
#[test]
fn the_bars_take_the_configured_color() {
fn the_default_style_is_the_queue_red_under_a_blue_peak() {
let style = SpectrumStyle::default();
assert_eq!(style.color, super::super::COLOR_RED);
assert_eq!(style.peak, Some(super::super::COLOR_PRIMARY));
assert!(style.gradient, "shaded out of the box");
}
#[test]
fn flat_bars_are_drawn_in_the_configured_color() {
let mut pane = now_playing(10_000, 60_000);
pane.update_spectrum(vec![1.0; 24]);
pane.set_spectrum_color(Color::Rgb(1, 2, 3));
pane.set_spectrum_style(SpectrumStyle {
color: Color::Rgb(1, 2, 3),
gradient: false,
peak: None,
});
assert_eq!(first_bar_color(&pane), Some(Color::Rgb(1, 2, 3)));
assert_eq!(
bar_colors(&pane),
vec![Color::Rgb(1, 2, 3)],
"gradient off: one color for the whole bar"
);
}
#[test]
fn shaded_bars_use_a_different_color_per_row() {
let mut pane = now_playing(10_000, 60_000);
pane.update_spectrum(vec![1.0; 24]);
// Full-level bars over several rows, shaded: each row is its own
// shade of the base color.
assert!(
bar_colors(&pane).len() > 1,
"expected a gradient, got {:?}",
bar_colors(&pane)
);
}
#[test]
fn the_gradient_runs_dim_at_the_floor_to_bright_at_the_top() {
let base = super::super::COLOR_RED;
let (bottom, top) = (gradient_color(base, 0, 8), gradient_color(base, 7, 8));
let brightness = |color: Color| match color {
Color::Rgb(r, g, b) => u32::from(r) + u32::from(g) + u32::from(b),
other => panic!("expected rgb, got {other:?}"),
};
assert!(
brightness(bottom) < brightness(base),
"floor dimmer than the base: {bottom:?}"
);
assert!(
brightness(top) > brightness(base),
"top brighter than the base: {top:?}"
);
// Monotonic in between, so the ramp reads as one gradient.
let ramp: Vec<u32> = (0..8)
.map(|r| brightness(gradient_color(base, r, 8)))
.collect();
assert!(ramp.windows(2).all(|w| w[0] <= w[1]), "{ramp:?}");
// A one-row area is all "top": the single row keeps the full color
// rather than being dimmed to the floor.
assert_eq!(gradient_color(base, 0, 1), gradient_color(base, 7, 8));
}
#[test]
fn a_named_or_indexed_color_is_never_shaded() {
// Their RGB belongs to the terminal theme, so there is nothing to
// interpolate — they render flat, whatever `gradient` says.
for color in [Color::LightBlue, Color::Indexed(208), Color::Reset] {
assert_eq!(gradient_color(color, 0, 8), color);
assert_eq!(gradient_color(color, 7, 8), color);
}
}
#[test]
fn a_held_peak_marks_the_row_above_the_bar() {
// Bar at the floor, peak still at full scale: the marker sits in
// the top row, in the peak color, and the bar keeps its own.
let style = SpectrumStyle {
color: COLOR_RED,
gradient: false,
peak: Some(COLOR_PRIMARY),
};
let lines = spectrum_lines(&[0.1], &[1.0], 1, 4, style);
let top = &lines[0].spans[0];
assert_eq!(top.content.as_ref(), PEAK_MARK.to_string());
assert_eq!(top.style.fg, Some(COLOR_PRIMARY));
let bottom = &lines[3].spans[0];
assert_eq!(bottom.style.fg, Some(COLOR_RED), "bar keeps its color");
}
#[test]
fn a_peak_inside_the_bar_is_not_drawn() {
// A cell holds one glyph: a marker in the bar's own top cell would
// eat the bar to repeat what its top edge already shows.
let style = SpectrumStyle {
color: COLOR_RED,
gradient: false,
peak: Some(COLOR_PRIMARY),
};
let lines = spectrum_lines(&[1.0], &[1.0], 1, 4, style);
assert!(
!lines
.iter()
.any(|l| l.spans.iter().any(|s| s.content.contains(PEAK_MARK))),
"peak at the bar top must not overwrite it"
);
}
#[test]
fn peaks_are_left_out_when_switched_off() {
let lines = spectrum_lines(&[0.1], &[1.0], 1, 4, flat());
assert!(
!lines
.iter()
.any(|l| l.spans.iter().any(|s| s.content.contains(PEAK_MARK))),
"`spectrum_peak_color = \"none\"` draws no markers"
);
}
#[test]
fn a_peak_holds_then_falls_to_the_floor() {
let mut pane = now_playing(10_000, 60_000);
pane.update_spectrum(vec![1.0]);
assert_eq!(
pane.spectrum_peaks,
vec![1.0],
"a hit sets the peak at once"
);
// Silence afterwards: the peak comes down a step per frame.
pane.update_spectrum(vec![0.0]);
assert!((pane.spectrum_peaks[0] - (1.0 - PEAK_DECAY)).abs() < 1e-6);
for _ in 0..200 {
pane.update_spectrum(vec![0.0]);
}
assert_eq!(pane.spectrum_peaks, vec![0.0], "and reaches the floor");
}
#[test]
fn a_peak_follows_a_changed_bin_count() {
// The bin count comes off the wire and may change mid-stream.
let mut pane = now_playing(10_000, 60_000);
pane.update_spectrum(vec![1.0; 8]);
pane.update_spectrum(vec![0.5; 3]);
assert_eq!(pane.spectrum_peaks.len(), 3);
pane.update_spectrum(vec![0.5; 16]);
assert_eq!(pane.spectrum_peaks.len(), 16);
}
#[test]
fn a_nonsense_bin_level_reads_as_silence() {
// Bins are floats from a peer: NaN and infinity must not become
// bars, peaks, or a panic.
let mut pane = now_playing(10_000, 60_000);
pane.update_spectrum(vec![f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -1.0]);
assert_eq!(pane.spectrum_peaks, vec![0.0; 4]);
let rows = rendered_rows(&pane);
assert!(
!rows.iter().any(|r| r.contains('█')),
"nonsense levels must draw nothing: {rows:?}"
);
}
#[test]

View File

@ -6,6 +6,8 @@ use std::{
use crabidy_core::{clap, clap_serde_derive, serde::Serialize, ClapSerde};
use ratatui::style::Color;
use crate::app::SpectrumStyle;
#[derive(ClapSerde, Serialize, Debug)]
#[clap(author, version, about)]
pub struct Config {
@ -178,30 +180,74 @@ pub struct ServerConfig {
/// of ratatui's names ("red", "light-blue", …), or a 0-255 index into
/// the terminal palette. Defaults to the red the queue marks the
/// playing track with. An unparsable value falls back to that
/// default with a warning — see [`spectrum_color`].
/// default with a warning — see [`spectrum_style`].
#[default(crate::app::COLOR_RED_HEX.to_string())]
#[clap(long)]
pub spectrum_color: String,
/// Shade the bars from dim at the floor to bright at the top, instead
/// of one flat color. Only a hex `spectrum_color` can be shaded: a
/// name or palette index has no RGB value of ours to interpolate, so
/// those stay flat whatever this says.
#[default(true)]
#[clap(long)]
pub spectrum_gradient: bool,
/// Color of the peak-hold markers riding above the bars, in the same
/// three forms as `spectrum_color`. Defaults to the primary blue;
/// "none" (or "off") draws no markers.
#[default(crate::app::COLOR_PRIMARY_HEX.to_string())]
#[clap(long)]
pub spectrum_peak_color: String,
}
/// Resolves [`ServerConfig::spectrum_color`] to a ratatui [`Color`].
/// Resolves the spectrum options to a [`SpectrumStyle`].
///
/// A config file is user input: a typo must not take the client down, so an
/// unparsable value is reported on stderr and the default red is used. The
/// unparsable color is reported on stderr and the default is used. The
/// terminal decides what a name or palette index looks like, so this cannot
/// tell a working color from an invisible one — only a syntactic one from a
/// nonsense one.
pub fn spectrum_color(config: &Config) -> Color {
let raw = config.server.spectrum_color.trim();
pub fn spectrum_style(config: &Config) -> SpectrumStyle {
SpectrumStyle {
color: color_or_default(
&config.server.spectrum_color,
"spectrum_color",
crate::app::COLOR_RED,
),
gradient: config.server.spectrum_gradient,
peak: peak_color(&config.server.spectrum_peak_color),
}
}
/// Parses one color option, falling back to `fallback` with a warning. An
/// empty value is a blank in the file rather than a mistake, so it takes
/// the default silently.
fn color_or_default(raw: &str, key: &str, fallback: Color) -> Color {
let raw = raw.trim();
if raw.is_empty() {
return crate::app::COLOR_RED;
return fallback;
}
Color::from_str(raw).unwrap_or_else(|_| {
eprintln!("invalid spectrum_color {raw:?}, using the default");
crate::app::COLOR_RED
eprintln!("invalid {key} {raw:?}, using the default");
fallback
})
}
/// Parses `spectrum_peak_color`, where "none"/"off" mean "draw no markers".
/// Those two words are not colors, so there is no value they shadow.
fn peak_color(raw: &str) -> Option<Color> {
let trimmed = raw.trim();
if trimmed.eq_ignore_ascii_case("none") || trimmed.eq_ignore_ascii_case("off") {
return None;
}
Some(color_or_default(
trimmed,
"spectrum_peak_color",
crate::app::COLOR_PRIMARY,
))
}
#[cfg(test)]
mod tests {
use super::*;
@ -247,7 +293,19 @@ mod tests {
// this is what keeps the two spellings of the same red together.
let config = Config::default();
assert_eq!(config.server.spectrum_color, crate::app::COLOR_RED_HEX);
assert_eq!(spectrum_color(&config), crate::app::COLOR_RED);
assert_eq!(spectrum_style(&config).color, crate::app::COLOR_RED);
}
#[test]
fn the_default_peak_color_is_the_primary_blue() {
let config = Config::default();
assert_eq!(
config.server.spectrum_peak_color,
crate::app::COLOR_PRIMARY_HEX
);
let style = spectrum_style(&config);
assert_eq!(style.peak, Some(crate::app::COLOR_PRIMARY));
assert!(style.gradient, "bars are shaded out of the box");
}
#[test]
@ -261,7 +319,10 @@ mod tests {
(" #010203 ", Color::Rgb(1, 2, 3)),
] {
config.server.spectrum_color = raw.to_string();
assert_eq!(spectrum_color(&config), expected, "parsing {raw:?}");
assert_eq!(spectrum_style(&config).color, expected, "parsing {raw:?}");
// The peak color takes the same three forms.
config.server.spectrum_peak_color = raw.to_string();
assert_eq!(spectrum_style(&config).peak, Some(expected));
}
}
@ -272,14 +333,34 @@ mod tests {
let mut config = Config::default();
for raw in ["", " ", "puce", "#12345", "300", "#"] {
config.server.spectrum_color = raw.to_string();
config.server.spectrum_peak_color = raw.to_string();
let style = spectrum_style(&config);
assert_eq!(style.color, crate::app::COLOR_RED, "rejecting {raw:?}");
assert_eq!(
spectrum_color(&config),
crate::app::COLOR_RED,
style.peak,
Some(crate::app::COLOR_PRIMARY),
"rejecting {raw:?}"
);
}
}
#[test]
fn a_peak_color_of_none_switches_the_markers_off() {
// "none" and "off" are not colors, so neither shadows a real value.
let mut config = Config::default();
for raw in ["none", "NONE", " off ", "Off"] {
config.server.spectrum_peak_color = raw.to_string();
assert_eq!(spectrum_style(&config).peak, None, "for {raw:?}");
}
}
#[test]
fn the_gradient_can_be_switched_off() {
let mut config = Config::default();
config.server.spectrum_gradient = false;
assert!(!spectrum_style(&config).gradient);
}
#[test]
fn write_auth_round_trips_user_password_address() {
let dir = TempDir::new().expect("tempdir");

View File

@ -45,9 +45,9 @@ pub async fn run(config: &'static Config) -> Result<(), Box<dyn Error>> {
let spectrum_enabled = config.server.spectrum;
// Resolved here rather than in the UI thread so a bad config string is
// reported on stderr before the alternate screen swallows it.
let spectrum_color = config::spectrum_color(config);
let spectrum_style = config::spectrum_style(config);
tokio::task::spawn_blocking(move || {
run_ui(ui_tx, ui_rx, spectrum_enabled, spectrum_color);
run_ui(ui_tx, ui_rx, spectrum_enabled, spectrum_style);
})
.await?;
@ -216,7 +216,7 @@ fn run_ui(
tx: Sender<MessageFromUi>,
rx: Receiver<MessageToUi>,
spectrum_enabled: bool,
spectrum_color: ratatui::style::Color,
spectrum_style: app::SpectrumStyle,
) {
// setup terminal
enable_raw_mode().unwrap();
@ -228,7 +228,7 @@ fn run_ui(
// create app and run it
let mut app = App::new(tx);
app.now_playing.set_spectrum_enabled(spectrum_enabled);
app.now_playing.set_spectrum_color(spectrum_color);
app.now_playing.set_spectrum_style(spectrum_style);
let tick_rate = Duration::from_millis(100);
let mut last_tick = Instant::now();

View File

@ -156,6 +156,34 @@ them; a hex triple asks for one exact color, which a 256-color terminal
will approximate. An unparsable value warns on stderr and falls back to
the default rather than stopping the client.
### Shading and peaks
The bars are **shaded** by height: dim at the floor, brightening toward
the top, so a loud bar reads as hot and not merely tall. Each row gets its
own shade of `spectrum_color`, interpolated from 55% brightness at the
bottom to 30% toward white at the top. Set `spectrum_gradient = false` for
one flat color instead.
Shading needs a color it can compute with, so it applies only to a
**hex** `spectrum_color`. A name or a palette index is a reference into
your terminal's own theme — its actual RGB value is the terminal's
business, not the client's — so those always render flat, whatever
`spectrum_gradient` says.
Above the bars ride **peak-hold markers**: a thin rule (`▔`) per column
that jumps to each new maximum and then falls back at about half scale a
second, so you can see what a transient reached after the bar has dropped.
`spectrum_peak_color` sets their color (same three forms), defaulting to
the primary blue so they read as a shadow of the red bars rather than part
of them. `spectrum_peak_color = "none"` (or `"off"`) leaves them out —
also the thing to do if your terminal font lacks `▔` and draws a box.
A marker is only drawn in a row the bar does not reach: a terminal cell
holds one glyph, so a marker inside the bar's own top cell would eat the
bar to repeat what its top edge already shows. Markers fall away when
playback stops, since the server keeps streaming zeroed bars while the
audio is idle.
## Key bindings
Global keys work in either pane. Pane keys apply only while that pane is

View File

@ -111,12 +111,22 @@ spectrum = true
# default is the red the queue marks the playing track with. An
# unparsable value warns on stderr and falls back to that default.
spectrum_color = "#bf616a"
# Shade the bars from dim at the floor to bright at the top instead of
# one flat color. Only a hex spectrum_color can be shaded — a name or a
# palette index has no RGB value the client may interpolate.
spectrum_gradient = true
# Color of the peak-hold markers riding above the bars, in the same three
# forms as spectrum_color. "none" (or "off") draws no markers.
spectrum_peak_color = "#81a1c1"
```
Every option except `spectrum_color` is also a command-line flag, given
before the subcommand
(`cbd-tui --address http://pi:50051 --user owner`, `cbd --spectrum
false`). A provided flag overrides the file value; an omitted flag
Every option except `spectrum_color`, `spectrum_gradient` and
`spectrum_peak_color` is also a command-line flag, given before the
subcommand (`cbd-tui --address http://pi:50051 --user owner`,
`cbd --spectrum false`). A provided flag overrides the file value; an
omitted flag
leaves the file value in place. To write credentials into the config
once instead of editing by hand, use the `auth` subcommand (see
[Command line](./clients/cli.md)):
@ -132,8 +142,8 @@ plaintext credential, not a hash. Keep the client config file private.
The password is never written to logs.
```
For the client's `spectrum` option in context, see
[The terminal client](./clients/tui.md).
For the client's `spectrum` options in context — shading, and the
peak-hold markers — see [The terminal client](./clients/tui.md).
## Where the server's own data lives

View File

@ -1668,3 +1668,46 @@ per invocation, and a color is not.
Existing config files predate the key. They keep working — every field of the
`ClapSerde` opt struct is optional — and take the default, but the new key
only appears in files written from now on.
## TUI spectrum shading and peak hold (2026-07-27)
Modelled on BeSpec's LED mode: bars shaded by height, with a peak marker riding
above each one.
Gradients are possible in a TUI because every cell carries its own foreground.
The useful accident is that shading by *height* makes the color a function of
the row alone, so it costs nothing per column — the paint is decided once per
row and the row's cells still coalesce into a couple of spans.
`gradient_color` interpolates the configured base from 55% brightness at the
floor to 30% toward white at the top. Only a `Color::Rgb` base can be
interpolated: a color name or a palette index is a reference into the
terminal's own theme, whose RGB value is not the client's to know, so those
render flat whatever `spectrum_gradient` says. Documented rather than
worked around, since guessing an RGB for `red` would defeat the point of
naming it.
The peak hold is per-bin state in `NowPlaying`, advanced in `update_spectrum`:
a bin at or above its peak raises it instantly, otherwise the peak falls
`PEAK_DECAY` (0.03) a frame — about 1.7s from full scale at the server's 20 fps.
The server keeps streaming zeroed frames while audio is idle, so markers fall
away on pause instead of freezing on screen. Bin count comes off the wire, so
the peaks `resize` to whatever arrives.
A marker is drawn only in a row the bar does not reach at all. A cell holds one
glyph, so a marker inside the bar's own top cell would replace up to seven
eighths of bar to repeat what the bar's top edge already shows. Skipping it
loses nothing: a peak that close to the bar is not news.
The three spectrum options (`spectrum_color`, `spectrum_gradient`,
`spectrum_peak_color`) now resolve together into a `SpectrumStyle`, which
replaces `set_spectrum_color`. `spectrum_peak_color = "none"` (or `"off"`)
draws no markers — also the escape hatch for a terminal font without U+2594.
Neither word is a color, so neither shadows a value someone might have meant.
`level_of` now guards every bin: they are wire floats, so NaN and infinity read
as silence in one place rather than being left to the arithmetic. Neither could
panic before — `clamp` propagates NaN and a NaN float cast saturates to 0 — and
a NaN can never become a held peak, since `level >= *peak` is false for it. The
guard is so the peak comparison and the glyph arithmetic agree on what a
nonsense level means instead of each shrugging it off differently.