diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2bba23f9e..90a173c07 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -53,6 +53,9 @@ rand = "0.8" rodio = { version = "0.19", features = ["symphonia-all"] } symphonia = { version = "0.5", features = ["all"] } +# Audio visualization (FFT) +spectrum-analyzer = "1.5" + # Media controls (MPRIS on Linux) souvlaki = "0.7" diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 96d4c617c..d0f425395 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -24,6 +24,7 @@ pub mod search; pub mod share; pub mod smart_playlists; pub mod remote_metadata; +pub mod visualizer; pub use audio_backends::*; pub use audio_diagnostics::*; @@ -47,3 +48,4 @@ pub use search::*; pub use share::*; pub use smart_playlists::*; pub use remote_metadata::*; +pub use visualizer::*; diff --git a/src-tauri/src/commands/visualizer.rs b/src-tauri/src/commands/visualizer.rs new file mode 100644 index 000000000..458de231d --- /dev/null +++ b/src-tauri/src/commands/visualizer.rs @@ -0,0 +1,19 @@ +//! Visualizer Commands +//! +//! Tauri commands for controlling the audio visualizer. + +use tauri::State; +use crate::AppState; + +/// Enable or disable the audio visualizer +#[tauri::command] +pub fn set_visualizer_enabled(enabled: bool, state: State<'_, AppState>) -> Result<(), String> { + state.visualizer.set_enabled(enabled); + Ok(()) +} + +/// Check if the visualizer is enabled +#[tauri::command] +pub fn is_visualizer_enabled(state: State<'_, AppState>) -> bool { + state.visualizer.is_enabled() +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 16445f257..d84792041 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -32,6 +32,7 @@ pub mod session_store; pub mod share; pub mod tray; pub mod updates; +pub mod visualizer; use std::sync::Arc; use tauri::{Emitter, Manager}; @@ -45,6 +46,7 @@ use playback_context::ContextManager; use player::Player; use queue::QueueManager; use share::SongLinkClient; +use visualizer::Visualizer; /// Application state shared across commands pub struct AppState { @@ -56,6 +58,7 @@ pub struct AppState { pub audio_cache: Arc, pub lastfm: Arc>, pub songlink: SongLinkClient, + pub visualizer: Visualizer, } impl AppState { @@ -96,6 +99,7 @@ impl AppState { audio_cache, lastfm: Arc::new(Mutex::new(LastFmClient::default())), songlink: SongLinkClient::new(), + visualizer: Visualizer::new(), } } } @@ -284,6 +288,11 @@ pub fn run() { .media_controls .init(app.handle().clone()); + // Start visualizer FFT thread (disabled by default, enabled via command) + app.state::() + .visualizer + .start(app.handle().clone()); + // Purge offline downloads if the subscription has been invalid for > 3 days // (compliance requirement for cached content). let app_handle = app.handle().clone(); @@ -896,6 +905,9 @@ pub fn run() { commands::remote_metadata_get_album, commands::remote_metadata_cache_stats, commands::remote_metadata_clear_cache, + // Visualizer commands + commands::set_visualizer_enabled, + commands::is_visualizer_enabled, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/visualizer/fft_processor.rs b/src-tauri/src/visualizer/fft_processor.rs new file mode 100644 index 000000000..b209b4371 --- /dev/null +++ b/src-tauri/src/visualizer/fft_processor.rs @@ -0,0 +1,198 @@ +//! FFT Processing for Audio Visualization +//! +//! Runs on a dedicated thread, completely separate from audio playback. +//! Uses spectrum-analyzer crate for efficient FFT computation. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::time::{Duration, Instant}; + +use spectrum_analyzer::{samples_fft_to_spectrum, FrequencyLimit}; +use spectrum_analyzer::scaling::divide_by_N_sqrt; +use spectrum_analyzer::windows::hann_window; +use tauri::{AppHandle, Emitter}; + +use super::ring_buffer::RingBuffer; +use super::{NUM_BARS, FFT_SIZE, TARGET_FPS}; + +/// Shared state for the visualizer thread +pub struct VisualizerState { + pub ring_buffer: Arc, + pub enabled: Arc, + pub sample_rate: Arc, +} + +/// Start the FFT processing thread +pub fn start_visualizer_thread(state: VisualizerState, app_handle: AppHandle) { + std::thread::Builder::new() + .name("visualizer-fft".to_string()) + .spawn(move || { + run_fft_loop(state, app_handle); + }) + .expect("Failed to spawn visualizer thread"); + + log::info!("Visualizer FFT thread started"); +} + +/// Main FFT processing loop +fn run_fft_loop(state: VisualizerState, app_handle: AppHandle) { + // Pre-allocate all buffers to avoid allocations in the hot path + let mut samples = vec![0.0f32; FFT_SIZE]; + let mut windowed = vec![0.0f32; FFT_SIZE]; + let mut output = vec![0.0f32; NUM_BARS]; + let mut smoothed = vec![0.0f32; NUM_BARS]; + + // Smoothing factor: 0 = no smoothing, higher = more smoothing + const SMOOTHING: f32 = 0.65; + + let frame_duration = Duration::from_micros(1_000_000 / TARGET_FPS); + + loop { + let frame_start = Instant::now(); + + if state.enabled.load(Ordering::Relaxed) { + let sample_rate = state.sample_rate.load(Ordering::Relaxed); + + // Get samples from ring buffer + state.ring_buffer.snapshot(&mut samples); + + // Apply Hann window to reduce spectral leakage + let window = hann_window(&samples); + for (i, (sample, win)) in samples.iter().zip(window.iter()).enumerate() { + windowed[i] = sample * win; + } + + // Compute FFT spectrum + match samples_fft_to_spectrum( + &windowed, + sample_rate, + FrequencyLimit::Range(20.0, 20000.0), + Some(÷_by_N_sqrt), + ) { + Ok(spectrum) => { + // Map spectrum to logarithmic frequency bars + map_to_log_bars(&spectrum, &mut output); + + // Apply smoothing for visual continuity + for i in 0..NUM_BARS { + let new = output[i]; + // Faster attack, slower decay for punchy visuals + if new > smoothed[i] { + smoothed[i] = smoothed[i] * 0.3 + new * 0.7; // Fast attack + } else { + smoothed[i] = smoothed[i] * SMOOTHING + new * (1.0 - SMOOTHING); // Slow decay + } + output[i] = smoothed[i]; + } + + // Send to frontend as binary data + let bytes: Vec = output + .iter() + .flat_map(|f| f.to_le_bytes()) + .collect(); + + let _ = app_handle.emit("viz:data", bytes); + } + Err(e) => { + log::debug!("FFT error: {:?}", e); + } + } + } + + // Maintain target FPS + let elapsed = frame_start.elapsed(); + if elapsed < frame_duration { + std::thread::sleep(frame_duration - elapsed); + } + } +} + +/// Map spectrum data to logarithmically-spaced frequency bars +/// +/// Human hearing is logarithmic, so we use log-spaced bars to match +/// how we perceive frequency. This gives equal visual weight to +/// bass, mids, and treble. +fn map_to_log_bars(spectrum: &spectrum_analyzer::FrequencySpectrum, output: &mut [f32]) { + let num_bars = output.len(); + + // Frequency range (Hz) + const MIN_FREQ: f32 = 20.0; + const MAX_FREQ: f32 = 20000.0; + + let min_log = MIN_FREQ.ln(); + let max_log = MAX_FREQ.ln(); + + // Get spectrum data + let data = spectrum.data(); + + for (i, bar) in output.iter_mut().enumerate() { + // Calculate logarithmic frequency bounds for this bar + let t_low = i as f32 / num_bars as f32; + let t_high = (i + 1) as f32 / num_bars as f32; + + let freq_low = (min_log + (max_log - min_log) * t_low).exp(); + let freq_high = (min_log + (max_log - min_log) * t_high).exp(); + + // Find all frequency bins that fall within this bar's range + let mut sum = 0.0f32; + let mut count = 0u32; + + for (freq, magnitude) in data.iter() { + let f = freq.val(); + if f >= freq_low && f < freq_high { + // Apply perceptual weighting (boost bass slightly) + let weight = if f < 200.0 { + 1.5 // Bass boost + } else if f < 2000.0 { + 1.0 // Mids + } else { + 0.8 // Reduce harsh highs + }; + + sum += magnitude.val() * weight; + count += 1; + } + } + + // Average magnitude for this bar + let avg = if count > 0 { + sum / count as f32 + } else { + 0.0 + }; + + // Apply dynamic range compression and normalize + // This makes quiet passages more visible while preventing clipping + let compressed = (avg * 4.0).powf(0.6); + *bar = compressed.clamp(0.0, 1.0); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_log_frequency_distribution() { + // Verify that frequency bars are logarithmically distributed + let num_bars = 64; + let min_log = 20.0_f32.ln(); + let max_log = 20000.0_f32.ln(); + + let mut freqs = Vec::new(); + for i in 0..num_bars { + let t = i as f32 / num_bars as f32; + let freq = (min_log + (max_log - min_log) * t).exp(); + freqs.push(freq); + } + + // First bar should be around 20Hz + assert!(freqs[0] > 19.0 && freqs[0] < 25.0); + + // Middle bar (~32) should be around 630Hz (geometric mean of 20 and 20000) + assert!(freqs[32] > 500.0 && freqs[32] < 800.0); + + // Last bar should approach 20000Hz + assert!(freqs[num_bars - 1] > 15000.0); + } +} diff --git a/src-tauri/src/visualizer/mod.rs b/src-tauri/src/visualizer/mod.rs new file mode 100644 index 000000000..bb31f6ac7 --- /dev/null +++ b/src-tauri/src/visualizer/mod.rs @@ -0,0 +1,121 @@ +//! Audio Visualizer Module +//! +//! Provides real-time FFT analysis for audio visualization without affecting bit-perfect playback. +//! Uses a lockless ring buffer to capture samples from the audio thread and processes them +//! on a dedicated thread. + +mod ring_buffer; +mod fft_processor; +mod tapped_source; + +pub use ring_buffer::RingBuffer; +pub use fft_processor::{VisualizerState, start_visualizer_thread}; +pub use tapped_source::TappedSource; + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use tauri::AppHandle; + +/// Number of frequency bins to send to frontend +pub const NUM_BARS: usize = 64; + +/// FFT size (must be power of 2) +/// 2048 gives good frequency resolution at ~21Hz per bin at 44.1kHz +pub const FFT_SIZE: usize = 2048; + +/// Target frames per second for visualization updates +pub const TARGET_FPS: u64 = 60; + +/// Shared state for visualization that can be passed to the audio thread +#[derive(Clone)] +pub struct VisualizerTap { + /// Ring buffer for sample capture + pub ring_buffer: Arc, + /// Whether visualization is enabled + pub enabled: Arc, + /// Current sample rate + pub sample_rate: Arc, +} + +impl VisualizerTap { + /// Create a new tap + pub fn new() -> Self { + Self { + ring_buffer: Arc::new(RingBuffer::new(FFT_SIZE * 2)), + enabled: Arc::new(AtomicBool::new(false)), + sample_rate: Arc::new(AtomicU32::new(44100)), + } + } + + /// Check if visualization is enabled (fast atomic check) + #[inline] + pub fn is_enabled(&self) -> bool { + self.enabled.load(Ordering::Relaxed) + } + + /// Push a sample (only if enabled) + #[inline] + pub fn push(&self, sample: f32) { + if self.is_enabled() { + self.ring_buffer.push(sample); + } + } +} + +impl Default for VisualizerTap { + fn default() -> Self { + Self::new() + } +} + +/// Manages the audio visualizer lifecycle +pub struct Visualizer { + /// Shared tap state (given to Player for sample capture) + tap: VisualizerTap, +} + +impl Visualizer { + /// Create a new visualizer instance + pub fn new() -> Self { + Self { + tap: VisualizerTap::new(), + } + } + + /// Get the tap to give to the Player + pub fn get_tap(&self) -> VisualizerTap { + self.tap.clone() + } + + /// Start the FFT processing thread + pub fn start(&self, app_handle: AppHandle) { + let state = VisualizerState { + ring_buffer: self.tap.ring_buffer.clone(), + enabled: self.tap.enabled.clone(), + sample_rate: self.tap.sample_rate.clone(), + }; + start_visualizer_thread(state, app_handle); + } + + /// Enable or disable visualization + pub fn set_enabled(&self, enabled: bool) { + self.tap.enabled.store(enabled, Ordering::Relaxed); + log::info!("Visualizer {}", if enabled { "enabled" } else { "disabled" }); + } + + /// Check if visualization is enabled + pub fn is_enabled(&self) -> bool { + self.tap.enabled.load(Ordering::Relaxed) + } + + /// Update the sample rate (call when audio format changes) + pub fn set_sample_rate(&self, rate: u32) { + self.tap.sample_rate.store(rate, Ordering::Relaxed); + } +} + +impl Default for Visualizer { + fn default() -> Self { + Self::new() + } +} diff --git a/src-tauri/src/visualizer/ring_buffer.rs b/src-tauri/src/visualizer/ring_buffer.rs new file mode 100644 index 000000000..877549871 --- /dev/null +++ b/src-tauri/src/visualizer/ring_buffer.rs @@ -0,0 +1,126 @@ +//! Lock-free Ring Buffer for Audio Sample Capture +//! +//! This implementation is designed to be called from the audio thread without +//! any blocking operations. The audio thread is the only writer, and the +//! visualizer thread is the only reader. +//! +//! CRITICAL: This must not affect bit-perfect playback in any way. +//! - No locks or mutexes +//! - No allocations +//! - Minimal atomic operations + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::cell::UnsafeCell; + +/// A lock-free single-producer single-consumer ring buffer +/// +/// # Safety +/// This is safe because: +/// - Only one thread (audio) calls `push` +/// - Only one thread (visualizer) calls `snapshot` +/// - The buffer size is fixed at creation +pub struct RingBuffer { + /// The actual sample storage + buffer: UnsafeCell>, + /// Current write position (only modified by audio thread) + write_pos: AtomicUsize, + /// Buffer size + size: usize, +} + +// Safety: RingBuffer is designed for single-producer single-consumer use +unsafe impl Sync for RingBuffer {} +unsafe impl Send for RingBuffer {} + +impl RingBuffer { + /// Create a new ring buffer with the specified size + pub fn new(size: usize) -> Self { + Self { + buffer: UnsafeCell::new(vec![0.0; size].into_boxed_slice()), + write_pos: AtomicUsize::new(0), + size, + } + } + + /// Push a sample to the buffer (called from audio thread) + /// + /// This is completely lock-free and will not block. + /// If the visualizer is reading, it might get slightly stale data, + /// which is perfectly fine for visualization purposes. + #[inline] + pub fn push(&self, sample: f32) { + let pos = self.write_pos.fetch_add(1, Ordering::Relaxed) % self.size; + + // Safety: We're the only writer, and readers can tolerate slightly stale data + unsafe { + let buffer = &mut *self.buffer.get(); + buffer[pos] = sample; + } + } + + /// Take a snapshot of the most recent samples + /// + /// Copies the last `dest.len()` samples into the provided slice. + /// The samples are ordered from oldest to newest. + pub fn snapshot(&self, dest: &mut [f32]) { + let write_pos = self.write_pos.load(Ordering::Relaxed); + let len = dest.len().min(self.size); + + // Safety: We're the only reader, and the writer only increments write_pos + unsafe { + let buffer = &*self.buffer.get(); + + for i in 0..len { + // Calculate the index for the i-th oldest sample + // We want samples from (write_pos - len) to (write_pos - 1) + let idx = (write_pos + self.size - len + i) % self.size; + dest[i] = buffer[idx]; + } + } + } + + /// Get the current write position (for debugging) + #[allow(dead_code)] + pub fn write_position(&self) -> usize { + self.write_pos.load(Ordering::Relaxed) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ring_buffer_basic() { + let buffer = RingBuffer::new(8); + + // Push some samples + for i in 0..5 { + buffer.push(i as f32); + } + + // Take a snapshot + let mut snapshot = [0.0f32; 4]; + buffer.snapshot(&mut snapshot); + + // Should get the last 4 samples: 1, 2, 3, 4 + assert_eq!(snapshot, [1.0, 2.0, 3.0, 4.0]); + } + + #[test] + fn test_ring_buffer_wrap_around() { + let buffer = RingBuffer::new(4); + + // Push more samples than buffer size + for i in 0..10 { + buffer.push(i as f32); + } + + // Take a snapshot + let mut snapshot = [0.0f32; 4]; + buffer.snapshot(&mut snapshot); + + // Should get the last 4 samples: 6, 7, 8, 9 + assert_eq!(snapshot, [6.0, 7.0, 8.0, 9.0]); + } +} diff --git a/src-tauri/src/visualizer/tapped_source.rs b/src-tauri/src/visualizer/tapped_source.rs new file mode 100644 index 000000000..d28af27cf --- /dev/null +++ b/src-tauri/src/visualizer/tapped_source.rs @@ -0,0 +1,133 @@ +//! Tapped Audio Source +//! +//! A wrapper around any rodio Source that intercepts samples for visualization +//! without affecting audio playback. The tap is completely transparent to the +//! audio pipeline. + +use std::sync::Arc; +use std::time::Duration; +use rodio::Source; + +use super::RingBuffer; + +/// Wraps a Source and sends samples to a ring buffer for visualization +pub struct TappedSource +where + S: Source, +{ + inner: S, + ring_buffer: Arc, + enabled: Arc, +} + +impl TappedSource +where + S: Source, +{ + /// Create a new TappedSource wrapping the given source + pub fn new( + source: S, + ring_buffer: Arc, + enabled: Arc, + ) -> Self { + Self { + inner: source, + ring_buffer, + enabled, + } + } +} + +impl Iterator for TappedSource +where + S: Source, +{ + type Item = i16; + + #[inline] + fn next(&mut self) -> Option { + let sample = self.inner.next()?; + + // Only send to visualizer if enabled - this is a fast atomic check + if self.enabled.load(std::sync::atomic::Ordering::Relaxed) { + // Convert i16 to f32 normalized to [-1, 1] + // This is the only computation we do in the hot path + let normalized = sample as f32 / i16::MAX as f32; + self.ring_buffer.push(normalized); + } + + Some(sample) + } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + self.inner.size_hint() + } +} + +impl Source for TappedSource +where + S: Source, +{ + #[inline] + fn current_frame_len(&self) -> Option { + self.inner.current_frame_len() + } + + #[inline] + fn channels(&self) -> u16 { + self.inner.channels() + } + + #[inline] + fn sample_rate(&self) -> u32 { + self.inner.sample_rate() + } + + #[inline] + fn total_duration(&self) -> Option { + self.inner.total_duration() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rodio::buffer::SamplesBuffer; + use std::sync::atomic::AtomicBool; + + #[test] + fn test_tapped_source_passes_through() { + let samples: Vec = vec![1000, 2000, 3000, -1000, -2000]; + let source = SamplesBuffer::new(1, 44100, samples.clone()); + + let ring_buffer = Arc::new(RingBuffer::new(16)); + let enabled = Arc::new(AtomicBool::new(true)); + + let tapped = TappedSource::new(source, ring_buffer, enabled); + let output: Vec = tapped.collect(); + + // Samples should pass through unchanged + assert_eq!(output, samples); + } + + #[test] + fn test_tapped_source_fills_ring_buffer() { + let samples: Vec = vec![i16::MAX, 0, i16::MIN]; + let source = SamplesBuffer::new(1, 44100, samples); + + let ring_buffer = Arc::new(RingBuffer::new(16)); + let enabled = Arc::new(AtomicBool::new(true)); + + let tapped = TappedSource::new(source, ring_buffer.clone(), enabled); + let _: Vec = tapped.collect(); + + // Check ring buffer received normalized samples + let mut snapshot = [0.0f32; 3]; + ring_buffer.snapshot(&mut snapshot); + + assert!((snapshot[0] - 1.0).abs() < 0.001); // i16::MAX -> ~1.0 + assert!((snapshot[1] - 0.0).abs() < 0.001); // 0 -> 0.0 + assert!((snapshot[2] - (-1.0)).abs() < 0.01); // i16::MIN -> ~-1.0 + } +}