use sha2::{Digest, Sha256};
use super::*;
use crate::audio::lid::{Error, frame_count};
const FILTERBANK_SHA256: &str = "264347e3ef9068b6f1951c63c6b0f8344584d55182879195edf021bee42f7328";
const WINDOW_SHA256: &str = "82a72e618e6d19dbf72699d2a0246693cba1cf5f122bb88c92cb80842dba6b30";
fn sha256_f32_le(values: impl IntoIterator<Item = f32>) -> String {
use core::fmt::Write;
let mut hasher = Sha256::new();
for value in values {
hasher.update(value.to_le_bytes());
}
hasher.finalize().iter().fold(String::new(), |mut acc, b| {
let _ = write!(acc, "{b:02x}");
acc
})
}
fn tone(seconds: f32) -> Vec<f32> {
let n = (seconds * 16_000.0) as usize;
(0..n)
.map(|i| 0.5 * (core::f32::consts::TAU * 1_000.0 * (i as f32 / 16_000.0)).sin())
.collect()
}
fn extract(samples: &[f32]) -> Vec<f32> {
let mel = MelExtractor::new();
let mut out = vec![f32::NAN; frame_count(samples.len()) * N_MELS];
mel.extract_into(samples, &mut out).expect("extract");
out
}
fn frame(out: &[f32], t: usize) -> &[f32] {
&out[t * N_MELS..(t + 1) * N_MELS]
}
#[test]
fn filterbank_reproduces_the_reference_digest_bit_exactly() {
let filterbank = MelExtractor::build_filterbank();
assert_eq!(filterbank.len(), N_FREQ * N_MELS);
assert_eq!(sha256_f32_le(filterbank.iter().copied()), FILTERBANK_SHA256);
}
#[test]
fn window_digest_is_pinned() {
let window = MelExtractor::periodic_hamming();
assert_eq!(window.len(), N_FFT);
assert_eq!(sha256_f32_le(window.iter().copied()), WINDOW_SHA256);
}
#[test]
fn window_is_periodic_hamming_not_hann_and_not_symmetric() {
let window = MelExtractor::periodic_hamming();
assert_eq!(window[0], 0.54f32 - 0.46f32);
assert!(
window[0] > 0.079 && window[0] < 0.081,
"Hann would open at 0.0, got {}",
window[0]
);
assert!(
window[N_FFT - 1] > window[0],
"a symmetric window ends where it starts ({} vs {})",
window[N_FFT - 1],
window[0]
);
assert!(
(window[N_FFT - 1] - 0.080_057).abs() < 1e-6,
"expected the periodic tail 0.080057, got {}",
window[N_FFT - 1]
);
let sum: f64 = window.iter().map(|&w| f64::from(w)).sum();
assert!(
(sum - 216.0).abs() < 1e-4,
"a periodic Hamming window sums to 216.0 (symmetric sums to ~216.46), got {sum}"
);
assert!((window[N_FFT / 2] - 1.0).abs() < 1e-6);
}
#[test]
fn center_padding_is_constant_zero_not_reflection() {
let signal: Vec<f32> = (0..8_000)
.map(|i| 0.5 * (core::f32::consts::TAU * 1_000.0 * (i as f32 / 16_000.0) + 1.2).sin())
.collect();
assert!(
signal[0].abs() > 0.2,
"fixture must open loud, got {}",
signal[0]
);
let mut prefixed = vec![0.0f32; 2 * HOP];
prefixed.extend_from_slice(&signal);
let plain = extract(&signal);
let shifted = extract(&prefixed);
assert_eq!(frame_count(prefixed.len()), frame_count(signal.len()) + 2);
for t in 0..frame_count(signal.len()) {
assert_eq!(
frame(&plain, t),
frame(&shifted, t + 2),
"frame {t} must be identical under a whole-hop zero prefix"
);
}
}
#[test]
fn constant_signal_shows_the_zero_pad_step_that_reflection_would_hide() {
let out = extract(&vec![0.3f32; 16_000]);
let head = frame(&out, 0);
let interior = frame(&out, 50);
let widest = head
.iter()
.zip(interior.iter())
.map(|(a, b)| (a - b).abs())
.fold(0.0f32, f32::max);
assert!(
widest > 30.0,
"reflection would make every frame of a constant clip identical; \
constant-zero padding must leave frame 0 tens of dB apart, got {widest}"
);
}
#[test]
fn triangles_are_symmetric_lower_side_bands_without_area_norm() {
let filterbank = MelExtractor::build_filterbank();
let mel_max = MelExtractor::hz_to_mel(F_MAX);
let step = mel_max / (N_MELS + 1) as f64;
let edge = |m: usize| MelExtractor::mel_to_hz(((m as f64) * step) as f32);
let bin_hz = |k: usize| (k as f64 * (F_MAX / (N_FREQ - 1) as f64)) as f32;
let asymmetric = |k: usize, m: usize| {
let (low, center, high) = (edge(m), edge(m + 1), edge(m + 2));
let freq = bin_hz(k);
let left = (freq - low) / (center - low);
let right = (high - freq) / (high - center);
f32::max(0.0, f32::min(left, right))
};
let mut separated = 0;
for m in 0..N_MELS {
let center = edge(m + 1);
let lower_band = edge(m + 1) - edge(m);
let upper_band = edge(m + 2) - edge(m + 1);
assert!(
upper_band > lower_band,
"mel spacing must widen with frequency at triangle {m} ({lower_band} -> {upper_band})"
);
for k in 0..N_FREQ {
if bin_hz(k) > center + lower_band && bin_hz(k) < center + upper_band {
assert_eq!(
filterbank[k * N_MELS + m],
0.0,
"triangle {m} must close at centre + LOWER band ({} Hz), but bin {k} \
({} Hz) is non-zero — that is the asymmetric construction",
center + lower_band,
bin_hz(k)
);
assert!(
asymmetric(k, m) > 0.0,
"fixture assumption: the asymmetric construction must still be open \
at bin {k} for triangle {m}"
);
assert!(filterbank[(k - 1) * N_MELS + m] > 0.0);
separated += 1;
}
}
}
assert!(
separated >= 4,
"the 40 Hz bin grid must resolve the two right feet somewhere; found {separated}"
);
let widest = (0..N_FREQ)
.flat_map(|k| (0..N_MELS).map(move |m| (k, m)))
.map(|(k, m)| (filterbank[k * N_MELS + m] - asymmetric(k, m)).abs())
.fold(0.0f32, f32::max);
assert!(
(widest - 0.0404).abs() < 1e-3,
"expected a peak divergence of ~0.0404 from the asymmetric construction, got {widest}"
);
let peak = filterbank.iter().copied().fold(0.0f32, f32::max);
assert!(
(0.99..=1.0).contains(&peak),
"unnormalized triangles peak at ~1.0, got {peak}"
);
let column_sum = |m: usize| (0..N_FREQ).map(|k| filterbank[k * N_MELS + m]).sum::<f32>();
let sums: Vec<f32> = (0..N_MELS).map(column_sum).collect();
let min = sums.iter().copied().fold(f32::INFINITY, f32::min);
let max = sums.iter().copied().fold(f32::NEG_INFINITY, f32::max);
assert!(
(min - 0.645).abs() < 1e-3 && (max - 8.452).abs() < 1e-3,
"column sums must run 0.645..8.452 (area normalization would flatten them), got {min}..{max}"
);
}
#[test]
fn top_db_floor_is_global_over_the_whole_utterance() {
let mut samples = vec![0.0f32; 16_000];
samples.extend_from_slice(&tone(1.0));
let out = extract(&samples);
let max = out.iter().copied().fold(f32::NEG_INFINITY, f32::max);
let min = out.iter().copied().fold(f32::INFINITY, f32::min);
assert!(
(min - (max - 80.0)).abs() < 1e-3,
"the floor must be the clip's own peak minus 80 dB (max {max}, min {min})"
);
assert!(
min > -99.0,
"a per-frame floor would leave the silent half at the -100 dB amin floor, got {min}"
);
let silent = frame(&out, 10);
assert!(silent.iter().all(|&v| (v - min).abs() < 1e-3));
}
#[test]
fn layout_is_time_major_rows() {
let mut samples = vec![0.0f32; 48_000];
samples.extend_from_slice(&tone(3.0));
let out = extract(&samples);
let frames = frame_count(samples.len());
let quiet_frames = 48_000 / HOP;
let floor = out.iter().copied().fold(f32::INFINITY, f32::min);
for t in 0..quiet_frames - 2 {
let row = frame(&out, t);
assert!(
row.iter().all(|&v| (v - floor).abs() < 1e-3),
"frame {t} is in the silent half, so every one of its {N_MELS} mel values \
must sit on the global floor {floor}"
);
}
for t in quiet_frames + 2..frames {
let row = frame(&out, t);
let loudest = row.iter().copied().fold(f32::NEG_INFINITY, f32::max);
assert!(
loudest > floor + 30.0,
"frame {t} is in the tone half, so it must rise far above the floor \
({loudest} vs {floor})"
);
}
}
#[test]
fn extraction_writes_exactly_the_expected_frame_count() {
for samples in [1_440usize, 1_600, 16_000, 48_000] {
let signal = vec![0.25f32; samples];
let out = extract(&signal);
assert_eq!(out.len(), frame_count(samples) * N_MELS);
assert_eq!(frame_count(samples), 1 + samples / HOP);
assert!(out.iter().all(|v| v.is_finite()), "{samples} samples");
}
}
#[test]
fn non_finite_samples_are_a_typed_error() {
let mel = MelExtractor::new();
let mut signal = vec![0.25f32; 1_600];
signal[900] = f32::NAN;
let mut out = vec![0.0f32; frame_count(signal.len()) * N_MELS];
assert!(matches!(
mel.extract_into(&signal, &mut out),
Err(Error::NonFiniteInput(900))
));
signal[900] = f32::NEG_INFINITY;
assert!(matches!(
mel.extract_into(&signal, &mut out),
Err(Error::NonFiniteInput(900))
));
}
#[test]
fn silence_floors_at_the_amin_power_floor() {
let out = extract(&vec![0.0f32; 16_000]);
assert!(
out.iter().all(|&v| (v - (-100.0)).abs() < 1e-4),
"silence must land on 10·log10(amin) = -100 dB"
);
}
#[test]
fn mel_scale_anchors_and_round_trips() {
assert_eq!(MelExtractor::hz_to_mel(0.0), 0.0);
assert!((MelExtractor::hz_to_mel(1_000.0) - 999.99).abs() < 0.05);
assert!((MelExtractor::hz_to_mel(F_MAX) - 2_840.023).abs() < 1e-3);
for hz in [0.0f32, 125.0, 440.0, 1_000.0, 4_000.0, 8_000.0] {
let mel = MelExtractor::hz_to_mel(f64::from(hz)) as f32;
let back = MelExtractor::mel_to_hz(mel);
assert!(
(back - hz).abs() < 0.01,
"round trip at {hz} Hz gave {back} Hz"
);
}
}
#[test]
fn tone_energy_lands_in_the_expected_mel_bin() {
let out = extract(&tone(1.0));
let frames = frame_count(16_000);
let column_mean =
|m: usize| (0..frames).map(|t| out[t * N_MELS + m]).sum::<f32>() / frames as f32;
let peak = (0..N_MELS)
.max_by(|&a, &b| column_mean(a).total_cmp(&column_mean(b)))
.expect("N_MELS > 0");
assert_eq!(peak, 20, "1 kHz must peak in mel bin 20");
assert!(
column_mean(20) > column_mean(55) + 30.0,
"the tone bin must dominate a far bin by more than 30 dB"
);
}