Audio & Speech

Turn-Taking, Endpointing & Barge-In

A voice agent that cuts you off mid-sentence, or leaves two seconds of dead air before every reply, is unusable — no matter how good the model behind it is. Deciding when the human is done talking is the hardest product problem in voice. We build it from an energy threshold up to full-duplex models that dissolve the question entirely.

Prerequisites: Sound is a wiggle you can measure + you have had a conversation. That’s it. (If you want the spectrogram background: Audio Representations.)
10
Chapters
11
Simulations
0
Assumed Knowledge

Chapter 0: The Two-Second Silence

You call a pizza place. A voice answers, warm and natural, indistinguishable from a person. You say: “Hi, I’d like to order a…” — and you pause for half a second, because you are looking at the menu and cannot remember whether you want the pepperoni or the margherita.

The agent says: “Great! One moment while I place that order for you.”

Order what? You said four words. The agent heard silence, decided you were finished, and started talking over the rest of your sentence. Now you have to interrupt it, it has to abort, and the conversation is already a mess. Every human on earth would have waited. The agent did not.

So the engineer does the obvious thing and raises the silence threshold: wait 2 seconds of quiet before responding. Now the agent never cuts you off. Instead, every single time you finish a sentence, there is a long, dead, hollow pause before it answers — the pause you get on a bad international phone call, the one that makes you say “hello? are you there?” and start talking again, which restarts the whole timer. The agent is now polite and unusable.

This is the whole lesson in one paragraph. Turn detection — deciding the moment the human has finished their turn and it is your turn to speak — is a single scalar decision made hundreds of times per conversation, and both ways of being wrong are severe. Being early is an interruption. Being late is dead air. There is no setting that avoids both, and the model quality behind the agent does not help you at all: a genius that talks over you is worse than an idiot that waits.

The misconception this lesson kills: “turn detection is just voice activity detection with a timer.” It is not. Voice activity detection answers is there speech in this 10 ms of audio? — a signal-processing question. Turn detection answers is this person finished with their thought? — a linguistic and pragmatic question. “I’d like to order a…” followed by 700 ms of silence is unambiguously not-finished to any human listener, and unambiguously finished to any energy threshold. Confusing these two questions is the single most common architectural mistake in voice agents.

Why this is worse than it sounds

In text chat, latency is free-ish. You send a message, the model thinks for two seconds, you read the answer. Nobody minds; the pause even signals “it’s working.” In voice, silence is not neutral — it is a message. Conversation analysts call this a paralinguistic signal: meaning carried by something other than the words. A 200 ms gap before “yes” means yes. A 900 ms gap before the same “yes” means reluctant yes. Your latency is being read as an emotion whether you like it or not.

Human conversation runs on gaps of roughly 200 milliseconds between one speaker stopping and the next starting — a number that holds up astonishingly well across cultures and languages (Stivers and colleagues measured ten languages from Danish to Tzeltal and found the modal gap in every one sitting within a couple of hundred milliseconds of zero). Two hundred milliseconds is less than it takes to plan a sentence. Which means humans do not wait for you to finish and then start thinking. They predict your ending, prepare their reply, and fire it off the instant you stop — sometimes a hair before.

A cascaded voice agent has to beat that number with a pipeline that contains, at minimum: network transport, streaming speech recognition, end-of-turn detection, a language model’s time-to-first-token, a text-to-speech model’s time-to-first-audio, and playout buffering. In 2026, the fastest production speech-to-speech systems measure end-to-end response times around 0.78–0.82 s, with plenty of well-known systems sitting near 3 s. Human pacing is still comfortably ahead.

Where the milliseconds actually go. Engineers instinctively blame the language model. But in a tuned cascaded stack the model’s first token arrives in ~150–300 ms and speech recognition adds tens of milliseconds on top of streaming partials. The genuinely large, genuinely tunable term is the end-of-turn wait — the silence you deliberately sit through before you dare to speak. Twilio’s engineering write-up puts it bluntly: end-of-turn detection is “usually the long pole in the tent,” because it is tuned to avoid false positives and therefore has padding built in. The biggest latency win available to most voice teams is not a faster model. It is a smarter answer to “are they done?”

Feel the failure yourself

Below is a real utterance laid out on a timeline: a caller saying “I’d like to order a … large pepperoni pizza” with a 700 ms hesitation in the middle while they look at the menu, then a genuine end of turn. The blue blocks are the caller’s speech; the gaps are silence. Drag the threshold slider: it sets how many milliseconds of silence the agent waits before it decides you are finished and starts talking (the orange block).

There is no good position on this slider. Below ~700 ms the agent fires inside the hesitation and talks over the second half of the sentence. Above it, the agent is silent and correct, but look at the dead-air number climbing on the right. Every millisecond you buy in safety, you pay in awkwardness — on every single turn of every conversation, not just the hard ones.

The impossible slider

One caller utterance with one mid-sentence hesitation. Move the silence threshold and watch the agent switch between interrupting and stalling. The verdict banner tells you which failure you just bought.

silence threshold300 ms
hesitation length700 ms

Now set the hesitation slider to 0 — a caller who never hesitates — and notice that a 200 ms threshold is suddenly perfect. That is the trap that kills voice products in testing: the engineer tests on themselves, speaking in clean, complete, rehearsed sentences, and 300 ms works beautifully. Real callers hesitate, read numbers off cards, say “um,” and think out loud. The distribution of pauses in the wild has a fat tail, and your threshold has to survive that tail.

What we are going to build

We will construct the whole stack from nothing, in the order it was historically invented, because each layer exists to fix a specific failure of the one before it:

1 · Energy VAD
Is this 10 ms frame loud? Frame energy + a threshold + hangover timers. Fails on noise.
↓ noise breaks it
2 · Neural VAD
Is this 10 ms frame speech? Spectral features into a tiny classifier. Still knows nothing about meaning.
↓ “order a…” breaks it
3 · Endpointing
Is this turn finished? Silence timers plus a model reading the transcript for completeness.
↓ the agent still has to be interruptible
4 · Barge-in
Stop playback, abort generation, cancel tools, repair the context to what was actually heard.
↓ but “mm-hm” is not an interruption
5 · Backchannel logic
Classify the overlap: acknowledgement or takeover? Asymmetric costs, asymmetric threshold.
↓ or: delete the whole problem
6 · Full-duplex models
Model both audio streams at once. No VAD, no endpointing, no turn boundary at all.

By the end you will be able to write the energy VAD by hand, derive the optimal silence threshold from a pause-length distribution, implement the barge-in state machine including the context-repair step everyone forgets, and explain precisely why Moshi’s architecture makes every box above disappear.

Why is turn detection called the hardest product problem in voice agents?

Chapter 1: What Is a Turn?

We just spent a chapter arguing about when a turn ends without ever defining what a turn is. That is not pedantry — every engineering decision downstream depends on the definition, and the definition is messier than you expect.

Naively: a turn is “the stuff one person says before the other person says stuff.” Conversation analysts have been measuring real recorded conversations for fifty years, and the picture they get back has four ingredients. Learn these four words and the rest of the lesson has vocabulary.

The four primitives

An IPU — inter-pausal unit — is a continuous stretch of one person’s speech bounded by silence on both sides (the usual cutoff is 200 ms of silence). It is the atom of measurement: not a word, not a sentence, just “a run of talking.” The sentence “I’d like to order a … large pepperoni pizza” is two IPUs, not one, because the hesitation splits it.

A pause is silence between two IPUs of the same speaker. That is our villain from Chapter 0: the caller is not finished, they are just breathing, thinking, or reading a menu.

A gap is silence between IPUs of different speakers — the handoff. This is the quantity that runs about 200 ms in natural conversation and is what your agent’s response latency is competing against.

An overlap is time when both speakers have an IPU running at once. Overlap is not an error; it is normal, healthy conversation. Measurements of multi-party meetings put overlapping speech at roughly 10–20% of all spoken time. A system architecture that assumes speech is strictly alternating — a half-duplex assumption — is throwing away a fifth of reality before it starts.

Why the same silence means two different things. A pause and a gap are physically identical — both are just quiet air. The only difference is what happens after: if the same person resumes it was a pause, if the other person starts it was a gap. Your detector has to decide at the moment the silence starts, using only the past. It is being asked to distinguish two things that are defined by the future. That is the deepest reason turn detection is hard, and it explains why every extra signal you can bring — prosody, syntax, semantics — buys real accuracy: they are all attempts to predict the future from the shape of the past.

The fifth ingredient: backchannels

Then there is the thing that breaks naive systems worst of all. While you are talking, the listener says “mm-hm.” “Right.” “Yeah.” “Oh wow.” These are backchannels — short vocalisations that mean keep going, I am with you. They are speech. They overlap. They are emphatically not a bid for the floor.

Now consider your agent. It is halfway through reading back an order. The caller says “mm-hm.” A naive barge-in implementation sees energy on the microphone, concludes it is being interrupted, kills the text-to-speech mid-word and goes silent, waiting. The caller, who said “mm-hm” precisely to signal continue, is met with an abrupt stop and an awkward pause. They say “…sorry, go on?” This failure is so common you have almost certainly experienced it this year.

Symmetrically, humans backchannel at agents constantly, and good agents backchannel back — the small “mm-hm” while you read out a long address is what makes the interaction feel alive. Most production systems today are half-duplex-with-barge-in: the user may interrupt the agent, the agent may not interrupt the user, except for these short acknowledgements.

A worked measurement, by hand

Let us annotate a real-shaped exchange with a stopwatch, because the numbers are the point. Timestamps in seconds from the start of the recording:

Time (s)WhoWhat
0.00 – 1.35Caller“Hi, I’d like to order a”
1.35 – 2.05silence (0.70 s)
2.05 – 3.40Caller“large pepperoni pizza”
3.40 – 3.62silence (0.22 s)
3.62 – 6.10Agent“Got it — one large pepperoni. Anything to drink?”
5.40 – 5.66Caller“mm-hm” (0.26 s, during the agent)

Now compute, step by step:

IPUs. The caller produces two: 0.00–1.35 and 2.05–3.40. Plus the backchannel 5.40–5.66, which is technically a third IPU. The agent produces one: 3.62–6.10.

Pause. Between caller IPU 1 and caller IPU 2: 1.35 s to 2.05 s, so the pause length is 2.05 − 1.35 = 0.70 s. Same speaker on both sides → pause, not gap. Any endpointer with a threshold below 700 ms fires here and is wrong.

Gap. Between caller IPU 2 and the agent’s IPU: 3.62 − 3.40 = 0.22 s. Different speakers → gap. And 220 ms is right in the human band, so this handoff feels human.

