diff --git a/README.md b/README.md index 5e486e6..bde52c6 100644 --- a/README.md +++ b/README.md @@ -100,9 +100,13 @@ cargo run --release -- \ - `--spectral-flux` emphasizes note attacks and percussion transients. - `--harmonics` and `--harmonic-boost` reinforce harmonic ladders. - `--path line|circle|arc|sine|diamond|pulse` changes the spectrum baseline. +- `--display braille` renders curved paths at a 2x4 subcell resolution. - `--trails` creates delayed ghost layers and supplies waterfall history. - `--display waterfall` shows frequency persistence through time. - `--beat-reactivity` lets bass energy expand the spectrum geometry. +- `--glow-strength` and `--glow-release` control a wider, slower spectrum + envelope underneath the sharp core, reproducing the common stacked-AE-layer + treatment without running another FFT. - `--channels stereo` maps left to Side A and right to Side B; `--channels mid-side` maps mono-compatible center energy to A and stereo width to B. The default `mix` mode computes only one FFT. diff --git a/benches/spectrum.rs b/benches/spectrum.rs index 91b2aa8..80861fc 100644 --- a/benches/spectrum.rs +++ b/benches/spectrum.rs @@ -33,6 +33,7 @@ fn config(fft_size: usize, detector: Detector) -> SpectrumConfig { tilt: 0.0, peak_hold_ms: 420.0, peak_decay_db: 24.0, + glow_release_ms: 480.0, } } diff --git a/src/cli.rs b/src/cli.rs index d8277ec..2cb276a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -55,6 +55,8 @@ pub enum Display { Bars, /// Frequency history flowing through time. Waterfall, + /// Smooth 2x4 subcell strokes, especially useful for curved paths. + Braille, } impl Display { @@ -64,7 +66,8 @@ impl Display { Self::AnalogDots => Self::Digital, Self::Digital => Self::Bars, Self::Bars => Self::Waterfall, - Self::Waterfall => Self::AnalogLines, + Self::Waterfall => Self::Braille, + Self::Braille => Self::AnalogLines, } } @@ -79,6 +82,7 @@ impl Display { Self::Digital => "DIGITAL", Self::Bars => "BARS", Self::Waterfall => "WATERFALL", + Self::Braille => "BRAILLE", } } } @@ -374,6 +378,14 @@ pub struct Cli { #[arg(long, default_value_t = 0.55, value_parser = parse_unit)] pub softness: f32, + /// Strength of a slower, wider spectrum layer under the core. + #[arg(long, default_value_t = 0.45, value_parser = parse_unit)] + pub glow_strength: f32, + + /// Release time of the slower glow layer in milliseconds. + #[arg(long, default_value_t = 480.0, value_parser = parse_milliseconds)] + pub glow_release: f32, + /// Number of palette interpolations across the spectrum path. #[arg(long, default_value_t = 1.0, value_parser = parse_hue_cycles)] pub hue_cycles: f32, diff --git a/src/main.rs b/src/main.rs index 01bd673..83ca067 100644 --- a/src/main.rs +++ b/src/main.rs @@ -92,6 +92,7 @@ fn run_ui(cli: Cli, mut capture: audio::AudioReceiver) -> Result<()> { tilt: cli.tilt, peak_hold_ms: cli.peak_hold, peak_decay_db: cli.peak_decay, + glow_release_ms: cli.glow_release, }; let mut spectrum = Spectrum::new(spectrum_config.clone(), capture.sample_rate); let mut secondary = (cli.channels != ChannelMode::Mix) @@ -203,8 +204,10 @@ fn ui_loop( &ui::View { levels: spectrum_frame.levels, peaks: spectrum_frame.peaks, + glow_levels: spectrum_frame.glow_levels, secondary_levels: secondary_frame.as_ref().map(|frame| frame.levels), secondary_peaks: secondary_frame.as_ref().map(|frame| frame.peaks), + secondary_glow_levels: secondary_frame.as_ref().map(|frame| frame.glow_levels), source: &capture.label, sample_rate: capture.sample_rate, channel_mode: cli.channels, @@ -220,6 +223,7 @@ fn ui_loop( side, max_height: cli.max_height, softness: cli.softness, + glow_strength: cli.glow_strength, hue_cycles: cli.hue_cycles, inside_color: cli.inside_color, outside_color: cli.outside_color, diff --git a/src/spectrum.rs b/src/spectrum.rs index cce2153..5a8877f 100644 --- a/src/spectrum.rs +++ b/src/spectrum.rs @@ -29,6 +29,7 @@ pub struct SpectrumConfig { pub tilt: f32, pub peak_hold_ms: f32, pub peak_decay_db: f32, + pub glow_release_ms: f32, } pub struct Spectrum { @@ -50,6 +51,7 @@ pub struct Spectrum { effect_work: Vec, spatial_work: Vec, levels: Vec, + glow_levels: Vec, peaks: Vec, peak_holds: Vec, } @@ -66,6 +68,7 @@ struct BandMap { pub struct SpectrumFrame<'a> { pub levels: &'a [f32], pub peaks: &'a [f32], + pub glow_levels: &'a [f32], } impl Spectrum { @@ -101,6 +104,7 @@ impl Spectrum { effect_work: Vec::new(), spatial_work: Vec::new(), levels: Vec::new(), + glow_levels: Vec::new(), peaks: Vec::new(), peak_holds: Vec::new(), config, @@ -168,6 +172,8 @@ impl Spectrum { ); let attack = smoothing_factor(dt, self.config.attack_ms / 1000.0); let release = smoothing_factor(dt, self.config.release_ms / 1000.0); + let glow_attack = smoothing_factor(dt, self.config.attack_ms * 2.0 / 1000.0); + let glow_release = smoothing_factor(dt, self.config.glow_release_ms / 1000.0); let range = self.config.db_ceiling - self.config.db_floor; let peak_decay = self.config.peak_decay_db / range * dt; @@ -178,6 +184,12 @@ impl Spectrum { release }; self.levels[index] += (target - self.levels[index]) * coefficient; + let glow_coefficient = if target > self.glow_levels[index] { + glow_attack + } else { + glow_release + }; + self.glow_levels[index] += (target - self.glow_levels[index]) * glow_coefficient; if self.levels[index] >= self.peaks[index] { self.peaks[index] = self.levels[index]; @@ -191,6 +203,7 @@ impl Spectrum { SpectrumFrame { levels: &self.levels, peaks: &self.peaks, + glow_levels: &self.glow_levels, } } @@ -204,6 +217,7 @@ impl Spectrum { self.effect_work.resize(bands, 0.0); self.spatial_work.resize(bands, 0.0); self.levels.resize(bands, 0.0); + self.glow_levels.resize(bands, 0.0); self.peaks.resize(bands, 0.0); self.peak_holds.resize(bands, 0.0); } @@ -486,6 +500,7 @@ mod tests { tilt: 0.0, peak_hold_ms: 0.0, peak_decay_db: 20.0, + glow_release_ms: 480.0, } } diff --git a/src/ui.rs b/src/ui.rs index 9a88977..a86f32e 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -11,8 +11,10 @@ use crate::cli::{ChannelMode, Display, FrequencyScale, Path, RgbColor, Side, The pub struct View<'a> { pub levels: &'a [f32], pub peaks: &'a [f32], + pub glow_levels: &'a [f32], pub secondary_levels: Option<&'a [f32]>, pub secondary_peaks: Option<&'a [f32]>, + pub secondary_glow_levels: Option<&'a [f32]>, pub source: &'a str, pub sample_rate: u32, pub channel_mode: ChannelMode, @@ -28,6 +30,7 @@ pub struct View<'a> { pub side: Side, pub max_height: f32, pub softness: f32, + pub glow_strength: f32, pub hue_cycles: f32, pub inside_color: Option, pub outside_color: Option, @@ -70,8 +73,8 @@ pub fn draw(frame: &mut Frame, view: &View<'_>) { match view.display { Display::Bars => draw_bars(frame, spectrum, view), Display::Waterfall => draw_waterfall(frame, spectrum, view), - Display::AnalogLines | Display::AnalogDots | Display::Digital => { - if view.path == Path::Line { + Display::AnalogLines | Display::AnalogDots | Display::Digital | Display::Braille => { + if view.path == Path::Line && view.display != Display::Braille { draw_ae_spectrum(frame, spectrum, view); } else { draw_path_spectrum(frame, spectrum, view); @@ -225,6 +228,17 @@ fn draw_ae_spectrum(frame: &mut Frame, area: Rect, view: &View<'_>) { top_space, bottom_space, ); + draw_line_glow( + buffer, + area, + view, + start_x, + stride, + width, + baseline, + top_space, + bottom_space, + ); for (index, &level) in view.levels.iter().enumerate() { let x = start_x + index as u16 * stride; @@ -232,8 +246,6 @@ fn draw_ae_spectrum(frame: &mut Frame, area: Rect, view: &View<'_>) { break; } let (inside, outside) = stroke_colors(view, index); - let glow_inside = scale_color(inside, 0.18 + view.softness * 0.24); - let glow_outside = scale_color(outside, 0.18 + view.softness * 0.24); let top_height = if matches!(view.side, Side::Both | Side::A) { (level.clamp(0.0, 1.0) * reactive_height(view) * top_space as f32).round() as u16 } else { @@ -251,24 +263,6 @@ fn draw_ae_spectrum(frame: &mut Frame, area: Rect, view: &View<'_>) { 0 }; - if view.softness > 0.0 { - for glow_x in [x.saturating_sub(1), x.saturating_add(width)] { - if glow_x >= area.x && glow_x < area.right() { - draw_stroke( - buffer, - glow_x, - 1, - baseline, - top_height, - bottom_height, - Display::AnalogDots, - glow_inside, - glow_outside, - false, - ); - } - } - } draw_stroke( buffer, x, @@ -306,6 +300,64 @@ fn draw_ae_spectrum(frame: &mut Frame, area: Rect, view: &View<'_>) { } } +#[allow(clippy::too_many_arguments)] +fn draw_line_glow( + buffer: &mut ratatui::buffer::Buffer, + area: Rect, + view: &View<'_>, + start_x: u16, + stride: u16, + width: u16, + baseline: u16, + top_space: u16, + bottom_space: u16, +) { + if view.glow_strength <= 0.0 { + return; + } + let alpha = (0.14 + view.softness * 0.3) * view.glow_strength; + for (index, &glow_level) in view.glow_levels.iter().enumerate() { + let x = start_x + index as u16 * stride; + if x >= area.right() { + break; + } + let secondary_glow = view + .secondary_glow_levels + .and_then(|levels| levels.get(index)) + .copied() + .unwrap_or(glow_level); + let glow_top = if matches!(view.side, Side::Both | Side::A) { + (glow_level * reactive_height(view) * top_space as f32).round() as u16 + } else { + 0 + }; + let glow_bottom = if matches!(view.side, Side::Both | Side::B) { + (secondary_glow * reactive_height(view) * bottom_space as f32).round() as u16 + } else { + 0 + }; + let (inside, outside) = stroke_colors(view, index); + let inside = scale_color(inside, alpha); + let outside = scale_color(outside, alpha); + for glow_x in [x.saturating_sub(1), x, x.saturating_add(width)] { + if glow_x >= area.x && glow_x < area.right() { + draw_stroke( + buffer, + glow_x, + 1, + baseline, + glow_top, + glow_bottom, + Display::AnalogDots, + inside, + outside, + false, + ); + } + } + } +} + #[allow(clippy::too_many_arguments)] fn draw_line_trails( buffer: &mut ratatui::buffer::Buffer, @@ -414,10 +466,53 @@ fn draw_path_spectrum(frame: &mut Frame, area: Rect, view: &View<'_>) { }; let ((x, y), (nx, ny)) = path_point(view.path, area, position); let (inside, outside) = stroke_colors(view, index); - plot_cell(buffer, area, x, y, '\u{2022}', scale_color(inside, 0.55)); + if view.glow_strength > 0.0 { + let glow_a = view.glow_levels.get(index).copied().unwrap_or(level); + let glow_b = view + .secondary_glow_levels + .and_then(|levels| levels.get(index)) + .copied() + .unwrap_or(glow_a); + let glow_inside = scale_color(inside, view.glow_strength * 0.35); + let glow_outside = scale_color(outside, view.glow_strength * 0.35); + let glow_display = if view.display == Display::Braille { + Display::Braille + } else { + Display::AnalogDots + }; + if matches!(view.side, Side::Both | Side::A) { + draw_path_vector( + buffer, + area, + (x, y), + (nx, ny), + glow_a * reactive_height(view) * maximum, + glow_display, + glow_inside, + glow_outside, + ); + } + if matches!(view.side, Side::Both | Side::B) { + draw_path_vector( + buffer, + area, + (x, y), + (-nx, -ny), + glow_b * reactive_height(view) * maximum, + glow_display, + glow_inside, + glow_outside, + ); + } + } + if view.display == Display::Braille { + plot_braille(buffer, area, x, y, scale_color(inside, 0.7)); + } else { + plot_cell(buffer, area, x, y, '\u{2022}', scale_color(inside, 0.55)); + } let height = level * reactive_height(view) * maximum; if matches!(view.side, Side::Both | Side::A) { - draw_vector( + draw_path_vector( buffer, area, (x, y), @@ -436,7 +531,7 @@ fn draw_path_spectrum(frame: &mut Frame, area: Rect, view: &View<'_>) { .unwrap_or(level) * reactive_height(view) * maximum; - draw_vector( + draw_path_vector( buffer, area, (x, y), @@ -450,6 +545,26 @@ fn draw_path_spectrum(frame: &mut Frame, area: Rect, view: &View<'_>) { } } +#[allow(clippy::too_many_arguments)] +fn draw_path_vector( + buffer: &mut ratatui::buffer::Buffer, + area: Rect, + origin: (f32, f32), + normal: (f32, f32), + height: f32, + display: Display, + inside: Color, + outside: Color, +) { + if display == Display::Braille { + draw_braille_vector(buffer, area, origin, normal, height, inside, outside); + } else { + draw_vector( + buffer, area, origin, normal, height, display, inside, outside, + ); + } +} + fn path_point(path: Path, area: Rect, position: f32) -> ((f32, f32), (f32, f32)) { let left = f32::from(area.x + 2); let right = f32::from(area.right().saturating_sub(3)); @@ -579,6 +694,58 @@ fn draw_vector( } } +#[allow(clippy::too_many_arguments)] +fn draw_braille_vector( + buffer: &mut ratatui::buffer::Buffer, + area: Rect, + origin: (f32, f32), + normal: (f32, f32), + height: f32, + inside: Color, + outside: Color, +) { + let length = (normal.0 * normal.0 + normal.1 * normal.1) + .sqrt() + .max(0.001); + let steps = (height * 4.0).round() as usize; + for step in 1..=steps { + let distance = step as f32 / 4.0; + plot_braille( + buffer, + area, + origin.0 + normal.0 / length * distance, + origin.1 + normal.1 / length * distance, + interpolate_color(inside, outside, step as f32 / steps.max(1) as f32), + ); + } +} + +fn plot_braille(buffer: &mut ratatui::buffer::Buffer, area: Rect, x: f32, y: f32, color: Color) { + let sub_x = ((x - f32::from(area.x)) * 2.0).round() as i32; + let sub_y = ((y - f32::from(area.y)) * 4.0).round() as i32; + if sub_x < 0 + || sub_y < 0 + || sub_x >= i32::from(area.width) * 2 + || sub_y >= i32::from(area.height) * 4 + { + return; + } + let cell_x = area.x + sub_x as u16 / 2; + let cell_y = area.y + sub_y as u16 / 4; + let column = sub_x as usize % 2; + let row = sub_y as usize % 4; + const BITS: [[u32; 4]; 2] = [[0, 1, 2, 6], [3, 4, 5, 7]]; + let cell = &mut buffer[(cell_x, cell_y)]; + let existing = cell.symbol().chars().next().map(u32::from).unwrap_or(0); + let mask = if (0x2800..=0x28ff).contains(&existing) { + existing - 0x2800 + } else { + 0 + }; + cell.set_char(char::from_u32(0x2800 + (mask | 1 << BITS[column][row])).unwrap()) + .set_fg(color); +} + fn plot_cell( buffer: &mut ratatui::buffer::Buffer, area: Rect, @@ -645,7 +812,7 @@ fn draw_stroke( fn stroke_visible(display: Display, offset: u16, height: u16) -> bool { match display { - Display::AnalogLines | Display::Bars | Display::Waterfall => true, + Display::AnalogLines | Display::Bars | Display::Waterfall | Display::Braille => true, Display::AnalogDots => offset.is_multiple_of(2) || offset == height, Display::Digital => offset.is_multiple_of(2) || offset == height || offset == 0, } @@ -662,6 +829,7 @@ fn stroke_char(display: Display, offset: u16, height: u16, core: bool) -> char { Display::AnalogDots => '\u{2022}', Display::Digital => '\u{2588}', Display::Bars | Display::Waterfall => '\u{2588}', + Display::Braille => '\u{28ff}', } } @@ -862,8 +1030,10 @@ mod tests { &View { levels: &[0.2, 0.45, 0.9, 0.65, 0.3], peaks: &[0.3, 0.55, 0.95, 0.75, 0.4], + glow_levels: &[0.3, 0.5, 0.8, 0.7, 0.4], secondary_levels: None, secondary_peaks: None, + secondary_glow_levels: None, source: "Test Source", sample_rate: 48_000, channel_mode: ChannelMode::Mix, @@ -879,6 +1049,7 @@ mod tests { side: Side::Both, max_height: 0.88, softness: 0.55, + glow_strength: 0.45, hue_cycles: 1.0, inside_color: None, outside_color: None, @@ -908,4 +1079,16 @@ mod tests { ); } } + + #[test] + fn braille_mode_uses_subcell_glyphs() { + let mut buffer = ratatui::buffer::Buffer::empty(Rect::new(0, 0, 4, 4)); + plot_braille(&mut buffer, Rect::new(0, 0, 4, 4), 1.25, 1.25, Color::Red); + assert!(buffer.content().iter().any(|cell| { + cell.symbol() + .chars() + .next() + .is_some_and(|symbol| ('\u{2800}'..='\u{28ff}').contains(&symbol)) + })); + } }