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)