Skip to main content

pipecrab_vad/
lib.rs

1//! pipecrab-vad: the voice-activity-detection interface, in two tiers.
2//!
3//! Voice activity has two natural shapes, and this crate names both:
4//!
5//! * [`VoiceActivityDetector`] — the **stage-facing** capability: audio in,
6//!   speech *edges* out ([`VadEvent::SpeechStarted`] /
7//!   [`VadEvent::SpeechStopped`]). Segmenter-class engines that already speak in
8//!   segments — sherpa's VAD, platform VADs — implement this directly. It is
9//!   what [`VadStage`] drives.
10//! * [`SpeechScorer`] — the **raw-model** tier: a per-window speech
11//!   *probability*. This is what a bare silero build (native `ort`, browser
12//!   onnxruntime-web) exposes. A scorer becomes a detector through the
13//!   [`Debounced`] adapter, which owns the windowing, threshold, and hangover.
14//!
15//! Probabilities used to live on the one VAD trait; they moved *down* to
16//! [`SpeechScorer`]. A segmenter never has a
17//! probability to hand back, and anything downstream that wants confidence
18//! (a turn manager, prosody) composes its own [`SpeechScorer`] rather than
19//! leaning on the VAD to surface one.
20//!
21//! # The edge contract
22//!
23//! Across the lifetime of a [`VoiceActivityDetector`], events **alternate**,
24//! starting with [`SpeechStarted`](VadEvent::SpeechStarted): started, stopped,
25//! started, stopped, … This is a documented invariant that [`VadStage`] and
26//! everything downstream of it trust.
27//!
28//! [`VadStage`] turns those edges into a **gate**: it owns a pre-roll ring and
29//! emits speech-only audio, bracketed by the edges. Note the contract this
30//! *inverts* — downstream of the gate, `SpeechStarted` **precedes** an
31//! utterance's audio (pre-roll included) and `SpeechStopped` **follows** its
32//! last chunk. (The older lane-discipline design emitted the edge *after* the
33//! chunk that triggered it; that wording is gone.) See [`VadStage`] for the full
34//! gate algorithm.
35//!
36//! # No runtime format detection
37//!
38//! Both traits take an `Arc<[f32]>`, which carries no sample rate. Samples are
39//! interpreted as [`input_format()`](VoiceActivityDetector::input_format); no
40//! runtime detection is possible. The stage enforces the format *fatally* before
41//! any audio reaches an engine, so an engine never sees nonconforming samples
42//! through the pipeline — which is why [`VadError`] carries no format-mismatch
43//! variant.
44//!
45//! Platform-neutral and `wasm32`-checkable: the concrete engines live elsewhere,
46//! each behind these traits, so the interface itself carries no backend
47//! dependency and compiles for the host and `wasm32-unknown-unknown`.
48#![forbid(unsafe_code)]
49#![warn(missing_docs)]
50
51mod debounced;
52mod stage;
53
54pub use debounced::{DebounceConfig, Debounced};
55pub use stage::{GateConfig, VadEffect, VadStage};
56
57use async_trait::async_trait;
58use pipecrab_core::AudioFormat;
59use pipecrab_runtime::MaybeSendSync;
60use std::sync::Arc;
61
62/// A speech-state transition, emitted by a [`VoiceActivityDetector`].
63#[derive(Clone, Copy, Debug, PartialEq, Eq)]
64pub enum VadEvent {
65    /// The silence→speech edge: the user started speaking.
66    SpeechStarted,
67    /// The speech→silence edge: the user stopped speaking.
68    SpeechStopped,
69}
70
71/// The stage-facing voice-activity capability: audio in, speech edges out.
72///
73/// Segmenter-class engines (sherpa's VAD, platform VADs) implement this
74/// directly; raw per-window scorers are lifted into it by [`Debounced`]. It is
75/// the trait [`VadStage`] drives.
76///
77/// Like every other pipecrab interface it takes `&self`, so an in-flight call
78/// can be dropped on a barge-in interrupt without tearing state. `?Send` on
79/// `wasm32` matches pipecrab's single-threaded execution model, so one
80/// implementation runs unchanged on a current-thread executor and in the
81/// browser, where `Send` bounds cannot be satisfied.
82#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
83#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
84pub trait VoiceActivityDetector: MaybeSendSync {
85    /// The one format this detector accepts. The stage caches it and enforces it
86    /// *fatally*: samples are interpreted as this format, and no runtime
87    /// detection is possible, so nonconforming audio is rejected before it ever
88    /// reaches [`process`](Self::process).
89    fn input_format(&self) -> AudioFormat;
90
91    /// Feed samples (any length; the engine buffers internally) and return zero
92    /// or more edges, in order. Shared ownership lets a worker-backed detector
93    /// retain or enqueue the buffer without copying its samples.
94    ///
95    /// Across calls, events **must alternate**, starting with
96    /// [`SpeechStarted`](VadEvent::SpeechStarted) — the documented invariant
97    /// [`VadStage`] and everything downstream trust. Samples are interpreted as
98    /// [`input_format()`](Self::input_format).
99    async fn process(&self, samples: Arc<[f32]>) -> Result<Vec<VadEvent>, VadError>;
100
101    /// Control call: return to the idle, no-speech state. Synchronous,
102    /// non-blocking, idempotent (see the [`Processor`](pipecrab_core::Processor)
103    /// control-call carve-out). Invoked on an
104    /// [`Interrupt`](pipecrab_core::SystemFrame::Interrupt) and by tests.
105    ///
106    /// There is deliberately no `cancel`: a process quantum is ~1 ms, so there is
107    /// nothing worth aborting mid-flight, and structural drop already covers the
108    /// await. `reset` is the only control call.
109    fn reset(&self);
110}
111
112/// The raw-model tier: per-window speech probability.
113///
114/// This is what a bare silero build implements — the web onnxruntime-web build
115/// and any future raw model. A scorer becomes a [`VoiceActivityDetector`]
116/// through [`Debounced`], which owns the windowing, threshold, and hangover.
117///
118/// `?Send` on `wasm32` matches pipecrab's single-threaded execution model, as on
119/// [`VoiceActivityDetector`].
120#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
121#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
122pub trait SpeechScorer: MaybeSendSync {
123    /// The one format this scorer accepts. Samples are interpreted as this
124    /// format; no runtime detection is possible.
125    fn input_format(&self) -> AudioFormat;
126
127    /// Exact window length in samples (e.g. 512 at 16 kHz for silero). Every
128    /// call to [`score`](Self::score) is handed exactly this many samples.
129    fn window_len(&self) -> usize;
130
131    /// Score exactly [`window_len()`](Self::window_len) samples; returns a
132    /// probability in `[0.0, 1.0]` that the window contains speech. Shared
133    /// ownership lets an asynchronous scorer retain the window without copying.
134    async fn score(&self, window: Arc<[f32]>) -> Result<f32, VadError>;
135}
136
137/// Why a [`VoiceActivityDetector::process`] or [`SpeechScorer::score`] call
138/// failed.
139///
140/// Mirrors the message-plus-kind shape of the pipeline's other error types
141/// (e.g. `pipecrab-stt`'s `SttError`) so the conversion at a stage boundary is
142/// direct.
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub enum VadError {
145    /// The engine itself failed — an inference error, a worker that crashed, a
146    /// model that never loaded. Carries a human-readable description.
147    Engine(String),
148}
149
150impl std::fmt::Display for VadError {
151    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152        match self {
153            VadError::Engine(msg) => write!(f, "vad engine error: {msg}"),
154        }
155    }
156}
157
158impl std::error::Error for VadError {}