Skip to content

spike(rust/cli): wire CLI binary and fixture round-trip test #99

Description

@natashaannn

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

  1. Add serde = { version = "1", features = ["derive"] } and serde_json = "1" to spike/audio-sync/Cargo.toml.
  2. Implement load_wav_samples(path: &str) -> Result<(Vec<f32>, u32), Box<dyn std::error::Error>> in src/lib.rs.
  3. 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
  4. 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
  5. Run cargo test --manifest-path spike/audio-sync/Cargo.toml — all tests including fixture_roundtrip pass.
  6. Run the binary manually against the fixtures; confirm output is reasonable.
  7. 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.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions