Files
peerspeak/src/dsp/render.rs
T
molluskandClaude Opus 4.8 d0a16cb8b9 style: apply cargo fmt across the crate (A20)
The repo never enforced rustfmt, so formatting had drifted broadly. This is a
single mechanical `cargo fmt` pass over the whole crate (no behavioral change;
lib suite green, 493 passed). Going forward fmt should be enforced (planned CI
fmt --check step). Part of the 0.6.1 hygiene pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 02:11:44 -04:00

266 lines
8.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Renders a [`Spectrogram`] to a string for the terminal.
//!
//! The trick that gives real resolution in a text grid: each character cell is
//! the Unicode upper-half block `▀` with a 24-bit-colour **foreground** (top
//! pixel) and **background** (bottom pixel), so one row of text shows two
//! frequency bins. Magnitudes are mapped to dB against the loudest cell and
//! coloured with a magma-style ramp (dark = quiet, bright = loud). An ASCII
//! fallback ramp is available for non-truecolor / piped output.
use super::stft::Spectrogram;
/// Rendering options for [`render`].
pub struct RenderOpts {
/// Output width in character columns (time axis).
pub width: usize,
/// Output height in character rows; vertical resolution is `2×height` bins.
pub height: usize,
/// Dynamic-range floor in dB below the loudest cell (e.g. `-80.0`).
pub floor_db: f32,
/// Top of the frequency axis in Hz (clamped to Nyquist).
pub max_hz: f32,
/// Use a plain-ASCII brightness ramp instead of 24-bit colour blocks.
pub ascii: bool,
}
impl Default for RenderOpts {
fn default() -> Self {
RenderOpts {
width: 100,
height: 30,
floor_db: -80.0,
max_hz: 12_000.0,
ascii: false,
}
}
}
const RESET: &str = "\x1b[0m";
/// Brightness ramp (dark→bright) for the ASCII fallback.
const ASCII_RAMP: &[u8] = b" .:-=+*#%@";
/// Renders `spec` to a multi-line string: a header, the frequency-labelled
/// spectrogram body, a time axis, and a dB colour legend.
pub fn render(spec: &Spectrogram, opts: &RenderOpts) -> String {
if spec.frames.is_empty() {
return "(empty signal — nothing to plot)\n".to_string();
}
let nyquist = spec.sample_rate as f32 / 2.0;
let max_hz = opts.max_hz.min(nyquist).max(spec.bin_hz(1));
// Number of bins from DC up to max_hz (at least 2 so we can aggregate).
let bins_used = (0..spec.bins())
.take_while(|&b| spec.bin_hz(b) <= max_hz)
.count()
.max(2);
let n_frames = spec.frames.len();
let width = opts.width.clamp(1, n_frames);
let rows = opts.height.max(1) * 2; // 2 frequency pixels per character row
// Stage 1: aggregate frames into `width` time columns (mean magnitude/bin).
let mut columns: Vec<Vec<f32>> = Vec::with_capacity(width);
for c in 0..width {
let f_lo = c * n_frames / width;
let f_hi = ((c + 1) * n_frames / width).max(f_lo + 1).min(n_frames);
let span = (f_hi - f_lo) as f32;
let mut acc = vec![0.0f32; bins_used];
for f in f_lo..f_hi {
let frame = &spec.frames[f];
for (bin, a) in acc.iter_mut().enumerate() {
*a += frame[bin];
}
}
for a in &mut acc {
*a /= span;
}
columns.push(acc);
}
// Stage 2: aggregate each column's bins into `rows` vertical pixels.
// grid[col][pixel], pixel 0 = lowest freq.
let mut grid: Vec<Vec<f32>> = Vec::with_capacity(width);
let mut max_mag = 1e-9f32;
for col in &columns {
let mut pixels = vec![0.0f32; rows];
for (p, px) in pixels.iter_mut().enumerate() {
let b_lo = p * bins_used / rows;
let b_hi = ((p + 1) * bins_used / rows).max(b_lo + 1).min(bins_used);
let mean = col[b_lo..b_hi].iter().copied().sum::<f32>() / (b_hi - b_lo) as f32;
*px = mean;
max_mag = max_mag.max(mean);
}
grid.push(pixels);
}
// Stage 3: draw. Char row r (0 = top) shows pixels (top=high freq, bottom).
let mut out = String::new();
out.push_str(&format!(
"Spectrogram {:.0} Hz{:.0} Hz · {:.2}s · {} frames, fft {}, hop {}\n",
0.0,
max_hz,
spec.frame_time(n_frames),
n_frames,
spec.fft_size,
spec.hop,
));
let label_every = (opts.height.max(1) / 6).max(1);
for r in 0..opts.height {
let top_pixel = rows - 1 - 2 * r;
let bot_pixel = rows.saturating_sub(2 + 2 * r);
// Frequency label for this char row (use the top pixel's centre freq).
let label = if r % label_every == 0 {
let frac = top_pixel as f32 / (rows - 1) as f32;
format!("{:>6.0}", frac * max_hz)
} else {
" ".to_string()
};
out.push_str(&label);
out.push_str(" \u{2502}"); // " │"
for col in &grid {
let top = norm_db(col[top_pixel], max_mag, opts.floor_db);
let bot = norm_db(col[bot_pixel], max_mag, opts.floor_db);
push_cell(&mut out, top, bot, opts.ascii);
}
if !opts.ascii {
out.push_str(RESET);
}
out.push('\n');
}
// Time axis.
out.push_str(" \u{2514}"); // " └"
out.push_str(&"\u{2500}".repeat(width));
out.push('\n');
out.push_str(&format!(
" 0.00s{:>width$.2}s\n",
spec.frame_time(n_frames),
width = width.saturating_sub(5),
));
out.push_str(&legend(opts));
out
}
/// Maps a linear magnitude to `[0,1]` over `[floor_db, 0]` relative to `max_mag`.
fn norm_db(mag: f32, max_mag: f32, floor_db: f32) -> f32 {
if mag <= 1e-9 {
return 0.0;
}
let db = 20.0 * (mag / max_mag).log10();
((db - floor_db) / -floor_db).clamp(0.0, 1.0)
}
/// Appends one character cell (two stacked pixels) to `out`.
fn push_cell(out: &mut String, top: f32, bot: f32, ascii: bool) {
if ascii {
// One pixel per cell in ASCII mode (use the brighter of the pair).
let v = top.max(bot);
let idx = ((v * (ASCII_RAMP.len() - 1) as f32).round() as usize).min(ASCII_RAMP.len() - 1);
out.push(ASCII_RAMP[idx] as char);
} else {
let (tr, tg, tb) = magma(top);
let (br, bg, bb) = magma(bot);
// fg = top pixel, bg = bottom pixel, glyph = upper half block.
out.push_str(&format!(
"\x1b[38;2;{tr};{tg};{tb}m\x1b[48;2;{br};{bg};{bb}m\u{2580}"
));
}
}
/// A magma-style colormap: `t` in `[0,1]` → `(r,g,b)`. Piecewise-linear through
/// control colours sampled from matplotlib's `magma`.
fn magma(t: f32) -> (u8, u8, u8) {
const STOPS: &[(f32, (f32, f32, f32))] = &[
(0.0, (0.0, 0.0, 4.0)),
(0.25, (80.0, 18.0, 123.0)),
(0.5, (182.0, 54.0, 121.0)),
(0.75, (252.0, 136.0, 97.0)),
(1.0, (252.0, 253.0, 191.0)),
];
let t = t.clamp(0.0, 1.0);
for win in STOPS.windows(2) {
let (t0, c0) = win[0];
let (t1, c1) = win[1];
if t <= t1 {
let f = if t1 > t0 { (t - t0) / (t1 - t0) } else { 0.0 };
return (
(c0.0 + (c1.0 - c0.0) * f) as u8,
(c0.1 + (c1.1 - c0.1) * f) as u8,
(c0.2 + (c1.2 - c0.2) * f) as u8,
);
}
}
(252, 253, 191)
}
/// A horizontal dB colour legend strip.
fn legend(opts: &RenderOpts) -> String {
let mut s = format!(" dB: {:.0}", opts.floor_db);
let steps = 32;
if opts.ascii {
for i in 0..steps {
let v = i as f32 / (steps - 1) as f32;
let idx =
((v * (ASCII_RAMP.len() - 1) as f32).round() as usize).min(ASCII_RAMP.len() - 1);
s.push(ASCII_RAMP[idx] as char);
}
} else {
s.push(' ');
for i in 0..steps {
let v = i as f32 / (steps - 1) as f32;
let (r, g, b) = magma(v);
s.push_str(&format!("\x1b[48;2;{r};{g};{b}m "));
}
s.push_str(RESET);
}
s.push_str(" 0\n");
s
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dsp::{generators, stft};
#[test]
fn norm_db_maps_floor_and_peak() {
// At max magnitude -> 0 dB -> top of range (1.0).
assert!((norm_db(1.0, 1.0, -80.0) - 1.0).abs() < 1e-5);
// Silence -> 0.0.
assert_eq!(norm_db(0.0, 1.0, -80.0), 0.0);
// -80 dB (mag 1e-4 of max) -> ~0.0 bottom of range.
assert!(norm_db(1e-4, 1.0, -80.0) < 0.01);
}
#[test]
fn magma_endpoints() {
assert_eq!(magma(0.0), (0, 0, 4));
assert_eq!(magma(1.0), (252, 253, 191));
}
#[test]
fn render_produces_grid_of_expected_height() {
let sig = generators::sine(2000.0, 0.8, 48_000, 48_000);
let spec = stft::analyze(&sig, 48_000, 1024, 512);
let opts = RenderOpts {
width: 40,
height: 10,
..Default::default()
};
let out = render(&spec, &opts);
// Header + 10 body rows + time axis (2) + legend = non-trivial.
let lines = out.lines().count();
assert!(lines >= 13, "expected >=13 lines, got {lines}");
assert!(out.contains("Spectrogram"));
}
#[test]
fn empty_spectrogram_renders_message() {
let spec = stft::analyze(&[], 48_000, 512, 256);
let out = render(&spec, &RenderOpts::default());
assert!(out.contains("empty"));
}
}