Skip to content

feat: add pure-Rust earshot VAD backend - #55

Open
mikefaille wants to merge 3 commits into
wavekat:mainfrom
mikefaille:feat/earshot-backend
Open

feat: add pure-Rust earshot VAD backend#55
mikefaille wants to merge 3 commits into
wavekat:mainfrom
mikefaille:feat/earshot-backend

Conversation

@mikefaille

Copy link
Copy Markdown

Adds an earshot feature wrapping the pure-Rust earshot 1.2.1 crate as a VoiceActivityDetector backend, plus a zero-allocation core for FrameAdapter and 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-sys cannot also link webrtc-vad. Both bundle libfvad and export unmangled WebRtcSpl_* 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 the webrtc backend 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 earshot resolves to a graph with no webrtc-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

EarshotVad is a raw primitive, matching the crate's existing posture:

  • 16 kHz, exactly 256 samples (16 ms), mono i16.
  • Returns the raw, unthresholded score in 0.0..=1.0. No thresholding, hangover, endpointing, or call control — that stays the caller's policy decision.
  • Wrong sample rate and wrong frame length are rejected with typed errors before any state is touched, so a rejected frame cannot desynchronise the stream.
  • One detector per stream: state is owned by the value, with no interior mutability and no globals, so two detectors never influence each other. 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.
  • The Detector keeps ~8 KiB of state inline, so it is built with default_boxed() rather than through an 8 KiB stack temporary.
  • Infallible construction: no model to load, no runtime to initialise.

FrameAdapter::process_each — a zero-allocation core

Earshot wants 256-sample frames while typical transports deliver 20 ms / 320-sample packets, so this backend leans on FrameAdapter harder than the others do. process_each is the new core:

  • Complete frames are scored straight out of the caller's slice — they are never copied into the carry buffer. Only the trailing partial frame is copied.
  • The carry buffer is allocated once in new() with exactly frame_size capacity and never grows, so the steady-state path performs no allocation at all.
  • process and process_latest are now thin wrappers and allocate nothing. process_all remains, documented explicitly as the allocating convenience wrapper.

Carry-buffer fix

process and process_all appended the entire input to the carry buffer and drained a single frame per call, so any multi-frame input left frame_size or 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_size after 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.sh runs cargo tree --edges normal,build against the real resolver output and fails if the earshot-only build reaches webrtc-vad, ort, ort-sys, ureq, ndarray, or nnnoiseless — and also fails if it does not reach earshot, so the check cannot pass vacuously. Dev-dependencies are excluded since they never reach a consumer. It runs as its own CI job and from make earshot-isolation; tests/feature_isolation.rs covers the same ground from the test harness.

realfft/rustfft are deliberately absent from the forbidden list: they arrive via rubato, 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 inspecting Vec::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.

earshot is added to the CI feature matrix both alone and in the all-features combination, and to the make ci target.

Also

Fixes a pre-existing unused_mut in benches/vad_comparison.rs that made clippy --all-targets --no-default-features fail on main.

Notes for review

  • Happy to split the FrameAdapter work (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 of process/process_all today.
  • The backend is running in production in a live telephony data plane, driving barge-in detection on real calls.

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
@wavekat-eason

Copy link
Copy Markdown
Contributor

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?

@mikefaille

Copy link
Copy Markdown
Author

@wavekat-eason I just fixed the clippy issues as requested.

@wavekat-eason

Copy link
Copy Markdown
Contributor

@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`.
@mikefaille

Copy link
Copy Markdown
Author

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. .github/workflows/ci.yml sets CARGO_TERM_COLOR: always at workflow scope (line 10), so the cargo tree that tests/feature_isolation.rs spawns inherits it and emits SGR escapes. crate_names finds each node's name by trimming leading non-alphanumeric characters — which eats ESC and [, then stops at the 2 of \x1b[2m, because 2 is alphanumeric. Every node parsed as 2m├──\x1b[0m.

That is why the failure looks self-contradictory: the panic claims earshot is absent while printing a tree whose first line is ├── earshot v1.2.1. It is also why the_isolation_check_is_not_vacuous failed alongside it — that test exists to catch exactly this class of parser bug, and it did its job.

It explains the job pattern too: earshot-isolation passes because scripts/check_earshot_isolation.sh matches with grep -qE '(^|[^-a-zA-Z0-9_])earshot v', and the character before earshot is a space either way, so it is colour-tolerant by construction.

Fix (97bf6d0):

  • cargo_tree sets CARGO_TERM_COLOR=never — removes the cause.
  • crate_names strips CSI sequences — the parser is now correct for any input, not only for the input we arrange to receive.
  • crate_names_reads_a_colour_escaped_tree pins it with a coloured fixture so it cannot regress silently.

Verified by reproducing CI locally rather than assuming. With CARGO_TERM_COLOR=always set, against the same feature set the failing job uses:

before:  test result: FAILED. 0 passed; 2 failed   (exit 101)
after:   test result: ok.     3 passed; 0 failed   (exit 0)

One unrelated observation, take it or leave it. The check job runs:

run: cargo test --workspace 2>&1 | tee test-output.txt

The step's exit status is tee's, not cargo's, so that job reports green even when tests fail — which is why check stayed green throughout this. set -o pipefail in the step, or exit ${PIPESTATUS[0]} after it, would close that. I have not touched it since it is outside this PR's scope and your call to make.

@mikefaille

Copy link
Copy Markdown
Author

One thing that will otherwise keep this stalled: the workflow run for the fix is sitting in action_required rather than having run.

run 33027135239  pull_request  97bf6d0f  completed/action_required

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 23d7aec9. Nothing here has actually been re-tested on your side yet — approving the run should be all it needs.

There are two commits since that last run: 97bf6d0 (the cargo tree colour fix) and 7670fd1 (requires earshot 1.2.2 for its clamped RNN hidden-state cast, pykeio/earshot#6 — an unclamped as i16 on the recurrent state truncates, which for a VAD is a wrong-score risk in the hot path).

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants