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 IDLE_POLL: Duration = Duration::from_millis(250);
53
54#[derive(Debug, Clone, Copy, Default)]
58pub enum FrequencyScale {
59 #[default]
61 Bark,
62 Mel,
64 Log,
66 Linear,
68}
69
70impl FrequencyScale {
71 pub fn parse(s: &str) -> Self {
72 match s.to_lowercase().as_str() {
73 "bark" => Self::Bark,
74 "mel" => Self::Mel,
75 "log" | "logarithmic" => Self::Log,
76 "linear" => Self::Linear,
77 _ => Self::default(),
78 }
79 }
80
81 fn normalize(&self, freq: f32) -> f32 {
83 match self {
84 Self::Bark => {
85 let bark = |f: f32| 26.81 / (1.0 + 1960.0 / f) - 0.53;
86 let b = bark(freq);
87 let b_min = bark(MIN_FREQ);
88 let b_max = bark(MAX_FREQ);
89 (b - b_min) / (b_max - b_min)
90 }
91 Self::Mel => {
92 let mel = |f: f32| 2595.0 * (1.0 + f / 700.0).log10();
93 let m = mel(freq);
94 let m_min = mel(MIN_FREQ);
95 let m_max = mel(MAX_FREQ);
96 (m - m_min) / (m_max - m_min)
97 }
98 Self::Log => {
99 let log_min = MIN_FREQ.ln();
100 let log_max = MAX_FREQ.ln();
101 (freq.ln() - log_min) / (log_max - log_min)
102 }
103 Self::Linear => (freq - MIN_FREQ) / (MAX_FREQ - MIN_FREQ),
104 }
105 }
106}
107
108#[derive(Debug, Clone, Copy, Default)]
112pub enum AmplitudeScale {
113 Perceptual,
115 #[default]
117 AWeight,
118 Sqrt,
120 Linear,
122}
123
124impl AmplitudeScale {
125 pub fn parse(s: &str) -> Self {
126 match s.to_lowercase().as_str() {
127 "perceptual" => Self::Perceptual,
128 "aweight" | "a-weight" | "a_weight" => Self::AWeight,
129 "sqrt" => Self::Sqrt,
130 "linear" => Self::Linear,
131 _ => Self::default(),
132 }
133 }
134
135 fn apply(self, level: f32) -> f32 {
137 match self {
138 Self::Perceptual => level.powf(0.4),
139 Self::AWeight => level,
140 Self::Sqrt => level.sqrt(),
141 Self::Linear => level,
142 }
143 }
144}
145
146fn a_weight_db(freq: f32) -> f32 {
151 let f2 = freq * freq;
152 let f4 = f2 * f2;
153
154 let num = 12194.0_f32.powi(2) * f4;
155 let denom = (f2 + 20.6_f32.powi(2))
156 * ((f2 + 107.7_f32.powi(2)) * (f2 + 737.9_f32.powi(2))).sqrt()
157 * (f2 + 12194.0_f32.powi(2));
158
159 if denom == 0.0 {
160 return DB_FLOOR;
161 }
162
163 let ra = num / denom;
165 20.0 * ra.log10() + 2.0
167}
168
169fn build_a_weight_table(sample_rate: f32) -> Vec<f32> {
171 let bin_hz = sample_rate / FFT_SIZE as f32;
172 let num_bins = FFT_SIZE / 2 + 1;
173 (0..num_bins)
174 .map(|bin_idx| {
175 let freq = bin_idx as f32 * bin_hz;
176 if freq < 1.0 {
177 DB_FLOOR } else {
179 a_weight_db(freq)
180 }
181 })
182 .collect()
183}
184
185fn hann_window() -> Vec<f32> {
189 (0..FFT_SIZE)
190 .map(|i| {
191 let t = std::f32::consts::PI * 2.0 * i as f32 / FFT_SIZE as f32;
192 0.5 * (1.0 - t.cos())
193 })
194 .collect()
195}
196
197fn build_bin_to_bar(sample_rate: f32, scale: FrequencyScale) -> Vec<Option<usize>> {
200 let bin_hz = sample_rate / FFT_SIZE as f32;
201 let num_bins = FFT_SIZE / 2 + 1;
202 (0..num_bins)
203 .map(|bin_idx| {
204 let freq = bin_idx as f32 * bin_hz;
205 if !(MIN_FREQ..=MAX_FREQ).contains(&freq) {
206 return None;
207 }
208 let normalized = scale.normalize(freq);
209 Some(((normalized * NUM_BARS as f32) as usize).min(NUM_BARS - 1))
210 })
211 .collect()
212}
213
214struct AnalysisState {
218 window: Vec<f32>,
220 fft_norm: f32,
224 fft_input: Vec<f32>,
226 fft_output: Vec<realfft::num_complex::Complex<f32>>,
228 fft: Arc<dyn realfft::RealToComplex<f32>>,
230 bin_to_bar: Vec<Option<usize>>,
232 last_sample_rate: f32,
234 bar_counts: [u32; NUM_BARS],
236 prev_spectrum: [f32; NUM_BARS],
238 spectrum: [f32; NUM_BARS],
240 peaks: [f32; NUM_BARS],
242 vu_levels: [f32; 2],
244 last_update: Instant,
246 scale: FrequencyScale,
248 bar_half_life: f32,
250 peak_half_life: f32,
252 amplitude_scale: AmplitudeScale,
254 a_weight_table: Vec<f32>,
256 beat_avg: f32,
259 beat_energy: f32,
261}
262
263impl AnalysisState {
264 fn new(
265 scale: FrequencyScale,
266 bar_half_life: f32,
267 peak_half_life: f32,
268 amplitude_scale: AmplitudeScale,
269 ) -> Self {
270 let mut planner = RealFftPlanner::<f32>::new();
271 let fft = planner.plan_fft_forward(FFT_SIZE);
272 let fft_input = fft.make_input_vec();
273 let fft_output = fft.make_output_vec();
274 let window = hann_window();
275 let fft_norm = 2.0 / window.iter().sum::<f32>();
276 Self {
277 window,
278 fft_norm,
279 fft_input,
280 fft_output,
281 fft,
282 bin_to_bar: Vec::new(),
283 last_sample_rate: 0.0,
284 bar_counts: [0u32; NUM_BARS],
285 prev_spectrum: [0.0; NUM_BARS],
286 spectrum: [0.0; NUM_BARS],
287 peaks: [0.0; NUM_BARS],
288 vu_levels: [0.0; 2],
289 last_update: Instant::now(),
290 scale,
291 bar_half_life,
292 peak_half_life,
293 amplitude_scale,
294 a_weight_table: Vec::new(),
295 beat_avg: 0.0,
296 beat_energy: 0.0,
297 }
298 }
299
300 fn decay_factors(&mut self) -> (f32, f32) {
302 let now = Instant::now();
303 let dt = now.duration_since(self.last_update).as_secs_f32();
304 self.last_update = now;
305 let bar_decay = 0.5f32.powf(dt / self.bar_half_life);
306 let peak_decay = 0.5f32.powf(dt / self.peak_half_life);
307 (bar_decay, peak_decay)
308 }
309
310 fn analyze(&mut self, samples: &[f32], channels: usize, sample_rate: f32) {
314 if samples.is_empty() || sample_rate <= 0.0 || channels == 0 {
315 self.decay_silence();
316 return;
317 }
318
319 self.compute_vu(samples, channels);
321
322 let total_frames = samples.len() / channels;
324 let frames_to_use = total_frames.min(FFT_SIZE);
325 let frame_start = total_frames - frames_to_use;
326
327 for i in 0..FFT_SIZE {
328 if i < frames_to_use {
329 let frame_idx = frame_start + i;
330 let sample_start = frame_idx * channels;
331 let mut sum = 0.0f32;
332 for ch in 0..channels {
333 if sample_start + ch < samples.len() {
334 sum += samples[sample_start + ch];
335 }
336 }
337 self.fft_input[i] = (sum / channels as f32) * self.window[i];
338 } else {
339 self.fft_input[i] = 0.0;
340 }
341 }
342
343 if self
345 .fft
346 .process(&mut self.fft_input, &mut self.fft_output)
347 .is_err()
348 {
349 self.decay_silence();
350 return;
351 }
352
353 if (sample_rate - self.last_sample_rate).abs() > 0.5 {
355 self.bin_to_bar = build_bin_to_bar(sample_rate, self.scale);
356 self.a_weight_table = build_a_weight_table(sample_rate);
357 self.last_sample_rate = sample_rate;
358 }
359
360 std::mem::swap(&mut self.spectrum, &mut self.prev_spectrum);
362 for bar in self.spectrum.iter_mut() {
363 *bar = 0.0;
364 }
365 for c in self.bar_counts.iter_mut() {
366 *c = 0;
367 }
368
369 let norm = self.fft_norm;
370 let db_range_inv = 1.0 / (DB_CEIL - DB_FLOOR);
371 let num_bins = self.fft_output.len().min(self.bin_to_bar.len());
372
373 for bin_idx in 0..num_bins {
374 let bar_idx = match self.bin_to_bar[bin_idx] {
375 Some(b) => b,
376 None => continue,
377 };
378 let c = self.fft_output[bin_idx];
379 let magnitude = (c.re * c.re + c.im * c.im).sqrt() * norm;
380 let mut db = if magnitude > 0.0 {
381 20.0 * magnitude.log10()
382 } else {
383 DB_FLOOR
384 };
385 if matches!(
387 self.amplitude_scale,
388 AmplitudeScale::Perceptual | AmplitudeScale::AWeight
389 ) && let Some(&aw) = self.a_weight_table.get(bin_idx)
390 {
391 db += aw;
392 }
393 let level = ((db - DB_FLOOR) * db_range_inv).clamp(0.0, 1.0);
394 let level = self.amplitude_scale.apply(level);
395 if level > self.spectrum[bar_idx] {
396 self.spectrum[bar_idx] = level;
397 }
398 self.bar_counts[bar_idx] += 1;
399 }
400
401 self.fill_empty_bars();
402
403 let (bar_decay, peak_decay) = self.decay_factors();
405 for i in 0..NUM_BARS {
406 let decayed = self.prev_spectrum[i] * bar_decay;
407 self.spectrum[i] = self.spectrum[i].max(decayed);
408
409 if self.spectrum[i] > self.peaks[i] {
410 self.peaks[i] = self.spectrum[i];
411 } else {
412 self.peaks[i] *= peak_decay;
413 }
414 }
415
416 let beat_bands = NUM_BARS.min(6);
419 let low_energy: f32 = self.spectrum[..beat_bands].iter().sum::<f32>() / beat_bands as f32;
420
421 const BEAT_AVG_ALPHA: f32 = 0.02;
424 self.beat_avg = self.beat_avg * (1.0 - BEAT_AVG_ALPHA) + low_energy * BEAT_AVG_ALPHA;
425
426 let beat_spike = if self.beat_avg > 0.005 {
429 let excess = (low_energy - self.beat_avg).max(0.0);
430 (excess / self.beat_avg.max(0.05)).clamp(0.0, 1.0)
431 } else {
432 (low_energy * 3.0).clamp(0.0, 1.0)
434 };
435
436 self.beat_energy = beat_spike.max(self.beat_energy * bar_decay.sqrt());
439 }
440
441 fn fill_empty_bars(&mut self) {
448 let mut i = 0;
449 while i < NUM_BARS {
450 if self.bar_counts[i] != 0 {
451 i += 1;
452 continue;
453 }
454 let mut end = i;
455 while end < NUM_BARS && self.bar_counts[end] == 0 {
456 end += 1;
457 }
458
459 match (i.checked_sub(1), (end < NUM_BARS).then_some(end)) {
460 (Some(left), Some(right)) => {
461 let (lo, hi) = (self.spectrum[left], self.spectrum[right]);
462 let span = (right - left) as f32;
463 for (n, bar) in (i..end).enumerate() {
464 let t = (n + 1) as f32 / span;
465 self.spectrum[bar] = lo + (hi - lo) * t;
466 }
467 }
468 (Some(left), None) => {
471 let value = self.spectrum[left];
472 self.spectrum[i..end].fill(value);
473 }
474 (None, Some(right)) => {
475 let value = self.spectrum[right];
476 self.spectrum[i..end].fill(value);
477 }
478 (None, None) => self.spectrum.fill(0.0),
479 }
480
481 i = end;
482 }
483 }
484
485 fn decay_silence(&mut self) {
487 let (bar_decay, peak_decay) = self.decay_factors();
488 for i in 0..NUM_BARS {
489 self.spectrum[i] *= bar_decay;
490 self.peaks[i] *= peak_decay;
491 }
492 for v in self.vu_levels.iter_mut() {
493 *v *= bar_decay;
494 }
495 self.beat_energy *= bar_decay;
496 }
497
498 fn compute_vu(&mut self, samples: &[f32], channels: usize) {
500 let total_frames = samples.len() / channels;
501 let frames_to_use = total_frames.min(2048);
502 let frame_start = total_frames - frames_to_use;
503 let vu_channels = channels.min(2);
504 let mut sum_sq = [0.0f64; 2];
505
506 for frame in 0..frames_to_use {
507 let idx = (frame_start + frame) * channels;
508 for ch in 0..vu_channels {
509 if idx + ch < samples.len() {
510 let s = samples[idx + ch] as f64;
511 sum_sq[ch] += s * s;
512 }
513 }
514 }
515
516 let db_range = DB_CEIL - DB_FLOOR;
517 for (ch, &sq) in sum_sq.iter().enumerate().take(vu_channels) {
518 let rms = (sq / frames_to_use as f64).sqrt() as f32;
519 let db = if rms > 0.0 {
520 20.0 * rms.log10()
521 } else {
522 DB_FLOOR
523 };
524 self.vu_levels[ch] = ((db - DB_FLOOR) / db_range).clamp(0.0, 1.0);
525 }
526
527 if vu_channels == 1 {
528 self.vu_levels[1] = self.vu_levels[0];
529 }
530 }
531}
532
533pub struct VizAnalyzer {
541 running: Arc<AtomicBool>,
542 handle: Option<thread::JoinHandle<()>>,
543}
544
545impl VizAnalyzer {
546 pub fn spawn_with_snapshot(
554 viz_buffer: Arc<VizBuffer>,
555 cfg: &VisualizerConfig,
556 snapshot: Arc<VizSnapshot>,
557 samples_played: Arc<AtomicU64>,
558 ) -> Self {
559 let running = Arc::new(AtomicBool::new(true));
560
561 let scale = FrequencyScale::parse(&cfg.scale);
562 let amplitude_scale = AmplitudeScale::parse(&cfg.amplitude_scale);
563 let bar_half_life = cfg.bar_decay_ms as f32 / 1000.0;
564 let peak_half_life = cfg.peak_decay_ms as f32 / 1000.0;
565 let interval = Duration::from_millis(1000 / cfg.fps.max(1) as u64);
566
567 let running_clone = Arc::clone(&running);
568
569 let handle = thread::Builder::new()
570 .name("viz-analyzer".into())
571 .spawn(move || {
572 analysis_loop(
573 viz_buffer,
574 snapshot,
575 samples_played,
576 running_clone,
577 scale,
578 amplitude_scale,
579 bar_half_life,
580 peak_half_life,
581 interval,
582 );
583 })
584 .expect("failed to spawn viz-analyzer thread");
585
586 Self {
587 running,
588 handle: Some(handle),
589 }
590 }
591
592 pub fn shutdown(&mut self) {
594 self.running.store(false, Ordering::Relaxed);
595 if let Some(h) = self.handle.take() {
596 let _ = h.join();
597 }
598 }
599}
600
601impl Drop for VizAnalyzer {
602 fn drop(&mut self) {
603 self.shutdown();
604 }
605}
606
607const WINDOW_FRAMES: usize = if FFT_SIZE > WAVEFORM_SAMPLES {
612 FFT_SIZE
613} else {
614 WAVEFORM_SAMPLES
615};
616
617#[allow(clippy::too_many_arguments)]
618fn analysis_loop(
619 viz_buffer: Arc<VizBuffer>,
620 snapshot: Arc<VizSnapshot>,
621 samples_played: Arc<AtomicU64>,
622 running: Arc<AtomicBool>,
623 scale: FrequencyScale,
624 amplitude_scale: AmplitudeScale,
625 bar_half_life: f32,
626 peak_half_life: f32,
627 interval: Duration,
628) {
629 let mut state = AnalysisState::new(scale, bar_half_life, peak_half_life, amplitude_scale);
630 let mut snap = RawVizSnapshot::default();
631 let mut last_reads = u64::MAX;
632 let mut last_read_at = Instant::now();
633
634 while running.load(Ordering::Relaxed) {
635 let start = Instant::now();
636
637 let reads = snapshot.reads();
643 if reads != last_reads {
644 last_reads = reads;
645 last_read_at = start;
646 } else if start.duration_since(last_read_at) > IDLE_AFTER {
647 thread::sleep(IDLE_POLL);
648 continue;
649 }
650
651 let played = samples_played.load(Ordering::Relaxed);
653 viz_buffer.snapshot_at(played, WINDOW_FRAMES, &mut snap);
654
655 state.analyze(
657 &snap.samples,
658 snap.channels.max(1) as usize,
659 snap.sample_rate as f32,
660 );
661
662 let interleaved_len = WAVEFORM_SAMPLES * snap.channels.max(1) as usize;
666 let waveform_start = snap.samples.len().saturating_sub(interleaved_len);
667 snapshot.write(VizFrame {
668 spectrum: state.spectrum,
669 peaks: state.peaks,
670 vu_levels: state.vu_levels,
671 beat_energy: state.beat_energy,
672 timestamp: Instant::now(),
673 waveform: snap.samples[waveform_start..].to_vec(),
674 });
675
676 let elapsed = start.elapsed();
678 if elapsed < interval {
679 thread::sleep(interval - elapsed);
680 }
681 }
682}
683
684#[cfg(test)]
687mod tests {
688 use super::*;
689 use crate::audio::viz::VizBuffer;
690 use crate::config::VisualizerConfig;
691
692 fn make_cfg() -> VisualizerConfig {
693 VisualizerConfig::default()
694 }
695
696 fn sine(frames: usize, freq: f32, amplitude: f32, sample_rate: u32) -> Vec<f32> {
698 let mut samples = Vec::with_capacity(frames * 2);
699 for i in 0..frames {
700 let t = i as f32 / sample_rate as f32;
701 let val = (2.0 * std::f32::consts::PI * freq * t).sin() * amplitude;
702 samples.push(val);
703 samples.push(val);
704 }
705 samples
706 }
707
708 fn spawn_analyzer(
709 buf: Arc<VizBuffer>,
710 cfg: &VisualizerConfig,
711 played: u64,
712 ) -> (VizAnalyzer, Arc<VizSnapshot>) {
713 let snapshot = VizSnapshot::new();
714 let analyzer = VizAnalyzer::spawn_with_snapshot(
715 buf,
716 cfg,
717 Arc::clone(&snapshot),
718 Arc::new(AtomicU64::new(played)),
719 );
720 (analyzer, snapshot)
721 }
722
723 #[test]
724 fn analyzer_spawns_and_shuts_down() {
725 let buf = VizBuffer::new();
726 let cfg = make_cfg();
727 let (mut analyzer, snapshot) = spawn_analyzer(buf, &cfg, 0);
728 std::thread::sleep(Duration::from_millis(100));
730 analyzer.shutdown();
731 let frame = snapshot.read();
733 assert_eq!(frame.spectrum.len(), NUM_BARS);
734 assert_eq!(frame.peaks.len(), NUM_BARS);
735 }
736
737 #[test]
738 fn analyzer_produces_nonzero_output_for_sine() {
739 let buf = VizBuffer::new();
740 let sample_rate = 44100u32;
741 let samples = sine(4096, 440.0, 0.5, sample_rate);
742 buf.push_samples(&samples, 2, sample_rate);
743
744 let cfg = make_cfg();
745 let (mut analyzer, snapshot) = spawn_analyzer(Arc::clone(&buf), &cfg, samples.len() as u64);
747 std::thread::sleep(Duration::from_millis(150));
749
750 let frame = snapshot.read();
751 analyzer.shutdown();
752
753 let max_bar = frame.spectrum.iter().cloned().fold(0.0f32, f32::max);
754 assert!(
755 max_bar > 0.05,
756 "expected nonzero spectrum for 440 Hz sine, max = {}",
757 max_bar
758 );
759 }
760
761 #[test]
762 fn analyzer_reads_the_delay_line_at_the_play_head() {
763 let sample_rate = 44100u32;
764 let buf = VizBuffer::new();
765 buf.push_samples(&vec![0.0; sample_rate as usize * 2], 2, sample_rate);
767 buf.push_samples(&sine(4096, 440.0, 0.8, sample_rate), 2, sample_rate);
768
769 let cfg = make_cfg();
770 let (mut analyzer, snapshot) = spawn_analyzer(Arc::clone(&buf), &cfg, sample_rate as u64);
772 std::thread::sleep(Duration::from_millis(150));
773 let frame = snapshot.read();
774 analyzer.shutdown();
775
776 let max_bar = frame.spectrum.iter().cloned().fold(0.0f32, f32::max);
777 assert!(
778 max_bar < 0.05,
779 "visualizer showed audio the DAC has not reached yet, max = {}",
780 max_bar
781 );
782 }
783
784 #[test]
785 fn full_scale_sine_reads_zero_db() {
786 let mut state =
790 AnalysisState::new(FrequencyScale::Bark, 0.08, 0.35, AmplitudeScale::Linear);
791 let sample_rate = 44100.0;
792 let freq = 46.0 * sample_rate / FFT_SIZE as f32;
793 let samples = sine(FFT_SIZE, freq, 1.0, sample_rate as u32);
794
795 state.analyze(&samples, 2, sample_rate);
796
797 let max_bar = state.spectrum.iter().cloned().fold(0.0f32, f32::max);
799 assert!(
800 max_bar > 0.98,
801 "full-scale sine should reach the top of the widget, got {}",
802 max_bar
803 );
804 }
805
806 #[test]
807 fn bark_bars_go_unmapped_at_high_sample_rates() {
808 let mapping = build_bin_to_bar(192_000.0, FrequencyScale::Bark);
812 let mut counts = [0u32; NUM_BARS];
813 for bar in mapping.iter().flatten() {
814 counts[*bar] += 1;
815 }
816 assert_eq!(
817 counts[0], 0,
818 "no bin reaches the lowest Bark bar at 192 kHz"
819 );
820 assert!(
821 counts.iter().filter(|&&c| c == 0).count() > 3,
822 "expected several unmapped bass bars, got {:?}",
823 counts
824 );
825 }
826
827 #[test]
828 fn empty_bars_interpolate_without_sawtooth() {
829 let mut state =
830 AnalysisState::new(FrequencyScale::Bark, 0.08, 0.35, AmplitudeScale::Linear);
831 for (n, &bar) in [1usize, 4, 6, 8].iter().enumerate() {
833 state.bar_counts[bar] = 1;
834 state.spectrum[bar] = 0.2 + 0.1 * n as f32;
835 }
836 for bar in 9..NUM_BARS {
837 state.bar_counts[bar] = 1;
838 state.spectrum[bar] = 0.5;
839 }
840
841 state.fill_empty_bars();
842
843 assert!((state.spectrum[0] - state.spectrum[1]).abs() < 1e-6);
846 for i in 0..8 {
847 assert!(
848 state.spectrum[i + 1] >= state.spectrum[i] - 1e-6,
849 "sawtooth across interpolated bass: {:?}",
850 &state.spectrum[..9]
851 );
852 }
853 }
854
855 #[test]
856 fn analysis_state_decays_to_zero_on_silence() {
857 let mut state =
860 AnalysisState::new(FrequencyScale::Bark, 0.08, 0.35, AmplitudeScale::Linear);
861
862 for v in state.spectrum.iter_mut() {
864 *v = 1.0;
865 }
866 for v in state.peaks.iter_mut() {
867 *v = 1.0;
868 }
869
870 let silence: Vec<f32> = vec![0.0; FFT_SIZE * 2];
875 for _ in 0..100 {
876 state.last_update = Instant::now() - Duration::from_millis(100);
877 state.analyze(&silence, 2, 44100.0);
878 }
879
880 let max_spec = state.spectrum.iter().cloned().fold(0.0f32, f32::max);
881 let max_peak = state.peaks.iter().cloned().fold(0.0f32, f32::max);
882 assert!(
883 max_spec < 0.1,
884 "spectrum should decay near zero, got {}",
885 max_spec
886 );
887 assert!(
888 max_peak < 0.1,
889 "peaks should decay near zero, got {}",
890 max_peak
891 );
892 }
893
894 #[test]
895 fn bin_to_bar_covers_audible_range() {
896 let mapping = build_bin_to_bar(44100.0, FrequencyScale::Bark);
897 let active_bins: Vec<usize> = mapping.iter().filter_map(|x| *x).collect();
898 assert!(
899 !active_bins.is_empty(),
900 "at least some bins should map to bars"
901 );
902 let max_bar = *active_bins.iter().max().unwrap();
903 assert!(max_bar < NUM_BARS, "bar index must be in range");
904 }
905
906 #[test]
907 fn frequency_scale_bark_normalize_monotonic() {
908 let scale = FrequencyScale::Bark;
909 let freqs: Vec<f32> = vec![100.0, 500.0, 1000.0, 4000.0, 10000.0];
910 let normed: Vec<f32> = freqs.iter().map(|&f| scale.normalize(f)).collect();
911 for w in normed.windows(2) {
912 assert!(w[1] > w[0], "Bark scale must be monotonically increasing");
913 }
914 }
915}