pmarmaroli commited on
Commit
140caa0
·
1 Parent(s): b956458

Stuttering classification demo: Wav2Vec2-XLSR-53 heatmap + donut + timeline

Browse files
Files changed (3) hide show
  1. README.md +58 -5
  2. app.py +732 -0
  3. requirements.txt +7 -0
README.md CHANGED
@@ -1,12 +1,65 @@
1
  ---
2
  title: Stuttering Classification
3
- emoji: 💻
4
- colorFrom: yellow
5
- colorTo: yellow
6
  sdk: gradio
7
- sdk_version: 6.12.0
8
  app_file: app.py
9
  pinned: false
 
 
 
 
 
 
 
 
 
 
 
 
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Stuttering Classification
3
+ emoji: 🗣️
4
+ colorFrom: purple
5
+ colorTo: pink
6
  sdk: gradio
7
+ sdk_version: 5.16.0
8
  app_file: app.py
9
  pinned: false
10
+ license: apache-2.0
11
+ models:
12
+ - vocametrix/wav2vec2-xlsr-53-stuttering-classification
13
+ datasets:
14
+ - SEP-28k
15
+ - SEP-28k-Extended
16
+ tags:
17
+ - speech
18
+ - stuttering
19
+ - wav2vec2
20
+ - audio-classification
21
+ - disfluency
22
  ---
23
 
