diff --git a/.husky/pre-push b/.husky/pre-push index 00076a7..8876945 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -57,7 +57,7 @@ if grep -rEn \ 'ghp_[A-Za-z0-9]{36}|AKIA[0-9A-Z]{16}|sk-ant-[A-Za-z0-9_\-]{90,}|sk-[A-Za-z0-9]{48}' \ --include='*.ts' --include='*.tsx' --include='*.js' \ --include='*.json' --include='*.py' \ - --exclude-dir=node_modules --exclude-dir=.next \ + --exclude-dir=node_modules --exclude-dir=.next --exclude-dir=.venv \ .; then echo "✗ Secret token detected — remove from source before pushing." exit 1 diff --git a/spike/audio-sync/Cargo.lock b/spike/audio-sync/Cargo.lock index 83dd763..cc74d43 100644 --- a/spike/audio-sync/Cargo.lock +++ b/spike/audio-sync/Cargo.lock @@ -8,6 +8,8 @@ version = "0.1.0" dependencies = [ "hound", "rustfft", + "serde", + "serde_json", ] [[package]] @@ -22,6 +24,18 @@ version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + [[package]] name = "num-complex" version = "0.4.6" @@ -58,6 +72,24 @@ dependencies = [ "num-integer", ] +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + [[package]] name = "rustfft" version = "6.4.1" @@ -72,12 +104,66 @@ dependencies = [ "transpose", ] +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "strength_reduce" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "transpose" version = "0.2.3" @@ -87,3 +173,15 @@ dependencies = [ "num-integer", "strength_reduce", ] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/spike/audio-sync/Cargo.toml b/spike/audio-sync/Cargo.toml index 88163f4..5a6af5f 100644 --- a/spike/audio-sync/Cargo.toml +++ b/spike/audio-sync/Cargo.toml @@ -6,3 +6,5 @@ edition = "2021" [dependencies] rustfft = "6" hound = "3" +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/spike/audio-sync/README.md b/spike/audio-sync/README.md index 632cc79..5adf61e 100644 --- a/spike/audio-sync/README.md +++ b/spike/audio-sync/README.md @@ -38,9 +38,24 @@ cargo build ## Run +Against the committed fixture WAVs (from repo root): + +```bash +cargo run --manifest-path spike/audio-sync/Cargo.toml -- spike/audio-sync/fixtures/video-audio.wav spike/audio-sync/fixtures/audio-track.wav +# → lag: -6.800000s snr: 9.815 +``` + +Against any two WAV files: + +```bash +cargo run --manifest-path spike/audio-sync/Cargo.toml -- +``` + +## Test + ```bash -cargo run --manifest-path spike/audio-sync/Cargo.toml -# → audio-sync spike — not yet implemented +cargo test --manifest-path spike/audio-sync/Cargo.toml +# 19 tests — includes fixture_roundtrip which asserts lag matches baseline.json to ±1 sample ``` ## Dependencies diff --git a/spike/audio-sync/src/lib.rs b/spike/audio-sync/src/lib.rs index 7b2675e..491f40c 100644 --- a/spike/audio-sync/src/lib.rs +++ b/spike/audio-sync/src/lib.rs @@ -1,5 +1,20 @@ use rustfft::{num_complex::Complex, FftPlanner}; +/// Loads mono PCM samples from a WAV file, normalising to `[-1.0, 1.0]`. +/// Supports 16-bit integer PCM (the fixture format) and 32-bit float WAV. +pub fn load_wav_samples(path: &str) -> Result<(Vec, u32), Box> { + let mut reader = hound::WavReader::open(path)?; + let spec = reader.spec(); + let samples: Vec = match spec.sample_format { + hound::SampleFormat::Int => reader + .samples::() + .map(|s| s.map(|v| v as f32 / i16::MAX as f32)) + .collect::>()?, + hound::SampleFormat::Float => reader.samples::().collect::>()?, + }; + Ok((samples, spec.sample_rate)) +} + // 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; @@ -356,4 +371,33 @@ mod tests { assert_eq!(snr, 0.0, "length-1 array must return snr=0.0, got {snr}"); assert!(!is_reliable, "length-1 array must not be reliable"); } + + // --- fixture round-trip test --- + + #[test] + fn fixture_roundtrip() { + // Run the full Rust pipeline against the committed fixture WAVs and assert + // the lag matches the JS baseline to within ±1 sample at 8 kHz. + let baseline_json = std::fs::read_to_string("fixtures/baseline.json") + .expect("fixtures/baseline.json not found — run cargo test from spike/audio-sync/"); + let baseline: serde_json::Value = + serde_json::from_str(&baseline_json).expect("invalid baseline.json"); + let baseline_lag: f64 = baseline["lagSeconds"] + .as_f64() + .expect("baseline.json missing lagSeconds"); + + let (samples_a, sample_rate) = + load_wav_samples("fixtures/video-audio.wav").expect("failed to load video-audio.wav"); + let (samples_b, _) = + load_wav_samples("fixtures/audio-track.wav").expect("failed to load audio-track.wav"); + + let correlation = compute_cross_correlation(&samples_a, &samples_b); + let lag = find_best_lag(&correlation, sample_rate); + + let tolerance = 1.0 / sample_rate as f64; // ±1 sample + assert!( + (lag - baseline_lag).abs() <= tolerance, + "Rust lag {lag:.6}s differs from JS baseline {baseline_lag:.6}s by more than ±1 sample ({tolerance:.9}s)" + ); + } } diff --git a/spike/audio-sync/src/main.rs b/spike/audio-sync/src/main.rs index a347589..98babb5 100644 --- a/spike/audio-sync/src/main.rs +++ b/spike/audio-sync/src/main.rs @@ -1,3 +1,27 @@ +use audio_sync::{compute_cross_correlation, find_best_lag, load_wav_samples, validate_peak}; + fn main() { - println!("audio-sync spike — not yet implemented"); + let args: Vec = std::env::args().collect(); + if args.len() != 3 { + eprintln!("Usage: audio-sync "); + std::process::exit(1); + } + + let run = || -> Result<(), Box> { + let (samples_a, sample_rate) = load_wav_samples(&args[1])?; + let (samples_b, _) = load_wav_samples(&args[2])?; + + let correlation = compute_cross_correlation(&samples_a, &samples_b); + let lag = find_best_lag(&correlation, sample_rate); + let (snr, is_reliable) = validate_peak(&correlation, lag, sample_rate); + + let confidence = if is_reliable { "" } else { " [LOW CONFIDENCE]" }; + println!("lag: {lag:.6}s snr: {snr:.3}{confidence}"); + Ok(()) + }; + + if let Err(e) = run() { + eprintln!("error: {e}"); + std::process::exit(1); + } }