44 KiB
Rutster slice 4 — Barge-in: VAD-driven playout kill on a dedicated media thread
- Status: Draft (pending review)
- Date: 2026-07-01
- Spearhead step: 4 of 6 (vision-revision §10 / PORT_PLAN "Phasing")
- Origin: brainstorming session 2026-07-01
- Depends on: slice 1 — WebRTC media loopback,
slice 2 — The agent tap, and slice-3's
OpenAI Realtime brain (merged as
c30a452—MockRealtimeBrain+ translator + thespeech_started/speech_stoppedadvisory events). All three must be landed and green. - Related: ADR-0002 (fused vertical — the hot-path hop invariant this slice re-affirms), ADR-0008 (FOB/green-zone doctrine — the reflex is a FOB member: hot-path, differentiating), ARCHITECTURE.md §"Biggest technical risk" (the reflex loop is the remaining long pole), ARCHITECTURE.md §"Media plane" ("Dedicated timing threads for the 20ms loop, never the shared tokio pool" — this slice finally lands that mandate).
TL;DR
Stand up spearhead step 4: the FOB reflex loop. Slice 3 pre-paved the advisory signals
(speech_started / speech_stopped from the brain) and locked the turn-ownership decision
(OpenAI Realtime server-side VAD disabled; the FOB owns turn-taking). Slice 4 acts on
the caller's speech with a local in-core VAD as the primary trigger — an RMS/energy
detector running in on_pcm_frame on the dedicated thread, in the 20 ms loop, with zero
brain round-trip between caller speech and playout kill. This is the property ARCHITECTURE.md:79-81
demands ("Local real-time reflexes... live in-core because the brain round-trip is too slow
to enforce them") and the proof wedge #1 rests on ("VAD killing TTS the instant the caller
speaks, without the brain" — README:98-100). Slice-3's speech_started/speech_stopped
advisory becomes the secondary/confirmation signal — the brain's ASR-quality VAD
confirms the local kill slightly later, but the kill itself fires from the FOB's own
inspection of caller audio.
Slice 4 also graduates the media loop off the tokio pool: a single dedicated std::thread
owns all RtcSessions exclusively and drives the 20 ms tick via Instant::sleep_until. This
honors ARCHITECTURE.md's "never the shared tokio pool" mandate, which slice-1 explicitly
deferred to "step 4 (barge-in)" (loop_driver.rs:18-23). The graduation is load-bearing:
the reflex is the differentiator and the long pole, and its timing discipline demands a
thread that doesn't compete with the axum runtime for scheduling.
The seam slice 1→3 preserved (loop_driver.rs + rtc_session.rs byte-identical) holds
for slice-4 as well: the reflex is a pair of composing AudioPipe decorators (Reflex<P> +
LocalVadReflex<P>) in rutster-media, invisibly to loop_driver::drive. Only the
binary-side wiring (session_map.rs → media_thread.rs) changes shape; the media crate's
hot path stays untouched.
1. Scope
1.1 In scope
- Implementation of spearhead step 4: barge-in / VAD-driven playout kill, driven by a
local in-core VAD (RMS/energy detector in
on_pcm_frame— the primary trigger, zero brain round-trip) with the brain'sspeech_started/speech_stoppedadvisory as the secondary/confirmation signal. This proves wedge #1 ("VAD killing TTS the instant the caller speaks, without the brain" — README:98-100, ARCHITECTURE.md:79-81). - A new
Reflex<P: AudioPipe>wrapper (rutster-media/src/reflex.rs) that decorates the pipe theRtcSessionholds. The reflex owns the mute state machine, the advisory channel receiver, and the barge-in flush trigger. It is the concrete embodiment of ARCHITECTURE.md's "local real-time reflexes" row for the barge-in case. Fed by BOTH the local VAD (via the outerLocalVadReflexwrapper that injectsAdvisoryEvents into the same mpsc) AND the brain's advisories. - A new
LocalVadReflex<P: AudioPipe>wrapper (rutster-media/src/reflex.rs) — the PRIMARY trigger. DecoratesAudioPipe'son_pcm_framepath: inspects the caller's decoded PCM samples (computes RMS, compares to aconstthreshold, debounces N consecutive above-threshold frames), firesAdvisoryEvent::SpeechStarted { at: Instant::now() }into theReflex's advisory mpsc when local VAD trips. WrapsReflex<TapAudioPipe>(composition:LocalVadReflex<Reflex<TapAudioPipe>>— the decorator pattern from §6.4 pays off here). Forward-compatible: the threshold is aconstfor the MVP (no tuning framework — that's the only piece deferred, per §1.2). - A new
barge_in_flushmethod on theAudioPipetrait (default impl delegates toclear_playout_ring) — the seam object's "kill now" path: clear the playout ring AND drain the brain-boundrx_audio_outchannel of any frames queued before the barge so the firstaudio_outobserved post-barge is provably post-barge.TapAudioPipeoverrides;EchoAudioPipeuses the default. - A new
AdvisoryEventenum (SpeechStarted { at },SpeechStopped { at }) flowing over a tokio mpsc from the TapEngine (tokio) to the Reflex (media thread). The engine pushes the events it already decodes from the brain (slice-3 wired these as log+count; slice-4 forwards them into the reflex). - A new dedicated media thread (
rutster/src/media_thread.rs) replacing the tokiospawn_poll_task. Onestd::thread::spawnat binary startup ownsHashMap<ChannelId, RtcSession>exclusively; all access from axum is via a command channel (AcceptOffer,Delete,Shutdown). The 20 ms tick isstd::thread::sleep. - Rewired
session_map.rs(binary):SessionEntry.rtc: Arc<Mutex<RtcSession>>→cmd_tx: mpsc::Sender<MediaCmd>.create_session,post_offer,close,spawn_poll_taskall route through the command channel. The async handlers are cold-path; no cross-thread coordination happens on the 20 ms tick. MockRealtimeBrainextension (rutster-brain-realtime/src/mock.rs): gains the ability to emitspeech_started/speech_stoppedon a programmable schedule (e.g. "after N audio_in frames received, sendspeech_started; after M more, sendspeech_stopped").- Barge-in e2e integration test (extends slice-3's
crates/rutster/tests/realtime_integration.rsharness): synthetic WebRTC peer → MediaThread → TapEngine → MockRealtimeBrain; mock emitsspeech_started; assert playout goes silent within ≤1 tick (20 ms); mock emits freshaudio_out; assert playout resumes. - New
ReflexMetrics(barge_in_count,advisory_dropped,frames_suppressed) mirroringTapMetricsshape (atomics, snapshot fn). Threaded through the sameTapConn.metricssurface where reasonable, or a new side-car. - Thorough learner-facing comments on the new std-thread / channel-bridge / wrapper-decorator patterns (slice-1 §7 standard carries over).
1.2 Out of scope (with scheduled return)
| Deferred item | Returns in | Why deferred |
|---|---|---|
| Local VAD tuning framework (configurable thresholds, per-environment calibration, adaptive noise floor) | post-spearhead refinement | The VAD itself (RMS/energy detector + debounce) IS in scope for slice-4 (the primary barge-in trigger, proving wedge #1). Only the tuning framework — configurable thresholds, calibration UI, adaptive noise floors — is deferred. The MVP ships with a single const threshold + N-frame debounce, exercised by the e2e test with a synthetic loud signal. Tuning to real-world noise conditions is post-spearhead. |
| Per-session media threads / threadpool shard | later rung | Single thread covers spearhead scale (loopback dev + low-concurrency PSTN via slice-5). The command-channel seam between axum and the thread makes the graduation to a threadpool shard localized. |
| Trickle ICE | later | Unchanged from slice-1 deferral. |
| Min-mute floor / inter-word-gap debouncing | post-spearhead | SpeechStopped is a no-op for mute; a floor timer on resume would protect against brain-yield races (brain emits fresh audio_out before the caller's inter-word gap ends). Defer until observed in practice. |
Brain-side input_audio_buffer.interrupt / clear on barge |
slice-5 or brain-side | Whether the brain should clear its own input buffer on speech_started is a brain-UX decision, not a FOB one; the FOB only kills playout (its half-duplex gate). The advisory already tells the brain what happened; the brain's response is its own concern. |
| Half-duplex gating beyond playout kill | later rung | Barge-in is the first half-duplex reflex; full HD gating (mixing, jitter buffer interaction, multi-party) arrives with conferencing. |
| TLS on HTTP / WSS | slice-5 | Unchanged. |
| Authn / authz / multi-tenancy | slice-6 | Unchanged. |
| Spend cap / abuse gate | slice-6 | Unchanged. |
| Browser-based automated e2e (Playwright/Selenium) | post-spearhead | Unchanged. The synthetic-peer harness from slice-2/3 is the test vehicle. |
2. Architecture delta
2.1 The reflex wrapper
Reflex<P: AudioPipe> is a zero-cost-style decorator around any AudioPipe. It sits
between RtcSession.pipe (which loop_driver::drive calls via session.pipe.next_pcm_frame())
and the concrete pipe (TapAudioPipe in production, EchoAudioPipe in slice-1's unit tests).
loop_driver is oblivious to the wrapper: it still calls session.pipe.next_pcm_frame(),
the dynamic dispatch through Box<dyn AudioPipe> lands in Reflex::next_pcm_frame, which
applies the state machine and delegates to inner.next_pcm_frame() per the table in §3.2.
The reflex owns three pieces of state:
advisory_rx: mpsc::Receiver<AdvisoryEvent>— drained sync-non-blocking viatry_recvon the 20 ms tick before delegating toinner. Fed by TWO senders on the same channel: the outerLocalVadReflex(local VAD, the primary trigger) and the TapEngine task (the brain's advisory, secondary/confirmation) — both over tokio mpsc.muted: bool— the kill state.next_pcm_framereturnsNonewhile muted, unless the inner returnsSome(the resume condition — the first freshaudio_outclears mute).barge_epoch: u64— incremented on everySpeechStarted. Load-bearing this slice: the local VAD (primary) and the brain's advisory (secondary) can both fire on the same barge, and the epoch distinguishes a genuine re-barge from the slower confirmation landing on the same event (§3.2). The flush + drain keeps resume race-free regardless.
2.2 The dedicated media thread
A single std::thread::spawn replaces the tokio spawn_poll_task. The thread owns
HashMap<ChannelId, RtcSession> exclusively — no Arc<Mutex<RtcSession>> shared with
axum. All access from the axum handlers is via a command channel:
enum MediaCmd {
AcceptOffer { id: ChannelId, sdp: String, reply: oneshot::Sender<Result<String, String>> },
Delete { id: ChannelId, reply: oneshot::Sender<()> },
Shutdown { reply: oneshot::Sender<()> },
}
The thread loop per 10 ms meta-tick:
- Drain
cmd_rxviatry_recvloop — handle all pending commands before ticking. - For each session in the map: drain the per-session
flush_rxside-channel (slice-2's existing disconnect-flush signal) BEFORErun_poll_once, then callRtcSession::run_poll_once(now)(the unchangedloop_driver::drive). - After
run_poll_once, observechannel.state:Connected && tap.is_none()→ spawn the TapEngine (tokio task via thetokio::runtime::Handlecaptured at thread-start) + wireReflex<TapAudioPipe>as the session's pipe. Mirror of slice-2's spawn seam, relocated fromsession_map.rs::drive_all_sessionsto here.Closed→ remove the entry + drop the session.
std::thread::sleep(Duration::from_millis(10))— 10 ms meta-tick. (Stable API:std::thread::sleep_untilis nightly-only;sleep(dur)is the stable path. The 20 ms outbound encode tick is driven insideloop_driver::drive(unchanged); the 10 ms meta-tick gives finer resolution so str0m'sTimeoutoutputs are honored promptly.)
The tokio ↔ std-thread bridge: all channels are tokio mpsc/oneshot (constructable on
tokio, drainable via try_recv/blocking_recv from any thread). The tokio::runtime::Handle
captured at MediaThread::spawn time is used on the std thread to handle.spawn(...) the
TapEngine when the Connected transition fires. No async code runs on the std thread itself
— only sync channel ops + RtcSession::run_poll_once.
Why a single thread, not per-session: spearhead scale. One loopback peer at a time in dev; even at low PSTN concurrency (slice-5) one thread drives dozens of sessions in 10 ms. Per-session threads arrive when the threadpool shard model lands (deferred). The command-channel seam between axum and the thread makes that graduation localized.
2.3 The hot-path audit (ADR-0002 honored)
ADR-0002's load-bearing rule: "the control↔media gRPC hop on the per-call hot path is removed." Slice 4 does not re-introduce a hop:
- The reflex's kill decision happens inside
Reflex::next_pcm_frameon the dedicated thread — no channel send, no cross-thread coordination on the 20 ms tick. The advisory arrives via atry_recvdrain (sync, non-blocking). - axum → media-thread is cold-path only (SDP accept, DELETE). None of it runs on the 20 ms tick.
- The brain WS ↔ TapEngine (tokio) path is unchanged from slice-3. The advisory channel
is a third mpsc alongside the existing
tx_pcm_in/rx_audio_out/flush_tx— same pattern, additive.
The fused vertical stays fused. ADR-0002 honored.
3. Component design
3.1 AdvisoryEvent enum
// crates/rutster-media/src/reflex.rs
/// A turn-event advisory from the brain. The brain decodes its own
/// speech-to-text / VAD results and forwards these; the FOB *owns*
/// turn-taking and acts on them (slice-3 §4.3 — OpenAI Realtime
/// server-side VAD is DISABLED; the FOB's reflex is authoritative).
///
/// Carried over a tokio mpsc from the TapEngine (tokio task) to the
/// `Reflex` wrapper (media thread). Drained sync via `try_recv` on the
/// 20 ms tick — the kill decision lives in the loop, not in a handler.
#[derive(Debug)]
pub enum AdvisoryEvent {
/// The brain detected caller speech. Trigger barge-in: kill playout.
SpeechStarted { at: Instant },
/// The brain detected caller speech ended. Observed + counted; does
/// NOT toggle mute (the resume condition is "first fresh audio_out
/// after the barge", not "speech_stopped" — see §3.2 state table).
SpeechStopped { at: Instant },
}
3.2 Reflex<P> state machine
| Current state | Event | Action | New state |
|---|---|---|---|
| Playing | SpeechStarted |
muted=true; epoch++; inner.barge_in_flush() (clear ring + drain rx_audio_out so stale brain frames queued pre-barge are dropped); metrics.barge_in_count++ |
Muted |
| Muted | SpeechStarted (duplicate/re-barge) |
epoch++; barge_in_flush() again (fresh barge resets the "fresh audio" clock); barge_in_count++ |
Muted |
| Muted | SpeechStopped |
increment advisory_observed_speech_stopped counter; no state change |
Muted |
| Playing | SpeechStopped |
increment counter; no state change | Playing |
| Muted | inner next_pcm_frame() returns Some(f) (fresh brain audio arrived post-barge) |
muted=false; return Some(f) |
Playing |
| Muted | inner next_pcm_frame() returns None |
return None (silence); metrics.frames_suppressed++ |
Muted |
Why SpeechStopped is a no-op for mute: per the resume-semantics decision (resume on
first fresh audio_out). The brain's speech_stopped is observed (counter) but doesn't
gate — this avoids the inter-word-gap problem (caller pauses, VAD fires stopped, brain
un-mutes too early, brain's audio overlaps caller's next word). The resume condition is
"the brain has yielded and started a new response," which is provably signaled by the first
audio_out frame after the barge — not by the caller's silence.
Why epoch: with two concurrent trigger sources this slice — the local VAD (primary)
and the brain's advisory (secondary/confirmation, §6.1) — the epoch disambiguates "is this
barge a re-barge of the same event, or a new one" when both race into the same advisory
channel. The local VAD trips first; the brain's slower ASR-grade advisory lands ~300 ms
later on the same event and must not be counted as a fresh barge. The epoch is that
disambiguator — load-bearing now, not a forward-compat seam.
3.3 AudioPipe trait extension
// crates/rutster-media/src/pcm.rs — additive method on `AudioPipe`
/// Barge-in flush: clear the playout ring AND drain the inbound brain
/// audio queue of any frames queued before the barge. Called by `Reflex`
/// on `SpeechStarted`. The drain of `rx_audio_out` is what makes the
/// resume condition race-free: the first `audio_out` observed post-barge
/// is provably post-barge (frames queued pre-barge are dropped here).
///
/// Default impl delegates to `clear_playout_ring` — sufficient for
/// pipes without an inbound queue to drain (like `EchoAudioPipe`).
fn barge_in_flush(&mut self) {
self.clear_playout_ring();
}
TapAudioPipe overrides:
// crates/rutster-tap/src/tap_audio_pipe.rs
fn barge_in_flush(&mut self) {
// Clear the playout ring (drops buffered brain-proposed frames).
self.playout_ring.clear();
// Drain rx_audio_out of any frames the engine task queued before
// the barge. Without this, a stale frame in the mpsc would un-mute
// immediately on the next tick — defeating the "first fresh audio_out"
// resume condition. Hot-path: try_recv loop, bounded, no blocking.
while self.rx_audio_out.try_recv().is_ok() {
self.metrics.barge_drained_inflight.fetch_add(1, Ordering::Relaxed);
}
}
3.4 LocalVadReflex<P> — the primary trigger (FOB-local, zero brain round-trip)
The decorator that proves wedge #1. Wraps any AudioPipe; inspects the caller's decoded PCM
in on_pcm_frame, computes RMS energy, fires AdvisoryEvent::SpeechStarted into the inner
Reflex's advisory channel when local VAD trips. Composes as
LocalVadReflex<Reflex<TapAudioPipe>> — the outer wrapper does local-VAD; the inner wrapper
applies the mute state machine to the advisory stream (which now has TWO sources: the local
VAD + the brain's advisory, both feeding the same mpsc).
// crates/rutster-media/src/reflex.rs
/// RMS energy threshold for caller-speech detection. 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. The synthetic-peer e2e test sends a frame with samples
/// well above this threshold so the trip is deterministic.
///
/// i16 max is 32767; a |sample| average of ~500 (~1.5% of full scale) is
/// a quiet-but-unmistakable signal; room-tone background sits well below.
/// The debounce (N consecutive frames above threshold) filters transient
/// spikes (clicks, nose-breaths) without slowing the trip materially.
pub const VAD_RMS_THRESHOLD: f64 = 500.0;
/// Number of consecutive above-threshold frames required before the VAD
/// trips. At 20 ms/frame, N=3 = 60 ms of above-threshold audio — well
/// below the brain's ~300 ms ASR-VAD latency, comfortably instant for
/// the wedge-#1 demonstration. Tunable in a later slice; const for MVP.
pub const VAD_DEBOUNCE_FRAMES: u32 = 3;
pub struct LocalVadReflex<P: AudioPipe> {
inner: P,
/// The advisory_tx the LocalVadReflex pushes into when local VAD
/// trips. This is the SAME channel the brain's advisories arrive
/// on (the Reflex holds the paired rx) — so the Reflex's mute state
/// machine sees both sources uniformly via `drain_advisories`.
advisory_tx: mpsc::Sender<AdvisoryEvent>,
/// Consecutive above-threshold frame count (the debounce counter).
above_threshold_streak: u32,
/// True once the VAD has tripped for the current caller-speech
/// burst. Reset to false when the streak breaks (caller stopped)
/// — so a new burst trips a fresh `SpeechStarted`.
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, // armed on construction
}
}
/// Compute RMS energy of a PCM frame. The frame is 480 i16 samples
/// @ 24 kHz; RMS = sqrt(mean(sample²)). Cheap: ~480 multiplications,
/// one division, one sqrt — well under the 20 ms tick budget.
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.
/// Called from `on_pcm_frame` (the sink path). Returns true if the
/// VAD tripped THIS call (so the caller can push the advisory).
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; // disarm until caller stops
return true; // trip!
}
} else {
// Caller went quiet → re-arm for the next speech burst.
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> {
// Pure delegation — the VAD only observes the SINK path
// (inbound caller audio); playout is the inner Reflex's concern.
self.inner.next_pcm_frame()
}
}
impl<P: AudioPipe> AudioSink for LocalVadReflex<P> {
fn on_pcm_frame(&mut self, frame: PcmFrame) {
// THE PRIMARY TRIGGER: inspect the caller's audio BEFORE delegating.
// If the local VAD trips, push an advisory into the same channel the
// brain's advisories arrive on — the inner Reflex drains it on the
// next next_pcm_frame call + applies the kill. Zero brain round-trip.
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; a missed
// local-VAD trip here is not catastrophic — the brain will fire
// its ASR-VAD ~300 ms later.
}
// Delegate to inner — the caller's audio still reaches the brain.
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() }
}
Why the VAD trips on_pcm_frame (sink), not next_pcm_frame (source): the caller's
audio is what the VAD inspects — and caller audio arrives via the sink path (decoded from
the peer's RTP by loop_driver::drive → session.pipe.on_pcm_frame(pcm)). The source path
is the brain's audio_out — the playout being killed, not the signal being detected.
Why the SAME advisory channel as the brain: the inner Reflex drains all advisories
uniformly — it doesn't care whether the source is local VAD or the brain's ASR. The brain's
advisory arriving ~300 ms later either finds the kill already applied (no-op, the muted
flag is already true) or confirms it. This is the composition pattern §6.4 anticipated; the
Reflex<P> wrapper shape was designed for exactly this layering.
3.5 Reflex<P> struct + impl
// crates/rutster-media/src/reflex.rs
pub struct Reflex<P: AudioPipe> {
inner: P,
advisory_rx: mpsc::Receiver<AdvisoryEvent>,
muted: bool,
barge_epoch: u64,
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 §3.2.
}
}
}
}
}
impl<P: AudioPipe> AudioPipe for Reflex<P> {
fn next_pcm_frame(&mut self) -> Option<PcmFrame> {
self.drain_advisories();
if self.muted {
// Muted: pull from inner. Some(f) = fresh brain audio arrived
// post-barge → un-mute + return. None = silence, stay 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()
}
}
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)
}
fn clear_playout_ring(&mut self) {
// The reconnect-flush path (slice-2 §5.3) still works through the
// wrapper. If it fires during mute, the ring stays empty and mute
// clears on the next post-reconnect audio_out.
self.inner.clear_playout_ring()
}
fn barge_in_flush(&mut self) {
// Allow the outer `LocalVadReflex` (primary trigger) to barge the inner.
self.inner.barge_in_flush()
}
}
3.6 ReflexMetrics
Mirror of TapMetrics shape (atomics + snapshot struct):
// crates/rutster-media/src/reflex.rs
#[derive(Default)]
pub struct ReflexMetrics {
pub barge_in_count: AtomicU64,
pub advisory_dropped: AtomicU64, // advisory channel full (e.g. 16-cap)
pub frames_suppressed: AtomicU64, // None returns while muted
pub advisory_observed_speech_stopped: AtomicU64,
}
pub struct ReflexMetricsSnapshot {
pub barge_in_count: u64,
pub advisory_dropped: u64,
pub frames_suppressed: u64,
pub advisory_observed_speech_stopped: u64,
}
// `barge_drained_inflight` lives on `TapMetrics` (in `rutster-tap`), not
// `ReflexMetrics`, because the drain happens inside `TapAudioPipe::barge_in_flush`,
// not inside `Reflex`. The path: `Reflex::drain_advisories` calls
// `inner.barge_in_flush()` which is `TapAudioPipe::barge_in_flush`, which is
// where the `rx_audio_out` drain + the counter increment happen.
4. The dedicated media thread
4.1 MediaThread
// crates/rutster/src/media_thread.rs
pub struct MediaThread {
cmd_tx: mpsc::Sender<MediaCmd>,
join: Option<std::thread::JoinHandle<()>>,
}
enum MediaCmd {
AcceptOffer { id: ChannelId, sdp: String, reply: oneshot::Sender<Result<String, String>> },
Delete { id: ChannelId, reply: oneshot::Sender<()> },
Shutdown { reply: oneshot::Sender<()> },
}
Spawned at binary startup (main.rs), before axum::serve. The thread captures a
tokio::runtime::Handle (to spawn TapEngine tasks when Connected transitions fire) and
owns HashMap<ChannelId, RtcSession> + (per-session, lazily) the TapConn / advisory_rx /
Reflex wrapper.
4.2 Thread loop (per 10 ms meta-tick)
cmd_rx.try_recv()loop — handle ALL pending commands before ticking.AcceptOffercallsRtcSession::accept_offer(sdp)and replies via the oneshot.Deletefiresclose_tx+ bounded-await the engine task (750 ms cap viatokio::runtime::Handle::block_on(timeout(...))) — the std thread briefly enters the tokio runtime to await; cold-path, not the 20 ms tick.Shutdowndrains + replies.- For each
RtcSessionin the map:- Drain per-session
flush_rxside-channel (slice-2's existing disconnect-flush) BEFORErun_poll_once. - Call
RtcSession::run_poll_once(now)— the unchangedloop_driver::drive. - Observe
channel.state:Connected && tap.is_none()→handle.spawn(spawn_tap_engine(...))to bring up the tokio task; constructReflex::new(TapAudioPipe::new(...), advisory_rx, metrics); callRtcSession::set_pipe(reflex). Mirror of slice-2's spawn seam.Closed→ remove the entry (drops theRtcSession+ its pipe + advisory ends).
- Drain per-session
std::thread::sleep(Duration::from_millis(10))— 10 ms meta-tick.
4.3 session_map.rs rewire
SessionEntry loses rtc: Arc<Mutex<RtcSession>>, gains cmd_tx: mpsc::Sender<MediaCmd>
(cloned per-entry; cheap). tap_url stays (the thread reads it when spawning the engine).
tap_conn: Option<TapConn> moves onto the media thread (the thread owns it after spawn).
AppState::create_session→ sends aRegister { tap_url, reply }command to the media thread; the thread constructsRtcSession::new()(saves a cross-thread move of the struct + keeps allRtcSessionconstruction on the thread that owns it). The thread replies with(id, cmd_tx_for_this_session); axum storesSessionEntry { cmd_tx, tap_url, tap_conn: None }.AppState::get(id)(SDP path) →cmd_tx.send(AcceptOffer { ... }).await+reply.await. Cold-path; the axum handler is async.AppState::close(id)→cmd_tx.send(Delete { id, reply }).await+reply.await. The reply returns after the TapEngine teardown completes on the thread.spawn_poll_task→spawn_media_thread: constructs the channels, spawns the std thread, storescmd_tx+joininAppState. Same idempotent-guard pattern.
4.4 TapEngine extension
spawn_tap_engine returns a third channel end: advisory_tx: mpsc::Sender<AdvisoryEvent>.
The pump loop, on receiving speech_started / speech_stopped from the brain (slice-3
already decodes these in the tap protocol layer — protocol_events.rs), pushes the
corresponding AdvisoryEvent into advisory_tx. If the channel is full, drop + count
(hot-path "drop + observe" policy; an advisory is a hint, not a command). The Reflex
wrapper holds advisory_rx.
4.5 MockRealtimeBrain extension
rutster-brain-realtime/src/mock.rs gains a programmable advisory schedule: the test can
register "after N audio_in frames received, send speech_started" and "after M more,
send speech_stopped". The mock already asserts turn_detection: null on
session.update (slice-3's S4 lock); slice-4 keeps that assertion.
5. Data flow
5.1 Barge-in (the kill) — primary path: local VAD, zero brain round-trip
1. caller speaks into mic → peer RTP → str0m decode → on_pcm_frame
→ LocalVadReflex::on_pcm_frame INSPECTS the frame FIRST:
• rms(frame) computed (~480 muls, < 1 µs)
• if rms ≥ VAD_RMS_THRESHOLD: streak++; if streak ≥ VAD_DEBOUNCE_FRAMES (3 = 60 ms) → TRIP
• on trip: try_send(AdvisoryEvent::SpeechStarted) into advisory_tx (same channel as
brain's advisories) → disarm until caller goes quiet
→ THEN delegates to inner.on_pcm_frame(frame) → tx_pcm_in → TapClient → audio_in (WS) → brain
2. [THREE HOPS HAPPEN HERE, ON THE BRAIN SIDE — NOT on the kill path]:
- brain's ASR-VAD fires (~300 ms later, slower but more accurate)
- brain sends speech_started back over WS
- TapEngine → advisory_tx (the SAME mpsc; both sources feed one channel)
3. media thread next 20 ms tick → Reflex::next_pcm_frame → drain_advisories:
• The LOCAL VAD's SpeechStarted arrives FIRST (it was pushed in step 1, same thread,
no WS hop) → muted=true; epoch++; inner.barge_in_flush() (ring cleared + rx_audio_out drained)
• The brain's SpeechStarted arrives ~300 ms LATER → Reflex sees muted=true already
→ re-barge (epoch++, barge_in_flush again — harmless, mute stays)
→ returns None (silence) for this + subsequent ticks while muted
4. loop_driver::drive pulls None from pipe → encodes Opus silence → peer hears silence
(the brain's in-flight audio_out frames are dropped; no overlap with caller's speech)
Latency budget: caller speaks → kill fires in ≤ 60 ms (3 debounce frames × 20 ms tick) + one tick to drain the advisory + apply the kill = ≤ 80 ms wallclock. Zero brain round-trip on the primary path. (The brain's ASR-VAD advisory arrives ~300 ms later — it confirms the kill but doesn't gate it.) This is what ARCHITECTURE.md:80 demands ("the brain round-trip is too slow to enforce them") and the proof wedge #1 rests on.
5.2 Resume (the un-mute)
1. brain decides to yield/respond → sends a fresh audio_out frame
(provably post-barge: barge_in_flush drained rx_audio_out)
2. TapClient → audio_out (WS) → TapEngine → tx_audio_out → rx_audio_out → playout ring
3. media thread 20 ms tick → Reflex::next_pcm_frame → drain_advisories (empty)
→ muted=true → inner.next_pcm_frame() returns Some(f) (fresh brain audio)
→ muted=false; return Some(f)
4. loop_driver encodes + writes → peer hears the brain's new response
5.3 Cold-path (axum ↔ media thread)
- POST /v1/sessions → AppState::create_session → MediaCmd::Register → thread constructs RtcSession → reply(id)
- POST /v1/sessions/{id}/offer → AppState::get + cmd_tx.send(AcceptOffer) → thread.lock(session).accept_offer(sdp) → reply(answer)
- DELETE /v1/sessions/{id} → AppState::close → cmd_tx.send(Delete) → thread: fire close_tx, bounded-await engine task teardown → reply
- graceful shutdown → cmd_tx.send(Shutdown) → thread drains + drops → reply → join
6. Why these decisions
6.1 Why both, local VAD primary (revised after adversarial review)
The initial brainstorming landed on advisory-only for MVP (cheapest path to a working barge-in; the brain's VAD already runs for STT). The 2026-07-01 adversarial review surfaced the load-bearing problem with that choice: README:98-100 + ARCHITECTURE.md:79-81 rest the wedge on "local reflexes that don't need the brain — VAD killing TTS the instant the caller speaks." Advisory-only puts the brain round-trip in the trigger path (brain VAD → WS → TapEngine → mpsc → Reflex); the kill DECISION is in-core but the TRIGGER SOURCE crossed the brain. The spearhead's step 4 would prove a reflex that depends on the brain — the opposite of the property steps 1-4 exist to prove.
The revision: local VAD in on_pcm_frame is the primary trigger (RMS/energy detector on
the dedicated thread, in the 20 ms loop, zero brain round-trip — the actual wedge-#1 proof).
Slice-3's advisory becomes the secondary/confirmation signal — the brain's ASR-quality
VAD confirms the local kill ~300 ms later (slower but more accurate — it knows words, not
just energy). The two sources feed the SAME advisory mpsc; the Reflex wrapper drains them
uniformly. The composition is LocalVadReflex<Reflex<TapAudioPipe>> — the decorator pattern
from §6.4, which was originally specced as deferred to "when local VAD arrives." It arrived.
- The VAD itself (~25 lines: RMS, threshold, debounce) is in scope. The tuning framework
(configurable thresholds, per-environment calibration, adaptive noise floor) is deferred —
the MVP ships a single
constthreshold + N-frame debounce, exercised by the e2e test with a synthetic loud signal. - Slice-3's advisory plumbing +
MockRealtimeBrainschedule aren't wasted — they become the secondary path, still exercised by the e2e test (the brain's advisory fires SLIGHTLY after the local VAD; both feeds land in the Reflex's drain). - The
Reflex<P>wrapper shape already supported composition — no structural rework, just a newLocalVadReflex<P>decorator landing in scope (Task 2b in the plan).
6.2 Why resume on first fresh audio_out (not speech_stopped)
- The "the brain has yielded and started a new response" condition is provably signaled by
the first
audio_outframe after the barge — not by the caller's silence.speech_stoppedfires between words; resuming on it un-mutes too early (inter-word-gap overlap). - The
barge_in_flushdrain ofrx_audio_outmakes the resume race-free: the firstaudio_outobserved post-barge is provably post-barge (frames queued pre-barge are dropped in the flush).
6.3 Why a single dedicated thread (not per-session)
- Spearhead scale: one loopback peer in dev; even at low PSTN concurrency (slice-5), one thread drives dozens of sessions in 10 ms.
- The command-channel seam between axum and the thread makes the graduation to a threadpool
shard localized — when per-CPU-shard threading arrives, it's a fan-out of the
cmd_rx/HashMapshape, not a redesign. - Per-session threads arrive when load demands; the spearhead's "shortest blocking path" rule dislikes spawning work per session that may not need it (pre-ICE-connected sessions would redundantly spin).
6.4 Why Reflex<P> as a wrapper (not inline in TapAudioPipe)
- Composition:
LocalVadReflex<P>composes outside the advisoryReflex<P>(§6.1), the same wayReflex<TapAudioPipe>composes. The pattern (decorator overAudioPipe) stacks the two trigger sources without restructuring either. - The seam:
loop_driver.rsbyte-identical (still callspipe.next_pcm_frame()). If the reflex lived inline inTapAudioPipe, the binary-side wiring would still change but theTapAudioPipemodule itself would grow the reflex state — less isolated. - The payoff is realized this slice, not deferred: two stacked reflexes (local-VAD primary + advisory secondary) live as independent, separately-testable decorators rather than one module's commingled state. Keeping them as wrappers is what makes that separation free.
6.5 Why barge_in_flush on AudioPipe (not just clear_playout_ring)
clear_playout_ring(slice-2) clears the ring.barge_in_flushclears the ring AND drains the inbound brain queue (rx_audio_out). The distinction matters: on a brain disconnect (slice-2's case), the brain is gone —rx_audio_outwill drain itself on the nextDisconnectedtry_recv. On a barge-in, the brain is alive and may have queued frames pre-barge that would un-mute immediately if not drained here. Two different "clear the playout path" semantics, two methods.
7. Done-criteria
cargo test --allpasses (stable + 1.85, the CI matrix).cargo fmt --check+cargo clippy -- -D warningsclean.loop_driver.rs+rtc_session.rsbyte-identical to slice-3 — CI-asserted viagit diff --exit-code main -- crates/rutster-media/src/loop_driver.rs crates/rutster-media/src/rtc_session.rs(the §8.5 #6 seam gate, restated for slice-4).- Dedicated media thread drives sessions off the tokio pool;
MediaThreadintegration test passes (AcceptOffer / Delete / Shutdown). Reflexstate-machine unit tests all pass:SpeechStarted→ nextnext_pcm_framereturns None even if ring has frames.SpeechStartedtheninner.next_pcm_frame()=Some→ un-mutes, returns the frame.SpeechStoppedduring Muted → stays Muted.SpeechStoppedduring Playing → no-op.- Duplicate
SpeechStartedre-flushes + stays Muted. - Metrics counters (
barge_in_count,frames_suppressed) increment correctly. advisory_rxfull →advisory_droppedincrements, no panic.
LocalVadReflexunit tests pass:- RMS computation verified against a known-loud + known-quiet frame.
- Debounce: N-1 above-threshold frames do NOT trip; the Nth does.
- Re-arm: above-threshold → trip; below-threshold → re-arm; next streak trips again.
on_pcm_frameALWAYS delegates to inner (caller audio reaches the brain even during barge).
barge_in_flushunit tests pass (ring +rx_audio_outdrain).- Barge-in e2e (PRIMARY PATH, proves wedge #1): synthetic loud caller audio → playout killed
within ≤4 ticks (≤80 ms wallclock: 3 debounce + 1 drain+apply) WITHOUT any brain advisory.
The brain's
speech_startedadvisory arrives later + is a no-op (mute already applied). - Barge-in e2e (SECONDARY PATH, exercises slice-3 advisory plumbing):
MockRealtimeBrainemitsspeech_startedon schedule; local VAD is NOT tripped (quiet synthetic caller audio); advisory → kill → freshaudio_out→ resume. Proves the advisory→reflex→kill path still works. - S4 turn-ownership lock preserved:
MockRealtimeBrainstill assertsturn_detection: nullonsession.update(slice-3's #7, unchanged). MockRealtimeBrainextended to emitspeech_started/speech_stoppedon schedule.cargo doc --no-depsrenders the newreflex.rs+media_thread.rsmodule/item docs cleanly (learner-facing comments present per AGENTS.md code style).
8. Open decisions
Trigger source— decided: both, local VAD primary + advisory secondary (revised after 2026-07-01 adversarial review; initial brainstorming landed on advisory-only, the review surfaced that advisory-only contradicts wedge #1's "VAD killing TTS the instant the caller speaks, without the brain"). Local VAD inon_pcm_frameis the primary trigger; the brain'sspeech_started/speech_stoppedadvisory is the secondary/confirmation.Resume semantics— decided: first freshaudio_outpost-barge;SpeechStoppedobservational only.Thread model— decided: single dedicatedstd::thread; per-session/threadpool deferred.MockRealtimeBrainadvisory schedule API shape — landed in §4.5 as a programmable "after N audio_in frames" schedule. Could alternatively be a free-formVec<(trigger_frame_count, AdvisoryEvent)>queue. The plan will pin the concrete API.- Thread shutdown ordering vs TapEngine teardown —
Deletecommand handler firesclose_tx+ bounded-await the engine task (750 ms cap viatokio::runtime::Handle::block_on(timeout(...))); the reply oneshot returns after teardown. Cold-path, std thread briefly enters the tokio runtime to await. Documented as an acceptable deviation (not the 20 ms tick).
9. Cross-references
- slice-1 spec — the media loop + the seam
(
AudioSource/AudioSinktraits inrutster-media); slice-1 §8.5 #6 is the seam gate this slice re-affirms. - slice-2 spec — the tap interface, the
TapAudioPipe, the core-authoritative playout buffer (§4.1), theflush_txside-channel pattern that theadvisory_rxmirrors. - slice-3 (merged
c30a452) —MockRealtimeBrain, the translator, thespeech_started/speech_stoppedprotocol events, the S4 turn-ownership lock. - ADR-0002 — fused vertical; the hot-path hop invariant this slice re-affirms (§2.3 audit).
- ADR-0008 — FOB/green-zone doctrine; the reflex is a FOB member (hot-path, security-constitutive for turn-taking, differentiating).
- ARCHITECTURE.md — §"Media plane" ("Dedicated timing threads for the 20ms loop, never the shared tokio pool" — this slice lands it); §"Biggest technical risk" (the reflex loop is the remaining long pole).
- PORT_PLAN.md — §Phasing, step 4 = barge-in.