max / audiofiles
3 files changed,
+178 insertions,
-17 deletions
| @@ -6,11 +6,13 @@ | |||
| 6 | 6 | /// 16-bit audio (96 dB dynamic range = 16 bits * 6 dB/bit) and serves as a | |
| 7 | 7 | /// practical "silent" value. | |
| 8 | 8 | pub fn peak_db(samples: &[f32]) -> f64 { | |
| 9 | - | if samples.is_empty() { | |
| 10 | - | return -96.0; | |
| 11 | - | } | |
| 9 | + | // Ignore non-finite samples: a crafted or corrupt float WAV can decode to | |
| 10 | + | // NaN/Inf; an unguarded max/log10 would emit NaN/Inf and (serialized as JSON | |
| 11 | + | // null) later break clustering. Mirrors the finiteness convention in | |
| 12 | + | // waveform.rs. All-non-finite (or empty) falls through to the -96 floor. | |
| 12 | 13 | let peak = samples | |
| 13 | 14 | .iter() | |
| 15 | + | .filter(|s| s.is_finite()) | |
| 14 | 16 | .fold(0.0f32, |max, &s| max.max(s.abs())); | |
| 15 | 17 | if peak == 0.0 { | |
| 16 | 18 | -96.0 | |
| @@ -23,11 +25,20 @@ pub fn peak_db(samples: &[f32]) -> f64 { | |||
| 23 | 25 | /// | |
| 24 | 26 | /// Returns -96.0 for silence or empty input (same floor convention as `peak_db`). | |
| 25 | 27 | pub fn rms_db(samples: &[f32]) -> f64 { | |
| 26 | - | if samples.is_empty() { | |
| 28 | + | // Sum only finite samples and divide by the finite count, so a stray NaN/Inf | |
| 29 | + | // can't poison the mean-square. All-non-finite (or empty) -> -96 floor. | |
| 30 | + | let mut sum_sq = 0.0f64; | |
| 31 | + | let mut n = 0usize; | |
| 32 | + | for &s in samples { | |
| 33 | + | if s.is_finite() { | |
| 34 | + | sum_sq += (s as f64) * (s as f64); | |
| 35 | + | n += 1; | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | if n == 0 { | |
| 27 | 39 | return -96.0; | |
| 28 | 40 | } | |
| 29 | - | let sum_sq: f64 = samples.iter().map(|&s| (s as f64) * (s as f64)).sum(); | |
| 30 | - | let rms = (sum_sq / samples.len() as f64).sqrt(); | |
| 41 | + | let rms = (sum_sq / n as f64).sqrt(); | |
| 31 | 42 | if rms == 0.0 { | |
| 32 | 43 | -96.0 | |
| 33 | 44 | } else { | |
| @@ -41,15 +52,21 @@ pub fn rms_db(samples: &[f32]) -> f64 { | |||
| 41 | 52 | /// Low values (<3) indicate sustained signals (pads, drones). | |
| 42 | 53 | /// Returns 0.0 for silence or empty input. | |
| 43 | 54 | pub fn crest_factor(samples: &[f32]) -> f64 { | |
| 44 | - | if samples.is_empty() { | |
| 45 | - | return 0.0; | |
| 55 | + | // Peak and mean-square over finite samples only -> never NaN/Inf out. | |
| 56 | + | let mut peak = 0.0f32; | |
| 57 | + | let mut sum_sq = 0.0f64; | |
| 58 | + | let mut n = 0usize; | |
| 59 | + | for &s in samples { | |
| 60 | + | if s.is_finite() { | |
| 61 | + | peak = peak.max(s.abs()); | |
| 62 | + | sum_sq += (s as f64) * (s as f64); | |
| 63 | + | n += 1; | |
| 64 | + | } | |
| 46 | 65 | } | |
| 47 | - | let peak = samples.iter().fold(0.0f32, |max, &s| max.max(s.abs())); | |
| 48 | - | if peak == 0.0 { | |
| 66 | + | if n == 0 || peak == 0.0 { | |
| 49 | 67 | return 0.0; | |
| 50 | 68 | } | |
| 51 | - | let sum_sq: f64 = samples.iter().map(|&s| (s as f64) * (s as f64)).sum(); | |
| 52 | - | let rms = (sum_sq / samples.len() as f64).sqrt(); | |
| 69 | + | let rms = (sum_sq / n as f64).sqrt(); | |
| 53 | 70 | if rms == 0.0 { | |
| 54 | 71 | 0.0 | |
| 55 | 72 | } else { | |
| @@ -77,8 +94,10 @@ pub fn attack_time(samples: &[f32], sample_rate: u32) -> f64 { | |||
| 77 | 94 | let mut envelope = Vec::with_capacity(samples.len() / window_len); | |
| 78 | 95 | let mut pos = 0; | |
| 79 | 96 | while pos + window_len <= samples.len() { | |
| 97 | + | // Skip non-finite samples so a NaN/Inf can't poison an envelope window. | |
| 80 | 98 | let sum_sq: f64 = samples[pos..pos + window_len] | |
| 81 | 99 | .iter() | |
| 100 | + | .filter(|s| s.is_finite()) | |
| 82 | 101 | .map(|&s| (s as f64) * (s as f64)) | |
| 83 | 102 | .sum(); | |
| 84 | 103 | envelope.push((sum_sq / window_len as f64).sqrt()); | |
| @@ -182,6 +201,46 @@ mod tests { | |||
| 182 | 201 | } | |
| 183 | 202 | ||
| 184 | 203 | #[test] | |
| 204 | + | fn non_finite_samples_are_ignored() { | |
| 205 | + | // A crafted/corrupt float WAV can decode to NaN/Inf. The metrics must | |
| 206 | + | // stay finite and match the finite-only signal, never emitting NaN/Inf | |
| 207 | + | // (which serialize to JSON null and later break clustering). | |
| 208 | + | let clean = vec![0.5f32; 1000]; | |
| 209 | + | let mut dirty = clean.clone(); | |
| 210 | + | dirty.push(f32::NAN); | |
| 211 | + | dirty.push(f32::INFINITY); | |
| 212 | + | dirty.push(f32::NEG_INFINITY); | |
| 213 | + | ||
| 214 | + | assert!(peak_db(&dirty).is_finite()); | |
| 215 | + | assert!(rms_db(&dirty).is_finite()); | |
| 216 | + | assert!(crest_factor(&dirty).is_finite()); | |
| 217 | + | assert!(attack_time(&dirty, 44100).is_finite()); | |
| 218 | + | ||
| 219 | + | assert!((peak_db(&dirty) - peak_db(&clean)).abs() < 1e-9); | |
| 220 | + | assert!((rms_db(&dirty) - rms_db(&clean)).abs() < 1e-9); | |
| 221 | + | assert!((crest_factor(&dirty) - crest_factor(&clean)).abs() < 1e-9); | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | #[test] | |
| 225 | + | fn all_non_finite_falls_to_floor() { | |
| 226 | + | let junk = vec![f32::NAN, f32::INFINITY, f32::NEG_INFINITY]; | |
| 227 | + | assert_eq!(peak_db(&junk), -96.0); | |
| 228 | + | assert_eq!(rms_db(&junk), -96.0); | |
| 229 | + | assert_eq!(crest_factor(&junk), 0.0); | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | #[test] | |
| 233 | + | fn single_inf_does_not_dominate_peak() { | |
| 234 | + | // Regression: +Inf used to make peak_db return +Inf, which then zeroed a | |
| 235 | + | // normalized file downstream and serialized to a clustering-breaking null. | |
| 236 | + | let mut samples = vec![0.25f32; 500]; | |
| 237 | + | samples[10] = f32::INFINITY; | |
| 238 | + | let p = peak_db(&samples); | |
| 239 | + | assert!(p.is_finite()); | |
| 240 | + | assert!((p - (-12.04)).abs() < 0.1, "peak should reflect 0.25 (~-12 dBFS), got {p}"); | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | #[test] | |
| 185 | 244 | fn attack_time_slow_ramp() { | |
| 186 | 245 | // Linear ramp from 0 to 1 over 1 second | |
| 187 | 246 | let sr = 44100u32; |
| @@ -10,7 +10,7 @@ | |||
| 10 | 10 | //! these away). See [`crate::rules`] for the provenance model. | |
| 11 | 11 | ||
| 12 | 12 | use crate::db::Database; | |
| 13 | - | use crate::error::{CoreError, Result}; | |
| 13 | + | use crate::error::Result; | |
| 14 | 14 | use tracing::instrument; | |
| 15 | 15 | ||
| 16 | 16 | use super::classify::{FEATURE_VERSION, NUM_FEATURES}; | |
| @@ -53,9 +53,16 @@ fn load_vectors(db: &Database) -> Result<Vec<(String, Vec<f64>)>> { | |||
| 53 | 53 | let mut out = Vec::new(); | |
| 54 | 54 | for r in rows { | |
| 55 | 55 | let (hash, json) = r?; | |
| 56 | - | let v: Vec<f64> = | |
| 57 | - | serde_json::from_str(&json).map_err(|e| CoreError::Serialization(e.to_string()))?; | |
| 58 | - | if v.len() == NUM_FEATURES { | |
| 56 | + | // A vector that carried a non-finite component was serialized to JSON | |
| 57 | + | // `null` (serde_json's non-finite behavior), which won't parse back into | |
| 58 | + | // `Vec<f64>`. Skip such a row rather than `?`-aborting the WHOLE clustering | |
| 59 | + | // run — one crafted sample must not break clustering for the entire | |
| 60 | + | // library. Also drop wrong-dimension / any-non-finite vectors, mirroring | |
| 61 | + | // the sibling guards in exemplar.rs / similarity.rs. | |
| 62 | + | let Ok(v) = serde_json::from_str::<Vec<f64>>(&json) else { | |
| 63 | + | continue; | |
| 64 | + | }; | |
| 65 | + | if v.len() == NUM_FEATURES && v.iter().all(|x| x.is_finite()) { | |
| 59 | 66 | out.push((hash, v)); | |
| 60 | 67 | } | |
| 61 | 68 | } | |
| @@ -326,4 +333,43 @@ mod tests { | |||
| 326 | 333 | assert_eq!(removed, 2); | |
| 327 | 334 | assert!(crate::tags::get_sample_tags(&db, "a1").unwrap().is_empty()); | |
| 328 | 335 | } | |
| 336 | + | ||
| 337 | + | #[test] | |
| 338 | + | fn load_vectors_drops_non_finite_vectors() { | |
| 339 | + | let mut nan_vec = vec_at(0.5); | |
| 340 | + | nan_vec[0] = f64::NAN; | |
| 341 | + | let mut inf_vec = vec_at(0.5); | |
| 342 | + | inf_vec[3] = f64::INFINITY; | |
| 343 | + | let db = db_with_features(&[ | |
| 344 | + | ("good", vec_at(0.2)), | |
| 345 | + | ("nan", nan_vec), | |
| 346 | + | ("inf", inf_vec), | |
| 347 | + | ]); | |
| 348 | + | ||
| 349 | + | let loaded = load_vectors(&db).unwrap(); | |
| 350 | + | // Only the finite vector survives; NaN/Inf rows are dropped like the | |
| 351 | + | // sibling `exemplar::is_usable_vector` guard does. | |
| 352 | + | let hashes: Vec<&str> = loaded.iter().map(|(h, _)| h.as_str()).collect(); | |
| 353 | + | assert_eq!(hashes, vec!["good"]); | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | #[test] | |
| 357 | + | fn clustering_ignores_non_finite_and_does_not_nan() { | |
| 358 | + | // A poisoned vector must not garbage the medoids: the run completes and | |
| 359 | + | // only clusters the finite members. | |
| 360 | + | let mut bad = vec_at(5.0); | |
| 361 | + | bad[1] = f64::INFINITY; | |
| 362 | + | let db = db_with_features(&[ | |
| 363 | + | ("a1", vec_at(0.0)), | |
| 364 | + | ("a2", vec_at(0.1)), | |
| 365 | + | ("b1", vec_at(9.0)), | |
| 366 | + | ("bad", bad), | |
| 367 | + | ]); | |
| 368 | + | let res = cluster_library(&db, 2, 20).unwrap(); | |
| 369 | + | let clustered: usize = res.clusters.iter().map(|c| c.member_hashes.len()).sum(); | |
| 370 | + | assert_eq!(clustered, 3, "the Inf-poisoned vector should be excluded"); | |
| 371 | + | for c in &res.clusters { | |
| 372 | + | assert!(!c.member_hashes.iter().any(|m| m == "bad")); | |
| 373 | + | } | |
| 374 | + | } | |
| 329 | 375 | } |
| @@ -16,6 +16,23 @@ use tracing::{instrument, warn}; | |||
| 16 | 16 | ||
| 17 | 17 | use crate::error::{io_err, AnalysisError, CoreError}; | |
| 18 | 18 | ||
| 19 | + | /// Replace any non-finite sample (NaN / ±Inf) with silence. | |
| 20 | + | /// | |
| 21 | + | /// A float WAV (or a crafted/corrupt file) can decode to NaN/Inf. Left alone, | |
| 22 | + | /// those propagate through the whole analysis pipeline — poisoning loudness | |
| 23 | + | /// metrics, and serializing (via serde_json) to JSON `null` in the feature | |
| 24 | + | /// vector, which later breaks clustering. Neutralizing them once here, at the | |
| 25 | + | /// single decode boundary, gives every downstream stage the invariant that | |
| 26 | + | /// decoded audio is always finite. Legitimate PCM audio never contains | |
| 27 | + | /// non-finite samples, so this only ever touches invalid input. | |
| 28 | + | fn sanitize_non_finite(samples: &mut [f32]) { | |
| 29 | + | for s in samples.iter_mut() { | |
| 30 | + | if !s.is_finite() { | |
| 31 | + | *s = 0.0; | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 19 | 36 | /// Decoded audio as mono f32 samples for analysis. | |
| 20 | 37 | #[derive(Debug)] | |
| 21 | 38 | pub struct DecodedAudio { | |
| @@ -147,6 +164,7 @@ pub fn decode_to_mono(path: &Path) -> Result<DecodedAudio, CoreError> { | |||
| 147 | 164 | if mono_samples.is_empty() { | |
| 148 | 165 | return Err(AnalysisError::NoAudioData.into()); | |
| 149 | 166 | } | |
| 167 | + | sanitize_non_finite(&mut mono_samples); | |
| 150 | 168 | ||
| 151 | 169 | let duration = mono_samples.len() as f64 / source_sample_rate as f64; | |
| 152 | 170 | ||
| @@ -169,7 +187,7 @@ fn decode_wav_hound(path: &Path) -> Result<DecodedAudio, CoreError> { | |||
| 169 | 187 | let source_sample_rate = spec.sample_rate; | |
| 170 | 188 | let source_channels = spec.channels; | |
| 171 | 189 | ||
| 172 | - | let mono_samples: Vec<f32> = match spec.sample_format { | |
| 190 | + | let mut mono_samples: Vec<f32> = match spec.sample_format { | |
| 173 | 191 | hound::SampleFormat::Int => { | |
| 174 | 192 | let max_val = (1i64 << (spec.bits_per_sample - 1)) as f32; | |
| 175 | 193 | let all: Vec<f32> = reader | |
| @@ -191,6 +209,7 @@ fn decode_wav_hound(path: &Path) -> Result<DecodedAudio, CoreError> { | |||
| 191 | 209 | if mono_samples.is_empty() { | |
| 192 | 210 | return Err(AnalysisError::NoAudioData.into()); | |
| 193 | 211 | } | |
| 212 | + | sanitize_non_finite(&mut mono_samples); | |
| 194 | 213 | ||
| 195 | 214 | let duration = mono_samples.len() as f64 / source_sample_rate as f64; | |
| 196 | 215 | ||
| @@ -263,6 +282,43 @@ mod tests { | |||
| 263 | 282 | } | |
| 264 | 283 | ||
| 265 | 284 | #[test] | |
| 285 | + | fn sanitize_non_finite_replaces_with_silence() { | |
| 286 | + | let mut s = vec![0.5f32, f32::NAN, -0.5, f32::INFINITY, f32::NEG_INFINITY, 0.25]; | |
| 287 | + | sanitize_non_finite(&mut s); | |
| 288 | + | assert_eq!(s, vec![0.5, 0.0, -0.5, 0.0, 0.0, 0.25]); | |
| 289 | + | assert!(s.iter().all(|x| x.is_finite())); | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | #[test] | |
| 293 | + | fn float_wav_with_non_finite_decodes_to_finite() { | |
| 294 | + | // A 32-bit float WAV carrying NaN/Inf must decode to an all-finite | |
| 295 | + | // signal, so no non-finite value reaches the analysis pipeline. | |
| 296 | + | let dir = tempfile::tempdir().unwrap(); | |
| 297 | + | let path = dir.path().join("nonfinite.wav"); | |
| 298 | + | let spec = hound::WavSpec { | |
| 299 | + | channels: 1, | |
| 300 | + | sample_rate: 44100, | |
| 301 | + | bits_per_sample: 32, | |
| 302 | + | sample_format: hound::SampleFormat::Float, | |
| 303 | + | }; | |
| 304 | + | { | |
| 305 | + | let mut w = hound::WavWriter::create(&path, spec).unwrap(); | |
| 306 | + | for s in [0.5f32, f32::NAN, -0.5, f32::INFINITY, 0.25, f32::NEG_INFINITY] { | |
| 307 | + | w.write_sample(s).unwrap(); | |
| 308 | + | } | |
| 309 | + | w.finalize().unwrap(); | |
| 310 | + | } | |
| 311 | + | let decoded = decode_to_mono(&path).unwrap(); | |
| 312 | + | assert!( | |
| 313 | + | decoded.samples.iter().all(|s| s.is_finite()), | |
| 314 | + | "decoded samples must all be finite, got {:?}", | |
| 315 | + | decoded.samples | |
| 316 | + | ); | |
| 317 | + | // The finite samples are preserved; the non-finite ones became 0.0. | |
| 318 | + | assert_eq!(decoded.samples, vec![0.5, 0.0, -0.5, 0.0, 0.25, 0.0]); | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | #[test] | |
| 266 | 322 | fn decoded_audio_field_access() { | |
| 267 | 323 | let audio = DecodedAudio { | |
| 268 | 324 | samples: vec![0.0, 0.5, -0.5, 1.0], |