Voice Interruption Handling¶
RoomKit provides four interruption strategies to control what happens when a user speaks while the AI is talking. This guide covers configuration, backchannel detection, and practical patterns for each strategy.
Quick Start¶
from __future__ import annotations
from roomkit.channels import VoiceChannel
from roomkit.voice.interruption import InterruptionConfig, InterruptionStrategy
voice = VoiceChannel(
"voice",
stt=stt,
tts=tts,
backend=backend,
interruption=InterruptionConfig(
strategy=InterruptionStrategy.CONFIRMED,
min_speech_ms=300,
),
)
Strategy Comparison¶
| Strategy | Behavior | Use Case | False Positives |
|---|---|---|---|
| IMMEDIATE | Cancel TTS on any speech detection | IVR, command interfaces | Highest |
| CONFIRMED | Wait for min_speech_ms before interrupting |
General conversation | Low |
| SEMANTIC | Use backchannel detection to distinguish "uh-huh" from real interruptions | Customer support, therapy | Lowest |
| DISABLED | Never interrupt — queue user speech until playback ends | Announcements, legal disclaimers | None |
Configuration¶
from roomkit.voice.interruption import InterruptionConfig, InterruptionStrategy
config = InterruptionConfig(
strategy=InterruptionStrategy.CONFIRMED,
min_speech_ms=300,
allow_during_first_ms=0,
flush_partial_tts=True,
keep_partial_transcript=True,
backchannel_detector=None,
)
| Field | Default | Description |
|---|---|---|
strategy |
CONFIRMED |
Which interruption strategy to use |
min_speech_ms |
300 |
Minimum speech duration (ms) before confirming interruption (CONFIRMED strategy) |
allow_during_first_ms |
0 |
Grace period: don't interrupt until N ms of audio were played (synthesis latency does not count) |
flush_partial_tts |
True |
Discard audio already handed to the backend. False lets the current utterance finish while the user's speech is processed alongside it |
keep_partial_transcript |
True |
Record what the room actually heard as an internal event when the bot is cut off |
backchannel_detector |
None |
Required for SEMANTIC strategy |
transcript_wait_ms |
1000 |
SEMANTIC: how long transcribed speech may run without words before it is judged on duration alone |
echo_guard_ms |
6000 |
While a session has played less response audio than this, speech made only of the response's own words is its echo: it neither interrupts nor becomes a turn (Echo guard). 0 turns it off |
What an interruption leaves behind¶
The AI's response event is written when the text is produced, not when it is
heard, so a bot cut off after two words still has its whole line in the
timeline. With keep_partial_transcript on, the interruption records what the
room actually heard as an internal-visibility event:
{
"interrupted": True,
"played_ms": 1840,
"voice_session_id": "...",
# "played_percentage": 46.0 — only when the utterance's duration is known
}
The full text is stored rather than a truncated guess: played_ms says how far
playback got, and cutting the string at the same proportion would invent a word
boundary that TTS timing does not guarantee.
Strategy Details¶
IMMEDIATE¶
Cancels TTS playback the instant any speech is detected. Best for systems where responsiveness matters more than naturalness.
voice = VoiceChannel(
"voice",
stt=stt, tts=tts, backend=backend,
interruption=InterruptionConfig(
strategy=InterruptionStrategy.IMMEDIATE,
allow_during_first_ms=200, # Prevent echo-triggered interruptions
),
)
Tip
Use allow_during_first_ms to add a grace period at the start of playback. This prevents the speaker's own audio echoing back and triggering a false interruption.
CONFIRMED¶
Waits until the user has been speaking for at least min_speech_ms before confirming the interruption. Filters out brief noise and accidental sounds.
The evaluation happens twice. At speech onset no duration has accumulated yet,
so the first look can only say "not yet" — the segment is held as possible echo
and a second look is taken once the speech has had min_speech_ms to sustain.
If the speech stopped in between, it was a blip and nothing is interrupted. The
confirmation logs at INFO:
That deferral is audible in one place: with a streaming STT provider, capture
starts at the confirmation, so roughly the first min_speech_ms of the
utterance does not reach the streaming transcript. The batch fallback receives
the whole segment, so the text is not lost — but lower min_speech_ms if you
need the leading words on the streaming path.
voice = VoiceChannel(
"voice",
stt=stt, tts=tts, backend=backend,
interruption=InterruptionConfig(
strategy=InterruptionStrategy.CONFIRMED,
min_speech_ms=300, # Require 300ms of continuous speech
),
)
SEMANTIC¶
Uses a BackchannelDetector to distinguish backchannels ("uh-huh", "yeah", "ok") from real interruptions. Falls back to CONFIRMED if no detector is configured.
The detector is never asked about an empty speech onset: when the pipeline VAD reports speech start there are no words yet and no duration. SEMANTIC holds the speech (nothing reaches the AI) until it has something to judge:
- With a streaming STT, the held speech is transcribed while the assistant
talks and each partial transcript is classified. "uh-huh" fires
ON_BACKCHANNELonce, the assistant keeps talking and the segment is discarded. "wait, stop" cuts the assistant off and becomes the user's turn, from its first word. - Without one, the speech is judged on duration alone once it has lasted
min_speech_ms, as CONFIRMED does. A keyword detector sees an empty transcript there and lets the interruption through; a detector working onspeech_duration_msoraudio_bytescan still recognise a backchannel.
A streaming STT often needs longer than min_speech_ms for its first words.
While one is transcribing the speech, SEMANTIC waits for them up to
transcript_wait_ms (default 1000) before judging on duration alone. Raise it
for a slow STT; lower it if a real interruption with no words should still cut
quickly.
Speech that ends before its first word is judged on its final transcript: a
streaming transducer often releases a short word only once the speech is over
(Nemotron gives "okay" and "no" only at the end). No words, or a backchannel,
is discarded and the assistant talks on; anything else ("stop") cuts it off
as soon as the STT has finalized, and becomes the user's turn. So a longer
transcript_wait_ms (2000 in the local examples) spares the assistant from
being cut by an "okay" without swallowing a short "stop".
In continuous-STT mode (no local VAD), the energy barge-in classifies the
words of the burst under way: a burst recognized as "uh-huh" fires
ON_BACKCHANNEL once and is not cut for running long, while words that turn
into a request still interrupt.
A backchannel lets the assistant keep talking, and its speech segment is discarded: the "uh-huh" does not become a user message (RFC §12.6).
voice = VoiceChannel(
"voice",
stt=stt, tts=tts, backend=backend,
interruption=InterruptionConfig(
strategy=InterruptionStrategy.SEMANTIC,
backchannel_detector=PhraseBackchannelDetector(),
),
)
DISABLED¶
Never interrupts. Speech that arrives while the assistant is talking is queued, not discarded: the assistant finishes its turn, and the held utterance is then processed as if it had just been spoken.
voice = VoiceChannel(
"voice",
stt=stt, tts=tts, backend=backend,
interruption=InterruptionConfig(
strategy=InterruptionStrategy.DISABLED,
),
)
The flush happens once the playback has ended — as soon as the last audio is sent when an AEC runs (the pipeline's or the backend's), after the two-second echo-decay drain otherwise — and it logs at INFO so a session shows plainly that nothing was dropped:
Two consequences to design around:
- The caller's interjection is answered a few seconds late by construction. That delay is the point of the strategy, not a defect.
- The backlog is bounded. A caller who talks continuously through a very long answer will have their earliest segments dropped, with a warning, rather than growing the queue without limit.
Use it for announcements, legal disclaimers and any turn that must be heard in full — while still hearing what the caller said during it.
Backchannel Detection¶
The SEMANTIC strategy requires a BackchannelDetector to classify short utterances.
PhraseBackchannelDetector¶
RoomKit ships one that reads the words: an utterance made only of known acknowledgements is a backchannel. "Okay", "mm-hmm", "yeah, right", "d'accord" or "c'est ça" let the assistant keep talking; "okay, and what about Calgary?" does not, and since each partial transcript is classified as it grows, the words after the "okay" still cut in. Thanks and reactions count as acknowledgements too: "merci", "thank you", "wow", "ouh", "ayoye" or a laugh ("haha", "[laughter]") said over the assistant answer what it just said, while "merci, mais attends" or "non merci" cut in. The French list covers Québec ("ouin", "mets-en", "c'est malade") as well as France. Left out on purpose: what asks the assistant to stop or repeat ("wait", "huh?", "hein ?"), what disagrees or doubts ("no", "oh no", "really?", "voyons donc"), what opens a turn ("um", "euh", "well"), and polite refusals ("that's okay", "c'est correct"). Punctuation, case and held letters ("hmmm", "ouiii") do not matter; a hummed "no" ("mm-mm", "uh-uh") still cuts in.
from __future__ import annotations
from roomkit.voice.interruption import InterruptionConfig, InterruptionStrategy
from roomkit.voice.pipeline.backchannel import (
ENGLISH_BACKCHANNELS,
PhraseBackchannelDetector,
)
interruption = InterruptionConfig(
strategy=InterruptionStrategy.SEMANTIC,
backchannel_detector=PhraseBackchannelDetector(), # English and French
)
english_only = PhraseBackchannelDetector(ENGLISH_BACKCHANNELS + ("righto",), max_words=3)
| Option | Default | Description |
|---|---|---|
phrases |
ENGLISH_BACKCHANNELS + FRENCH_BACKCHANNELS |
The acknowledgements, multi-word ones included ("i see", "tout à fait") |
max_words |
4 |
A longer utterance is never a backchannel, whatever its words |
cut_without_words |
True |
Whether an utterance without words may cut the assistant (on its duration) |
It needs words: with no transcript it judges no utterance a backchannel, and SEMANTIC falls back on duration as CONFIRMED does. Pair it with a streaming STT. A short sound that ends before it can be confirmed (a breath, a remnant of echo) is dropped by the channel anyway, without cutting the assistant.
With cut_without_words=False only words interrupt: sound the STT made nothing
of (echo an AEC left, a cough, a door) never cuts the assistant, however long it
lasts, and neither does a wordless interruption. On a laptop's speakers, three
of six cuts once came from such sound, with no word in the transcript. The
examples' INTERRUPTION=words sets it.
words_only = InterruptionConfig(
strategy=InterruptionStrategy.SEMANTIC,
backchannel_detector=PhraseBackchannelDetector(cut_without_words=False),
)
A detector of your own¶
Implement the ABC, for an acoustic classifier for instance:
from __future__ import annotations
from roomkit.voice.pipeline.backchannel import (
BackchannelContext,
BackchannelDecision,
BackchannelDetector,
)
class KeywordBackchannelDetector(BackchannelDetector):
"""Simple keyword-based backchannel detector."""
BACKCHANNELS = {"uh-huh", "yeah", "ok", "mhm", "right", "sure", "hmm", "yes"}
@property
def name(self) -> str:
return "keyword_backchannel"
def classify(self, context: BackchannelContext) -> BackchannelDecision:
text = (context.transcript or "").strip().lower()
is_bc = text in self.BACKCHANNELS and context.speech_duration_ms < 1000
return BackchannelDecision(
is_backchannel=is_bc,
confidence=0.9 if is_bc else 0.1,
label="acknowledgement" if is_bc else None,
)
BackchannelContext¶
The detector receives rich context for classification:
| Field | Type | Description |
|---|---|---|
transcript |
str \| None |
Transcribed text of the utterance |
speech_duration_ms |
float |
Duration of the utterance |
audio_bytes |
bytes \| None |
Raw audio for acoustic classifiers |
bot_speech_progress |
float |
How far into the bot's utterance (0.0-1.0) |
session_id |
str |
Voice session ID |
metadata |
dict |
Additional context |
Pipeline Integration¶
Interruption config can also be set via AudioPipelineConfig:
from __future__ import annotations
from roomkit.channels import VoiceChannel
from roomkit.voice.interruption import InterruptionConfig, InterruptionStrategy
from roomkit.voice.pipeline import AudioPipelineConfig
pipeline = AudioPipelineConfig(
vad=vad,
denoiser=denoiser,
backchannel_detector=my_detector,
interruption=InterruptionConfig(
strategy=InterruptionStrategy.SEMANTIC,
min_speech_ms=300,
),
)
voice = VoiceChannel(
"voice",
stt=stt, tts=tts,
backend=backend,
pipeline=pipeline,
)
Configuration Priority
- Explicit
interruptionparameter on VoiceChannel (highest) pipeline.interruptionfrom AudioPipelineConfig- Legacy
enable_barge_in+barge_in_threshold_ms(fallback)
Hook Integration¶
Three hooks fire during interruption events:
Where the speech is detected does not change who decides
Speech during playback can be noticed by the pipeline's VAD, by the
continuous-STT energy check, or by a transport that does its own detection
and calls on_barge_in. All three routes consult the same
InterruptionConfig: a backend with native detection cannot override
DISABLED, and it is subject to allow_during_first_ms like every other
route. A transport reports detected speech, not a decision.
ON_BARGE_IN¶
Fires when the user successfully interrupts the AI:
from __future__ import annotations
from roomkit import HookTrigger, HookExecution
@kit.hook(HookTrigger.ON_BARGE_IN, execution=HookExecution.ASYNC)
async def on_barge_in(event, ctx):
# event is a BargeInEvent
# audio_position_ms: audio the user heard, the timeline's played_ms
print(f"User interrupted at {event.audio_position_ms}ms")
print(f"AI was saying: {event.interrupted_text}")
ON_TTS_CANCELLED¶
Fires when TTS playback is cancelled for any reason:
@kit.hook(HookTrigger.ON_TTS_CANCELLED, execution=HookExecution.ASYNC)
async def on_tts_cancelled(event, ctx):
# event is a TTSCancelledEvent
# event.reason: "barge_in" | "explicit" | "disconnect" | "error"
if event.reason == "barge_in":
print(f"TTS stopped at {event.audio_position_ms}ms due to barge-in")
ON_BACKCHANNEL¶
Fires when the SEMANTIC strategy detects a backchannel (not a real interruption):
@kit.hook(HookTrigger.ON_BACKCHANNEL, execution=HookExecution.ASYNC)
async def on_backchannel(event, ctx):
# event is a BackchannelEvent
print(f"Backchannel: '{event.text}' (confidence={event.confidence})")
Echo guard: echo_guard_ms¶
An echo canceller learns the room from the responses it hears. At a session's start it has heard none: the first response's opening words ("Hi! How are you?") come back through the microphone at full strength, a streaming STT transcribes them, and they cut the agent off, then arrive as the person's turn.
Until a session has played echo_guard_ms of response audio (6 s by default),
speech whose words are only words of the response being played, consecutive
and in order, is taken for its echo, whatever the strategy. Case, punctuation
and accents do not matter. In a partial transcript the last word may be cut
short after a whole word ("Hi! How are y"); a lone word and a final are
matched whole, so "No" over "I'm not sure" cuts in. The echo does not
interrupt and is no turn: its partial transcripts are not published, and its
final transcript is discarded, even when the STT delivers it seconds after the
response ended (up to 10 s). One word the response does not say there makes
it speech: "Hi, wait" cuts in, and so does a final that says it during the
playback when its first words were taken for the echo. Audio played with
VoiceChannel.play() counts toward the guard. In continuous-STT mode, while
the guard is open, loud audio with no words yet waits for the STT's first
words (up to transcript_wait_ms) before it may cut on its duration.
Words taken for the echo decide nothing else: when the STT never closes that utterance, they cut neither the response that took over from the echoed one nor the next, nor do the STT's later partials of the same echo, and they lapse with the verdict.
Past the guard, the response's own words cut in as any words do, so someone
answering "the sword" to "the sword or the shield?" is heard. Raise it for a
canceller that learns slowly, or set echo_guard_ms=0 to turn it off.
With an STT that tells voices apart (Meta's DIARIZATION), the agent's own
voice is caught past the guard too. A voice heard speaking while nothing played
is a person's. A final from a voice never heard so, said over a response, whose
words are the response's in their order, one misheard at most ("Eh bien, la
lampe salon" for "c'est bien la lampe Salon") and three at least heard right,
is the response coming back past the canceller: it is discarded. Its first
words may still have cut the response, as the voice is known only with the
final. The rule has a cost: a person not yet heard while nothing played who
answers over a response with three or more of its words ("the kitchen lamp"
to "the kitchen lamp or the living room lamp?") cuts it and is not heard, so
the agent stops and says nothing; what they say next while nothing plays is
heard. Voices are known within one STT stream: when the provider closes the
stream, those heard alone are forgotten, as the next one numbers its voices
afresh.
Grace Period: allow_during_first_ms¶
The allow_during_first_ms field prevents interruptions during the first N milliseconds of playback. It counts audio actually played: a response still waiting for its first TTS chunk has played 0 ms, however long synthesis takes. This is useful for:
- Echo prevention: When AEC isn't perfect, the first few hundred ms of playback can echo back and trigger false interruptions
- Critical content: Ensure the AI's opening words are always heard
InterruptionConfig(
strategy=InterruptionStrategy.IMMEDIATE,
allow_during_first_ms=500, # Don't interrupt during first 500ms
)
On RealtimeVoiceChannel, a provider such as OpenAI may own the VAD and cancel
its response before RoomKit receives the speech-start callback. When the
transport reports actual playback, RoomKit therefore enforces this grace period
at the provider-input boundary: AEC, AGC, denoising, and recording still receive
the real microphone signal, while the provider receives equal-duration PCM
silence. Once the interval expires, normal audio and barge-in resume. Transports
without playback callbacks cannot apply this physical-playback guard.
Legacy Compatibility¶
The older enable_barge_in and barge_in_threshold_ms parameters still work and map to InterruptionConfig internally:
# Legacy (still works):
VoiceChannel("voice", enable_barge_in=True, barge_in_threshold_ms=300)
# Equivalent to:
# InterruptionConfig(strategy=IMMEDIATE, allow_during_first_ms=300)
# Legacy disabled:
VoiceChannel("voice", enable_barge_in=False)
# Equivalent to:
# InterruptionConfig(strategy=DISABLED)
Warning
Prefer the explicit interruption parameter over legacy fields for new code.
Testing¶
Use MockBackchannelDetector for deterministic tests:
from __future__ import annotations
from roomkit.voice.interruption import InterruptionConfig, InterruptionHandler, InterruptionStrategy
from roomkit.voice.pipeline.backchannel import BackchannelDecision
from roomkit.voice.pipeline.backchannel.mock import MockBackchannelDetector
# Test CONFIRMED strategy
config = InterruptionConfig(strategy=InterruptionStrategy.CONFIRMED, min_speech_ms=300)
handler = InterruptionHandler(config)
decision = handler.evaluate(playback_position_ms=500, speech_duration_ms=400)
assert decision.should_interrupt
# Test SEMANTIC with backchannel
detector = MockBackchannelDetector(
decisions=[BackchannelDecision(is_backchannel=True, confidence=0.95)]
)
config = InterruptionConfig(
strategy=InterruptionStrategy.SEMANTIC,
backchannel_detector=detector,
)
handler = InterruptionHandler(config)
decision = handler.evaluate(playback_position_ms=500, speech_duration_ms=300, speech_text="uh-huh")
assert not decision.should_interrupt
assert decision.is_backchannel