User story
As a coding agent implementing the Rust FFT spike (tracked in #93), I want a working CLI binary and a passing fixture round-trip test, so that the Rust binary can be run on each target machine and its output verified against the committed JS baseline.
Background
RFC 0001 (#90) proposes rewriting the editing pipeline in Rust. To validate the agent-driven Rust dev loop before committing, the team is running a spike: port the FFT cross-correlation algorithm from scripts/sync/AudioSyncer.js to a standalone Rust binary.
This issue wires the three algorithm functions (ported in #96, #97, #98) into a CLI binary that reads two WAV files and prints the computed lag. It also adds the fixture round-trip test — the definitive assertion that Rust and JS agree on the real episode fixtures committed in #94. This is the gate before cross-machine verification (#100).
Acceptance criteria
Happy path
Given the two fixture WAVs committed in #94 (spike/audio-sync/fixtures/video-audio.wav, spike/audio-sync/fixtures/audio-track.wav)
When cargo run --manifest-path spike/audio-sync/Cargo.toml -- spike/audio-sync/fixtures/video-audio.wav spike/audio-sync/fixtures/audio-track.wav is run
Then stdout prints a line in the format lag: <seconds> snr: <value> [LOW CONFIDENCE]? and exits 0
Given the fixture round-trip test runs
When cargo test --manifest-path spike/audio-sync/Cargo.toml fixture_roundtrip
Then the computed lag_seconds is within ±(1.0 / 8000.0) seconds of baseline.json's lagSeconds (±1 sample at 8 kHz)
Error path / edge case
Given the binary is called with a path to a non-existent file
When it runs
Then it prints a human-readable error to stderr and exits with a non-zero code — no panic/unwrap
Given the correlation peak has SNR < 3.0
When the binary runs
Then it prints [LOW CONFIDENCE] at the end of the output line
Out of scope
Technical context
WAV reading with hound:
fn load_wav_samples(path: &str) -> Result<(Vec<f32>, u32), Box<dyn std::error::Error>> {
let mut reader = hound::WavReader::open(path)?;
let spec = reader.spec();
let samples: Vec<f32> = match spec.sample_format {
hound::SampleFormat::Int => reader.samples::<i16>()
.map(|s| s.map(|v| v as f32 / i16::MAX as f32))
.collect::<Result<_, _>>()?,
hound::SampleFormat::Float => reader.samples::<f32>()
.collect::<Result<_, _>>()?,
};
Ok((samples, spec.sample_rate))
}
The fixture WAVs are 16-bit PCM (produced by ffmpeg -acodec pcm_s16le in #94), so the SampleFormat::Int branch is the active path. The i16::MAX normalization produces f32 values in [-1.0, 1.0], matching the JS wav.toBitDepth('32f') / getSamples(false, Float32Array) call in AudioSyncer.loadWavSamples.
CLI arg parsing: use std::env::args() directly — no clap or other arg-parsing crate needed for a two-argument binary.
Output format:
lag: -1.234s snr: 12.5 [LOW CONFIDENCE]
[LOW CONFIDENCE] is omitted when is_reliable == true.
Fixture round-trip test: read baseline.json from the path spike/audio-sync/fixtures/baseline.json relative to the crate root using include_str! or std::fs::read_to_string. Parse with serde_json (add serde_json = "1" and serde = { version = "1", features = ["derive"] } to Cargo.toml). Assert (lag - baseline.lag_seconds).abs() <= 1.0 / 8000.0.
Sample rate: the fixture WAVs were extracted at 8000 Hz (see #94). The load_wav_samples function returns the sample rate from the WAV header — pass it through to find_best_lag and validate_peak rather than hardcoding.
Implementation details
- Add
serde = { version = "1", features = ["derive"] } and serde_json = "1" to spike/audio-sync/Cargo.toml.
- Implement
load_wav_samples(path: &str) -> Result<(Vec<f32>, u32), Box<dyn std::error::Error>> in src/lib.rs.
- Implement
src/main.rs:
- Parse two CLI args as WAV file paths; error with a usage message if not exactly two are provided
- Call
load_wav_samples on each
- Call
compute_cross_correlation, find_best_lag, validate_peak in sequence
- Print the result line to stdout
- Add
#[test] fn fixture_roundtrip() in src/lib.rs (or a tests/ file):
- Load
spike/audio-sync/fixtures/baseline.json
- Load
spike/audio-sync/fixtures/video-audio.wav and spike/audio-sync/fixtures/audio-track.wav
- Run the full chain
- Assert lag is within ±1 sample
- Run
cargo test --manifest-path spike/audio-sync/Cargo.toml — all tests including fixture_roundtrip pass.
- Run the binary manually against the fixtures; confirm output is reasonable.
- Commit
spike/audio-sync/Cargo.toml, spike/audio-sync/Cargo.lock, spike/audio-sync/src/main.rs, spike/audio-sync/src/lib.rs.
Additional test scenarios
- Binary called with zero args: exits non-zero with usage message
- Binary called with one arg: exits non-zero with usage message
- Binary called with a path to a file that exists but is not a WAV: exits non-zero with a readable error (not a panic)
Hard constraints
- The fixture round-trip test must pass before this issue is closed — it is the primary correctness gate
- No
unwrap() or expect() calls in main.rs — all errors must be propagated and printed gracefully
- No changes to files outside
spike/
Dependency issues
Depends on #94 (fixtures + baseline.json must be committed).
Depends on #96 (compute_cross_correlation), #97 (find_best_lag), #98 (validate_peak) — all three functions must be in lib.rs.
#100 (cross-machine verification) depends on this issue being complete.
User story
As a coding agent implementing the Rust FFT spike (tracked in #93), I want a working CLI binary and a passing fixture round-trip test, so that the Rust binary can be run on each target machine and its output verified against the committed JS baseline.
Background
RFC 0001 (#90) proposes rewriting the editing pipeline in Rust. To validate the agent-driven Rust dev loop before committing, the team is running a spike: port the FFT cross-correlation algorithm from
scripts/sync/AudioSyncer.jsto a standalone Rust binary.This issue wires the three algorithm functions (ported in #96, #97, #98) into a CLI binary that reads two WAV files and prints the computed lag. It also adds the fixture round-trip test — the definitive assertion that Rust and JS agree on the real episode fixtures committed in #94. This is the gate before cross-machine verification (#100).
Acceptance criteria
Happy path
Given the two fixture WAVs committed in #94 (
spike/audio-sync/fixtures/video-audio.wav,spike/audio-sync/fixtures/audio-track.wav)When
cargo run --manifest-path spike/audio-sync/Cargo.toml -- spike/audio-sync/fixtures/video-audio.wav spike/audio-sync/fixtures/audio-track.wavis runThen stdout prints a line in the format
lag: <seconds> snr: <value> [LOW CONFIDENCE]?and exits 0Given the fixture round-trip test runs
When
cargo test --manifest-path spike/audio-sync/Cargo.toml fixture_roundtripThen the computed
lag_secondsis within ±(1.0 / 8000.0) seconds ofbaseline.json'slagSeconds(±1 sample at 8 kHz)Error path / edge case
Given the binary is called with a path to a non-existent file
When it runs
Then it prints a human-readable error to stderr and exits with a non-zero code — no panic/unwrap
Given the correlation peak has SNR < 3.0
When the binary runs
Then it prints
[LOW CONFIDENCE]at the end of the output lineOut of scope
Technical context
WAV reading with
hound:The fixture WAVs are 16-bit PCM (produced by
ffmpeg -acodec pcm_s16lein #94), so theSampleFormat::Intbranch is the active path. Thei16::MAXnormalization producesf32values in[-1.0, 1.0], matching the JSwav.toBitDepth('32f')/getSamples(false, Float32Array)call inAudioSyncer.loadWavSamples.CLI arg parsing: use
std::env::args()directly — noclapor other arg-parsing crate needed for a two-argument binary.Output format:
[LOW CONFIDENCE]is omitted whenis_reliable == true.Fixture round-trip test: read
baseline.jsonfrom the pathspike/audio-sync/fixtures/baseline.jsonrelative to the crate root usinginclude_str!orstd::fs::read_to_string. Parse withserde_json(addserde_json = "1"andserde = { version = "1", features = ["derive"] }toCargo.toml). Assert(lag - baseline.lag_seconds).abs() <= 1.0 / 8000.0.Sample rate: the fixture WAVs were extracted at 8000 Hz (see #94). The
load_wav_samplesfunction returns the sample rate from the WAV header — pass it through tofind_best_lagandvalidate_peakrather than hardcoding.Implementation details
serde = { version = "1", features = ["derive"] }andserde_json = "1"tospike/audio-sync/Cargo.toml.load_wav_samples(path: &str) -> Result<(Vec<f32>, u32), Box<dyn std::error::Error>>insrc/lib.rs.src/main.rs:load_wav_sampleson eachcompute_cross_correlation,find_best_lag,validate_peakin sequence#[test] fn fixture_roundtrip()insrc/lib.rs(or atests/file):spike/audio-sync/fixtures/baseline.jsonspike/audio-sync/fixtures/video-audio.wavandspike/audio-sync/fixtures/audio-track.wavcargo test --manifest-path spike/audio-sync/Cargo.toml— all tests includingfixture_roundtrippass.spike/audio-sync/Cargo.toml,spike/audio-sync/Cargo.lock,spike/audio-sync/src/main.rs,spike/audio-sync/src/lib.rs.Additional test scenarios
Hard constraints
unwrap()orexpect()calls inmain.rs— all errors must be propagated and printed gracefullyspike/Dependency issues
Depends on #94 (fixtures + baseline.json must be committed).
Depends on #96 (
compute_cross_correlation), #97 (find_best_lag), #98 (validate_peak) — all three functions must be inlib.rs.#100 (cross-machine verification) depends on this issue being complete.