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
47#[derive(Debug, Clone, Copy, Default)]
51pub enum FrequencyScale {
52 #[default]
54 Bark,
55 Mel,
57 Log,
59 Linear,
61}
62
63impl FrequencyScale {
64 pub fn parse(s: &str) -> Self {
65 match s.to_lowercase().as_str() {
66 "bark" => Self::Bark,
67 "mel" => Self::Mel,
68 "log" | "logarithmic" => Self::Log,
69 "linear" => Self::Linear,
70 _ => Self::default(),
71 }
72 }
73
74 fn normalize(&self, freq: f32) -> f32 {
76 match self {
77 Self::Bark => {
78 let bark = |f: f32| 26.81 / (1.0 + 1960.0 / f) - 0.53;
79 let b = bark(freq);
80 let b_min = bark(MIN_FREQ);
81 let b_max = bark(MAX_FREQ);
82 (b - b_min) / (b_max - b_min)
83 }
84 Self::Mel => {
85 let mel = |f: f32| 2595.0 * (1.0 + f / 700.0).log10();
86 let m = mel(freq);
87 let m_min = mel(MIN_FREQ);
88 let m_max = mel(MAX_FREQ);
89 (m - m_min) / (m_max - m_min)
90 }
91 Self::Log => {
92 let log_min = MIN_FREQ.ln();
93 let log_max = MAX_FREQ.ln();
94 (freq.ln() - log_min) / (log_max - log_min)
95 }
96 Self::Linear => (freq - MIN_FREQ) / (MAX_FREQ - MIN_FREQ),
97 }
98 }
99}
100
101#[derive(Debug, Clone, Copy, Default)]
105pub enum AmplitudeScale {
106 Perceptual,
108 #[default]
110 AWeight,
111 Sqrt,
113 Linear,
115}
116
117impl AmplitudeScale {
118 pub fn parse(s: &str) -> Self {
119 match s.to_lowercase().as_str() {
120 "perceptual" => Self::Perceptual,
121 "aweight" | "a-weight" | "a_weight" => Self::AWeight,
122 "sqrt" => Self::Sqrt,
123 "linear" => Self::Linear,
124 _ => Self::default(),
125 }
126 }
127
128 fn apply(self, level: f32) -> f32 {
130 match self {
131 Self::Perceptual => level.powf(0.4),
132 Self::AWeight => level,
133 Self::Sqrt => level.sqrt(),
134 Self::Linear => level,
135 }
136 }
137}
138
139fn a_weight_db(freq: f32) -> f32 {
144 let f2 = freq * freq;
145 let f4 = f2 * f2;
146
147 let num = 12194.0_f32.powi(2) * f4;
148 let denom = (f2 + 20.6_f32.powi(2))
149 * ((f2 + 107.7_f32.powi(2)) * (f2 + 737.9_f32.powi(2))).sqrt()
150 * (f2 + 12194.0_f32.powi(2));
151
152 if denom == 0.0 {
153 return DB_FLOOR;
154 }
155
156 let ra = num / denom;
158 20.0 * ra.log10() + 2.0
160}
161
162fn build_a_weight_table(sample_rate: f32) -> Vec<f32> {
164 let bin_hz = sample_rate / FFT_SIZE as f32;
165 let num_bins = FFT_SIZE / 2 + 1;
166 (0..num_bins)
167 .map(|bin_idx| {
168 let freq = bin_idx as f32 * bin_hz;
169 if freq < 1.0 {
170 DB_FLOOR } else {
172 a_weight_db(freq)
173 }
174 })
175 .collect()
176}
177
178fn hann_window() -> Vec<f32> {
182 (0..FFT_SIZE)
183 .map(|i| {
184 let t = std::f32::consts::PI * 2.0 * i as f32 / FFT_SIZE as f32;
185 0.5 * (1.0 - t.cos())
186 })
187 .collect()
188}
189
190fn build_bin_to_bar(sample_rate: f32, scale: FrequencyScale) -> Vec<Option<usize>> {
193 let bin_hz = sample_rate / FFT_SIZE as f32;
194 let num_bins = FFT_SIZE / 2 + 1;
195 (0..num_bins)
196 .map(|bin_idx| {
197 let freq = bin_idx as f32 * bin_hz;
198 if !(MIN_FREQ..=MAX_FREQ).contains(&freq) {
199 return None;
200 }
201 let normalized = scale.normalize(freq);
202 Some(((normalized * NUM_BARS as f32) as usize).min(NUM_BARS - 1))
203 })
204 .collect()
205}
206
207struct AnalysisState {
211 window: Vec<f32>,
213 fft_norm: f32,
217 fft_input: Vec<f32>,
219 fft_output: Vec<realfft::num_complex::Complex<f32>>,
221 fft: Arc<dyn realfft::RealToComplex<f32>>,
223 bin_to_bar: Vec<Option<usize>>,
225 last_sample_rate: f32,
227 bar_counts: [u32; NUM_BARS],
229 prev_spectrum: [f32; NUM_BARS],
231 spectrum: [f32; NUM_BARS],
233 peaks: [f32; NUM_BARS],
235 vu_levels: [f32; 2],
237 last_update: Instant,
239 scale: FrequencyScale,
241 bar_half_life: f32,
243 peak_half_life: f32,
245 amplitude_scale: AmplitudeScale,
247 a_weight_table: Vec<f32>,
249 beat_avg: f32,
252 beat_energy: f32,
254}
255
256impl AnalysisState {
257 fn new(
258 scale: FrequencyScale,
259 bar_half_life: f32,
260 peak_half_life: f32,
261 amplitude_scale: AmplitudeScale,
262 ) -> Self {
263 let mut planner = RealFftPlanner::<f32>::new();
264 let fft = planner.plan_fft_forward(FFT_SIZE);
265 let fft_input = fft.make_input_vec();
266 let fft_output = fft.make_output_vec();
267 let window = hann_window();
268 let fft_norm = 2.0 / window.iter().sum::<f32>();
269 Self {
270 window,
271 fft_norm,
272 fft_input,
273 fft_output,
274 fft,
275 bin_to_bar: Vec::new(),
276 last_sample_rate: 0.0,
277 bar_counts: [0u32; NUM_BARS],
278 prev_spectrum: [0.0; NUM_BARS],
279 spectrum: [0.0; NUM_BARS],
280 peaks: [0.0; NUM_BARS],
281 vu_levels: [0.0; 2],
282 last_update: Instant::now(),
283 scale,
284 bar_half_life,
285 peak_half_life,
286 amplitude_scale,
287 a_weight_table: Vec::new(),
288 beat_avg: 0.0,
289 beat_energy: 0.0,
290 }
291 }
292
293 fn decay_factors(&mut self) -> (f32, f32) {
295 let now = Instant::now();
296 let dt = now.duration_since(self.last_update).as_secs_f32();
297 self.last_update = now;
298 let bar_decay = 0.5f32.powf(dt / self.bar_half_life);
299 let peak_decay = 0.5f32.powf(dt / self.peak_half_life);
300 (bar_decay, peak_decay)
301 }
302
303 fn analyze(&mut self, samples: &[f32], channels: usize, sample_rate: f32) {
307 if samples.is_empty() || sample_rate <= 0.0 || channels == 0 {
308 self.decay_silence();
309 return;
310 }
311
312 self.compute_vu(samples, channels);
314
315 let total_frames = samples.len() / channels;
317 let frames_to_use = total_frames.min(FFT_SIZE);
318 let frame_start = total_frames - frames_to_use;
319
320 for i in 0..FFT_SIZE {
321 if i < frames_to_use {
322 let frame_idx = frame_start + i;
323 let sample_start = frame_idx * channels;
324 let mut sum = 0.0f32;
325 for ch in 0..channels {
326 if sample_start + ch < samples.len() {
327 sum += samples[sample_start + ch];
328 }
329 }
330 self.fft_input[i] = (sum / channels as f32) * self.window[i];
331 } else {
332 self.fft_input[i] = 0.0;
333 }
334 }
335
336 if self
338 .fft
339 .process(&mut self.fft_input, &mut self.fft_output)
340 .is_err()
341 {
342 self.decay_silence();
343 return;
344 }
345
346 if (sample_rate - self.last_sample_rate).abs() > 0.5 {
348 self.bin_to_bar = build_bin_to_bar(sample_rate, self.scale);
349 self.a_weight_table = build_a_weight_table(sample_rate);
350 self.last_sample_rate = sample_rate;
351 }
352
353 std::mem::swap(&mut self.spectrum, &mut self.prev_spectrum);
355 for bar in self.spectrum.iter_mut() {
356 *bar = 0.0;
357 }
358 for c in self.bar_counts.iter_mut() {
359 *c = 0;
360 }
361
362 let norm = self.fft_norm;
363 let db_range_inv = 1.0 / (DB_CEIL - DB_FLOOR);
364 let num_bins = self.fft_output.len().min(self.bin_to_bar.len());
365
366 for bin_idx in 0..num_bins {
367 let bar_idx = match self.bin_to_bar[bin_idx] {
368 Some(b) => b,
369 None => continue,
370 };
371 let c = self.fft_output[bin_idx];
372 let magnitude = (c.re * c.re + c.im * c.im).sqrt() * norm;
373 let mut db = if magnitude > 0.0 {
374 20.0 * magnitude.log10()
375 } else {
376 DB_FLOOR
377 };
378 if matches!(
380 self.amplitude_scale,
381 AmplitudeScale::Perceptual | AmplitudeScale::AWeight
382 ) && let Some(&aw) = self.a_weight_table.get(bin_idx)
383 {
384 db += aw;
385 }
386 let level = ((db - DB_FLOOR) * db_range_inv).clamp(0.0, 1.0);
387 let level = self.amplitude_scale.apply(level);
388 if level > self.spectrum[bar_idx] {
389 self.spectrum[bar_idx] = level;
390 }
391 self.bar_counts[bar_idx] += 1;
392 }
393
394 self.fill_empty_bars();
395
396 let (bar_decay, peak_decay) = self.decay_factors();
398 for i in 0..NUM_BARS {
399 let decayed = self.prev_spectrum[i] * bar_decay;
400 self.spectrum[i] = self.spectrum[i].max(decayed);
401
402 if self.spectrum[i] > self.peaks[i] {
403 self.peaks[i] = self.spectrum[i];
404 } else {
405 self.peaks[i] *= peak_decay;
406 }
407 }
408
409 let beat_bands = NUM_BARS.min(6);
412 let low_energy: f32 = self.spectrum[..beat_bands].iter().sum::<f32>() / beat_bands as f32;
413
414 const BEAT_AVG_ALPHA: f32 = 0.02;
417 self.beat_avg = self.beat_avg * (1.0 - BEAT_AVG_ALPHA) + low_energy * BEAT_AVG_ALPHA;
418
419 let beat_spike = if self.beat_avg > 0.005 {
422 let excess = (low_energy - self.beat_avg).max(0.0);
423 (excess / self.beat_avg.max(0.05)).clamp(0.0, 1.0)
424 } else {
425 (low_energy * 3.0).clamp(0.0, 1.0)
427 };
428
429 self.beat_energy = beat_spike.max(self.beat_energy * bar_decay.sqrt());
432 }
433
434 fn fill_empty_bars(&mut self) {
441 let mut i = 0;
442 while i < NUM_BARS {
443 if self.bar_counts[i] != 0 {
444 i += 1;
445 continue;
446 }
447 let mut end = i;
448 while end < NUM_BARS && self.bar_counts[end] == 0 {
449 end += 1;
450 }
451
452 match (i.checked_sub(1), (end < NUM_BARS).then_some(end)) {
453 (Some(left), Some(right)) => {
454 let (lo, hi) = (self.spectrum[left], self.spectrum[right]);
455 let span = (right - left) as f32;
456 for (n, bar) in (i..end).enumerate() {
457 let t = (n + 1) as f32 / span;
458 self.spectrum[bar] = lo + (hi - lo) * t;
459 }
460 }
461 (Some(left), None) => {
464 let value = self.spectrum[left];
465 self.spectrum[i..end].fill(value);
466 }
467 (None, Some(right)) => {
468 let value = self.spectrum[right];
469 self.spectrum[i..end].fill(value);
470 }
471 (None, None) => self.spectrum.fill(0.0),
472 }
473
474 i = end;
475 }
476 }
477
478 fn decay_silence(&mut self) {
480 let (bar_decay, peak_decay) = self.decay_factors();
481 for i in 0..NUM_BARS {
482 self.spectrum[i] *= bar_decay;
483 self.peaks[i] *= peak_decay;
484 }
485 for v in self.vu_levels.iter_mut() {
486 *v *= bar_decay;
487 }
488 self.beat_energy *= bar_decay;
489 }
490
491 fn compute_vu(&mut self, samples: &[f32], channels: usize) {
493 let total_frames = samples.len() / channels;
494 let frames_to_use = total_frames.min(2048);
495 let frame_start = total_frames - frames_to_use;
496 let vu_channels = channels.min(2);
497 let mut sum_sq = [0.0f64; 2];
498
499 for frame in 0..frames_to_use {
500 let idx = (frame_start + frame) * channels;
501 for ch in 0..vu_channels {
502 if idx + ch < samples.len() {
503 let s = samples[idx + ch] as f64;
504 sum_sq[ch] += s * s;
505 }
506 }
507 }
508
509 let db_range = DB_CEIL - DB_FLOOR;
510 for (ch, &sq) in sum_sq.iter().enumerate().take(vu_channels) {
511 let rms = (sq / frames_to_use as f64).sqrt() as f32;
512 let db = if rms > 0.0 {
513 20.0 * rms.log10()
514 } else {
515 DB_FLOOR
516 };
517 self.vu_levels[ch] = ((db - DB_FLOOR) / db_range).clamp(0.0, 1.0);
518 }
519
520 if vu_channels == 1 {
521 self.vu_levels[1] = self.vu_levels[0];
522 }
523 }
524}
525
526pub struct VizAnalyzer {
534 running: Arc<AtomicBool>,
535 handle: Option<thread::JoinHandle<()>>,
536}
537
538impl VizAnalyzer {
539 pub fn spawn_with_snapshot(
547 viz_buffer: Arc<VizBuffer>,
548 cfg: &VisualizerConfig,
549 snapshot: Arc<VizSnapshot>,
550 samples_played: Arc<AtomicU64>,
551 ) -> Self {
552 let running = Arc::new(AtomicBool::new(true));
553
554 let scale = FrequencyScale::parse(&cfg.scale);
555 let amplitude_scale = AmplitudeScale::parse(&cfg.amplitude_scale);
556 let bar_half_life = cfg.bar_decay_ms as f32 / 1000.0;
557 let peak_half_life = cfg.peak_decay_ms as f32 / 1000.0;
558 let interval = Duration::from_millis(1000 / cfg.fps.max(1) as u64);
559
560 let running_clone = Arc::clone(&running);
561
562 let handle = thread::Builder::new()
563 .name("viz-analyzer".into())
564 .spawn(move || {
565 analysis_loop(
566 viz_buffer,
567 snapshot,
568 samples_played,
569 running_clone,
570 scale,
571 amplitude_scale,
572 bar_half_life,
573 peak_half_life,
574 interval,
575 );
576 })
577 .expect("failed to spawn viz-analyzer thread");
578
579 Self {
580 running,
581 handle: Some(handle),
582 }
583 }
584
585 pub fn shutdown(&mut self) {
587 self.running.store(false, Ordering::Relaxed);
588 if let Some(h) = self.handle.take() {
589 let _ = h.join();
590 }
591 }
592}
593
594impl Drop for VizAnalyzer {
595 fn drop(&mut self) {
596 self.shutdown();
597 }
598}
599
600const WINDOW_FRAMES: usize = if FFT_SIZE > WAVEFORM_SAMPLES {
605 FFT_SIZE
606} else {
607 WAVEFORM_SAMPLES
608};
609
610#[allow(clippy::too_many_arguments)]
611fn analysis_loop(
612 viz_buffer: Arc<VizBuffer>,
613 snapshot: Arc<VizSnapshot>,
614 samples_played: Arc<AtomicU64>,
615 running: Arc<AtomicBool>,
616 scale: FrequencyScale,
617 amplitude_scale: AmplitudeScale,
618 bar_half_life: f32,
619 peak_half_life: f32,
620 interval: Duration,
621) {
622 let mut state = AnalysisState::new(scale, bar_half_life, peak_half_life, amplitude_scale);
623 let mut snap = RawVizSnapshot::default();
624
625 while running.load(Ordering::Relaxed) {
626 let start = Instant::now();
627
628 let played = samples_played.load(Ordering::Relaxed);
630 viz_buffer.snapshot_at(played, WINDOW_FRAMES, &mut snap);
631
632 state.analyze(
634 &snap.samples,
635 snap.channels.max(1) as usize,
636 snap.sample_rate as f32,
637 );
638
639 let interleaved_len = WAVEFORM_SAMPLES * snap.channels.max(1) as usize;
643 let waveform_start = snap.samples.len().saturating_sub(interleaved_len);
644 snapshot.write(VizFrame {
645 spectrum: state.spectrum,
646 peaks: state.peaks,
647 vu_levels: state.vu_levels,
648 beat_energy: state.beat_energy,
649 timestamp: Instant::now(),
650 waveform: snap.samples[waveform_start..].to_vec(),
651 });
652
653 let elapsed = start.elapsed();
655 if elapsed < interval {
656 thread::sleep(interval - elapsed);
657 }
658 }
659}
660
661#[cfg(test)]
664mod tests {
665 use super::*;
666 use crate::audio::viz::VizBuffer;
667 use crate::config::VisualizerConfig;
668
669 fn make_cfg() -> VisualizerConfig {
670 VisualizerConfig::default()
671 }
672
673 fn sine(frames: usize, freq: f32, amplitude: f32, sample_rate: u32) -> Vec<f32> {
675 let mut samples = Vec::with_capacity(frames * 2);
676 for i in 0..frames {
677 let t = i as f32 / sample_rate as f32;
678 let val = (2.0 * std::f32::consts::PI * freq * t).sin() * amplitude;
679 samples.push(val);
680 samples.push(val);
681 }
682 samples
683 }
684
685 fn spawn_analyzer(
686 buf: Arc<VizBuffer>,
687 cfg: &VisualizerConfig,
688 played: u64,
689 ) -> (VizAnalyzer, Arc<VizSnapshot>) {
690 let snapshot = VizSnapshot::new();
691 let analyzer = VizAnalyzer::spawn_with_snapshot(
692 buf,
693 cfg,
694 Arc::clone(&snapshot),
695 Arc::new(AtomicU64::new(played)),
696 );
697 (analyzer, snapshot)
698 }
699
700 #[test]
701 fn analyzer_spawns_and_shuts_down() {
702 let buf = VizBuffer::new();
703 let cfg = make_cfg();
704 let (mut analyzer, snapshot) = spawn_analyzer(buf, &cfg, 0);
705 std::thread::sleep(Duration::from_millis(100));
707 analyzer.shutdown();
708 let frame = snapshot.read();
710 assert_eq!(frame.spectrum.len(), NUM_BARS);
711 assert_eq!(frame.peaks.len(), NUM_BARS);
712 }
713
714 #[test]
715 fn analyzer_produces_nonzero_output_for_sine() {
716 let buf = VizBuffer::new();
717 let sample_rate = 44100u32;
718 let samples = sine(4096, 440.0, 0.5, sample_rate);
719 buf.push_samples(&samples, 2, sample_rate);
720
721 let cfg = make_cfg();
722 let (mut analyzer, snapshot) = spawn_analyzer(Arc::clone(&buf), &cfg, samples.len() as u64);
724 std::thread::sleep(Duration::from_millis(150));
726
727 let frame = snapshot.read();
728 analyzer.shutdown();
729
730 let max_bar = frame.spectrum.iter().cloned().fold(0.0f32, f32::max);
731 assert!(
732 max_bar > 0.05,
733 "expected nonzero spectrum for 440 Hz sine, max = {}",
734 max_bar
735 );
736 }
737
738 #[test]
739 fn analyzer_reads_the_delay_line_at_the_play_head() {
740 let sample_rate = 44100u32;
741 let buf = VizBuffer::new();
742 buf.push_samples(&vec![0.0; sample_rate as usize * 2], 2, sample_rate);
744 buf.push_samples(&sine(4096, 440.0, 0.8, sample_rate), 2, sample_rate);
745
746 let cfg = make_cfg();
747 let (mut analyzer, snapshot) = spawn_analyzer(Arc::clone(&buf), &cfg, sample_rate as u64);
749 std::thread::sleep(Duration::from_millis(150));
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 "visualizer showed audio the DAC has not reached yet, max = {}",
757 max_bar
758 );
759 }
760
761 #[test]
762 fn full_scale_sine_reads_zero_db() {
763 let mut state =
767 AnalysisState::new(FrequencyScale::Bark, 0.08, 0.35, AmplitudeScale::Linear);
768 let sample_rate = 44100.0;
769 let freq = 46.0 * sample_rate / FFT_SIZE as f32;
770 let samples = sine(FFT_SIZE, freq, 1.0, sample_rate as u32);
771
772 state.analyze(&samples, 2, sample_rate);
773
774 let max_bar = state.spectrum.iter().cloned().fold(0.0f32, f32::max);
776 assert!(
777 max_bar > 0.98,
778 "full-scale sine should reach the top of the widget, got {}",
779 max_bar
780 );
781 }
782
783 #[test]
784 fn bark_bars_go_unmapped_at_high_sample_rates() {
785 let mapping = build_bin_to_bar(192_000.0, FrequencyScale::Bark);
789 let mut counts = [0u32; NUM_BARS];
790 for bar in mapping.iter().flatten() {
791 counts[*bar] += 1;
792 }
793 assert_eq!(
794 counts[0], 0,
795 "no bin reaches the lowest Bark bar at 192 kHz"
796 );
797 assert!(
798 counts.iter().filter(|&&c| c == 0).count() > 3,
799 "expected several unmapped bass bars, got {:?}",
800 counts
801 );
802 }
803
804 #[test]
805 fn empty_bars_interpolate_without_sawtooth() {
806 let mut state =
807 AnalysisState::new(FrequencyScale::Bark, 0.08, 0.35, AmplitudeScale::Linear);
808 for (n, &bar) in [1usize, 4, 6, 8].iter().enumerate() {
810 state.bar_counts[bar] = 1;
811 state.spectrum[bar] = 0.2 + 0.1 * n as f32;
812 }
813 for bar in 9..NUM_BARS {
814 state.bar_counts[bar] = 1;
815 state.spectrum[bar] = 0.5;
816 }
817
818 state.fill_empty_bars();
819
820 assert!((state.spectrum[0] - state.spectrum[1]).abs() < 1e-6);
823 for i in 0..8 {
824 assert!(
825 state.spectrum[i + 1] >= state.spectrum[i] - 1e-6,
826 "sawtooth across interpolated bass: {:?}",
827 &state.spectrum[..9]
828 );
829 }
830 }
831
832 #[test]
833 fn analysis_state_decays_to_zero_on_silence() {
834 let mut state =
837 AnalysisState::new(FrequencyScale::Bark, 0.08, 0.35, AmplitudeScale::Linear);
838
839 for v in state.spectrum.iter_mut() {
841 *v = 1.0;
842 }
843 for v in state.peaks.iter_mut() {
844 *v = 1.0;
845 }
846
847 let silence: Vec<f32> = vec![0.0; FFT_SIZE * 2];
852 for _ in 0..100 {
853 state.last_update = Instant::now() - Duration::from_millis(100);
854 state.analyze(&silence, 2, 44100.0);
855 }
856
857 let max_spec = state.spectrum.iter().cloned().fold(0.0f32, f32::max);
858 let max_peak = state.peaks.iter().cloned().fold(0.0f32, f32::max);
859 assert!(
860 max_spec < 0.1,
861 "spectrum should decay near zero, got {}",
862 max_spec
863 );
864 assert!(
865 max_peak < 0.1,
866 "peaks should decay near zero, got {}",
867 max_peak
868 );
869 }
870
871 #[test]
872 fn bin_to_bar_covers_audible_range() {
873 let mapping = build_bin_to_bar(44100.0, FrequencyScale::Bark);
874 let active_bins: Vec<usize> = mapping.iter().filter_map(|x| *x).collect();
875 assert!(
876 !active_bins.is_empty(),
877 "at least some bins should map to bars"
878 );
879 let max_bar = *active_bins.iter().max().unwrap();
880 assert!(max_bar < NUM_BARS, "bar index must be in range");
881 }
882
883 #[test]
884 fn frequency_scale_bark_normalize_monotonic() {
885 let scale = FrequencyScale::Bark;
886 let freqs: Vec<f32> = vec![100.0, 500.0, 1000.0, 4000.0, 10000.0];
887 let normed: Vec<f32> = freqs.iter().map(|&f| scale.normalize(f)).collect();
888 for w in normed.windows(2) {
889 assert!(w[1] > w[0], "Bark scale must be monotonically increasing");
890 }
891 }
892}