#![allow(dead_code)]
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone, Copy, Default)]
pub struct BandEnergies {
pub bass: f32,
pub mids: f32,
pub highs: f32,
}
impl BandEnergies {
pub fn curvature(&self) -> f32 {
0.5 + self.bass * 2.5
}
pub fn trail_length_frames(&self) -> usize {
(30.0 + self.mids * 570.0) as usize
}
pub fn particle_speed(&self) -> f32 {
0.5 + self.highs * 3.5
}
}
pub struct BandSplitter {
pub sample_rate: f32,
pub fft_size: usize,
}
impl BandSplitter {
pub fn new(sample_rate: f32, fft_size: usize) -> Self {
Self {
sample_rate,
fft_size: fft_size.max(64),
}
}
fn bin_hz(&self, k: usize) -> f32 {
k as f32 * self.sample_rate / self.fft_size as f32
}
fn band_rms(&self, magnitudes: &[f32], lo_hz: f32, hi_hz: f32) -> f32 {
let nyquist = (self.fft_size / 2).min(magnitudes.len());
let lo_bin = ((lo_hz / self.sample_rate * self.fft_size as f32) as usize).min(nyquist);
let hi_bin = ((hi_hz / self.sample_rate * self.fft_size as f32) as usize).min(nyquist);
if hi_bin <= lo_bin {
return 0.0;
}
let sum_sq: f32 = magnitudes[lo_bin..hi_bin].iter().map(|&m| m * m).sum();
let rms = (sum_sq / (hi_bin - lo_bin) as f32).sqrt();
rms
}
pub fn split(&self, magnitudes: &[f32]) -> BandEnergies {
BandEnergies {
bass: self.band_rms(magnitudes, 20.0, 250.0),
mids: self.band_rms(magnitudes, 250.0, 4000.0),
highs: self.band_rms(magnitudes, 4000.0, 20_000.0),
}
}
}
pub struct EnergySmoother {
pub alpha: f32,
smoothed: BandEnergies,
}
impl EnergySmoother {
pub fn new(alpha: f32) -> Self {
Self {
alpha: alpha.clamp(1e-4, 1.0),
smoothed: BandEnergies::default(),
}
}
pub fn update(&mut self, raw: BandEnergies) -> BandEnergies {
let a = self.alpha;
self.smoothed.bass = (1.0 - a) * self.smoothed.bass + a * raw.bass;
self.smoothed.mids = (1.0 - a) * self.smoothed.mids + a * raw.mids;
self.smoothed.highs = (1.0 - a) * self.smoothed.highs + a * raw.highs;
self.smoothed
}
pub fn current(&self) -> BandEnergies {
self.smoothed
}
}
pub type SharedEnergies = Arc<Mutex<BandEnergies>>;
pub fn shared_energies() -> SharedEnergies {
Arc::new(Mutex::new(BandEnergies {
bass: 0.5,
mids: 0.5,
highs: 0.5,
}))
}
#[derive(Debug, Clone)]
pub struct AudioCaptureConfig {
pub fft_size: usize,
pub smooth_alpha: f32,
pub normalization: f32,
}
impl Default for AudioCaptureConfig {
fn default() -> Self {
Self {
fft_size: 1024,
smooth_alpha: 0.1,
normalization: 0.3,
}
}
}
pub fn start_capture(config: AudioCaptureConfig, energies: SharedEnergies) -> Result<(), String> {
let smooth_alpha = config.smooth_alpha;
let energies_thread = Arc::clone(&energies);
std::thread::Builder::new()
.name("audio-reactive-sim".into())
.spawn(move || {
let mut smoother = EnergySmoother::new(smooth_alpha);
let start = std::time::Instant::now();
loop {
let t = start.elapsed().as_secs_f32();
let raw = BandEnergies {
bass: 0.5 + 0.4 * (t * 0.3).sin(),
mids: 0.5 + 0.4 * (t * 0.7).sin(),
highs: 0.5 + 0.4 * (t * 1.3).sin(),
};
let smoothed = smoother.update(raw);
{
let Ok(mut e) = energies_thread.lock() else {
break;
};
*e = smoothed;
}
std::thread::sleep(std::time::Duration::from_millis(16));
}
log::info!("[audio-reactive] simulation thread exiting");
})
.map_err(|e| format!("spawn error: {e}"))?;
log::info!("[audio-reactive] simulation capture started");
Ok(())
}
#[cfg(test)]
mod tests {
#[test]
fn short_spectrum_does_not_panic() {
let bs = BandSplitter::new(44_100.0, 2048);
let e = bs.split(&[1.0; 10]);
assert!(e.bass > 0.0);
assert_eq!(e.highs, 0.0);
let e = bs.split(&[]);
assert_eq!((e.bass, e.mids, e.highs), (0.0, 0.0, 0.0));
}
use super::*;
#[test]
fn test_band_splitter_frequencies() {
let splitter = BandSplitter::new(44100.0, 1024);
let mags = vec![1.0f32; 512];
let bands = splitter.split(&mags);
assert!(
bands.bass > 0.0,
"bass should be non-zero for flat spectrum"
);
assert!(
bands.mids > 0.0,
"mids should be non-zero for flat spectrum"
);
}
#[test]
fn test_energy_smoother_converges() {
let mut smoother = EnergySmoother::new(0.5);
for _ in 0..20 {
smoother.update(BandEnergies {
bass: 1.0,
mids: 1.0,
highs: 1.0,
});
}
let c = smoother.current();
assert!(c.bass > 0.99, "smoother should converge to 1.0: {}", c.bass);
}
#[test]
fn test_band_energies_curvature_range() {
let e = BandEnergies {
bass: 0.0,
mids: 0.5,
highs: 1.0,
};
assert!((e.curvature() - 0.5).abs() < 1e-4);
let e2 = BandEnergies {
bass: 1.0,
mids: 0.0,
highs: 0.0,
};
assert!((e2.curvature() - 3.0).abs() < 1e-4);
}
#[test]
fn test_band_energies_trail_length_range() {
let e_min = BandEnergies {
bass: 0.0,
mids: 0.0,
highs: 0.0,
};
let e_max = BandEnergies {
bass: 0.0,
mids: 1.0,
highs: 0.0,
};
assert_eq!(e_min.trail_length_frames(), 30);
assert_eq!(e_max.trail_length_frames(), 600);
}
#[test]
fn test_band_energies_particle_speed_range() {
let e_min = BandEnergies {
bass: 0.0,
mids: 0.0,
highs: 0.0,
};
let e_max = BandEnergies {
bass: 0.0,
mids: 0.0,
highs: 1.0,
};
assert!((e_min.particle_speed() - 0.5).abs() < 1e-4);
assert!((e_max.particle_speed() - 4.0).abs() < 1e-4);
}
#[test]
fn test_shared_energies_default() {
let e = shared_energies();
let v = *e.lock().unwrap();
assert!((v.bass - 0.5).abs() < 1e-4);
}
}