Overlap. The backchannel runs 5.40–5.66 while the agent runs 3.62–6.10. Overlap = min(5.66, 6.10) − max(5.40, 3.62) = 5.66 − 5.40 = 0.26 s. As a fraction of total spoken time: total speech = caller 1.35 + 1.35 + 0.26 = 2.96 s, agent 2.48 s, so 5.44 s of speech, of which 0.26 s overlaps → 0.26 / 5.44 = 4.8%. A single polite exchange is at the low end; heated multi-party meetings are where you get to 20%.

The design consequence, in one line. To handle this six-second exchange correctly, a system must tolerate a 700 ms same-speaker pause without firing, then fire within about 220 ms of a same-length silence that happens to be a real ending, and then ignore a 260 ms overlap entirely. A pure silence timer cannot do this: 700 > 220 means no single threshold separates the two silences. The information that distinguishes them is not in the silence. It is in the words on either side.

Concept → realization. This is why the data flow of a modern endpointer is not audio → decision. It is audio → (VAD frames at 10–30 ms) and audio → (streaming ASR partial transcript) → both into a turn model that also sees prosody. Two streams, different rates, joined at the decision. Any diagram that shows a single arrow from microphone to “VAD” to “LLM” has drawn a system that cannot solve the exchange above.

Explore the anatomy

The widget below is that exact exchange, drawn to scale. Step through the events with the slider — each stop highlights one primitive and prints its arithmetic. Notice that the pause (0.70 s) and the gap (0.22 s) look like the same thing to a microphone, and only the labels above them differ.

Anatomy of a turn exchange

Caller track (teal) above, agent track (orange) below, in real time. Step the slider to highlight each IPU, pause, gap and overlap with its measured length. Toggle the “detector view” to see what a bare energy detector sees: one undifferentiated on/off strip.

step0
detector viewoff

Flip “detector view” on and the tragedy is visible: the annotated exchange collapses into a binary strip of loud and quiet. The 0.70 s pause and the 0.22 s gap become indistinguishable rectangles of nothing, differing only in width — and the wrong one is wider. Everything we build from here is an attempt to recover the labels that this view destroys.

A caller says a phrase, goes quiet for 0.7 s, then continues the same sentence. In turn-taking vocabulary, that 0.7 s of silence is:

Chapter 2: Energy VAD, built by hand

Start at the bottom. Before any question about turns or meaning, there is a much smaller question: is there sound in this tiny slice of audio? Answering it is voice activity detection (VAD), and the oldest answer — still running in production in more places than anyone admits — is a threshold on energy.

Frames: chopping time into decisions

Audio arrives as a stream of samples: at 16 kHz, sixteen thousand numbers per second, each one the air pressure at an instant. You cannot make a decision per sample — a single sample tells you nothing, since even a loud sine wave passes through zero. So you chop the stream into frames: short blocks, typically 10, 20 or 30 milliseconds. At 16 kHz a 10 ms frame is 160 samples. One decision per frame gives you a decision every 10 ms, which is plenty — a phoneme lasts 50–150 ms.

Frame size is a real trade-off, and it is the same trade you meet everywhere in audio. Too short and the energy estimate is noisy (you might land inside a zero-crossing). Too long and your boundaries get coarse — a 100 ms frame cannot tell you the speech started 30 ms in. Ten to thirty milliseconds is the sweet spot for the same reason it is the sweet spot for spectrograms.

Energy: the one formula

For a frame of N samples x1xN, the root-mean-square amplitude is exactly what it says: square every sample, take the mean, take the square root.

RMS = √( (1/N) · ∑i xi2 )

Why square first? Because pressure swings both ways — a loud frame has big positive and big negative values, and their plain average is near zero. Squaring makes everything positive and, conveniently, proportional to energy. The square root at the end puts us back in amplitude units so the number is comparable to the sample values themselves.

Then we convert to decibels, because loudness perception is logarithmic and because it makes thresholds behave sanely across a 100 000:1 dynamic range:

level(dB) = 20 · log10(RMS)

With samples normalised to the range −1 to +1, this is dBFS — decibels relative to full scale. Full-scale is 0 dB; everything real is negative. Loud speech into a phone lands around −10 to −20 dBFS; room tone lands around −45 to −60. Why 20 and not 10? Because we are taking the log of an amplitude, and amplitude squared is power — 20 log10(A) = 10 log10(A2). Same number, different units in.

The hand-worked example — every single step

Here is a toy utterance: eight frames. Each toy frame holds 8 samples so you can do the arithmetic on paper; treat each one as standing in for a real 10 ms frame (160 samples at 16 kHz). The clip is “quiet → speech onset → loud vowel → a stop-consonant closure → loud vowel → quiet” — the classic shape that breaks naive detectors, because the closure in frame 4 is genuinely silent inside a word.

the toy clip — 8 frames × 8 samples
f1  # room tone   [ 0.02, -0.01,  0.00,  0.01, -0.02,  0.01,  0.00, -0.01]
f2  # onset       [ 0.10, -0.14,  0.18, -0.12,  0.09, -0.15,  0.13, -0.09]
f3  # loud vowel  [ 0.30, -0.42,  0.51, -0.38,  0.27, -0.45,  0.40, -0.29]
f4  # /p/ closure [ 0.03, -0.02,  0.02, -0.03,  0.02, -0.01,  0.03, -0.02]
f5  # loud vowel  [ 0.25, -0.31,  0.36, -0.28,  0.22, -0.33,  0.30, -0.24]
f6  # room tone   [ 0.01, -0.02,  0.01,  0.00, -0.01,  0.02, -0.01,  0.00]
f7  # room tone   [ 0.01, -0.01,  0.00,  0.01, -0.01,  0.00,  0.01, -0.01]
f8  # room tone   [ 0.01, -0.01,  0.00,  0.01, -0.01,  0.00,  0.01, -0.01]

Frame 1. Square each sample: 0.022=0.0004, 0.012=0.0001, 02=0, 0.012=0.0001, 0.022=0.0004, 0.012=0.0001, 02=0, 0.012=0.0001. Sum = 0.0004+0.0001+0+0.0001+0.0004+0.0001+0+0.0001 = 0.0012. Mean = 0.0012 / 8 = 0.00015. RMS = √0.00015 = 0.012247. Level = 20 log10(0.012247). Now log10(1.2247) = 0.0881, and we have 1.2247×10−2, so log10 = 0.0881 − 2 = −1.9119. Times 20: −38.24 dBFS.

Frame 2. Squares: 0.0100, 0.0196, 0.0324, 0.0144, 0.0081, 0.0225, 0.0169, 0.0081. Running sum: 0.0100 → 0.0296 → 0.0620 → 0.0764 → 0.0845 → 0.1070 → 0.1239 → 0.1320. Mean = 0.1320 / 8 = 0.0165. RMS = √0.0165 = 0.12845. log10(1.2845) = 0.1088 → −0.8912 → ×20 = −17.82 dBFS.

Frame 3. Squares: 0.0900, 0.1764, 0.2601, 0.1444, 0.0729, 0.2025, 0.1600, 0.0841. Running sum: 0.0900 → 0.2664 → 0.5265 → 0.6709 → 0.7438 → 0.9463 → 1.1063 → 1.1904. Mean = 0.1488. RMS = √0.1488 = 0.38575. log10(3.8575) = 0.5863 → −0.4137 → −8.27 dBFS. This is a shout compared to frame 1 — 30 dB louder, which is a factor of about 31 in amplitude and 1000 in power.

Frame 4 — the dangerous one. Squares: 0.0009, 0.0004, 0.0004, 0.0009, 0.0004, 0.0001, 0.0009, 0.0004. Sum = 0.0044. Mean = 0.00055. RMS = √0.00055 = 0.023452. log10(2.3452) = 0.3703 → 0.3703 − 2 = −1.6297 → −32.59 dBFS. That is only 5.6 dB above room tone. To an energy detector, the closure of a /p/ is silence. To a listener, it is the middle of a word.

Frame 5. Squares: 0.0625, 0.0961, 0.1296, 0.0784, 0.0484, 0.1089, 0.0900, 0.0576. Running sum: 0.0625 → 0.1586 → 0.2882 → 0.3666 → 0.4150 → 0.5239 → 0.6139 → 0.6715. Mean = 0.08394. RMS = 0.28972. log10(2.8972) = 0.4619 → −0.5381 → −10.76 dBFS.

Frame 6. Identical squares to frame 1 (0.0001×4 and 0.0004×2, sum 0.0012) → −38.24 dBFS.

Frames 7 and 8. Squares: six values of 0.0001 and two zeros → sum = 0.0006. Mean = 0.000075. RMS = √0.000075 = 0.008660. log10(8.660) = 0.9375, so log10(8.660×10−3) = 0.9375 − 3 = −2.0625 → −41.25 dBFS.

Frame∑x2meanRMSdBFS> −25 dB?
f10.00120.000150.01225−38.24no
f20.13200.016500.12845−17.82yes
f31.19040.148800.38575−8.27yes
f40.00440.000550.02345−32.59no
f50.67150.083940.28972−10.76yes
f60.00120.000150.01225−38.24no
f70.00060.0000750.00866−41.25no
f80.00060.0000750.00866−41.25no

Raw decision sequence with a −25 dBFS threshold: 0 1 1 0 1 0 0 0. The word has been chopped in half. Downstream, that reads as two IPUs with a pause between them, and if your endpointer’s threshold is short enough it will fire inside a single word. Everybody hits this bug.

Hangover: the fix that costs latency

The cure is a hangover timer (also called a hold-over or release time): once you declare speech, keep declaring speech for H more frames after the energy drops. It is exactly the “wait a beat before you conclude they stopped” instinct, made mechanical.

statek = SPEECH if (levelk > θ)  or  (hk > 0);    hk = H if levelk > θ, else max(0, hk−1 − 1)

Trace it on our eight frames with H = 2, starting with h = 0:

kdBFSabove θ?h beforestateh after
1−38.24no0SILENCE0
2−17.82yes0SPEECH2
3−8.27yes2SPEECH2
4−32.59no2SPEECH (held)1
5−10.76yes1SPEECH2
6−38.24no2SPEECH (held)1
7−41.25no1SPEECH (held)0
8−41.25no0SILENCE0

Now read what the hangover bought and what it cost, because both are in the table and both matter.

Bought: frame 4 is now SPEECH. The word is one IPU again. The plosive closure no longer looks like the end of a turn.

