1use std::sync::Arc;
21use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
22use std::thread;
23use std::time::{Duration, Instant};
24
25use realfft::RealFftPlanner;
26
27use super::viz::{NUM_BARS, RawVizSnapshot, VizBuffer, VizFrame, VizSnapshot, WAVEFORM_SAMPLES};
28use crate::config::VisualizerConfig;
29
30const FFT_SIZE: usize = 2048;
34
35const MIN_FREQ: f32 = 20.0;
37
38const MAX_FREQ: f32 = 18_000.0;
40
41const DB_FLOOR: f32 = -80.0;
43
44const DB_CEIL: f32 = 0.0;
46
47const IDLE_AFTER: Duration = Duration::from_secs(1);
50
51const SILENT: f32 = 0.001;
54
55#[derive(Debug, Clone, Copy, Default)]
59pub enum FrequencyScale {
60 #[default]
62 Bark,
63 Mel,
65 Log,
67 Linear,
69}
70
71impl FrequencyScale {
72 pub fn parse(s: &str) -> Self {
73 match s.to_lowercase().as_str() {
74 "bark" => Self::Bark,
75 "mel" => Self::Mel,
76 "log" | "logarithmic" => Self::Log,
77 "linear" => Self::Linear,
78 _ => Self::default(),
79 }
80 }
81
82 fn normalize(&self, freq: f32) -> f32 {
84 match self {
85 Self::Bark => {
86 let bark = |f: f32| 26.81 / (1.0 + 1960.0 / f) - 0.53;
87 let b = bark(freq);
88 let b_min = bark(MIN_FREQ);
89 let b_max = bark(MAX_FREQ);
90 (b - b_min) / (b_max - b_min)
91 }
92 Self::Mel => {
93 let mel = |f: f32| 2595.0 * (1.0 + f / 700.0).log10();
94 let m = mel(freq);
95 let m_min = mel(MIN_FREQ);
96 let m_max = mel(MAX_FREQ);
97 (m - m_min) / (m_max - m_min)
98 }
99 Self::Log => {
100 let log_min = MIN_FREQ.ln();
101 let log_max = MAX_FREQ.ln();
102 (freq.ln() - log_min) / (log_max - log_min)
103 }
104 Self::Linear => (freq - MIN_FREQ) / (MAX_FREQ - MIN_FREQ),
105 }
106 }
107}
108
109#[derive(Debug, Clone, Copy, Default)]
113pub enum AmplitudeScale {
114 Perceptual,
116 #[default]
118 AWeight,
119 Sqrt,
121 Linear,
123}
124
125impl AmplitudeScale {
126 pub fn parse(s: &str) -> Self {
127 match s.to_lowercase().as_str() {
128 "perceptual" => Self::Perceptual,
129 "aweight" | "a-weight" | "a_weight" => Self::AWeight,
130 "sqrt" => Self::Sqrt,
131 "linear" => Self::Linear,
132 _ => Self::default(),
133 }
134 }
135
136 fn apply(self, level: f32) -> f32 {
138 match self {
139 Self::Perceptual => level.powf(0.4),
140 Self::AWeight => level,
141 Self::Sqrt => level.sqrt(),
142 Self::Linear => level,
143 }
144 }
145}
146
147fn a_weight_db(freq: f32) -> f32 {
152 let f2 = freq * freq;
153 let f4 = f2 * f2;
154
155 let num = 12194.0_f32.powi(2) * f4;
156 let denom = (f2 + 20.6_f32.powi(2))
157 * ((f2 + 107.7_f32.powi(2)) * (f2 + 737.9_f32.powi(2))).sqrt()
158 * (f2 + 12194.0_f32.powi(2));
159
160 if denom == 0.0 {
161 return DB_FLOOR;
162 }
163
164 let ra = num / denom;
166 20.0 * ra.log10() + 2.0
168}
169
170fn build_a_weight_table(sample_rate: f32) -> Vec<f32> {
172 let bin_hz = sample_rate / FFT_SIZE as f32;
173 let num_bins = FFT_SIZE / 2 + 1;
174 (0..num_bins)
175 .map(|bin_idx| {
176 let freq = bin_idx as f32 * bin_hz;
177 if freq < 1.0 {
178 DB_FLOOR } else {
180 a_weight_db(freq)
181 }
182 })
183 .collect()
184}
185
186fn hann_window() -> Vec<f32> {
190 (0..FFT_SIZE)
191 .map(|i| {
192 let t = std::f32::consts::PI * 2.0 * i as f32 / FFT_SIZE as f32;
193 0.5 * (1.0 - t.cos())
194 })
195 .collect()
196}
197
198fn build_bin_to_bar(sample_rate: f32, scale: FrequencyScale) -> Vec<Option<usize>> {
201 let bin_hz = sample_rate / FFT_SIZE as f32;
202 let num_bins = FFT_SIZE / 2 + 1;
203 (0..num_bins)
204 .map(|bin_idx| {
205 let freq = bin_idx as f32 * bin_hz;
206 if !(MIN_FREQ..=MAX_FREQ).contains(&freq) {
207 return None;
208 }
209 let normalized = scale.normalize(freq);
210 Some(((normalized * NUM_BARS as f32) as usize).min(NUM_BARS - 1))
211 })
212 .collect()
213}
214
215struct AnalysisState {
219 window: Vec<f32>,
221 fft_norm: f32,
225 fft_input: Vec<f32>,
227 fft_output: Vec<realfft::num_complex::Complex<f32>>,
229 fft: Arc<dyn realfft::RealToComplex<f32>>,
231 bin_to_bar: Vec<Option<usize>>,
233 last_sample_rate: f32,
235 bar_counts: [u32; NUM_BARS],
237 prev_spectrum: [f32; NUM_BARS],
239 spectrum: [f32; NUM_BARS],
241 peaks: [f32; NUM_BARS],
243 vu_levels: [f32; 2],
245 last_update: Instant,
247 scale: FrequencyScale,
249 bar_half_life: f32,
251 peak_half_life: f32,
253 amplitude_scale: AmplitudeScale,
255 a_weight_table: Vec<f32>,
257 beat_avg: f32,
260 beat_energy: f32,
262}
263
264impl AnalysisState {
265 fn new(
266 scale: FrequencyScale,
267 bar_half_life: f32,
268 peak_half_life: f32,
269 amplitude_scale: AmplitudeScale,
270 ) -> Self {
271 let mut planner = RealFftPlanner::<f32>::new();
272 let fft = planner.plan_fft_forward(FFT_SIZE);
273 let fft_input = fft.make_input_vec();
274 let fft_output = fft.make_output_vec();
275 let window = hann_window();
276 let fft_norm = 2.0 / window.iter().sum::<f32>();
277 Self {
278 window,
279 fft_norm,
280 fft_input,
281 fft_output,
282 fft,
283 bin_to_bar: Vec::new(),
284 last_sample_rate: 0.0,
285 bar_counts: [0u32; NUM_BARS],
286 prev_spectrum: [0.0; NUM_BARS],
287 spectrum: [0.0; NUM_BARS],
288 peaks: [0.0; NUM_BARS],
289 vu_levels: [0.0; 2],
290 last_update: Instant::now(),
291 scale,
292 bar_half_life,
293 peak_half_life,
294 amplitude_scale,
295 a_weight_table: Vec::new(),
296 beat_avg: 0.0,
297 beat_energy: 0.0,
298 }
299 }
300
301 fn decay_factors(&mut self) -> (f32, f32) {
303 let now = Instant::now();
304 let dt = now.duration_since(self.last_update).as_secs_f32();
305 self.last_update = now;
306 let bar_decay = 0.5f32.powf(dt / self.bar_half_life);
307 let peak_decay = 0.5f32.powf(dt / self.peak_half_life);
308 (bar_decay, peak_decay)
309 }
310
311 fn analyze(&mut self, samples: &[f32], channels: usize, sample_rate: f32) {
315 if samples.is_empty() || sample_rate <= 0.0 || channels == 0 {
316 self.decay_silence();
317 return;
318 }
319
320 self.compute_vu(samples, channels);
322
323 let total_frames = samples.len() / channels;
325 let frames_to_use = total_frames.min(FFT_SIZE);
326 let frame_start = total_frames - frames_to_use;
327
328 for i in 0..FFT_SIZE {
329 if i < frames_to_use {
330 let frame_idx = frame_start + i;
331 let sample_start = frame_idx * channels;
332 let mut sum = 0.0f32;
333 for ch in 0..channels {
334 if sample_start + ch < samples.len() {
335 sum += samples[sample_start + ch];
336 }
337 }
338 self.fft_input[i] = (sum / channels as f32) * self.window[i];
339 } else {
340 self.fft_input[i] = 0.0;
341 }
342 }
343
344 if self
346 .fft
347 .process(&mut self.fft_input, &mut self.fft_output)
348 .is_err()
349 {
350 self.decay_silence();
351 return;
352 }
353
354 if (sample_rate - self.last_sample_rate).abs() > 0.5 {
356 self.bin_to_bar = build_bin_to_bar(sample_rate, self.scale);
357 self.a_weight_table = build_a_weight_table(sample_rate);
358 self.last_sample_rate = sample_rate;
359 }
360
361 std::mem::swap(&mut self.spectrum, &mut self.prev_spectrum);
363 for bar in self.spectrum.iter_mut() {
364 *bar = 0.0;
365 }
366 for c in self.bar_counts.iter_mut() {
367 *c = 0;
368 }
369
370 let norm = self.fft_norm;
371 let db_range_inv = 1.0 / (DB_CEIL - DB_FLOOR);
372 let num_bins = self.fft_output.len().min(self.bin_to_bar.len());
373
374 for bin_idx in 0..num_bins {
375 let bar_idx = match self.bin_to_bar[bin_idx] {
376 Some(b) => b,
377 None => continue,
378 };
379 let c = self.fft_output[bin_idx];
380 let magnitude = (c.re * c.re + c.im * c.im).sqrt() * norm;
381 let mut db = if magnitude > 0.0 {
382 20.0 * magnitude.log10()
383 } else {
384 DB_FLOOR
385 };
386 if matches!(
388 self.amplitude_scale,
389 AmplitudeScale::Perceptual | AmplitudeScale::AWeight
390 ) && let Some(&aw) = self.a_weight_table.get(bin_idx)
391 {
392 db += aw;
393 }
394 let level = ((db - DB_FLOOR) * db_range_inv).clamp(0.0, 1.0);
395 let level = self.amplitude_scale.apply(level);
396 if level > self.spectrum[bar_idx] {
397 self.spectrum[bar_idx] = level;
398 }
399 self.bar_counts[bar_idx] += 1;
400 }
401
402 self.fill_empty_bars();
403
404 let (bar_decay, peak_decay) = self.decay_factors();
406 for i in 0..NUM_BARS {
407 let decayed = self.prev_spectrum[i] * bar_decay;
408 self.spectrum[i] = self.spectrum[i].max(decayed);
409
410 if self.spectrum[i] > self.peaks[i] {
411 self.peaks[i] = self.spectrum[i];
412 } else {
413 self.peaks[i] *= peak_decay;
414 }
415 }
416
417 let beat_bands = NUM_BARS.min(6);
420 let low_energy: f32 = self.spectrum[..beat_bands].iter().sum::<f32>() / beat_bands as f32;
421
422 const BEAT_AVG_ALPHA: f32 = 0.02;
425 self.beat_avg = self.beat_avg * (1.0 - BEAT_AVG_ALPHA) + low_energy * BEAT_AVG_ALPHA;
426
427 let beat_spike = if self.beat_avg > 0.005 {
430 let excess = (low_energy - self.beat_avg).max(0.0);
431 (excess / self.beat_avg.max(0.05)).clamp(0.0, 1.0)
432 } else {
433 (low_energy * 3.0).clamp(0.0, 1.0)
435 };
436
437 self.beat_energy = beat_spike.max(self.beat_energy * bar_decay.sqrt());
440 }
441
442 fn fill_empty_bars(&mut self) {
449 let mut i = 0;
450 while i < NUM_BARS {
451 if self.bar_counts[i] != 0 {
452 i += 1;
453 continue;
454 }
455 let mut end = i;
456 while end < NUM_BARS && self.bar_counts[end] == 0 {
457 end += 1;
458 }
459
460 match (i.checked_sub(1), (end < NUM_BARS).then_some(end)) {
461 (Some(left), Some(right)) => {
462 let (lo, hi) = (self.spectrum[left], self.spectrum[right]);
463 let span = (right - left) as f32;
464 for (n, bar) in (i..end).enumerate() {
465 let t = (n + 1) as f32 / span;
466 self.spectrum[bar] = lo + (hi - lo) * t;
467 }
468 }
469 (Some(left), None) => {
472 let value = self.spectrum[left];
473 self.spectrum[i..end].fill(value);
474 }
475 (None, Some(right)) => {
476 let value = self.spectrum[right];
477 self.spectrum[i..end].fill(value);
478 }
479 (None, None) => self.spectrum.fill(0.0),
480 }
481
482 i = end;
483 }
484 }
485
486 fn is_silent(&self) -> bool {
490 self.spectrum.iter().all(|&v| v < SILENT)
491 && self.peaks.iter().all(|&v| v < SILENT)
492 && self.vu_levels.iter().all(|&v| v < SILENT)
493 && self.beat_energy < SILENT
494 }
495
496 fn silence(&mut self) {
499 self.spectrum.fill(0.0);
500 self.peaks.fill(0.0);
501 self.vu_levels = [0.0, 0.0];
502 self.beat_energy = 0.0;
503 }
504
505 fn decay_silence(&mut self) {
507 let (bar_decay, peak_decay) = self.decay_factors();
508 for i in 0..NUM_BARS {
509 self.spectrum[i] *= bar_decay;
510 self.peaks[i] *= peak_decay;
511 }
512 for v in self.vu_levels.iter_mut() {
513 *v *= bar_decay;
514 }
515 self.beat_energy *= bar_decay;
516 }
517
518 fn compute_vu(&mut self, samples: &[f32], channels: usize) {
520 let total_frames = samples.len() / channels;
521 let frames_to_use = total_frames.min(2048);
522 let frame_start = total_frames - frames_to_use;
523 let vu_channels = channels.min(2);
524 let mut sum_sq = [0.0f64; 2];
525
526 for frame in 0..frames_to_use {
527 let idx = (frame_start + frame) * channels;
528 for ch in 0..vu_channels {
529 if idx + ch < samples.len() {
530 let s = samples[idx + ch] as f64;
531 sum_sq[ch] += s * s;
532 }
533 }
534 }
535
536 let db_range = DB_CEIL - DB_FLOOR;
537 for (ch, &sq) in sum_sq.iter().enumerate().take(vu_channels) {
538 let rms = (sq / frames_to_use as f64).sqrt() as f32;
539 let db = if rms > 0.0 {
540 20.0 * rms.log10()
541 } else {
542 DB_FLOOR
543 };
544 self.vu_levels[ch] = ((db - DB_FLOOR) / db_range).clamp(0.0, 1.0);
545 }
546
547 if vu_channels == 1 {
548 self.vu_levels[1] = self.vu_levels[0];
549 }
550 }
551}
552
553pub struct VizAnalyzer {
561 running: Arc<AtomicBool>,
562 snapshot: Arc<VizSnapshot>,
565 handle: Option<thread::JoinHandle<()>>,
566}
567
568impl VizAnalyzer {
569 pub fn spawn_with_snapshot(
577 viz_buffer: Arc<VizBuffer>,
578 cfg: &VisualizerConfig,
579 snapshot: Arc<VizSnapshot>,
580 samples_played: Arc<AtomicU64>,
581 ) -> Self {
582 let running = Arc::new(AtomicBool::new(true));
583
584 let scale = FrequencyScale::parse(&cfg.scale);
585 let amplitude_scale = AmplitudeScale::parse(&cfg.amplitude_scale);
586 let bar_half_life = cfg.bar_decay_ms as f32 / 1000.0;
587 let peak_half_life = cfg.peak_decay_ms as f32 / 1000.0;
588 snapshot.set_fps(cfg.fps);
591
592 let running_clone = Arc::clone(&running);
593 let snapshot_clone = Arc::clone(&snapshot);
594
595 let handle = thread::Builder::new()
596 .name("viz-analyzer".into())
597 .spawn(move || {
598 analysis_loop(
599 viz_buffer,
600 snapshot_clone,
601 samples_played,
602 running_clone,
603 scale,
604 amplitude_scale,
605 bar_half_life,
606 peak_half_life,
607 );
608 })
609 .expect("failed to spawn viz-analyzer thread");
610
611 Self {
612 running,
613 snapshot,
614 handle: Some(handle),
615 }
616 }
617
618 pub fn shutdown(&mut self) {
620 self.running.store(false, Ordering::Relaxed);
621 self.snapshot.wake();
624 if let Some(h) = self.handle.take() {
625 let _ = h.join();
626 }
627 }
628}
629
630impl Drop for VizAnalyzer {
631 fn drop(&mut self) {
632 self.shutdown();
633 }
634}
635
636const WINDOW_FRAMES: usize = if FFT_SIZE > WAVEFORM_SAMPLES {
641 FFT_SIZE
642} else {
643 WAVEFORM_SAMPLES
644};
645
646#[allow(clippy::too_many_arguments)]
647fn analysis_loop(
648 viz_buffer: Arc<VizBuffer>,
649 snapshot: Arc<VizSnapshot>,
650 samples_played: Arc<AtomicU64>,
651 running: Arc<AtomicBool>,
652 scale: FrequencyScale,
653 amplitude_scale: AmplitudeScale,
654 bar_half_life: f32,
655 peak_half_life: f32,
656) {
657 let mut state = AnalysisState::new(scale, bar_half_life, peak_half_life, amplitude_scale);
658 let mut snap = RawVizSnapshot::default();
659 let mut last_reads = u64::MAX;
660 let mut last_read_at = Instant::now();
661 let mut last_played = u64::MAX;
662
663 while running.load(Ordering::Relaxed) {
664 let start = Instant::now();
665
666 let reads = snapshot.reads();
677 if reads != last_reads {
678 last_reads = reads;
679 last_read_at = start;
680 } else if start.duration_since(last_read_at) > IDLE_AFTER {
681 snapshot.park_while_idle(|| snapshot.reads() == last_reads);
682 last_read_at = Instant::now();
683 continue;
684 }
685
686 let played = samples_played.load(Ordering::Relaxed);
692 let heard = played != last_played;
693 last_played = played;
694
695 if !heard {
696 if state.is_silent() {
697 thread::sleep(snapshot.interval());
700 continue;
701 }
702 state.decay_silence();
703 if state.is_silent() {
704 state.silence();
705 }
706 } else {
707 viz_buffer.snapshot_at(played, WINDOW_FRAMES, &mut snap);
708
709 state.analyze(
711 &snap.samples,
712 snap.channels.max(1) as usize,
713 snap.sample_rate as f32,
714 );
715 }
716
717 let interleaved_len = WAVEFORM_SAMPLES * snap.channels.max(1) as usize;
721 let waveform_start = snap.samples.len().saturating_sub(interleaved_len);
722 snapshot.write(VizFrame {
723 spectrum: state.spectrum,
724 peaks: state.peaks,
725 vu_levels: state.vu_levels,
726 beat_energy: state.beat_energy,
727 timestamp: Instant::now(),
728 waveform: snap.samples[waveform_start..].to_vec(),
729 });
730
731 let interval = snapshot.interval();
735 let elapsed = start.elapsed();
736 if elapsed < interval {
737 thread::sleep(interval - elapsed);
738 }
739 }
740}
741
742#[cfg(test)]
745mod tests {
746 use super::*;
747 use crate::audio::viz::VizBuffer;
748 use crate::config::VisualizerConfig;
749
750 fn make_cfg() -> VisualizerConfig {
751 VisualizerConfig::default()
752 }
753
754 fn sine(frames: usize, freq: f32, amplitude: f32, sample_rate: u32) -> Vec<f32> {
756 let mut samples = Vec::with_capacity(frames * 2);
757 for i in 0..frames {
758 let t = i as f32 / sample_rate as f32;
759 let val = (2.0 * std::f32::consts::PI * freq * t).sin() * amplitude;
760 samples.push(val);
761 samples.push(val);
762 }
763 samples
764 }
765
766 fn spawn_analyzer(
767 buf: Arc<VizBuffer>,
768 cfg: &VisualizerConfig,
769 played: u64,
770 ) -> (VizAnalyzer, Arc<VizSnapshot>) {
771 let snapshot = VizSnapshot::new();
772 let analyzer = VizAnalyzer::spawn_with_snapshot(
773 buf,
774 cfg,
775 Arc::clone(&snapshot),
776 Arc::new(AtomicU64::new(played)),
777 );
778 (analyzer, snapshot)
779 }
780
781 #[test]
782 fn analyzer_spawns_and_shuts_down() {
783 let buf = VizBuffer::new();
784 let cfg = make_cfg();
785 let (mut analyzer, snapshot) = spawn_analyzer(buf, &cfg, 0);
786 std::thread::sleep(Duration::from_millis(100));
788 analyzer.shutdown();
789 let frame = snapshot.read();
791 assert_eq!(frame.spectrum.len(), NUM_BARS);
792 assert_eq!(frame.peaks.len(), NUM_BARS);
793 }
794
795 #[test]
796 fn analyzer_produces_nonzero_output_for_sine() {
797 let buf = VizBuffer::new();
798 let sample_rate = 44100u32;
799 let samples = sine(4096, 440.0, 0.5, sample_rate);
800 buf.push_samples(&samples, 2, sample_rate);
801
802 let cfg = make_cfg();
803 let (mut analyzer, snapshot) = spawn_analyzer(Arc::clone(&buf), &cfg, samples.len() as u64);
805 std::thread::sleep(Duration::from_millis(150));
807
808 let frame = snapshot.read();
809 analyzer.shutdown();
810
811 let max_bar = frame.spectrum.iter().cloned().fold(0.0f32, f32::max);
812 assert!(
813 max_bar > 0.05,
814 "expected nonzero spectrum for 440 Hz sine, max = {}",
815 max_bar
816 );
817 }
818
819 #[test]
820 fn analyzer_reads_the_delay_line_at_the_play_head() {
821 let sample_rate = 44100u32;
822 let buf = VizBuffer::new();
823 buf.push_samples(&vec![0.0; sample_rate as usize * 2], 2, sample_rate);
825 buf.push_samples(&sine(4096, 440.0, 0.8, sample_rate), 2, sample_rate);
826
827 let cfg = make_cfg();
828 let (mut analyzer, snapshot) = spawn_analyzer(Arc::clone(&buf), &cfg, sample_rate as u64);
830 std::thread::sleep(Duration::from_millis(150));
831 let frame = snapshot.read();
832 analyzer.shutdown();
833
834 let max_bar = frame.spectrum.iter().cloned().fold(0.0f32, f32::max);
835 assert!(
836 max_bar < 0.05,
837 "visualizer showed audio the DAC has not reached yet, max = {}",
838 max_bar
839 );
840 }
841
842 #[test]
843 fn full_scale_sine_reads_zero_db() {
844 let mut state =
848 AnalysisState::new(FrequencyScale::Bark, 0.08, 0.35, AmplitudeScale::Linear);
849 let sample_rate = 44100.0;
850 let freq = 46.0 * sample_rate / FFT_SIZE as f32;
851 let samples = sine(FFT_SIZE, freq, 1.0, sample_rate as u32);
852
853 state.analyze(&samples, 2, sample_rate);
854
855 let max_bar = state.spectrum.iter().cloned().fold(0.0f32, f32::max);
857 assert!(
858 max_bar > 0.98,
859 "full-scale sine should reach the top of the widget, got {}",
860 max_bar
861 );
862 }
863
864 #[test]
865 fn bark_bars_go_unmapped_at_high_sample_rates() {
866 let mapping = build_bin_to_bar(192_000.0, FrequencyScale::Bark);
870 let mut counts = [0u32; NUM_BARS];
871 for bar in mapping.iter().flatten() {
872 counts[*bar] += 1;
873 }
874 assert_eq!(
875 counts[0], 0,
876 "no bin reaches the lowest Bark bar at 192 kHz"
877 );
878 assert!(
879 counts.iter().filter(|&&c| c == 0).count() > 3,
880 "expected several unmapped bass bars, got {:?}",
881 counts
882 );
883 }
884
885 #[test]
886 fn empty_bars_interpolate_without_sawtooth() {
887 let mut state =
888 AnalysisState::new(FrequencyScale::Bark, 0.08, 0.35, AmplitudeScale::Linear);
889 for (n, &bar) in [1usize, 4, 6, 8].iter().enumerate() {
891 state.bar_counts[bar] = 1;
892 state.spectrum[bar] = 0.2 + 0.1 * n as f32;
893 }
894 for bar in 9..NUM_BARS {
895 state.bar_counts[bar] = 1;
896 state.spectrum[bar] = 0.5;
897 }
898
899 state.fill_empty_bars();
900
901 assert!((state.spectrum[0] - state.spectrum[1]).abs() < 1e-6);
904 for i in 0..8 {
905 assert!(
906 state.spectrum[i + 1] >= state.spectrum[i] - 1e-6,
907 "sawtooth across interpolated bass: {:?}",
908 &state.spectrum[..9]
909 );
910 }
911 }
912
913 #[test]
914 fn analysis_state_decays_to_zero_on_silence() {
915 let mut state =
918 AnalysisState::new(FrequencyScale::Bark, 0.08, 0.35, AmplitudeScale::Linear);
919
920 for v in state.spectrum.iter_mut() {
922 *v = 1.0;
923 }
924 for v in state.peaks.iter_mut() {
925 *v = 1.0;
926 }
927
928 let silence: Vec<f32> = vec![0.0; FFT_SIZE * 2];
933 for _ in 0..100 {
934 state.last_update = Instant::now() - Duration::from_millis(100);
935 state.analyze(&silence, 2, 44100.0);
936 }
937
938 let max_spec = state.spectrum.iter().cloned().fold(0.0f32, f32::max);
939 let max_peak = state.peaks.iter().cloned().fold(0.0f32, f32::max);
940 assert!(
941 max_spec < 0.1,
942 "spectrum should decay near zero, got {}",
943 max_spec
944 );
945 assert!(
946 max_peak < 0.1,
947 "peaks should decay near zero, got {}",
948 max_peak
949 );
950 }
951
952 #[test]
953 fn bin_to_bar_covers_audible_range() {
954 let mapping = build_bin_to_bar(44100.0, FrequencyScale::Bark);
955 let active_bins: Vec<usize> = mapping.iter().filter_map(|x| *x).collect();
956 assert!(
957 !active_bins.is_empty(),
958 "at least some bins should map to bars"
959 );
960 let max_bar = *active_bins.iter().max().unwrap();
961 assert!(max_bar < NUM_BARS, "bar index must be in range");
962 }
963
964 #[test]
965 fn frequency_scale_bark_normalize_monotonic() {
966 let scale = FrequencyScale::Bark;
967 let freqs: Vec<f32> = vec![100.0, 500.0, 1000.0, 4000.0, 10000.0];
968 let normed: Vec<f32> = freqs.iter().map(|&f| scale.normalize(f)).collect();
969 for w in normed.windows(2) {
970 assert!(w[1] > w[0], "Bark scale must be monotonically increasing");
971 }
972 }
973}