Skip to main content

max / audiofiles

Audio/DSP: AIFF stream length backpatch, rate reconcile, NaN/MFCC fixes Ultra-fuzz run-6 remediation, Audio & DSP axis. - export AIFF streaming: the resampler can emit fewer frames than the up-front round(in*ratio) estimate the header was sized from, leaving a container whose COMM numSampleFrames / SSND size overstate the real sample count (strict hardware-sampler parsers reject that). AiffSink now tracks samples written and backpatches the fixed-size header at finalize to the true count -- matching the buffered path and WAV streaming (which hound already backpatches). New test asserts numSampleFrames equals data bytes across several odd-ratio resamples. - analysis decode: reconcile the decoded packet spec.rate (was read then discarded) against the declared codec rate; trust the decoded rate so duration/BPM/loop math can't follow a lying container header. - similarity feature_distance: treat a non-finite (NaN/Inf) stored or normalized feature as missing/imputed, so it can't produce a NaN distance that silently fails the VP-tree prune comparisons and drops real nearest neighbours. - mfcc: force mel filterbank bin edges strictly increasing so adjacent low-frequency filters no longer collapse onto the same center bin (which fed duplicated energies into the DCT). FEATURE_VERSION 3 -> 4 triggers backfill. - fingerprint: add a trimmed-duplicate recall test (the real near-dup case, not an identical envelope) -- the index recalls it at the current summary radius. Replace the dead f64::MIN+1.0 "no overlap" sentinel with NEG_INFINITY/is_finite. - minors: bpm key_confidence gated on key presence; loop_detect denom epsilon guard; export mono downmix documented as a deliberate unity-gain choice (Max). 586 core tests pass (3 new); clippy clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-23 15:51 UTC
Commit: 354a271a8d4009bb1497c2de7a9c58d3062dd050
Parent: 0e3cf3a
9 files changed, +182 insertions, -17 deletions
@@ -71,8 +71,10 @@ pub fn detect_bpm_key(samples: &[f32], sample_rate: u32, min_duration: f64) -> B
71 71 BpmKeyResult {
72 72 bpm,
73 73 bpm_confidence: bpm.map(|_| result.bpm_confidence),
74 + // Gate key_confidence on key presence, mirroring bpm_confidence: a
75 + // confidence for a key that was filtered to None is meaningless.
76 + key_confidence: key.as_ref().map(|_| result.key_confidence),
74 77 key,
75 - key_confidence: Some(result.key_confidence),
76 78 }
77 79 }
78 80
@@ -25,7 +25,12 @@ pub const NUM_FEATURES: usize = 35;
25 25 ///
26 26 /// v3: `onset_strength` switched to *normalised* spectral flux (loudness-invariant),
27 27 /// so that dim of the vector changed scale. Same backfill-on-next-launch path.
28 - pub const FEATURE_VERSION: u32 = 3;
28 + ///
29 + /// v4: mel filterbank bin edges are now forced strictly increasing, so adjacent
30 + /// low-frequency filters no longer collapse onto the same center bin (which had
31 + /// fed duplicated energies into the DCT). The MFCC dims changed numerically;
32 + /// backfill-on-next-launch as before.
33 + pub const FEATURE_VERSION: u32 = 4;
29 34
30 35 // ── SampleClass enum (unchanged) ──
31 36
@@ -72,7 +72,7 @@ pub fn decode_to_mono(path: &Path) -> Result<DecodedAudio, CoreError> {
72 72 .ok_or(AnalysisError::NoAudioTrack)?;
73 73
74 74 let track_id = track.id;
75 - let source_sample_rate = track
75 + let mut source_sample_rate = track
76 76 .codec_params
77 77 .sample_rate
78 78 .ok_or(AnalysisError::ProbeFailed("missing sample rate".to_string()))?;
@@ -117,6 +117,19 @@ pub fn decode_to_mono(path: &Path) -> Result<DecodedAudio, CoreError> {
117 117 continue;
118 118 }
119 119
120 + // Reconcile the decoded rate with the declared codec rate. They normally
121 + // agree; if a container's header lies, the decoded spec is the truth, and
122 + // duration/BPM/loop math must not trust the wrong declared rate. The specs
123 + // are identical across packets, so this fires at most once.
124 + if spec.rate != 0 && spec.rate != source_sample_rate {
125 + tracing::warn!(
126 + declared = source_sample_rate,
127 + decoded = spec.rate,
128 + "decoded sample rate disagrees with declared header; using decoded rate"
129 + );
130 + source_sample_rate = spec.rate;
131 + }
132 +
120 133 let mut sample_buf = SampleBuffer::<f32>::new(num_frames as u64, *decoded.spec());
121 134 sample_buf.copy_interleaved_ref(decoded);
122 135 let samples = sample_buf.samples();
@@ -66,10 +66,12 @@ fn start_end_correlation(samples: &[f32]) -> f64 {
66 66 var_e += de * de; // end variance numerator
67 67 }
68 68
69 - // denom = sqrt(var_s * var_e). If zero, one or both windows are constant (silent),
70 - // so there's no meaningful correlation — return 0.
69 + // denom = sqrt(var_s * var_e). Near zero, one or both windows are effectively
70 + // constant (silent), so there's no meaningful correlation — return 0. An
71 + // epsilon (not exact == 0.0) guards against a tiny denormal denom blowing up
72 + // the ratio before the clamp.
71 73 let denom = (var_s * var_e).sqrt();
72 - if denom == 0.0 {
74 + if denom < 1e-12 {
73 75 0.0
74 76 } else {
75 77 (cov / denom).clamp(-1.0, 1.0)
@@ -69,10 +69,23 @@ fn build_mel_filterbank(fft_size: usize, sample_rate: u32, num_filters: usize) -
69 69 .collect();
70 70
71 71 let hz_points: Vec<f64> = mel_points.iter().map(|&m| mel_to_hz(m)).collect();
72 - let bin_points: Vec<usize> = hz_points
72 + let mut bin_points: Vec<usize> = hz_points
73 73 .iter()
74 74 .map(|&hz| ((hz / freq_bin_hz).round() as usize).min(spectrum_len - 1))
75 75 .collect();
76 + // Force strictly increasing bins. At low frequencies the mel points are
77 + // spaced less than one FFT bin apart, so plain rounding maps several adjacent
78 + // mel filters onto the *same* center bin — feeding duplicated, correlated
79 + // energies into the DCT and reintroducing the constant ln(1e-10) floor the
80 + // degenerate path tries to avoid. Nudging each point at least one bin past the
81 + // previous (clamped to the last bin) keeps every filter's start<center<end
82 + // distinct. The natural spacing already exceeds one bin higher up, so this is
83 + // a no-op there; num_filters+2 points fit well within spectrum_len.
84 + for i in 1..bin_points.len() {
85 + if bin_points[i] <= bin_points[i - 1] {
86 + bin_points[i] = (bin_points[i - 1] + 1).min(spectrum_len - 1);
87 + }
88 + }
76 89
77 90 let mut filters = Vec::with_capacity(num_filters);
78 91
@@ -29,6 +29,11 @@ pub fn convert_channels(
29 29 if src_channels == 1 {
30 30 return (samples.to_vec(), 1);
31 31 }
32 + // Unity-gain average downmix (sum / channels). Deliberate choice (not an
33 + // equal-power -3 dB fold): correct for the common correlated/dual-mono
34 + // material a hardware-sampler "conform to mono" target receives. Known
35 + // trade-off: anti-correlated content (true L/-R) folds quieter or to
36 + // silence — accepted rather than shift the level of every normal file.
32 37 let ch = src_channels as usize;
33 38 let num_frames = samples.len() / ch;
34 39 let mut mono = Vec::with_capacity(num_frames);
@@ -16,7 +16,7 @@
16 16 //! to the buffered pipeline.
17 17
18 18 use std::fs::File;
19 - use std::io::{BufWriter, Write};
19 + use std::io::{BufWriter, Seek, SeekFrom, Write};
20 20 use std::path::Path;
21 21 use std::sync::atomic::{AtomicBool, Ordering};
22 22
@@ -217,9 +217,13 @@ impl StreamSink for WavSink {
217 217 /// buffered `encode_aiff` byte-for-byte.
218 218 struct AiffSink {
219 219 writer: BufWriter<File>,
220 + channels: u16,
221 + sample_rate: u32,
220 222 bit_depth: u16,
221 223 rng: SimpleRng,
222 - needs_pad: bool,
224 + /// Samples (not frames) actually written, so finalize can backpatch the
225 + /// header with the true frame count rather than the up-front estimate.
226 + samples_written: u64,
223 227 }
224 228 impl AiffSink {
225 229 fn create(
@@ -232,29 +236,50 @@ impl AiffSink {
232 236 if channels == 0 {
233 237 return Err(CoreError::Export("AIFF: 0 channels".to_string()));
234 238 }
235 - let (header, needs_pad) = aiff_header_bytes(channels, sample_rate, bit_depth, out_frames)?;
239 + // Initial header sized to the estimate; backpatched at finalize with the
240 + // exact count. The header is a fixed-size block so the rewrite is in place.
241 + let (header, _) = aiff_header_bytes(channels, sample_rate, bit_depth, out_frames)?;
236 242 let file = File::create(dest).map_err(|e| io_err(dest, e))?;
237 243 let mut writer = BufWriter::new(file);
238 244 writer.write_all(&header).map_err(|e| io_err(dest, e))?;
239 245 Ok(Self {
240 246 writer,
247 + channels,
248 + sample_rate,
241 249 bit_depth,
242 250 rng: SimpleRng::new(DITHER_SEED),
243 - needs_pad,
251 + samples_written: 0,
244 252 })
245 253 }
246 254 }
247 255 impl StreamSink for AiffSink {
248 256 fn write_sample(&mut self, sample: f32) -> Result<(), CoreError> {
249 - write_aiff_sample(&mut self.writer, sample, self.bit_depth, &mut self.rng)
257 + write_aiff_sample(&mut self.writer, sample, self.bit_depth, &mut self.rng)?;
258 + self.samples_written += 1;
259 + Ok(())
250 260 }
251 261 fn finalize(mut self) -> Result<(), CoreError> {
252 - if self.needs_pad {
262 + // The resampler can emit fewer frames than the up-front estimate for some
263 + // ratios (round(in*ratio) overestimates the true output). Backpatch COMM
264 + // numSampleFrames and the SSND/FORM sizes to the frames actually written
265 + // so the container never overstates its sample count (strict hardware-
266 + // sampler parsers reject that). Matches the buffered path, which sizes its
267 + // header from the real output, and WAV streaming, which hound backpatches.
268 + let frames = (self.samples_written / self.channels.max(1) as u64) as usize;
269 + let (header, needs_pad) =
270 + aiff_header_bytes(self.channels, self.sample_rate, self.bit_depth, frames)?;
271 + if needs_pad {
253 272 self.writer
254 273 .write_all(&[0u8])
255 274 .map_err(|e| CoreError::Export(format!("AIFF pad: {e}")))?;
256 275 }
257 276 self.writer
277 + .seek(SeekFrom::Start(0))
278 + .map_err(|e| CoreError::Export(format!("AIFF seek: {e}")))?;
279 + self.writer
280 + .write_all(&header)
281 + .map_err(|e| CoreError::Export(format!("AIFF header backpatch: {e}")))?;
282 + self.writer
258 283 .flush()
259 284 .map_err(|e| CoreError::Export(format!("AIFF flush: {e}")))
260 285 }
@@ -708,6 +733,58 @@ mod tests {
708 733 }
709 734 }
710 735
736 + #[test]
737 + fn aiff_streaming_header_frame_count_matches_data() {
738 + // The AIFF header is written up front from round(in*ratio); the resampler
739 + // can emit fewer frames for some ratios. finalize() must backpatch
740 + // numSampleFrames + SSND size to the frames actually written, so the
741 + // header never overstates the real sample count. Verify across several
742 + // odd-ratio resamples that the declared count equals the bytes present.
743 + for (src_rate, dst_rate) in [(44100u32, 48000u32), (48000, 44100), (44100, 22050), (32000, 44100)] {
744 + for bd in [16u16, 24] {
745 + let dir = tempfile::tempdir().unwrap();
746 + let src = dir.path().join("src.wav");
747 + let samples: Vec<f32> =
748 + (0..7777).map(|i| ((i % 53) as f32 / 53.0) - 0.5).collect();
749 + write_wav(&src, 1, src_rate, &samples);
750 +
751 + let out = dir.path().join("out.aiff");
752 + try_stream_aiff_export(
753 + &src,
754 + &out,
755 + &ExportChannels::Original,
756 + Some(dst_rate),
757 + bd,
758 + &AtomicBool::new(false),
759 + )
760 + .unwrap();
761 +
762 + let bytes = std::fs::read(&out).unwrap();
763 + // numSampleFrames: BE u32 at offset 22; SSND chunk size: BE u32 at
764 + // offset 42 (= 8 + sample_data_size).
765 + let num_frames =
766 + u32::from_be_bytes([bytes[22], bytes[23], bytes[24], bytes[25]]) as usize;
767 + let ssnd_size =
768 + u32::from_be_bytes([bytes[42], bytes[43], bytes[44], bytes[45]]) as usize;
769 + let sample_data = ssnd_size - 8;
770 + let bytes_per_sample = (bd / 8) as usize;
771 + assert_eq!(
772 + num_frames * bytes_per_sample,
773 + sample_data,
774 + "COMM numSampleFrames disagrees with SSND data size ({src_rate}->{dst_rate}, {bd}-bit)"
775 + );
776 + // And the declared data must actually be present on disk (header is
777 + // 54 bytes; a trailing pad byte may follow odd data).
778 + let on_disk = bytes.len() - 54;
779 + assert!(
780 + on_disk == sample_data || on_disk == sample_data + 1,
781 + "SSND data size overstates bytes on disk ({src_rate}->{dst_rate}, {bd}-bit): \
782 + declared {sample_data}, on disk {on_disk}"
783 + );
784 + }
785 + }
786 + }
787 +
711 788 /// Edit: streamed gain/fade must equal decode → apply_edit → encode_wav(24).
712 789 fn assert_edit_streaming_matches_buffered(
713 790 channels: u16,
@@ -180,7 +180,9 @@ pub fn envelope_distance(a: &[u8], b: &[u8]) -> (f64, i64) {
180 180 // 50 blocks = 0.5s tolerance at 100 Hz envelope rate
181 181 let max_shift = len_diff + 50;
182 182
183 - let mut best_corr = f64::MIN;
183 + // NEG_INFINITY, not MIN: the "no valid overlap" guard below tests is_finite,
184 + // and MIN + 1.0 == MIN made the original guard dead (it never fired).
185 + let mut best_corr = f64::NEG_INFINITY;
184 186 let mut best_offset: i64 = 0;
185 187
186 188 // Slide shorter over longer at each offset from -max_shift to +max_shift.
@@ -209,7 +211,8 @@ pub fn envelope_distance(a: &[u8], b: &[u8]) -> (f64, i64) {
209 211 }
210 212 }
211 213
212 - if best_corr < f64::MIN + 1.0 {
214 + if !best_corr.is_finite() {
215 + // No offset had enough overlap to correlate.
213 216 return (1.0, 0);
214 217 }
215 218
@@ -656,4 +659,44 @@ mod tests {
656 659 assert!((l.score - i.score).abs() < 1e-10);
657 660 }
658 661 }
662 +
663 + #[test]
664 + fn index_recalls_trimmed_duplicate() {
665 + // The recall case the index exists to handle: a *trimmed* near-duplicate,
666 + // not an identical envelope. env_b is the middle slice of env_a, so at the
667 + // aligning offset the cross-correlation is ~1.0 (a genuine duplicate by the
668 + // linear definition). This proves the 16-bin summary prune at
669 + // SUMMARY_SEARCH_RADIUS does NOT drop it — the gap the old identical-only
670 + // test never exercised.
671 + let db = crate::db::Database::open_in_memory().unwrap();
672 + insert_fake_sample(&db, "full");
673 + insert_fake_sample(&db, "trim");
674 +
675 + let env_full: Vec<u8> = (0..200).map(|i| ((i * 3) % 256) as u8).collect();
676 + let env_trim: Vec<u8> = env_full[20..180].to_vec();
677 +
678 + for (h, e) in [("full", &env_full), ("trim", &env_trim)] {
679 + save_fingerprint(
680 + &db,
681 + &Fingerprint { hash: h.to_string(), envelope: e.clone(), sample_rate: 44100 },
682 + )
683 + .unwrap();
684 + }
685 +
686 + // The trimmed slice is a near-duplicate by the linear (cross-correlation)
687 + // definition: best-alignment score is under the duplicate threshold.
688 + let (score, _) = envelope_distance(&env_full, &env_trim);
689 + assert!(
690 + score <= DUPLICATE_THRESHOLD,
691 + "trimmed slice should be a near-duplicate, score = {score}"
692 + );
693 +
694 + // The index must recall it (summary prune must not drop it).
695 + let idx = FingerprintIndex::build(&db).unwrap();
696 + let hits = idx.find_near_duplicates("full", &env_full, 50);
697 + assert!(
698 + hits.iter().any(|h| h.hash == "trim"),
699 + "index dropped a genuine trimmed duplicate; SUMMARY_SEARCH_RADIUS too tight"
700 + );
701 + }
659 702 }
@@ -145,8 +145,13 @@ pub fn feature_distance(a: &FeatureVector, b: &FeatureVector, weights: &FeatureW
145 145 let mut sum = 0.0;
146 146 let mut total_weight = 0.0;
147 147 for (va, vb, w) in &pairs {
148 - let va = va.unwrap_or(FEATURE_IMPUTE);
149 - let vb = vb.unwrap_or(FEATURE_IMPUTE);
148 + // Treat a non-finite stored/normalized feature as missing. A NaN or Inf
149 + // (a corrupt DB value, or a degenerate normalization range) would
150 + // otherwise yield a NaN distance which, ordered via total_cmp so it never
151 + // panics, silently fails the VP-tree's prune comparisons and drops real
152 + // nearest neighbours. Imputing keeps the metric well-defined.
153 + let va = va.filter(|v| v.is_finite()).unwrap_or(FEATURE_IMPUTE);
154 + let vb = vb.filter(|v| v.is_finite()).unwrap_or(FEATURE_IMPUTE);
150 155 let diff = va - vb;
151 156 sum += w * diff * diff;
152 157 total_weight += w;