24
+ # Stuttering Classification Demo
25
+
26
+ Interactive demo for [vocametrix/wav2vec2-xlsr-53-stuttering-classification](https://huggingface.co/vocametrix/wav2vec2-xlsr-53-stuttering-classification) — a Wav2Vec2-XLSR-53 model fine-tuned end-to-end on [SEP-28k-Extended](https://arxiv.org/abs/2206.14568) for 6-class stuttering type detection.
27
+
28
+ ## Features
29
+
30
+ - **Upload or record** a speech audio sample
31
+ - **Configurable chunking**: choose chunk duration (2–6s) and overlap (0–75%)
32
+ - **Visual heatmap**: color-coded grid of chunks
33
+ - **Donut chart**: distribution of stuttering types
34
+ - **Timeline**: temporal view of classification results
35
+ - **Detailed table**: per-chunk label and confidence
36
+
37
+ ## Classes
38
+
39
+ | Label | Description |
40
+ |-------|-------------|
41
+ | Fluent | No disfluency detected |
42
+ | Block | Silent or audible block in speech flow |
43
+ | Prolongation | Prolonged sounds (e.g., "ssssnake") |
44
+ | Word Repetition | Repetition of whole words (e.g., "I-I-I want") |
45
+ | Sound Repetition | Repetition of individual sounds (e.g., "b-b-ball") |
46
+ | Interjection | Filler words or sounds (e.g., "um", "uh") |
47
+
48
+ ## Citation
49
+
50
+ ```bibtex
51
+ @article{sheikh2026speechtherapyagent,
52
+ title={SpeechTherapyAgent: A Clinician-in-the-Loop AI Virtual Speech Therapist for Personalized and Supervised Therapy},
53
+ author={Sheikh, Shakeel A. and Marmaroli, Patrick and Sahidullah, Md and Ouni, Slim and Hirsch, Fabrice and Leal, Gon\c{c}alo and Schuller, Bj\"orn W.},
54
+ journal={Under review},
55
+ year={2026}
56
+ }
57
+ ```
58
+
59
+ ## Links
60
+
61
+ - [Model Card](https://huggingface.co/vocametrix/wav2vec2-xlsr-53-stuttering-classification)
62
+ - [Vocametrix](https://www.vocametrix.com)
63
+ - [Live Platform Demo](https://vocametrix.com/ai/stuttering-therapy-planning-agent)
64
+
65
+ Built by [Vocametrix OÜ](https://vocametrix.com) 🇪🇪
app.py ADDED
@@ -0,0 +1,732 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Stuttering Classification Demo
3
+ Wav2Vec2-XLSR-53 Fine-Tuned for Stuttering Detection
4
+ Built by Vocametrix — vocametrix.com
5
+ """
6
+
7
+ import gradio as gr
8
+
9
+ # ── Monkey-patch: fix gradio_client crash when JSON Schema uses boolean values ──
10
+ import gradio_client.utils as _gc_utils
11
+
12
+ _orig_json_schema_fn = _gc_utils._json_schema_to_python_type
13
+
14
+
15
+ def _patched_json_schema_fn(schema, defs):
16
+ if isinstance(schema, bool):
17
+ return "Any" if schema else "None"
18
+ return _orig_json_schema_fn(schema, defs)
19
+
20
+
21
+ _gc_utils._json_schema_to_python_type = _patched_json_schema_fn
22
+ # ── End monkey-patch ──
23
+
24
+ import numpy as np
25
+ import librosa
26
+ import torch
27
+ import torch.nn.functional as F
28
+ from transformers import AutoModelForAudioClassification, AutoFeatureExtractor
29
+
30
+ # ═══════════════════════════════════════════════════════════════
31
+ # CONFIGURATION
32
+ # ═══════════════════════════════════════════════════════════════
33
+
34
+ MODEL_NAME = "vocametrix/wav2vec2-xlsr-53-stuttering-classification"
35
+ SAMPLE_RATE = 16000
36
+ DEFAULT_CHUNK_DURATION = 4.0 # seconds
37
+ DEFAULT_OVERLAP = 0.50 # 50%
38
+
39
+ LABEL_COLORS = {
40
+ "fluent": "#10B981",
41
+ "block": "#EF4444",
42
+ "prolongation": "#F59E0B",
43
+ "Wordrepetition": "#8B5CF6",
44
+ "Soundrepetition": "#EC4899",
45
+ "interjection": "#3B82F6",
46
+ }
47
+
48
+ LABEL_DISPLAY = {
49
+ "fluent": "Fluent",
50
+ "block": "Block",
51
+ "prolongation": "Prolongation",
52
+ "Wordrepetition": "Word Repetition",
53
+ "Soundrepetition": "Sound Repetition",
54
+ "interjection": "Interjection",
55
+ }
56
+
57
+ LABEL_DESCRIPTIONS = {
58
+ "fluent": "Fluent speech — no disfluency detected",
59
+ "block": "Silent or audible block in speech flow",
60
+ "prolongation": 'Prolonged sounds (e.g., "ssssnake")',
61
+ "Wordrepetition": 'Repetition of whole words (e.g., "I-I-I want")',
62
+ "Soundrepetition": 'Repetition of individual sounds (e.g., "b-b-ball")',
63
+ "interjection": 'Filler words or sounds (e.g., "um", "uh")',
64
+ }
65
+
66
+ FALLBACK_COLOR = "#9CA3AF"
67
+
68
+ # ═══════════════════════════════════════════════════════════════
69
+ # LOAD MODEL (cached at startup)
70
+ # ═══════════════════════════════════════════════════════════════
71
+
72
+ print("Loading model from Hugging Face Hub...")
73
+ feature_extractor = AutoFeatureExtractor.from_pretrained(MODEL_NAME)
74
+ model = AutoModelForAudioClassification.from_pretrained(MODEL_NAME)
75
+ model.eval()
76
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
77
+ model.to(device)
78
+ print(f"Model loaded on {device}")
79
+
80
+
81
+ # ═══════════════════════════════════════════════════════════════
82
+ # INFERENCE
83
+ # ═══════════════════════════════════════════════════════════════
84
+
85
+
86
+ def classify_chunk(audio_chunk, sr=SAMPLE_RATE):
87
+ """Classify a single audio chunk. Returns (label, confidence, all_probs)."""
88
+ inputs = feature_extractor(
89
+ audio_chunk, sampling_rate=sr, return_tensors="pt", padding=True
90
+ )
91
+ inputs = {k: v.to(device) for k, v in inputs.items()}
92
+
93
+ with torch.no_grad():
94
+ logits = model(**inputs).logits
95
+ probs = F.softmax(logits, dim=-1)
96
+ pred_id = torch.argmax(probs, dim=-1).item()
97
+ confidence = probs[0][pred_id].item()
98
+ label = model.config.id2label[pred_id]
99
+
100
+ all_probs = {
101
+ model.config.id2label[i]: float(probs[0][i]) for i in range(probs.shape[1])
102
+ }
103
+ return label, confidence, all_probs
104
+
105
+
106
+ def chunked_classification(audio_data, sr, chunk_duration, overlap_pct):
107
+ """Split audio into overlapping chunks and classify each."""
108
+ chunk_samples = int(chunk_duration * sr)
109
+ step_samples = int(chunk_samples * (1 - overlap_pct / 100))
110
+
111
+ if step_samples < 1:
112
+ step_samples = chunk_samples
113
+
114
+ results = []
115
+ pos = 0
116
+ while pos < len(audio_data):
117
+ end = min(pos + chunk_samples, len(audio_data))
118
+ chunk = audio_data[pos:end]
119
+
120
+ # Pad short final chunk
121
+ if len(chunk) < chunk_samples:
122
+ chunk = np.pad(chunk, (0, chunk_samples - len(chunk)))
123
+
124
+ label, confidence, all_probs = classify_chunk(chunk, sr)
125
+
126
+ results.append(
127
+ {
128
+ "start": pos / sr,
129
+ "end": min(end, len(audio_data)) / sr,
130
+ "label": label,
131
+ "confidence": confidence,
132
+ "probs": all_probs,
133
+ }
134
+ )
135
+ pos += step_samples
136
+
137
+ return results
138
+
139
+
140
+ # ═══════════════════════════════════════════════════════════════
141
+ # VISUALIZATION (HTML/SVG)
142
+ # ═══════════════════════════════════════════════════════════════
143
+
144
+
145
+ def generate_results_html(results, total_duration):
146
+ """Generate the full results visualization as HTML."""
147
+ if not results:
148
+ return '<div style="text-align:center; color:#9ca3af; padding:40px;">No results</div>'
149
+
150
+ # ── Summary statistics ──
151
+ label_counts = {}
152
+ total_confidence = 0
153
+ for r in results:
154
+ lbl = r["label"]
155
+ label_counts[lbl] = label_counts.get(lbl, 0) + 1
156
+ total_confidence += r["confidence"]
157
+
158
+ n = len(results)
159
+ avg_confidence = total_confidence / n if n > 0 else 0
160
+
161
+ fluent_count = label_counts.get("fluent", 0)
162
+ disfluent_count = n - fluent_count
163
+ stuttering_pct = (disfluent_count / n * 100) if n > 0 else 0
164
+
165
+ # ── Summary cards ──
166
+ summary_html = f"""
167
+ <div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 24px;">
168
+ <div style="background: linear-gradient(135deg, #f0fdf4, #dcfce7); border: 1px solid #86efac; border-radius: 12px; padding: 16px; text-align: center;">
169
+ <div style="font-size: 2rem; font-weight: 800; color: #16a34a;">{n}</div>
170
+ <div style="font-size: 0.8rem; color: #4b5563; font-weight: 600;">Total Chunks</div>
171
+ </div>
172
+ <div style="background: linear-gradient(135deg, #faf5ff, #f3e8ff); border: 1px solid #c4b5fd; border-radius: 12px; padding: 16px; text-align: center;">
173
+ <div style="font-size: 2rem; font-weight: 800; color: #7c3aed;">{avg_confidence:.0%}</div>
174
+ <div style="font-size: 0.8rem; color: #4b5563; font-weight: 600;">Avg Confidence</div>
175
+ </div>
176
+ <div style="background: linear-gradient(135deg, #fef2f2, #fee2e2); border: 1px solid #fca5a5; border-radius: 12px; padding: 16px; text-align: center;">
177
+ <div style="font-size: 2rem; font-weight: 800; color: #dc2626;">{stuttering_pct:.0f}%</div>
178
+ <div style="font-size: 0.8rem; color: #4b5563; font-weight: 600;">Disfluent</div>
179
+ </div>
180
+ <div style="background: linear-gradient(135deg, #eff6ff, #dbeafe); border: 1px solid #93c5fd; border-radius: 12px; padding: 16px; text-align: center;">
181
+ <div style="font-size: 2rem; font-weight: 800; color: #2563eb;">{total_duration:.1f}s</div>
182
+ <div style="font-size: 0.8rem; color: #4b5563; font-weight: 600;">Duration</div>
183
+ </div>
184
+ </div>
185
+ """
186
+
187
+ # ── Color legend ──
188
+ present_labels = sorted(set(r["label"] for r in results))
189
+ legend_items = []
190
+ for lbl in present_labels:
191
+ color = LABEL_COLORS.get(lbl, FALLBACK_COLOR)
192
+ display = LABEL_DISPLAY.get(lbl, lbl)
193
+ count = label_counts.get(lbl, 0)
194
+ legend_items.append(
195
+ f'<div style="display:flex; align-items:center; gap:6px;">'
196
+ f'<div style="width:14px; height:14px; border-radius:4px; background:{color};"></div>'
197
+ f'<span style="font-size:0.8rem; font-weight:600; color:#374151;">{display}</span>'
198
+ f'<span style="font-size:0.75rem; color:#6b7280;">({count})</span>'
199
+ f"</div>"
200
+ )
201
+
202
+ legend_html = (
203
+ f'<div style="display:flex; flex-wrap:wrap; gap:16px; margin-bottom:16px; '
204
+ f'padding:12px 16px; background:#f9fafb; border-radius:10px; border:1px solid #e5e7eb;">'
205
+ f'{"".join(legend_items)}</div>'
206
+ )
207
+
208
+ # ── Chunk heatmap grid ──
209
+ cols = min(len(results), 15)
210
+ chunks_html_items = []
211
+ for i, r in enumerate(results):
212
+ color = LABEL_COLORS.get(r["label"], FALLBACK_COLOR)
213
+ display = LABEL_DISPLAY.get(r["label"], r["label"])
214
+ desc = LABEL_DESCRIPTIONS.get(r["label"], "")
215
+ opacity = 0.5 + (r["confidence"]) * 0.5
216
+
217
+ # Build probability bars for tooltip
218
+ prob_bars = ""
219
+ sorted_probs = sorted(r["probs"].items(), key=lambda x: -x[1])
220
+ for lbl_name, prob in sorted_probs[:3]:
221
+ bar_color = LABEL_COLORS.get(lbl_name, FALLBACK_COLOR)
222
+ bar_display = LABEL_DISPLAY.get(lbl_name, lbl_name)
223
+ bar_width = max(2, prob * 100)
224
+ prob_bars += (
225
+ f'<div style="display:flex; align-items:center; gap:4px; font-size:0.7rem;">'
226
+ f'<span style="width:80px; color:#6b7280; text-align:right;">{bar_display}</span>'
227
+ f'<div style="flex:1; background:#e5e7eb; border-radius:3px; height:8px; min-width:60px;">'
228
+ f'<div style="width:{bar_width}%; background:{bar_color}; height:100%; border-radius:3px;"></div>'
229
+ f"</div>"
230
+ f'<span style="width:35px; color:#374151; font-weight:600;">{prob:.0%}</span>'
231
+ f"</div>"
232
+ )
233
+
234
+ tooltip_html = (
235
+ f'<div class="chunk-tooltip">'
236
+ f'<div style="display:flex; align-items:center; gap:6px; margin-bottom:6px;">'
237
+ f'<div style="width:10px; height:10px; border-radius:3px; background:{color};"></div>'
238
+ f'<span style="font-weight:700; color:#111827;">{display}</span>'
239
+ f'<span style="color:#6b7280; font-size:0.75rem;">{r["confidence"]:.0%}</span>'
240
+ f"</div>"
241
+ f'<div style="font-size:0.7rem; color:#6b7280; margin-bottom:6px;">'
242
+ f'{r["start"]:.1f}s — {r["end"]:.1f}s</div>'
243
+ f'<div style="font-size:0.7rem; color:#9ca3af; font-style:italic; margin-bottom:8px;">{desc}</div>'
244
+ f"{prob_bars}"
245
+ f"</div>"
246
+ )
247
+
248
+ chunks_html_items.append(
249
+ f'<div class="chunk-cell" style="position:relative;">'
250
+ f'<div style="aspect-ratio:1; border-radius:6px; background:{color}; opacity:{opacity}; '
251
+ f"cursor:pointer; transition:all 0.15s ease; border:2px solid transparent; "
252
+ f'display:flex; align-items:center; justify-content:center; font-size:0.65rem; '
253
+ f'color:white; font-weight:700; text-shadow:0 1px 2px rgba(0,0,0,0.3);" '
254
+ f'class="chunk-square" '
255
+ f'title="{display} ({r["confidence"]:.0%}) — {r["start"]:.1f}s-{r["end"]:.1f}s">'
256
+ f"{i + 1}"
257
+ f"</div>"
258
+ f"{tooltip_html}"
259
+ f"</div>"
260
+ )
261
+
262
+ heatmap_html = (
263
+ f'<div style="display:grid; grid-template-columns:repeat({cols}, minmax(0,1fr)); gap:6px;">'
264
+ f'{"".join(chunks_html_items)}'
265
+ f"</div>"
266
+ )
267
+
268
+ # ── Donut chart ──
269
+ donut_html = generate_donut_html(label_counts, n)
270
+
271
+ # ── Timeline bar ──
272
+ timeline_html = generate_timeline_html(results, total_duration)
273
+
274
+ # ── Detailed table ──
275
+ table_rows = ""
276
+ for i, r in enumerate(results):
277
+ color = LABEL_COLORS.get(r["label"], FALLBACK_COLOR)
278
+ display = LABEL_DISPLAY.get(r["label"], r["label"])
279
+ conf_bar_width = r["confidence"] * 100
280
+ table_rows += (
281
+ f"<tr>"
282
+ f'<td style="padding:8px 12px; font-size:0.8rem; color:#6b7280; text-align:center;">{i + 1}</td>'
283
+ f'<td style="padding:8px 12px; font-size:0.8rem; color:#6b7280; font-family:monospace;">'
284
+ f'{r["start"]:.1f}s — {r["end"]:.1f}s</td>'
285
+ f'<td style="padding:8px 12px;">'
286
+ f'<div style="display:flex; align-items:center; gap:6px;">'
287
+ f'<div style="width:10px; height:10px; border-radius:3px; background:{color};"></div>'
288
+ f'<span style="font-size:0.8rem; font-weight:600; color:#111827;">{display}</span>'
289
+ f"</div></td>"
290
+ f'<td style="padding:8px 12px;">'
291
+ f'<div style="display:flex; align-items:center; gap:6px;">'
292
+ f'<div style="width:60px; background:#e5e7eb; border-radius:3px; height:6px;">'
293
+ f'<div style="width:{conf_bar_width}%; background:{color}; height:100%; border-radius:3px;"></div>'
294
+ f"</div>"
295
+ f'<span style="font-size:0.8rem; font-weight:600; color:#374151;">{r["confidence"]:.0%}</span>'
296
+ f"</div></td>"
297
+ f"</tr>"
298
+ )
299
+
300
+ table_html = (
301
+ f'<details style="margin-top:20px;">'
302
+ f'<summary style="cursor:pointer; font-weight:700; color:#7c3aed; font-size:0.9rem; padding:8px 0;">'
303
+ f"📋 Detailed Chunk Table ({n} chunks)</summary>"
304
+ f'<div style="overflow-x:auto; margin-top:8px;">'
305
+ f'<table style="width:100%; border-collapse:collapse; border:1px solid #e5e7eb; border-radius:8px; overflow:hidden;">'
306
+ f'<thead><tr style="background:#f9fafb;">'
307
+ f'<th style="padding:10px 12px; text-align:center; font-size:0.75rem; color:#6b7280; font-weight:600; text-transform:uppercase;">#</th>'
308
+ f'<th style="padding:10px 12px; text-align:left; font-size:0.75rem; color:#6b7280; font-weight:600; text-transform:uppercase;">Time Range</th>'
309
+ f'<th style="padding:10px 12px; text-align:left; font-size:0.75rem; color:#6b7280; font-weight:600; text-transform:uppercase;">Label</th>'
310
+ f'<th style="padding:10px 12px; text-align:left; font-size:0.75rem; color:#6b7280; font-weight:600; text-transform:uppercase;">Confidence</th>'
311
+ f"</tr></thead>"
312
+ f"<tbody>{table_rows}</tbody>"
313
+ f"</table></div></details>"
314
+ )
315
+
316
+ # ── Assemble ──
317
+ full_html = f"""
318
+ <div style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;">
319
+ {summary_html}
320
+
321
+ <div style="display:grid; grid-template-columns: 1fr 240px; gap:20px; margin-bottom:20px; align-items:start;">
322
+ <div>
323
+ <h3 style="font-size:1rem; font-weight:700; color:#111827; margin-bottom:12px;">
324
+ 🧱 Classification Heatmap
325
+ </h3>
326
+ {legend_html}
327
+ {heatmap_html}
328
+ </div>
329
+ <div>
330
+ <h3 style="font-size:1rem; font-weight:700; color:#111827; margin-bottom:12px;">
331
+ 📊 Distribution
332
+ </h3>
333
+ {donut_html}
334
+ </div>
335
+ </div>
336
+
337
+ <h3 style="font-size:1rem; font-weight:700; color:#111827; margin-bottom:12px;">
338
+ 📈 Timeline
339
+ </h3>
340
+ {timeline_html}
341
+
342
+ {table_html}
343
+ </div>
344
+ """
345
+ return full_html
346
+
347
+
348
+ def generate_donut_html(label_counts, total):
349
+ """Generate an SVG donut chart."""
350
+ size = 200
351
+ cx, cy = size // 2, size // 2
352
+ outer_r = 80
353
+ inner_r = 50
354
+
355
+ if total == 0:
356
+ return ""
357
+
358
+ # Sort labels by count descending
359
+ sorted_labels = sorted(label_counts.items(), key=lambda x: -x[1])
360
+
361
+ slices_svg = ""
362
+ angle_offset = -90 # start from top
363
+
364
+ for lbl, count in sorted_labels:
365
+ pct = count / total
366
+ if pct <= 0:
367
+ continue
368
+
369
+ color = LABEL_COLORS.get(lbl, FALLBACK_COLOR)
370
+ angle_span = pct * 360
371
+
372
+ start_angle = angle_offset
373
+ end_angle = angle_offset + angle_span
374
+
375
+ # Convert to radians
376
+ sa = np.radians(start_angle)
377
+ ea = np.radians(end_angle)
378
+
379
+ # Outer arc
380
+ x1_o = cx + outer_r * np.cos(sa)
381
+ y1_o = cy + outer_r * np.sin(sa)
382
+ x2_o = cx + outer_r * np.cos(ea)
383
+ y2_o = cy + outer_r * np.sin(ea)
384
+
385
+ # Inner arc
386
+ x1_i = cx + inner_r * np.cos(ea)
387
+ y1_i = cy + inner_r * np.sin(ea)
388
+ x2_i = cx + inner_r * np.cos(sa)
389
+ y2_i = cy + inner_r * np.sin(sa)
390
+
391
+ large_arc = 1 if angle_span > 180 else 0
392
+
393
+ path = (
394
+ f"M {x1_o},{y1_o} "
395
+ f"A {outer_r},{outer_r} 0 {large_arc} 1 {x2_o},{y2_o} "
396
+ f"L {x1_i},{y1_i} "
397
+ f"A {inner_r},{inner_r} 0 {large_arc} 0 {x2_i},{y2_i} Z"
398
+ )
399
+
400
+ slices_svg += f'<path d="{path}" fill="{color}" stroke="white" stroke-width="2" />'
401
+ angle_offset = end_angle
402
+
403
+ # Center text
404
+ center_svg = (
405
+ f'<text x="{cx}" y="{cy - 6}" text-anchor="middle" fill="#111827" '
406
+ f'font-size="24" font-weight="800" font-family="Inter, sans-serif">{total}</text>'
407
+ f'<text x="{cx}" y="{cy + 14}" text-anchor="middle" fill="#6b7280" '
408
+ f'font-size="10" font-weight="500" font-family="Inter, sans-serif">chunks</text>'
409
+ )
410
+
411
+ svg = (
412
+ f'<svg width="{size}" height="{size}" viewBox="0 0 {size} {size}" '
413
+ f'style="display:block; margin:0 auto;">'
414
+ f"{slices_svg}{center_svg}</svg>"
415
+ )
416
+
417
+ # Legend below donut
418
+ legend_items = ""
419
+ for lbl, count in sorted_labels:
420
+ color = LABEL_COLORS.get(lbl, FALLBACK_COLOR)
421
+ display = LABEL_DISPLAY.get(lbl, lbl)
422
+ pct = count / total * 100
423
+ legend_items += (
424
+ f'<div style="display:flex; align-items:center; gap:6px; font-size:0.75rem;">'
425
+ f'<div style="width:10px; height:10px; border-radius:3px; background:{color}; flex-shrink:0;"></div>'
426
+ f'<span style="color:#374151; white-space:nowrap;">{display}</span>'
427
+ f'<span style="color:#9ca3af; margin-left:auto;">{count} ({pct:.0f}%)</span>'
428
+ f"</div>"
429
+ )
430
+
431
+ return (
432
+ f'<div style="text-align:center;">{svg}'
433
+ f'<div style="display:flex; flex-direction:column; gap:4px; margin-top:12px;">'
434
+ f"{legend_items}</div></div>"
435
+ )
436
+
437
+
438
+ def generate_timeline_html(results, total_duration):
439
+ """Generate a horizontal timeline bar showing chunk labels."""
440
+ if not results or total_duration <= 0:
441
+ return ""
442
+
443
+ bars = ""
444
+ for r in results:
445
+ color = LABEL_COLORS.get(r["label"], FALLBACK_COLOR)
446
+ display = LABEL_DISPLAY.get(r["label"], r["label"])
447
+ width_pct = (r["end"] - r["start"]) / total_duration * 100
448
+ opacity = 0.5 + r["confidence"] * 0.5
449
+
450
+ start_time = r["start"]
451
+ time_label = "" if width_pct < 5 else f"{start_time:.0f}s"
452
+ bars += (
453
+ f'<div style="width:{width_pct}%; height:32px; background:{color}; opacity:{opacity}; '
454
+ f'display:flex; align-items:center; justify-content:center; font-size:0.6rem; '
455
+ f'color:white; font-weight:700; text-shadow:0 1px 2px rgba(0,0,0,0.3); overflow:hidden; '
456
+ f'white-space:nowrap; min-width:2px;" '
457
+ f'title="{display} ({r["confidence"]:.0%}) — {r["start"]:.1f}s-{r["end"]:.1f}s">'
458
+ f"{time_label}"
459
+ f"</div>"
460
+ )
461
+
462
+ time_labels = ""
463
+ step = max(1, int(total_duration / 10))
464
+ for t in range(0, int(total_duration) + 1, step):
465
+ left_pct = t / total_duration * 100
466
+ time_labels += (
467
+ f'<span style="position:absolute; left:{left_pct}%; transform:translateX(-50%); '
468
+ f'font-size:0.7rem; color:#9ca3af; font-family:monospace;">{t}s</span>'
469
+ )
470
+
471
+ return (
472
+ f'<div style="margin-bottom:20px;">'
473
+ f'<div style="display:flex; border-radius:8px; overflow:hidden; border:1px solid #e5e7eb;">'
474
+ f"{bars}</div>"
475
+ f'<div style="position:relative; height:20px; margin-top:4px;">{time_labels}</div>'
476
+ f"</div>"
477
+ )
478
+
479
+
480
+ # ═══════════════════════════════════════════════════════════════
481
+ # MAIN HANDLER
482
+ # ═══════════════════════════════════════════════════════════════
483
+
484
+
485
+ def process_audio(audio_input, chunk_duration, overlap_pct):
486
+ """Main processing function called by Gradio."""
487
+ if audio_input is None:
488
+ return '<div style="text-align:center; color:#9ca3af; padding:40px; font-size:1.1rem;">Upload or record an audio file to get started.</div>'
489
+
490
+ try:
491
+ # Parse audio input
492
+ if isinstance(audio_input, tuple):
493
+ sr, audio_data = audio_input
494
+ if audio_data is None or len(audio_data) == 0:
495
+ return '<div style="color:#ef4444; padding:20px;">Empty audio data.</div>'
496
+ # Convert to float32
497
+ if audio_data.dtype == np.int16:
498
+ audio_data = audio_data.astype(np.float32) / 32768.0
499
+ elif audio_data.dtype == np.int32:
500
+ audio_data = audio_data.astype(np.float32) / 2147483648.0
501
+ else:
502
+ audio_data = audio_data.astype(np.float32)
503
+ # Mono
504
+ if len(audio_data.shape) > 1:
505
+ audio_data = audio_data.mean(axis=1)
506
+ elif isinstance(audio_input, str):
507
+ audio_data, sr = librosa.load(audio_input, sr=SAMPLE_RATE, mono=True)
508
+ else:
509
+ return '<div style="color:#ef4444; padding:20px;">Unsupported audio format.</div>'
510
+
511
+ # Resample to 16 kHz
512
+ if sr != SAMPLE_RATE:
513
+ audio_data = librosa.resample(audio_data, orig_sr=sr, target_sr=SAMPLE_RATE)
514
+ sr = SAMPLE_RATE
515
+
516
+ total_duration = len(audio_data) / sr
517
+
518
+ if total_duration < 0.5:
519
+ return '<div style="color:#ef4444; padding:20px;">Audio too short. Please provide at least 0.5 seconds.</div>'
520
+
521
+ # Run chunked classification
522
+ results = chunked_classification(audio_data, sr, chunk_duration, overlap_pct)
523
+
524
+ if not results:
525
+ return '<div style="color:#ef4444; padding:20px;">No chunks could be classified.</div>'
526
+
527
+ return generate_results_html(results, total_duration)
528
+
529
+ except Exception as e:
530
+ import traceback
531
+
532
+ traceback.print_exc()
533
+ return f'<div style="color:#ef4444; padding:20px;">Error: {str(e)}</div>'
534
+
535
+
536
+ # ═══════════════════════════════════════════════════════════════
537
+ # GRADIO UI
538
+ # ═══════════════════════════════════════════════════════════════
539
+
540
+ CUSTOM_CSS = """
541
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
542
+
543
+ * { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
544
+
545
+ .gradio-container {
546
+ max-width: 900px !important;
547
+ margin: auto !important;
548
+ background: #ffffff !important;
549
+ }
550
+
551
+ .main-title {
552
+ text-align: center;
553
+ font-size: 2rem;
554
+ font-weight: 800;
555
+ background: linear-gradient(to right, #9333ea, #db2777);
556
+ -webkit-background-clip: text;
557
+ -webkit-text-fill-color: transparent;
558
+ background-clip: text;
559
+ margin-bottom: 0.25rem;
560
+ padding-bottom: 0.25rem;
561
+ }
562
+
563
+ .subtitle {
564
+ text-align: center;
565
+ color: #6b7280;
566
+ font-size: 1rem;
567
+ margin-bottom: 1.5rem;
568
+ }
569
+
570
+ .info-box {
571
+ background: linear-gradient(135deg, #faf5ff, #f3e8ff);
572
+ border: 1px solid #e9d5ff;
573
+ border-radius: 12px;
574
+ padding: 16px 20px;
575
+ margin-bottom: 16px;
576
+ font-size: 0.85rem;
577
+ color: #374151;
578
+ line-height: 1.6;
579
+ }
580
+
581
+ .info-box a {
582
+ color: #7e22ce;
583
+ text-decoration: none;
584
+ font-weight: 600;
585
+ }
586
+
587
+ .info-box a:hover {
588
+ text-decoration: underline;
589
+ }
590
+
591
+ .footer {
592
+ text-align: center;
593
+ padding: 16px;
594
+ color: #9ca3af;
595
+ font-size: 0.8rem;
596
+ border-top: 1px solid #e5e7eb;
597
+ margin-top: 24px;
598
+ }
599
+
600
+ .footer a {
601
+ color: #9333ea;
602
+ text-decoration: none;
603
+ font-weight: 500;
604
+ }
605
+
606
+ .footer a:hover {
607
+ text-decoration: underline;
608
+ }
609
+
610
+ footer { display: none !important; }
611
+
612
+ /* Chunk tooltip */
613
+ .chunk-cell { position: relative; }
614
+ .chunk-tooltip {
615
+ display: none;
616
+ position: absolute;
617
+ bottom: calc(100% + 8px);
618
+ left: 50%;
619
+ transform: translateX(-50%);
620
+ background: #1f2937;
621
+ color: #f9fafb;
622
+ padding: 10px 14px;
623
+ border-radius: 10px;
624
+ font-size: 0.75rem;
625
+ z-index: 50;
626
+ min-width: 200px;
627
+ box-shadow: 0 10px 25px rgba(0,0,0,0.3);
628
+ pointer-events: none;
629
+ }
630
+ .chunk-tooltip::after {
631
+ content: '';
632
+ position: absolute;
633
+ top: 100%;
634
+ left: 50%;
635
+ transform: translateX(-50%);
636
+ border: 6px solid transparent;
637
+ border-top-color: #1f2937;
638
+ }
639
+ .chunk-cell:hover .chunk-tooltip { display: block; }
640
+ .chunk-cell:hover .chunk-square {
641
+ transform: scale(1.15);
642
+ border-color: white !important;
643
+ box-shadow: 0 4px 12px rgba(0,0,0,0.3);
644
+ }
645
+ """
646
+
647
+ _theme = gr.themes.Soft(
648
+ primary_hue="purple",
649
+ secondary_hue="purple",
650
+ neutral_hue="gray",
651
+ )
652
+
653
+ with gr.Blocks(css=CUSTOM_CSS, theme=_theme, title="Stuttering Classification — Vocametrix") as demo:
654
+
655
+ # Header
656
+ gr.HTML('<div class="main-title">Stuttering Classification</div>')
657
+ gr.HTML(
658
+ '<div class="subtitle">Upload speech audio and visualize stuttering patterns chunk by chunk</div>'
659
+ )
660
+
661
+ # Info box
662
+ gr.HTML("""
663
+ <div class="info-box">
664
+ <strong>Model:</strong>
665
+ <a href="https://huggingface.co/vocametrix/wav2vec2-xlsr-53-stuttering-classification" target="_blank">
666
+ Wav2Vec2-XLSR-53
667
+ </a> fine-tuned end-to-end on SEP-28k-Extended (~28k 3-second clips) for 6-class stuttering detection.
668
+ <br>
669
+ <strong>Classes:</strong> Fluent · Block · Prolongation · Word Repetition · Sound Repetition · Interjection
670
+ <br>
671
+ <strong>Paper:</strong> <em>"SpeechTherapyAgent: A Clinician-in-the-Loop AI Virtual Speech Therapist"</em>
672
+ — Sheikh, Marmaroli et al. (under review)
673
+ </div>
674
+ """)
675
+
676
+ with gr.Row():
677
+ with gr.Column(scale=2):
678
+ audio_input = gr.Audio(
679
+ sources=["upload", "microphone"],
680
+ type="numpy",
681
+ label="Speech Audio",
682
+ )
683
+ with gr.Column(scale=1):
684
+ chunk_duration = gr.Slider(
685
+ minimum=2,
686
+ maximum=6,
687
+ value=4,
688
+ step=1,
689
+ label="Chunk Duration (seconds)",
690
+ info="Duration of each analysis window",
691
+ )
692
+ overlap_pct = gr.Slider(
693
+ minimum=0,
694
+ maximum=75,
695
+ value=50,
696
+ step=25,
697
+ label="Chunk Overlap (%)",
698
+ info="Overlap between consecutive chunks",
699
+ )
700
+ classify_btn = gr.Button(
701
+ "🧠 Classify Stuttering",
702
+ variant="primary",
703
+ size="lg",
704
+ )
705
+
706
+ # Results output
707
+ results_output = gr.HTML(
708
+ value='<div style="text-align:center; color:#9ca3af; padding:40px; font-size:1.1rem;">'
709
+ "Upload or record an audio file, then click <strong>Classify Stuttering</strong>.</div>"
710
+ )
711
+
712
+ # Event handlers
713
+ classify_btn.click(
714
+ fn=process_audio,
715
+ inputs=[audio_input, chunk_duration, overlap_pct],
716
+ outputs=[results_output],
717
+ )
718
+
719
+ # Footer
720
+ gr.HTML("""
721
+ <div class="footer">
722
+ Built by <a href="https://www.vocametrix.com" target="_blank">Vocametrix</a>
723
+ &nbsp;·&nbsp;
724
+ <a href="https://huggingface.co/vocametrix/wav2vec2-xlsr-53-stuttering-classification" target="_blank">Model Card</a>
725
+ &nbsp;·&nbsp;
726
+ <a href="mailto:info@vocametrix.com">info@vocametrix.com</a>
727
+ </div>
728
+ """)
729
+
730
+
731
+ if __name__ == "__main__":
732
+ demo.launch()
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ gradio==5.16.0
2
+ numpy
3
+ librosa
4
+ torch
5
+ torchaudio
6
+ transformers
7
+ soundfile