Add audio visualizer backend infrastructure

- Add spectrum-analyzer crate for FFT processing
- Create visualizer module with:
  - RingBuffer: lockless SPSC buffer for audio samples
  - FFT processor: dedicated thread for spectrum analysis
  - TappedSource: rodio Source wrapper for sample capture
  - VisualizerTap: shared state for audio thread integration
- Add Tauri commands: set_visualizer_enabled, is_visualizer_enabled
- Register visualizer in AppState and lib.rs

Backend compiles. Next: integrate with audio thread to capture samples.
This commit is contained in:
vicrodh committed 2026-02-01 15:07:21 -06:00
1 parent 5a93ac509d
commit b8cfcfb34a
8 files changed
+614

No files matched your search

+3
View File
@@ -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"
+2
View File
@@ -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::*;
+19
View File
@@ -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()
}
+12
View File
@@ -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<AudioCache>,
pub lastfm: Arc<Mutex<LastFmClient>>,
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::<AppState>()
.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");
+198
View File
@@ -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<RingBuffer>,
pub enabled: Arc<AtomicBool>,
pub sample_rate: Arc<AtomicU32>,
}
/// 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(&divide_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<u8> = 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);
}
}
+121
View File
@@ -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<RingBuffer>,
/// Whether visualization is enabled
pub enabled: Arc<AtomicBool>,
/// Current sample rate
pub sample_rate: Arc<AtomicU32>,
}
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()
}
}
+126
View File
@@ -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<Box<[f32]>>,
/// 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]);
}
}
+133
View File
@@ -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<S>
where
S: Source<Item = i16>,
{
inner: S,
ring_buffer: Arc<RingBuffer>,
enabled: Arc<std::sync::atomic::AtomicBool>,
}
impl<S> TappedSource<S>
where
S: Source<Item = i16>,
{
/// Create a new TappedSource wrapping the given source
pub fn new(
source: S,
ring_buffer: Arc<RingBuffer>,
enabled: Arc<std::sync::atomic::AtomicBool>,
) -> Self {
Self {
inner: source,
ring_buffer,
enabled,
}
}
}
impl<S> Iterator for TappedSource<S>
where
S: Source<Item = i16>,
{
type Item = i16;
#[inline]
fn next(&mut self) -> Option<Self::Item> {
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<usize>) {
self.inner.size_hint()
}
}
impl<S> Source for TappedSource<S>
where
S: Source<Item = i16>,
{
#[inline]
fn current_frame_len(&self) -> Option<usize> {
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<Duration> {
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<i16> = 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<i16> = tapped.collect();
// Samples should pass through unchanged
assert_eq!(output, samples);
}
#[test]
fn test_tapped_source_fills_ring_buffer() {
let samples: Vec<i16> = 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<i16> = 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
}
}