Cost: the true end of speech was the end of frame 5. The detector does not say SILENCE until frame 8. That is a two-frame delay on every single endpoint, always, unconditionally. With 10 ms frames that is 20 ms — cheap. But real systems set hangover to 200–400 ms to survive real pauses between words, and that lands directly on your response latency. Hangover is latency you pay on every turn to avoid a chop you would suffer on some turns. That trade is the DNA of this entire field, and we will formalise it in Chapter 5.

The mistake: raising the threshold θ to fix false triggers on noise. It feels right — noisy room, so demand more energy. But raising θ makes quiet speech disappear too: soft talkers, people leaning away from the phone, the unstressed ends of sentences (which is exactly where your endpoint decision lives). You will trade a false-trigger bug for a truncated-speech bug that is much harder to notice in testing and much more infuriating in production. The right fix is not a bigger number; it is a better feature — which is the whole point of Chapter 3.

Adaptive noise floor — the trick that makes it survive

A fixed −25 dBFS threshold is doomed: a phone call from a car has a noise floor near −30 dB, a quiet office near −55. The standard fix is to track the floor and set the threshold relative to it. Take a slow running estimate of the quietest recent level and demand speech exceed it by a fixed margin:

noisek = (1−α) · noisek−1 + α · levelk  (only when the frame is judged silent)    θk = noisek + margin

With α = 0.02 the floor moves at about 2% per frame — slow enough that speech does not drag it up, fast enough to follow an air conditioner switching on. A margin of 10–15 dB is typical. Work one step: if noisek−1 = −41.0 and a silent frame arrives at −38.24 dB, then noisek = 0.98×(−41.0) + 0.02×(−38.24) = −40.18 − 0.765 = −40.945 dB, and with a 15 dB margin the threshold becomes −25.95 dB. It crept up by 0.055 dB. Slow is the point.

Code: three forms

python — form 1: the arithmetic, exactly as we did it by hand
import math

frame = [0.10, -0.14, 0.18, -0.12, 0.09, -0.15, 0.13, -0.09]

total = 0.0
for x in frame:
    total += x * x                 # 0.0100, then 0.0296, then 0.0620 ...
mean = total / len(frame)      # 0.1320 / 8 = 0.01650
rms  = math.sqrt(mean)          # 0.12845
db   = 20 * math.log10(rms)    # -17.82 dBFS  <- matches the table
python — form 2: the streaming detector with hangover and adaptive floor
import numpy as np

class EnergyVAD:
    def __init__(self, sr=16000, frame_ms=10,
                 margin_db=15.0, hangover_ms=200, onset_frames=2, alpha=0.02):
        self.n       = int(sr * frame_ms / 1000)   # 160 samples per frame
        self.H       = int(hangover_ms / frame_ms)  # 20 frames of hold
        self.onset   = onset_frames                # consecutive loud frames to START
        self.margin  = margin_db
        self.alpha   = alpha
        self.noise   = -50.0    # dBFS, will adapt to the room
        self.h       = 0        # hangover countdown
        self.run     = 0        # consecutive-loud counter
        self.speech  = False

    def level_db(self, frame):
        rms = np.sqrt(np.mean(frame.astype(np.float64) ** 2) + 1e-12)
        return 20.0 * np.log10(rms)

    def push(self, frame):
        db  = self.level_db(frame)
        thr = self.noise + self.margin
        loud = db > thr

        if loud:
            self.run += 1
        else:
            self.run = 0
            # only learn the floor from quiet frames, or speech drags it up
            self.noise = (1 - self.alpha) * self.noise + self.alpha * db

        if self.run >= self.onset:      # ONSET: needs onset_frames in a row
            self.speech = True
            self.h = self.H
        elif self.h > 0:                 # HANGOVER: hold through short dips
            self.h -= 1
        else:
            self.speech = False

        return self.speech, db, thr
python — form 3: the library one-liner (WebRTC VAD, the industry workhorse)
import webrtcvad
vad = webrtcvad.Vad(2)                       # aggressiveness 0..3
is_speech = vad.is_speech(frame_bytes, 16000)  # 10/20/30 ms of 16-bit PCM
# Same shape of answer. Underneath: a GMM on sub-band energies plus, yes, a hangover.

Note what the library did not remove: you still choose an aggressiveness (a threshold by another name), you still choose the frame size, and you still have to add your own hangover and onset logic on top if you want stable segments. The one-liner hides the arithmetic, not the design decisions.

Play with it

The widget draws our eight-frame clip as an energy bar chart with your threshold as a line, then the raw decision strip, then the decision strip after hangover. Push the threshold up and watch the onset frame vanish; push the hangover up and watch the trailing SPEECH tail grow — that tail is your added response latency, drawn to scale.

Energy VAD with hangover

Bars are frame levels in dBFS (the hand-computed numbers). The dashed line is θ. Middle strip: raw decisions. Bottom strip: after hangover. Add noise to the room and watch the floor rise until room tone crosses θ.

threshold θ−25 dB
hangover H (frames)2
room noise added0 dB

Now crank the room noise to about 18 dB. The room-tone frames climb above −25 dB and the detector declares speech continuously. Your voice agent, in a car, now believes the user never stops talking, so it never responds at all. That is not a hypothetical: it is the number-one field failure of energy VAD, and no amount of threshold tuning fixes it, because the noise and the speech now have the same energy. They do not, however, have the same shape. Next chapter.

A hangover timer holds the SPEECH state for H frames after the energy drops. What does it buy and what does it cost?

Chapter 3: Neural VAD — from “loud” to “speech”

We left the energy detector drowning in a car. The problem is precisely stated: energy is not speech-specific. A fan, an engine, a fridge compressor, road roar, another conversation across the room — all have energy. Turning up the threshold to reject them also rejects the quiet ends of real sentences. One number cannot separate two things that share that number.

But listen to a fan and a vowel and the difference is obvious to you instantly. Where is it? Not in the loudness. It is in the distribution of energy across frequency, and how that distribution moves over time. That is a picture, not a number — and pictures are what classifiers eat.

What makes speech look like speech

Four properties separate voiced speech from most steady noise, and every VAD since 1990 exploits some subset:

1. Harmonic structure. A voiced sound (any vowel, and the consonants that hum) is produced by vocal folds opening and closing periodically, at 85–255 Hz depending on the speaker. That periodicity puts energy at a fundamental frequency and at integer multiples of it — a comb of evenly spaced spikes in the spectrum. Fans do not do combs. This is the single strongest cue.

2. Spectral tilt. Broadband mechanical noise (engines, road, HVAC) is usually low-frequency-heavy: lots of energy under 500 Hz, falling steeply above. Speech puts substantial energy in the 500–3400 Hz band, because that is where the formants that distinguish vowels live. Measure the difference between a low band and a high band and noise gives you a steep number, speech a shallow one.

3. Modulation at the syllable rate. Speech turns on and off at roughly 4 Hz — that is the syllable rate, and it is remarkably universal. So the energy envelope of speech wiggles a few times a second. A fan’s envelope is flat. A classifier that sees a short history of frames, not just one frame, gets this for free.

4. Spectral flatness. White-ish noise spreads energy evenly across frequency; speech concentrates it in peaks. The ratio of the geometric mean to the arithmetic mean of the spectrum (near 1 for flat noise, near 0 for peaky speech) captures this in one scalar.

Concept → realization: what actually flows through a neural VAD. A 30 ms frame of 16 kHz audio is 480 samples. The front end computes a short FFT and folds it into a small number of log-mel bands — commonly 40, sometimes as few as 16 for a tiny model — so the frame becomes a vector of shape (40,). Then a context window of, say, 8 past frames is stacked to shape (8, 40), because properties 1 and 3 above only exist across time. That tensor goes into a small network (a few 1-D convolutions or a two-layer GRU, on the order of 100k–1M parameters) which outputs a single number in [0, 1]: the probability this frame is speech. Silero VAD, the de facto open-source standard, is about a megabyte and runs a 30 ms chunk in well under a millisecond on one CPU thread. The whole thing is smaller than the JPEG on this page.

The hand-worked example: why the fan loses

Let us build the smallest honest version — four log-mel-ish bands into a logistic regression — and run two frames through it by hand. Bands, in dBFS:

BandRangeFan noise frameVoiced speech frame
b10–500 Hz−22 dB−24 dB
b2500–1000 Hz−31 dB−20 dB
b31–2 kHz−36 dB−23 dB
b42–4 kHz−41 dB−28 dB

First, notice the thing that dooms the energy detector. Total level is dominated by the loudest band, and the fan’s b1 is −22 dB — louder than the speech frame’s loudest band (−20 dB is speech’s b2, so they are within 2 dB). A single-number detector with any threshold either accepts both or rejects both. There is no line.

Now the features. Spectral tilt = b1 − b4:

Fan: (−22) − (−41) = 19 dB of tilt — steep, all the energy is low.
Speech: (−24) − (−28) = 4 dB of tilt — nearly flat across the band.

And the mid-band presence, measured as how far b2 and b3 sit above a nominal −34 dB / −38 dB reference floor:

Fan: b2 is −31, so (−31) − (−34) = 3 dB above. b3 is −36, so (−36) − (−38) = 2 dB above.
Speech: b2: (−20) − (−34) = 14 dB. b3: (−23) − (−38) = 15 dB.

Feed them into a logistic unit with weights we will justify in a second:

z = 0.35 · (mid2) + 0.30 · (mid3) − 0.25 · (tilt − 6) − 1.2

Fan frame:
0.35 × 3 = 1.05
0.30 × 2 = 0.60
tilt term: (19 − 6) = 13, times −0.25 = −3.25
bias: −1.2
z = 1.05 + 0.60 − 3.25 − 1.20 = −2.80

Squash with the logistic σ(z) = 1 / (1 + e−z): e2.80 = 16.4446, so σ(−2.80) = 1 / (1 + 16.4446) = 1 / 17.4446 = 0.057. Five percent probability of speech. Rejected.

Speech frame:
0.35 × 14 = 4.90
0.30 × 15 = 4.50
tilt term: (4 − 6) = −2, times −0.25 = +0.50
bias: −1.2
z = 4.90 + 4.50 + 0.50 − 1.20 = 8.70

e−8.70 = 0.000166, so σ(8.70) = 1 / 1.000166 = 0.9998. Ninety-nine point nine eight percent. Accepted, decisively.

Look at the gap: 0.057 versus 0.9998, from two frames that an energy detector cannot tell apart at all. The positive weights on mid-band presence say “speech puts energy where formants live.” The negative weight on excess tilt says “if it is all bass, it is machinery.” The −1.2 bias says “default to not-speech,” which is the right prior for a microphone in a room.

