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();