diff --git a/Cargo.toml b/Cargo.toml index bb4fd05..67b3d2d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,3 +28,4 @@ arboard = "3" minimad = "0.13" crossbeam-channel = "0.5" confy = "2" +rustfft = "6" diff --git a/src/app.rs b/src/app.rs index ff96d11..1023b97 100644 --- a/src/app.rs +++ b/src/app.rs @@ -85,6 +85,8 @@ impl App { display: crate::settings::DisplaySettings { fps: self.audio.config.refresh_rate.to_fps(), runtime_highlight: self.ui.runtime_highlight, + show_scope: self.audio.config.show_scope, + show_spectrum: self.audio.config.show_spectrum, }, link: crate::settings::LinkSettings { enabled: link.is_enabled(), diff --git a/src/engine/audio.rs b/src/engine/audio.rs index c800136..510f1a6 100644 --- a/src/engine/audio.rs +++ b/src/engine/audio.rs @@ -2,6 +2,7 @@ use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use cpal::Stream; use crossbeam_channel::Receiver; use doux::{Engine, EngineMetrics}; +use rustfft::{num_complex::Complex, FftPlanner}; use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::Arc; @@ -50,6 +51,106 @@ impl ScopeBuffer { } } +pub struct SpectrumBuffer { + pub bands: [AtomicU32; 32], +} + +impl SpectrumBuffer { + pub fn new() -> Self { + Self { + bands: std::array::from_fn(|_| AtomicU32::new(0)), + } + } + + pub fn write(&self, data: &[f32; 32]) { + for (atom, &val) in self.bands.iter().zip(data.iter()) { + atom.store(val.to_bits(), Ordering::Relaxed); + } + } + + pub fn read(&self) -> [f32; 32] { + std::array::from_fn(|i| f32::from_bits(self.bands[i].load(Ordering::Relaxed))) + } +} + +const FFT_SIZE: usize = 512; +const NUM_BANDS: usize = 32; + +struct SpectrumAnalyzer { + ring: Vec, + pos: usize, + fft: Arc>, + window: [f32; FFT_SIZE], + scratch: Vec>, + band_edges: [usize; NUM_BANDS + 1], +} + +impl SpectrumAnalyzer { + fn new(sample_rate: f32) -> Self { + let mut planner = FftPlanner::new(); + let fft = planner.plan_fft_forward(FFT_SIZE); + let scratch_len = fft.get_inplace_scratch_len(); + + let window: [f32; FFT_SIZE] = std::array::from_fn(|i| { + 0.5 * (1.0 - (2.0 * std::f32::consts::PI * i as f32 / (FFT_SIZE - 1) as f32).cos()) + }); + + let nyquist = sample_rate / 2.0; + let min_freq: f32 = 20.0; + let log_min = min_freq.ln(); + let log_max = nyquist.ln(); + let band_edges: [usize; NUM_BANDS + 1] = std::array::from_fn(|i| { + let freq = (log_min + (log_max - log_min) * i as f32 / NUM_BANDS as f32).exp(); + let bin = (freq * FFT_SIZE as f32 / sample_rate).round() as usize; + bin.min(FFT_SIZE / 2) + }); + + Self { + ring: vec![0.0; FFT_SIZE], + pos: 0, + fft, + window, + scratch: vec![Complex::default(); scratch_len], + band_edges, + } + } + + fn feed(&mut self, samples: &[f32], output: &SpectrumBuffer) { + for &s in samples { + self.ring[self.pos] = s; + self.pos += 1; + if self.pos >= FFT_SIZE { + self.pos = 0; + self.run_fft(output); + } + } + } + + fn run_fft(&mut self, output: &SpectrumBuffer) { + let mut buf: Vec> = (0..FFT_SIZE) + .map(|i| { + let idx = (self.pos + i) % FFT_SIZE; + Complex::new(self.ring[idx] * self.window[i], 0.0) + }) + .collect(); + + self.fft.process_with_scratch(&mut buf, &mut self.scratch); + + let mut bands = [0.0f32; NUM_BANDS]; + for (band, mag) in bands.iter_mut().enumerate() { + let lo = self.band_edges[band]; + let hi = self.band_edges[band + 1].max(lo + 1); + let sum: f32 = buf[lo..hi].iter().map(|c| c.norm()).sum(); + let avg = sum / (hi - lo) as f32; + let amplitude = avg / (FFT_SIZE as f32 / 2.0); + let db = 20.0 * amplitude.max(1e-10).log10(); + *mag = ((db + 60.0) / 60.0).clamp(0.0, 1.0); + } + + output.write(&bands); + } +} + pub struct AudioStreamConfig { pub output_device: Option, pub channels: u16, @@ -60,6 +161,7 @@ pub fn build_stream( config: &AudioStreamConfig, audio_rx: Receiver, scope_buffer: Arc, + spectrum_buffer: Arc, metrics: Arc, initial_samples: Vec, ) -> Result<(Stream, f32), String> { @@ -95,6 +197,8 @@ pub fn build_stream( let mut engine = Engine::new_with_metrics(sample_rate, channels, Arc::clone(&metrics)); engine.sample_index = initial_samples; + let mut analyzer = SpectrumAnalyzer::new(sample_rate); + let stream = device .build_output_stream( &stream_config, @@ -128,6 +232,12 @@ pub fn build_stream( engine.metrics.load.set_buffer_time(buffer_time_ns); engine.process_block(data, &[], &[]); scope_buffer.write(&engine.output); + + // Feed mono mix to spectrum analyzer + let mono: Vec = engine.output.chunks(channels) + .map(|ch| ch.iter().sum::() / channels as f32) + .collect(); + analyzer.feed(&mono, &spectrum_buffer); }, |err| eprintln!("stream error: {err}"), None, diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 7891938..33ba94e 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -2,7 +2,7 @@ mod audio; mod link; mod sequencer; -pub use audio::{build_stream, AudioStreamConfig, ScopeBuffer}; +pub use audio::{build_stream, AudioStreamConfig, ScopeBuffer, SpectrumBuffer}; pub use link::LinkState; pub use sequencer::{ spawn_sequencer, AudioCommand, PatternChange, PatternSnapshot, SeqCommand, SequencerSnapshot, diff --git a/src/input.rs b/src/input.rs index e8d6856..9355db9 100644 --- a/src/input.rs +++ b/src/input.rs @@ -601,6 +601,12 @@ fn handle_audio_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { AudioFocus::RuntimeHighlight => { ctx.app.ui.runtime_highlight = !ctx.app.ui.runtime_highlight } + AudioFocus::ShowScope => { + ctx.app.audio.config.show_scope = !ctx.app.audio.config.show_scope; + } + AudioFocus::ShowSpectrum => { + ctx.app.audio.config.show_spectrum = !ctx.app.audio.config.show_spectrum; + } AudioFocus::SamplePaths => ctx.app.audio.remove_last_sample_path(), AudioFocus::LinkEnabled => ctx.link.set_enabled(!ctx.link.is_enabled()), AudioFocus::StartStopSync => ctx @@ -622,6 +628,12 @@ fn handle_audio_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { AudioFocus::RuntimeHighlight => { ctx.app.ui.runtime_highlight = !ctx.app.ui.runtime_highlight } + AudioFocus::ShowScope => { + ctx.app.audio.config.show_scope = !ctx.app.audio.config.show_scope; + } + AudioFocus::ShowSpectrum => { + ctx.app.audio.config.show_spectrum = !ctx.app.audio.config.show_spectrum; + } AudioFocus::SamplePaths => {} AudioFocus::LinkEnabled => ctx.link.set_enabled(!ctx.link.is_enabled()), AudioFocus::StartStopSync => ctx diff --git a/src/main.rs b/src/main.rs index c413f6a..7fad8b4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -28,7 +28,7 @@ use ratatui::prelude::CrosstermBackend; use ratatui::Terminal; use app::App; -use engine::{build_stream, spawn_sequencer, AudioStreamConfig, LinkState, ScopeBuffer}; +use engine::{build_stream, spawn_sequencer, AudioStreamConfig, LinkState, ScopeBuffer, SpectrumBuffer}; use input::{handle_key, InputContext, InputResult}; use settings::Settings; use state::audio::RefreshRate; @@ -84,9 +84,12 @@ fn main() -> io::Result<()> { app.audio.config.sample_paths = args.samples; app.audio.config.refresh_rate = RefreshRate::from_fps(settings.display.fps); app.ui.runtime_highlight = settings.display.runtime_highlight; + app.audio.config.show_scope = settings.display.show_scope; + app.audio.config.show_spectrum = settings.display.show_spectrum; let metrics = Arc::new(EngineMetrics::default()); let scope_buffer = Arc::new(ScopeBuffer::new()); + let spectrum_buffer = Arc::new(SpectrumBuffer::new()); let mut initial_samples = Vec::new(); for path in &app.audio.config.sample_paths { @@ -114,6 +117,7 @@ fn main() -> io::Result<()> { &stream_config, sequencer.audio_rx.clone(), Arc::clone(&scope_buffer), + Arc::clone(&spectrum_buffer), Arc::clone(&metrics), initial_samples, ) { @@ -157,6 +161,7 @@ fn main() -> io::Result<()> { &new_config, sequencer.audio_rx.clone(), Arc::clone(&scope_buffer), + Arc::clone(&spectrum_buffer), Arc::clone(&metrics), restart_samples, ) { @@ -182,6 +187,7 @@ fn main() -> io::Result<()> { app.metrics.schedule_depth = metrics.schedule_depth.load(Ordering::Relaxed) as usize; app.metrics.scope = scope_buffer.read(); (app.metrics.peak_left, app.metrics.peak_right) = scope_buffer.peaks(); + app.metrics.spectrum = spectrum_buffer.read(); } let seq_snapshot = sequencer.snapshot(); diff --git a/src/settings.rs b/src/settings.rs index 5ad77b4..c5dfe52 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -21,6 +21,8 @@ pub struct AudioSettings { pub struct DisplaySettings { pub fps: u32, pub runtime_highlight: bool, + pub show_scope: bool, + pub show_spectrum: bool, } #[derive(Debug, Serialize, Deserialize)] @@ -46,6 +48,8 @@ impl Default for DisplaySettings { Self { fps: 60, runtime_highlight: false, + show_scope: true, + show_spectrum: true, } } } diff --git a/src/state/audio.rs b/src/state/audio.rs index 9b0dee1..5dee5a0 100644 --- a/src/state/audio.rs +++ b/src/state/audio.rs @@ -56,6 +56,8 @@ pub struct AudioConfig { pub sample_paths: Vec, pub sample_count: usize, pub refresh_rate: RefreshRate, + pub show_scope: bool, + pub show_spectrum: bool, } impl Default for AudioConfig { @@ -69,6 +71,8 @@ impl Default for AudioConfig { sample_paths: Vec::new(), sample_count: 0, refresh_rate: RefreshRate::default(), + show_scope: true, + show_spectrum: true, } } } @@ -82,6 +86,8 @@ pub enum AudioFocus { BufferSize, RefreshRate, RuntimeHighlight, + ShowScope, + ShowSpectrum, SamplePaths, LinkEnabled, StartStopSync, @@ -97,6 +103,7 @@ pub struct Metrics { pub scope: [f32; 64], pub peak_left: f32, pub peak_right: f32, + pub spectrum: [f32; 32], } impl Default for Metrics { @@ -110,6 +117,7 @@ impl Default for Metrics { scope: [0.0; 64], peak_left: 0.0, peak_right: 0.0, + spectrum: [0.0; 32], } } } @@ -149,7 +157,9 @@ impl AudioSettings { AudioFocus::Channels => AudioFocus::BufferSize, AudioFocus::BufferSize => AudioFocus::RefreshRate, AudioFocus::RefreshRate => AudioFocus::RuntimeHighlight, - AudioFocus::RuntimeHighlight => AudioFocus::SamplePaths, + AudioFocus::RuntimeHighlight => AudioFocus::ShowScope, + AudioFocus::ShowScope => AudioFocus::ShowSpectrum, + AudioFocus::ShowSpectrum => AudioFocus::SamplePaths, AudioFocus::SamplePaths => AudioFocus::LinkEnabled, AudioFocus::LinkEnabled => AudioFocus::StartStopSync, AudioFocus::StartStopSync => AudioFocus::Quantum, @@ -165,7 +175,9 @@ impl AudioSettings { AudioFocus::BufferSize => AudioFocus::Channels, AudioFocus::RefreshRate => AudioFocus::BufferSize, AudioFocus::RuntimeHighlight => AudioFocus::RefreshRate, - AudioFocus::SamplePaths => AudioFocus::RuntimeHighlight, + AudioFocus::ShowScope => AudioFocus::RuntimeHighlight, + AudioFocus::ShowSpectrum => AudioFocus::ShowScope, + AudioFocus::SamplePaths => AudioFocus::ShowSpectrum, AudioFocus::LinkEnabled => AudioFocus::SamplePaths, AudioFocus::StartStopSync => AudioFocus::LinkEnabled, AudioFocus::Quantum => AudioFocus::StartStopSync, diff --git a/src/views/audio_view.rs b/src/views/audio_view.rs index 884f498..579a279 100644 --- a/src/views/audio_view.rs +++ b/src/views/audio_view.rs @@ -47,7 +47,7 @@ fn render_audio_section(frame: &mut Frame, app: &App, area: Rect) { let [devices_area, _, settings_area, _, samples_area] = Layout::vertical([ Constraint::Length(4), Constraint::Length(1), - Constraint::Length(6), + Constraint::Length(8), Constraint::Length(1), Constraint::Min(3), ]) @@ -112,8 +112,12 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) { let buffer_focused = app.audio.focus == AudioFocus::BufferSize; let fps_focused = app.audio.focus == AudioFocus::RefreshRate; let highlight_focused = app.audio.focus == AudioFocus::RuntimeHighlight; + let scope_focused = app.audio.focus == AudioFocus::ShowScope; + let spectrum_focused = app.audio.focus == AudioFocus::ShowSpectrum; let highlight_text = if app.ui.runtime_highlight { "On" } else { "Off" }; + let scope_text = if app.audio.config.show_scope { "On" } else { "Off" }; + let spectrum_text = if app.audio.config.show_spectrum { "On" } else { "Off" }; let rows = vec![ Row::new(vec![ @@ -147,6 +151,14 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) { Span::styled("Highlight", label_style), render_selector(highlight_text, highlight_focused, highlight, normal), ]), + Row::new(vec![ + Span::styled("Scope", label_style), + render_selector(scope_text, scope_focused, highlight, normal), + ]), + Row::new(vec![ + Span::styled("Spectrum", label_style), + render_selector(spectrum_text, spectrum_focused, highlight, normal), + ]), Row::new(vec![ Span::styled("Rate", label_style), Span::styled( diff --git a/src/views/main_view.rs b/src/views/main_view.rs index ef96d6b..44b3600 100644 --- a/src/views/main_view.rs +++ b/src/views/main_view.rs @@ -5,7 +5,7 @@ use ratatui::Frame; use crate::app::App; use crate::engine::SequencerSnapshot; -use crate::widgets::{Orientation, Scope, VuMeter}; +use crate::widgets::{Orientation, Scope, Spectrum, VuMeter}; pub fn render(frame: &mut Frame, app: &mut App, snapshot: &SequencerSnapshot, area: Rect) { let [left_area, _spacer, vu_area] = Layout::horizontal([ @@ -15,13 +15,31 @@ pub fn render(frame: &mut Frame, app: &mut App, snapshot: &SequencerSnapshot, ar ]) .areas(area); - let [scope_area, sequencer_area] = Layout::vertical([ - Constraint::Length(14), + let show_scope = app.audio.config.show_scope; + let show_spectrum = app.audio.config.show_spectrum; + let viz_height = if show_scope || show_spectrum { 14 } else { 0 }; + + let [viz_area, sequencer_area] = Layout::vertical([ + Constraint::Length(viz_height), Constraint::Fill(1), ]) .areas(left_area); - render_scope(frame, app, scope_area); + if show_scope && show_spectrum { + let [scope_area, _, spectrum_area] = Layout::horizontal([ + Constraint::Percentage(50), + Constraint::Length(2), + Constraint::Percentage(50), + ]) + .areas(viz_area); + render_scope(frame, app, scope_area); + render_spectrum(frame, app, spectrum_area); + } else if show_scope { + render_scope(frame, app, viz_area); + } else if show_spectrum { + render_spectrum(frame, app, viz_area); + } + render_sequencer(frame, app, snapshot, sequencer_area); render_vu_meter(frame, app, vu_area); } @@ -166,6 +184,12 @@ fn render_scope(frame: &mut Frame, app: &App, area: Rect) { frame.render_widget(scope, area); } +fn render_spectrum(frame: &mut Frame, app: &App, area: Rect) { + let area = Rect { height: area.height.saturating_sub(1), ..area }; + let spectrum = Spectrum::new(&app.metrics.spectrum); + frame.render_widget(spectrum, area); +} + fn render_vu_meter(frame: &mut Frame, app: &App, area: Rect) { let vu = VuMeter::new(app.metrics.peak_left, app.metrics.peak_right); frame.render_widget(vu, area); diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs index 977a199..5f7fc07 100644 --- a/src/widgets/mod.rs +++ b/src/widgets/mod.rs @@ -1,11 +1,13 @@ mod confirm; mod modal; mod scope; +mod spectrum; mod text_input; mod vu_meter; pub use confirm::ConfirmModal; pub use modal::ModalFrame; pub use scope::{Orientation, Scope}; +pub use spectrum::Spectrum; pub use text_input::TextInputModal; pub use vu_meter::VuMeter; diff --git a/src/widgets/spectrum.rs b/src/widgets/spectrum.rs new file mode 100644 index 0000000..39bf81f --- /dev/null +++ b/src/widgets/spectrum.rs @@ -0,0 +1,62 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::widgets::Widget; + +const BLOCKS: [char; 8] = ['\u{2581}', '\u{2582}', '\u{2583}', '\u{2584}', '\u{2585}', '\u{2586}', '\u{2587}', '\u{2588}']; + +pub struct Spectrum<'a> { + data: &'a [f32; 32], +} + +impl<'a> Spectrum<'a> { + pub fn new(data: &'a [f32; 32]) -> Self { + Self { data } + } +} + +impl Widget for Spectrum<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + if area.width == 0 || area.height == 0 { + return; + } + + let height = area.height as f32; + let band_width = area.width as usize / 32; + if band_width == 0 { + return; + } + + for (band, &mag) in self.data.iter().enumerate() { + let bar_height = mag * height; + let full_cells = bar_height as usize; + let frac = bar_height - full_cells as f32; + let frac_idx = (frac * 8.0) as usize; + + let x_start = area.x + (band * band_width) as u16; + + 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 = if ratio < 0.33 { + Color::Rgb(40, 180, 80) + } else if ratio < 0.66 { + Color::Rgb(220, 180, 40) + } else { + Color::Rgb(220, 60, 40) + }; + for dx in 0..band_width as u16 { + let x = x_start + dx; + if x >= area.x + area.width { + break; + } + 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); + } + } + } + } + } +}