max / audiofiles
3 files changed,
+101 insertions,
-9 deletions
| @@ -232,6 +232,37 @@ mod tests { | |||
| 232 | 232 | } | |
| 233 | 233 | ||
| 234 | 234 | #[test] | |
| 235 | + | fn hound_fallback_decodes_8bit_pcm() { | |
| 236 | + | // The 8-bit hound fallback scaling was unproven (8-bit WAV is stored | |
| 237 | + | // offset-128 unsigned; hound returns it signed when read as i32, and the | |
| 238 | + | // code scales by 2^(bits-1)=128). Prove the roundtrip end to end. | |
| 239 | + | let dir = tempfile::tempdir().unwrap(); | |
| 240 | + | let path = dir.path().join("eight.wav"); | |
| 241 | + | let spec = hound::WavSpec { | |
| 242 | + | channels: 1, | |
| 243 | + | sample_rate: 8000, | |
| 244 | + | bits_per_sample: 8, | |
| 245 | + | sample_format: hound::SampleFormat::Int, | |
| 246 | + | }; | |
| 247 | + | { | |
| 248 | + | let mut w = hound::WavWriter::create(&path, spec).unwrap(); | |
| 249 | + | for s in [i8::MIN, 0, i8::MAX, 64, -64] { | |
| 250 | + | w.write_sample(s).unwrap(); | |
| 251 | + | } | |
| 252 | + | w.finalize().unwrap(); | |
| 253 | + | } | |
| 254 | + | let decoded = decode_wav_hound(&path).unwrap(); | |
| 255 | + | assert_eq!(decoded.sample_rate, 8000); | |
| 256 | + | assert_eq!(decoded.channels, 1); | |
| 257 | + | // Scale by 2^7 = 128: MIN -> -1.0, 0 -> 0.0, MAX -> 127/128, etc. | |
| 258 | + | let expected = [-1.0f32, 0.0, 127.0 / 128.0, 64.0 / 128.0, -64.0 / 128.0]; | |
| 259 | + | assert_eq!(decoded.samples.len(), expected.len()); | |
| 260 | + | for (got, exp) in decoded.samples.iter().zip(expected.iter()) { | |
| 261 | + | assert!((got - exp).abs() < 1e-6, "8-bit decode: got {got}, expected {exp}"); | |
| 262 | + | } | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | #[test] | |
| 235 | 266 | fn decoded_audio_field_access() { | |
| 236 | 267 | let audio = DecodedAudio { | |
| 237 | 268 | samples: vec![0.0, 0.5, -0.5, 1.0], |
| @@ -102,23 +102,30 @@ fn compute_spectral_inner( | |||
| 102 | 102 | let mut onset_diffs = Vec::new(); | |
| 103 | 103 | let mut magnitude_frames = Vec::new(); | |
| 104 | 104 | ||
| 105 | + | // Hoisted STFT scratch, reused every frame: `windowed` is refilled before each | |
| 106 | + | // FFT (realfft also uses it as scratch) and `spectrum` is overwritten by | |
| 107 | + | // `process`. Only `magnitudes` stays per-frame, since it is moved into | |
| 108 | + | // `magnitude_frames` for the later MFCC pass. | |
| 109 | + | let mut windowed: Vec<f64> = vec![0.0; window_size]; | |
| 110 | + | let mut spectrum = fft.make_output_vec(); | |
| 111 | + | ||
| 105 | 112 | let mut pos = 0; | |
| 106 | 113 | while pos + window_size <= samples.len() { | |
| 107 | - | // Apply window | |
| 108 | - | let mut windowed: Vec<f64> = samples[pos..pos + window_size] | |
| 109 | - | .iter() | |
| 110 | - | .enumerate() | |
| 111 | - | .map(|(i, &s)| s as f64 * hann[i]) | |
| 112 | - | .collect(); | |
| 114 | + | // Apply window into the reused buffer. | |
| 115 | + | for (w, (&s, &h)) in windowed | |
| 116 | + | .iter_mut() | |
| 117 | + | .zip(samples[pos..pos + window_size].iter().zip(hann.iter())) | |
| 118 | + | { | |
| 119 | + | *w = s as f64 * h; | |
| 120 | + | } | |
| 113 | 121 | ||
| 114 | - | // FFT | |
| 115 | - | let mut spectrum = fft.make_output_vec(); | |
| 122 | + | // FFT (overwrites `spectrum`). | |
| 116 | 123 | if fft.process(&mut windowed, &mut spectrum).is_err() { | |
| 117 | 124 | pos += hop_size; | |
| 118 | 125 | continue; | |
| 119 | 126 | } | |
| 120 | 127 | ||
| 121 | - | // Magnitude spectrum | |
| 128 | + | // Magnitude spectrum (retained per-frame for MFCC). | |
| 122 | 129 | let magnitudes: Vec<f64> = spectrum.iter().map(|c| c.norm()).collect(); | |
| 123 | 130 | ||
| 124 | 131 | // Spectral centroid: frequency-weighted average of the magnitude spectrum. |
| @@ -489,6 +489,60 @@ mod tests { | |||
| 489 | 489 | use crate::test_helpers::insert_fake_sample; | |
| 490 | 490 | use crate::analysis::{self, AnalysisResult}; | |
| 491 | 491 | ||
| 492 | + | /// `feature_distance` is documented as a true weighted-Euclidean metric — the | |
| 493 | + | /// VP-tree's triangle-inequality prune depends on it (a pseudometric could | |
| 494 | + | /// drop a genuine nearest neighbour). Prove the property rather than assert it | |
| 495 | + | /// in prose: over randomized vectors (incl. missing/imputed dims and the | |
| 496 | + | /// non-finite values the ingest guard maps to imputed), the triangle | |
| 497 | + | /// inequality d(a,c) <= d(a,b) + d(b,c) must hold. | |
| 498 | + | #[test] | |
| 499 | + | fn feature_distance_satisfies_triangle_inequality() { | |
| 500 | + | // Deterministic LCG (no rand dep; reproducible). Numerical Recipes constants. | |
| 501 | + | let mut state: u64 = 0x9E3779B97F4A7C15; | |
| 502 | + | let mut next = || { | |
| 503 | + | state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); | |
| 504 | + | (state >> 33) as f64 / (1u64 << 31) as f64 // [0, 2) | |
| 505 | + | }; | |
| 506 | + | // A dim is None ~1/4 of the time, NaN ~1/16 (exercise the finite guard), | |
| 507 | + | // else a value in roughly the normalized [0,1] range (with some spill). | |
| 508 | + | let mut gen_vec = || { | |
| 509 | + | let mut d = || -> Option<f64> { | |
| 510 | + | let r = next(); | |
| 511 | + | if r < 0.5 { | |
| 512 | + | None | |
| 513 | + | } else if r < 0.625 { | |
| 514 | + | Some(f64::NAN) | |
| 515 | + | } else { | |
| 516 | + | Some(next() / 2.0) // ~[0,1) | |
| 517 | + | } | |
| 518 | + | }; | |
| 519 | + | FeatureVector { | |
| 520 | + | bpm: d(), duration: d(), lufs: d(), spectral_centroid: d(), | |
| 521 | + | spectral_flatness: d(), spectral_rolloff: d(), zero_crossing_rate: d(), | |
| 522 | + | onset_strength: d(), spectral_bandwidth: d(), centroid_variance: d(), | |
| 523 | + | crest_factor: d(), attack_time: d(), | |
| 524 | + | } | |
| 525 | + | }; | |
| 526 | + | ||
| 527 | + | let weights = FeatureWeights::default(); | |
| 528 | + | for _ in 0..5000 { | |
| 529 | + | let a = gen_vec(); | |
| 530 | + | let b = gen_vec(); | |
| 531 | + | let c = gen_vec(); | |
| 532 | + | let dab = feature_distance(&a, &b, &weights); | |
| 533 | + | let dbc = feature_distance(&b, &c, &weights); | |
| 534 | + | let dac = feature_distance(&a, &c, &weights); | |
| 535 | + | // All distances finite (the non-finite guard holds). | |
| 536 | + | assert!(dab.is_finite() && dbc.is_finite() && dac.is_finite()); | |
| 537 | + | // Triangle inequality with a small float-rounding tolerance. | |
| 538 | + | assert!( | |
| 539 | + | dac <= dab + dbc + 1e-9, | |
| 540 | + | "triangle inequality violated: d(a,c)={dac} > d(a,b)+d(b,c)={}", | |
| 541 | + | dab + dbc | |
| 542 | + | ); | |
| 543 | + | } | |
| 544 | + | } | |
| 545 | + | ||
| 492 | 546 | fn insert_with_features(db: &Database, hash: &str, bpm: f64, duration: f64) { | |
| 493 | 547 | insert_fake_sample(db, hash); | |
| 494 | 548 | let result = AnalysisResult { |