From 69908f5e4e6f7d61b6e9bd79bbcaf744fe846c8a Mon Sep 17 00:00:00 2001 From: cozy Date: Sat, 18 Apr 2026 12:32:45 -0400 Subject: [PATCH] fix(viz): render braille visualisers edge-to-edge Pulse grows its disc against the longer half-axis so it fills the pane on wide terminals instead of hugging the middle with black gutters. Scope is intrinsically square (Lissajous in signal space), so it letterboxes with a muted phosphor-green gutter rather than stretching. Wave, Heartbeat, and BarsDot now paint empty cells with their palette's muted backdrop colour instead of raw black, so the pane reads as "the viz chose this shape" rather than "the viz is broken". Each touched visualiser gains a 120x40 render test asserting that every edge row and column carries either a painted dot or a palette-tinted cell. Closes clitunes-ciy / CLI-87 --- .../src/visualiser/bars_dot.rs | 38 +++++- .../src/visualiser/heartbeat.rs | 38 +++++- .../clitunes-engine/src/visualiser/pulse.rs | 41 ++++++- .../clitunes-engine/src/visualiser/scope.rs | 115 +++++++++++++----- crates/clitunes-engine/src/visualiser/wave.rs | 38 +++++- 5 files changed, 229 insertions(+), 41 deletions(-) diff --git a/crates/clitunes-engine/src/visualiser/bars_dot.rs b/crates/clitunes-engine/src/visualiser/bars_dot.rs index 34b78bb..ec28f5c 100644 --- a/crates/clitunes-engine/src/visualiser/bars_dot.rs +++ b/crates/clitunes-engine/src/visualiser/bars_dot.rs @@ -150,13 +150,16 @@ impl Visualiser for BarsDot { } } + // Subtle warm gutter so empty air above the bars reads as a muted + // backdrop rather than a dead pane. + let gutter = Rgb::new(4, 2, 0); let grid_h = h; self.braille.compose(grid, |_cx, cy, dot_count| { if dot_count > 0 { let fg = Self::color_for_row(cy, grid_h); - (fg, Rgb::BLACK) + (fg, gutter) } else { - (Rgb::BLACK, Rgb::BLACK) + (gutter, gutter) } }); } @@ -221,6 +224,37 @@ mod tests { ); } + #[test] + fn gutter_is_tinted_not_black() { + // Even when the bars are short, the air above them must carry a + // palette-consistent tint rather than raw black. + let mut viz = BarsDot::new(); + let fft = FftSnapshot::new(vec![0.0; 128], 48_000, 256); + let mut grid = CellGrid::new(120, 40); + { + let mut ctx = TuiContext { grid: &mut grid }; + viz.render_tui(&mut ctx, &fft); + } + + let edge_rows = [0u16, 39]; + let edge_cols = [0u16, 119]; + + for row in edge_rows { + let any_tinted = (0..120u16).any(|x| { + let cell = grid.cells()[(row as usize) * 120 + x as usize]; + cell.bg != Rgb::BLACK || cell.fg != Rgb::BLACK + }); + assert!(any_tinted, "row {row} must have palette-tinted cells"); + } + for col in edge_cols { + let any_tinted = (0..40u16).any(|y| { + let cell = grid.cells()[(y as usize) * 120 + col as usize]; + cell.bg != Rgb::BLACK || cell.fg != Rgb::BLACK + }); + assert!(any_tinted, "col {col} must have palette-tinted cells"); + } + } + #[test] fn resize_no_panic() { let mut viz = BarsDot::new(); diff --git a/crates/clitunes-engine/src/visualiser/heartbeat.rs b/crates/clitunes-engine/src/visualiser/heartbeat.rs index 5198ab8..81f1a8f 100644 --- a/crates/clitunes-engine/src/visualiser/heartbeat.rs +++ b/crates/clitunes-engine/src/visualiser/heartbeat.rs @@ -120,14 +120,17 @@ impl Visualiser for Heartbeat { let base = 0.3_f32; let brightness = (base + energy * 0.7).min(1.0); + // Muted hospital-monitor green so empty cells carry the palette + // rather than leave a raw black pane. + let gutter = Rgb::new(0, 6, 2); self.braille.compose(grid, |_cx, _cy, dot_count| { if dot_count > 0 { let peak_boost = (dot_count as f32 / 8.0).min(1.0); let green_val = brightness * (0.5 + 0.5 * peak_boost); let fg = Rgb::new(0, f32_to_u8(green_val), 0); - (fg, Rgb::BLACK) + (fg, gutter) } else { - (Rgb::BLACK, Rgb::BLACK) + (gutter, gutter) } }); } @@ -209,6 +212,37 @@ mod tests { assert!(diff > 0, "different inputs should produce different output"); } + #[test] + fn gutter_is_tinted_not_black() { + // Even with silent input the pane must carry the hospital-green + // gutter palette instead of collapsing to raw black. + let mut hb = Heartbeat::new(); + let fft = fft_with_samples(vec![0.0; 1024]); + let mut grid = CellGrid::new(120, 40); + { + let mut ctx = TuiContext { grid: &mut grid }; + hb.render_tui(&mut ctx, &fft); + } + + let edge_rows = [0u16, 39]; + let edge_cols = [0u16, 119]; + + for row in edge_rows { + let any_tinted = (0..120u16).any(|x| { + let cell = grid.cells()[(row as usize) * 120 + x as usize]; + cell.bg != Rgb::BLACK || cell.fg != Rgb::BLACK + }); + assert!(any_tinted, "row {row} must have palette-tinted cells"); + } + for col in edge_cols { + let any_tinted = (0..40u16).any(|y| { + let cell = grid.cells()[(y as usize) * 120 + col as usize]; + cell.bg != Rgb::BLACK || cell.fg != Rgb::BLACK + }); + assert!(any_tinted, "col {col} must have palette-tinted cells"); + } + } + #[test] fn resize_does_not_panic() { let mut hb = Heartbeat::new(); diff --git a/crates/clitunes-engine/src/visualiser/pulse.rs b/crates/clitunes-engine/src/visualiser/pulse.rs index a9afd00..0b4844c 100644 --- a/crates/clitunes-engine/src/visualiser/pulse.rs +++ b/crates/clitunes-engine/src/visualiser/pulse.rs @@ -78,9 +78,11 @@ impl Visualiser for Pulse { let cx = bw / 2; let cy = bh / 2; - // Compute main circle radius based on energy. - let min_dim = cx.min(cy) as f32; - let base_radius = min_dim * 0.3 + energy * min_dim * 0.5; + // Scale the pulsing disc against the longer half-axis so the circle + // fills the pane even on wide terminals, rather than sitting as a + // small island in the middle with black gutters. + let max_dim = cx.max(cy) as f32; + let base_radius = max_dim * 0.35 + energy * max_dim * 0.65; // Detect transient and spawn shockwave ring. if energy - self.prev_energy > TRANSIENT_THRESHOLD { @@ -230,6 +232,39 @@ mod tests { pulse.render_tui(&mut ctx, &fft); } + #[test] + fn fills_pane_at_120x40() { + let mut pulse = Pulse::new(); + let fft = loud_fft(); + let mut grid = CellGrid::new(120, 40); + // Drive energy high so the disc reaches its full radius. + for _ in 0..20 { + let mut ctx = TuiContext { grid: &mut grid }; + pulse.render_tui(&mut ctx, &fft); + } + + let has_dot = |row: u16, col_range: std::ops::Range| { + col_range.clone().any(|x| { + let cell = grid.cells()[(row as usize) * 120 + x as usize]; + cell.ch != '\u{2800}' && cell.ch != ' ' + }) + }; + let has_dot_col = |col: u16, row_range: std::ops::Range| { + row_range.clone().any(|y| { + let cell = grid.cells()[(y as usize) * 120 + col as usize]; + cell.ch != '\u{2800}' && cell.ch != ' ' + }) + }; + + assert!(has_dot(0, 0..120), "top edge should have painted dots"); + assert!(has_dot(39, 0..120), "bottom edge should have painted dots"); + assert!(has_dot_col(0, 0..40), "left edge should have painted dots"); + assert!( + has_dot_col(119, 0..40), + "right edge should have painted dots" + ); + } + #[test] fn shockwave_spawns_on_transient() { let mut pulse = Pulse::new(); diff --git a/crates/clitunes-engine/src/visualiser/scope.rs b/crates/clitunes-engine/src/visualiser/scope.rs index 09063c0..1d26304 100644 --- a/crates/clitunes-engine/src/visualiser/scope.rs +++ b/crates/clitunes-engine/src/visualiser/scope.rs @@ -62,49 +62,67 @@ impl Visualiser for Scope { self.ensure_buf(w, h); self.braille.clear(); - let sub_w = self.braille.width() as f32; - let sub_h = self.braille.height() as f32; + // The Lissajous figure is intrinsically square in signal space. + // Letterbox on the longer axis (in braille sub-cell units — they + // are near-square on screen) and paint the surrounding gutter with + // a muted phosphor tint so the shape reads as deliberate rather + // than broken. + let buf_w = self.braille.width() as f32; + let buf_h = self.braille.height() as f32; + let square_side = buf_w.min(buf_h); + let sub_w = square_side; + let sub_h = square_side; + let x_margin = ((buf_w - sub_w) * 0.5).max(0.0); + let y_margin = ((buf_h - sub_h) * 0.5).max(0.0); + // Convert sub-cell margins to terminal-cell bounds for gutter paint. + let x_gutter_cells = (x_margin / 2.0).round() as u16; + let y_gutter_cells = (y_margin / 4.0).round() as u16; let samples = &fft.samples; - if samples.len() < 2 { - self.braille - .compose(grid, |_, _, _| (Rgb::BLACK, Rgb::BLACK)); - return; - } - - // Phase offset oscillates slowly for evolving Lissajous figures. - let phase_offset = (self.frame % 512) as usize; - let mut prev: Option<(i32, i32)> = None; - for i in 0..samples.len() { - let x_sample = samples[i]; - let y_sample = samples[(i + phase_offset) % samples.len()]; - - // Map [-1, 1] to sub-pixel coords with a small margin. - let px = ((x_sample + 1.0) * 0.5 * (sub_w - 1.0)) - .round() - .clamp(0.0, sub_w - 1.0) as i32; - let py = ((y_sample + 1.0) * 0.5 * (sub_h - 1.0)) - .round() - .clamp(0.0, sub_h - 1.0) as i32; - - if let Some((ppx, ppy)) = prev { - self.braille.line(ppx, ppy, px, py); - } - prev = Some((px, py)); - } - // Phosphor-green CRT colour, brightness modulated by energy. let base = 0.35_f32; let brightness = (base + energy * 0.65).min(1.0); + let gutter = Rgb::new(0, 4, 0); + + if samples.len() >= 2 { + // Phase offset oscillates slowly for evolving Lissajous figures. + let phase_offset = (self.frame % 512) as usize; + let mut prev: Option<(i32, i32)> = None; + for i in 0..samples.len() { + let x_sample = samples[i]; + let y_sample = samples[(i + phase_offset) % samples.len()]; + + let px = (x_margin + (x_sample + 1.0) * 0.5 * (sub_w - 1.0)) + .round() + .clamp(0.0, buf_w - 1.0) as i32; + let py = (y_margin + (y_sample + 1.0) * 0.5 * (sub_h - 1.0)) + .round() + .clamp(0.0, buf_h - 1.0) as i32; + + if let Some((ppx, ppy)) = prev { + self.braille.line(ppx, ppy, px, py); + } + prev = Some((px, py)); + } + } + + let x_lo = x_gutter_cells; + let x_hi = w.saturating_sub(x_gutter_cells); + let y_lo = y_gutter_cells; + let y_hi = h.saturating_sub(y_gutter_cells); - self.braille.compose(grid, |_cx, _cy, dot_count| { + self.braille.compose(grid, |cx, cy, dot_count| { + let in_frame = cx >= x_lo && cx < x_hi && cy >= y_lo && cy < y_hi; if dot_count > 0 { let peak_boost = (dot_count as f32 / 8.0).min(1.0); let green_val = brightness * (0.6 + 0.4 * peak_boost); let fg = Rgb::new(0, f32_to_u8(green_val), 0); - (fg, Rgb::BLACK) - } else { + let bg = if in_frame { Rgb::BLACK } else { gutter }; + (fg, bg) + } else if in_frame { (Rgb::BLACK, Rgb::BLACK) + } else { + (gutter, gutter) } }); } @@ -180,6 +198,39 @@ mod tests { ); } + #[test] + fn letterboxed_gutter_is_tinted() { + // Scope's Lissajous is intrinsically square; on a wide pane the + // left/right gutters must be painted with a muted phosphor tint, + // never pure black. + let mut scope = Scope::new(); + let samples: Vec = (0..1024).map(|i| (i as f32 * 0.03).sin() * 0.6).collect(); + let fft = fft_with_samples(samples); + let mut grid = CellGrid::new(120, 40); + { + let mut ctx = TuiContext { grid: &mut grid }; + scope.render_tui(&mut ctx, &fft); + } + + let edge_rows = [0u16, 39]; + let edge_cols = [0u16, 119]; + + for row in edge_rows { + let any_tinted = (0..120u16).any(|x| { + let cell = grid.cells()[(row as usize) * 120 + x as usize]; + cell.bg != Rgb::BLACK || cell.fg != Rgb::BLACK + }); + assert!(any_tinted, "row {row} must have palette-tinted cells"); + } + for col in edge_cols { + let any_tinted = (0..40u16).any(|y| { + let cell = grid.cells()[(y as usize) * 120 + col as usize]; + cell.bg != Rgb::BLACK || cell.fg != Rgb::BLACK + }); + assert!(any_tinted, "col {col} must have palette-tinted cells"); + } + } + #[test] fn resize_does_not_panic() { let mut scope = Scope::new(); diff --git a/crates/clitunes-engine/src/visualiser/wave.rs b/crates/clitunes-engine/src/visualiser/wave.rs index c159dd4..bcab5e2 100644 --- a/crates/clitunes-engine/src/visualiser/wave.rs +++ b/crates/clitunes-engine/src/visualiser/wave.rs @@ -95,6 +95,9 @@ impl Visualiser for Wave { let base = 0.3_f32; let brightness = (base + energy * 0.7).min(1.0); + // Muted oceanic gutter so empty cells read as palette-consistent + // background rather than broken screen. + let gutter = Rgb::new(1, 3, 6); self.braille.compose(grid, |_cx, _cy, dot_count| { if dot_count > 0 { // Brighter where more dots (trace peaks). @@ -103,9 +106,9 @@ impl Visualiser for Wave { let r = f32_to_u8(val * 0.2); let g = f32_to_u8(val * 0.7); let b = f32_to_u8(val * 1.0); - (Rgb::new(r, g, b), Rgb::BLACK) + (Rgb::new(r, g, b), gutter) } else { - (Rgb::BLACK, Rgb::BLACK) + (gutter, gutter) } }); } @@ -175,6 +178,37 @@ mod tests { assert!(diff > 0, "different inputs should produce different output"); } + #[test] + fn gutter_is_tinted_not_black() { + // With silent input the trace collapses to a baseline stripe; the + // rest of the pane must still carry the palette bg, not pure black. + let mut wave = Wave::new(); + let fft = fft_with_samples(vec![0.0; 1024]); + let mut grid = CellGrid::new(120, 40); + { + let mut ctx = TuiContext { grid: &mut grid }; + wave.render_tui(&mut ctx, &fft); + } + + let edge_rows = [0u16, 39]; + let edge_cols = [0u16, 119]; + + for row in edge_rows { + let any_tinted = (0..120u16).any(|x| { + let cell = grid.cells()[(row as usize) * 120 + x as usize]; + cell.bg != Rgb::BLACK || cell.fg != Rgb::BLACK + }); + assert!(any_tinted, "row {row} must have palette-tinted cells"); + } + for col in edge_cols { + let any_tinted = (0..40u16).any(|y| { + let cell = grid.cells()[(y as usize) * 120 + col as usize]; + cell.bg != Rgb::BLACK || cell.fg != Rgb::BLACK + }); + assert!(any_tinted, "col {col} must have palette-tinted cells"); + } + } + #[test] fn resize_does_not_panic() { let mut wave = Wave::new();