//! 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::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::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::() / (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")); } }