diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 48cc21ed6..2bba23f9e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -53,9 +53,6 @@ rand = "0.8" rodio = { version = "0.19", features = ["symphonia-all"] } symphonia = { version = "0.5", features = ["all"] } -# Audio visualization (FFT) -rustfft = "6.2" - # 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 d0f425395..96d4c617c 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -24,7 +24,6 @@ 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::*; @@ -48,4 +47,3 @@ 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 deleted file mode 100644 index 57a2db1f1..000000000 --- a/src-tauri/src/commands/visualizer.rs +++ /dev/null @@ -1,18 +0,0 @@ -use tauri::command; -use crate::visualizer; - -/// Enable or disable the audio visualizer -#[command] -pub fn set_visualizer_enabled(enabled: bool) { - visualizer::set_visualizer_enabled(enabled); -} - -/// Check if visualizer is enabled -#[command] -pub fn get_visualizer_enabled() -> bool { - if let Ok(viz) = visualizer::VISUALIZER.lock() { - viz.is_enabled() - } else { - false - } -} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0a1267cb0..d1a12b704 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -32,7 +32,6 @@ pub mod session_store; pub mod share; pub mod tray; pub mod updates; -pub mod visualizer; use std::sync::Arc; use tauri::{Emitter, Manager}; @@ -285,9 +284,6 @@ pub fn run() { .media_controls .init(app.handle().clone()); - // Initialize audio visualizer - visualizer::init_visualizer(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(); @@ -894,9 +890,6 @@ pub fn run() { commands::remote_metadata_get_album, commands::remote_metadata_cache_stats, commands::remote_metadata_clear_cache, - // Audio visualizer commands - commands::set_visualizer_enabled, - commands::get_visualizer_enabled, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/player/mod.rs b/src-tauri/src/player/mod.rs index c14e13268..9d2934ca4 100644 --- a/src-tauri/src/player/mod.rs +++ b/src-tauri/src/player/mod.rs @@ -39,7 +39,6 @@ use symphonia::default::{get_codecs, get_probe}; use crate::api::{client::QobuzClient, models::Quality}; use crate::audio::{AudioBackendType, BackendConfig, BackendManager}; use crate::config::audio_settings::AudioSettings; -use crate::visualizer::VisualizerSource; use playback_engine::PlaybackEngine; /// Commands sent to the audio thread @@ -1047,9 +1046,6 @@ impl Player { .unwrap_or(duration_secs); thread_state.duration.store(actual_duration, Ordering::SeqCst); - // Wrap with visualizer tap (zero-copy passthrough) - let source = VisualizerSource::new(source); - if let Err(e) = engine.append(source) { log::error!("Failed to append source to engine: {}", e); return; @@ -1263,11 +1259,8 @@ impl Player { // This allows the seekbar to show progress even during streaming thread_state.duration.store(duration_secs, Ordering::SeqCst); - // Wrap with visualizer tap (zero-copy passthrough) - let visualized_source = VisualizerSource::new(incremental_source); - - // Box the source to match the expected type - let source_to_play: Box + Send> = Box::new(visualized_source); + // Box the incremental source to match the expected type + let source_to_play: Box + Send> = Box::new(incremental_source); if let Err(e) = engine.append(source_to_play) { log::error!("Failed to append streaming source to engine: {}", e); return; @@ -1375,11 +1368,9 @@ impl Player { let resume_pos = thread_state.position.load(Ordering::SeqCst); let skipped_source: Box + Send> = if resume_pos > 0 { - // Wrap with visualizer tap, then skip - let viz_source = VisualizerSource::new(source); - Box::new(viz_source.skip_duration(Duration::from_secs(resume_pos))) + Box::new(source.skip_duration(Duration::from_secs(resume_pos))) } else { - Box::new(VisualizerSource::new(source)) + source }; if let Err(e) = engine.append(skipped_source) { @@ -1476,10 +1467,8 @@ impl Player { } }; - // Wrap with visualizer tap, then skip - let viz_source = VisualizerSource::new(source); let skip_duration = Duration::from_secs(position_secs); - let skipped_source = viz_source.skip_duration(skip_duration); + let skipped_source = source.skip_duration(skip_duration); if let Err(e) = engine.append(skipped_source) { log::error!("Failed to append source for seek: {}", e); diff --git a/src-tauri/src/visualizer/mod.rs b/src-tauri/src/visualizer/mod.rs deleted file mode 100644 index d886c5dfd..000000000 --- a/src-tauri/src/visualizer/mod.rs +++ /dev/null @@ -1,401 +0,0 @@ -use rustfft::{FftPlanner, num_complex::Complex}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex}; -use std::collections::VecDeque; -use std::time::Duration; -use tauri::{AppHandle, Emitter}; -use rodio::Source; - -/// Number of frequency bins to output (matches frontend bars) -const OUTPUT_BINS: usize = 32; - -/// FFT window size (power of 2 for efficiency) -const FFT_SIZE: usize = 1024; - -/// How often to compute FFT (in samples) - ~60fps at 44.1kHz -const FFT_INTERVAL: usize = 735; - -/// Visualizer state shared between audio thread and emitter -pub struct VisualizerState { - /// Ring buffer for incoming samples - sample_buffer: VecDeque, - /// FFT planner (reusable) - fft_planner: FftPlanner, - /// Scratch buffer for FFT input - fft_input: Vec>, - /// Scratch buffer for FFT output - fft_output: Vec>, - /// Current frequency magnitudes (0.0 to 1.0) - frequency_bins: [f32; OUTPUT_BINS], - /// Smoothed bins for display (with decay) - smoothed_bins: [f32; OUTPUT_BINS], - /// Sample counter for FFT interval - sample_count: usize, - /// Current sample rate - sample_rate: u32, - /// Number of channels - channels: u16, -} - -impl VisualizerState { - pub fn new() -> Self { - Self { - sample_buffer: VecDeque::with_capacity(FFT_SIZE * 2), - fft_planner: FftPlanner::new(), - fft_input: vec![Complex::new(0.0, 0.0); FFT_SIZE], - fft_output: vec![Complex::new(0.0, 0.0); FFT_SIZE], - frequency_bins: [0.0; OUTPUT_BINS], - smoothed_bins: [0.0; OUTPUT_BINS], - sample_count: 0, - sample_rate: 44100, - channels: 2, - } - } - - /// Update sample rate when track changes - pub fn set_format(&mut self, sample_rate: u32, channels: u16) { - self.sample_rate = sample_rate; - self.channels = channels; - // Clear buffer on format change - self.sample_buffer.clear(); - self.sample_count = 0; - } - - /// Push samples into the buffer (called from audio thread) - /// Returns true if FFT should be computed - pub fn push_samples(&mut self, samples: &[i16]) -> bool { - // Convert i16 to f32 and mix to mono if stereo - let channels = self.channels as usize; - - for chunk in samples.chunks(channels) { - // Average channels to mono - let mono: f32 = chunk.iter() - .map(|&s| s as f32 / 32768.0) - .sum::() / channels as f32; - - self.sample_buffer.push_back(mono); - - // Keep buffer bounded - if self.sample_buffer.len() > FFT_SIZE * 4 { - self.sample_buffer.pop_front(); - } - } - - self.sample_count += samples.len() / channels; - - // Check if we should compute FFT - if self.sample_count >= FFT_INTERVAL && self.sample_buffer.len() >= FFT_SIZE { - self.sample_count = 0; - true - } else { - false - } - } - - /// Compute FFT and update frequency bins - pub fn compute_fft(&mut self) { - if self.sample_buffer.len() < FFT_SIZE { - return; - } - - // Copy samples to FFT input with Hann window - let start = self.sample_buffer.len().saturating_sub(FFT_SIZE); - for (i, sample) in self.sample_buffer.iter().skip(start).take(FFT_SIZE).enumerate() { - // Hann window to reduce spectral leakage - let window = 0.5 * (1.0 - (2.0 * std::f32::consts::PI * i as f32 / (FFT_SIZE - 1) as f32).cos()); - self.fft_input[i] = Complex::new(sample * window, 0.0); - } - - // Perform FFT - let fft = self.fft_planner.plan_fft_forward(FFT_SIZE); - self.fft_output.copy_from_slice(&self.fft_input); - fft.process(&mut self.fft_output); - - // Convert to magnitude spectrum (only first half is meaningful) - let useful_bins = FFT_SIZE / 2; - - // Map FFT bins to output bins (logarithmic scale for perceptual accuracy) - // Lower frequencies get more bins, higher frequencies are grouped - for i in 0..OUTPUT_BINS { - // Logarithmic mapping: more resolution at low frequencies - let start_bin = self.log_bin_start(i, useful_bins); - let end_bin = self.log_bin_start(i + 1, useful_bins); - - // Average magnitude in this range - let mut sum = 0.0f32; - let mut count = 0; - - for bin in start_bin..end_bin.min(useful_bins) { - let magnitude = self.fft_output[bin].norm(); - sum += magnitude; - count += 1; - } - - let avg = if count > 0 { sum / count as f32 } else { 0.0 }; - - // Scale to 0-1 range - higher multiplier for more visible response - let scaled = (avg * 50.0 / FFT_SIZE as f32).min(1.0); - - self.frequency_bins[i] = scaled; - } - - // Apply smoothing (fast attack, fast decay for responsive feel) - for i in 0..OUTPUT_BINS { - let current = self.frequency_bins[i]; - let smoothed = self.smoothed_bins[i]; - - if current > smoothed { - // Very fast attack - self.smoothed_bins[i] = smoothed + (current - smoothed) * 0.85; - } else { - // Fast decay for snappy response - self.smoothed_bins[i] = smoothed + (current - smoothed) * 0.5; - } - } - } - - /// Logarithmic bin mapping for perceptual frequency distribution - fn log_bin_start(&self, bin: usize, total_bins: usize) -> usize { - // Map 0..OUTPUT_BINS to 0..total_bins with mild log curve - // Less aggressive = more even distribution across all bars - let ratio = bin as f32 / OUTPUT_BINS as f32; - let log_ratio = ratio.powf(1.4); // Gentler curve for better spread - (log_ratio * total_bins as f32) as usize - } - - /// Get current smoothed bins for display - pub fn get_bins(&self) -> [f32; OUTPUT_BINS] { - self.smoothed_bins - } - - /// Reset all bins (for pause/stop) - pub fn reset(&mut self) { - self.frequency_bins = [0.0; OUTPUT_BINS]; - self.smoothed_bins = [0.0; OUTPUT_BINS]; - self.sample_buffer.clear(); - self.sample_count = 0; - } -} - -/// Global visualizer instance -pub struct Visualizer { - state: Arc>, - enabled: Arc, - app_handle: Option, -} - -impl Visualizer { - pub fn new() -> Self { - Self { - state: Arc::new(Mutex::new(VisualizerState::new())), - enabled: Arc::new(AtomicBool::new(false)), - app_handle: None, - } - } - - /// Set the app handle for emitting events - pub fn set_app_handle(&mut self, handle: AppHandle) { - self.app_handle = Some(handle); - } - - /// Enable/disable visualization - pub fn set_enabled(&self, enabled: bool) { - self.enabled.store(enabled, Ordering::SeqCst); - if !enabled { - if let Ok(mut state) = self.state.lock() { - state.reset(); - } - } - } - - /// Check if visualization is enabled - pub fn is_enabled(&self) -> bool { - self.enabled.load(Ordering::SeqCst) - } - - /// Update format when track changes - pub fn set_format(&self, sample_rate: u32, channels: u16) { - if let Ok(mut state) = self.state.lock() { - state.set_format(sample_rate, channels); - } - } - - /// Push samples and emit if ready (non-blocking) - /// Returns quickly - designed to be called from audio thread - pub fn push_samples(&self, samples: &[i16]) { - if !self.is_enabled() { - return; - } - - // Try to lock, but don't block audio thread - if let Ok(mut state) = self.state.try_lock() { - let should_emit = state.push_samples(samples); - - if should_emit { - state.compute_fft(); - let bins = state.get_bins(); - drop(state); // Release lock before emitting - - self.emit_spectrum(bins); - } - } - // If lock fails, skip this batch - visualization can afford to drop frames - } - - /// Emit spectrum data to frontend - fn emit_spectrum(&self, bins: [f32; OUTPUT_BINS]) { - if let Some(ref handle) = self.app_handle { - // Convert to Vec for JSON serialization - let bins_vec: Vec = bins.to_vec(); - let _ = handle.emit("audio-spectrum", bins_vec); - } - } - - /// Reset visualization (for pause/stop) - pub fn reset(&self) { - if let Ok(mut state) = self.state.lock() { - state.reset(); - } - // Emit zeros - self.emit_spectrum([0.0; OUTPUT_BINS]); - } - - /// Get a clone of the state Arc for sharing - pub fn get_state(&self) -> Arc> { - self.state.clone() - } - - /// Get a clone of the enabled flag for sharing - pub fn get_enabled_flag(&self) -> Arc { - self.enabled.clone() - } -} - -impl Default for Visualizer { - fn default() -> Self { - Self::new() - } -} - -// Global instance -lazy_static::lazy_static! { - pub static ref VISUALIZER: Mutex = Mutex::new(Visualizer::new()); -} - -/// Initialize visualizer with app handle -pub fn init_visualizer(handle: AppHandle) { - if let Ok(mut viz) = VISUALIZER.lock() { - viz.set_app_handle(handle); - log::info!("[Visualizer] Initialized"); - } -} - -/// Enable/disable visualizer -pub fn set_visualizer_enabled(enabled: bool) { - if let Ok(viz) = VISUALIZER.lock() { - viz.set_enabled(enabled); - log::info!("[Visualizer] Enabled: {}", enabled); - } -} - -/// Update visualizer format -pub fn set_visualizer_format(sample_rate: u32, channels: u16) { - if let Ok(viz) = VISUALIZER.lock() { - viz.set_format(sample_rate, channels); - log::debug!("[Visualizer] Format: {}Hz, {} channels", sample_rate, channels); - } -} - -/// Push samples to visualizer (called from audio thread) -pub fn push_visualizer_samples(samples: &[i16]) { - if let Ok(viz) = VISUALIZER.try_lock() { - viz.push_samples(samples); - } -} - -/// Reset visualizer -pub fn reset_visualizer() { - if let Ok(viz) = VISUALIZER.lock() { - viz.reset(); - } -} - -/// A wrapper source that taps into audio samples for visualization -/// without modifying the audio flow. This is a zero-copy passthrough -/// that only observes samples. -pub struct VisualizerSource { - inner: S, - buffer: Vec, - buffer_threshold: usize, - channels: u16, - sample_rate: u32, -} - -impl VisualizerSource -where - S: Source, -{ - /// Wrap a source with visualization tap - pub fn new(inner: S) -> Self { - let channels = inner.channels(); - let sample_rate = inner.sample_rate(); - - // Set visualizer format - set_visualizer_format(sample_rate, channels); - - // Buffer enough samples before sending to visualizer - // ~50ms worth at a time to reduce overhead - let buffer_threshold = (sample_rate as usize * channels as usize) / 20; - - Self { - inner, - buffer: Vec::with_capacity(buffer_threshold), - buffer_threshold, - channels, - sample_rate, - } - } -} - -impl Iterator for VisualizerSource -where - S: Source, -{ - type Item = i16; - - fn next(&mut self) -> Option { - let sample = self.inner.next()?; - - // Collect samples in buffer - self.buffer.push(sample); - - // When buffer is full, send to visualizer - if self.buffer.len() >= self.buffer_threshold { - push_visualizer_samples(&self.buffer); - self.buffer.clear(); - } - - Some(sample) - } -} - -impl Source for VisualizerSource -where - S: Source, -{ - fn current_frame_len(&self) -> Option { - self.inner.current_frame_len() - } - - fn channels(&self) -> u16 { - self.channels - } - - fn sample_rate(&self) -> u32 { - self.sample_rate - } - - fn total_duration(&self) -> Option { - self.inner.total_duration() - } -} diff --git a/src/lib/components/immersive/panels/VisualizerPanel.svelte b/src/lib/components/immersive/panels/VisualizerPanel.svelte index efd1260e3..1a022f3a7 100644 --- a/src/lib/components/immersive/panels/VisualizerPanel.svelte +++ b/src/lib/components/immersive/panels/VisualizerPanel.svelte @@ -1,10 +1,8 @@
- {#if mode === 'bars'} -
- {#each bins as bin, i} -
- {/each} -
- {:else if mode === 'wave'} -
- - - - - - - - { - const x = (i / (bins.length - 1)) * 320; - const y = 50 - bin * 45; - return `L ${x} ${y}`; - }).join(' ')} L 320 50`} - fill="none" - stroke="url(#waveGradient)" - stroke-width="3" - stroke-linecap="round" - stroke-linejoin="round" - /> - -
- {:else if mode === 'mirror'} -
- {#each bins as bin, i} -
-
-
-
- {/each} -
- {/if} +