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