Why this generalises where thresholds do not. The energy detector has one knob and it points in the one direction where speech and noise overlap. The classifier has a knob per band and can therefore find a direction in which they separate — and a real neural VAD, with 40 bands and 8 frames of context, is searching a 320-dimensional space for that direction, learned from thousands of hours of labelled noise and speech. The lesson is not “neural nets are magic.” It is that the discriminating information was never in the scalar you were thresholding.

Probability is not a decision

The network gives you pk ∈ [0, 1] per frame. You still need a state machine, and it is worth knowing that production VADs use the same two tricks as our energy version plus one more:

Hysteresis (two thresholds). Enter SPEECH when p > 0.5; leave SPEECH only when p < 0.35. The gap prevents chattering when p hovers at the boundary — the same reason your thermostat has a deadband. One threshold gives you a detector that flickers on and off ten times a second on a marginal frame.

Min-duration constraints. Reject speech segments shorter than ~100 ms (a door click, a keyboard tap) and silences shorter than ~100 ms (a stop consonant). Two counters.

Hangover, still. Yes, even neural VADs use it. The model is per-frame; conversation is not.

python — the tiny classifier, exactly as hand-computed
import math

def features(bands_db):
    b1, b2, b3, b4 = bands_db
    tilt = b1 - b4                       # 19 for the fan, 4 for speech
    mid2 = b2 - (-34.0)                  # presence above a nominal floor
    mid3 = b3 - (-38.0)
    return mid2, mid3, tilt

def p_speech(bands_db):
    mid2, mid3, tilt = features(bands_db)
    z = 0.35*mid2 + 0.30*mid3 - 0.25*(tilt - 6.0) - 1.2
    return 1.0 / (1.0 + math.exp(-z))

print(p_speech([-22, -31, -36, -41]))   # 0.0573  fan   -> reject
print(p_speech([-24, -20, -23, -28]))   # 0.9998  voice -> accept
python — the hysteresis state machine on top of any probability stream
class VADState:
    def __init__(self, on=0.50, off=0.35, hangover_frames=7, min_speech=4):
        self.on, self.off = on, off
        self.H, self.min_speech = hangover_frames, min_speech
        self.speech, self.h, self.run = False, 0, 0

    def push(self, p):
        if not self.speech:
            self.run = self.run + 1 if p > self.on else 0
            if self.run >= self.min_speech:      # ignore 30 ms clicks
                self.speech, self.h = True, self.H
        else:
            if p > self.off:                     # note: the LOWER threshold
                self.h = self.H
            else:
                self.h -= 1
                if self.h <= 0:
                    self.speech, self.run = False, 0
        return self.speech
python — the one-liner everybody actually ships
import torch
model, utils = torch.hub.load('snakers4/silero-vad', 'silero_vad')
p = model(torch.from_numpy(chunk_512_samples), 16000).item()   # 0..1 per 32 ms
# Still yours to own: the on/off thresholds, min durations, and the hangover.

See the separation

The widget shows both frames as four-band spectra, computes the features live, and plots them in the 2-D feature space (tilt on one axis, mid-band presence on the other) with the learned decision boundary. Drag the noise slider to make the fan louder: the energy detector’s verdict flips to SPEECH and stays wrong, while the classifier’s point slides along the tilt axis and stays on the correct side, because making a fan louder does not make it look like a voice.

Energy versus shape

Left: the four-band spectrum of the selected frame. Right: the same frame as a point in feature space with the logistic boundary drawn. Switch the source between fan / speech / quiet speech, and raise the level — watch the energy verdict break while the shape verdict holds.

sourcefan
level offset0 dB

Try “quiet speech” at a level offset of −10 dB. The energy verdict says SILENCE — the frame is genuinely quiet — while the shape verdict still says SPEECH with high confidence, because tilt and mid-band ratios are invariant to gain. That invariance is why neural VADs pick up the soft trailing syllables at the end of a sentence, which is exactly the region where your endpoint decision gets made.

The mistake that survives into neural systems: assuming a good VAD gives you good turn detection. Silero will tell you, correctly and robustly, that the caller stopped making speech sounds at t = 1.35 s after “I’d like to order a.” It is right. It is also useless for the decision you actually need, because the caller is not finished. A perfect VAD, with zero error, still cannot solve Chapter 0’s problem. VAD is a necessary input to endpointing and never a substitute for it — and the next chapter is where most voice teams discover this the expensive way.
A fan and a vowel can have the same frame energy. What lets a neural VAD separate them?

Chapter 4: Endpointing Is Not VAD

Here is the sentence that should be printed on the wall of every voice-agent team:

VAD answers “is there speech right now?” Endpointing answers “is this person finished?” The first is a property of the acoustic signal in a 30 ms window. The second is a property of a thought, and the evidence for it lives in the words, the melody, and the context — not in the loudness.

Endpointing (also called end-of-turn detection, or EOT) is the decision that ends the user’s turn and starts your response pipeline. It consumes VAD output but it is a different problem with different inputs and a different failure cost.

The silence-only endpointer, and exactly how it fails

The classical implementation is three lines: run a VAD, accumulate consecutive non-speech time, fire when that time exceeds a threshold T. Every voice platform ships this, and the default is nearly always around 500 ms — a number arrived at empirically by the whole industry, and one we will actually derive in the next chapter.

It fails on a specific, enumerable, and utterly common list of utterances:

Utterance so farThen 600 ms of silence because…Silence-only verdictTruth
“I’d like to order a”they are reading the menufire — interruptnot finished
“my account number is”they are finding the cardfire — interruptnot finished
“so, um”they are formulatingfire — interruptnot finished
“it’s four one seven”digits come in chunksfire — interruptnot finished
“and I think that’s, uh, everything”they are donefire — correctfinished
“yes”they are done, instantlyfire after 500 ms — slowfinished 500 ms ago

Read the last row too. A one-word answer is maximally obviously complete, and the silence-only endpointer still burns its full threshold before it will admit that. You are paying the tail risk premium on the easiest turns in the conversation.

What a human uses instead

When you talk to a person, you know they are done before the silence has even started — that is how 200 ms gaps are physically possible. The cues you use, roughly in order of strength:

Syntactic completeness. “I’d like to order a …” ends on a determiner; a determiner requires a noun after it. The sentence is grammatically unfinished, and you know it without any conscious effort. Compare “… a large pepperoni pizza”: a complete noun phrase completing a complete clause.

Prosody — the melody of finality. English speakers drop pitch and lengthen the final syllable at the end of a statement (a falling final contour); they hold pitch level or rise slightly when they intend to continue. Final lengthening alone is a strong predictor. This is why endpointers that see fundamental-frequency contours beat text-only ones on the same transcripts.

Fillers. “um,” “uh,” “like” are floor-holding devices — their literal function in conversation is “I am still going, do not take the floor.” A trailing filler is nearly conclusive evidence of not-done.

Pragmatics. If the agent asked “what is your order number?” then the expected answer is a number of a certain length. “Four one seven” when the format is six digits is obviously incomplete in context, though it is a perfectly complete phrase in isolation.

Semantic endpointing, hand-worked

Modern “smart turn” detectors are models that consume the streaming ASR partial transcript (and often the audio directly) and output P(the turn is complete). In production these are small fine-tuned transformers — the open ones are in the tens-of-millions-of-parameters range and run in a few milliseconds. But the logic is legible, so let us build a bag-of-cues version we can compute by hand and get the real behaviour out of it.

z = −0.8 + 2.1·[ends on content word] − 2.6·[ends on function word] + 1.3·[contains a main verb] + 0.9·[question form] − 1.7·[ends on filler]

“Function word” means determiners (a, the), prepositions (to, for, with), conjunctions (and, but) — the words that grammatically demand a continuation. “Content word” means nouns, verbs, adjectives, numbers. Now three prefixes, in full:

Prefix A: “I’d like to order a”
ends on “a” → function word → −2.6
contains main verb (“order”) → +1.3
content-word ending: no → 0. Question form: no → 0. Filler: no → 0.
z = −0.8 − 2.6 + 1.3 = −2.1
e2.1 = 8.1662, so P = 1 / (1 + 8.1662) = 1 / 9.1662 = 0.109 → 11% complete. Do not fire.

Prefix B: “I’d like to order a large pepperoni pizza”
ends on “pizza” → content word → +2.1
contains main verb → +1.3
z = −0.8 + 2.1 + 1.3 = 2.6
e−2.6 = 0.07427, so P = 1 / 1.07427 = 0.931 → 93% complete. Fire, fast.

Prefix C: “my order number is, um”
ends on filler → −1.7
contains main verb (“is”) → +1.3
z = −0.8 − 1.7 + 1.3 = −1.2
e1.2 = 3.3201, P = 1 / 4.3201 = 0.231 → 23% complete. Do not fire.

Fusing probability with silence — the rule that ships

You do not use P alone; you combine it with elapsed silence, because confidence should be allowed to decay. The elegant formulation used by production stacks is a time-varying threshold: demand high completion confidence early, accept lower confidence as the silence stretches, and always keep a hard timeout as a floor.

fire when   P(complete) > τ(s),    τ(s) = 0.9 − 0.6 · min(1, s / 1200 ms)

Compute τ at a few silences: at s = 0 ms, τ = 0.9. At 200 ms, τ = 0.9 − 0.6×(200/1200) = 0.9 − 0.1 = 0.80. At 600 ms, τ = 0.9 − 0.3 = 0.60. At 1200 ms and beyond, τ = 0.30.

Now run our three prefixes through it:

B (“…pizza”, P = 0.931): at 200 ms of silence, τ = 0.80 and 0.931 > 0.80 → fires at 200 ms. The user gets a human-paced gap on a clearly finished sentence. The silence-only endpointer would have waited 500 ms — we just cut 300 ms off the most common case in the conversation.

A (“…order a”, P = 0.109): τ bottoms out at 0.30 and 0.109 never exceeds it. So this turn never fires on the semantic rule — it falls through to the hard timeout (typically 1.5–2 s), giving the caller all the time they need to find the word. Correct behaviour, and the exact failure the fixed threshold could not avoid.

C (“…is, um”, P = 0.231): also never crosses (0.231 < 0.30), also falls through to the timeout. Also correct.

