feat: add pure-Rust earshot VAD backend - #55
Conversation
Adds an `earshot` feature wrapping the pure-Rust earshot 1.2.1 crate as a VoiceActivityDetector backend: 16 kHz, 256-sample (16 ms) frames, raw continuous score in 0.0..=1.0. Why: a downstream host that links LiveKit's webrtc-sys cannot also link webrtc-vad. Both export unmangled WebRtcSpl_* symbols from identically named object files, and strict linkers (Wild) reject the duplicate definitions, so the host binary fails to link at all. Earshot links no native code, so `--no-default-features --features earshot` resolves to a graph with no webrtc-vad, no ONNX runtime, and no build-time downloader. scripts/check_earshot_isolation.sh and tests/feature_isolation.rs assert that and run in CI, so it cannot regress silently. The backend is a raw primitive: one mutable detector per stream, no interior mutability, no globals, no thresholding, hangover, endpointing, or call control. Wrong sample rate and wrong frame length are rejected with typed errors before any state is touched. FrameAdapter gains a zero-allocation core, `process_each`, which scores complete frames straight out of the caller's slice and copies only the trailing partial frame. `process_latest` and `process` are now built on it and allocate nothing; `process_all` remains as the documented allocating convenience wrapper. This also fixes a carry-buffer defect in the adapter. `process` and `process_all` appended the whole input to the carry buffer and drained a single frame per call, so multi-frame input left `frame_size` or more samples buffered. With the real consumer's shape -- 20 ms / 320-sample ingress chunks into 16 ms / 256-sample frames -- that leaked 64 samples per call and the buffer grew without bound. The invariant is now `buffered_samples() < frame_size` after every call, including one that returns an error, and it is asserted across every API, chunk size, and frame size. Tests: earshot capabilities, typed rejection of bad rate/length, score finiteness and range, reset() reproducing a fresh detector's exact output sequence, state isolation between concurrent detectors, adapter framing asserting exact sample ORDERING for one-at-a-time / 320-sample / multi- frame / partial-carry inputs, the carry invariant, and allocation counts measured with a per-thread counting global allocator (with a self-test proving the counter is not vacuous). Also fixes a pre-existing `unused_mut` in benches/vad_comparison.rs that made `clippy --all-targets --no-default-features` fail on main. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LgbQJio8v72Enp6ErxU19S
|
Hi @mikefaille, thanks for the PR! Would you mind taking a look at the CI/CD failures? We'll need them green before we can merge. What do you think? |
|
@wavekat-eason I just fixed the clippy issues as requested. |
Hi @mikefaille — thanks very much for your quick response and fix! The error I meant is this one: https://github.com/wavekat/wavekat-vad/actions/runs/31041696942/job/92562594868 Sorry for not specifying which error I was seeing yesterday. |
`features (*)` has failed on every matrix entry since this branch opened, on both tests in tests/feature_isolation.rs, while `check` and `earshot-isolation` passed. The dependency graph was always correct; the test could not read it. .github/workflows/ci.yml sets `CARGO_TERM_COLOR: always` at workflow scope, so the `cargo tree` this test spawns inherits it and emits SGR escapes. `crate_names` trimmed leading non-alphanumeric characters to find each node's name, which eats `ESC` and `[` and then stops at the `2` of `\x1b[2m` — `2` is alphanumeric — so every node parsed as `2m├──\x1b[0m`. Hence a panic claiming earshot is absent while printing a tree whose first line is `├── earshot v1.2.1`, and hence `the_isolation_check_is_not_vacuous` failing alongside it: that test exists to catch precisely this, and it did. - `cargo_tree` now sets `CARGO_TERM_COLOR=never`, removing the cause. - `crate_names` strips CSI sequences, so the parser is correct for any input rather than only for the input we currently arrange to receive. - `crate_names_reads_a_colour_escaped_tree` pins that with a coloured fixture, so the regression cannot return silently. Verified by reproducing CI locally rather than assuming: with `CARGO_TERM_COLOR=always` set, the pre-fix tests give `0 passed; 2 failed` (exit 101) and the fixed ones give `3 passed; 0 failed`.
|
Thanks for pointing at the specific job — that was the one I needed. Cause. The dependency graph was always correct; the test could not read it. That is why the failure looks self-contradictory: the panic claims It explains the job pattern too: Fix (97bf6d0):
Verified by reproducing CI locally rather than assuming. With One unrelated observation, take it or leave it. The run: cargo test --workspace 2>&1 | tee test-output.txtThe step's exit status is |
|
One thing that will otherwise keep this stalled: the workflow run for the fix is sitting in GitHub holds workflow runs on fork PRs pending a maintainer's approval, so the checks on this PR still show the failure from the 20-day-old run against There are two commits since that last run: Happy to drop the earshot bump into a separate PR if you would rather keep this one to the backend itself — say the word and I will split it. |
Adds an
earshotfeature wrapping the pure-Rust earshot 1.2.1 crate as aVoiceActivityDetectorbackend, plus a zero-allocation core forFrameAdapterand a fix for a carry-buffer defect found while wiring it up.Additive only: no existing feature, default, or public signature changes behaviour.
Why
A host that links LiveKit's
webrtc-syscannot also linkwebrtc-vad. Both bundle libfvad and export unmangledWebRtcSpl_*symbols from identically-named object files. Permissive linkers pick one; strict ones (Wild) reject the duplicate definitions and the host binary fails to link at all — so today thewebrtcbackend is simply unavailable to those hosts, and the remaining backends all pull ONNX Runtime plus a build-time model downloader.Earshot links no native code, so
--no-default-features --features earshotresolves to a graph with nowebrtc-vad, no ONNX runtime, and no downloader. That is the whole point of the backend, so it is asserted rather than assumed — see Guarding the guarantee below.The backend
EarshotVadis a raw primitive, matching the crate's existing posture:0.0..=1.0. No thresholding, hangover, endpointing, or call control — that stays the caller's policy decision.reset()restores exactly the state of a freshly constructed detector — there is a test asserting it reproduces a fresh detector's output sequence sample-for-sample.Detectorkeeps ~8 KiB of state inline, so it is built withdefault_boxed()rather than through an 8 KiB stack temporary.FrameAdapter::process_each— a zero-allocation coreEarshot wants 256-sample frames while typical transports deliver 20 ms / 320-sample packets, so this backend leans on
FrameAdapterharder than the others do.process_eachis the new core:new()with exactlyframe_sizecapacity and never grows, so the steady-state path performs no allocation at all.processandprocess_latestare now thin wrappers and allocate nothing.process_allremains, documented explicitly as the allocating convenience wrapper.Carry-buffer fix
processandprocess_allappended the entire input to the carry buffer and drained a single frame per call, so any multi-frame input leftframe_sizeor more samples buffered. At 320-sample chunks into 256-sample frames that leaked 64 samples per call and the buffer grew without bound — latency drifting upward for as long as the stream ran.The invariant is now explicit and enforced:
buffered_samples() < frame_sizeafter every call, including one that returns an error. The error path clears the buffer before propagating, so a failing detector cannot strand a partial frame. It is asserted across every API, chunk size, and frame size.Ordering is part of the contract too: the concatenation of every slice ever passed in is split into consecutive frames with the remainder carried — no sample dropped, duplicated, or reordered. Tests assert exact ordering for one-at-a-time, 320-sample, multi-frame, and partial-carry inputs, because a stateful backend is corrupted by a skipped frame rather than merely inaccurate.
Guarding the guarantee
scripts/check_earshot_isolation.shrunscargo tree --edges normal,buildagainst the real resolver output and fails if the earshot-only build reacheswebrtc-vad,ort,ort-sys,ureq,ndarray, ornnnoiseless— and also fails if it does not reachearshot, so the check cannot pass vacuously. Dev-dependencies are excluded since they never reach a consumer. It runs as its own CI job and frommake earshot-isolation;tests/feature_isolation.rscovers the same ground from the test harness.realfft/rustfftare deliberately absent from the forbidden list: they arrive viarubato, which is currently a mandatory dependency on every feature set, and they are pure Rust.Testing
Beyond the framing and backend tests: the allocation claim is verified by counting real calls into a
#[global_allocator], not by inspectingVec::capacity()— a capacity that happens not to change is not evidence that nothing was allocated. The counter is per-thread so the parallel harness cannot pollute a measurement, and it carries a self-test proving the counter is not vacuous.earshotis added to the CI feature matrix both alone and in the all-features combination, and to themake citarget.Also
Fixes a pre-existing
unused_mutinbenches/vad_comparison.rsthat madeclippy --all-targets --no-default-featuresfail on main.Notes for review
FrameAdapterwork (core + carry fix) into its own commit ahead of the backend if you would rather land the bug fix independently — it stands alone and affects existing users ofprocess/process_alltoday.