From 7912bd512ab7856e31d5ab27362974c74ed7848a Mon Sep 17 00:00:00 2001 From: Natasha Ann Date: Sun, 12 Jul 2026 19:03:58 +0800 Subject: [PATCH 1/3] spike(rust/lag): port find_best_lag to Rust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replicates findBestLag from AudioSyncer.js (L188-216). The candidate-reset logic (clear candidates list on each new maximum) must match the JS exactly; without it, near-equal peaks early in the array can survive as candidates relative to a lower intermediate maximum, breaking the tie-break. PEAK_NEARNESS_THRESHOLD (0.5) and SYNC_FRAME_RATE (30) are module-level constants — the baseline.json fixture was computed with these values so they must not be changed here. The negative-lag test uses index 724 (= N-300) rather than 924 (= N-100) because 100 samples at 8000 Hz rounds to 0 frames; 300 samples rounds to -1 frame and survives quantization to produce a verifiable negative result. AC: all acceptance criteria from #97 --- spike/audio-sync/src/lib.rs | 107 ++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/spike/audio-sync/src/lib.rs b/spike/audio-sync/src/lib.rs index 6a23789..d844b28 100644 --- a/spike/audio-sync/src/lib.rs +++ b/spike/audio-sync/src/lib.rs @@ -1,5 +1,9 @@ use rustfft::{num_complex::Complex, FftPlanner}; +// Mirror of AudioSyncer.js constants — must not be changed; baseline.json was computed with these. +const PEAK_NEARNESS_THRESHOLD: f64 = 0.5; +const SYNC_FRAME_RATE: f64 = 30.0; + pub fn next_power_of_two(n: usize) -> usize { let mut p = 1usize; while p < n { @@ -44,6 +48,45 @@ pub fn compute_cross_correlation(samples_a: &[f32], samples_b: &[f32]) -> Vec f64 { + let n = correlation.len(); + let mut max_val = f64::NEG_INFINITY; + let mut candidates: Vec = Vec::new(); + + for (i, &v) in correlation.iter().enumerate() { + let abs_v = v.abs(); + if abs_v > max_val { + max_val = abs_v; + candidates.clear(); + candidates.push(i); + } else if (abs_v - max_val).abs() <= PEAK_NEARNESS_THRESHOLD { + candidates.push(i); + } + } + + // Earliest candidate = deterministic tie-break, matching JS `candidateIndices[0]`. + let max_idx = candidates.first().copied().unwrap_or(0); + + // Circular index → signed lag in samples. + let lag_samples = if max_idx <= n / 2 { + max_idx as f64 + } else { + max_idx as f64 - n as f64 + }; + + // Quantize to nearest frame boundary, return as seconds. + let lag_frames = (lag_samples * SYNC_FRAME_RATE / sample_rate as f64).round(); + lag_frames / SYNC_FRAME_RATE +} + #[cfg(test)] mod tests { use super::*; @@ -116,4 +159,68 @@ mod tests { // next_power_of_two(1 + 1 - 1) = next_power_of_two(1) = 1 assert_eq!(corr.len(), 1, "single-sample inputs should return length-1 result"); } + + // --- find_best_lag tests --- + + fn make_corr(len: usize, peak_idx: usize, peak_val: f64) -> Vec { + let mut c = vec![0.0f64; len]; + c[peak_idx] = peak_val; + c + } + + #[test] + fn find_best_lag_positive_lag() { + // Peak at index 100 in length-1024; K <= N/2 so positive lag. + // Expected: round(100 * 30 / 8000) / 30 = round(0.375) / 30 = 0.0 / 30 = 0.0 + let corr = make_corr(1024, 100, 1.0); + let lag = find_best_lag(&corr, 8000); + let expected = (100.0_f64 * 30.0 / 8000.0).round() / 30.0; + assert_eq!(lag, expected); + } + + #[test] + fn find_best_lag_negative_lag() { + // Peak at index 724 in length-1024; K > N/2 → lag = 724 - 1024 = -300 samples. + // -300 * 30 / 8000 = -1.125 → round to -1 frame → -1/30 s (survives quantization). + let corr = make_corr(1024, 724, 1.0); + let lag = find_best_lag(&corr, 8000); + assert!(lag < 0.0, "peak at index > N/2 should yield a negative lag, got {lag}"); + let expected = ((-300.0_f64) * 30.0 / 8000.0).round() / 30.0; + assert_eq!(lag, expected); + } + + #[test] + fn find_best_lag_tie_break_earliest_wins() { + // Two equal peaks at indices 50 and 200; earliest should win. + let mut corr = vec![0.0f64; 1024]; + corr[50] = 1.0; + corr[200] = 1.0; + let lag = find_best_lag(&corr, 8000); + let expected = (50.0_f64 * 30.0 / 8000.0).round() / 30.0; + assert_eq!(lag, expected, "tie-break should select earliest candidate (index 50)"); + } + + #[test] + fn find_best_lag_all_zeros_no_panic() { + let corr = vec![0.0f64; 1024]; + let lag = find_best_lag(&corr, 8000); + assert_eq!(lag, 0.0, "all-zero correlation should return 0.0"); + } + + #[test] + fn find_best_lag_peak_at_n_over_2_is_positive() { + // Peak exactly at N/2 = 512; boundary condition: maxIdx <= N/2 → positive lag. + let corr = make_corr(1024, 512, 1.0); + let lag = find_best_lag(&corr, 8000); + assert!(lag >= 0.0, "peak at exactly N/2 should be treated as positive lag, got {lag}"); + } + + #[test] + fn find_best_lag_one_frame_at_44100() { + // 1 frame at 30fps = 44100/30 = 1470 samples; result must be exactly 1/30 s. + let corr = make_corr(131072, 1470, 1.0); + let lag = find_best_lag(&corr, 44100); + let expected = 1.0_f64 / 30.0; + assert_eq!(lag, expected, "1470 samples at 44100 Hz should give exactly 1/30 s"); + } } From c912fd603ff9c0f090b9066614e756c240d271a4 Mon Sep 17 00:00:00 2001 From: Natasha Ann Date: Sun, 12 Jul 2026 19:21:45 +0800 Subject: [PATCH 2/3] fix(rust/lag): match JS Math.round for negative half-frame boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rust f64::round() rounds away from zero (-1.5 → -2), but JavaScript Math.round(-1.5) = -1 (rounds toward +infinity). The divergence is only observable at exact half-frame boundaries, which AudioSyncer.js handles with JS semantics. Fix: replace .round() with (x + 0.5).floor(). Adds regression test: lag_samples = -400 at 8000 Hz → -1.5 frames → must return -1/30 s, not -2/30 s. Also adds find_best_lag_positive_lag_nonzero at index 267 (→ 1/30 s) so the positive-lag AC has a test that asserts a nonzero result rather than relying solely on the index-100 case that quantizes to 0.0. --- spike/audio-sync/src/lib.rs | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/spike/audio-sync/src/lib.rs b/spike/audio-sync/src/lib.rs index d844b28..8e8c79a 100644 --- a/spike/audio-sync/src/lib.rs +++ b/spike/audio-sync/src/lib.rs @@ -83,7 +83,11 @@ pub fn find_best_lag(correlation: &[f64], sample_rate: u32) -> f64 { }; // Quantize to nearest frame boundary, return as seconds. - let lag_frames = (lag_samples * SYNC_FRAME_RATE / sample_rate as f64).round(); + // Use (x + 0.5).floor() to replicate JavaScript Math.round, which rounds half-frames + // towards +infinity. Rust's f64::round() rounds away from zero, so Math.round(-1.5) = -1 + // but (-1.5f64).round() = -2 — they diverge on negative half-frame boundaries. + let x = lag_samples * SYNC_FRAME_RATE / sample_rate as f64; + let lag_frames = (x + 0.5).floor(); lag_frames / SYNC_FRAME_RATE } @@ -171,13 +175,23 @@ mod tests { #[test] fn find_best_lag_positive_lag() { // Peak at index 100 in length-1024; K <= N/2 so positive lag. - // Expected: round(100 * 30 / 8000) / 30 = round(0.375) / 30 = 0.0 / 30 = 0.0 + // 100 * 30 / 8000 = 0.375 → 0 frames → 0.0 s (rounds to zero but formula is correct). let corr = make_corr(1024, 100, 1.0); let lag = find_best_lag(&corr, 8000); - let expected = (100.0_f64 * 30.0 / 8000.0).round() / 30.0; + let expected = (100.0_f64 * 30.0 / 8000.0 + 0.5).floor() / 30.0; assert_eq!(lag, expected); } + #[test] + fn find_best_lag_positive_lag_nonzero() { + // Peak at index 267 (≤ N/2 = 512, so positive lag). + // 267 * 30 / 8000 = 1.00125 → (1.00125 + 0.5).floor() = 1 frame → 1/30 s. + // Verifies a nonzero positive result so the "positive lag" AC has meaningful coverage. + let corr = make_corr(1024, 267, 1.0); + let lag = find_best_lag(&corr, 8000); + assert_eq!(lag, 1.0 / 30.0); + } + #[test] fn find_best_lag_negative_lag() { // Peak at index 724 in length-1024; K > N/2 → lag = 724 - 1024 = -300 samples. @@ -215,6 +229,17 @@ mod tests { assert!(lag >= 0.0, "peak at exactly N/2 should be treated as positive lag, got {lag}"); } + #[test] + fn find_best_lag_negative_half_frame_regression() { + // lag_samples = -400 → -400 * 30 / 8000 = -1.5 frames. + // JS Math.round(-1.5) = -1; Rust f64::round(-1.5) = -2. + // The (x + 0.5).floor() fix must return -1/30, not -2/30. + let n = 1024usize; + let corr = make_corr(n, n - 400, 1.0); + let lag = find_best_lag(&corr, 8000); + assert_eq!(lag, -1.0 / 30.0, "negative half-frame should round toward +inf like JS Math.round"); + } + #[test] fn find_best_lag_one_frame_at_44100() { // 1 frame at 30fps = 44100/30 = 1470 samples; result must be exactly 1/30 s. From d49a42e670bb45753bd3b6266fd8b7dbb467ec38 Mon Sep 17 00:00:00 2001 From: Natasha Ann Date: Sun, 12 Jul 2026 20:27:31 +0800 Subject: [PATCH 3/3] test(rust/lag): align expected values with (x + 0.5).floor() formula find_best_lag_negative_lag and find_best_lag_tie_break_earliest_wins were computing their expected values with .round(), which agrees with the fix for all non-half-integer inputs but diverges at half-frame boundaries. Using the same formula as the implementation makes the tests self-documenting and safe if either test input is later modified. --- spike/audio-sync/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spike/audio-sync/src/lib.rs b/spike/audio-sync/src/lib.rs index 8e8c79a..64a818c 100644 --- a/spike/audio-sync/src/lib.rs +++ b/spike/audio-sync/src/lib.rs @@ -199,7 +199,7 @@ mod tests { let corr = make_corr(1024, 724, 1.0); let lag = find_best_lag(&corr, 8000); assert!(lag < 0.0, "peak at index > N/2 should yield a negative lag, got {lag}"); - let expected = ((-300.0_f64) * 30.0 / 8000.0).round() / 30.0; + let expected = ((-300.0_f64) * 30.0 / 8000.0 + 0.5).floor() / 30.0; assert_eq!(lag, expected); } @@ -210,7 +210,7 @@ mod tests { corr[50] = 1.0; corr[200] = 1.0; let lag = find_best_lag(&corr, 8000); - let expected = (50.0_f64 * 30.0 / 8000.0).round() / 30.0; + let expected = (50.0_f64 * 30.0 / 8000.0 + 0.5).floor() / 30.0; assert_eq!(lag, expected, "tie-break should select earliest candidate (index 50)"); }