The insight that makes this worth the complexity. Notice what we did: we did not pick a better point on the latency-versus-interruption trade-off. We moved the trade-off curve itself. Prefix B got faster (200 ms instead of 500) and prefixes A and C got safer (timeout instead of a 500 ms premature fire). Both at once. That is only possible because we added information — the transcript — that a silence timer does not have. Any time you find yourself agonising over a threshold, the real question is whether you can add a feature instead.
And the mistake it introduces. Smart endpointing lowers median latency and can make tail latency worse. When the model is wrong in the not-done direction — a divergent speaking style, a heavy accent the ASR mangles, a bad phone line, an unusual grammar — the turn falls all the way through to the hard timeout even though the user finished long ago. Twilio’s field diagnosis is worth memorising: a discrete jump in the tail of your latency distribution, sitting right at your timeout value, is the signature of a mistuned smart endpointer. If your p95 response time is suspiciously close to your fallback timeout, that is not a coincidence, it is a bug report.
python — the fused endpointer, complete
class Endpointer:
    """Consumes VAD frames + streaming ASR partials. Emits END_OF_TURN."""
    def __init__(self, frame_ms=20, hard_timeout_ms=1500, min_silence_ms=120):
        self.frame_ms  = frame_ms
        self.hard      = hard_timeout_ms
        self.min_sil   = min_silence_ms   # never fire on a stop consonant
        self.silence   = 0
        self.p         = 0.0              # P(turn complete) from the turn model

    def tau(self, s):
        return 0.9 - 0.6 * min(1.0, s / 1200.0)

    def on_partial(self, text):
        self.p = turn_model.complete_prob(text)   # the semantic model

    def on_frame(self, is_speech):
        if is_speech:
            self.silence = 0
            return False
        self.silence += self.frame_ms
        if self.silence < self.min_sil:
            return False
        if self.p > self.tau(self.silence):    # semantic path: can fire at 200 ms
            return True
        if self.silence >= self.hard:        # safety net: always terminates
            return True
        return False

Two details in that code are load-bearing and easy to skip. min_sil exists so a stop consonant can never end a turn no matter how confident the semantic model is. And the hard timeout exists because every production endpointer needs a path that terminates without the model’s consent — if the smart layer dies, silently returns 0, or the ASR stops emitting partials, the conversation must still proceed. Nearly all shipped end-of-turn systems keep a raw-silence fallback underneath the clever one for exactly this reason.

Drive the endpointer

Step through a transcript word by word. The widget shows the running prefix, the cue flags that fired, the computed z and P, and the τ(s) curve with the current silence marked — so you can see the exact moment the fire condition becomes true, or watch it never become true and fall to the timeout.

Semantic endpointing, step by step

Pick an utterance, then advance word by word. Top: the prefix with its cue flags and the arithmetic for z. Bottom: P(complete) against the decaying threshold τ(s), with the silence slider controlling how long they have been quiet.

utterancepizza
words spoken5
silence so far200 ms
Why can a semantic endpointer be both faster on easy turns and safer on hard ones, when a silence threshold cannot?

Chapter 5: The Trade-off Curve

We have been saying “too eager interrupts, too lazy stalls” for five chapters. Now we compute it. By the end of this chapter you will have derived the industry’s 500 ms default from a pause-length distribution, and you will know exactly what a smart endpointer buys in units of expected pain.

Step 1: the only measurement that matters

Go and log your users’ mid-turn pause lengths — the durations of silence that occur while the user is not finished. This single distribution determines your threshold, and no amount of reasoning substitutes for it, because it varies enormously by task: a chatty support call has short pauses, an intake form where people read numbers off documents has long ones.

Here is a realistic survival table — the fraction of user turns containing at least one mid-turn pause of at least the given length:

Pause length LP(some mid-turn pause ≥ L)Meaning
≥ 200 ms0.35ordinary between-word breathing
≥ 400 ms0.18a real hesitation
≥ 600 ms0.09thinking, reading
≥ 800 ms0.04looking something up
≥ 1000 ms0.02long retrieval
≥ 1400 ms0.008rare

Note the shape: it falls fast and then has a long, thin tail that never quite reaches zero. That tail is why “just set it high enough” does not work — you would need a threshold of several seconds to drive interruptions near zero, and several seconds of dead air is a worse product than an occasional interruption.

Step 2: the two costs, in the same units

For a silence threshold T:

Interruption rate = P(some mid-turn pause ≥ T). Read straight off the table. Every turn containing a pause at least as long as your threshold gets cut off.

Added latency = T. You always sit through T of silence before responding, on every turn, including all the ones with no hesitation at all. This is the part teams forget: the threshold is a tax levied on the 99% of turns that did not need it.

To compare them we need an exchange rate. Ask your product: how many seconds of extra response latency would I accept, per conversation, to avoid one interruption? A common honest answer is “interruptions are about twice as bad as a second of delay,” giving weights wint = 1.0 per interruption and wlat = 0.5 per second. Then:

Cost(T) = wint · P(pause ≥ T) + wlat · (T / 1000 ms)

Step 3: evaluate it, every number

T = 200 ms: interruption term = 1.0 × 0.35 = 0.350. Latency term = 0.5 × 0.200 = 0.100. Cost = 0.350 + 0.100 = 0.450.

T = 400 ms: 1.0 × 0.18 = 0.180. Latency = 0.5 × 0.400 = 0.200. Cost = 0.180 + 0.200 = 0.380.

T = 500 ms: interpolate the table between 0.18 at 400 and 0.09 at 600: halfway is 0.135. Latency = 0.5 × 0.500 = 0.250. Cost = 0.135 + 0.250 = 0.385.

T = 600 ms: 0.090 + (0.5 × 0.600 = 0.300) = 0.390.

T = 800 ms: 0.040 + 0.400 = 0.440.

T = 1000 ms: 0.020 + 0.500 = 0.520.

T = 1400 ms: 0.008 + 0.700 = 0.708.

The minimum sits at roughly T = 400–500 ms, with cost ≈ 0.38. That is where the industry default came from. It was not chosen by taste; it is the flat bottom of a shallow bowl whose left wall is built from the pause distribution of human speech and whose right wall is built from the human tolerance for dead air. And notice the bowl is shallow — 400 and 600 differ by 0.01 — which is why teams argue about this number forever without ever measuring a difference. The gains are not on this axis.

Step 4: what changing the exchange rate does

Suppose you are building an emergency-services triage line where interrupting a distressed caller is disastrous: wint = 4.0. Redo T = 400: 4.0 × 0.18 = 0.72, plus 0.20 = 0.92. T = 800: 4.0 × 0.04 = 0.16, plus 0.40 = 0.56. T = 1000: 4.0 × 0.02 = 0.08, plus 0.50 = 0.58. The optimum has moved out to ~800–900 ms. Conversely, for a fast-paced quiz game where snappiness is the product and interruptions are forgivable (wint = 0.3): T = 200 gives 0.105 + 0.100 = 0.205; T = 400 gives 0.054 + 0.200 = 0.254. The optimum collapses to ~200 ms. Same math, different product, threshold differing by 4×. Anyone who tells you the “correct” endpointing threshold without asking what the agent does is guessing.

Step 5: the smart endpointer, in the same units

Now quantify Chapter 4’s claim. Let the semantic model have recall 0.8 on incompleteness: of all the mid-turn pauses that would have triggered a premature fire, it correctly recognises 80% as “user is not done” and holds. And let it have a false-incomplete rate of 0.10: on 10% of genuinely finished turns it wrongly believes more is coming, so those turns fall through to the hard timeout at 1500 ms.

Interruptions. At an aggressive T = 200 ms, the raw rate was 0.35. Multiply by the 20% the model misses: 0.35 × (1 − 0.8) = 0.070. We just did better than any point on the fixed curve — the fixed curve does not reach 0.07 until T ≈ 700 ms.

Expected latency. 90% of turns fire at 200 ms; 10% fall to 1500 ms. E[latency] = 0.90 × 200 + 0.10 × 1500 = 180 + 150 = 330 ms.

Cost. 1.0 × 0.070 + 0.5 × 0.330 = 0.070 + 0.165 = 0.235 — versus 0.380 for the best possible fixed threshold. A 38% reduction in expected pain, and it came from information, not from tuning.

But look at the percentiles, not the mean. The median latency is 200 ms — excellent, human. The 90th percentile is 200 ms. The 95th percentile is 1500 ms. The distribution is bimodal: a tight spike at 200 and a second spike at the timeout, with nothing in between. Users do not experience your mean. They experience the 1 turn in 10 where the agent goes quiet for a second and a half for no reason they can perceive, which reads as the agent being confused. Report p95, always.

The mistake: optimising E[latency] and shipping. A system with 330 ms mean latency and a bimodal 200/1500 split feels distinctly worse than a system with a flat 450 ms, even though the mean is better, because humans are far more sensitive to variance in conversational timing than to its level. Predictable pacing is itself a feature. If you cannot lower the tail, consider making the fallback path audible — a quick “mm-hm” or a keyboard-clack earcon at 600 ms tells the user “I am here, still listening” and converts dead air into presence. This is called latency masking and it is the cheapest UX win in voice.

The latency ledger it plugs into

Endpointing does not live alone. Here is where a full cascaded response actually spends its milliseconds, end of user speech to first audio out:

StageTypicalNotes
Endpoint wait200–800 msthe term you control; pure policy
ASR finalisation30–80 mspartials already streamed; this is the flush
LLM time-to-first-token150–300 msprompt caching helps a lot
TTS time-to-first-audio100–200 msstreaming synthesis, first chunk only
Transport + jitter buffer40–150 msWebRTC lean; PSTN and extra hops add up
Total520–1530 msversus a human’s ~200 ms gap

Add the best case by hand: 200 + 30 + 150 + 100 + 40 = 520 ms. The worst: 800 + 80 + 300 + 200 + 150 = 1530 ms. The endpoint wait is 38% of the best case and 52% of the worst — the largest single line item in both, and the only one that is a choice rather than a physical cost. That is why this lesson exists.

There is one more lever in that table and it is delicious: if you can gracefully abort the downstream stages, you can afford to endpoint aggressively. Fire at 250 ms, start the ASR flush, start the LLM, start the TTS — and if the user resumes speaking 300 ms later, kill all of it before a single sample reaches the speaker. You bought several hundred milliseconds of “free” speculation, because the pipeline’s own latency gave you a window to be wrong in. The cost is that abort must be airtight: buffers cleared, streams cancelled, tools cancelled, context repaired. Get that wrong and the user hears a stutter — half a word of the agent, then silence. That machinery is Chapter 6.

Walk the frontier

The widget plots the pause-length survival curve on the left and the cost curve on the right. Move the threshold and watch both terms and the total. Flip on the smart endpointer to see the single point it occupies — below and to the left of the entire fixed curve — and drag the model quality slider to watch that point degrade back toward the curve as the model gets worse.

The interruption / latency frontier

Left: P(mid-turn pause ≥ T), the survival curve, with your threshold marked. Right: the cost curve with the interruption and latency terms stacked, plus the smart endpointer’s operating point. Change the exchange rate and watch the optimum slide.

