Skip to main content

max / audiofiles

fix: harden the analysis pipeline against non-finite audio (G3/G4) A crafted or corrupt float WAV can decode to NaN/Inf. Left unguarded these propagate through analysis: loudness metrics persist NaN/Inf into audio_analysis, and a non-finite feature-vector component serializes (via serde_json) to JSON `null` in sample_features. cluster::load_vectors then fails to parse `null` back into Vec<f64> and `?`-aborted the WHOLE clustering run — so one bad sample broke clustering for the entire library. Fixes, from root cause outward: - decode: sanitize_non_finite replaces NaN/±Inf with 0.0 at the single decode boundary (both the symphonia and hound paths), giving every downstream stage the invariant that decoded audio is always finite. Legitimate PCM never has non-finite samples, so only invalid input is touched. - basic: peak_db/rms_db/crest_factor/attack_time now aggregate over finite samples only (mirroring waveform.rs), so they never emit NaN/Inf even if called directly. All-non-finite falls through to the existing silence floor. - cluster::load_vectors: skip rows that fail to parse or carry a non-finite component instead of aborting the whole run; mirrors the sibling exemplar::is_usable_vector guard this consumer was missing. +7 tests (decode float-WAV sanitize, basic finiteness, cluster null-row resilience). Investigation note: serde_json 1.0.150 serializes non-finite as `null` (not an error), which is what made this reachable. cargo test --workspace: 1090 pass; clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-04 19:51 UTC
Commit: ee8ffcd87240542696cb916b5d8367670d0ecf65
Parent: 3a3c131
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],