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
File size: 17,293 Bytes
b0c051e | 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 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 | """modeling_dualturn.py β HuggingFace PreTrainedModel wrapper for DualTurn.
Usage (requires trust_remote_code=True):
import torch, torchaudio
from transformers import AutoModel
model = AutoModel.from_pretrained(
"anyreach-ai/dualturn-endpointing",
trust_remote_code=True,
)
model.eval()
wav, sr = torchaudio.load("conversation.wav") # [2, T] CH0=user CH1=agent
with torch.no_grad():
out = model(wav, sr=sr)
# Per-frame signals at 12.5 Hz (one frame = 80 ms)
print(out.vad_probs.shape) # [1, T, 2] (user, agent)
print(out.eot_probs.shape) # [1, T, 2] (user, agent)
print(out.bot_probs.shape) # [1, T, 2] (user, agent)
print(out.fvad_probs.shape) # [1, T, 4] (user_short, user_long, agent_short, agent_long)
# Endpoint decisions (sparse β only at VAD offsets)
for ep in out.endpoints:
print(ep["t_s"], ep["action"], ep["p_st"])
# ep["action"] is "ST" (user done) or "CL" (user paused)
# ep["p_st"] is P(ST) from the endpoint classifier
Notes
-----
- Input audio must be stereo: channel 0 = user, channel 1 = agent.
Swap channels or set user_channel / agent_channel if needed.
- The model resamples to 24 kHz internally.
- For streaming use the endpointing.py helper (DualTurnEndpointing.stream()).
"""
from __future__ import annotations
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
import joblib
import numpy as np
import torch
import torchaudio
from transformers import PreTrainedModel
from transformers.utils import ModelOutput
from configuration_dualturn import DualTurnConfig
# ββ Add bundled source to path ββββββββββββββββββββββββββββββββββββββββββββββββ
_SRC = Path(__file__).parent / "src"
if str(_SRC) not in sys.path:
sys.path.insert(0, str(_SRC))
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Output dataclass
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@dataclass
class DualTurnOutput(ModelOutput):
"""
Output from DualTurnModel.
Attributes
----------
vad_probs : Tensor [batch, T, 2]
Per-frame voice activity probability.
dim 2: [user, agent]
eot_probs : Tensor [batch, T, 2]
Per-frame end-of-turn probability.
dim 2: [user, agent]
bot_probs : Tensor [batch, T, 2]
Per-frame begin-of-turn probability.
dim 2: [user, agent]
fvad_probs : Tensor [batch, T, 4]
Per-frame fast VAD (Silero) with two smoothing windows per channel.
dim 2: [user_short, user_long, agent_short, agent_long]
endpoints : list[dict]
Sparse endpoint decisions at VAD-offset anchors (agent silent).
Each dict:
t_s : float β time in seconds
action : str β "ST" (user done) or "CL" (user paused)
p_st : float β P(ST) from endpoint classifier [0, 1]
signals: dict β all 10 signal values at the anchor
"""
vad_probs: Optional[torch.Tensor] = None # [B, T, 2]
eot_probs: Optional[torch.Tensor] = None # [B, T, 2]
bot_probs: Optional[torch.Tensor] = None # [B, T, 2]
fvad_probs: Optional[torch.Tensor] = None # [B, T, 4]
endpoints: list = field(default_factory=list)
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Model
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
class DualTurnModel(PreTrainedModel):
"""
Dual-channel turn-taking model with built-in speech endpoint detection.
Wraps the dualturn transformer backbone + a trained logistic regression
endpoint classifier into a single HuggingFace-compatible model.
Load with:
model = AutoModel.from_pretrained(
"anyreach-ai/dualturn-endpointing",
trust_remote_code=True,
)
"""
config_class = DualTurnConfig
def __init__(self, config: DualTurnConfig):
super().__init__(config)
self.config = config
# Backbone and classifier are loaded lazily from weights in
# _load_backbone() / _load_classifier(), called by from_pretrained
self._backbone = None
self._input_mode = None
self._clf = None
self._clf_feat_names: list[str] = []
self._silero_sr = None
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# from_pretrained hook β load backbone weights + classifier
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _init_weights(self, module):
pass # weights loaded from checkpoint, not randomly initialised
@classmethod
def from_pretrained(cls, pretrained_model_name_or_path, *args, **kwargs):
"""Override to load backbone (.pt) and classifier (.pkl) from the repo."""
import os
from huggingface_hub import snapshot_download
# Download full repo snapshot to get all files
local_dir = snapshot_download(pretrained_model_name_or_path,
cache_dir=kwargs.pop("cache_dir", None),
ignore_patterns=["*.md"])
# Build config
config = DualTurnConfig.from_pretrained(local_dir)
# Instantiate with base PreTrainedModel machinery (no weight loading yet)
kwargs["config"] = config
model = cls(config)
# Load dualturn backbone
backbone_path = os.path.join(local_dir, "best.pt")
model._load_backbone(backbone_path)
# Load endpoint classifier
clf_path = os.path.join(local_dir, "endpoint_clf.pkl")
model._load_classifier(clf_path)
return model
def _load_backbone(self, checkpoint_path: str):
from evaluation.verify_v2_model import load_model
from dualturn.data.labeling import silero_probs_native, clean_anti_flicker, SILERO_RATE_HZ
device = next(iter(["cuda", "cpu"]), "cpu") if not hasattr(self, "device") else str(self.device)
print(f"[dualturn] Loading backbone from {checkpoint_path} β¦")
self._backbone, self._input_mode = load_model(checkpoint_path, device=device)
self._backbone.eval()
self._silero_probs = silero_probs_native
self._anti_flicker = clean_anti_flicker
self._silero_sr = int(SILERO_RATE_HZ)
print(f"[dualturn] Backbone ready (input_mode={self._input_mode})")
def _load_classifier(self, clf_path: str):
bundle = joblib.load(clf_path)
self._clf = bundle["model"]
self._clf_feat_names = bundle["feature_names"]
# Honour tuned threshold from bundle unless config overrides
if "recommended_threshold" in bundle:
self.config.st_threshold = bundle["recommended_threshold"]
print(f"[dualturn] Classifier ready "
f"(type={bundle.get('clf_type','?')} "
f"thr={self.config.st_threshold} "
f"features={len(self._clf_feat_names)})")
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Forward
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def forward(
self,
audio: torch.Tensor,
sr: int = 24_000,
user_channel: int = 0,
agent_channel: int = 1,
) -> DualTurnOutput:
"""
Run backbone + endpoint classifier on dual-channel audio.
Parameters
----------
audio : Tensor [2, T] or [1, 2, T]
Stereo audio. Channel 0 = user, channel 1 = agent (by default).
sr : int
Sample rate of `audio`. Resampled to 24 kHz internally.
user_channel : int
Which channel is the user (default 0).
agent_channel : int
Which channel is the agent (default 1).
Returns
-------
DualTurnOutput
.vad_probs [1, T, 2] voice activity (user, agent)
.eot_probs [1, T, 2] end-of-turn (user, agent)
.bot_probs [1, T, 2] begin-of-turn (user, agent)
.fvad_probs [1, T, 4] fast VAD (us, ul, as, al)
.endpoints list[dict] sparse ST/CL decisions
"""
assert self._backbone is not None, "Backbone not loaded. Use from_pretrained()."
# ββ 1. Prepare audio βββββββββββββββββββββββββββββββββββββββββββββ
if audio.dim() == 3:
audio = audio.squeeze(0) # [2, T]
if audio.shape[0] != 2:
raise ValueError(f"Expected 2-channel audio, got shape {audio.shape}")
mimi_sr = self.config.mimi_sample_rate
if sr != mimi_sr:
audio = torchaudio.functional.resample(audio, sr, mimi_sr)
user_wav = audio[user_channel].unsqueeze(0) # [1, T]
agent_wav = audio[agent_channel].unsqueeze(0) # [1, T]
stereo = torch.stack([audio[user_channel], audio[agent_channel]], dim=0) # [2, T]
# ββ 2. Backbone inference βββββββββββββββββββββββββββββββββββββββββ
from evaluation.run_model_on_audio import encode_with_mimi, run_inference
dev = next(self._backbone.parameters()).device
with torch.no_grad():
tokens = encode_with_mimi(stereo.unsqueeze(0).to(dev),
self._backbone, self._input_mode)
preds = run_inference(tokens, self._backbone)
# preds: dict of numpy arrays, shape (n_frames,)
n_frames = preds["vad_user"].shape[0]
# ββ 3. FVAD (Silero) ββββββββββββββββββββββββββββββββββββββββββββββ
u16 = torchaudio.functional.resample(user_wav, mimi_sr, self._silero_sr)[0].numpy()
a16 = torchaudio.functional.resample(agent_wav, mimi_sr, self._silero_sr)[0].numpy()
raw_u = self._silero_probs(u16, self._silero_sr)
raw_a = self._silero_probs(a16, self._silero_sr)
fvad_us = _r2n(self._anti_flicker(raw_u, alpha=self.config.fvad_alpha_short), n_frames)
fvad_ul = _r2n(self._anti_flicker(raw_u, alpha=self.config.fvad_alpha_long), n_frames)
fvad_as = _r2n(self._anti_flicker(raw_a, alpha=self.config.fvad_alpha_short), n_frames)
fvad_al = _r2n(self._anti_flicker(raw_a, alpha=self.config.fvad_alpha_long), n_frames)
# ββ 4. Assemble output tensors ββββββββββββββββββββββββββββββββββββ
vad = torch.tensor(np.stack([preds["vad_user"], preds["vad_agent"]], axis=-1)).unsqueeze(0).float()
eot = torch.tensor(np.stack([preds["eot_user"], preds["eot_agent"]], axis=-1)).unsqueeze(0).float()
bot = torch.tensor(np.stack([preds["bot_user"], preds["bot_agent"]], axis=-1)).unsqueeze(0).float()
fvad = torch.tensor(np.stack([fvad_us, fvad_ul, fvad_as, fvad_al], axis=-1)).unsqueeze(0).float()
# shapes: [1, T, 2], [1, T, 2], [1, T, 2], [1, T, 4]
# ββ 5. Endpoint decisions βββββββββββββββββββββββββββββββββββββββββ
endpoints = self._detect_endpoints(preds, fvad_us, fvad_ul, fvad_as, fvad_al, n_frames)
return DualTurnOutput(
vad_probs = vad,
eot_probs = eot,
bot_probs = bot,
fvad_probs = fvad,
endpoints = endpoints,
)
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Endpoint detection loop
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _detect_endpoints(self, preds, fvad_us, fvad_ul, fvad_as, fvad_al, n_frames):
"""Scan frames for VAD offsets (agent silent) and classify ST/CL."""
from collections import deque
cfg = self.config
vad_thr = cfg.vad_edge_threshold
agent_min = cfg.agent_voice_min
ms = cfg.mimi_frame_ms
history: deque = deque(maxlen=int(2.0 * cfg.mimi_frame_rate) + 5)
endpoints = []
prev_above = False
last_ep_t = -999.0
for i in range(n_frames):
t_s = i * ms / 1000.0
sig = {
"vad_user": float(preds["vad_user"][i]),
"vad_agent": float(preds["vad_agent"][i]),
"eot_user": float(preds["eot_user"][i]),
"eot_agent": float(preds["eot_agent"][i]),
"bot_user": float(preds["bot_user"][i]),
"bot_agent": float(preds["bot_agent"][i]),
"fvad_user_short": float(fvad_us[i]),
"fvad_user_long": float(fvad_ul[i]),
"fvad_agent_short": float(fvad_as[i]),
"fvad_agent_long": float(fvad_al[i]),
}
history.append(sig)
cur_above = sig["vad_user"] > vad_thr
recent_agent = float(np.mean([h["vad_agent"] for h in list(history)[-10:]]))
agent_voicing = recent_agent > agent_min
# VAD offset + agent silent + refractory
if prev_above and not cur_above and not agent_voicing:
if t_s - last_ep_t >= 0.4:
ep = self._classify_endpoint(t_s, sig)
endpoints.append(ep)
last_ep_t = t_s
prev_above = cur_above
return endpoints
def _classify_endpoint(self, t_s: float, sig: dict) -> dict:
"""
Run endpoint_clf on the 10 signal values at the VAD offset.
Features (10, *_last):
vad_user_last, vad_agent_last, eot_user_last, eot_agent_last,
bot_user_last, bot_agent_last, fvad_user_short_last,
fvad_user_long_last, fvad_agent_short_last, fvad_agent_long_last
Output:
P(ST) >= threshold β "ST" (user finished, agent should respond)
P(ST) < threshold β "CL" (user paused mid-sentence, wait)
"""
SIGNAL_KEYS = [
"vad_user", "vad_agent", "eot_user", "eot_agent",
"bot_user", "bot_agent", "fvad_user_short", "fvad_user_long",
"fvad_agent_short", "fvad_agent_long",
]
feats = np.array([sig[k] for k in SIGNAL_KEYS], dtype=np.float32).reshape(1, -1)
proba = self._clf.predict_proba(feats)[0]
classes = list(self._clf.classes_)
p_st = float(proba[classes.index(1)] if 1 in classes else proba[-1])
action = "ST" if p_st >= self.config.st_threshold else "CL"
return {"t_s": t_s, "action": action, "p_st": p_st, "signals": sig}
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Helper
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _r2n(arr: np.ndarray, n: int) -> np.ndarray:
"""Resample 1-D array to length n via linear interpolation."""
idx = np.linspace(0, len(arr) - 1, n)
return np.interp(idx, np.arange(len(arr)), arr).astype(np.float32)
|