flowcat-core: opt-in full-duplex (barge-in) support for the cascaded pipeline - #61
Conversation
The cascaded builder is half-duplex by design (TurnMute); this adds an opt-in duplex path with working barge-in, validated end-to-end over the str0m WebRTC transport (aiortc client, whisper.cpp STT, OpenRouter LLM with tool calls, Kokoro TTS): - FrameProcessor::on_interruption() hook (default no-op): the runtime intercepts Frame::Interruption (drain + forward) and never delivers it to process_frame, so the existing Interruption arms in sinks are unreachable. The hook gives processors a real delivery path. - VadProcessor: optional barge-in generation counter + Notify, bumped synchronously at detection. A busy process_frame (LLM mid-stream, TTS mid-synthesis) cannot be preempted by the frame path; these enable cooperative cancellation and an out-of-band reactor. - LlmProcessor: cooperative stream cancel between chunks on barge-in; closes response framing so aggregators cannot wedge open. - AssistantContextAggregator::on_interruption: keeps the partial reply in context, drops the open span (a late LlmResponseEnd from a cancelled stream can no longer speak the interrupted reply). - SpeechGate: VAD-edged speech segmentation (300 ms pre-roll, all-zero flush marker at the falling edge) so fixed-window batch STT gets one utterance per VAD turn instead of hallucinating turns on silence. - CascadedTransportOutput: emits BotStarted/StoppedSpeaking via a playout-tracking notifier (nothing armed the VAD barge-in gate on the cascaded path), flushes the carrier in on_interruption, and drops stale audio behind a reactor-armed latch. - build_cascaded_call_duplex: assembles the above; the stock builder is unchanged. Measured detection-to-flush: ~110us via the reactor vs 14ms-2.1s via the frame path (stalls behind mid-await hops). All existing unit tests pass (302); clippy -D warnings clean.
Review follow-ups on the full-duplex barge-in work. Runtime / hook: - `on_interruption` had swallowed `stop`'s doc comment — restore it. - Migrate the three remaining dead `Frame::Interruption` arms onto the new hook: `TransportOutput` (realtime path) plus both text_filter processors. The realtime sink's `send_clear` was unreachable for the same reason, so barge-in never flushed the carrier there either — that is a real bug on the Gemini path, not only the cascaded one. - Drop the dead `Frame::Interruption` arm left in the cascaded sink. STT endpointing: - Replace the `SPEECH_GATE_FLUSH_SAMPLES` all-zero marker chunk with a defaulted `SttService::flush()`, called by `SttProcessor` on `UserStoppedSpeaking`, and implement it for `whisper_local`. The marker was inert for the in-tree provider — it buffered the 333 zero samples like any other audio — and only worked against an STT in on the convention. The seam is additive: streaming services keep the no-op default. Barge-in races: - Make the stale-audio latch generation-stamped rather than a bool. The reactor and the sink's hook are woken independently; a reactor arm landing after the sink's clear left the latch set and muted the bot for the rest of the call. - Use `notify_one` rather than `notify_waiters` for the reactor wakeup, so a barge-in raised while it is mid-`send_clear` is stored instead of lost. - Downgrade the per-barge-in `info!` logs to `debug!`. Tests + docs: - 16 tests: hook delivery and error handling in the runtime, the realtime sink flush, the STT flush seam, the LLM cooperative cancel (with and without the flag), the speech gate, the bot-speaking edges, the latch ordering, and the aggregator/filter resets. - Document the hook in PROCESSOR-DESIGN §2.1/§2.2/§2.5 and CONTRIBUTING, including what the frame path cannot do. CONTRIBUTING claimed the runtime cancels an in-flight interruptible `process_frame`; it does not, and that belief is what left the barge-in arms looking wired.
|
Reviewed this against the runtime. Both of the first two findings reproduce exactly as described, and the third is the right diagnosis of why the frame path alone can't carry barge-in:
The shape of the fix is right. Merged with a follow-up commit on top (a0f4139). What changed and why: The hook was only half-applied. Three dead
Replaced Fixed a latch race that could mute the bot for the rest of the call. The reactor and the sink's hook are woken independently and either can be scheduled first. In the common ordering the reactor wins, but if the sink's
Tests. This was the main gap — 513 lines with none. Added 16 covering hook delivery and error handling in the runtime, the realtime sink flush, the STT flush seam, the cooperative LLM cancel both with and without the flag wired, the speech gate (pre-roll replay, pre-roll bound, no synthetic flush audio), the bot-speaking edges including the superseded-watchdog case, the latch ordering, and the aggregator/filter resets. Also tightened two comments that overstated their mechanism: Docs updated in PROCESSOR-DESIGN §2.1/§2.2/§2.5 and CONTRIBUTING. Worth noting CONTRIBUTING claimed the task loop cancels "an in-flight interruptible Two things left open, both yours to pick up if you want:
On the two extras in #60 — Thanks — good bug report, and the live measurements made the case far easier to evaluate than a description would have. |
Closes #60.
Adds an opt-in full-duplex path for the cascaded pipeline with working barge-in, validated live end-to-end (str0m WebRTC + aiortc client, whisper.cpp STT, OpenRouter LLM with tool calls, Kokoro TTS). The stock half-duplex builder and its
TurnMutebehavior are untouched; nothing changes unless you call the new builder.The problem (details in #60)
Frame::Interruption(drain + forward) and never delivers it toprocess_frame— the existingInterruptionarms in the transport sinks are unreachable, so frame-level barge-in cannot work on the cascaded path.BotStartedSpeaking/BotStoppedSpeaking, soVadProcessor's barge-in gate never arms.awaithop — we measured detection→sink delivery of 14 ms to 2.1 s depending on TTS/LLM activity — and an in-flight LLM stream cannot be cancelled, so the interrupted reply is spoken afterwards anyway.The changes
FrameProcessor::on_interruption()(new, default no-op)VadProcessor::{with_interrupt_flag, with_interrupt_notify}Notifybumped synchronously at detection (before the broadcast)LlmProcessor::with_interrupt_flagAssistantContextAggregator::on_interruptionLlmResponseEndcan no longer speak the reply)SpeechGate(new)SPEECH_GATE_FLUSH_SAMPLES) at the falling edge — without the turn lock, fixed-window batch STT hallucinates turns on silence and splits utterancesBotSpeakingNotifier(new) + sink wiringNotify-woken task flushes the carrier immediately (~110 µs from detection in our runs); the latch drops TTS audio that outran its interruptionbuild_cascaded_call_duplex(new, exported)VadAnalyzerso it stays feature-agnostic (caller passes e.g.SileroVad)Validation
cargo test -p flowcat-core --lib: 302 passed.cargo clippy -p flowcat-core --lib -- -D warnings: clean.cargo fmtapplied.Notes for review
process_frame), the hook + flag still stand alone.VAD_MIN_VOLUME = 0.6gated out moderate-volume speech entirely in our runs (only the loudest tail of utterances passed); we had to run with 0.2. Left untouched here since it's pipecat parity.🤖 Generated with Claude Code