threshold T500 ms
interruption weight1.0
smart-model recall0.80

Pull the smart-model recall down to 0.3 and the advantage almost vanishes — a bad turn model is worse than no turn model, because you also inherit its false-incomplete tail. This is the honest answer to “should we use semantic endpointing?”: only if you can measure its recall on your traffic. A turn detector trained on clean read speech, dropped onto an 8 kHz telephone line with accented speakers, can easily land under 0.5 and make your product slower and ruder simultaneously.

A smart endpointer shows median response latency of 200 ms but p95 of 1500 ms, exactly matching its fallback timeout. What is this telling you?

Chapter 6: Barge-In — the state machine (SHOWCASE)

Everything so far has been about the user’s turn ending. This chapter is the other half: the agent’s turn ending, against its will, because the human started talking.

Barge-in is the ability of the user to interrupt the agent mid-sentence and be heard. It sounds like a small feature. It is the difference between a conversation and a voicemail menu, and it is one of the most bug-prone pieces of code in the entire stack, because the moment the user speaks over you, four independent asynchronous systems are all mid-flight and every one of them has to be stopped correctly, in the right order, with the right cleanup.

What is actually running when the user interrupts

Picture the instant. The agent is 1.2 seconds into an eight-second reply. At that moment:

1. Audio is buffered ahead of the ear. The text-to-speech model generated maybe 3 seconds of audio; 2.5 s of it has been handed to the transport; roughly 1.2 s has actually reached the speaker. The rest is sitting in send buffers, network buffers, and the client’s jitter buffer. If you only stop generating, the user keeps hearing the agent for hundreds of milliseconds. This is the classic “it does not stop when I talk” bug and it is a buffer problem, not a detection problem.

2. The TTS stream is still producing. It has a whole sentence queued.

3. The LLM is still generating tokens. It is 60 tokens into a 200-token reply and every token costs money and occupies a slot.

4. Tool calls may be in flight. The agent decided to look up an order; there is an HTTP request outstanding, possibly a mutating one. This is where interruption stops being a UX concern and becomes a correctness concern.

The bug nearly everyone ships first: handling barge-in by calling tts.stop() and nothing else. The agent keeps talking for 400 ms out of the buffer, the LLM keeps burning tokens invisibly, the order-placement API call completes anyway, and — worst of all — the conversation history records the full generated reply as if it had been spoken. Four bugs, one missing teardown.

The teardown, in order, with reasons

Step 1 — flush the outbound audio buffer, first and immediately. Everything else can take a few milliseconds; this cannot, because it is the only step the user can hear. On WebRTC that means stopping the track feed and clearing your send queue; on a telephony bridge it means sending a clear-buffer control message so the media edge drops what it has queued. Twilio’s guidance is explicit about this ordering: clear the outgoing audio buffer, then abort the model stream, then cancel tools, then clean up history.

Step 2 — abort the TTS stream. Cancel the request so you stop paying for and receiving audio you will never play.

Step 3 — abort the LLM stream. Same reasoning, bigger bill. With streaming APIs this is a cancellation on the request; make sure your framework actually propagates it rather than just abandoning the iterator.

Step 4 — cancel in-flight tool calls, and know which ones cannot be cancelled. A read is safe to abandon. A write is not: if place_order already reached the server, cancelling your HTTP request does not un-place the order. The engineering answer is to make mutating tools idempotent (an idempotency key per turn) and to defer them — do not fire a mutation until the agent has actually spoken the confirmation the user is agreeing to. Design the tool boundary so that an interruption can never leave the world in a state the conversation does not reflect.

Step 5 — repair the context. This is the step everyone forgets.

Context repair, the deepest idea in this chapter. Your language model generated: “Your order is one large pepperoni pizza, one garlic bread, and a two-litre cola, arriving in about forty minutes.” The user heard: “Your order is one large pepperoni—” and said “no wait, make it margherita.” If you append the generated text to the conversation history, the model now believes it announced the garlic bread, the cola and the delivery time. It will never mention them again. It will resolve “that” and “the second one” against things the user never heard. The conversation is silently grounded in a lie, and the failures show up three turns later where nobody will connect them to the interruption. The rule: truncate the assistant message to the text corresponding to audio that actually reached the user, and mark it — for example "Your order is one large pepperoni <interrupted>". To do that you need a word-to-audio-offset mapping from your TTS (most streaming TTS APIs emit word timing events) plus a running count of milliseconds actually played out.

Work the truncation by hand. Suppose TTS emitted these word boundaries, and playout reached 1,240 ms before the flush:

Wordstarts atplayed?
Your0 msyes
order230 msyes
is520 msyes
one640 msyes
large810 msyes
pepperoni1,090 msyes (started at 1,090 < 1,240)
pizza1,600 msno — 1,600 > 1,240
one1,980 msno

Kept text = the words whose start time is below 1,240 ms: “Your order is one large pepperoni”. Everything from “pizza” onward is discarded from history. Six words kept out of the sentence the model thought it said. Note the subtlety: “pepperoni” started at 1,090 but would not have finished until ~1,600 — the user heard a fragment. Keeping the whole word is the right call (partial words are unrepresentable in text and the user did hear its beginning), but if you want to be strict you can mark it: "...one large pepper<cut>".

The state machine

Here is the whole thing as states and transitions. Six states, and the two dashed edges are the ones that make it a conversation instead of an IVR.

The barge-in state machine (live)

The current state lights up as the simulator below runs, so you can watch the machine walk. CANCELLING is the state where the five teardown steps happen — it is drawn wide because it is where the bugs live.

python — the teardown, correct order, with context repair
async def on_user_barge_in(session, played_ms):
    # 1. AUDIBLE FIRST: nothing else matters until the speaker goes quiet.
    await session.transport.clear_output_buffer()     # local send queue
    await session.transport.send_clear_signal()      # tell the far edge to drop its queue

    # 2 & 3. Stop paying for audio and tokens nobody will hear.
    session.tts_task.cancel()
    session.llm_task.cancel()

    # 4. Tools: reads are abandoned, writes must be safe by construction.
    for call in session.inflight_tools:
        if call.mutating:
            # cannot un-send it; rely on the idempotency key and record it
            session.note_uncancellable(call)
        else:
            call.cancel()

    # 5. CONTEXT REPAIR: history must reflect what was HEARD, not what was GENERATED.
    spoken = " ".join(w.text for w in session.word_timings if w.start_ms < played_ms)
    session.history[-1] = {"role": "assistant",
                          "content": spoken + " <interrupted>"}

    session.state = "USER_SPEAKING"

One detail worth stealing: played_ms is not “time since we started sending.” It is the playout position, which you get either from the client’s audio element, from RTP timestamps, or by tracking bytes actually written to the device minus the buffer depth. Using send-time instead of playout-time over-counts by exactly the buffer depth — usually 200–400 ms — and silently corrupts every repaired transcript. Colocating your speech services and keeping buffers shallow makes this race narrower; a wide race produces the disorienting stutter where the agent stops, then emits one more fragment.

THE SHOWCASE: play the human

Now you drive. Below is a live conversation simulator. You are the caller; the agent runs whichever turn-taking policy you select. The timeline scrolls in real time: your track on top, the agent’s below, the state machine above lighting up as it walks, and a scoreboard of the three things that matter — interruptions inflicted on you, dead air you sat through, and backchannels mishandled.

The controls. Press speak (more coming) to say a chunk of a sentence you have not finished — this is “I’d like to order a…”. Press speak (done) to finish your sentence. hesitate inserts 700 ms of thinking silence mid-sentence. mm-hm fires a 250 ms backchannel — try it while the agent is talking. interrupt! is a real 600 ms takeover attempt. Or press run scenario and watch the canonical hard case play out hands-free.

The policies. naive 200 ms is a bare silence timer. fixed 500 ms + hangover is the industry default from Chapter 5. semantic uses completeness (it knows whether your last chunk left the sentence hanging) with a 1500 ms fallback. full-duplex is Chapter 8’s answer, included here so you can feel the difference before we explain it.

Conversation simulator — you are the caller

Press play, then talk with the buttons. Watch the state machine above walk in step. Try the same button sequence under each policy and compare the scoreboard — that comparison is the lesson.

policyfixed 500 ms
speed1.0×

What to actually try

Experiment 1 — manufacture the Chapter 0 bug. Policy naive 200 ms. Press speak (more coming), then hesitate. The agent fires inside your hesitation and the interruption counter increments. Now switch to semantic and repeat: the agent sits still, because your last chunk left the sentence grammatically open. Same audio, different answer.

Experiment 2 — pay the dead-air tax. Policy fixed 500 ms. Press speak (done) alone, repeatedly. Every single turn adds ~500 ms to the dead-air counter even though you never hesitated once. Switch to semantic and watch the same sequence cost ~200 ms per turn. That difference, multiplied by 40 turns in a support call, is 12 seconds of the customer’s life.

Experiment 3 — the backchannel trap. Get the agent talking (press speak (done), wait for the orange block), then press mm-hm. Under naive and fixed, the agent stops dead — the “mishandled backchannel” counter ticks. Under semantic and full-duplex it keeps going, because those policies classify the overlap first. That classifier is Chapter 7.

Experiment 4 — a real interruption. While the agent talks, press interrupt!. Every policy stops, but watch the little teardown ladder that flashes in the CANCELLING state, and note the different stop latencies: the cascaded policies need detection plus buffer flush, while full-duplex stops in roughly one 80 ms frame.

When a user barges in, which teardown step is most often forgotten — and what does forgetting it break?

Chapter 7: Backchannel or Interrupt?

You just felt it in the simulator: the agent is talking, the microphone lights up, and you have somewhere between 150 and 400 milliseconds to decide whether that sound means “stop, I want the floor” or “keep going, I am following.” Get it wrong in one direction and you talk over a person trying to correct you. Get it wrong in the other and you stop dead every time they say “mm.”

This is the hardest classification problem in the stack, and unlike endpointing it has almost no room to wait for more evidence — the whole point is that you must act while the sound is still happening.

The signals, and how far each gets you

Duration. The strongest single cue and the one you can only use by waiting. Backchannels are short: “mm-hm” is 200–400 ms, “yeah” 150–300 ms. Takeover attempts keep going. Waiting 500 ms to be sure destroys the whole point — that is half a second of you talking over someone who wanted the floor. Practical systems use a partial-duration signal: at 300 ms, still going and rising in energy is strong evidence of takeover.

