File size: 14,810 Bytes
a51c780 07c2e6c a51c780 c2bffe4 a51c780 e7a225d a51c780 e7a225d a51c780 e7a225d a51c780 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 | ---
license: bsd-2-clause
library_name: onnx
pipeline_tag: audio-classification
tags:
- turn-detection
- end-of-turn
- voice-agents
- pipecat
- streaming
- onnx
datasets:
- pipecat-ai/smart-turn-data-v3.2-train
language:
- en
- multilingual
base_model: pipecat-ai/smart-turn-v3
---
# Smart Turn streaming end-of-turn heads (R1, audio-only)
A silence-aware, streaming end-of-turn detector for voice agents, built on Pipecat's frozen
[Smart Turn v3.2](https://huggingface.co/pipecat-ai/smart-turn-v3) encoder. Instead of one query
at the VAD stop followed by a 3 s timeout, it scores the conversation every 100 ms on the CPU and
decides at 0.2 / 0.5 / 1.2 s of silence with thresholds fitted to a false-alarm budget. On real
multi-party conversation (AMI Meeting Corpus) it ends the turn on 5 % of mid-turn pauses where the
stock rule fires on 42 %, and brings the 90th-percentile response time from 3.2 s to 1.3 s. A Pipecat
process with it takes about 17 % of one Apple M4 core against 9 % with the stock analyzer (the detector
alone is 9 % in a tight loop); numpy and onnxruntime only.
Code (Pipecat 1.14 turn analyzer, eager-then-commit stop strategy, training and evaluation):
[github.com/OpenHRIai/smart-turn-streaming](https://github.com/OpenHRIai/smart-turn-streaming).




## Files
| file | size | what |
|---|---|---|
| `smart-turn-v3.2-encoder-pooled.onnx` | 31 MB | Pipecat's Smart Turn v3.2 (Whisper-tiny encoder + its classifier), weights unchanged, with the pooled 384-d vector exposed as a second output. Input `input_features` (1, 80, 800) Whisper log-mel of the last 8 s; outputs `logits` (the stock model's P(complete)) and `sum_1` (the pooled vector). |
| `eot-heads-v32.onnx` | 0.7 MB | The trained heads: LayerNorm + Linear on the pooled vector, a GRU over the last second of log-mel (20 × 80 at 50 ms), the silence length, a fusion MLP. Inputs `pooled` (1, 384), `mel20` (1, 20, 80), `tau_feat` (1, 3) = [τ / 2.5, log1p(τ), speaking]; output `probs` (1, 2) = [P(turn complete), P(turn ends within 0.4 s)]. 172k parameters. |
| `smart-turn-streaming-clipend.onnx` | 33 MB | Drop-in with the stock interface: the pooled encoder, the heads at τ = 0 and a Platt calibration fitted on the training split in one graph, `input_features` (B, 80, 800) → `probability` (B, 1). For pipecat's `benchmark.py` and for callers that ask once at the clip end; the streaming rules use the two files above. |
| `rules/theta_tables.csv` | | Commit thresholds θ(0.2 s), θ(0.5 s), θ(1.2 s) fitted on AMI dev at false-alarm budgets of 5 / 10 / 15 %, with their AMI test numbers. |
| `rules/eager_tables.csv` | | Eager thresholds for Pipecat's speculation path, fitted on AMI dev at budgets of LLM generations spent on pauses (20–50 %). |
| `eval/ami_eval.py`, `eval/ami_{dev,test}.csv`, `eval/ami_eval_reference.md` | | Reproduces the AMI tables below from the public corpus audio (numpy, onnxruntime, soundfile); the reference file is its output on 2026-10-25. |
| `LICENSE`, `AMI_LICENCE.txt`, `ATTRIBUTION.md` | | BSD 2-Clause for this release; CC BY 4.0 for the AMI-derived manifests; sources and citations. |
## How it works
Two cadences in one object on one CPU thread, plus one worker thread for the encoder:
| path | when | work | cost (Apple M4, 1 ORT thread) |
|---|---|---|---|
| mel | every 20 ms audio frame | incremental Whisper log-mel, one 10 ms column at a time (exact to 0.003 against the reference extractor on real audio) | 0.02 ms |
| heads | every 100 ms, and at each silence-clock point | last 1 s of mel → GRU, cached pooled vector, τ → P(complete) | 0.7 ms |
| encoder | every 320 ms while the user speaks; at τ = 0, 0.2, 0.5, 0.8, 1.2, 1.8, 2.5 s of silence (the grid the heads were trained on), where the decision waits for it | 8 s window → Whisper-tiny → pooled vector, on a worker thread | 22 ms (46 ms when the core is otherwise idle) |
The decision rule queries P(complete) at 0.2, 0.5 and 1.2 s of silence while the pause lasts and
commits at the first offset where it exceeds θ(offset); Pipecat's `stop_secs` (3 s) stays as the
backstop. The budget is the product knob: `far10` for an agent that must not interrupt, `far15` when
a fast reply matters more. A second, looser table (`eager_tables.csv`) on the same decision points
drives Pipecat's speculation path (`UserTurnSpeculation` → `SpeculationGate`): the LLM starts
generating at the eager point, the response is held until the commit and discarded if the user
resumes.
Why silence awareness has to be trained: 79 % of the clips in `smart-turn-data-v3.2` end within
0.2 s of the speech, so the published models never saw a pause, and their false-alarm rate on
incomplete utterances rises from 5 % at the offset to 19 % after 2.5 s of silence. The heads were
trained on windows ending τ ∈ {0 … 2.5} s after the acoustic offset (clips padded with their own
room tone) plus 43k real conversational pauses from AMI. The GRU over the last second of mel is the
component that carries the silence awareness (AUC at 1.2 s of silence 0.74 with it, 0.66 without).
## Results
### AMI Meeting Corpus, test split (511 turn ends, 4,304 mid-turn pauses, 16 meetings, 4-party)
Offline, windows ending at Ï„ seconds of silence (`eval/ami_eval.py` reproduces this table):
| Ï„ (s) | AUC heads | AUC frozen Smart Turn v3.2 |
|---|---|---|
| 0.2 | 0.775 | 0.671 |
| 0.5 | 0.776 | 0.638 |
| 1.2 | 0.742 | 0.575 |
| 2.5 | 0.735 | 0.619 |
Over three training seeds the AUC at 0.2 s is 0.78 ± 0.01 and at 1.2 s 0.73 ± 0.01.
Decision rule, thresholds fitted on AMI dev, scored on AMI test; a turn end with no commit is
charged the 3 s timeout:
| rule | θ at 0.2 / 0.5 / 1.2 s | recall | false alarms on pauses | mean latency | FAR on pauses ≥ 1.5 s |
|---|---|---|---|---|---|
| stock rule (one query at the VAD stop, θ 0.5), frozen v3.2 | 0.50 / – / – | 0.51 | 30.4 % | 1.56 s | 0.45 |
| stock rule, heads | 0.50 / – / – | 0.64 | 25.5 % | 1.21 s | 0.46 |
| re-query, 10 % budget, frozen v3.2 | 1.00 / 1.00 / 0.28 | 0.70 | 10.6 % | 1.74 s | 0.61 |
| **re-query, 10 % budget, heads (`far10`)** | 0.92 / 0.98 / 0.86 | **0.80** | **9.4 %** | **1.48 s** | 0.50 |
| re-query, 15 % budget, frozen v3.2 | 1.00 / 1.00 / 0.02 | 0.87 | 13.7 % | 1.44 s | 0.78 |
| **re-query, 15 % budget, heads (`far15`)** | 0.98 / 0.88 / 0.80 | **0.86** | **13.3 %** | **1.21 s** | 0.61 |
| no model: end every turn at 1.2 s of silence | – | 1.00 | 18.0 % | 1.20 s | 1.00 |
### Pipecat's official benchmark (`benchmark.py`, full `smart-turn-data-v3.2` test, 31,527 clips, threshold 0.5, CPU)
| model | accuracy | precision | recall | F1 | FPR | inference p50 |
|---|---|---|---|---|---|---|
| Smart Turn v3.2 (fp32) | **93.71 %** | 0.931 | 0.944 | 0.937 | 3.51 % | 10.2 ms |
| `smart-turn-streaming-clipend.onnx` (heads, calibrated) | 93.23 % | 0.919 | 0.947 | 0.933 | 4.15 % | 10.3 ms |
The regression guard, not the result: the benchmark has no pauses, so the re-query, the eager stage and the
cancel path never run. At the clip end the calibrated heads trail v3.2 by 0.5 points at the fixed 0.5
threshold (same ranking, AUC 0.975 vs 0.978; the residual is the operating point), with the same per-language
pattern within noise (19 of 23 languages within 0.7 points; French, Polish and Turkish ahead; Arabic −1.6,
Marathi −1.4, Norwegian −1.1 behind). Per-language tables and the method: `docs/c3_benchmark.md` in the code
repository.
### Regression guard: `smart-turn-data-v3.2` test (6,000 clips, balanced)
| Ï„ (s) | AUC heads / frozen v3.2 | recall at 5 % FAR, heads / frozen |
|---|---|---|
| 0.0 | 0.975 / 0.978 | 0.87 / 0.89 |
| 0.2 | 0.976 / 0.976 | 0.87 / 0.86 |
| 1.2 | 0.972 / 0.964 | 0.84 / 0.77 |
| 2.5 | 0.965 / 0.955 | 0.81 / 0.72 |
Per-language and per-source AUC at Ï„ = 0.2 s is unchanged within noise. The heads are calibrated
differently from the frozen model (class-weighted loss), so use the fitted tables, not θ = 0.5.
### End to end in Pipecat 1.14 (real-time replay of 160 AMI test silences through Silero VAD and `UserTurnProcessor`)
| analyzer and rule | false alarms on pauses | turn ends committed by the model (not the 3 s backstop) | commit latency p50 / p90 | response time p50 / p90 / mean at 0.8 s of LLM latency | LLM generations spent on pauses |
|---|---|---|---|---|---|
| stock `LocalSmartTurnAnalyzerV3`, v3.2 fp32 | 42.5 % | 0.70 | 0.23 / 3.20 s | 1.03 / 4.00 / 1.89 s | 42.5 % |
| streaming, `far10` | 2.5 % | 0.78 | 1.21 / 3.00 s | 2.01 / 3.80 / 2.36 s | 2.5 % |
| streaming, `far15` | 5.0 % | 0.93 | 1.19 / 1.27 s | 1.99 / 2.07 / 1.92 s | 5.0 % |
| **streaming, `far15` + eager 30 %** | **5.0 %** | **0.93** | 1.19 / 1.26 s | **1.31 / 2.04 / 1.55 s** | 18.8 % |
With the eager stage the median response time matches the stock analyzer within 0.3 s, the p90 is
halved, an eighth of the user turns are split, and only 2.5 % of pauses are audibly interrupted
(stock: 7.5 %).
### Cost (Apple M4, single ORT thread, numpy + onnxruntime)
| | |
|---|---|
| detector share of one core, tight loop | 9.1 % (encoder refreshes included) |
| whole Pipecat process (Silero VAD, pipeline, analyzer) | 16–19 % of one core at real-time pace (stock analyzer: 9 %) |
| heads hop | 0.7 ms |
| encoder refresh | 22 ms (46 ms at real-time pace on an otherwise idle machine) |
| pipeline-task wall time per audio frame, encoder on the worker thread | p99 4–5 ms (inline: 45 ms); the 0.5 s and 1.2 s decisions 0.2 ms |
| resident memory added by the analyzer | +114 MB (stock: +76 MB) |
| model files | 32 MB |
A cloud vCPU is about 3× slower than an M4 core for this encoder (Pipecat's own figure for the same
architecture on Pipecat Cloud), so expect roughly 25–30 % of one vCPU for the detector alone.
## Usage
### In Pipecat (recommended)
```python
from eot.pipecat_analyzer import StreamingEOTAnalyzer, StreamingEOTParams
from eot.pipecat_eager_strategy import TurnAnalyzerEagerUserTurnStopStrategy
from pipecat.turns.user_turn_strategies import UserTurnStrategies
from pipecat.turns.user_start import VADUserTurnStartStrategy
analyzer = StreamingEOTAnalyzer(
encoder_onnx="smart-turn-v3.2-encoder-pooled.onnx",
heads_onnx="eot-heads-v32.onnx",
params=StreamingEOTParams.from_rule("far15", eager="far15_gen30"), # commit and eager tables
)
strategies = UserTurnStrategies(
start=[VADUserTurnStartStrategy()],
stop=[TurnAnalyzerEagerUserTurnStopStrategy(turn_analyzer=analyzer)], # or pipecat's TurnAnalyzerUserTurnStopStrategy
)
```
`eot/` is in the repository linked above; the analyzer needs 16 kHz audio and Pipecat's default VAD
`stop_secs` of 0.2 s. `StreamingEOTParams.from_rule("far10")` without an eager table gives the
one-stage rule.
### Standalone streaming (numpy + onnxruntime)
```python
from eot.streaming import StreamingEOT
eot = StreamingEOT("smart-turn-v3.2-encoder-pooled.onnx", "eot-heads-v32.onnx", worker=True)
for frame, is_speech in audio_frames(): # 20 ms float32 frames in [-1, 1] and the VAD's verdict
out = eot.push(frame, is_speech) # None, or {"p_end", "p_eager", "tau", "stale_s", ...} at a hop
if out and not out["speaking"] and out["tau"] >= 1.2 and out["p_end"] >= 0.80:
... # end of turn (the far15 table's 1.2 s threshold)
```
### One window, offline
```python
import numpy as np, onnxruntime as ort
enc = ort.InferenceSession("smart-turn-v3.2-encoder-pooled.onnx")
heads = ort.InferenceSession("eot-heads-v32.onnx")
feats = whisper_logmel(last_8_seconds) # (80, 800); eval/ami_eval.py has a numpy implementation
logits, pooled = enc.run(None, {"input_features": feats[None]})
mel20 = feats[:, -100:].reshape(80, 20, 5).mean(-1).T[None] # last 1 s at 50 ms steps
tau = 0.5 # seconds of silence at the window end
tau_feat = np.array([[tau / 2.5, np.log1p(tau), 0.0]], np.float32)
p_end, p_eager = heads.run(None, {"pooled": pooled, "mel20": mel20, "tau_feat": tau_feat})[0][0]
```
## Limitations
- **Conversational evaluation is English-only.** The silence-aware behaviour was trained and measured on
English meetings (AMI); the per-language check covers 23 languages but only on isolated utterances
(`smart-turn-data`), where the heads stay within noise of the frozen model in every language and hold the decision through
silence after an unfinished utterance in all 23 (false alarms after 2.5 s of silence 9.4 % against the frozen
model's 22.5 % at a matched 5 % at the clip end). Whether
the pause thresholds transfer to other languages' conversational timing is not measured.
- Meetings have a ceiling set by the labels: ending every turn at 1.2 s of silence
gives recall 1.0 at 18 % false alarms, and below that budget about a quarter of turn ends still
wait for the backstop, for every model tried.
- Long pauses (≥ 1.5 s) remain the weak spot: 50–60 % false alarms at the 10–15 % budgets.
- The second output (`p_eager`, "the turn ends within 0.4 s") is not usable as a free-running
trigger during speech (AUC 0.70 on AMI; ~22 false fires per minute of speech). The eager tables use
P(complete) at the silence-clock points instead.
- Every decision waits for an encoder refresh of its own window: the 0.2 s decision at the VAD stop for up
to 50 ms (`wait_s`), the 0.5 s and 1.2 s decisions for up to 80 ms (`max_defer_s`); the encoder takes
22–46 ms on an M4 and about 3× that on a cloud vCPU, so raise the allowances there. If the refresh is
late the decision uses the cached vector, up to 320 ms old, and the analyzer records the staleness of
every decision. With the refresh in time the streaming decisions equal the offline evaluation's within
0.001 AUC on AMI.
- Labels are by outcome, not intent: a speaker who was cut off counts as a complete turn.
## Reproduce the AMI tables
```
pip install numpy onnxruntime soundfile
python eval/ami_eval.py --audio-dir ./ami_audio # downloads 34 headset files (~3 GB), ~10 min on an M4
```
## Citation
If you use this model, please cite Pipecat's Smart Turn (the encoder) and the AMI Meeting Corpus
(`ATTRIBUTION.md`).
|