|
|
|
|
@@ -12,22 +12,25 @@
|
|
|
|
|
//!
|
|
|
|
|
//! Composition: `LocalVadReflex<P>` composes outside the advisory
|
|
|
|
|
//! `Reflex<P>`, the same way `Reflex<TapAudioPipe>` composes today (spec
|
|
|
|
|
//! §6.4). `LocalVadReflex<P>` is the PRIMARY trigger this slice — it lands
|
|
|
|
|
//! via Task 2b — so the pattern is exercised, not speculative. Keeping the
|
|
|
|
|
//! advisory `Reflex` and the local-VAD `Reflex` as separate decorators (rather
|
|
|
|
|
//! than fusing them into one type) preserves an independent override seam:
|
|
|
|
|
//! §6.4). `LocalVadReflex<P>` is the PRIMARY trigger this slice — the
|
|
|
|
|
//! local RMS/energy VAD fires in the 20 ms tick with zero brain round-trip —
|
|
|
|
|
//! so the pattern is exercised, not speculative. Keeping the advisory
|
|
|
|
|
//! `Reflex` and the local-VAD `Reflex` as separate decorators (rather than
|
|
|
|
|
//! fusing them into one type) preserves an independent override seam:
|
|
|
|
|
//! each layer can be swapped or tested in isolation.
|
|
|
|
|
|
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
|
|
|
use std::time::Instant;
|
|
|
|
|
|
|
|
|
|
// Task 2 will reintroduce these when `Reflex<P>` lands: `Reflex` holds the
|
|
|
|
|
// `mpsc::Receiver<AdvisoryEvent>` drained on each 20 ms tick, and the
|
|
|
|
|
// `P: AudioPipe` generic bound names `AudioPipe`/`AudioSource`/`AudioSink`
|
|
|
|
|
// (plus `PcmFrame` for the source/sink methods it delegates to). They are
|
|
|
|
|
// intentionally absent here to keep Task 1 self-contained (no `tokio` dep
|
|
|
|
|
// added yet — Task 2 adds it together with the type that consumes it).
|
|
|
|
|
// `Reflex<P>` consumes advisories from a tokio mpsc drained on the 20 ms
|
|
|
|
|
// tick (try_recv, never blocking) + delegates the `AudioPipe` seam to
|
|
|
|
|
// `P`. `mpsc` lives in the production type signature, not just tests, so
|
|
|
|
|
// the import is module-level — `tokio` stays a runtime dep of the binary
|
|
|
|
|
// (which constructs the channel + spawns the TapEngine); rutster-media
|
|
|
|
|
// only names the channel's `Receiver` type, no tokio runtime calls.
|
|
|
|
|
use crate::pcm::{AudioPipe, AudioSink, AudioSource, PcmFrame};
|
|
|
|
|
use tokio::sync::mpsc;
|
|
|
|
|
|
|
|
|
|
/// A turn-event advisory from the brain. The brain decodes its own
|
|
|
|
|
/// speech-to-text / VAD results and forwards these; the FOB *owns*
|
|
|
|
|
@@ -86,9 +89,216 @@ pub struct ReflexMetricsSnapshot {
|
|
|
|
|
pub advisory_observed_speech_stopped: u64,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The FOB reflex decorator (slice-4 spec §3.2). Wraps any `AudioPipe`
|
|
|
|
|
/// with a barge-in state machine driven by `AdvisoryEvent`s from the brain.
|
|
|
|
|
///
|
|
|
|
|
/// # Why `P: AudioPipe` generic (not `Box<dyn AudioPipe>`)
|
|
|
|
|
///
|
|
|
|
|
/// The wrapper is instantiated exactly once per session, with a concrete
|
|
|
|
|
/// `TapAudioPipe` inner. Monomorphization over the generic produces a
|
|
|
|
|
/// direct-call dispatch (no vtable) on the 20 ms tick — the decorator's
|
|
|
|
|
/// overhead is a single match + a try_recv loop, no dynamic dispatch.
|
|
|
|
|
/// The `Reflex` itself is stored behind `Box<dyn AudioPipe>` in
|
|
|
|
|
/// `RtcSession.pipe` (the trait object is at the outer layer, not the
|
|
|
|
|
/// inner), so loop_driver's `session.pipe.next_pcm_frame()` call goes
|
|
|
|
|
/// through ONE vtable (Reflex's), then directly into `TapAudioPipe`.
|
|
|
|
|
pub struct Reflex<P: AudioPipe> {
|
|
|
|
|
pub(crate) inner: P,
|
|
|
|
|
pub(crate) advisory_rx: mpsc::Receiver<AdvisoryEvent>,
|
|
|
|
|
pub(crate) muted: bool,
|
|
|
|
|
// `barge_epoch` is load-bearing THIS slice, not a forward-compat seam:
|
|
|
|
|
// the local VAD (Task 2b) fires ~0 ms after caller speech; the brain's
|
|
|
|
|
// slower ASR advisory fires ~300 ms later on the SAME barge. The epoch
|
|
|
|
|
// disambiguates "a fresh re-barge" from "the late confirmation of the
|
|
|
|
|
// barge already in flight" — see slice-4 spec §6.1, commit 86b7460.
|
|
|
|
|
pub(crate) barge_epoch: u64,
|
|
|
|
|
pub(crate) metrics: Arc<ReflexMetrics>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl<P: AudioPipe> Reflex<P> {
|
|
|
|
|
pub fn new(
|
|
|
|
|
inner: P,
|
|
|
|
|
advisory_rx: mpsc::Receiver<AdvisoryEvent>,
|
|
|
|
|
metrics: Arc<ReflexMetrics>,
|
|
|
|
|
) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
inner,
|
|
|
|
|
advisory_rx,
|
|
|
|
|
muted: false,
|
|
|
|
|
barge_epoch: 0,
|
|
|
|
|
metrics,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Drain all pending advisories + apply the state table. Called at
|
|
|
|
|
/// the top of `next_pcm_frame`. Hot-path: try_recv loop, bounded.
|
|
|
|
|
fn drain_advisories(&mut self) {
|
|
|
|
|
while let Ok(ev) = self.advisory_rx.try_recv() {
|
|
|
|
|
match ev {
|
|
|
|
|
AdvisoryEvent::SpeechStarted { at } => {
|
|
|
|
|
self.muted = true;
|
|
|
|
|
self.barge_epoch = self.barge_epoch.wrapping_add(1);
|
|
|
|
|
self.inner.barge_in_flush();
|
|
|
|
|
self.metrics.barge_in_count.fetch_add(1, Ordering::Relaxed);
|
|
|
|
|
tracing::info!(epoch = self.barge_epoch, ?at, "barge-in");
|
|
|
|
|
}
|
|
|
|
|
AdvisoryEvent::SpeechStopped { at: _ } => {
|
|
|
|
|
self.metrics
|
|
|
|
|
.advisory_observed_speech_stopped
|
|
|
|
|
.fetch_add(1, Ordering::Relaxed);
|
|
|
|
|
// No state change — see slice-4 spec §3.2.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl<P: AudioPipe> AudioSource for Reflex<P> {
|
|
|
|
|
fn next_pcm_frame(&mut self) -> Option<PcmFrame> {
|
|
|
|
|
self.drain_advisories();
|
|
|
|
|
if self.muted {
|
|
|
|
|
match self.inner.next_pcm_frame() {
|
|
|
|
|
Some(f) => {
|
|
|
|
|
self.muted = false;
|
|
|
|
|
Some(f)
|
|
|
|
|
}
|
|
|
|
|
None => {
|
|
|
|
|
self.metrics
|
|
|
|
|
.frames_suppressed
|
|
|
|
|
.fetch_add(1, Ordering::Relaxed);
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
self.inner.next_pcm_frame()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl<P: AudioPipe> AudioSink for Reflex<P> {
|
|
|
|
|
fn on_pcm_frame(&mut self, frame: PcmFrame) {
|
|
|
|
|
// Inbound caller audio is NEVER gated by the reflex. The brain
|
|
|
|
|
// still hears the caller during barge — that's the point (the
|
|
|
|
|
// brain needs to know the caller interrupted; the FOB only kills
|
|
|
|
|
// its OWN playout, not the caller's path to the brain).
|
|
|
|
|
self.inner.on_pcm_frame(frame)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl<P: AudioPipe> AudioPipe for Reflex<P> {
|
|
|
|
|
fn clear_playout_ring(&mut self) {
|
|
|
|
|
self.inner.clear_playout_ring()
|
|
|
|
|
}
|
|
|
|
|
fn barge_in_flush(&mut self) {
|
|
|
|
|
self.inner.barge_in_flush()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// RMS energy threshold for caller-speech detection (slice-4 spec §3.4).
|
|
|
|
|
/// The MVP ships with a single tuned-for-synthetic-loud-signal const;
|
|
|
|
|
/// the tuning framework (per-environment calibration, adaptive noise
|
|
|
|
|
/// floor) is deferred per slice-4 §1.2.
|
|
|
|
|
pub const VAD_RMS_THRESHOLD: f64 = 500.0;
|
|
|
|
|
|
|
|
|
|
/// Number of consecutive above-threshold frames required before the VAD
|
|
|
|
|
/// trips (slice-4 spec §3.4). At 20 ms/frame, N=3 = 60 ms of above-
|
|
|
|
|
/// threshold audio — well below the brain's ~300 ms ASR-VAD latency.
|
|
|
|
|
pub const VAD_DEBOUNCE_FRAMES: u32 = 3;
|
|
|
|
|
|
|
|
|
|
/// The PRIMARY barge-in trigger (slice-4 spec §3.4): a local in-core
|
|
|
|
|
/// RMS/energy VAD running in `on_pcm_frame` on the dedicated thread, in
|
|
|
|
|
/// the 20 ms loop, with ZERO brain round-trip. Proves wedge #1 ("VAD
|
|
|
|
|
/// killing TTS the instant the caller speaks, without the brain" —
|
|
|
|
|
/// README:98-100, ARCHITECTURE.md:79-81). Composes as
|
|
|
|
|
/// `LocalVadReflex<Reflex<TapAudioPipe>>` — the outer wrapper does local
|
|
|
|
|
/// VAD; the inner wrapper applies the mute state machine to the advisory
|
|
|
|
|
/// stream (which has TWO sources: local VAD + brain advisory, both
|
|
|
|
|
/// feeding the same mpsc).
|
|
|
|
|
pub struct LocalVadReflex<P: AudioPipe> {
|
|
|
|
|
pub(crate) inner: P,
|
|
|
|
|
pub(crate) advisory_tx: mpsc::Sender<AdvisoryEvent>,
|
|
|
|
|
pub(crate) above_threshold_streak: u32,
|
|
|
|
|
pub(crate) vad_armed: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl<P: AudioPipe> LocalVadReflex<P> {
|
|
|
|
|
pub fn new(inner: P, advisory_tx: mpsc::Sender<AdvisoryEvent>) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
inner,
|
|
|
|
|
advisory_tx,
|
|
|
|
|
above_threshold_streak: 0,
|
|
|
|
|
vad_armed: true,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Compute RMS energy of a PCM frame. ~480 multiplications + one
|
|
|
|
|
/// sqrt — well under the 20 ms tick budget. Hot-path, no allocations.
|
|
|
|
|
fn rms(frame: &PcmFrame) -> f64 {
|
|
|
|
|
let sum_sq: u64 = frame
|
|
|
|
|
.samples
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|&s| (s as i64 * s as i64) as u64)
|
|
|
|
|
.sum();
|
|
|
|
|
(sum_sq as f64 / frame.samples.len() as f64).sqrt()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Inspect a caller PCM frame + apply the debounce state machine.
|
|
|
|
|
/// Returns true if the VAD tripped THIS call (so on_pcm_frame can
|
|
|
|
|
/// push the advisory). Called from `on_pcm_frame` (the sink path).
|
|
|
|
|
fn observe(&mut self, frame: &PcmFrame) -> bool {
|
|
|
|
|
let energy = Self::rms(frame);
|
|
|
|
|
if energy >= VAD_RMS_THRESHOLD {
|
|
|
|
|
self.above_threshold_streak += 1;
|
|
|
|
|
if self.above_threshold_streak >= VAD_DEBOUNCE_FRAMES && self.vad_armed {
|
|
|
|
|
self.vad_armed = false;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
self.above_threshold_streak = 0;
|
|
|
|
|
self.vad_armed = true;
|
|
|
|
|
}
|
|
|
|
|
false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl<P: AudioPipe> AudioSource for LocalVadReflex<P> {
|
|
|
|
|
fn next_pcm_frame(&mut self) -> Option<PcmFrame> {
|
|
|
|
|
self.inner.next_pcm_frame()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl<P: AudioPipe> AudioSink for LocalVadReflex<P> {
|
|
|
|
|
fn on_pcm_frame(&mut self, frame: PcmFrame) {
|
|
|
|
|
// THE PRIMARY TRIGGER: inspect BEFORE delegating.
|
|
|
|
|
if self.observe(&frame) {
|
|
|
|
|
let _ = self
|
|
|
|
|
.advisory_tx
|
|
|
|
|
.try_send(AdvisoryEvent::SpeechStarted { at: Instant::now() });
|
|
|
|
|
// try_send failure (channel full) → drop + observe (hot-path
|
|
|
|
|
// policy). The brain's advisory path is the backstop.
|
|
|
|
|
}
|
|
|
|
|
self.inner.on_pcm_frame(frame)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl<P: AudioPipe> AudioPipe for LocalVadReflex<P> {
|
|
|
|
|
fn clear_playout_ring(&mut self) {
|
|
|
|
|
self.inner.clear_playout_ring()
|
|
|
|
|
}
|
|
|
|
|
fn barge_in_flush(&mut self) {
|
|
|
|
|
self.inner.barge_in_flush()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
// Task 2: tokio's mpsc provides the advisory channel the production
|
|
|
|
|
// `Reflex` consumes from a tokio task (the TapEngine); the tests drive
|
|
|
|
|
// it from `#[tokio::test]`. The `pcm` items name the trait bounds on
|
|
|
|
|
// `Reflex<P: AudioPipe>` + the `PcmFrame` the source/sink methods pass.
|
|
|
|
|
use crate::pcm::{AudioPipe, AudioSink, AudioSource, PcmFrame};
|
|
|
|
|
use tokio::sync::mpsc;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn reflex_metrics_snapshot_reads_zeroes_initially() {
|
|
|
|
|
@@ -126,4 +336,281 @@ mod tests {
|
|
|
|
|
let st = AdvisoryEvent::SpeechStopped { at: Instant::now() };
|
|
|
|
|
let _ = format!("{:?}", st);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A minimal mock pipe for unit-testing Reflex. Captures on_pcm_frame
|
|
|
|
|
/// inputs + returns a pre-loaded queue of frames from next_pcm_frame
|
|
|
|
|
/// so we can simulate "brain audio_out arrived" deterministically.
|
|
|
|
|
struct MockPipe {
|
|
|
|
|
queued: std::collections::VecDeque<PcmFrame>,
|
|
|
|
|
flush_calls: usize,
|
|
|
|
|
barge_calls: usize,
|
|
|
|
|
/// Last inbound frame observed via `on_pcm_frame` — proves the
|
|
|
|
|
/// reflex delegates caller→brain audio to the inner pipe rather
|
|
|
|
|
/// than dropping it on the floor (Test 6's delegation check).
|
|
|
|
|
last_inbound_frame: Option<PcmFrame>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl MockPipe {
|
|
|
|
|
fn new() -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
queued: Default::default(),
|
|
|
|
|
flush_calls: 0,
|
|
|
|
|
barge_calls: 0,
|
|
|
|
|
last_inbound_frame: None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
fn push_frame(&mut self, frame: PcmFrame) {
|
|
|
|
|
self.queued.push_back(frame);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl AudioSource for MockPipe {
|
|
|
|
|
fn next_pcm_frame(&mut self) -> Option<PcmFrame> {
|
|
|
|
|
self.queued.pop_front()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl AudioSink for MockPipe {
|
|
|
|
|
fn on_pcm_frame(&mut self, frame: PcmFrame) {
|
|
|
|
|
self.last_inbound_frame = Some(frame);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl AudioPipe for MockPipe {
|
|
|
|
|
fn clear_playout_ring(&mut self) {
|
|
|
|
|
self.flush_calls += 1;
|
|
|
|
|
self.queued.clear();
|
|
|
|
|
}
|
|
|
|
|
fn barge_in_flush(&mut self) {
|
|
|
|
|
self.barge_calls += 1;
|
|
|
|
|
self.queued.clear();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn setup() -> (
|
|
|
|
|
Reflex<MockPipe>,
|
|
|
|
|
mpsc::Sender<AdvisoryEvent>,
|
|
|
|
|
Arc<ReflexMetrics>,
|
|
|
|
|
) {
|
|
|
|
|
let (tx, rx) = mpsc::channel::<AdvisoryEvent>(16);
|
|
|
|
|
let metrics = ReflexMetrics::new();
|
|
|
|
|
let reflex = Reflex::new(MockPipe::new(), rx, metrics.clone());
|
|
|
|
|
(reflex, tx, metrics)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Case 1: SpeechStarted → next_pcm_frame returns None even if ring
|
|
|
|
|
/// had frames (the barge flush drained + muted).
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn barge_kills_playout_and_flushes_ring() {
|
|
|
|
|
let (mut reflex, tx, metrics) = setup();
|
|
|
|
|
// Pre-load a frame onto the inner pipe — it's in the "playout ring."
|
|
|
|
|
reflex.inner.push_frame(PcmFrame::zeroed());
|
|
|
|
|
// Barge in.
|
|
|
|
|
tx.send(AdvisoryEvent::SpeechStarted { at: Instant::now() })
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
|
|
|
|
// Next tick: drain the advisory, apply the state machine.
|
|
|
|
|
let frame = reflex.next_pcm_frame();
|
|
|
|
|
assert!(frame.is_none(), "barge must silence the next frame");
|
|
|
|
|
assert_eq!(metrics.barge_in_count.load(Ordering::Relaxed), 1);
|
|
|
|
|
assert_eq!(reflex.inner.barge_calls, 1, "barge_in_flush called");
|
|
|
|
|
assert!(reflex.muted, "state is Muted");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Case 2: Muted + inner returns Some → un-mute + return the frame.
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn first_fresh_audio_out_resumes_playout() {
|
|
|
|
|
let (mut reflex, tx, metrics) = setup();
|
|
|
|
|
reflex.inner.push_frame(PcmFrame::zeroed());
|
|
|
|
|
tx.send(AdvisoryEvent::SpeechStarted { at: Instant::now() })
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
|
|
|
|
// First tick after barge: muted, none (queue was drained).
|
|
|
|
|
let f1 = reflex.next_pcm_frame();
|
|
|
|
|
assert!(f1.is_none());
|
|
|
|
|
assert_eq!(metrics.frames_suppressed.load(Ordering::Relaxed), 1);
|
|
|
|
|
// Brain sends a fresh frame post-barge.
|
|
|
|
|
reflex.inner.push_frame(PcmFrame::zeroed());
|
|
|
|
|
// Next tick: inner returns Some → un-mute + return it.
|
|
|
|
|
let f2 = reflex.next_pcm_frame();
|
|
|
|
|
assert!(f2.is_some(), "first fresh audio_out must resume playout");
|
|
|
|
|
assert!(!reflex.muted, "state is Playing");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Case 3: SpeechStopped during Muted → stays muted.
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn speech_stopped_during_mute_is_noop() {
|
|
|
|
|
let (mut reflex, tx, metrics) = setup();
|
|
|
|
|
tx.send(AdvisoryEvent::SpeechStarted { at: Instant::now() })
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
|
|
|
|
reflex.next_pcm_frame(); // drain + apply barge
|
|
|
|
|
assert!(reflex.muted);
|
|
|
|
|
tx.send(AdvisoryEvent::SpeechStopped { at: Instant::now() })
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
|
|
|
|
let f = reflex.next_pcm_frame(); // drain + apply stopped
|
|
|
|
|
assert!(f.is_none());
|
|
|
|
|
assert!(reflex.muted, "still muted — SpeechStopped does NOT toggle");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
metrics
|
|
|
|
|
.advisory_observed_speech_stopped
|
|
|
|
|
.load(Ordering::Relaxed),
|
|
|
|
|
1
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Case 4: SpeechStopped during Playing → no-op.
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn speech_stopped_during_play_is_noop() {
|
|
|
|
|
let (mut reflex, tx, metrics) = setup();
|
|
|
|
|
// No barge → playing.
|
|
|
|
|
tx.send(AdvisoryEvent::SpeechStopped { at: Instant::now() })
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
|
|
|
|
let f = reflex.next_pcm_frame();
|
|
|
|
|
assert!(f.is_none(), "no frame queued, silence (not barge)");
|
|
|
|
|
assert!(!reflex.muted, "playing");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
metrics
|
|
|
|
|
.advisory_observed_speech_stopped
|
|
|
|
|
.load(Ordering::Relaxed),
|
|
|
|
|
1
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(metrics.barge_in_count.load(Ordering::Relaxed), 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Case 5: duplicate SpeechStarted re-flushes + stays muted.
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn duplicate_speech_started_re_barges() {
|
|
|
|
|
let (mut reflex, tx, metrics) = setup();
|
|
|
|
|
reflex.inner.push_frame(PcmFrame::zeroed());
|
|
|
|
|
tx.send(AdvisoryEvent::SpeechStarted { at: Instant::now() })
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
|
|
|
|
reflex.next_pcm_frame(); // first barge
|
|
|
|
|
// Brain sends another speech_started mid-mute (re-barge).
|
|
|
|
|
reflex.inner.push_frame(PcmFrame::zeroed());
|
|
|
|
|
tx.send(AdvisoryEvent::SpeechStarted { at: Instant::now() })
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
|
|
|
|
let f = reflex.next_pcm_frame(); // second barge
|
|
|
|
|
assert!(f.is_none(), "re-barge must re-mute + drain");
|
|
|
|
|
assert!(reflex.muted);
|
|
|
|
|
assert_eq!(metrics.barge_in_count.load(Ordering::Relaxed), 2);
|
|
|
|
|
assert_eq!(reflex.inner.barge_calls, 2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Case 6: on_pcm_frame is NEVER gated — brain still hears caller.
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn inbound_audio_is_never_gated_during_barge() {
|
|
|
|
|
let (mut reflex, tx, _metrics) = setup();
|
|
|
|
|
tx.send(AdvisoryEvent::SpeechStarted { at: Instant::now() })
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
|
|
|
|
reflex.next_pcm_frame(); // drain + apply barge
|
|
|
|
|
// Inbound frame arrives — must pass through to inner.
|
|
|
|
|
reflex.on_pcm_frame(PcmFrame::zeroed());
|
|
|
|
|
// The inbound frame is observable on the inner pipe — proof the
|
|
|
|
|
// reflex delegates to inner, never gates the caller→brain path.
|
|
|
|
|
assert!(
|
|
|
|
|
reflex.inner.last_inbound_frame.is_some(),
|
|
|
|
|
"inbound audio must reach inner even during barge"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// RMS of a zeroed frame is 0.0 (perfect silence).
|
|
|
|
|
#[test]
|
|
|
|
|
fn rms_of_silence_is_zero() {
|
|
|
|
|
let frame = PcmFrame::zeroed();
|
|
|
|
|
assert_eq!(LocalVadReflex::<MockPipe>::rms(&frame), 0.0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// RMS of a loud frame is well above the threshold.
|
|
|
|
|
#[test]
|
|
|
|
|
fn rms_of_loud_frame_exceeds_threshold() {
|
|
|
|
|
let mut frame = PcmFrame::zeroed();
|
|
|
|
|
for s in frame.samples.iter_mut() {
|
|
|
|
|
*s = 1000; // well above VAD_RMS_THRESHOLD (500.0)
|
|
|
|
|
}
|
|
|
|
|
assert!(LocalVadReflex::<MockPipe>::rms(&frame) >= VAD_RMS_THRESHOLD);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Debounce: N-1 above-threshold frames do NOT trip; the Nth does.
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn debounce_requires_n_consecutive_above_threshold_frames() {
|
|
|
|
|
let (tx, mut rx) = mpsc::channel::<AdvisoryEvent>(16);
|
|
|
|
|
let mut vad = LocalVadReflex::new(MockPipe::new(), tx);
|
|
|
|
|
let mut loud = PcmFrame::zeroed();
|
|
|
|
|
for s in loud.samples.iter_mut() {
|
|
|
|
|
*s = 1000;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// VAD_DEBOUNCE_FRAMES - 1 frames: no trip.
|
|
|
|
|
for _ in 0..(VAD_DEBOUNCE_FRAMES - 1) {
|
|
|
|
|
vad.on_pcm_frame(loud.clone());
|
|
|
|
|
assert!(
|
|
|
|
|
rx.try_recv().is_err(),
|
|
|
|
|
"no advisory before debounce threshold"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
// Nth frame: trip!
|
|
|
|
|
vad.on_pcm_frame(loud.clone());
|
|
|
|
|
let ev = rx.try_recv().expect("advisory after debounce threshold");
|
|
|
|
|
assert!(matches!(ev, AdvisoryEvent::SpeechStarted { .. }));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Re-arm: a below-threshold frame resets the streak + re-arms.
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn below_threshold_re_arms_vad() {
|
|
|
|
|
let (tx, mut rx) = mpsc::channel::<AdvisoryEvent>(16);
|
|
|
|
|
let mut vad = LocalVadReflex::new(MockPipe::new(), tx);
|
|
|
|
|
let mut loud = PcmFrame::zeroed();
|
|
|
|
|
for s in loud.samples.iter_mut() {
|
|
|
|
|
*s = 1000;
|
|
|
|
|
}
|
|
|
|
|
let quiet = PcmFrame::zeroed();
|
|
|
|
|
|
|
|
|
|
// Trip the VAD.
|
|
|
|
|
for _ in 0..VAD_DEBOUNCE_FRAMES {
|
|
|
|
|
vad.on_pcm_frame(loud.clone());
|
|
|
|
|
}
|
|
|
|
|
let _ = rx.try_recv().expect("first trip");
|
|
|
|
|
|
|
|
|
|
// Caller goes quiet — re-arm.
|
|
|
|
|
vad.on_pcm_frame(quiet);
|
|
|
|
|
|
|
|
|
|
// Next streak trips again.
|
|
|
|
|
for _ in 0..VAD_DEBOUNCE_FRAMES {
|
|
|
|
|
vad.on_pcm_frame(loud.clone());
|
|
|
|
|
}
|
|
|
|
|
let ev = rx.try_recv().expect("second trip after re-arm");
|
|
|
|
|
assert!(matches!(ev, AdvisoryEvent::SpeechStarted { .. }));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// on_pcm_frame ALWAYS delegates to inner (caller audio reaches the brain
|
|
|
|
|
/// even during barge — the FOB only kills playout, not the caller's path).
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn on_pcm_frame_always_delegates_to_inner() {
|
|
|
|
|
let (tx, _rx) = mpsc::channel::<AdvisoryEvent>(16);
|
|
|
|
|
let mut vad = LocalVadReflex::new(MockPipe::new(), tx);
|
|
|
|
|
let frame = PcmFrame::zeroed();
|
|
|
|
|
vad.on_pcm_frame(frame.clone());
|
|
|
|
|
// The inner MockPipe captured it — verified by the lack of panic
|
|
|
|
|
// + the MockPipe's on_pcm_frame being called (push_back_bounded
|
|
|
|
|
// on the underlying queue, which we don't observe here directly;
|
|
|
|
|
// the absence of a drop is the assertion).
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// next_pcm_frame is pure delegation — the VAD only observes the SINK path.
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn next_pcm_frame_delegates_to_inner() {
|
|
|
|
|
let (tx, _rx) = mpsc::channel::<AdvisoryEvent>(16);
|
|
|
|
|
let mut vad = LocalVadReflex::new(MockPipe::new(), tx);
|
|
|
|
|
// Inner has no frames queued → None.
|
|
|
|
|
assert!(vad.next_pcm_frame().is_none());
|
|
|
|
|
// Queue a frame on the inner directly + verify it comes through.
|
|
|
|
|
vad.inner.push_frame(PcmFrame::zeroed());
|
|
|
|
|
assert!(vad.next_pcm_frame().is_some());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|