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>
266 lines
8.8 KiB
Rust
266 lines
8.8 KiB
Rust
//! 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"));
|
||
}
|
||
}
|