Audio Classification
Transformers
ONNX
Safetensors
English
dualturn_endpointing
feature-extraction
turn-taking
endpointing
end-of-turn
voice-activity-detection
voice-agents
conversation
speech
audio
mimi
dualturn
real-time
custom_code
Instructions to use anyreach-ai/dualturn-endpointing with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use anyreach-ai/dualturn-endpointing with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("audio-classification", model="anyreach-ai/dualturn-endpointing", trust_remote_code=True)# Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("anyreach-ai/dualturn-endpointing", trust_remote_code=True, device_map="auto") - Notebooks
- Google Colab
- Kaggle
Upload README.md with huggingface_hub
Browse files
README.md
CHANGED
|
@@ -1,135 +1,115 @@
|
|
| 1 |
-
# dualturn-endpointing
|
| 2 |
|
| 3 |
-
|
| 4 |
|
| 5 |
-
|
| 6 |
|
| 7 |
-
> **Has the user finished
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
---
|
| 10 |
|
| 11 |
-
##
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
---
|
| 19 |
|
| 20 |
-
##
|
| 21 |
|
| 22 |
```
|
| 23 |
Dual-channel audio (24 kHz stereo)
|
| 24 |
β
|
| 25 |
-
βΌ
|
| 26 |
Per-frame signals:
|
| 27 |
-
vad_user, vad_agent
|
| 28 |
-
|
| 29 |
-
bot_user, bot_agent β beginning a new turn?
|
| 30 |
-
fvad_user_short/long β fast VAD (Silero), two smoothing windows
|
| 31 |
-
fvad_agent_short/long
|
| 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 |
-
### From HuggingFace (recommended)
|
| 57 |
|
| 58 |
```python
|
| 59 |
from endpointing import DualTurnEndpointing
|
| 60 |
|
| 61 |
-
model
|
| 62 |
-
|
| 63 |
-
# Offline: process a stereo WAV file
|
| 64 |
-
frames, endpoints = model.process_file(
|
| 65 |
-
"call.wav",
|
| 66 |
-
user_channel=0, # which stereo channel is the user
|
| 67 |
-
agent_channel=1,
|
| 68 |
-
)
|
| 69 |
-
|
| 70 |
-
for ep in endpoints:
|
| 71 |
-
print(f"t={ep['t_s']:.2f}s action={ep['action']} P(ST)={ep['p_st']:.3f}")
|
| 72 |
-
```
|
| 73 |
-
|
| 74 |
-
### From local files
|
| 75 |
-
|
| 76 |
-
```python
|
| 77 |
-
model = DualTurnEndpointing(
|
| 78 |
-
backbone_path = "best.pt",
|
| 79 |
-
classifier_path = "endpoint_clf.pkl",
|
| 80 |
-
device = "cuda",
|
| 81 |
-
)
|
| 82 |
-
```
|
| 83 |
-
|
| 84 |
-
### Streaming (real-time, 80 ms chunks)
|
| 85 |
-
|
| 86 |
-
```python
|
| 87 |
-
model = DualTurnEndpointing.from_pretrained("anyreach/dualturn-endpointing")
|
| 88 |
stream = model.stream(user_channel=0, agent_channel=1)
|
| 89 |
|
| 90 |
-
|
|
|
|
|
|
|
| 91 |
result = stream.push(chunk)
|
| 92 |
if result:
|
| 93 |
if result["action"] == "ST":
|
| 94 |
agent.start_responding()
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
```
|
| 98 |
-
|
| 99 |
-
### CLI
|
| 100 |
-
|
| 101 |
-
```bash
|
| 102 |
-
# From HuggingFace
|
| 103 |
-
python endpointing.py --audio call.wav --from-hf anyreach/dualturn-endpointing
|
| 104 |
-
|
| 105 |
-
# From local files
|
| 106 |
-
python endpointing.py \
|
| 107 |
-
--audio call.wav \
|
| 108 |
-
--backbone best.pt \
|
| 109 |
-
--classifier endpoint_clf.pkl \
|
| 110 |
-
--user-channel 0 \
|
| 111 |
-
--agent-channel 1 \
|
| 112 |
-
--out-json results.json
|
| 113 |
-
```
|
| 114 |
-
|
| 115 |
-
---
|
| 116 |
-
|
| 117 |
-
## Output format
|
| 118 |
-
|
| 119 |
-
Each endpoint decision:
|
| 120 |
-
```json
|
| 121 |
-
{
|
| 122 |
-
"t_s": 9.70,
|
| 123 |
-
"action": "ST",
|
| 124 |
-
"p_st": 0.984,
|
| 125 |
-
"signals": {
|
| 126 |
-
"vad_user": 0.02, "vad_agent": 0.01,
|
| 127 |
-
"eot_user": 0.91, "eot_agent": 0.03,
|
| 128 |
-
"bot_user": 0.01, "bot_agent": 0.05,
|
| 129 |
-
"fvad_user_short": 0.03, "fvad_user_long": 0.12,
|
| 130 |
-
"fvad_agent_short": 0.01, "fvad_agent_long": 0.02
|
| 131 |
-
}
|
| 132 |
-
}
|
| 133 |
```
|
| 134 |
|
| 135 |
---
|
|
@@ -140,8 +120,39 @@ Each endpoint decision:
|
|
| 140 |
|--------|-------|
|
| 141 |
| ST recall | 99% |
|
| 142 |
| ST precision | 90% |
|
| 143 |
-
|
|
| 144 |
-
| AUC | 0.853 |
|
| 145 |
| Threshold | 0.30 |
|
| 146 |
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# anyreach-ai/dualturn-endpointing
|
| 2 |
|
| 3 |
+
Real-time speech endpoint detector for two-channel (user + agent) audio.
|
| 4 |
|
| 5 |
+
Built on top of [DualTurn-Qwen2.5-Mimi-0.5B](https://huggingface.co/anyreach-ai/dualturn-qwen2.5-mimi-0.5B) with a trained endpoint classifier that answers one question at every VAD offset:
|
| 6 |
|
| 7 |
+
> **Has the user finished their turn?**
|
| 8 |
+
> `ST` β yes, agent should respond now
|
| 9 |
+
> `CL` β no, user just paused mid-sentence, keep waiting
|
| 10 |
+
|
| 11 |
+
| Output | Shape | Description |
|
| 12 |
+
|--------|-------|-------------|
|
| 13 |
+
| `vad_probs` | `[B, T, 2]` | P(speaking now) β `[:,0]`=user `[:,1]`=agent |
|
| 14 |
+
| `eot_probs` | `[B, T, 2]` | P(end of turn) per channel |
|
| 15 |
+
| `bot_probs` | `[B, T, 2]` | P(beginning of turn) per channel |
|
| 16 |
+
| `fvad_probs` | `[B, T, 4]` | Fast VAD (Silero) β user_short, user_long, agent_short, agent_long |
|
| 17 |
+
| `endpoints` | `list[dict]` | Sparse ST/CL decisions at VAD-offset anchors with P(ST) |
|
| 18 |
+
|
| 19 |
+
Frame rate: **12.5 Hz** (80 ms per frame). Audio resampled to 24 kHz internally.
|
| 20 |
|
| 21 |
---
|
| 22 |
|
| 23 |
+
## Inference
|
| 24 |
+
|
| 25 |
+
```bash
|
| 26 |
+
pip install transformers torch torchaudio joblib scikit-learn silero-vad huggingface_hub
|
| 27 |
+
```
|
| 28 |
+
|
| 29 |
+
```python
|
| 30 |
+
import torch, torchaudio
|
| 31 |
+
from transformers import AutoModel
|
| 32 |
+
|
| 33 |
+
model = AutoModel.from_pretrained(
|
| 34 |
+
"anyreach-ai/dualturn-endpointing",
|
| 35 |
+
trust_remote_code=True,
|
| 36 |
+
)
|
| 37 |
+
model.eval()
|
| 38 |
+
|
| 39 |
+
wav, sr = torchaudio.load("conversation.wav") # [2, T] CH0=user CH1=agent
|
| 40 |
+
|
| 41 |
+
with torch.no_grad():
|
| 42 |
+
out = model(wav, sr=sr)
|
| 43 |
|
| 44 |
+
# Per-frame signals at 12.5 Hz
|
| 45 |
+
print(out.vad_probs.shape) # [1, T, 2] P(speaking) β (user, agent)
|
| 46 |
+
print(out.eot_probs.shape) # [1, T, 2] P(end-of-turn)
|
| 47 |
+
print(out.bot_probs.shape) # [1, T, 2] P(begin-of-turn)
|
| 48 |
+
print(out.fvad_probs.shape) # [1, T, 4] fast VAD
|
| 49 |
+
|
| 50 |
+
# Sparse endpoint decisions β one per VAD offset (user stops speaking)
|
| 51 |
+
for ep in out.endpoints:
|
| 52 |
+
print(f"t={ep['t_s']:.2f}s action={ep['action']} P(ST)={ep['p_st']:.3f}")
|
| 53 |
+
# ep["action"] β "ST" (agent should respond) or "CL" (keep waiting)
|
| 54 |
+
# ep["p_st"] β P(user is done) threshold = 0.30
|
| 55 |
+
```
|
| 56 |
|
| 57 |
---
|
| 58 |
|
| 59 |
+
## Endpoint decision logic
|
| 60 |
|
| 61 |
```
|
| 62 |
Dual-channel audio (24 kHz stereo)
|
| 63 |
β
|
| 64 |
+
βΌ Mimi encoder + DualTurn backbone (every 80 ms)
|
| 65 |
Per-frame signals:
|
| 66 |
+
vad_user, vad_agent, eot_user, eot_agent,
|
| 67 |
+
bot_user, bot_agent, fvad_*_short, fvad_*_long
|
|
|
|
|
|
|
|
|
|
| 68 |
β
|
| 69 |
+
βΌ watch vad_user crossing 0.5
|
| 70 |
β
|
| 71 |
+
VAD offset detected (user stopped) + agent silent?
|
| 72 |
+
β
|
| 73 |
+
βΌ endpoint_clf.predict_proba(10 signal values)
|
| 74 |
+
P(ST) >= 0.30 β ST β agent should respond now
|
| 75 |
+
P(ST) < 0.30 β CL user paused mid-sentence, wait
|
| 76 |
```
|
| 77 |
|
| 78 |
+
Threshold **0.30** is tuned to maximise ST recall (99% recall on held-out test set).
|
| 79 |
|
| 80 |
---
|
| 81 |
|
| 82 |
+
## Files
|
| 83 |
|
| 84 |
+
| File | Description |
|
| 85 |
+
|------|-------------|
|
| 86 |
+
| `best.pt` | DualTurn backbone weights (two-stream transformer, 34M params) |
|
| 87 |
+
| `endpoint_clf.pkl` | Endpoint classifier β sklearn bundle with trained model + recommended threshold |
|
| 88 |
+
| `modeling_dualturn.py` | `DualTurnModel(PreTrainedModel)` β AutoModel-compatible wrapper |
|
| 89 |
+
| `configuration_dualturn.py` | `DualTurnConfig` |
|
| 90 |
+
| `config.json` | `auto_map` for AutoModel/AutoConfig |
|
| 91 |
+
| `endpointing.py` | Higher-level `DualTurnEndpointing` class with streaming support |
|
| 92 |
+
| `src/` | Bundled dualturn + evaluation source code |
|
| 93 |
|
| 94 |
---
|
| 95 |
|
| 96 |
+
## Streaming (real-time)
|
|
|
|
|
|
|
| 97 |
|
| 98 |
```python
|
| 99 |
from endpointing import DualTurnEndpointing
|
| 100 |
|
| 101 |
+
model = DualTurnEndpointing.from_pretrained("anyreach-ai/dualturn-endpointing")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
stream = model.stream(user_channel=0, agent_channel=1)
|
| 103 |
|
| 104 |
+
# Feed 80 ms of 24 kHz stereo PCM float32 every tick
|
| 105 |
+
# chunk shape: (2, 1920)
|
| 106 |
+
for chunk in audio_source():
|
| 107 |
result = stream.push(chunk)
|
| 108 |
if result:
|
| 109 |
if result["action"] == "ST":
|
| 110 |
agent.start_responding()
|
| 111 |
+
# result["p_st"] β P(ST) from classifier
|
| 112 |
+
# result["signals"] β all 10 signal values at the anchor
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
```
|
| 114 |
|
| 115 |
---
|
|
|
|
| 120 |
|--------|-------|
|
| 121 |
| ST recall | 99% |
|
| 122 |
| ST precision | 90% |
|
| 123 |
+
| AUC (ST vs CL) | 0.853 |
|
|
|
|
| 124 |
| Threshold | 0.30 |
|
| 125 |
|
| 126 |
+
Tuned for **high ST recall** β almost never misses a real turn end. Accepts some false STs to avoid making the user repeat themselves.
|
| 127 |
+
|
| 128 |
+
---
|
| 129 |
+
|
| 130 |
+
## Training Data
|
| 131 |
+
|
| 132 |
+
Endpoint classifier trained on 70 real dual-channel calls (user + Gemini agent) automatically labelled by Gemini 2.5 Pro.
|
| 133 |
+
|
| 134 |
+
Backbone: [anyreach-ai/dualturn-qwen2.5-mimi-0.5B](https://huggingface.co/anyreach-ai/dualturn-qwen2.5-mimi-0.5B)
|
| 135 |
+
|
| 136 |
+
---
|
| 137 |
+
|
| 138 |
+
## Authors
|
| 139 |
+
|
| 140 |
+
* Shangeth Rajaa β Senior ML Research Scientist, Anyreach AI
|
| 141 |
+
|
| 142 |
+
---
|
| 143 |
+
|
| 144 |
+
## Citation
|
| 145 |
+
|
| 146 |
+
**Paper:** DualTurn: Learning Turn-Taking from Dual-Channel Generative Speech Pretraining
|
| 147 |
+
|
| 148 |
+
```bibtex
|
| 149 |
+
@misc{rajaa2026dualturnlearningturntakingdualchannel,
|
| 150 |
+
title={DualTurn: Learning Turn-Taking from Dual-Channel Generative Speech Pretraining},
|
| 151 |
+
author={Shangeth Rajaa},
|
| 152 |
+
year={2026},
|
| 153 |
+
eprint={2603.08216},
|
| 154 |
+
archivePrefix={arXiv},
|
| 155 |
+
primaryClass={eess.AS},
|
| 156 |
+
url={https://arxiv.org/abs/2603.08216},
|
| 157 |
+
}
|
| 158 |
+
```
|