max / audiofiles
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; |