Loudness relative to your own output. People backchannel quietly; it is a deliberate politeness signal that the sound is not a bid. People who want the floor get louder, often noticeably louder than their normal speaking level, because they are competing with your voice. Measure the user’s level relative to their own recent speaking average, not in absolute dB.

Lexical content. The backchannel lexicon is small and shockingly effective: mm-hm, uh-huh, yeah, right, okay, sure, I see, wow, oh, huh. If your streaming ASR emits a partial within ~200 ms and it matches that list, you have very strong evidence. If the first word is “wait,” “no,” “actually,” “sorry,” or “stop,” you have very strong evidence the other way — there is a takeover lexicon too and it is worth hardcoding.

Pitch and contour. Backchannels are typically flat or falling; a takeover attempt often starts high. Cheap to compute, genuinely informative, rarely used because it needs a pitch tracker in the hot path.

Where you are in your own sentence. Massively underused. Humans backchannel at phrase boundaries — the little breath points in your speech — and interrupt at points of disagreement, which are usually right after you said the wrong thing. If the user starts talking 80 ms after you said a number, the prior for “correction” is much higher than if they started during a smooth clause.

Concept → realization. The classifier’s input is not one thing arriving at one time. Energy and pitch are available per 20 ms frame; duration accrues continuously; the ASR partial may arrive at 250 ms or never. So this is a streaming decision that gets re-evaluated every frame with a growing feature vector, not a one-shot call. The implementation is a small function running at frame rate with an early-exit: if the lexical evidence lands and it is decisive, decide immediately; otherwise keep accumulating until a deadline (~400 ms), then decide on acoustics alone.

The model, hand-worked on three real cases

A logistic on three features. d = duration of the overlapping burst in seconds (capped at 1.5), e = relative loudness in [0, 1] (0 = much quieter than the user’s normal, 1 = much louder), b = 1 if the first ASR partial is in the backchannel lexicon.

z = −2.4 + 3.0·d + 1.6·e − 2.2·b     P(takeover) = σ(z)

Case A — “mm-hm”, soft, brief. d = 0.25 s, e = 0.35, b = 1.
3.0 × 0.25 = 0.75
1.6 × 0.35 = 0.56
−2.2 × 1 = −2.20
z = −2.4 + 0.75 + 0.56 − 2.20 = −3.29
e3.29 = 26.84, so P = 1 / 27.84 = 0.036. Three and a half percent. Keep talking.

Case B — “wait, no—”, loud, longer. d = 0.55 s, e = 0.85, b = 0.
3.0 × 0.55 = 1.65
1.6 × 0.85 = 1.36
z = −2.4 + 1.65 + 1.36 = 0.61
e−0.61 = 0.5434, so P = 1 / 1.5434 = 0.648. Sixty-five percent. Stop.

Case C — the genuinely hard one: “yeah…” loud and dragging. d = 0.80 s, e = 0.90, b = 1 (“yeah” is in the lexicon).
3.0 × 0.80 = 2.40
1.6 × 0.90 = 1.44
−2.2 × 1 = −2.20
z = −2.4 + 2.40 + 1.44 − 2.20 = −0.76
e0.76 = 2.1383, so P = 1 / 3.1383 = 0.319. Thirty-two percent.

Case C is “yeah, but actually I wanted…” — a takeover that opens with a backchannel word. The lexicon says acknowledgement; the duration and loudness say takeover. The model lands at 0.32, genuinely uncertain. So what do you do at 0.32?

The decision threshold is not 0.5 — derive it

Classify by expected cost, not by probability. Two costs:

Cmiss = cost of continuing to talk when it really was a takeover. You are now talking over a human who is trying to correct you. They repeat themselves louder. It is the single most rage-inducing voice-agent failure. Call it 5.

Cfalse = cost of stopping when it was only a backchannel. Mildly awkward: a short gap, then you resume. Recoverable, and you can even make it graceful (resume mid-phrase). Call it 1.

Expected cost of keep talking = P × Cmiss = 5P.
Expected cost of stop = (1 − P) × Cfalse = 1 − P.
Stop when 5P > 1 − P, that is 6P > 1, that is:

P* = Cfalse / (Cmiss + Cfalse) = 1 / (5 + 1) = 0.167

Re-run the three cases against 0.167 instead of 0.5:

Case A: 0.036 < 0.167 → keep talking. Correct.
Case B: 0.648 > 0.167 → stop. Correct.
Case C: 0.319 > 0.167 → stop. And that is the right call — with a 5:1 cost asymmetry you should stop on a one-in-three chance of a takeover, because being wrong in the stopping direction is cheap and recoverable while being wrong the other way is not.

The generalisable lesson. Any time you have an asymmetric cost, the argmax-probability decision rule is wrong and the threshold is Cfalse / (Cmiss + Cfalse). Voice is full of these — interrupting is worse than waiting, mis-firing a mutation is worse than asking again, mis-hearing a digit is worse than requesting a repeat. Writing down the two costs takes five minutes and usually moves the threshold by a factor of three. Teams instead spend weeks improving the model that feeds a mis-set threshold.
The mistake: treating “stop” as an all-or-nothing state. The best implementations have a third action: duck. Drop the agent’s output gain by 12 dB and keep going for another 200 ms while the classifier accumulates evidence. If the user turns out to be backchanneling, you fade straight back up and they barely notice a dip. If it was a takeover, you were already fading and the full stop lands smoothly instead of as an abrupt cut. Ducking converts a hard binary decision into a soft one and costs a single gain ramp. It also happens to be exactly what humans do — we get quieter when someone starts talking over us, before we decide whether to yield.

Cost matrix, as code

python — streaming overlap classifier with ducking and a decision deadline
BACKCHANNEL = {"mm", "mm-hm", "mhm", "uh-huh", "yeah", "yep",
               "right", "okay", "ok", "sure", "wow", "oh", "i see"}
TAKEOVER    = {"wait", "no", "stop", "sorry", "actually", "hold on", "but"}

C_MISS, C_FALSE = 5.0, 1.0
P_STAR = C_FALSE / (C_MISS + C_FALSE)          # 0.1667 — NOT 0.5

def p_takeover(dur_s, rel_loud, first_word):
    b = 1.0 if first_word in BACKCHANNEL else 0.0
    z = -2.4 + 3.0*min(dur_s, 1.5) + 1.6*rel_loud - 2.2*b
    if first_word in TAKEOVER:
        z += 3.0                              # hardcoded prior; cheap and very effective
    return 1.0 / (1.0 + math.exp(-z))

def on_overlap_frame(session, dur_s, rel_loud, first_word):
    p = p_takeover(dur_s, rel_loud, first_word)

    if dur_s > 0.12 and session.gain == 1.0:
        session.duck(db=-12)                   # soften while we decide

    if p > P_STAR:
        return on_user_barge_in(session, session.played_ms)   # full teardown, ch.6

    if dur_s > 0.45:                          # deadline: it was a backchannel
        session.unduck()
        session.backchannels_ignored += 1
    return None

Explore the boundary

Drag the point around the feature space. The horizontal axis is burst duration, the vertical is relative loudness; the shaded region is “stop.” Toggle whether the first word is in the backchannel lexicon and watch the whole boundary shift — that is the −2.2 weight moving the intercept. Then drag the cost ratio and watch the boundary sweep: at 1:1 the machine is stubborn and talks over people; at 10:1 it is jumpy and stops at every breath.

Backchannel versus takeover — the decision boundary

Drag the sliders to move the observed burst (the ring). The shaded region is where expected cost says STOP. The three labelled dots are hand-worked cases A, B and C from above — check the printed probabilities against your own arithmetic.

burst duration0.25 s
relative loudness0.35
first word in lexiconyes
cost ratio Cmiss:Cfalse5.0 : 1

One last observation before we leave this chapter. Everything above is a heroic effort to recover, from acoustics and a partial transcript, a distinction the human speaker made effortlessly and communicated clearly — through timing, volume, pitch and word choice, all simultaneously. We are decoding it through a pipeline that deliberately threw most of that away when it converted audio to text. Which raises an obvious question: what if we did not throw it away?

Continuing to talk over a real takeover costs 5; stopping for a mere backchannel costs 1. At what P(takeover) should the agent stop?

Chapter 8: Full-Duplex — dissolving the problem

Step back and look at what we have built. A voice activity detector. A hangover timer. A semantic completeness model. A time-decaying threshold. A hard timeout. A barge-in teardown with five steps. A backchannel classifier with an asymmetric cost threshold. Seven mechanisms, thousands of lines, dozens of tunables — all of it in service of one job: reconstructing turn boundaries that the pipeline itself invented.

Because here is the thing. The turn boundary is not a property of the conversation. It is a property of the architecture. We built a system that can only be in one of two modes — listening or speaking — so we need a mechanism to switch between them, so we need to detect when to switch, so we need all of the above. Humans are not like this. You hear while you talk. You start forming a reply while the other person is still going. You say “mm-hm” without stopping your own thought.

The reframing. Turn detection is not a hard problem that needs a better model. It is an artifact of a half-duplex architecture. Remove the architectural constraint — let the model listen and speak simultaneously, always — and the problem does not get solved. It stops existing. There is nothing to detect, because there is no switch to throw.

Moshi: two streams, always on

Kyutai’s Moshi (2024) is the cleanest realisation of that idea, and it is worth understanding precisely because its design decisions all follow from this one commitment.

The architecture starts with Mimi, a neural audio codec that turns a waveform into discrete tokens at 12.5 tokens per second per codebook stream. So one “frame” of Moshi’s world is 1 / 12.5 = 80 milliseconds of audio. Mimi’s first codebook is distilled to carry semantic content (what was said) while the remaining residual codebooks carry acoustic detail (how it sounded) — so a single token stream contains both meaning and voice.

Then the key move. Moshi models two audio streams jointly: its own output, and the user’s input. Both are token sequences. Both advance every 80 ms, forever. At every frame the model predicts its own next audio tokens conditioned on both streams so far.

Read that again and notice what disappeared:

Cascaded stackMoshi
VAD decides speech vs silencesilence is just tokens; nothing decides anything
endpointer decides when the user is doneno such decision exists
hangover / timeout tuningno timers anywhere
barge-in detection + teardownthe model simply starts predicting speech tokens while the user’s stream is active — or stops
backchannel classifieroverlap is in the training data; the model learned the distinction
“turn” as a first-class objectthere is no boundary between speaker turns at all

The paper is explicit that this dissolves the assumption, not just the code: pipelines “rely on a segmentation into speaker turns, which does not take into account overlapping speech, interruptions and interjections,” and overlap is 10–20% of real spoken time. Moshi always listens and can always speak.

Where the 160 ms comes from — compute it

