Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 36 additions & 2 deletions crates/clitunes-engine/src/visualiser/bars_dot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
});
}
Expand Down Expand Up @@ -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();
Expand Down
38 changes: 36 additions & 2 deletions crates/clitunes-engine/src/visualiser/heartbeat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
});
}
Expand Down Expand Up @@ -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();
Expand Down
41 changes: 38 additions & 3 deletions crates/clitunes-engine/src/visualiser/pulse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<u16>| {
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<u16>| {
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();
Expand Down
115 changes: 83 additions & 32 deletions crates/clitunes-engine/src/visualiser/scope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
});
}
Expand Down Expand Up @@ -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<f32> = (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();
Expand Down
38 changes: 36 additions & 2 deletions crates/clitunes-engine/src/visualiser/wave.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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)
}
});
}
Expand Down Expand Up @@ -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();
Expand Down
Loading