AbhiCommits commited on
Commit
95cceca
·
verified ·
1 Parent(s): da4ec34

Smart Turn Hinglish: side-by-side demo vs stock v3.2

Browse files
.gitattributes CHANGED
@@ -33,3 +33,9 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ examples/01_hesitation_INCOMPLETE.wav filter=lfs diff=lfs merge=lfs -text
37
+ examples/02_tagparticle_COMPLETE.wav filter=lfs diff=lfs merge=lfs -text
38
+ examples/03_hesitation_INCOMPLETE.wav filter=lfs diff=lfs merge=lfs -text
39
+ examples/04_tagparticle_COMPLETE.wav filter=lfs diff=lfs merge=lfs -text
40
+ examples/05_SAMEsentence_COMPLETE.wav filter=lfs diff=lfs merge=lfs -text
41
+ examples/06_SAMEsentence_INCOMPLETE.wav filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -1,13 +1,70 @@
1
- ---
2
- title: Smart Turn Hinglish
3
- emoji: 🦀
4
- colorFrom: green
5
- colorTo: blue
6
- sdk: gradio
7
- sdk_version: 6.26.0
8
- python_version: '3.13'
9
- app_file: app.py
10
- pinned: false
11
- ---
12
-
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Smart Turn Hinglish
3
+ emoji: 🗣️
4
+ colorFrom: indigo
5
+ colorTo: green
6
+ sdk: gradio
7
+ sdk_version: 5.50.0
8
+ app_file: app.py
9
+ pinned: false
10
+ license: bsd-2-clause
11
+ ---
12
+
13
+ # Smart Turn Hinglish
14
+
15
+ Audio-only **turn detection** for Indian Hinglish, Indian-accented English and
16
+ Indian filler words — compared live against stock
17
+ [Smart Turn v3.2](https://huggingface.co/pipecat-ai/smart-turn-v3).
18
+
19
+ **Complete** = the speaker has finished, a voice bot may reply.
20
+ **Incomplete** = they are mid-thought; replying now interrupts them.
21
+
22
+ ## Why this exists
23
+
24
+ Indian fillers split into two families that sound similar and label **opposite**:
25
+
26
+ | Ends with | Label | Examples |
27
+ |---|---|---|
28
+ | hesitation marker | incomplete | matlab, toh, woh, arre, yaani |
29
+ | tag particle | complete | na, haan, ji, achha, theek hai |
30
+
31
+ Stock v3.2 has never heard them. On held-out Hinglish it scores **0.596 on tag
32
+ particles** — barely above chance. This model scores **0.907**.
33
+
34
+ ## Results
35
+
36
+ | | stock v3.2 | this model |
37
+ |---|---|---|
38
+ | Hinglish accuracy | 0.621 | **0.909** |
39
+ | False-interruption rate | 46.9% | **7.0%** |
40
+ | Tag-particle accuracy | 0.596 | **0.907** |
41
+ | CPU p95 latency | 40.4 ms | 42.5 ms |
42
+ | Params | 8M | 8M |
43
+
44
+ False-interruption is the error that matters: the bot talks over a human and the
45
+ user has to start again. A false *wait* just adds a short pause.
46
+
47
+ On Pipecat's own multilingual test set, stock shows the same asymmetry —
48
+ **22.4% false-interruption on Indic languages vs 8.4% elsewhere**, with false-wait
49
+ identical (5.78% vs 5.90%).
50
+
51
+ ## Try the examples
52
+
53
+ `05` and `06` are the same sentence in the same voice, one complete and one cut
54
+ off mid-thought. Only the ending differs — which is the whole point: turn
55
+ completion lives in the terminal prosody, not the words.
56
+
57
+ ## Limitations
58
+
59
+ Training audio is synthetic (Sarvam Bulbul v3, 36 voices). The human eval set is
60
+ 60 hand-labelled FLEURS clips — read Hindi, not conversational, and containing no
61
+ Indian-accented English. Full detail in the
62
+ [GitHub repo](https://github.com/abhinav7289A/Hinglish-turn-detection).
63
+
64
+ ## Credits
65
+
66
+ Built on [pipecat-ai/smart-turn](https://github.com/pipecat-ai/smart-turn)
67
+ (BSD-2-Clause) and `openai/whisper-tiny`. `models/smart-turn-v3.2-cpu.onnx` is
68
+ Pipecat's unmodified released checkpoint, redistributed here under BSD-2-Clause
69
+ so the side-by-side comparison works without a network fetch. Hinglish speech
70
+ synthesised with Sarvam Bulbul v3; evaluation clips from Google FLEURS.
app.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gradio demo -- stock Smart Turn v3.2 vs our Hinglish model, side by side.
2
+
3
+ The side-by-side IS the demo. Anyone evaluating this should see the difference
4
+ in about five seconds without reading a table, so the layout is two probability
5
+ bars and a one-line verdict, and the preloaded examples are chosen to land on
6
+ the exact cases stock v3 gets wrong: a hesitation filler that should read as
7
+ incomplete, a tag particle that should read as complete, and an Indian-English
8
+ declarative with a non-falling terminal contour.
9
+
10
+ Function over form -- CLAUDE.md lists over-polishing this as a known time sink.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from pathlib import Path
16
+
17
+ import gradio as gr
18
+ import numpy as np
19
+
20
+ from src.audio_utils import build_ort_session, extract_features, ort_predict
21
+ from src.config import EVAL_MANIFEST, MODELS, RESULTS, SAMPLE_RATE, TRAIN_MANIFEST
22
+
23
+ OURS = MODELS / "smart-turn-hinglish-int8.onnx"
24
+ STOCK = MODELS / "smart-turn-v3.2-cpu.onnx"
25
+ EXAMPLES_DIR = Path(__file__).resolve().parent / "examples"
26
+
27
+ _sessions: dict[str, object] = {}
28
+
29
+
30
+ def ensure_stock() -> None:
31
+ """Fetch stock v3.2 if it is not vendored.
32
+
33
+ Our own ONNX is committed, but stock's is gitignored (it is Pipecat's file,
34
+ not ours to redistribute in-repo). On a fresh Hugging Face Space the
35
+ comparison half of the demo would silently disappear, so pull it at startup.
36
+ """
37
+ if STOCK.exists():
38
+ return
39
+ try:
40
+ from huggingface_hub import hf_hub_download
41
+ MODELS.mkdir(parents=True, exist_ok=True)
42
+ src = hf_hub_download("pipecat-ai/smart-turn-v3", "smart-turn-v3.2-cpu.onnx")
43
+ STOCK.write_bytes(Path(src).read_bytes())
44
+ print(f"downloaded stock v3.2 -> {STOCK}")
45
+ except Exception as e:
46
+ print(f"could not fetch stock v3.2 ({e}); running without the comparison")
47
+
48
+
49
+ ensure_stock()
50
+
51
+
52
+ def session(path: Path):
53
+ key = str(path)
54
+ if key not in _sessions:
55
+ if not path.exists():
56
+ return None
57
+ _sessions[key] = build_ort_session(path)
58
+ return _sessions[key]
59
+
60
+
61
+ def _threshold() -> float:
62
+ """Use the operating point chosen in Phase 7, not a bare 0.5."""
63
+ p = RESULTS / "benchmark.json"
64
+ if not p.exists():
65
+ return 0.5
66
+ try:
67
+ report = json.loads(p.read_text())
68
+ for run in ("e2_d", "e2_c", "e1"):
69
+ s = report.get(run, {}).get("splits", {}).get("human")
70
+ if s:
71
+ return float(s["chosen_threshold"]["threshold"])
72
+ except Exception:
73
+ pass
74
+ return 0.5
75
+
76
+
77
+ THR = _threshold()
78
+
79
+
80
+ def predict(audio) -> tuple[dict, dict, str]:
81
+ if audio is None:
82
+ return {}, {}, "Record or upload a clip to compare the two models."
83
+
84
+ sr, data = audio
85
+ data = np.asarray(data, dtype=np.float32)
86
+ if data.ndim > 1:
87
+ data = data.mean(axis=1)
88
+ peak = float(np.max(np.abs(data))) or 1.0
89
+ data = data / max(peak, 1.0) if peak > 1.0 else data
90
+ if data.dtype.kind in "iu" or peak > 1.5:
91
+ data = data / 32768.0
92
+ if sr != SAMPLE_RATE:
93
+ import librosa
94
+ data = librosa.resample(data, orig_sr=sr, target_sr=SAMPLE_RATE)
95
+
96
+ feats = extract_features(data)
97
+ ours_s, stock_s = session(OURS), session(STOCK)
98
+ p_ours = ort_predict(ours_s, feats) if ours_s else float("nan")
99
+ p_stock = ort_predict(stock_s, feats) if stock_s else float("nan")
100
+
101
+ ours_lbl = {"complete (bot may reply)": p_ours, "incomplete (keep listening)": 1 - p_ours}
102
+ stock_lbl = {"complete (bot may reply)": p_stock, "incomplete (keep listening)": 1 - p_stock}
103
+
104
+ def verdict(p, thr):
105
+ return "COMPLETE" if p > thr else "INCOMPLETE"
106
+
107
+ agree = verdict(p_ours, THR) == verdict(p_stock, 0.5)
108
+ note = (
109
+ f"**Ours (Hinglish):** {verdict(p_ours, THR)} — p={p_ours:.3f} "
110
+ f"(threshold {THR:.2f}, chosen to hold false-interruptions under 5%)\n\n"
111
+ f"**Stock Smart Turn v3.2:** {verdict(p_stock, 0.5)} — p={p_stock:.3f} "
112
+ f"(threshold 0.50)\n\n"
113
+ + ("The two models agree on this clip."
114
+ if agree else
115
+ "**They disagree.** This is the kind of clip the Hinglish training targets — "
116
+ "typically a hesitation marker (*matlab, toh, woh*) that should read as "
117
+ "incomplete, a tag particle (*na, haan, ji*) that should read as complete, "
118
+ "or an Indian-English declarative whose terminal pitch does not fall.")
119
+ )
120
+ return ours_lbl, stock_lbl, note
121
+
122
+
123
+ def load_examples(n: int = 6) -> list[list]:
124
+ """Pick contrastive examples: hesitation, tag, and plain, both classes.
125
+
126
+ Prefers the committed `examples/` folder, which is what a deployed Space
127
+ has; falls back to the local manifests when running from a full checkout.
128
+ """
129
+ if EXAMPLES_DIR.exists():
130
+ wavs = sorted(EXAMPLES_DIR.glob("*.wav"))
131
+ if wavs:
132
+ return [[str(w)] for w in wavs[:n]]
133
+
134
+ rows: list[dict] = []
135
+ for manifest in (EVAL_MANIFEST, TRAIN_MANIFEST):
136
+ if manifest.exists():
137
+ rows += [json.loads(l) for l in
138
+ manifest.read_text(encoding="utf-8").splitlines() if l.strip()]
139
+ wanted = [("hesitation", 0), ("tag", 1), ("none", 1), ("none", 0),
140
+ ("hesitation", 0), ("tag", 1)]
141
+ picked, seen = [], set()
142
+ for want in wanted:
143
+ for r in rows:
144
+ path = Path(r.get("path", ""))
145
+ if (r.get("filler_type"), r.get("label")) == want \
146
+ and str(path) not in seen and path.exists():
147
+ picked.append([str(path)])
148
+ seen.add(str(path))
149
+ break
150
+ for r in rows:
151
+ if len(picked) >= n:
152
+ break
153
+ path = Path(r.get("path", ""))
154
+ if str(path) not in seen and path.exists():
155
+ picked.append([str(path)])
156
+ seen.add(str(path))
157
+ return picked[:n]
158
+
159
+
160
+ def build() -> gr.Blocks:
161
+ missing = [p.name for p in (OURS, STOCK) if not p.exists()]
162
+ banner = (f"\n\n⚠️ Missing model file(s): {missing}. "
163
+ f"Run `python run.py export` first." if missing else "")
164
+
165
+ with gr.Blocks(title="Smart Turn Hinglish") as demo:
166
+ gr.Markdown(
167
+ "# Smart Turn — Hinglish\n"
168
+ "Audio-only turn detection tuned for Indian Hinglish, Indian-accented "
169
+ "English, and Indian filler words. Compared live against stock "
170
+ "Smart Turn v3.2.\n\n"
171
+ "**Complete** = the speaker has finished and a voice bot may reply. "
172
+ "**Incomplete** = they are mid-thought; replying now interrupts them."
173
+ + banner)
174
+
175
+ with gr.Row():
176
+ audio_in = gr.Audio(sources=["microphone", "upload"], type="numpy",
177
+ label="Record or upload (16 kHz mono, ≤8 s used)")
178
+ btn = gr.Button("Compare", variant="primary")
179
+
180
+ with gr.Row():
181
+ ours_out = gr.Label(label="Ours — Hinglish fine-tuned", num_top_classes=2)
182
+ stock_out = gr.Label(label="Stock — Smart Turn v3.2", num_top_classes=2)
183
+ note_out = gr.Markdown()
184
+
185
+ btn.click(predict, [audio_in], [ours_out, stock_out, note_out])
186
+ audio_in.change(predict, [audio_in], [ours_out, stock_out, note_out])
187
+
188
+ examples = load_examples()
189
+ if examples:
190
+ gr.Examples(examples=examples, inputs=[audio_in],
191
+ label="Examples — the filler/tag pairs are where the two differ")
192
+ gr.Markdown(
193
+ "---\n*Hesitation markers* (matlab, toh, woh, arre, yaani) signal "
194
+ "mid-thought → **incomplete**. *Tag particles* (na, haan, ji, theek hai) "
195
+ "hand over the turn → **complete**. They sound similar and label "
196
+ "oppositely, which is why a model that has never heard them gets both "
197
+ "directions wrong.")
198
+ return demo
199
+
200
+
201
+ if __name__ == "__main__":
202
+ build().launch()
examples/01_hesitation_INCOMPLETE.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f7264ea004fd95fe41af9983c30dd860f30ccfafcb364f18d4974a862a0fae8d
3
+ size 125120
examples/02_tagparticle_COMPLETE.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d1239f83fe4fe359b6c2b45857dd304a8b0b1499ab7f6ec09ca618223887c317
3
+ size 170784
examples/03_hesitation_INCOMPLETE.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:361370d08006dea5b0af2dd26877ba37b68e70a645eecc70689453345034d281
3
+ size 146784
examples/04_tagparticle_COMPLETE.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2cddff0d07b9b5f77d0029f3fe9a54023dbe2be669de4fdc2f623e1ffa46d1e7
3
+ size 163478
examples/05_SAMEsentence_COMPLETE.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8f8cf51621dfe3235d260ddf2a51adcb58019118bff71e0381b6113dacff0c8b
3
+ size 165824
examples/06_SAMEsentence_INCOMPLETE.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2e8cbffd88210426d8809b0a620b14d0097fec574592a3b6b3462d692b6d2783
3
+ size 149940
examples/README.md ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Demo examples
2
+
3
+ Hesitation markers (*matlab, toh, woh, yaani*) mark mid-thought -> **incomplete**. Tag particles (*na, haan, ji, theek hai*) hand over the turn -> **complete**. They sound alike and label oppositely, which is exactly where stock Smart Turn v3.2 fails (0.596 accuracy on tags).
4
+
5
+ Clips 05/06 are the **same sentence, same voice** — only the ending differs.
6
+
7
+ - `01_hesitation_INCOMPLETE.wav` — label 0 (hesitation): Aapka KYC update pending hai yaani... woh last date kab tak hai submit karne ki?
8
+ - `02_tagparticle_COMPLETE.wav` — label 1 (tag): Bhaiya metro card recharge karna hai but OTP nahi aa raha hai since morning, kya
9
+ - `03_hesitation_INCOMPLETE.wav` — label 0 (hesitation): Bhaiya mera metro card recharge nahi ho raha hai toh... kya bolte hain, koi alte
10
+ - `04_tagparticle_COMPLETE.wav` — label 1 (tag): Yaar vo courier wale ne phir se galat address pe delivery kar di, humein abhi ca
11
+ - `05_SAMEsentence_COMPLETE.wav` — label 1 (hesitation): Geyser repair ke liye bill pay karna hai matlab... yaani, EMI option available h
12
+ - `06_SAMEsentence_INCOMPLETE.wav` — label 0 (hesitation): Geyser repair ke liye bill pay karna hai matlab... yaani, EMI option available h
models/smart-turn-hinglish-int8.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:bf268d9b6b4a0a5d994cee14974ca5f576abf218a242d7f5532ad18ef1c8f52f
3
+ size 8244451
models/smart-turn-v3.2-cpu.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2bb026316b14a660486a75b1733cd3fbab8c2fd0314dc9af7be49f8cca967e4f
3
+ size 8679182
requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hugging Face Space requirements -- runtime only.
2
+ # Deliberately excludes torch, faster-whisper, silero-vad, datasets and
3
+ # umap-learn: those are needed to BUILD the corpus and train, not to run
4
+ # inference. The demo is pure ONNX Runtime.
5
+ gradio>=5.0,<6.0
6
+ onnxruntime>=1.18
7
+ numpy>=1.26,<2.0
8
+ librosa>=0.10
9
+ soundfile>=0.12
10
+ transformers>=4.40,<5.0
11
+ huggingface_hub>=0.34,<1.0
12
+ python-dotenv>=1.0
results/benchmark.json ADDED
The diff for this file is too large to render. See raw diff
 
src/__init__.py ADDED
File without changes
src/audio_utils.py ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The preprocessing contract.
2
+
3
+ Reproduced from pipecat-ai/smart-turn `audio_utils.py` + `inference.py`.
4
+ Every model in this project -- stock v3.2, E1, the E2 sweep, E3 -- sees audio
5
+ through THIS module and nothing else. If these functions drift, no number in
6
+ `results/` is comparable to anything.
7
+
8
+ The order matters and is easy to get wrong:
9
+ 1. left-zero-pad (or keep the LAST 8 s) to exactly 8 s
10
+ 2. THEN hand to WhisperFeatureExtractor(chunk_length=8)
11
+ Doing it the other way round right-pads, and a padding bug measurably hurt
12
+ official v3.1 before it was fixed in v3.2.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from functools import lru_cache
17
+ from pathlib import Path
18
+
19
+ import numpy as np
20
+
21
+ from .config import MAX_AUDIO_S, N_FRAMES, N_MELS, SAMPLE_RATE
22
+
23
+ MAX_SAMPLES = MAX_AUDIO_S * SAMPLE_RATE
24
+
25
+
26
+ def truncate_or_leftpad(audio: np.ndarray, n_seconds: int = MAX_AUDIO_S,
27
+ sample_rate: int = SAMPLE_RATE) -> np.ndarray:
28
+ """Keep the last `n_seconds`, or left-pad with zeros up to it.
29
+
30
+ Verbatim behaviour of pipecat's `truncate_audio_to_last_n_seconds`.
31
+ Left padding is deliberate: the decision lives at the END of the clip, so
32
+ the terminal contour must always land at the same position in the window.
33
+ """
34
+ max_samples = n_seconds * sample_rate
35
+ if len(audio) > max_samples:
36
+ return audio[-max_samples:]
37
+ if len(audio) < max_samples:
38
+ return np.pad(audio, (max_samples - len(audio), 0),
39
+ mode="constant", constant_values=0)
40
+ return audio
41
+
42
+
43
+ @lru_cache(maxsize=1)
44
+ def get_feature_extractor():
45
+ from transformers import WhisperFeatureExtractor
46
+ return WhisperFeatureExtractor(chunk_length=MAX_AUDIO_S)
47
+
48
+
49
+ def extract_features(audio: np.ndarray, batched: bool = True) -> np.ndarray:
50
+ """float32 waveform at 16 kHz -> log-mel (1, 80, 800) float32.
51
+
52
+ `batched=False` returns (80, 800), for writing into a feature cache.
53
+ """
54
+ audio = np.asarray(audio, dtype=np.float32)
55
+ audio = truncate_or_leftpad(audio)
56
+ inputs = get_feature_extractor()(
57
+ audio,
58
+ sampling_rate=SAMPLE_RATE,
59
+ return_tensors="np",
60
+ padding="max_length",
61
+ max_length=MAX_SAMPLES,
62
+ truncation=True,
63
+ do_normalize=True,
64
+ )
65
+ feats = inputs.input_features.squeeze(0).astype(np.float32)
66
+ return feats[None, ...] if batched else feats
67
+
68
+
69
+ def load_audio(path: str | Path, sr: int = SAMPLE_RATE) -> np.ndarray:
70
+ """Load any file to mono float32 at `sr`, peak-normalised into [-1, 1].
71
+
72
+ Manifests store repo-relative paths so they survive the trip to Kaggle and
73
+ the Hub. Resolve against the repo root as a fallback so callers work
74
+ regardless of the current working directory.
75
+ """
76
+ import librosa
77
+ p = Path(path)
78
+ if not p.exists() and not p.is_absolute():
79
+ from .config import ROOT
80
+ if (ROOT / p).exists():
81
+ p = ROOT / p
82
+ audio, _ = librosa.load(str(p), sr=sr, mono=True)
83
+ audio = audio.astype(np.float32)
84
+ peak = float(np.max(np.abs(audio))) if audio.size else 0.0
85
+ if peak > 1.0:
86
+ audio = audio / peak
87
+ return audio
88
+
89
+
90
+ def decode_bytes(raw: bytes, sr: int = SAMPLE_RATE) -> np.ndarray:
91
+ """Decode an in-memory audio blob (FLAC/WAV from HF parquet) to mono 16 kHz."""
92
+ import io
93
+
94
+ import librosa
95
+ import soundfile as sf
96
+ data, src_sr = sf.read(io.BytesIO(raw), dtype="float32", always_2d=False)
97
+ if data.ndim > 1:
98
+ data = data.mean(axis=1)
99
+ if src_sr != sr:
100
+ data = librosa.resample(data, orig_sr=src_sr, target_sr=sr)
101
+ return np.ascontiguousarray(data, dtype=np.float32)
102
+
103
+
104
+ def build_ort_session(onnx_path: str | Path):
105
+ """ONNX Runtime session with pipecat's exact options.
106
+
107
+ These options are part of the latency claim -- benchmarking under different
108
+ ones would not be comparable to their published 12 ms.
109
+ """
110
+ import onnxruntime as ort
111
+ so = ort.SessionOptions()
112
+ so.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
113
+ so.inter_op_num_threads = 1
114
+ so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
115
+ return ort.InferenceSession(str(onnx_path), sess_options=so,
116
+ providers=["CPUExecutionProvider"])
117
+
118
+
119
+ def build_fast_session(onnx_path: str | Path):
120
+ """Default threading -- for BULK SCORING ONLY.
121
+
122
+ `build_ort_session` deliberately pins ORT_SEQUENTIAL and one inter-op thread
123
+ because those options ARE Pipecat's published latency contract; benchmarking
124
+ under anything else would not be comparable. Accuracy scoring has no such
125
+ contract, so running thousands of clips single-threaded is pure waste.
126
+
127
+ Never use this for latency_bench.
128
+ """
129
+ import onnxruntime as ort
130
+ so = ort.SessionOptions()
131
+ so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
132
+ return ort.InferenceSession(str(onnx_path), sess_options=so,
133
+ providers=["CPUExecutionProvider"])
134
+
135
+
136
+ def ort_predict_batch(session, feats: np.ndarray) -> np.ndarray:
137
+ """(B, 80, 800) -> (B,) probabilities. Uses the graph's dynamic batch axis."""
138
+ out = session.run(None, {"input_features": feats.astype(np.float32)})
139
+ return np.asarray(out[0]).reshape(-1)
140
+
141
+
142
+ def ort_predict(session, feats: np.ndarray) -> float:
143
+ """Run a session on (1, 80, 800) features. Output is ALREADY sigmoided."""
144
+ out = session.run(None, {"input_features": feats.astype(np.float32)})
145
+ return float(np.asarray(out[0]).reshape(-1)[0])
146
+
147
+
148
+ def self_test() -> None:
149
+ """Contract test. Cheap, and it catches the highest-risk bug in the project."""
150
+ short = np.ones(SAMPLE_RATE, dtype=np.float32) * 0.5 # 1 s
151
+ padded = truncate_or_leftpad(short)
152
+ assert padded.shape == (MAX_SAMPLES,), padded.shape
153
+ assert np.all(padded[: MAX_SAMPLES - SAMPLE_RATE] == 0), "must pad on the LEFT"
154
+ assert np.all(padded[MAX_SAMPLES - SAMPLE_RATE:] == 0.5), "signal must land at the END"
155
+
156
+ long = np.arange(12 * SAMPLE_RATE, dtype=np.float32) # 12 s
157
+ kept = truncate_or_leftpad(long)
158
+ assert kept.shape == (MAX_SAMPLES,)
159
+ assert kept[-1] == long[-1], "must keep the LAST 8 s, not the first"
160
+
161
+ feats = extract_features(short)
162
+ assert feats.shape == (1, N_MELS, N_FRAMES), feats.shape
163
+ assert feats.dtype == np.float32
164
+ assert np.isfinite(feats).all()
165
+ print(f"audio_utils self-test OK -> {feats.shape} {feats.dtype}")
166
+
167
+
168
+ if __name__ == "__main__":
169
+ self_test()
src/config.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Single source of truth for the whole project.
2
+
3
+ Every path, seed, budget and hyperparameter lives here. Nothing downstream
4
+ hardcodes a constant; if a number matters, it is in this file.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ from pathlib import Path
10
+
11
+ from dotenv import load_dotenv
12
+
13
+ ROOT = Path(__file__).resolve().parent.parent
14
+ load_dotenv(ROOT / ".env")
15
+
16
+ # ---------------------------------------------------------------- paths
17
+ DATA = ROOT / "data"
18
+ RAW = DATA / "raw"
19
+ TEXT_DIR = DATA / "hinglish_text"
20
+ AUDIO_DIR = DATA / "hinglish_audio"
21
+ TRAIN_DIR = DATA / "hinglish_train"
22
+ EVAL_DIR = DATA / "eval_human"
23
+ NOISE_DIR = DATA / "noise"
24
+ MODELS = ROOT / "models"
25
+ RESULTS = ROOT / "results"
26
+ EDA_DIR = RESULTS / "eda"
27
+ FIG_DIR = RESULTS / "figures"
28
+ NOTES = ROOT / "notes.md"
29
+
30
+ for _d in (RAW, TEXT_DIR, AUDIO_DIR, TRAIN_DIR, EVAL_DIR, NOISE_DIR,
31
+ MODELS, RESULTS, EDA_DIR, FIG_DIR):
32
+ _d.mkdir(parents=True, exist_ok=True)
33
+
34
+
35
+ def rel(p) -> str:
36
+ """Repo-relative POSIX path, for anything written into a manifest.
37
+
38
+ Manifests travel to Kaggle and to the HF Hub, so an absolute Windows path
39
+ with backslashes is useless on the other end. Everything that records a
40
+ clip location goes through this.
41
+ """
42
+ p = Path(p).resolve()
43
+ try:
44
+ return p.relative_to(ROOT).as_posix()
45
+ except ValueError:
46
+ return p.as_posix()
47
+
48
+
49
+ UTTERANCES = TEXT_DIR / "utterances.jsonl"
50
+ TRAIN_MANIFEST = TRAIN_DIR / "manifest.jsonl"
51
+ EVAL_MANIFEST = EVAL_DIR / "manifest.jsonl"
52
+ EVAL_FROZEN = EVAL_DIR / "FROZEN.json"
53
+ TEST_SUBSET = RAW / "test_subset"
54
+
55
+ # ---------------------------------------------------------------- audio contract
56
+ # These four numbers ARE the Pipecat contract. Changing any of them silently
57
+ # invalidates every comparison against stock Smart Turn v3.
58
+ SAMPLE_RATE = 16_000
59
+ MAX_AUDIO_S = 8
60
+ N_MELS = 80
61
+ N_FRAMES = 800 # 8 s * 100 frames/s
62
+ ENCODER_POSITIONS = 400 # N_FRAMES // 2 (Whisper conv stride 2)
63
+
64
+ SEED = 42
65
+
66
+ # ---------------------------------------------------------------- upstream ids
67
+ STOCK_REPO = "pipecat-ai/smart-turn-v3"
68
+ STOCK_INT8 = "smart-turn-v3.2-cpu.onnx"
69
+ STOCK_FP32 = "smart-turn-v3.2-gpu.onnx"
70
+ BASE_MODEL = "openai/whisper-tiny"
71
+ DS_TRAIN = "pipecat-ai/smart-turn-data-v3.2-train"
72
+ DS_TEST = "pipecat-ai/smart-turn-data-v3.2-test"
73
+ # Overridable from the environment so the Kaggle notebook can change them for a
74
+ # subprocess (`!python -m src.prepare`) -- assigning to the module in the parent
75
+ # process would have no effect there.
76
+ N_TRAIN_SHARDS = int(os.getenv("N_TRAIN_SHARDS", "20")) # of 83; ~65k rows, ~10 GB
77
+ N_TOTAL_SHARDS = 83
78
+
79
+ # ---------------------------------------------------------------- eval subset
80
+ TEST_SUBSET_PER_LANG = 500 # cap per language
81
+ TEST_SUBSET_TARGET = 6000 # ~ +/-1.3% at 95% CI
82
+
83
+ # ---------------------------------------------------------------- budgets
84
+ SARVAM_CHAR_LIMIT = 70_000 # HARD stop (spec: 83k affordable, stop at 70k)
85
+ SARVAM_URL = "https://api.sarvam.ai/text-to-speech"
86
+ SARVAM_MODEL = "bulbul:v3"
87
+ SARVAM_VOICES = [
88
+ "shubh", "aditya", "ritu", "priya", "neha", "rahul", "pooja", "rohan",
89
+ "simran", "kavya", "amit", "dev", "ishita", "shreya", "ratan", "varun",
90
+ "manan", "sumit", "roopa", "kabir", "aayan", "ashutosh", "advait", "anand",
91
+ "tanya", "tarun", "sunny", "mani", "gokul", "vijay", "shruti", "suhani",
92
+ "mohit", "kavitha", "rehan", "soham", "rupali",
93
+ ]
94
+ SARVAM_KEY = os.getenv("SARVAM_API_KEY", "")
95
+
96
+ OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
97
+ # deepseek-chat-v3-0324 is the only DeepSeek on OpenRouter with NO reasoning
98
+ # capability at all -- its supported_parameters list has no `reasoning`,
99
+ # `reasoning_effort` or `include_reasoning`. That matters: every v4/v3.2 variant
100
+ # is a hybrid reasoner, and `reasoning={"exclude": True}` only HIDES the thinking,
101
+ # it does not stop it. The model burns the whole completion budget thinking and
102
+ # returns content=null with finish_reason=length. A model that cannot think
103
+ # cannot do that to us.
104
+ #
105
+ # Costs ~$0.20 for the full corpus instead of ~$0.03. Irrelevant next to the
106
+ # wall clock; buy the reliability.
107
+ OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "deepseek/deepseek-chat-v3-0324")
108
+ OPENROUTER_KEY = os.getenv("OPENROUTER_API_KEY", "")
109
+
110
+ HF_USER = os.getenv("HF_USER", "")
111
+ HF_CORPUS_REPO = f"{HF_USER}/hinglish-turn-detection" if HF_USER else ""
112
+ HF_EVAL_REPO = f"{HF_USER}/hinglish-turn-eval" if HF_USER else ""
113
+
114
+ # ---------------------------------------------------------------- G3: filler taxonomy
115
+ # The sharpest differentiator: these two classes sound similar and label OPPOSITE.
116
+ HESITATION_FILLERS = [ # mid-thought -> label 0 (incomplete)
117
+ "matlab", "toh", "woh", "arre", "yaani", "kya bolte hain",
118
+ "iska matlab", "aisa hai ki", "kaise bolun",
119
+ ]
120
+ TAG_PARTICLES = [ # terminal tag -> label 1 (complete)
121
+ "na", "haan", "ji", "achha", "theek hai", "hai na", "bas", "hi na",
122
+ ]
123
+
124
+ REGISTERS = ["hinglish", "indian_english", "hindi_en_nouns"]
125
+ REGISTER_MIX = {"hinglish": 0.50, "indian_english": 0.30, "hindi_en_nouns": 0.20}
126
+ DOMAINS = ["booking", "complaint", "food_delivery", "casual",
127
+ "bank_telecom", "directions"]
128
+
129
+ N_UTTERANCES = 2000
130
+ N_HESITATION = 400
131
+ N_TAG = 300
132
+
133
+ # ---------------------------------------------------------------- text gen
134
+ # Small batches on purpose. Providers cap completion length well below whatever
135
+ # `max_tokens` we request (OpenInference truncated us at ~1,700 tokens), and 25
136
+ # utterances of JSON needs ~2,000+. Asking for 8 keeps every response inside any
137
+ # plausible cap; the extra calls are parallel and cost fractions of a cent.
138
+ GEN_BATCH = 8 # utterances per LLM request
139
+ GEN_WORKERS = 20
140
+ GEN_MAX_TOKENS = 4000
141
+ DUP_JACCARD = 0.80 # char-3-gram near-duplicate threshold
142
+
143
+ # ---------------------------------------------------------------- segmentation
144
+ TRAIL_SILENCE_MS = (200, 400) # appended after every truncation
145
+ DURATION_JS_MAX = 0.15 # class duration histograms must overlap
146
+
147
+ # Word-boundary alignment decides where every label-0 clip gets cut, so it is
148
+ # worth the extra compute. Bulk EDA transcription only needs to be good enough
149
+ # to spot Latin-vs-Devanagari code-switching, and `small` on 2,000 CPU clips
150
+ # costs about an hour we do not have.
151
+ # `base` not `small` for alignment: we only need word BOUNDARIES, not accurate
152
+ # transcription, and every cut lands on a word edge followed by 200-400 ms of
153
+ # appended silence -- so a +/-100 ms timestamp error is absorbed. `small` is
154
+ # ~3x slower on CPU for no benefit we can hear.
155
+ ASR_MODEL = "base" # forced alignment (segment.py)
156
+ EDA_ASR_MODEL = "tiny" # bulk transcription (eda.py)
157
+
158
+ # ---------------------------------------------------------------- augmentation
159
+ AUG_TELEPHONY_P = 0.30 # mu-law 8 kHz round trip (G5)
160
+ AUG_NOISE_P = 0.40 # ambient noise 5-20 dB SNR
161
+ AUG_SNR_DB = (5.0, 20.0)
162
+ AUG_SPEED = (0.95, 1.05)
163
+ AUG_GAIN_DB = (-4.0, 4.0)
164
+
165
+ # ---------------------------------------------------------------- training
166
+ TRAIN = dict(
167
+ encoder=BASE_MODEL,
168
+ lr_encoder=5e-5,
169
+ lr_head=1e-3,
170
+ weight_decay=0.01,
171
+ batch_size=64,
172
+ epochs=3,
173
+ warmup_ratio=0.1,
174
+ early_stop_patience=2,
175
+ monitor="val_hinglish_acc",
176
+ seed=SEED,
177
+ amp=True,
178
+ grad_clip=1.0,
179
+ val_frac=0.1,
180
+ )
181
+ E2_SWEEP = [0.0, 0.05, 0.15, 0.30] # Hinglish fraction of each batch
182
+ BASE_SUBSET_TARGET = int(os.getenv("BASE_SUBSET_TARGET", "40000"))
183
+
184
+ # ---------------------------------------------------------------- evaluation
185
+ FALSE_INTERRUPT_BUDGET = 0.05 # pick threshold holding FI below this
186
+ N_BOOTSTRAP = 2000
187
+ LATENCY_RUNS = 200
188
+
189
+ RUN_NAMES = ["stock", "e1", "e2_a", "e2_b", "e2_c", "e2_d", "e3"]
190
+ QUANT_DELTA_N = 2000 # +/-1.1% at 95% CI; ample for an int8-vs-fp32 delta
191
+ MAX_QUANT_LOSS_PP = 1.5 # int8 must stay within this of fp32, else ship fp32