The latency arithmetic is refreshingly small. One Mimi frame is 80 ms. Moshi generates audio tokens with a small acoustic delay: the acoustic codebooks lag the semantic one by a couple of steps so the model can condition fine detail on coarse content. With a delay of 2 steps:

latency = frame × delay = 80 ms × 2 = 160 ms (theoretical), ≈ 200 ms measured

That is below the ~230 ms average human turn-taking gap. For the first time the machine is not racing to catch up with human pacing; it is inside it. And compare the ledger from Chapter 5: a cascaded stack’s best case was 520 ms, of which 200–800 ms was endpoint wait. Deleting the endpoint wait is not an optimisation, it is a category change.

Contrast the delay budgets directly. If you naively delayed all eight of Mimi’s codebooks by one step each in a strict hierarchical pattern you would get 8 × 80 = 640 ms — worse than a decent cascaded pipeline. The delay pattern matters: putting the semantic codebook first with zero delay and the acoustic ones behind it at a small fixed offset is what gets you to 160. Architecture choices at the token level become product latency, directly and legibly.

Inner monologue: how it still reasons

A pure audio-to-audio model has a known weakness: audio tokens are a much less efficient carrier of meaning than text tokens, so reasoning quality suffers. Moshi’s answer is the Inner Monologue: the model also predicts a stream of text tokens, time-aligned to its own speech, slightly ahead of the audio it will produce. The text acts as a plan; the audio follows it.

This is a beautiful piece of engineering because of what it buys for free. Delay the audio tokens behind the text and you have streaming text-to-speech. Delay the text behind the audio and you have streaming speech recognition. One architecture, three products, selected by a delay parameter.

Concept → realization. The compute shape is the other half of the story. Modelling 8 codebooks × 2 streams at 12.5 Hz means many tokens per second, and running a 7B transformer once per token would be far too slow. Moshi uses an RQ-Transformer: a large temporal transformer runs once per 80 ms frame producing a context vector, and a much smaller depth transformer walks the codebooks within that frame. Big model at 12.5 Hz, small model at 100 Hz. That factorisation is what makes real-time full duplex fit on one GPU — the same “expensive thing rarely, cheap thing often” trick you see in hierarchical decoders everywhere.

See the streams

The widget below draws Moshi’s actual data layout: user stream on top, Moshi’s audio stream below it, the inner-monologue text stream between them, all quantised into 80 ms frames. Scrub the playhead through an exchange that includes an overlap and a barge-in. Notice there is no state, no timer, no boundary marker — only tokens, some of which happen to encode silence.

Dual-stream token timeline

Each cell is one 80 ms frame. Filled = speech tokens, hollow = silence tokens (still tokens, still predicted). Scrub the playhead; toggle the delay pattern to see the acoustic lag that sets the 160 ms latency. Compare the cascaded track at the bottom, which must wait for its endpointer.

playhead1.92 s
acoustic delay (frames)2 → 160 ms

So why is anyone still building cascaded stacks?

Because full duplex is not free, and in 2026 the enterprise default is still the cascade. The honest ledger:

Cascaded (STT → LLM → TTS)Full-duplex speech-to-speech
Response latency520–1530 ms, dominated by endpointing160–500 ms, no endpoint wait
Overlap / backchannelsbolted on with classifiersnative
Prosody, emotion, laughterdestroyed at the text bottleneckpreserved end to end
Debuggabilityevery stage has a readable transcriptopaque; no text layer to inspect by default
Swapping componentschange STT vendor in an afternoontightly coupled to one model/provider
Instruction following, toolsthe full LLM ecosystemweaker; improving fast
Compliance / redactiontext checkpoints make audits easyhard: what exactly do you log?
Cost controlmature, cacheable promptsvaries wildly by provider

The debuggability row is doing most of the work in real procurement decisions. When a regulated business must explain what its agent said and why, a stack with a transcript at every boundary is a much easier conversation than one where the model reasoned in audio space. This is why the dominant 2026 pattern for enterprise voice is still cascaded, with speech-to-speech winning where latency and naturalness are the product — concierge, companionship, live support with a premium feel.

It is also why the frontier is not “full duplex or not” but how much duplex you can get with a text layer intact. Inner Monologue is exactly that compromise: full-duplex audio with a readable text stream running alongside. Thinker-Talker designs make the same bet from the other direction — a text brain and a speech mouth sharing context, streaming.

The mistake: assuming full-duplex models are automatically good at being interrupted. Being architecturally capable of overlap is not the same as behaving well during it. Benchmarks that specifically measure interruption handling find enormous spread: a model can be full-duplex and still fail to yield the floor most of the time, or yield and then lose the thread of its task. NVIDIA’s PersonaPlex reports 100% barge-in success against 60.6% for Moshi and 43.9% for a major commercial live model on the same benchmark, with task adherence scores of 4.34 versus 1.26. The architecture removes the mechanism; the training data and objectives still have to teach the behaviour. “No VAD” is not the same as “good manners.”
Why does a full-duplex model like Moshi need no VAD and no endpointer?

Chapter 9: Cheat Sheet & Connections

You started this lesson with a caller being cut off mid-order. You can now explain exactly why it happened, compute the threshold that would have prevented it, quantify what that prevention costs on every other turn, implement the recovery when it happens anyway, and describe the architecture in which the failure is not expressible. Here is everything in one place.

The ladder

The widget places every technique on the two axes that matter — how naturally it handles the shape of real conversation, and what it costs you in response latency. Step through the rungs to see what each layer fixed and what it left broken.

The turn-taking ladder

Each rung is a technique, placed by typical response latency (horizontal) and conversational naturalness (vertical). Step through to read what each one fixed — and the failure that forced the next rung.

rungenergy VAD

Every symbol and number

QuantityMeaningTypical value
frameaudio block for one decision10–30 ms (160–480 samples at 16 kHz)
RMS√(mean of squared samples)0.01 quiet → 0.4 loud
dBFS20 log10(RMS)−50 room tone, −20 speech, −8 shout
θVAD energy thresholdnoise floor + 10–15 dB
Hhangover: frames held after energy drops200–400 ms worth
αnoise-floor adaptation rate0.01–0.05 per frame
pspeechneural VAD per-frame probabilityon at 0.5, off at 0.35 (hysteresis)
Tsilence threshold for end-of-turn~500 ms default; derived in ch.5
P(complete)semantic turn-completion probability0.11 “order a…”, 0.93 “…pizza”
τ(s)decaying confidence threshold0.9 → 0.3 over 1200 ms
hard timeoutfallback endpoint, always present1500–2000 ms
P*barge-in decision thresholdCfalse/(Cmiss+Cfalse) ≈ 0.167
gaphuman inter-speaker silence~200 ms, cross-linguistically stable
overlapfraction of spoken time with both talking10–20%
Mimi framefull-duplex token period80 ms (12.5 Hz)

The equations

RMS = √( (1/N) · ∑i xi2 )     level = 20 · log10(RMS)
statek = SPEECH if levelk > θ or hk > 0;   hk = H if loud else max(0, hk−1 − 1)
noisek = (1−α)·noisek−1 + α·levelk  (silent frames only)    θ = noise + margin
fire EOT when P(complete) > τ(s),   τ(s) = 0.9 − 0.6·min(1, s/1200)   or   s ≥ hard timeout
Cost(T) = wint·P(pause ≥ T) + wlat·(T/1000)
stop speaking when P(takeover) > Cfalse / (Cmiss + Cfalse)

The debugging table — symptom to cause

Symptom in productionAlmost always
Agent cuts users off mid-sentencesilence threshold below the mid-turn pause tail; add semantic endpointing before touching T
Agent never responds in a car / cafeenergy VAD saturating on noise floor; move to neural VAD, do not raise θ
p95 latency sits exactly at your fallback timeoutsemantic endpointer false-incomplete rate; measure recall on your audio
Agent keeps talking ~300 ms after the user interruptsyou stopped generation but did not flush the outbound/jitter buffers
Agent stops dead every time the user says “mm-hm”no overlap classifier; add duration + relative loudness + lexicon, and duck instead of hard-stopping
Agent references things it never actually saidmissing context repair; truncate the assistant turn at the playout position
Duplicate orders / double tool effects after interruptionsmutating tools fired before confirmation was spoken; make them idempotent and deferred
Word chopped into two turns (“pep” / “peroni”)no hangover, or min-silence below stop-consonant closure length (~120 ms)
Everything feels sluggish but every component is fastthe endpoint wait; it is 40–50% of your budget and it is pure policy

The five things worth remembering in a year

1. VAD answers “is there speech?”; endpointing answers “is this person finished?” A perfect VAD does not solve endpointing, because the evidence is in the words, not the silence.

2. Every threshold you agonise over is a point on a curve. Adding a feature moves the curve. Prefer information to tuning.

3. Report p95, not the mean. Bimodal latency — fast most turns, timeout on the rest — feels worse than a uniformly slower system.

4. Barge-in is five steps, not one, and the one everybody forgets is repairing the context to what was heard rather than what was generated.

5. When a problem needs seven mechanisms to patch, check whether it is an artifact of your architecture. Turn detection was.

Where to go next

Moshi (Veanor) — the full paper walkthrough: Mimi’s semantic distillation, the RQ-Transformer, the delay patterns, Inner Monologue, and the turn-taking statistics of generated dialogue.
PersonaPlex (Veanor) — voice and role control for full-duplex models, and the benchmark that measures whether a model actually yields the floor.
The Voice Agent Stack (Nuranor) — the product-level teardown: cascaded versus speech-to-speech, the full latency ledger, telephony, evals, and safety.
VALL-E (Veanor) — where the speech tokens that make any of this possible came from.
TTS Architectures — the synthesis side, including why streaming time-to-first-audio behaves the way it does in the ledger.
Whisper — the recognition side, and why long-form decoding has its own turn-shaped problems.
Audio Representations — frames, spectra and mel bands, the substrate under every VAD feature in Chapter 3.
Neural Audio Codecs — how a waveform becomes the 12.5 Hz token stream Chapter 8 depends on.
Agents & Tool Use — the cancellation and idempotency problems from Chapter 6, in their general form.

“What I cannot create, I do not understand.” You built the energy detector by hand, frame by frame, and watched a stop consonant break it. You added hangover and measured what it cost. You derived the industry’s 500 ms default from a pause distribution and then beat it with a transcript. You played the human against four different policies and felt each one fail. And then you saw the whole apparatus dissolve, because it was never a fact about conversation — only about the architecture we happened to build first.
A voice agent’s median response latency is 400 ms and its p95 is 1500 ms. Which change most improves how it feels?