From c24dee3a0e54f2d6759a0a024d2a11d50ed40db1 Mon Sep 17 00:00:00 2001 From: Luke Stebner Date: Sat, 11 Jul 2026 21:35:52 -0700 Subject: [PATCH 1/3] feat: add radial FFT visualizer Adds a full-screen visualizer panel (Waves icon in toolbar, Escape to close) with 64-band FFT-driven radial bars around the album art. - Rust: new `AnalysisSource` wraps the audio pipeline with RMS EMA and a 2048-sample Hann-windowed FFT (rustfft), writing log-spaced band magnitudes with attack/decay smoothing to a shared ArcSwap. Engine tick lowered to 50ms; a new `visualizer-data` event fires at 20Hz only while the panel is open (enable/disable commands gate it). - Frontend: canvas rAF loop interpolates toward incoming FFT bands for smooth motion between frames; radial wedge bars drawn with a glow gradient from the accent colour. Album art fades to 28% opacity and morphs to a circle while playing, returning on pause. Co-Authored-By: Claude Sonnet 4.6 --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/audio/analysis.rs | 320 ++++++++++++++++++ src-tauri/src/audio/engine.rs | 55 ++- src-tauri/src/audio/mod.rs | 36 +- src-tauri/src/audio/rms.rs | 148 ++++++++ src-tauri/src/commands/playback.rs | 10 + src-tauri/src/lib.rs | 2 + src/lib/api/commands.ts | 2 + src/lib/api/events.ts | 3 + .../visualizer/VisualizerPanel.svelte | 270 +++++++++++++++ src/lib/stores/player.svelte.ts | 1 + src/lib/stores/ui.svelte.ts | 10 + src/lib/types.ts | 1 + src/routes/+layout.svelte | 20 +- 15 files changed, 868 insertions(+), 12 deletions(-) create mode 100644 src-tauri/src/audio/analysis.rs create mode 100644 src-tauri/src/audio/rms.rs create mode 100644 src/lib/components/visualizer/VisualizerPanel.svelte diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index fb2717b..a11161a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4910,6 +4910,7 @@ dependencies = [ "rubato", "rusqlite", "rusqlite_migration", + "rustfft", "serde", "serde_json", "souvlaki", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f53f8a9..6b17c90 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -42,4 +42,5 @@ rubato = "0.15" souvlaki = { version = "0.7", default-features = false, features = ["use_zbus"] } tauri-plugin-dialog = "2.7.1" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +rustfft = "6" diff --git a/src-tauri/src/audio/analysis.rs b/src-tauri/src/audio/analysis.rs new file mode 100644 index 0000000..8641c7d --- /dev/null +++ b/src-tauri/src/audio/analysis.rs @@ -0,0 +1,320 @@ +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use arc_swap::ArcSwap; +use rodio::source::SeekError; +use rodio::Source; +use rustfft::num_complex::Complex; +use rustfft::{Fft, FftPlanner}; + +pub const FFT_SIZE: usize = 2048; +pub const NUM_BANDS: usize = 64; + +// dB floor for normalization: bands below this are treated as silent (mapped to 0). +const DB_FLOOR: f32 = -80.0; + +// Exponential smoothing: bands rise quickly on transients and fall slowly. +const ATTACK_ALPHA: f32 = 0.8; +const DECAY_ALPHA: f32 = 0.15; + +/// Precomputes which FFT output bins contribute to each log-spaced frequency band. +/// Returns `NUM_BANDS` entries of `(start_bin, end_bin)` (inclusive). +fn compute_band_bins(sample_rate: u32) -> Vec<(usize, usize)> { + let min_freq = 20.0f32; + let max_freq = (sample_rate as f32 / 2.0).min(20_000.0); + let log_min = min_freq.log2(); + let log_max = max_freq.log2(); + (0..NUM_BANDS) + .map(|i| { + let low = + 2f32.powf(log_min + i as f32 * (log_max - log_min) / NUM_BANDS as f32); + let high = 2f32 + .powf(log_min + (i + 1) as f32 * (log_max - log_min) / NUM_BANDS as f32); + let start = ((low * FFT_SIZE as f32 / sample_rate as f32).floor() as usize).max(1); + let end = ((high * FFT_SIZE as f32 / sample_rate as f32).ceil() as usize) + .min(FFT_SIZE / 2) + .max(start); + (start, end) + }) + .collect() +} + +fn hann_window() -> Vec { + (0..FFT_SIZE) + .map(|i| { + 0.5 * (1.0 + - (2.0 * std::f32::consts::PI * i as f32 / (FFT_SIZE - 1) as f32).cos()) + }) + .collect() +} + +/// Wraps any `Source` and continuously computes two things: +/// +/// - **RMS amplitude**: exponential moving average written to `shared_rms`. +/// - **Frequency spectrum**: 64-band FFT run on every `FFT_SIZE`-sample mono +/// frame, with log-spaced bands and temporal smoothing, written to `shared_fft`. +/// +/// Both shared values are updated inside `Iterator::next` so they reflect the +/// audio the device is actually playing, not a lookahead. +pub struct AnalysisSource> { + inner: S, + // RMS + ema_squared: f32, + alpha_rms: f32, + shared_rms: Arc, + // FFT — mono mix-down buffer + channel_accumulator: f32, + channel_counter: u16, + channels: u16, + mono_buffer: Vec, + // FFT plan and working buffers (reused each frame) + fft: Arc>, + fft_buffer: Vec>, + scratch: Vec>, + window: Vec, + band_bins: Vec<(usize, usize)>, + smoothed_bands: Vec, + shared_fft: Arc>>, +} + +impl> AnalysisSource { + pub fn new( + inner: S, + shared_rms: Arc, + shared_fft: Arc>>, + ) -> Self { + let sample_rate = inner.sample_rate(); + let channels = inner.channels().max(1); + + let alpha_rms = 1.0 / (sample_rate as f32 * 0.1); + + let mut planner = FftPlanner::::new(); + let fft = planner.plan_fft_forward(FFT_SIZE); + let scratch_len = fft.get_inplace_scratch_len(); + + Self { + inner, + ema_squared: 0.0, + alpha_rms, + shared_rms, + channel_accumulator: 0.0, + channel_counter: 0, + channels, + mono_buffer: Vec::with_capacity(FFT_SIZE), + fft, + fft_buffer: vec![Complex::new(0.0, 0.0); FFT_SIZE], + scratch: vec![Complex::new(0.0, 0.0); scratch_len], + window: hann_window(), + band_bins: compute_band_bins(sample_rate), + smoothed_bands: vec![0.0; NUM_BANDS], + shared_fft, + } + } + + fn run_fft(&mut self) { + // Apply Hann window and fill the FFT input buffer. + for (i, &sample) in self.mono_buffer.iter().enumerate() { + self.fft_buffer[i] = Complex::new(sample * self.window[i], 0.0); + } + self.fft.process_with_scratch(&mut self.fft_buffer, &mut self.scratch); + + // Compute magnitude for each log-spaced band. + let scale = 2.0 / FFT_SIZE as f32; + let mut new_bands = vec![0.0f32; NUM_BANDS]; + for (band, &(start, end)) in self.band_bins.iter().enumerate() { + let mean_mag = self.fft_buffer[start..=end] + .iter() + .map(|c| c.norm() * scale) + .sum::() + / (end - start + 1) as f32; + + // Convert to dB and normalize to [0, 1]. + let db = 20.0 * mean_mag.max(1e-10).log10(); + new_bands[band] = ((db - DB_FLOOR) / DB_FLOOR.abs()).clamp(0.0, 1.0); + } + + // Temporal smoothing: fast attack, slow decay. + for i in 0..NUM_BANDS { + let alpha = if new_bands[i] > self.smoothed_bands[i] { + ATTACK_ALPHA + } else { + DECAY_ALPHA + }; + self.smoothed_bands[i] = + self.smoothed_bands[i] * (1.0 - alpha) + new_bands[i] * alpha; + } + + self.shared_fft.store(Arc::new(self.smoothed_bands.clone())); + self.mono_buffer.clear(); + } + + fn reset(&mut self) { + self.ema_squared = 0.0; + self.channel_accumulator = 0.0; + self.channel_counter = 0; + self.mono_buffer.clear(); + self.smoothed_bands.fill(0.0); + self.shared_rms.store(0.0f32.to_bits(), Ordering::Relaxed); + self.shared_fft.store(Arc::new(vec![0.0; NUM_BANDS])); + } +} + +impl> Iterator for AnalysisSource { + type Item = i16; + + fn next(&mut self) -> Option { + let sample = self.inner.next()?; + + // Update RMS. + let normalized = sample as f32 / 32768.0; + self.ema_squared = + self.ema_squared * (1.0 - self.alpha_rms) + normalized * normalized * self.alpha_rms; + self.shared_rms.store(self.ema_squared.sqrt().to_bits(), Ordering::Relaxed); + + // Mix down to mono for FFT. + self.channel_accumulator += normalized; + self.channel_counter += 1; + if self.channel_counter == self.channels { + let mono = self.channel_accumulator / self.channels as f32; + self.mono_buffer.push(mono); + self.channel_counter = 0; + self.channel_accumulator = 0.0; + + if self.mono_buffer.len() == FFT_SIZE { + self.run_fft(); + } + } + + Some(sample) + } +} + +impl> Source for AnalysisSource { + fn current_frame_len(&self) -> Option { + self.inner.current_frame_len() + } + + fn channels(&self) -> u16 { + self.inner.channels() + } + + fn sample_rate(&self) -> u32 { + self.inner.sample_rate() + } + + fn total_duration(&self) -> Option { + self.inner.total_duration() + } + + fn try_seek(&mut self, pos: Duration) -> Result<(), SeekError> { + let result = self.inner.try_seek(pos); + if result.is_ok() { + self.reset(); + } + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct TestSource { + samples: std::vec::IntoIter, + sample_rate: u32, + channels: u16, + } + + impl Iterator for TestSource { + type Item = i16; + fn next(&mut self) -> Option { + self.samples.next() + } + } + + impl Source for TestSource { + fn current_frame_len(&self) -> Option { + None + } + fn channels(&self) -> u16 { + self.channels + } + fn sample_rate(&self) -> u32 { + self.sample_rate + } + fn total_duration(&self) -> Option { + None + } + } + + fn make_source(samples: Vec) -> TestSource { + TestSource { samples: samples.into_iter(), sample_rate: 44100, channels: 1 } + } + + #[test] + fn silence_gives_zero_rms() { + let shared_rms = Arc::new(AtomicU32::new(0)); + let shared_fft = Arc::new(ArcSwap::from_pointee(vec![0.0f32; NUM_BANDS])); + let source = make_source(vec![0i16; FFT_SIZE * 2]); + let mut analysis = AnalysisSource::new(source, shared_rms.clone(), shared_fft); + for _ in 0..FFT_SIZE * 2 { + analysis.next(); + } + let rms = f32::from_bits(shared_rms.load(Ordering::Relaxed)); + assert!(rms < 1e-6, "expected near-zero RMS for silence, got {rms}"); + } + + #[test] + fn loud_signal_gives_nonzero_rms() { + let shared_rms = Arc::new(AtomicU32::new(0)); + let shared_fft = Arc::new(ArcSwap::from_pointee(vec![0.0f32; NUM_BANDS])); + let samples = vec![16000i16; FFT_SIZE * 4]; + let source = make_source(samples); + let mut analysis = AnalysisSource::new(source, shared_rms.clone(), shared_fft); + for _ in 0..FFT_SIZE * 4 { + analysis.next(); + } + let rms = f32::from_bits(shared_rms.load(Ordering::Relaxed)); + assert!(rms > 0.1, "expected nonzero RMS for loud signal, got {rms}"); + } + + #[test] + fn fft_runs_after_enough_samples_and_produces_nonzero_bands() { + let shared_rms = Arc::new(AtomicU32::new(0)); + let shared_fft = Arc::new(ArcSwap::from_pointee(vec![0.0f32; NUM_BANDS])); + + // Generate a 1 kHz sine wave. + let sample_rate = 44100u32; + let frequency = 1000.0f32; + let omega = 2.0 * std::f32::consts::PI * frequency / sample_rate as f32; + let samples: Vec = + (0..FFT_SIZE * 2).map(|i| ((omega * i as f32).sin() * 16000.0) as i16).collect(); + + let source = + TestSource { samples: samples.into_iter(), sample_rate, channels: 1 }; + let mut analysis = + AnalysisSource::new(source, shared_rms, shared_fft.clone()); + for _ in 0..FFT_SIZE * 2 { + analysis.next(); + } + + let bands = shared_fft.load(); + let max_band = bands.iter().cloned().fold(0.0f32, f32::max); + assert!(max_band > 0.1, "expected nonzero FFT output for 1 kHz tone, max band was {max_band}"); + } + + #[test] + fn band_bins_cover_audible_range() { + let bins = compute_band_bins(44100); + assert_eq!(bins.len(), NUM_BANDS); + // First band must start at a bin > 0 (DC component excluded). + assert!(bins[0].0 >= 1); + // Last band must not exceed Nyquist. + assert!(bins[NUM_BANDS - 1].1 <= FFT_SIZE / 2); + // Bins should be monotonically non-decreasing. + for i in 1..NUM_BANDS { + assert!(bins[i].0 >= bins[i - 1].0); + } + } +} diff --git a/src-tauri/src/audio/engine.rs b/src-tauri/src/audio/engine.rs index 97e67f8..85088e9 100644 --- a/src-tauri/src/audio/engine.rs +++ b/src-tauri/src/audio/engine.rs @@ -1,3 +1,4 @@ +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::mpsc::{Receiver, RecvTimeoutError}; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -7,16 +8,25 @@ use rodio::{OutputStream, OutputStreamHandle, Sink}; use souvlaki::{MediaMetadata, MediaPlayback, MediaPosition}; use tauri::{AppHandle, Emitter}; +use super::analysis::NUM_BANDS; use super::decode::FileSource; use super::eq::{EqGains, EQ_BAND_COUNT}; -use super::{EqualizerSource, PlaybackSnapshot, PlaybackState, PlayerCommand, Queue, TrackInfo}; +use super::{AnalysisSource, EqualizerSource, PlaybackSnapshot, PlaybackState, PlayerCommand, Queue, TrackInfo}; use crate::db::queries::{settings, stats, tracks}; use crate::mpris::Mpris; use crate::state::DbPool; const PROGRESS_EVENT: &str = "playback-progress"; -const TICK: Duration = Duration::from_millis(250); -const SESSION_CHECKPOINT_TICKS: u64 = 40; +const VISUALIZER_EVENT: &str = "visualizer-data"; + +// Tick at 20 Hz so visualizer data is smooth. +const TICK: Duration = Duration::from_millis(50); + +// Only emit the full playback-progress snapshot every 5 ticks (= 250ms, same as before). +const PROGRESS_EMIT_TICKS: u64 = 5; + +// Checkpoint session state every 200 ticks (= 10 seconds, same as before). +const SESSION_CHECKPOINT_TICKS: u64 = 200; const SETTING_VOLUME: &str = "volume"; const SETTING_LAST_QUEUE: &str = "last_queue"; @@ -32,6 +42,9 @@ struct EngineState { last_sink_len: usize, volume: f32, eq: Arc>, + rms: Arc, + fft_bands: Arc>>, + visualizer_enabled: Arc, db: DbPool, tick_count: u64, } @@ -43,6 +56,9 @@ pub(super) fn run_engine( mpris: Arc, db: DbPool, eq: Arc>, + rms: Arc, + fft_bands: Arc>>, + visualizer_enabled: Arc, ) { let (_stream, handle) = match OutputStream::try_default() { Ok(v) => v, @@ -59,6 +75,9 @@ pub(super) fn run_engine( last_sink_len: 0, volume: 1.0, eq, + rms, + fft_bands, + visualizer_enabled, db, tick_count: 0, }; @@ -82,7 +101,15 @@ pub(super) fn run_engine( let snap = build_snapshot(&state); snapshot.store(Arc::new(snap.clone())); - let _ = app.emit(PROGRESS_EVENT, snap); + + if state.tick_count % PROGRESS_EMIT_TICKS == 0 { + let _ = app.emit(PROGRESS_EVENT, snap); + } + + if state.visualizer_enabled.load(Ordering::Relaxed) { + let bands = (**state.fft_bands.load()).clone(); + let _ = app.emit(VISUALIZER_EVENT, bands); + } } } @@ -118,7 +145,7 @@ fn poll_queue_advance(state: &mut EngineState, mpris: &Mpris) { if let Some(queue) = &state.queue { announce_current(queue, mpris, true); if let Some(next) = queue.peek_next() { - append_track(sink, next, state.eq.clone()); + append_track(sink, next, state.eq.clone(), state.rms.clone(), state.fft_bands.clone()); } } state.last_sink_len = sink.len(); @@ -175,6 +202,8 @@ fn handle_command(state: &mut EngineState, cmd: PlayerCommand, mpris: &Mpris) { state.sink = None; state.queue = None; state.last_sink_len = 0; + state.rms.store(0.0f32.to_bits(), Ordering::Relaxed); + state.fft_bands.store(Arc::new(vec![0.0f32; NUM_BANDS])); mpris.set_playback(MediaPlayback::Stopped); clear_session(state); } @@ -241,11 +270,11 @@ fn start_playback_from_queue( { let Some(queue) = &state.queue else { return }; let Some(current) = queue.current() else { return }; - if !append_track(&sink, current, state.eq.clone()) { + if !append_track(&sink, current, state.eq.clone(), state.rms.clone(), state.fft_bands.clone()) { return; } if let Some(next) = queue.peek_next() { - append_track(&sink, next, state.eq.clone()); + append_track(&sink, next, state.eq.clone(), state.rms.clone(), state.fft_bands.clone()); } if autoplay { sink.play(); @@ -262,10 +291,16 @@ fn start_playback_from_queue( state.sink = Some(sink); } -fn append_track(sink: &Sink, track: &TrackInfo, eq: Arc>) -> bool { +fn append_track( + sink: &Sink, + track: &TrackInfo, + eq: Arc>, + rms: Arc, + fft_bands: Arc>>, +) -> bool { match FileSource::open(&track.path) { Ok(source) => { - sink.append(EqualizerSource::new(source, eq)); + sink.append(AnalysisSource::new(EqualizerSource::new(source, eq), rms, fft_bands)); true } Err(e) => { @@ -417,6 +452,7 @@ fn build_snapshot(state: &EngineState) -> PlaybackSnapshot { Some(_) => PlaybackState::Playing, }; let current = state.queue.as_ref().and_then(|q| q.current()); + let rms_amplitude = f32::from_bits(state.rms.load(Ordering::Relaxed)); PlaybackSnapshot { state: playback_state, track_id: current.map(|t| t.track_id), @@ -428,5 +464,6 @@ fn build_snapshot(state: &EngineState) -> PlaybackSnapshot { album: current.map(|t| t.album.clone()), album_id: current.and_then(|t| t.album_id), art_path: current.and_then(|t| t.art_path.clone()), + rms_amplitude, } } diff --git a/src-tauri/src/audio/mod.rs b/src-tauri/src/audio/mod.rs index 0b79c9c..f9f406e 100644 --- a/src-tauri/src/audio/mod.rs +++ b/src-tauri/src/audio/mod.rs @@ -1,12 +1,16 @@ +mod analysis; mod decode; mod engine; pub mod eq; mod queue; +mod rms; +pub use analysis::{AnalysisSource, NUM_BANDS}; pub use eq::{EqGains, EqualizerSource, EQ_BAND_COUNT}; pub use queue::Queue; use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, AtomicU32}; use std::sync::mpsc::{channel, Receiver, Sender}; use std::sync::Arc; use std::thread; @@ -39,6 +43,7 @@ pub struct PlaybackSnapshot { pub album: Option, pub album_id: Option, pub art_path: Option, + pub rms_amplitude: f32, } impl Default for PlaybackSnapshot { @@ -54,6 +59,7 @@ impl Default for PlaybackSnapshot { album: None, album_id: None, art_path: None, + rms_amplitude: 0.0, } } } @@ -86,6 +92,7 @@ pub struct PlayerHandle { tx: Sender, snapshot: Arc>, eq: Arc>, + pub visualizer_enabled: Arc, } impl PlayerHandle { @@ -100,6 +107,14 @@ impl PlayerHandle { pub fn eq_state(&self) -> Arc { self.eq.load_full() } + + pub fn enable_visualizer(&self) { + self.visualizer_enabled.store(true, std::sync::atomic::Ordering::Relaxed); + } + + pub fn disable_visualizer(&self) { + self.visualizer_enabled.store(false, std::sync::atomic::Ordering::Relaxed); + } } /// Two-step construction resolves the circular dependency between the @@ -112,6 +127,9 @@ pub struct EngineBuilder { rx: Receiver, snapshot: Arc>, eq: Arc>, + rms: Arc, + fft_bands: Arc>>, + visualizer_enabled: Arc, } impl EngineBuilder { @@ -119,7 +137,10 @@ impl EngineBuilder { let (tx, rx) = channel(); let snapshot = Arc::new(ArcSwap::from_pointee(PlaybackSnapshot::default())); let eq = Arc::new(ArcSwap::from_pointee(EqGains::default())); - Self { tx, rx, snapshot, eq } + let rms = Arc::new(AtomicU32::new(0.0f32.to_bits())); + let fft_bands = Arc::new(ArcSwap::from_pointee(vec![0.0f32; NUM_BANDS])); + let visualizer_enabled = Arc::new(AtomicBool::new(false)); + Self { tx, rx, snapshot, eq, rms, fft_bands, visualizer_enabled } } pub fn handle(&self) -> PlayerHandle { @@ -127,12 +148,23 @@ impl EngineBuilder { tx: self.tx.clone(), snapshot: self.snapshot.clone(), eq: self.eq.clone(), + visualizer_enabled: self.visualizer_enabled.clone(), } } pub fn spawn(self, app: AppHandle, mpris: Arc, db: DbPool) { thread::spawn(move || { - engine::run_engine(self.rx, self.snapshot, app, mpris, db, self.eq) + engine::run_engine( + self.rx, + self.snapshot, + app, + mpris, + db, + self.eq, + self.rms, + self.fft_bands, + self.visualizer_enabled, + ) }); } } diff --git a/src-tauri/src/audio/rms.rs b/src-tauri/src/audio/rms.rs new file mode 100644 index 0000000..186d1c8 --- /dev/null +++ b/src-tauri/src/audio/rms.rs @@ -0,0 +1,148 @@ +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use rodio::source::SeekError; +use rodio::Source; + +/// Wraps any `Source`, maintains an exponential moving average of +/// signal amplitude, and writes the current RMS to a shared atomic so the +/// audio engine can include it in playback snapshots without owning the source. +/// +/// Time constant is fixed at 100ms (relative to the source's sample rate), +/// which produces a value that tracks the recent loudness of a passage rather +/// than individual transients. +pub struct RmsSource> { + inner: S, + ema_squared: f32, + alpha: f32, + shared: Arc, +} + +impl> RmsSource { + pub fn new(inner: S, shared: Arc) -> Self { + let sample_rate = inner.sample_rate(); + let alpha = 1.0 / (sample_rate as f32 * 0.1); + Self { inner, ema_squared: 0.0, alpha, shared } + } +} + +impl> Iterator for RmsSource { + type Item = i16; + + fn next(&mut self) -> Option { + let sample = self.inner.next()?; + let normalized = sample as f32 / 32768.0; + self.ema_squared = + self.ema_squared * (1.0 - self.alpha) + normalized * normalized * self.alpha; + let rms = self.ema_squared.sqrt(); + self.shared.store(rms.to_bits(), Ordering::Relaxed); + Some(sample) + } +} + +impl> Source for RmsSource { + fn current_frame_len(&self) -> Option { + self.inner.current_frame_len() + } + + fn channels(&self) -> u16 { + self.inner.channels() + } + + fn sample_rate(&self) -> u32 { + self.inner.sample_rate() + } + + fn total_duration(&self) -> Option { + self.inner.total_duration() + } + + fn try_seek(&mut self, pos: Duration) -> Result<(), SeekError> { + let result = self.inner.try_seek(pos); + if result.is_ok() { + self.ema_squared = 0.0; + self.shared.store(0.0f32.to_bits(), Ordering::Relaxed); + } + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + struct TestSource { + samples: std::vec::IntoIter, + sample_rate: u32, + } + + impl Iterator for TestSource { + type Item = i16; + fn next(&mut self) -> Option { + self.samples.next() + } + } + + impl Source for TestSource { + fn current_frame_len(&self) -> Option { + None + } + fn channels(&self) -> u16 { + 1 + } + fn sample_rate(&self) -> u32 { + self.sample_rate + } + fn total_duration(&self) -> Option { + None + } + } + + #[test] + fn silence_produces_zero_rms() { + let shared = Arc::new(AtomicU32::new(0)); + let source = TestSource { samples: vec![0i16; 100].into_iter(), sample_rate: 44100 }; + let mut rms_source = RmsSource::new(source, shared.clone()); + for _ in 0..100 { + rms_source.next(); + } + let rms = f32::from_bits(shared.load(Ordering::Relaxed)); + assert!(rms < 1e-6, "expected RMS near zero for silence, got {rms}"); + } + + #[test] + fn loud_signal_produces_nonzero_rms() { + let shared = Arc::new(AtomicU32::new(0)); + let samples: Vec = (0..44100).map(|_| 16000i16).collect(); + let source = TestSource { samples: samples.into_iter(), sample_rate: 44100 }; + let mut rms_source = RmsSource::new(source, shared.clone()); + for _ in 0..44100 { + rms_source.next(); + } + let rms = f32::from_bits(shared.load(Ordering::Relaxed)); + assert!(rms > 0.1, "expected nonzero RMS for loud signal, got {rms}"); + } + + #[test] + fn seek_resets_ema_to_zero() { + let shared = Arc::new(AtomicU32::new(0)); + let samples: Vec = (0..44100).map(|_| 16000i16).collect(); + let source = TestSource { samples: samples.into_iter(), sample_rate: 44100 }; + let mut rms_source = RmsSource::new(source, shared.clone()); + for _ in 0..44100 { + rms_source.next(); + } + assert!(f32::from_bits(shared.load(Ordering::Relaxed)) > 0.0); + + // Seeking on a TestSource always fails (not seekable), but the RMS + // wrapper should still reset on a successful seek. We verify the + // reset path by temporarily trusting the contract: when try_seek + // would return Ok, the shared value is zeroed. Since TestSource + // returns Err, the value should be unchanged here. + let _ = rms_source.try_seek(Duration::ZERO); + // Value should be unchanged because the inner seek failed. + assert!(f32::from_bits(shared.load(Ordering::Relaxed)) > 0.0); + } +} diff --git a/src-tauri/src/commands/playback.rs b/src-tauri/src/commands/playback.rs index 0ba9722..99e1fca 100644 --- a/src-tauri/src/commands/playback.rs +++ b/src-tauri/src/commands/playback.rs @@ -131,3 +131,13 @@ pub fn playback_get_eq(state: State) -> EqSnapshot { let eq = state.player.eq_state(); EqSnapshot { gains_db: eq.gains_db.to_vec(), enabled: eq.enabled } } + +#[tauri::command] +pub fn playback_enable_visualizer(state: State) { + state.player.enable_visualizer(); +} + +#[tauri::command] +pub fn playback_disable_visualizer(state: State) { + state.player.disable_visualizer(); +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ef2fcdc..9e32c0c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -98,6 +98,8 @@ pub fn run() { commands::playback::playback_get_snapshot, commands::playback::playback_set_eq, commands::playback::playback_get_eq, + commands::playback::playback_enable_visualizer, + commands::playback::playback_disable_visualizer, commands::device::device_get_status, commands::device::device_find_music_folders, commands::device::device_save_music_subfolder, diff --git a/src/lib/api/commands.ts b/src/lib/api/commands.ts index bcd1e02..10cdb28 100644 --- a/src/lib/api/commands.ts +++ b/src/lib/api/commands.ts @@ -33,6 +33,8 @@ export const commands = { playbackSetEq: (gainsDb: number[], enabled: boolean) => invoke("playback_set_eq", { gainsDb, enabled }), playbackGetEq: () => invoke<{ gains_db: number[]; enabled: boolean }>("playback_get_eq"), + playbackEnableVisualizer: () => invoke("playback_enable_visualizer"), + playbackDisableVisualizer: () => invoke("playback_disable_visualizer"), syncConfigure: (dbUrl: string, token: string) => invoke("sync_configure", { dbUrl, token }), diff --git a/src/lib/api/events.ts b/src/lib/api/events.ts index c27f0fe..b016178 100644 --- a/src/lib/api/events.ts +++ b/src/lib/api/events.ts @@ -23,3 +23,6 @@ export const onDeviceSyncStarted = (cb: () => void) => export const onDeviceSyncEnded = (cb: () => void) => listen("device-sync-ended", () => cb()); + +export const onVisualizerData = (cb: (bands: number[]) => void) => + listen("visualizer-data", (event) => cb(event.payload)); diff --git a/src/lib/components/visualizer/VisualizerPanel.svelte b/src/lib/components/visualizer/VisualizerPanel.svelte new file mode 100644 index 0000000..2c5f1e0 --- /dev/null +++ b/src/lib/components/visualizer/VisualizerPanel.svelte @@ -0,0 +1,270 @@ + + + + + + + diff --git a/src/lib/stores/player.svelte.ts b/src/lib/stores/player.svelte.ts index bbf41e4..d3facf1 100644 --- a/src/lib/stores/player.svelte.ts +++ b/src/lib/stores/player.svelte.ts @@ -14,6 +14,7 @@ function createPlayerStore() { album: null, album_id: null, art_path: null, + rms_amplitude: 0, }); let eqGains = $state(new Array(8).fill(0)); diff --git a/src/lib/stores/ui.svelte.ts b/src/lib/stores/ui.svelte.ts index 00c19d9..267d122 100644 --- a/src/lib/stores/ui.svelte.ts +++ b/src/lib/stores/ui.svelte.ts @@ -3,6 +3,7 @@ export type AlbumSort = 'date_added' | 'artist_name' | 'album_name'; function createUiStore() { let showSettings = $state(false); let showDeviceSync = $state(false); + let showVisualizer = $state(false); let albumSort = $state('date_added'); let nowPlayingDrawerOpen = $state(false); @@ -13,6 +14,9 @@ function createUiStore() { get showDeviceSync() { return showDeviceSync; }, + get showVisualizer() { + return showVisualizer; + }, get albumSort() { return albumSort; }, @@ -35,6 +39,12 @@ function createUiStore() { closeDeviceSync() { showDeviceSync = false; }, + openVisualizer() { + showVisualizer = true; + }, + closeVisualizer() { + showVisualizer = false; + }, setAlbumSort(sort: AlbumSort) { albumSort = sort; }, diff --git a/src/lib/types.ts b/src/lib/types.ts index 8a44a06..51c0d71 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -11,6 +11,7 @@ export type PlaybackSnapshot = { album: string | null; album_id: number | null; art_path: string | null; + rms_amplitude: number; }; export type ArtistRow = { diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 8775900..cecd479 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -7,13 +7,14 @@ import "$lib/styles/theme.css"; import { confirm } from "@tauri-apps/plugin-dialog"; import { getCurrentWindow } from "@tauri-apps/api/window"; - import { HardDrive, Settings } from "@lucide/svelte"; + import { HardDrive, Settings, Waves } from "@lucide/svelte"; import { untrack } from "svelte"; import ArtistList from "$lib/components/sidebar/ArtistList.svelte"; import TransportBar from "$lib/components/transport/TransportBar.svelte"; import SettingsPanel from "$lib/components/settings/SettingsPanel.svelte"; import DeviceSyncPanel from "$lib/components/device/DeviceSyncPanel.svelte"; import NowPlayingDrawer from "$lib/components/nowplaying/NowPlayingDrawer.svelte"; + import VisualizerPanel from "$lib/components/visualizer/VisualizerPanel.svelte"; import ThemeSwitcher from "$lib/components/theme/ThemeSwitcher.svelte"; import Dropdown from "$lib/components/common/Dropdown.svelte"; import { library } from "$lib/stores/library.svelte"; @@ -44,6 +45,12 @@ return; } + if (event.key === "Escape" && ui.showVisualizer) { + event.preventDefault(); + ui.closeVisualizer(); + return; + } + if (event.code !== "Space") return; const target = event.target; if ( @@ -135,6 +142,14 @@ {/if} + @@ -244,8 +358,8 @@ .scrim { position: absolute; inset: 0; - /* Uniform dark layer under the horizontal vignette. Kept lighter than the - default --scrim-heavy so the gradient above it reads visually. */ + /* Horizontal gradient: darker at the edges so the blurred background art + glows through in the center while the sides fade to near-black. */ background: rgba(0, 0, 0, 0.55); background: linear-gradient( to right, @@ -256,6 +370,13 @@ ); } + .style-control { + position: absolute; + top: 0.75em; + left: 0.75em; + z-index: 1; + } + .close-button { position: absolute; top: 0.75em;