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:
1 parent
5a93ac509d
commit
b8cfcfb34a
8 files changed
+614
No files matched your search
@@ -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"
|
||||
|
||||
|
||||
@@ -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::*;
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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(÷_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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user