//! 32-band frequency spectrum display with optional peak hold. use crate::theme; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Color; use ratatui::widgets::Widget; use std::cell::RefCell; const BLOCKS: [char; 8] = ['\u{2581}', '\u{2582}', '\u{2583}', '\u{2584}', '\u{2585}', '\u{2586}', '\u{2587}', '\u{2588}']; #[derive(Clone, Copy, PartialEq, Eq, Default)] pub enum SpectrumStyle { #[default] Bars, Line, Filled, } thread_local! { static PEAKS: RefCell<[f32; 32]> = const { RefCell::new([0.0; 32]) }; static PATTERNS: RefCell> = const { RefCell::new(Vec::new()) }; } /// 32-band spectrum analyzer using block characters. pub struct Spectrum<'a> { data: &'a [f32; 32], gain: f32, style: SpectrumStyle, peaks: bool, } impl<'a> Spectrum<'a> { pub fn new(data: &'a [f32; 32]) -> Self { Self { data, gain: 1.0, style: SpectrumStyle::Bars, peaks: false, } } pub fn gain(mut self, g: f32) -> Self { self.gain = g; self } pub fn style(mut self, s: SpectrumStyle) -> Self { self.style = s; self } pub fn peaks(mut self, enabled: bool) -> Self { self.peaks = enabled; self } } impl Widget for Spectrum<'_> { fn render(self, area: Rect, buf: &mut Buffer) { if area.width == 0 || area.height == 0 { return; } // Update peak hold state let peak_values = if self.peaks { Some(PEAKS.with(|p| { let mut peaks = p.borrow_mut(); for (i, &mag) in self.data.iter().enumerate() { let v = (mag * self.gain).min(1.0); if v >= peaks[i] { peaks[i] = v; } else { peaks[i] = (peaks[i] - 0.02).max(v); } } *peaks })) } else { None }; match self.style { SpectrumStyle::Bars => render_bars(self.data, area, buf, self.gain, peak_values.as_ref()), SpectrumStyle::Line => render_braille(self.data, area, buf, self.gain, false, peak_values.as_ref()), SpectrumStyle::Filled => render_braille(self.data, area, buf, self.gain, true, peak_values.as_ref()), } } } fn band_color(ratio: f32, colors: &theme::ThemeColors) -> Color { if ratio < 0.33 { Color::Rgb(colors.meter.low_rgb.0, colors.meter.low_rgb.1, colors.meter.low_rgb.2) } else if ratio < 0.66 { Color::Rgb(colors.meter.mid_rgb.0, colors.meter.mid_rgb.1, colors.meter.mid_rgb.2) } else { Color::Rgb(colors.meter.high_rgb.0, colors.meter.high_rgb.1, colors.meter.high_rgb.2) } } fn render_bars(data: &[f32; 32], area: Rect, buf: &mut Buffer, gain: f32, peaks: Option<&[f32; 32]>) { let colors = theme::get(); let height = area.height as f32; let base = area.width as usize / 32; let remainder = area.width as usize % 32; if base == 0 && remainder == 0 { return; } let mut x_start = area.x; for (band, &mag) in data.iter().enumerate() { let w = base + if band < remainder { 1 } else { 0 }; if w == 0 { continue; } let bar_height = (mag * gain).min(1.0) * height; let full_cells = bar_height as usize; let frac = bar_height - full_cells as f32; let frac_idx = (frac * 8.0) as usize; // Peak hold row let peak_row = peaks.map(|p| { let ph = p[band] * height; let row = (height - ph).max(0.0) as usize; row.min(area.height as usize - 1) }); for row in 0..area.height as usize { let y = area.y + area.height - 1 - row as u16; let ratio = row as f32 / area.height as f32; let color = band_color(ratio, &colors); for dx in 0..w as u16 { let x = x_start + dx; if row < full_cells { buf[(x, y)].set_char(BLOCKS[7]).set_fg(color); } else if row == full_cells && frac_idx > 0 { buf[(x, y)].set_char(BLOCKS[frac_idx - 1]).set_fg(color); } else if let Some(pr) = peak_row { // peak_row is from top (0 = top), row is from bottom let from_top = area.height as usize - 1 - row; if from_top == pr { buf[(x, y)].set_char('─').set_fg(colors.meter.high); } } } } x_start += w as u16; } } fn render_braille( data: &[f32; 32], area: Rect, buf: &mut Buffer, gain: f32, filled: bool, peaks: Option<&[f32; 32]>, ) { let colors = theme::get(); let width = area.width as usize; let height = area.height as usize; let fine_w = width * 2; let fine_h = height * 4; PATTERNS.with(|p| { let mut patterns = p.borrow_mut(); patterns.clear(); patterns.resize(width * height, 0); // Interpolate 32 bands across fine_w columns for fx in 0..fine_w { let band_f = fx as f32 * 31.0 / (fine_w - 1).max(1) as f32; let lo = band_f as usize; let hi = (lo + 1).min(31); let t = band_f - lo as f32; let mag = ((data[lo] * (1.0 - t) + data[hi] * t) * gain).min(1.0); let fy = ((1.0 - mag) * (fine_h - 1) as f32).round() as usize; let fy = fy.min(fine_h - 1); if filled { for y in fy..fine_h { let cy = y / 4; let dy = y % 4; let cx = fx / 2; let dx = fx % 2; patterns[cy * width + cx] |= braille_bit(dx, dy); } } else { let cy = fy / 4; let dy = fy % 4; let cx = fx / 2; let dx = fx % 2; patterns[cy * width + cx] |= braille_bit(dx, dy); } // Peak dots if let Some(pk) = peaks { let pv = (pk[lo] * (1.0 - t) + pk[hi] * t).min(1.0); let py = ((1.0 - pv) * (fine_h - 1) as f32).round() as usize; let py = py.min(fine_h - 1); let cy = py / 4; let dy = py % 4; let cx = fx / 2; let dx = fx % 2; patterns[cy * width + cx] |= braille_bit(dx, dy); } } for cy in 0..height { for cx in 0..width { let pattern = patterns[cy * width + cx]; if pattern != 0 { let ratio = 1.0 - (cy as f32 / height as f32); let color = band_color(ratio, &colors); let ch = char::from_u32(0x2800 + pattern as u32).unwrap_or(' '); buf[(area.x + cx as u16, area.y + cy as u16)] .set_char(ch) .set_fg(color); } } } }); } fn braille_bit(dot_x: usize, dot_y: usize) -> u8 { match (dot_x, dot_y) { (0, 0) => 0x01, (0, 1) => 0x02, (0, 2) => 0x04, (0, 3) => 0x40, (1, 0) => 0x08, (1, 1) => 0x10, (1, 2) => 0x20, (1, 3) => 0x80, _ => unreachable!(), } }