Spaces:
Sleeping
Sleeping
Upload 12 files
Browse files- app.py +362 -789
- deploy.sh +75 -0
- nlu.py +17 -2
- pipeline.py +20 -7
- requirements.txt +25 -22
- test_app.py +155 -0
- verify_boot.py +85 -0
app.py
CHANGED
|
@@ -1,853 +1,426 @@
|
|
| 1 |
"""
|
| 2 |
-
PlotWeaver Hausa Voice AI Agent — HuggingFace Spaces
|
| 3 |
-
======================================================
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
"""
|
| 10 |
|
| 11 |
-
import gradio as gr
|
| 12 |
-
import numpy as np
|
| 13 |
import os
|
| 14 |
-
import
|
| 15 |
-
import
|
| 16 |
from datetime import datetime
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
from pipeline import HausaVoiceAIPipeline
|
| 19 |
from nlu import NLU
|
| 20 |
from orchestrator import Orchestrator
|
| 21 |
-
from integrations.crm import CRMClient
|
| 22 |
|
| 23 |
-
# ──
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
-
# ──
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
-
# ── Demo phrases (for visitors who don't speak Hausa) ────────────────────────
|
| 34 |
DEMO_PROMPTS = [
|
| 35 |
-
("Compound
|
| 36 |
-
"Duba asusuna sannan ka aika
|
| 37 |
-
("Entity prefill
|
| 38 |
-
"Ina son aika kuɗi zuwa Abu
|
| 39 |
-
("Branch
|
| 40 |
"Ina ne reshenku mafi kusa domin in karɓi katin ATM"),
|
| 41 |
-
("Report a problem",
|
| 42 |
-
("
|
| 43 |
]
|
| 44 |
|
| 45 |
-
# ── Custom CSS ────────────────────────────────────────────────────────────────
|
| 46 |
-
CSS = """
|
| 47 |
-
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=Syne:wght@700;800&family=JetBrains+Mono:wght@400;500&display=swap');
|
| 48 |
-
|
| 49 |
-
:root {
|
| 50 |
-
--amber: #F59E0B;
|
| 51 |
-
--ember: #DC2626;
|
| 52 |
-
--sand: #FDE68A;
|
| 53 |
-
--dark: #0C0A09;
|
| 54 |
-
--panel: #1C1917;
|
| 55 |
-
--border: #292524;
|
| 56 |
-
--text: #E7E5E4;
|
| 57 |
-
--muted: #78716C;
|
| 58 |
-
--success: #22C55E;
|
| 59 |
-
--info: #38BDF8;
|
| 60 |
-
}
|
| 61 |
-
|
| 62 |
-
body, .gradio-container {
|
| 63 |
-
background: var(--dark) !important;
|
| 64 |
-
font-family: 'Space Grotesk', sans-serif !important;
|
| 65 |
-
color: var(--text) !important;
|
| 66 |
-
}
|
| 67 |
-
|
| 68 |
-
/* ── Header ── */
|
| 69 |
-
.pw-header {
|
| 70 |
-
background: linear-gradient(135deg, #1C1917 0%, #292524 50%, #1C1917 100%);
|
| 71 |
-
border-bottom: 1px solid var(--border);
|
| 72 |
-
padding: 28px 40px 24px;
|
| 73 |
-
position: relative;
|
| 74 |
-
overflow: hidden;
|
| 75 |
-
}
|
| 76 |
-
.pw-header::before {
|
| 77 |
-
content: '';
|
| 78 |
-
position: absolute;
|
| 79 |
-
top: -60px; right: -60px;
|
| 80 |
-
width: 300px; height: 300px;
|
| 81 |
-
background: radial-gradient(circle, rgba(245,158,11,0.15) 0%, transparent 70%);
|
| 82 |
-
pointer-events: none;
|
| 83 |
-
}
|
| 84 |
-
.pw-logo {
|
| 85 |
-
font-family: 'Syne', sans-serif;
|
| 86 |
-
font-weight: 800;
|
| 87 |
-
font-size: 28px;
|
| 88 |
-
color: var(--amber);
|
| 89 |
-
letter-spacing: -0.5px;
|
| 90 |
-
margin: 0;
|
| 91 |
-
}
|
| 92 |
-
.pw-logo span { color: var(--text); }
|
| 93 |
-
.pw-tagline {
|
| 94 |
-
color: var(--muted);
|
| 95 |
-
font-size: 13px;
|
| 96 |
-
margin: 4px 0 0;
|
| 97 |
-
letter-spacing: 0.5px;
|
| 98 |
-
text-transform: uppercase;
|
| 99 |
-
}
|
| 100 |
-
|
| 101 |
-
/* ── Pill badges ── */
|
| 102 |
-
.pill {
|
| 103 |
-
display: inline-flex;
|
| 104 |
-
align-items: center;
|
| 105 |
-
gap: 6px;
|
| 106 |
-
background: rgba(245,158,11,0.12);
|
| 107 |
-
border: 1px solid rgba(245,158,11,0.3);
|
| 108 |
-
color: var(--amber);
|
| 109 |
-
padding: 4px 12px;
|
| 110 |
-
border-radius: 100px;
|
| 111 |
-
font-size: 11px;
|
| 112 |
-
font-weight: 600;
|
| 113 |
-
letter-spacing: 0.8px;
|
| 114 |
-
text-transform: uppercase;
|
| 115 |
-
}
|
| 116 |
-
.pill-green {
|
| 117 |
-
background: rgba(34,197,94,0.12);
|
| 118 |
-
border-color: rgba(34,197,94,0.3);
|
| 119 |
-
color: var(--success);
|
| 120 |
-
}
|
| 121 |
-
.pill-blue {
|
| 122 |
-
background: rgba(56,189,248,0.12);
|
| 123 |
-
border-color: rgba(56,189,248,0.3);
|
| 124 |
-
color: var(--info);
|
| 125 |
-
}
|
| 126 |
-
|
| 127 |
-
/* ── Panel cards ── */
|
| 128 |
-
.pw-card {
|
| 129 |
-
background: var(--panel);
|
| 130 |
-
border: 1px solid var(--border);
|
| 131 |
-
border-radius: 12px;
|
| 132 |
-
padding: 20px;
|
| 133 |
-
margin-bottom: 12px;
|
| 134 |
-
}
|
| 135 |
-
.pw-card-title {
|
| 136 |
-
font-family: 'Syne', sans-serif;
|
| 137 |
-
font-size: 13px;
|
| 138 |
-
font-weight: 700;
|
| 139 |
-
color: var(--amber);
|
| 140 |
-
letter-spacing: 1px;
|
| 141 |
-
text-transform: uppercase;
|
| 142 |
-
margin-bottom: 14px;
|
| 143 |
-
}
|
| 144 |
-
|
| 145 |
-
/* ── Conversation bubbles ── */
|
| 146 |
-
.conversation-box {
|
| 147 |
-
background: var(--panel);
|
| 148 |
-
border: 1px solid var(--border);
|
| 149 |
-
border-radius: 12px;
|
| 150 |
-
padding: 16px;
|
| 151 |
-
height: 360px;
|
| 152 |
-
overflow-y: auto;
|
| 153 |
-
font-family: 'Space Grotesk', sans-serif;
|
| 154 |
-
scroll-behavior: smooth;
|
| 155 |
-
}
|
| 156 |
-
.bubble {
|
| 157 |
-
max-width: 85%;
|
| 158 |
-
padding: 10px 14px;
|
| 159 |
-
border-radius: 14px;
|
| 160 |
-
margin-bottom: 10px;
|
| 161 |
-
line-height: 1.5;
|
| 162 |
-
font-size: 14px;
|
| 163 |
-
}
|
| 164 |
-
.bubble-user {
|
| 165 |
-
background: rgba(245,158,11,0.15);
|
| 166 |
-
border: 1px solid rgba(245,158,11,0.25);
|
| 167 |
-
margin-left: auto;
|
| 168 |
-
border-bottom-right-radius: 4px;
|
| 169 |
-
}
|
| 170 |
-
.bubble-agent {
|
| 171 |
-
background: rgba(255,255,255,0.05);
|
| 172 |
-
border: 1px solid var(--border);
|
| 173 |
-
border-bottom-left-radius: 4px;
|
| 174 |
-
}
|
| 175 |
-
.bubble-label {
|
| 176 |
-
font-size: 10px;
|
| 177 |
-
font-weight: 600;
|
| 178 |
-
letter-spacing: 0.8px;
|
| 179 |
-
text-transform: uppercase;
|
| 180 |
-
opacity: 0.6;
|
| 181 |
-
margin-bottom: 4px;
|
| 182 |
-
}
|
| 183 |
-
.bubble-hausa {
|
| 184 |
-
font-size: 12px;
|
| 185 |
-
color: var(--amber);
|
| 186 |
-
margin-top: 4px;
|
| 187 |
-
font-style: italic;
|
| 188 |
-
}
|
| 189 |
-
.bubble-time {
|
| 190 |
-
font-size: 10px;
|
| 191 |
-
color: var(--muted);
|
| 192 |
-
margin-top: 3px;
|
| 193 |
-
font-family: 'JetBrains Mono', monospace;
|
| 194 |
-
}
|
| 195 |
-
|
| 196 |
-
/* ── Pipeline status ── */
|
| 197 |
-
.pipeline-step {
|
| 198 |
-
display: flex;
|
| 199 |
-
align-items: center;
|
| 200 |
-
gap: 10px;
|
| 201 |
-
padding: 8px 0;
|
| 202 |
-
border-bottom: 1px solid var(--border);
|
| 203 |
-
font-size: 13px;
|
| 204 |
-
}
|
| 205 |
-
.pipeline-step:last-child { border-bottom: none; }
|
| 206 |
-
.step-icon {
|
| 207 |
-
width: 28px; height: 28px;
|
| 208 |
-
border-radius: 8px;
|
| 209 |
-
display: flex;
|
| 210 |
-
align-items: center;
|
| 211 |
-
justify-content: center;
|
| 212 |
-
font-size: 14px;
|
| 213 |
-
flex-shrink: 0;
|
| 214 |
-
}
|
| 215 |
-
.step-active { background: rgba(245,158,11,0.2); }
|
| 216 |
-
.step-done { background: rgba(34,197,94,0.2); }
|
| 217 |
-
.step-idle { background: rgba(255,255,255,0.05); }
|
| 218 |
-
|
| 219 |
-
/* ── Gradio overrides ── */
|
| 220 |
-
.gr-button-primary {
|
| 221 |
-
background: var(--amber) !important;
|
| 222 |
-
color: var(--dark) !important;
|
| 223 |
-
font-weight: 700 !important;
|
| 224 |
-
border: none !important;
|
| 225 |
-
font-family: 'Space Grotesk', sans-serif !important;
|
| 226 |
-
}
|
| 227 |
-
.gr-button-secondary {
|
| 228 |
-
background: var(--panel) !important;
|
| 229 |
-
color: var(--text) !important;
|
| 230 |
-
border: 1px solid var(--border) !important;
|
| 231 |
-
}
|
| 232 |
-
label, .gr-form > div > label {
|
| 233 |
-
color: var(--muted) !important;
|
| 234 |
-
font-size: 12px !important;
|
| 235 |
-
font-weight: 500 !important;
|
| 236 |
-
letter-spacing: 0.5px !important;
|
| 237 |
-
text-transform: uppercase !important;
|
| 238 |
-
}
|
| 239 |
-
.gr-box, .gr-input, textarea, .gr-text-input {
|
| 240 |
-
background: var(--panel) !important;
|
| 241 |
-
border-color: var(--border) !important;
|
| 242 |
-
color: var(--text) !important;
|
| 243 |
-
border-radius: 8px !important;
|
| 244 |
-
font-family: 'Space Grotesk', sans-serif !important;
|
| 245 |
-
}
|
| 246 |
-
.tabitem { background: var(--dark) !important; }
|
| 247 |
-
.tab-nav button {
|
| 248 |
-
background: transparent !important;
|
| 249 |
-
color: var(--muted) !important;
|
| 250 |
-
border-bottom: 2px solid transparent !important;
|
| 251 |
-
font-family: 'Space Grotesk', sans-serif !important;
|
| 252 |
-
font-weight: 600 !important;
|
| 253 |
-
}
|
| 254 |
-
.tab-nav button.selected {
|
| 255 |
-
color: var(--amber) !important;
|
| 256 |
-
border-bottom-color: var(--amber) !important;
|
| 257 |
-
}
|
| 258 |
-
footer { display: none !important; }
|
| 259 |
-
"""
|
| 260 |
|
| 261 |
-
# ──
|
| 262 |
-
ARCH_HTML = """
|
| 263 |
-
<div style="font-family:'Space Grotesk',sans-serif; color:#E7E5E4; padding:16px;">
|
| 264 |
-
<div style="font-family:'Syne',sans-serif; font-size:13px; font-weight:700;
|
| 265 |
-
color:#F59E0B; letter-spacing:1px; text-transform:uppercase;
|
| 266 |
-
margin-bottom:20px;">System Architecture</div>
|
| 267 |
-
|
| 268 |
-
<div style="display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:20px;">
|
| 269 |
-
|
| 270 |
-
<!-- Input channels -->
|
| 271 |
-
<div style="background:#1C1917;border:1px solid #292524;border-radius:10px;
|
| 272 |
-
padding:12px 16px; min-width:100px; text-align:center;">
|
| 273 |
-
<div style="font-size:20px;">🎤</div>
|
| 274 |
-
<div style="font-size:11px; color:#78716C; margin-top:4px;">MICROPHONE</div>
|
| 275 |
-
<div style="font-size:10px; color:#22C55E;">Gradio</div>
|
| 276 |
-
</div>
|
| 277 |
-
<div style="background:#1C1917;border:1px solid #292524;border-radius:10px;
|
| 278 |
-
padding:12px 16px; min-width:100px; text-align:center;">
|
| 279 |
-
<div style="font-size:20px;">💬</div>
|
| 280 |
-
<div style="font-size:11px; color:#78716C; margin-top:4px;">WHATSAPP</div>
|
| 281 |
-
<div style="font-size:10px; color:#22C55E;">Cloud API</div>
|
| 282 |
-
</div>
|
| 283 |
-
<div style="background:#1C1917;border:1px solid #292524;border-radius:10px;
|
| 284 |
-
padding:12px 16px; min-width:100px; text-align:center;">
|
| 285 |
-
<div style="font-size:20px;">📞</div>
|
| 286 |
-
<div style="font-size:11px; color:#78716C; margin-top:4px;">PHONE/SIP</div>
|
| 287 |
-
<div style="font-size:10px; color:#22C55E;">Twilio</div>
|
| 288 |
-
</div>
|
| 289 |
-
|
| 290 |
-
<div style="color:#F59E0B; font-size:20px; margin:0 4px;">→</div>
|
| 291 |
-
|
| 292 |
-
<!-- Pipeline -->
|
| 293 |
-
<div style="display:flex; flex-direction:column; gap:8px;">
|
| 294 |
-
<div style="background:rgba(220,38,38,0.1);border:1px solid rgba(220,38,38,0.3);
|
| 295 |
-
border-radius:8px; padding:10px 20px; text-align:center;">
|
| 296 |
-
<div style="font-size:11px; font-weight:700; color:#F87171;">VAD ENDPOINTING</div>
|
| 297 |
-
<div style="font-size:10px; color:#78716C;">Silero VAD · preroll · hangover · barge-in</div>
|
| 298 |
-
</div>
|
| 299 |
-
<div style="background:rgba(245,158,11,0.1);border:1px solid rgba(245,158,11,0.3);
|
| 300 |
-
border-radius:8px; padding:10px 20px; text-align:center;">
|
| 301 |
-
<div style="font-size:11px; font-weight:700; color:#F59E0B;">WHISPER (DUAL)</div>
|
| 302 |
-
<div style="font-size:10px; color:#78716C;">small → partials · large-v3 → final</div>
|
| 303 |
-
</div>
|
| 304 |
-
<div style="background:rgba(56,189,248,0.1);border:1px solid rgba(56,189,248,0.3);
|
| 305 |
-
border-radius:8px; padding:10px 20px; text-align:center;">
|
| 306 |
-
<div style="font-size:11px; font-weight:700; color:#38BDF8;">NLLB-200 (600M)</div>
|
| 307 |
-
<div style="font-size:10px; color:#78716C;">hau_Latn ↔ eng_Latn</div>
|
| 308 |
-
</div>
|
| 309 |
-
<div style="background:rgba(168,85,247,0.1);border:1px solid rgba(168,85,247,0.3);
|
| 310 |
-
border-radius:8px; padding:10px 20px; text-align:center;">
|
| 311 |
-
<div style="font-size:11px; font-weight:700; color:#A855F7;">DIALOGUE MANAGER</div>
|
| 312 |
-
<div style="font-size:10px; color:#78716C;">FSM + Intent · Multi-turn</div>
|
| 313 |
-
</div>
|
| 314 |
-
<div style="background:rgba(34,197,94,0.1);border:1px solid rgba(34,197,94,0.3);
|
| 315 |
-
border-radius:8px; padding:10px 20px; text-align:center;">
|
| 316 |
-
<div style="font-size:11px; font-weight:700; color:#22C55E;">MMS-TTS (HAU)</div>
|
| 317 |
-
<div style="font-size:10px; color:#78716C;">facebook/mms-tts-hau · VITS</div>
|
| 318 |
-
</div>
|
| 319 |
-
</div>
|
| 320 |
-
|
| 321 |
-
<div style="color:#F59E0B; font-size:20px; margin:0 4px;">→</div>
|
| 322 |
-
|
| 323 |
-
<!-- Integrations -->
|
| 324 |
-
<div style="display:flex; flex-direction:column; gap:8px;">
|
| 325 |
-
<div style="background:#1C1917;border:1px solid #292524;border-radius:10px;
|
| 326 |
-
padding:12px 16px; min-width:110px; text-align:center;">
|
| 327 |
-
<div style="font-size:20px;">🗂️</div>
|
| 328 |
-
<div style="font-size:11px; color:#78716C; margin-top:4px;">CRM / ZENDESK</div>
|
| 329 |
-
<div style="font-size:10px; color:#F59E0B;">Auto-tickets</div>
|
| 330 |
-
</div>
|
| 331 |
-
<div style="background:#1C1917;border:1px solid #292524;border-radius:10px;
|
| 332 |
-
padding:12px 16px; min-width:110px; text-align:center;">
|
| 333 |
-
<div style="font-size:20px;">👤</div>
|
| 334 |
-
<div style="font-size:11px; color:#78716C; margin-top:4px;">HUMAN AGENT</div>
|
| 335 |
-
<div style="font-size:10px; color:#DC2626;">Fallback</div>
|
| 336 |
-
</div>
|
| 337 |
-
</div>
|
| 338 |
-
</div>
|
| 339 |
-
|
| 340 |
-
<div style="display:flex; gap:20px; flex-wrap:wrap; margin-top:16px; padding-top:16px;
|
| 341 |
-
border-top:1px solid #292524;">
|
| 342 |
-
<div><span style="color:#F59E0B; font-weight:700;">Latency target:</span>
|
| 343 |
-
<span style="color:#78716C;"> ASR<2s · MT<0.5s · TTS<1s · Total<4s</span></div>
|
| 344 |
-
<div><span style="color:#F59E0B; font-weight:700;">Languages:</span>
|
| 345 |
-
<span style="color:#78716C;"> Hausa (primary) · English pivot · French (roadmap)</span></div>
|
| 346 |
-
<div><span style="color:#F59E0B; font-weight:700;">Deployment:</span>
|
| 347 |
-
<span style="color:#78716C;"> HF Spaces (POC) → Docker / K8s (prod)</span></div>
|
| 348 |
-
</div>
|
| 349 |
-
</div>
|
| 350 |
-
"""
|
| 351 |
|
| 352 |
-
|
|
|
|
|
|
|
| 353 |
|
| 354 |
-
def _init_state():
|
| 355 |
-
return {
|
| 356 |
-
"conv": dm.new_session(),
|
| 357 |
-
"history": [], # [{role, hausa, english, time}]
|
| 358 |
-
"sasr": None, # StreamingASR — created on first streamed chunk
|
| 359 |
-
"partial": "", # live (unconfirmed) transcript
|
| 360 |
-
"metrics": [], # per-turn latency records
|
| 361 |
-
}
|
| 362 |
|
| 363 |
-
def
|
| 364 |
if not history:
|
| 365 |
-
return (
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
"""
|
| 401 |
-
if state is None:
|
| 402 |
-
state = _init_state()
|
| 403 |
-
|
| 404 |
-
hausa_text = ""
|
| 405 |
-
asr_status = "—"
|
| 406 |
-
|
| 407 |
-
# 1. ASR
|
| 408 |
-
if audio is not None:
|
| 409 |
-
sample_rate, audio_array = audio
|
| 410 |
-
audio_array = audio_array.astype(np.float32) / 32768.0
|
| 411 |
-
if audio_array.ndim > 1:
|
| 412 |
-
audio_array = audio_array.mean(axis=1)
|
| 413 |
-
hausa_text = ai_pipeline.audio_to_hausa_text(audio_array, sample_rate)
|
| 414 |
-
asr_status = f"✓ {hausa_text[:60]}…" if len(hausa_text) > 60 else f"✓ {hausa_text}"
|
| 415 |
-
elif text_input and text_input.strip():
|
| 416 |
-
hausa_text = text_input.strip()
|
| 417 |
-
asr_status = "(text input)"
|
| 418 |
-
else:
|
| 419 |
-
return None, _render_conversation(state["history"]), "⚠ No input provided", state
|
| 420 |
-
|
| 421 |
-
audio_out, status = _handle_turn(hausa_text, state)
|
| 422 |
-
return audio_out, _render_conversation(state["history"]), status, state
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
# ── Shared turn handler (used by both push-to-talk and streaming) ────────────
|
| 426 |
-
|
| 427 |
-
def _handle_turn(hausa_text: str, state: dict, asr_ms: float = 0.0):
|
| 428 |
-
"""
|
| 429 |
-
Runs MT → dialogue → MT → TTS for one finalized Hausa utterance.
|
| 430 |
-
Mutates state['history'] and state['conv']. Returns (audio_out, status).
|
| 431 |
-
"""
|
| 432 |
-
import time as _time
|
| 433 |
-
t_mt0 = _time.perf_counter()
|
| 434 |
-
english_text = ai_pipeline.hausa_to_english(hausa_text)
|
| 435 |
-
mt_in_ms = (_time.perf_counter() - t_mt0) * 1000
|
| 436 |
-
|
| 437 |
-
t_nlu0 = _time.perf_counter()
|
| 438 |
-
english_response, conv_state, escalate = dm.respond(
|
| 439 |
-
english_text, hausa_text, state["conv"]
|
| 440 |
-
)
|
| 441 |
-
state["conv"] = conv_state
|
| 442 |
-
nlu_ms = (_time.perf_counter() - t_nlu0) * 1000
|
| 443 |
-
|
| 444 |
-
t_mt1 = _time.perf_counter()
|
| 445 |
-
hausa_response = ai_pipeline.english_to_hausa(english_response)
|
| 446 |
-
mt_out_ms = (_time.perf_counter() - t_mt1) * 1000
|
| 447 |
-
|
| 448 |
-
t_tts = _time.perf_counter()
|
| 449 |
-
sr, audio_out = ai_pipeline.hausa_text_to_audio(hausa_response)
|
| 450 |
-
tts_ms = (_time.perf_counter() - t_tts) * 1000
|
| 451 |
|
| 452 |
now = datetime.now().strftime("%H:%M:%S")
|
| 453 |
-
state["history"]
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
total = asr_ms +
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 483 |
"""
|
| 484 |
-
|
| 485 |
-
|
| 486 |
|
| 487 |
-
Returns
|
|
|
|
| 488 |
"""
|
| 489 |
if state is None:
|
| 490 |
-
state =
|
| 491 |
-
if
|
| 492 |
-
return (
|
| 493 |
-
"Listening …", state.get("partial", ""), state)
|
| 494 |
|
| 495 |
if state["sasr"] is None:
|
| 496 |
state["sasr"] = ai_pipeline.make_streaming_session(
|
| 497 |
emit_partials=SHOW_PARTIALS)
|
| 498 |
|
| 499 |
-
sr,
|
| 500 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 501 |
|
| 502 |
-
audio_out =
|
| 503 |
-
status = None
|
| 504 |
|
| 505 |
for ev in events:
|
| 506 |
if ev.kind == "speech_start":
|
| 507 |
-
status = "
|
| 508 |
-
|
| 509 |
elif ev.kind == "partial":
|
| 510 |
state["partial"] = ev.text
|
| 511 |
-
status = f"
|
| 512 |
-
|
| 513 |
elif ev.kind == "bargein":
|
| 514 |
-
# Caller interrupted the agent: stop playback immediately
|
| 515 |
audio_out = None
|
| 516 |
-
status = "
|
| 517 |
-
|
| 518 |
elif ev.kind == "discarded":
|
| 519 |
state["partial"] = ""
|
| 520 |
-
status = "Listening …"
|
| 521 |
-
|
| 522 |
elif ev.kind == "final":
|
| 523 |
state["partial"] = ""
|
| 524 |
-
state["sasr"].agent_speaking = True
|
| 525 |
-
audio_out, status =
|
| 526 |
-
|
| 527 |
|
| 528 |
-
return
|
| 529 |
-
_render_conversation(state["history"]),
|
| 530 |
-
status or "Listening …",
|
| 531 |
-
state.get("partial", ""),
|
| 532 |
-
state)
|
| 533 |
|
| 534 |
|
| 535 |
-
def
|
| 536 |
-
|
| 537 |
-
return None, _render_conversation([]), "New session started.", "", state
|
| 538 |
|
| 539 |
|
| 540 |
-
|
| 541 |
-
"""Inject a demo Hausa phrase as text input."""
|
| 542 |
-
return prompt_ha, state
|
| 543 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 544 |
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
<span class="
|
| 559 |
-
<span class="
|
| 560 |
-
<span class="
|
| 561 |
-
<span class="pill-green">⚡ Real-time</span>
|
| 562 |
-
<span class="pill-blue">🏢 Enterprise-ready</span>
|
| 563 |
</div>
|
| 564 |
-
</div>
|
| 565 |
-
""")
|
| 566 |
|
| 567 |
with gr.Tabs():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 568 |
|
| 569 |
-
# ── Tab 1 : Live Demo ───────────────────────────────────────────────
|
| 570 |
-
with gr.TabItem("🎙️ Live Demo"):
|
| 571 |
with gr.Row():
|
| 572 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 573 |
with gr.Column(scale=2):
|
| 574 |
-
gr.
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
)
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
)
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
label
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
# Flush any in-progress utterance when the mic stops.
|
| 653 |
-
stream_in.stop_recording(
|
| 654 |
-
fn=lambda s: reset_session(s)[1:],
|
| 655 |
-
inputs=[app_state],
|
| 656 |
-
outputs=[conversation_display, status_box, partial_box,
|
| 657 |
-
app_state],
|
| 658 |
-
)
|
| 659 |
-
|
| 660 |
-
submit_btn.click(
|
| 661 |
-
fn=process_voice,
|
| 662 |
-
inputs=[audio_in, text_in, app_state],
|
| 663 |
-
outputs=[audio_out, conversation_display, status_box, app_state],
|
| 664 |
-
)
|
| 665 |
-
reset_btn.click(
|
| 666 |
-
fn=reset_session,
|
| 667 |
-
inputs=[app_state],
|
| 668 |
-
outputs=[audio_out, conversation_display, status_box,
|
| 669 |
-
partial_box, app_state],
|
| 670 |
-
)
|
| 671 |
-
|
| 672 |
-
# ── Tab 2 : Architecture ────────────────────────────────────────────
|
| 673 |
-
with gr.TabItem("🏗️ Architecture"):
|
| 674 |
-
gr.HTML(ARCH_HTML)
|
| 675 |
-
|
| 676 |
-
# ── Tab 3 : Integrations ────────────────────────────────────────────
|
| 677 |
-
with gr.TabItem("🔌 Integrations"):
|
| 678 |
-
gr.HTML("""
|
| 679 |
-
<div style="font-family:'Space Grotesk',sans-serif; color:#E7E5E4; padding:16px;">
|
| 680 |
-
<div style="font-family:'Syne',sans-serif; font-size:13px; font-weight:700;
|
| 681 |
-
color:#F59E0B; letter-spacing:1px; text-transform:uppercase;
|
| 682 |
-
margin-bottom:20px;">Enterprise Integration Matrix</div>
|
| 683 |
-
|
| 684 |
-
<div style="display:grid; grid-template-columns:repeat(auto-fill,minmax(220px,1fr)); gap:16px;">
|
| 685 |
-
|
| 686 |
-
<div style="background:#1C1917;border:1px solid #292524;border-radius:12px;padding:18px;">
|
| 687 |
-
<div style="font-size:24px; margin-bottom:8px;">💬</div>
|
| 688 |
-
<div style="font-weight:700; margin-bottom:4px;">WhatsApp Business</div>
|
| 689 |
-
<div style="font-size:12px; color:#78716C; margin-bottom:10px;">
|
| 690 |
-
Meta Cloud API v18+. Inbound voice notes → ASR pipeline.
|
| 691 |
-
Quick-reply buttons. Media download.
|
| 692 |
-
</div>
|
| 693 |
-
<span style="font-size:10px; background:rgba(34,197,94,0.12);
|
| 694 |
-
border:1px solid rgba(34,197,94,0.3); color:#22C55E;
|
| 695 |
-
padding:2px 8px; border-radius:100px;">READY</span>
|
| 696 |
-
</div>
|
| 697 |
-
|
| 698 |
-
<div style="background:#1C1917;border:1px solid #292524;border-radius:12px;padding:18px;">
|
| 699 |
-
<div style="font-size:24px; margin-bottom:8px;">📞</div>
|
| 700 |
-
<div style="font-weight:700; margin-bottom:4px;">Twilio / SIP</div>
|
| 701 |
-
<div style="font-size:12px; color:#78716C; margin-bottom:10px;">
|
| 702 |
-
Twilio Media Streams WebSocket. Inbound + outbound IVR.
|
| 703 |
-
Warm transfer to human agent. Bandwidth BXML also supported.
|
| 704 |
-
</div>
|
| 705 |
-
<span style="font-size:10px; background:rgba(34,197,94,0.12);
|
| 706 |
-
border:1px solid rgba(34,197,94,0.3); color:#22C55E;
|
| 707 |
-
padding:2px 8px; border-radius:100px;">READY</span>
|
| 708 |
-
</div>
|
| 709 |
-
|
| 710 |
-
<div style="background:#1C1917;border:1px solid #292524;border-radius:12px;padding:18px;">
|
| 711 |
-
<div style="font-size:24px; margin-bottom:8px;">🗂️</div>
|
| 712 |
-
<div style="font-weight:700; margin-bottom:4px;">CRM / Ticketing</div>
|
| 713 |
-
<div style="font-size:12px; color:#78716C; margin-bottom:10px;">
|
| 714 |
-
Zendesk native adapter. Generic REST adapter for Freshdesk,
|
| 715 |
-
HubSpot, Salesforce. Auto-ticket on issue report.
|
| 716 |
-
</div>
|
| 717 |
-
<span style="font-size:10px; background:rgba(34,197,94,0.12);
|
| 718 |
-
border:1px solid rgba(34,197,94,0.3); color:#22C55E;
|
| 719 |
-
padding:2px 8px; border-radius:100px;">READY</span>
|
| 720 |
-
</div>
|
| 721 |
-
|
| 722 |
-
<div style="background:#1C1917;border:1px solid #292524;border-radius:12px;padding:18px;">
|
| 723 |
-
<div style="font-size:24px; margin-bottom:8px;">👤</div>
|
| 724 |
-
<div style="font-weight:700; margin-bottom:4px;">Human Fallback</div>
|
| 725 |
-
<div style="font-size:12px; color:#78716C; margin-bottom:10px;">
|
| 726 |
-
Threshold-based escalation: low confidence, explicit request,
|
| 727 |
-
or max-turns. SIP REFER transfer + CRM context handoff.
|
| 728 |
-
</div>
|
| 729 |
-
<span style="font-size:10px; background:rgba(34,197,94,0.12);
|
| 730 |
-
border:1px solid rgba(34,197,94,0.3); color:#22C55E;
|
| 731 |
-
padding:2px 8px; border-radius:100px;">READY</span>
|
| 732 |
-
</div>
|
| 733 |
-
|
| 734 |
-
<div style="background:#1C1917;border:1px solid #292524;border-radius:12px;padding:18px;">
|
| 735 |
-
<div style="font-size:24px; margin-bottom:8px;">🌍</div>
|
| 736 |
-
<div style="font-weight:700; margin-bottom:4px;">More Languages</div>
|
| 737 |
-
<div style="font-size:12px; color:#78716C; margin-bottom:10px;">
|
| 738 |
-
Yorùbá, Igbo, Fulfulde, Kanuri roadmap.
|
| 739 |
-
NLLB covers 200 languages. MMS covers 1,000+ TTS languages.
|
| 740 |
-
</div>
|
| 741 |
-
<span style="font-size:10px; background:rgba(245,158,11,0.12);
|
| 742 |
-
border:1px solid rgba(245,158,11,0.3); color:#F59E0B;
|
| 743 |
-
padding:2px 8px; border-radius:100px;">ROADMAP</span>
|
| 744 |
-
</div>
|
| 745 |
-
|
| 746 |
-
<div style="background:#1C1917;border:1px solid #292524;border-radius:12px;padding:18px;">
|
| 747 |
-
<div style="font-size:24px; margin-bottom:8px;">⚡</div>
|
| 748 |
-
<div style="font-weight:700; margin-bottom:4px;">Fine-tuned Models</div>
|
| 749 |
-
<div style="font-size:12px; color:#78716C; margin-bottom:10px;">
|
| 750 |
-
Custom Whisper fine-tune for Hausa dialectal variance.
|
| 751 |
-
OuteTTS / MMS fine-tune on domain vocabulary. NLLB domain adaptation.
|
| 752 |
-
</div>
|
| 753 |
-
<span style="font-size:10px; background:rgba(245,158,11,0.12);
|
| 754 |
-
border:1px solid rgba(245,158,11,0.3); color:#F59E0B;
|
| 755 |
-
padding:2px 8px; border-radius:100px;">IN PROGRESS</span>
|
| 756 |
-
</div>
|
| 757 |
-
|
| 758 |
-
</div>
|
| 759 |
-
|
| 760 |
-
<div style="margin-top:24px; padding:16px; background:rgba(245,158,11,0.05);
|
| 761 |
-
border:1px solid rgba(245,158,11,0.2); border-radius:10px;">
|
| 762 |
-
<div style="font-size:12px; font-weight:700; color:#F59E0B;
|
| 763 |
-
margin-bottom:8px; letter-spacing:0.5px;">⚙ CONFIGURATION</div>
|
| 764 |
-
<div style="font-family:'JetBrains Mono',monospace; font-size:11px;
|
| 765 |
-
color:#78716C; line-height:2;">
|
| 766 |
-
WHATSAPP_TOKEN=<meta-token> WHATSAPP_PHONE_ID=<phone-id><br>
|
| 767 |
-
TWILIO_ACCOUNT_SID=ACxxxx TWILIO_AUTH_TOKEN=xxxx<br>
|
| 768 |
-
CRM_PROVIDER=zendesk ZENDESK_SUBDOMAIN=yourco ZENDESK_API_TOKEN=xxxx<br>
|
| 769 |
-
SIP_PROVIDER=twilio (or bandwidth)
|
| 770 |
-
</div>
|
| 771 |
-
</div>
|
| 772 |
-
</div>
|
| 773 |
""")
|
| 774 |
|
| 775 |
-
|
| 776 |
-
|
| 777 |
-
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
|
| 790 |
-
|
| 791 |
-
|
| 792 |
-
|
| 793 |
-
|
| 794 |
-
|
| 795 |
-
color:#F59E0B;">63%</div>
|
| 796 |
-
<div style="font-size:12px; color:#78716C; margin-top:4px;">Low literacy rate</div>
|
| 797 |
-
<div style="font-size:11px; color:#38BDF8;">Voice is the UX</div>
|
| 798 |
-
</div>
|
| 799 |
-
<div style="background:#1C1917;border:1px solid #292524;border-radius:12px;
|
| 800 |
-
padding:18px; text-align:center;">
|
| 801 |
-
<div style="font-family:'Syne',sans-serif; font-size:32px; font-weight:800;
|
| 802 |
-
color:#F59E0B;">$4.2B</div>
|
| 803 |
-
<div style="font-size:12px; color:#78716C; margin-top:4px;">Africa call-centre spend</div>
|
| 804 |
-
<div style="font-size:11px; color:#F59E0B;">2027 projection</div>
|
| 805 |
-
</div>
|
| 806 |
-
<div style="background:#1C1917;border:1px solid #292524;border-radius:12px;
|
| 807 |
-
padding:18px; text-align:center;">
|
| 808 |
-
<div style="font-family:'Syne',sans-serif; font-size:32px; font-weight:800;
|
| 809 |
-
color:#F59E0B;">0</div>
|
| 810 |
-
<div style="font-size:12px; color:#78716C; margin-top:4px;">Production Hausa VoiceBots</div>
|
| 811 |
-
<div style="font-size:11px; color:#DC2626;">Whitespace opportunity</div>
|
| 812 |
-
</div>
|
| 813 |
-
</div>
|
| 814 |
-
|
| 815 |
-
<div style="display:grid; grid-template-columns:1fr 1fr; gap:16px;">
|
| 816 |
-
<div style="background:#1C1917;border:1px solid #292524;border-radius:12px;padding:18px;">
|
| 817 |
-
<div style="font-weight:700; color:#F59E0B; margin-bottom:12px;">🎯 Target Verticals</div>
|
| 818 |
-
<div style="font-size:13px; line-height:2; color:#A8A29E;">
|
| 819 |
-
📱 Telecoms (MTN, Airtel Nigeria, Glo)<br>
|
| 820 |
-
🏦 Fintech / Mobile money (Kuda, PalmPay)<br>
|
| 821 |
-
🏥 Health (NHIS, telemedicine IVR)<br>
|
| 822 |
-
🏛 Government services (NIMC, NIN)<br>
|
| 823 |
-
🛒 E-commerce (Jumia, Konga)
|
| 824 |
-
</div>
|
| 825 |
-
</div>
|
| 826 |
-
<div style="background:#1C1917;border:1px solid #292524;border-radius:12px;padding:18px;">
|
| 827 |
-
<div style="font-weight:700; color:#F59E0B; margin-bottom:12px;">🏆 Competitive Moat</div>
|
| 828 |
-
<div style="font-size:13px; line-height:2; color:#A8A29E;">
|
| 829 |
-
✓ Open-source stack (no API lock-in)<br>
|
| 830 |
-
✓ Fine-tuned Hausa models (PlotWeaver IP)<br>
|
| 831 |
-
✓ On-premise deployable (data sovereignty)<br>
|
| 832 |
-
✓ Multi-channel from day one<br>
|
| 833 |
-
✓ Academic NLP + production engineering
|
| 834 |
-
</div>
|
| 835 |
-
</div>
|
| 836 |
-
</div>
|
| 837 |
-
</div>
|
| 838 |
""")
|
| 839 |
|
| 840 |
-
|
| 841 |
-
|
| 842 |
-
<div style="text-align:center; padding:20px; color:#44403C; font-size:11px;
|
| 843 |
-
border-top:1px solid #1C1917; margin-top:8px;">
|
| 844 |
-
PlotWeaver · Hausa Voice AI Agent POC |
|
| 845 |
-
Whisper large-v3 · NLLB-200 · MMS-TTS-hau |
|
| 846 |
-
<a href="https://plotweaver.ai" style="color:#F59E0B; text-decoration:none;">
|
| 847 |
-
plotweaver.ai</a>
|
| 848 |
-
</div>
|
| 849 |
-
""")
|
| 850 |
|
| 851 |
|
| 852 |
if __name__ == "__main__":
|
| 853 |
-
demo.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
PlotWeaver Hausa Voice AI Agent — HuggingFace Spaces
|
| 3 |
+
======================================================
|
| 4 |
+
Verified against gradio 5.49.1. Key constraints this file respects:
|
| 5 |
+
|
| 6 |
+
* Models load LAZILY. The Space must show UI within seconds, not after a
|
| 7 |
+
3.5GB download. Nothing heavy is imported or loaded at module scope.
|
| 8 |
+
* CPU-SAFE DEFAULTS. whisper-large-v3 needs ~6GB and takes ~60s per utterance
|
| 9 |
+
on the 2-vCPU free tier — unusable. Default is whisper-small; large-v3 is
|
| 10 |
+
one env var away when you attach a GPU.
|
| 11 |
+
* gr.skip() on stream ticks that produce no audio, otherwise the player
|
| 12 |
+
restarts on every 0.5s tick and the agent's reply stutters.
|
| 13 |
+
* Streaming partials OFF by default on CPU: an extra decode every 900ms
|
| 14 |
+
saturates the box and makes the demo feel worse, not better.
|
| 15 |
"""
|
| 16 |
|
|
|
|
|
|
|
| 17 |
import os
|
| 18 |
+
import sys
|
| 19 |
+
import logging
|
| 20 |
from datetime import datetime
|
| 21 |
|
| 22 |
+
import gradio as gr
|
| 23 |
+
import numpy as np
|
| 24 |
+
|
| 25 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 26 |
+
|
| 27 |
+
logging.basicConfig(level=logging.INFO)
|
| 28 |
+
logger = logging.getLogger("app")
|
| 29 |
+
|
| 30 |
from pipeline import HausaVoiceAIPipeline
|
| 31 |
from nlu import NLU
|
| 32 |
from orchestrator import Orchestrator
|
|
|
|
| 33 |
|
| 34 |
+
# ── Optional integrations (never block startup) ──────────────────────────────
|
| 35 |
+
try:
|
| 36 |
+
from integrations.crm import CRMClient
|
| 37 |
+
_crm = CRMClient()
|
| 38 |
+
INTEGRATIONS_OK = True
|
| 39 |
+
except Exception as e:
|
| 40 |
+
logger.warning(f"Integrations unavailable ({e}) — demo mode.")
|
| 41 |
+
_crm, INTEGRATIONS_OK = None, False
|
| 42 |
|
| 43 |
+
# ── Config ───────────────────────────────────────────────────────────────────
|
| 44 |
+
HAS_GPU = os.getenv("SPACES_GPU", "") or os.getenv("CUDA_VISIBLE_DEVICES", "")
|
| 45 |
+
SHOW_PARTIALS = os.getenv("SHOW_PARTIALS", "0") == "1" # off by default on CPU
|
| 46 |
+
STREAM_EVERY = float(os.getenv("STREAM_EVERY", "0.5"))
|
| 47 |
+
|
| 48 |
+
ai_pipeline = HausaVoiceAIPipeline()
|
| 49 |
+
dm = Orchestrator(crm=_crm, nlu=NLU())
|
| 50 |
|
|
|
|
| 51 |
DEMO_PROMPTS = [
|
| 52 |
+
("Compound request (balance + transfer)",
|
| 53 |
+
"Duba asusuna sannan ka aika 35000 zuwa Amina"),
|
| 54 |
+
("Entity prefill — recipient named",
|
| 55 |
+
"Ina son aika kuɗi zuwa Abu"),
|
| 56 |
+
("Branch info, NOT block-card",
|
| 57 |
"Ina ne reshenku mafi kusa domin in karɓi katin ATM"),
|
| 58 |
+
("Report a problem", "Ina da matsala da asusuna"),
|
| 59 |
+
("Escalate to human", "Ina son magana da mutum"),
|
| 60 |
]
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
+
# ── State ───────────────────────────────────��────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
+
def new_state():
|
| 66 |
+
return {"conv": dm.new_session(), "history": [], "sasr": None,
|
| 67 |
+
"partial": "", "warm": False}
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
+
def render(history):
|
| 71 |
if not history:
|
| 72 |
+
return ('<div class="empty">Press <b>Warm up models</b>, then speak or '
|
| 73 |
+
'tap a demo prompt.</div>')
|
| 74 |
+
out = []
|
| 75 |
+
for m in history:
|
| 76 |
+
me = m["role"] == "user"
|
| 77 |
+
out.append(
|
| 78 |
+
f'<div class="row {"r-me" if me else "r-ag"}">'
|
| 79 |
+
f'<div class="bub {"b-me" if me else "b-ag"}">'
|
| 80 |
+
f'<div class="lbl">{"YOU" if me else "AGENT"}</div>'
|
| 81 |
+
f'<div class="ha">{m["hausa"]}</div>'
|
| 82 |
+
f'<div class="en">{m["english"]}</div>'
|
| 83 |
+
f'<div class="tm">{m["time"]}</div></div></div>')
|
| 84 |
+
return "".join(out)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
# ── Core turn ────────────────────────────────────────────────────────────────
|
| 88 |
+
|
| 89 |
+
def run_turn(hausa_text, state, asr_ms=0.0):
|
| 90 |
+
import time
|
| 91 |
+
t = time.perf_counter()
|
| 92 |
+
english = ai_pipeline.hausa_to_english(hausa_text)
|
| 93 |
+
mt_in = (time.perf_counter() - t) * 1000
|
| 94 |
+
|
| 95 |
+
t = time.perf_counter()
|
| 96 |
+
reply_en, conv, escalated = dm.respond(english, hausa_text, state["conv"])
|
| 97 |
+
state["conv"] = conv
|
| 98 |
+
nlu_ms = (time.perf_counter() - t) * 1000
|
| 99 |
+
|
| 100 |
+
t = time.perf_counter()
|
| 101 |
+
reply_ha = ai_pipeline.english_to_hausa(reply_en)
|
| 102 |
+
mt_out = (time.perf_counter() - t) * 1000
|
| 103 |
+
|
| 104 |
+
t = time.perf_counter()
|
| 105 |
+
sr, wav = ai_pipeline.hausa_text_to_audio(reply_ha)
|
| 106 |
+
tts_ms = (time.perf_counter() - t) * 1000
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
|
| 108 |
now = datetime.now().strftime("%H:%M:%S")
|
| 109 |
+
state["history"] += [
|
| 110 |
+
{"role": "user", "hausa": hausa_text, "english": english, "time": now},
|
| 111 |
+
{"role": "agent", "hausa": reply_ha, "english": reply_en, "time": now},
|
| 112 |
+
]
|
| 113 |
+
|
| 114 |
+
total = asr_ms + mt_in + nlu_ms + mt_out + tts_ms
|
| 115 |
+
openq = [x for x in conv.tasks
|
| 116 |
+
if x.status in ("pending", "collecting", "confirming")]
|
| 117 |
+
status = (f"turn {conv.turn} · queue {len(openq)} open / "
|
| 118 |
+
f"{sum(1 for x in conv.tasks if x.status=='done')} done"
|
| 119 |
+
f"{' · ' + conv.active_task.intent if conv.active_task else ''}"
|
| 120 |
+
f" | ASR {asr_ms:.0f} · MT {mt_in+mt_out:.0f} · "
|
| 121 |
+
f"NLU {nlu_ms:.0f} · TTS {tts_ms:.0f} · total {total:.0f} ms")
|
| 122 |
+
if escalated:
|
| 123 |
+
status += " ESCALATED"
|
| 124 |
+
return (sr, wav), status
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
# ── Handlers ─────────────────────────────────────────────────────────────────
|
| 128 |
+
|
| 129 |
+
def warm_up(state, progress=gr.Progress()):
|
| 130 |
+
"""Download + load models with visible progress, so the first real
|
| 131 |
+
utterance isn't a 3-minute silence."""
|
| 132 |
+
if state is None:
|
| 133 |
+
state = new_state()
|
| 134 |
+
try:
|
| 135 |
+
progress(0.05, desc="Loading ASR …")
|
| 136 |
+
ai_pipeline._load_asr()
|
| 137 |
+
progress(0.55, desc="Loading translation …")
|
| 138 |
+
ai_pipeline._load_nllb()
|
| 139 |
+
progress(0.85, desc="Loading speech synthesis …")
|
| 140 |
+
ai_pipeline._load_tts()
|
| 141 |
+
progress(1.0, desc="Ready")
|
| 142 |
+
state["warm"] = True
|
| 143 |
+
gr.Info("Models loaded. You can speak now.")
|
| 144 |
+
return f"Ready · {ai_pipeline.describe_models()}", state
|
| 145 |
+
except Exception as e:
|
| 146 |
+
logger.exception("warm-up failed")
|
| 147 |
+
gr.Warning(f"Model load failed: {e}")
|
| 148 |
+
return f"Model load failed: {e}", state
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def on_text(text, state):
|
| 152 |
+
if state is None:
|
| 153 |
+
state = new_state()
|
| 154 |
+
if not text or not text.strip():
|
| 155 |
+
return gr.skip(), render(state["history"]), "Type or say something first.", state
|
| 156 |
+
try:
|
| 157 |
+
audio, status = run_turn(text.strip(), state)
|
| 158 |
+
return audio, render(state["history"]), status, state
|
| 159 |
+
except Exception as e:
|
| 160 |
+
logger.exception("turn failed")
|
| 161 |
+
gr.Warning(str(e))
|
| 162 |
+
return gr.skip(), render(state["history"]), f"Error: {e}", state
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def on_record(audio, state):
|
| 166 |
+
"""Push-to-talk: one complete recording."""
|
| 167 |
+
if state is None:
|
| 168 |
+
state = new_state()
|
| 169 |
+
if audio is None:
|
| 170 |
+
return gr.skip(), render(state["history"]), "No audio recorded.", state
|
| 171 |
+
sr, arr = audio
|
| 172 |
+
try:
|
| 173 |
+
import time
|
| 174 |
+
t = time.perf_counter()
|
| 175 |
+
hausa = ai_pipeline.transcribe(arr, sr)
|
| 176 |
+
asr_ms = (time.perf_counter() - t) * 1000
|
| 177 |
+
if not hausa.strip():
|
| 178 |
+
return gr.skip(), render(state["history"]), "Didn't catch that.", state
|
| 179 |
+
out, status = run_turn(hausa, state, asr_ms)
|
| 180 |
+
return out, render(state["history"]), status, state
|
| 181 |
+
except Exception as e:
|
| 182 |
+
logger.exception("record failed")
|
| 183 |
+
gr.Warning(str(e))
|
| 184 |
+
return gr.skip(), render(state["history"]), f"Error: {e}", state
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def on_stream(chunk, state):
|
| 188 |
"""
|
| 189 |
+
Live mic. Fires every STREAM_EVERY seconds. VAD decides when the turn ends,
|
| 190 |
+
so there is no send button.
|
| 191 |
|
| 192 |
+
Returns gr.skip() for the audio output on ticks with no reply, otherwise
|
| 193 |
+
the player restarts on every tick.
|
| 194 |
"""
|
| 195 |
if state is None:
|
| 196 |
+
state = new_state()
|
| 197 |
+
if chunk is None:
|
| 198 |
+
return gr.skip(), gr.skip(), gr.skip(), state.get("partial", ""), state
|
|
|
|
| 199 |
|
| 200 |
if state["sasr"] is None:
|
| 201 |
state["sasr"] = ai_pipeline.make_streaming_session(
|
| 202 |
emit_partials=SHOW_PARTIALS)
|
| 203 |
|
| 204 |
+
sr, arr = chunk
|
| 205 |
+
try:
|
| 206 |
+
events = state["sasr"].accept_audio(arr, sr)
|
| 207 |
+
except Exception as e:
|
| 208 |
+
logger.exception("stream failed")
|
| 209 |
+
return gr.skip(), gr.skip(), f"Stream error: {e}", "", state
|
| 210 |
|
| 211 |
+
audio_out, status, convo = gr.skip(), gr.skip(), gr.skip()
|
|
|
|
| 212 |
|
| 213 |
for ev in events:
|
| 214 |
if ev.kind == "speech_start":
|
| 215 |
+
status = "listening — speech detected"
|
|
|
|
| 216 |
elif ev.kind == "partial":
|
| 217 |
state["partial"] = ev.text
|
| 218 |
+
status = f"listening … {ev.duration_ms/1000:.1f}s"
|
|
|
|
| 219 |
elif ev.kind == "bargein":
|
|
|
|
| 220 |
audio_out = None
|
| 221 |
+
status = "you interrupted — go ahead"
|
|
|
|
| 222 |
elif ev.kind == "discarded":
|
| 223 |
state["partial"] = ""
|
|
|
|
|
|
|
| 224 |
elif ev.kind == "final":
|
| 225 |
state["partial"] = ""
|
| 226 |
+
state["sasr"].agent_speaking = True
|
| 227 |
+
audio_out, status = run_turn(ev.text, state, ev.latency_ms)
|
| 228 |
+
convo = render(state["history"])
|
| 229 |
|
| 230 |
+
return audio_out, convo, status, state.get("partial", ""), state
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
|
| 232 |
|
| 233 |
+
def reset(state):
|
| 234 |
+
return None, render([]), "New session.", "", new_state()
|
|
|
|
| 235 |
|
| 236 |
|
| 237 |
+
# ── UI ───────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
| 238 |
|
| 239 |
+
CSS = """
|
| 240 |
+
@import url('https://fonts.googleapis.com/css2?family=Sora:wght@400;600;800&family=IBM+Plex+Mono:wght@400;500&display=swap');
|
| 241 |
+
:root{--bg:#0B0B0F;--pnl:#15151C;--brd:#26262F;--txt:#E8E6E3;--mut:#7A7A88;
|
| 242 |
+
--acc:#FF8A3D;--acc2:#4ADE80;--blu:#60A5FA}
|
| 243 |
+
.gradio-container{background:var(--bg)!important;font-family:'Sora',sans-serif!important;
|
| 244 |
+
color:var(--txt)!important;max-width:1280px!important}
|
| 245 |
+
.hdr{background:linear-gradient(135deg,#15151C,#1E1E28 60%,#15151C);
|
| 246 |
+
border:1px solid var(--brd);border-radius:14px;padding:22px 26px;margin-bottom:14px}
|
| 247 |
+
.hdr h1{font-size:25px;font-weight:800;margin:0;letter-spacing:-.5px}
|
| 248 |
+
.hdr h1 b{color:var(--acc)}
|
| 249 |
+
.hdr p{color:var(--mut);font-size:12.5px;margin:6px 0 0;letter-spacing:.4px}
|
| 250 |
+
.tags{display:flex;gap:7px;margin-top:13px;flex-wrap:wrap}
|
| 251 |
+
.tag{background:rgba(255,138,61,.12);border:1px solid rgba(255,138,61,.3);
|
| 252 |
+
color:var(--acc);padding:3px 11px;border-radius:99px;font-size:10.5px;
|
| 253 |
+
font-weight:600;letter-spacing:.6px}
|
| 254 |
+
.tag.g{background:rgba(74,222,128,.12);border-color:rgba(74,222,128,.3);color:var(--acc2)}
|
| 255 |
+
.tag.b{background:rgba(96,165,250,.12);border-color:rgba(96,165,250,.3);color:var(--blu)}
|
| 256 |
+
.convo{background:var(--pnl);border:1px solid var(--brd);border-radius:13px;
|
| 257 |
+
padding:15px;height:395px;overflow-y:auto}
|
| 258 |
+
.empty{color:var(--mut);text-align:center;margin-top:150px;font-size:13px}
|
| 259 |
+
.row{display:flex;margin-bottom:11px}
|
| 260 |
+
.r-me{justify-content:flex-end}.r-ag{justify-content:flex-start}
|
| 261 |
+
.bub{max-width:86%;padding:10px 13px;border-radius:13px;font-size:13.5px;line-height:1.5}
|
| 262 |
+
.b-me{background:rgba(255,138,61,.14);border:1px solid rgba(255,138,61,.26);
|
| 263 |
+
border-bottom-right-radius:4px}
|
| 264 |
+
.b-ag{background:rgba(255,255,255,.045);border:1px solid var(--brd);
|
| 265 |
+
border-bottom-left-radius:4px}
|
| 266 |
+
.lbl{font-size:9.5px;font-weight:700;letter-spacing:.9px;opacity:.55;margin-bottom:4px}
|
| 267 |
+
.ha{font-weight:600}
|
| 268 |
+
.en{font-size:11.5px;color:var(--mut);margin-top:3px;font-style:italic}
|
| 269 |
+
.tm{font-size:9.5px;color:#4A4A56;margin-top:4px;font-family:'IBM Plex Mono',monospace}
|
| 270 |
+
.note{background:rgba(96,165,250,.07);border:1px solid rgba(96,165,250,.22);
|
| 271 |
+
border-radius:11px;padding:13px 15px;font-size:12.5px;color:#B9C4D4;line-height:1.65}
|
| 272 |
+
.note b{color:var(--blu)}
|
| 273 |
+
footer{display:none!important}
|
| 274 |
+
"""
|
| 275 |
|
| 276 |
+
with gr.Blocks(css=CSS, title="PlotWeaver · Hausa Voice AI",
|
| 277 |
+
theme=gr.themes.Base()) as demo:
|
| 278 |
+
|
| 279 |
+
st = gr.State(None)
|
| 280 |
+
|
| 281 |
+
gr.HTML(f"""
|
| 282 |
+
<div class="hdr">
|
| 283 |
+
<h1>Plot<b>Weaver</b> — Hausa Voice AI Agent</h1>
|
| 284 |
+
<p>Real-time conversational AI for 100M+ Hausa speakers · investor POC</p>
|
| 285 |
+
<div class="tags">
|
| 286 |
+
<span class="tag">Whisper ASR</span>
|
| 287 |
+
<span class="tag">NLLB-200</span>
|
| 288 |
+
<span class="tag">MMS-TTS</span>
|
| 289 |
+
<span class="tag g">VAD endpointing</span>
|
| 290 |
+
<span class="tag g">Multi-intent NLU</span>
|
| 291 |
+
<span class="tag b">{'GPU' if HAS_GPU else 'CPU'} mode</span>
|
|
|
|
|
|
|
| 292 |
</div>
|
| 293 |
+
</div>""")
|
|
|
|
| 294 |
|
| 295 |
with gr.Tabs():
|
| 296 |
+
with gr.TabItem("Live Demo"):
|
| 297 |
+
gr.HTML('<div class="note"><b>First run:</b> press '
|
| 298 |
+
'<b>Warm up models</b> — roughly 2–4 minutes while ~3.5GB '
|
| 299 |
+
'downloads and caches. It only happens once per Space '
|
| 300 |
+
'restart. On CPU, expect 5–15s per reply; a GPU brings '
|
| 301 |
+
'that under 3s.</div>')
|
| 302 |
|
|
|
|
|
|
|
| 303 |
with gr.Row():
|
| 304 |
+
with gr.Column(scale=3):
|
| 305 |
+
convo = gr.HTML(render([]), elem_classes=["convo"])
|
| 306 |
+
partial_box = gr.Textbox(label="Live transcript",
|
| 307 |
+
interactive=False, lines=1)
|
| 308 |
+
status = gr.Textbox(
|
| 309 |
+
label="Status",
|
| 310 |
+
value=("Not warmed up." if INTEGRATIONS_OK else
|
| 311 |
+
"Not warmed up · demo mode (no integrations)."),
|
| 312 |
+
interactive=False, lines=2)
|
| 313 |
+
|
| 314 |
with gr.Column(scale=2):
|
| 315 |
+
warm_btn = gr.Button("Warm up models", variant="primary")
|
| 316 |
+
|
| 317 |
+
reply = gr.Audio(label="Agent reply", autoplay=True,
|
| 318 |
+
interactive=False)
|
| 319 |
+
|
| 320 |
+
with gr.Tab("Type"):
|
| 321 |
+
txt = gr.Textbox(
|
| 322 |
+
label="Hausa text",
|
| 323 |
+
placeholder="Duba asusuna sannan ka aika 35000 zuwa Amina",
|
| 324 |
+
lines=2)
|
| 325 |
+
send = gr.Button("Send", variant="primary")
|
| 326 |
+
|
| 327 |
+
with gr.Tab("Record"):
|
| 328 |
+
rec = gr.Audio(sources=["microphone", "upload"],
|
| 329 |
+
type="numpy", label="Record, then Send")
|
| 330 |
+
send_rec = gr.Button("Send recording", variant="primary")
|
| 331 |
+
|
| 332 |
+
with gr.Tab("Live mic (VAD)"):
|
| 333 |
+
gr.Markdown(
|
| 334 |
+
"Speak naturally — the VAD ends your turn on a "
|
| 335 |
+
"pause. Heavy on CPU; use Type or Record if it lags.")
|
| 336 |
+
live = gr.Audio(sources=["microphone"], type="numpy",
|
| 337 |
+
streaming=True, label="Live")
|
| 338 |
+
|
| 339 |
+
reset_btn = gr.Button("Reset conversation")
|
| 340 |
+
|
| 341 |
+
gr.Markdown("**Demo prompts**")
|
| 342 |
+
for label, phrase in DEMO_PROMPTS:
|
| 343 |
+
gr.Button(label, size="sm").click(
|
| 344 |
+
lambda p=phrase: p, outputs=[txt])
|
| 345 |
+
|
| 346 |
+
warm_btn.click(warm_up, [st], [status, st])
|
| 347 |
+
send.click(on_text, [txt, st], [reply, convo, status, st])
|
| 348 |
+
txt.submit(on_text, [txt, st], [reply, convo, status, st])
|
| 349 |
+
send_rec.click(on_record, [rec, st], [reply, convo, status, st])
|
| 350 |
+
live.stream(on_stream, [live, st],
|
| 351 |
+
[reply, convo, status, partial_box, st],
|
| 352 |
+
stream_every=STREAM_EVERY, show_progress="hidden")
|
| 353 |
+
reset_btn.click(reset, [st],
|
| 354 |
+
[reply, convo, status, partial_box, st])
|
| 355 |
+
|
| 356 |
+
with gr.TabItem("How it works"):
|
| 357 |
+
gr.Markdown("""
|
| 358 |
+
### Pipeline
|
| 359 |
+
|
| 360 |
+
`mic → VAD endpointing → Whisper (Hausa) → NLLB hau→eng → task-queue
|
| 361 |
+
orchestrator → NLLB eng→hau → MMS-TTS → audio`
|
| 362 |
+
|
| 363 |
+
### What the dialogue layer does
|
| 364 |
+
|
| 365 |
+
Most voice bots resolve one intent and one missing slot per turn, which breaks
|
| 366 |
+
the moment a caller combines requests or answers out of order. This one
|
| 367 |
+
decomposes each message into a **task queue**:
|
| 368 |
+
|
| 369 |
+
| Caller says | What happens |
|
| 370 |
+
|---|---|
|
| 371 |
+
| "check my balance **and also** send 35000 to Amina" | Both tasks queued and acknowledged; neither is silently dropped |
|
| 372 |
+
| "send money **to Abu**" | Recipient prefilled from the utterance — never re-asked |
|
| 373 |
+
| "where's your branch so I can **get** my ATM card" | Branch info. *Not* block-card — destructive intents need an explicit verb |
|
| 374 |
+
| "too small" (as a return reason) | Accepted as a slot answer, not a fallback dead-end |
|
| 375 |
+
| Transfer above balance | Refused before the confirmation prompt |
|
| 376 |
+
| Two unparseable turns | Human handoff **with the transcript attached** |
|
| 377 |
+
|
| 378 |
+
Money actions require confidence ≥ 0.75 *and* an explicit yes/no.
|
| 379 |
+
|
| 380 |
+
### Model configuration
|
| 381 |
+
|
| 382 |
+
Defaults are tuned for a free CPU Space. Override via Space secrets:
|
| 383 |
+
|
| 384 |
+
| Variable | Default | Notes |
|
| 385 |
+
|---|---|---|
|
| 386 |
+
| `ASR_FINAL_MODEL` | `openai/whisper-small` | `whisper-large-v3` on GPU |
|
| 387 |
+
| `SHOW_PARTIALS` | `0` | `1` only on GPU |
|
| 388 |
+
| `HF_TOKEN` | unset | Enables LLM-based NLU (big quality gain) |
|
| 389 |
+
| `CRM_PROVIDER` | `demo` | `zendesk` for real tickets |
|
| 390 |
+
|
| 391 |
+
Without `HF_TOKEN` the NLU uses rule-based decomposition: it passes the full
|
| 392 |
+
regression suite but only handles phrasings that were anticipated.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
""")
|
| 394 |
|
| 395 |
+
with gr.TabItem("Market"):
|
| 396 |
+
gr.Markdown("""
|
| 397 |
+
### Why Hausa, why now
|
| 398 |
+
|
| 399 |
+
| | |
|
| 400 |
+
|---|---|
|
| 401 |
+
| **100M+** | Hausa speakers — the largest language in West Africa |
|
| 402 |
+
| **~63%** | Low literacy in core regions — voice *is* the interface |
|
| 403 |
+
| **$4.2B** | Projected African contact-centre spend by 2027 |
|
| 404 |
+
| **~0** | Production-grade Hausa voice agents in market |
|
| 405 |
+
|
| 406 |
+
**Target verticals** — telecoms (MTN, Airtel, Glo), fintech and mobile money
|
| 407 |
+
(Kuda, PalmPay, OPay), public services (NIMC), health IVR, e-commerce.
|
| 408 |
+
|
| 409 |
+
**Moat** — open-weights stack with no per-call API lock-in; fine-tuned Hausa
|
| 410 |
+
models as owned IP; deployable on-premise for data-residency requirements;
|
| 411 |
+
multi-channel (voice, WhatsApp, SIP) from day one.
|
| 412 |
+
|
| 413 |
+
**Roadmap** — Yorùbá, Igbo, Fulfulde, Kanuri. NLLB covers 200 languages and
|
| 414 |
+
MMS covers 1,000+ for TTS, so each new language is a fine-tune, not a rebuild.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 415 |
""")
|
| 416 |
|
| 417 |
+
gr.HTML('<div style="text-align:center;padding:16px;color:#3A3A46;'
|
| 418 |
+
'font-size:11px">PlotWeaver · open-weights Hausa voice AI</div>')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 419 |
|
| 420 |
|
| 421 |
if __name__ == "__main__":
|
| 422 |
+
demo.queue(max_size=12).launch(
|
| 423 |
+
server_name="0.0.0.0",
|
| 424 |
+
server_port=int(os.getenv("PORT", 7860)),
|
| 425 |
+
show_api=False,
|
| 426 |
+
)
|
deploy.sh
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# ── Deploy to HuggingFace Spaces ─────────────────────────────────────────────
|
| 3 |
+
# Validates the file layout BEFORE pushing, because the most common failure is
|
| 4 |
+
# integrations/ never reaching the Space (the HF web uploader skips folders),
|
| 5 |
+
# which crashes startup with ModuleNotFoundError.
|
| 6 |
+
#
|
| 7 |
+
# Usage: ./deploy.sh <hf-username> <space-name>
|
| 8 |
+
# Example: ./deploy.sh plotweaver hausa-voice-agent
|
| 9 |
+
|
| 10 |
+
set -euo pipefail
|
| 11 |
+
|
| 12 |
+
USER="${1:-}"
|
| 13 |
+
SPACE="${2:-}"
|
| 14 |
+
|
| 15 |
+
if [[ -z "$USER" || -z "$SPACE" ]]; then
|
| 16 |
+
echo "Usage: ./deploy.sh <hf-username> <space-name>"
|
| 17 |
+
exit 1
|
| 18 |
+
fi
|
| 19 |
+
|
| 20 |
+
echo "── Validating layout ─────────────────────────────────────────"
|
| 21 |
+
|
| 22 |
+
REQUIRED=(
|
| 23 |
+
app.py pipeline.py nlu.py orchestrator.py vad.py streaming_asr.py
|
| 24 |
+
requirements.txt README.md
|
| 25 |
+
integrations/__init__.py integrations/crm.py
|
| 26 |
+
integrations/sip.py integrations/whatsapp.py
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
MISSING=0
|
| 30 |
+
for f in "${REQUIRED[@]}"; do
|
| 31 |
+
if [[ -f "$f" ]]; then
|
| 32 |
+
printf ' ✓ %s\n' "$f"
|
| 33 |
+
else
|
| 34 |
+
printf ' ✗ %s MISSING\n' "$f"
|
| 35 |
+
MISSING=1
|
| 36 |
+
fi
|
| 37 |
+
done
|
| 38 |
+
|
| 39 |
+
if [[ $MISSING -eq 1 ]]; then
|
| 40 |
+
echo ""
|
| 41 |
+
echo "Refusing to deploy: files are missing. The Space would crash on boot."
|
| 42 |
+
exit 1
|
| 43 |
+
fi
|
| 44 |
+
|
| 45 |
+
echo ""
|
| 46 |
+
echo "── Running tests ─────────────────────────────────────────────"
|
| 47 |
+
python verify_boot.py || { echo "Boot check failed."; exit 1; }
|
| 48 |
+
python test_app.py || { echo "App launch test failed."; exit 1; }
|
| 49 |
+
python test_streaming.py || { echo "Streaming tests failed."; exit 1; }
|
| 50 |
+
python test_regressions.py || { echo "NLU tests failed."; exit 1; }
|
| 51 |
+
|
| 52 |
+
echo ""
|
| 53 |
+
echo "── Pushing to Space ──────────────────────────────────────────"
|
| 54 |
+
|
| 55 |
+
if [[ ! -d .git ]]; then
|
| 56 |
+
git init -q
|
| 57 |
+
git remote add origin "https://huggingface.co/spaces/${USER}/${SPACE}"
|
| 58 |
+
fi
|
| 59 |
+
|
| 60 |
+
git add -A # -A, not '.', so nothing is quietly skipped
|
| 61 |
+
|
| 62 |
+
echo ""
|
| 63 |
+
echo "Staged files:"
|
| 64 |
+
git status --short
|
| 65 |
+
|
| 66 |
+
echo ""
|
| 67 |
+
read -r -p "Push these to ${USER}/${SPACE}? [y/N] " CONFIRM
|
| 68 |
+
[[ "$CONFIRM" == "y" || "$CONFIRM" == "Y" ]] || { echo "Aborted."; exit 0; }
|
| 69 |
+
|
| 70 |
+
git commit -q -m "Deploy Hausa Voice AI Agent" || echo "Nothing new to commit."
|
| 71 |
+
git push -u origin main
|
| 72 |
+
|
| 73 |
+
echo ""
|
| 74 |
+
echo "Done → https://huggingface.co/spaces/${USER}/${SPACE}"
|
| 75 |
+
echo "Set HF_TOKEN under Settings → Variables and secrets for best NLU quality."
|
nlu.py
CHANGED
|
@@ -91,7 +91,13 @@ class NLU:
|
|
| 91 |
"NLU_API_MODEL", "Qwen/Qwen2.5-72B-Instruct")
|
| 92 |
self.local_model_id = os.getenv(
|
| 93 |
"NLU_LOCAL_MODEL", "Qwen/Qwen2.5-1.5B-Instruct")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
self._local_pipe = None
|
|
|
|
| 95 |
self.prefer = prefer
|
| 96 |
|
| 97 |
# ── Public API ────────────────────────────────────────────────────────────
|
|
@@ -110,6 +116,9 @@ class NLU:
|
|
| 110 |
f"A short answer likely fills that slot.")
|
| 111 |
|
| 112 |
for backend in self._backend_order():
|
|
|
|
|
|
|
|
|
|
| 113 |
try:
|
| 114 |
result = backend(text, context)
|
| 115 |
if result and result.get("tasks"):
|
|
@@ -118,7 +127,12 @@ class NLU:
|
|
| 118 |
f"{json.dumps(result['tasks'])[:200]}")
|
| 119 |
return result
|
| 120 |
except Exception as e:
|
| 121 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
# Absolute last resort
|
| 123 |
return {"tasks": [{"intent": "unknown", "confidence": 0.0,
|
| 124 |
"slots": {}, "utterance_span": text}],
|
|
@@ -132,7 +146,8 @@ class NLU:
|
|
| 132 |
chain = []
|
| 133 |
if self.hf_token:
|
| 134 |
chain.append(self._api_backend)
|
| 135 |
-
|
|
|
|
| 136 |
chain.append(self._rules_backend)
|
| 137 |
return chain
|
| 138 |
|
|
|
|
| 91 |
"NLU_API_MODEL", "Qwen/Qwen2.5-72B-Instruct")
|
| 92 |
self.local_model_id = os.getenv(
|
| 93 |
"NLU_LOCAL_MODEL", "Qwen/Qwen2.5-1.5B-Instruct")
|
| 94 |
+
# The local backend is OPT-IN. Left automatic, the first NLU call on a
|
| 95 |
+
# Space silently downloads a ~3GB model mid-demo and blocks for
|
| 96 |
+
# minutes. Enable deliberately with NLU_LOCAL=1 on hardware that can
|
| 97 |
+
# take it.
|
| 98 |
+
self.use_local = os.getenv("NLU_LOCAL", "0") == "1"
|
| 99 |
self._local_pipe = None
|
| 100 |
+
self._dead = set() # backends that failed — never retried
|
| 101 |
self.prefer = prefer
|
| 102 |
|
| 103 |
# ── Public API ────────────────────────────────────────────────────────────
|
|
|
|
| 116 |
f"A short answer likely fills that slot.")
|
| 117 |
|
| 118 |
for backend in self._backend_order():
|
| 119 |
+
name = backend.__name__
|
| 120 |
+
if name in self._dead:
|
| 121 |
+
continue
|
| 122 |
try:
|
| 123 |
result = backend(text, context)
|
| 124 |
if result and result.get("tasks"):
|
|
|
|
| 127 |
f"{json.dumps(result['tasks'])[:200]}")
|
| 128 |
return result
|
| 129 |
except Exception as e:
|
| 130 |
+
# Mark dead so a missing dependency or bad token doesn't cost
|
| 131 |
+
# a retry (and a re-download attempt) on every single turn.
|
| 132 |
+
self._dead.add(name)
|
| 133 |
+
logger.warning(
|
| 134 |
+
f"NLU backend {name} failed and is disabled for this "
|
| 135 |
+
f"session: {e}")
|
| 136 |
# Absolute last resort
|
| 137 |
return {"tasks": [{"intent": "unknown", "confidence": 0.0,
|
| 138 |
"slots": {}, "utterance_span": text}],
|
|
|
|
| 146 |
chain = []
|
| 147 |
if self.hf_token:
|
| 148 |
chain.append(self._api_backend)
|
| 149 |
+
if self.use_local:
|
| 150 |
+
chain.append(self._local_backend)
|
| 151 |
chain.append(self._rules_backend)
|
| 152 |
return chain
|
| 153 |
|
pipeline.py
CHANGED
|
@@ -34,11 +34,24 @@ class HausaVoiceAIPipeline:
|
|
| 34 |
the Space startup time reasonable on CPU.
|
| 35 |
"""
|
| 36 |
|
| 37 |
-
#
|
| 38 |
-
#
|
| 39 |
-
#
|
| 40 |
-
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
def __init__(self, pivot_language: str = "english"):
|
| 44 |
self.pivot = pivot_language # dialogue logic runs in English
|
|
@@ -89,7 +102,7 @@ class HausaVoiceAIPipeline:
|
|
| 89 |
return
|
| 90 |
logger.info("Loading NLLB-200-distilled-600M …")
|
| 91 |
from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
|
| 92 |
-
model_id =
|
| 93 |
self._nllb_tokenizer = AutoTokenizer.from_pretrained(model_id)
|
| 94 |
self._nllb_model = AutoModelForSeq2SeqLM.from_pretrained(model_id).to(DEVICE)
|
| 95 |
|
|
@@ -98,7 +111,7 @@ class HausaVoiceAIPipeline:
|
|
| 98 |
return
|
| 99 |
logger.info("Loading MMS-TTS Hausa …")
|
| 100 |
from transformers import VitsModel, AutoTokenizer
|
| 101 |
-
model_id =
|
| 102 |
self._tts_tokenizer = AutoTokenizer.from_pretrained(model_id)
|
| 103 |
self._tts_model = VitsModel.from_pretrained(model_id).to(DEVICE)
|
| 104 |
self.tts_sample_rate = self._tts_model.config.sampling_rate
|
|
|
|
| 34 |
the Space startup time reasonable on CPU.
|
| 35 |
"""
|
| 36 |
|
| 37 |
+
# Model selection is CPU-FIRST. whisper-large-v3 is ~6GB in fp32 and takes
|
| 38 |
+
# roughly a minute per utterance on the 2-vCPU free tier — it makes the
|
| 39 |
+
# Space look broken. Default to small on CPU, large-v3 when a GPU is
|
| 40 |
+
# present, and let an env var override either way.
|
| 41 |
+
_GPU = torch.cuda.is_available()
|
| 42 |
+
ASR_FINAL_MODEL = os.getenv(
|
| 43 |
+
"ASR_FINAL_MODEL",
|
| 44 |
+
"openai/whisper-large-v3" if _GPU else "openai/whisper-small")
|
| 45 |
+
ASR_PARTIAL_MODEL = os.getenv(
|
| 46 |
+
"ASR_PARTIAL_MODEL",
|
| 47 |
+
"openai/whisper-small" if _GPU else "openai/whisper-base")
|
| 48 |
+
NLLB_MODEL = os.getenv("NLLB_MODEL", "facebook/nllb-200-distilled-600M")
|
| 49 |
+
TTS_MODEL = os.getenv("TTS_MODEL", "facebook/mms-tts-hau")
|
| 50 |
+
|
| 51 |
+
def describe_models(self) -> str:
|
| 52 |
+
short = lambda m: m.split("/")[-1]
|
| 53 |
+
return (f"{short(self.ASR_FINAL_MODEL)} · {short(self.NLLB_MODEL)} · "
|
| 54 |
+
f"{short(self.TTS_MODEL)} · {'GPU' if self._GPU else 'CPU'}")
|
| 55 |
|
| 56 |
def __init__(self, pivot_language: str = "english"):
|
| 57 |
self.pivot = pivot_language # dialogue logic runs in English
|
|
|
|
| 102 |
return
|
| 103 |
logger.info("Loading NLLB-200-distilled-600M …")
|
| 104 |
from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
|
| 105 |
+
model_id = self.NLLB_MODEL
|
| 106 |
self._nllb_tokenizer = AutoTokenizer.from_pretrained(model_id)
|
| 107 |
self._nllb_model = AutoModelForSeq2SeqLM.from_pretrained(model_id).to(DEVICE)
|
| 108 |
|
|
|
|
| 111 |
return
|
| 112 |
logger.info("Loading MMS-TTS Hausa …")
|
| 113 |
from transformers import VitsModel, AutoTokenizer
|
| 114 |
+
model_id = self.TTS_MODEL
|
| 115 |
self._tts_tokenizer = AutoTokenizer.from_pretrained(model_id)
|
| 116 |
self._tts_model = VitsModel.from_pretrained(model_id).to(DEVICE)
|
| 117 |
self.tts_sample_rate = self._tts_model.config.sampling_rate
|
requirements.txt
CHANGED
|
@@ -1,37 +1,40 @@
|
|
| 1 |
# ── PlotWeaver Hausa Voice AI Agent ──────────────────────────────────────────
|
| 2 |
-
#
|
| 3 |
-
#
|
| 4 |
-
#
|
| 5 |
-
#
|
| 6 |
-
#
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
# ── Core ML ──────────────────────────────────────────────────────────────────
|
| 9 |
torch>=2.2.0 # Whisper, NLLB, MMS, Silero VAD
|
| 10 |
transformers>=4.40.0 # >=4.40 required for VitsModel (MMS-TTS)
|
| 11 |
-
sentencepiece>=0.2.0 # NLLB tokenizer — NOT optional
|
| 12 |
-
accelerate>=0.28.0 #
|
| 13 |
protobuf>=4.25.0 # slow→fast tokenizer conversion for NLLB
|
| 14 |
|
| 15 |
# ── Audio ────────────────────────────────────────────────────────────────────
|
| 16 |
-
numpy>=1.26.0,<3.0.0
|
| 17 |
scipy>=1.12.0 # scipy.signal.resample — the only resampler used
|
| 18 |
soundfile>=0.12.1 # Gradio audio I/O backend
|
| 19 |
|
| 20 |
-
# ──
|
| 21 |
-
gradio>=4.44.0 # streaming=True + stream_every need >=4.44
|
| 22 |
requests>=2.31.0 # WhatsApp Cloud API, Zendesk, HF Inference API
|
| 23 |
|
| 24 |
-
# ── Optional
|
| 25 |
-
#
|
| 26 |
-
#
|
| 27 |
-
#
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
# ── Deliberately absent ──────────────────────────────────────────────────────
|
| 31 |
-
#
|
| 32 |
-
#
|
| 33 |
-
#
|
| 34 |
-
#
|
| 35 |
-
# audioop — G.711 mu-law decoding is implemented in numpy in
|
| 36 |
-
# integrations/sip.py, because audioop was REMOVED from the
|
| 37 |
-
# stdlib in Python 3.13 and would break telephony on new images.
|
|
|
|
| 1 |
# ── PlotWeaver Hausa Voice AI Agent ──────────────────────────────────────────
|
| 2 |
+
# Versions verified by actually launching the app (see test_app.py), not by
|
| 3 |
+
# assumption. Two things were caught this way:
|
| 4 |
+
# * gradio 4.44 is now BROKEN against current huggingface_hub (HfFolder was
|
| 5 |
+
# removed) — a fresh Space build on 4.44 fails at import.
|
| 6 |
+
# * `stream_every` on Audio.stream does not exist before gradio 5.
|
| 7 |
+
# Keep sdk_version in README.md frontmatter in sync with the gradio pin below.
|
| 8 |
+
|
| 9 |
+
gradio>=5.49.0,<6.0.0 # Audio.stream(stream_every=...) + gr.skip()
|
| 10 |
|
| 11 |
# ── Core ML ──────────────────────────────────────────────────────────────────
|
| 12 |
torch>=2.2.0 # Whisper, NLLB, MMS, Silero VAD
|
| 13 |
transformers>=4.40.0 # >=4.40 required for VitsModel (MMS-TTS)
|
| 14 |
+
sentencepiece>=0.2.0 # NLLB tokenizer — NOT optional
|
| 15 |
+
accelerate>=0.28.0 # device placement
|
| 16 |
protobuf>=4.25.0 # slow→fast tokenizer conversion for NLLB
|
| 17 |
|
| 18 |
# ── Audio ────────────────────────────────────────────────────────────────────
|
| 19 |
+
numpy>=1.26.0,<3.0.0
|
| 20 |
scipy>=1.12.0 # scipy.signal.resample — the only resampler used
|
| 21 |
soundfile>=0.12.1 # Gradio audio I/O backend
|
| 22 |
|
| 23 |
+
# ── Misc ─────────────────────────────────────────────────────────────────────
|
|
|
|
| 24 |
requests>=2.31.0 # WhatsApp Cloud API, Zendesk, HF Inference API
|
| 25 |
|
| 26 |
+
# ── Optional ─────────────────────────────────────────────────────────────────
|
| 27 |
+
# torchaudio is NOT required by this app, but Silero VAD imports it via
|
| 28 |
+
# torch.hub. Without it the built-in energy VAD is used instead, which works
|
| 29 |
+
# fine — uncomment only if you want Silero's noise robustness.
|
| 30 |
+
# torchaudio>=2.2.0
|
| 31 |
+
#
|
| 32 |
+
# Only needed with SIP_PROVIDER=twilio and real credentials; sip.py imports it
|
| 33 |
+
# lazily, so the demo never touches it.
|
| 34 |
+
# twilio>=9.0.0
|
| 35 |
|
| 36 |
# ── Deliberately absent ──────────────────────────────────────────────────────
|
| 37 |
+
# librosa — resampling uses scipy.signal.resample.
|
| 38 |
+
# silero-vad — loaded via torch.hub with an energy-VAD fallback.
|
| 39 |
+
# audioop — removed from the stdlib in Python 3.13; G.711 mu-law decoding
|
| 40 |
+
# is implemented in numpy in integrations/sip.py.
|
|
|
|
|
|
|
|
|
test_app.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Verifies the app ACTUALLY LAUNCHES under the pinned gradio, and that every
|
| 3 |
+
event handler is wired with a matching signature.
|
| 4 |
+
|
| 5 |
+
This is the check that was missing before: the previous app.py used
|
| 6 |
+
`stream_every`, which does not exist in gradio 4.44 — it would have crashed on
|
| 7 |
+
Space startup no matter how correct the rest of the code was.
|
| 8 |
+
|
| 9 |
+
Models are stubbed, so this tests the app wiring, not the ML.
|
| 10 |
+
"""
|
| 11 |
+
import os
|
| 12 |
+
import sys
|
| 13 |
+
import types
|
| 14 |
+
import numpy as np
|
| 15 |
+
|
| 16 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 17 |
+
|
| 18 |
+
# ── Stub the pipeline so no 3.5GB download happens ───────────────────────────
|
| 19 |
+
import pipeline as _pl
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _fake_transcribe(self, audio, sr=16000):
|
| 23 |
+
return "duba asusuna sannan ka aika 35000 zuwa Amina"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _fake_translate(self, text, src, tgt, **kw):
|
| 27 |
+
table = {
|
| 28 |
+
"duba asusuna sannan ka aika 35000 zuwa Amina":
|
| 29 |
+
"check my balance and also send 35000 to amina",
|
| 30 |
+
"2234567890": "2234567890",
|
| 31 |
+
"eh": "yes",
|
| 32 |
+
}
|
| 33 |
+
return table.get(text, text)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _fake_tts(self, text):
|
| 37 |
+
return 16000, (np.sin(np.arange(8000) / 20) * 8000).astype(np.int16)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
_pl.HausaVoiceAIPipeline.transcribe = _fake_transcribe
|
| 41 |
+
_pl.HausaVoiceAIPipeline.transcribe_partial = _fake_transcribe
|
| 42 |
+
_pl.HausaVoiceAIPipeline.translate = _fake_translate
|
| 43 |
+
_pl.HausaVoiceAIPipeline.synthesize = _fake_tts
|
| 44 |
+
_pl.HausaVoiceAIPipeline.hausa_text_to_audio = _fake_tts
|
| 45 |
+
_pl.HausaVoiceAIPipeline._load_asr = lambda self: None
|
| 46 |
+
_pl.HausaVoiceAIPipeline._load_asr_fast = lambda self: None
|
| 47 |
+
_pl.HausaVoiceAIPipeline._load_nllb = lambda self: None
|
| 48 |
+
_pl.HausaVoiceAIPipeline._load_tts = lambda self: None
|
| 49 |
+
|
| 50 |
+
os.environ.setdefault("SHOW_PARTIALS", "0")
|
| 51 |
+
|
| 52 |
+
PASS, FAIL = "\033[92mPASS\033[0m", "\033[91mFAIL\033[0m"
|
| 53 |
+
results = []
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def check(name, cond, detail=""):
|
| 57 |
+
results.append((name, cond))
|
| 58 |
+
print(f" [{PASS if cond else FAIL}] {name}" + (f" — {detail}" if detail else ""))
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
print("\n══ App imports and builds under the pinned gradio ══")
|
| 62 |
+
import gradio as gr
|
| 63 |
+
print(f" gradio {gr.__version__}")
|
| 64 |
+
import app as A
|
| 65 |
+
check("app module imported", True)
|
| 66 |
+
check("Blocks object built", isinstance(A.demo, gr.Blocks))
|
| 67 |
+
|
| 68 |
+
print("\n══ Streaming API exists in this gradio version ══")
|
| 69 |
+
import inspect
|
| 70 |
+
p = inspect.signature(gr.Audio(streaming=True).stream).parameters
|
| 71 |
+
check("Audio.stream accepts stream_every", "stream_every" in p)
|
| 72 |
+
check("gr.skip available", hasattr(gr, "skip"))
|
| 73 |
+
|
| 74 |
+
print("\n══ Event handlers are wired ══")
|
| 75 |
+
fns = A.demo.fns
|
| 76 |
+
check("events registered", len(fns) > 0, f"{len(fns)} handlers")
|
| 77 |
+
|
| 78 |
+
print("\n══ Text handler: full compound-request conversation ══")
|
| 79 |
+
st = None
|
| 80 |
+
audio, convo, status, st = A.on_text(
|
| 81 |
+
"duba asusuna sannan ka aika 35000 zuwa Amina", st)
|
| 82 |
+
check("turn 1 returns audio", audio is not None and audio is not gr.skip())
|
| 83 |
+
check("turn 1 acknowledges BOTH tasks",
|
| 84 |
+
"one at a time" in convo.lower() or "balance" in convo.lower(),
|
| 85 |
+
status[:70])
|
| 86 |
+
audio, convo, status, st = A.on_text("2234567890", st)
|
| 87 |
+
check("turn 2 delivers balance + continues transfer",
|
| 88 |
+
"balance is" in convo.lower() and "amina" in convo.lower())
|
| 89 |
+
audio, convo, status, st = A.on_text("eh", st)
|
| 90 |
+
check("turn 3 executes the transfer", "sent to amina" in convo.lower())
|
| 91 |
+
check("latency breakdown in status", "total" in status, status[-52:])
|
| 92 |
+
|
| 93 |
+
print("\n══ Empty input does not crash ══")
|
| 94 |
+
a, c, s, st2 = A.on_text("", None)
|
| 95 |
+
check("empty text handled", a == gr.skip() or a is None)
|
| 96 |
+
a, c, s, st2 = A.on_record(None, None)
|
| 97 |
+
check("no recording handled", a == gr.skip() or a is None)
|
| 98 |
+
|
| 99 |
+
print("\n══ Streaming handler with synthetic audio ══")
|
| 100 |
+
SR = 16000
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def sil(ms, n=0.0005):
|
| 104 |
+
k = int(SR * ms / 1000)
|
| 105 |
+
return (np.random.randn(k) * n).astype(np.float32)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def sp(ms, amp=0.25):
|
| 109 |
+
k = int(SR * ms / 1000)
|
| 110 |
+
t = np.arange(k) / SR
|
| 111 |
+
s = (np.sin(2*np.pi*120*t) + 0.5*np.sin(2*np.pi*700*t)
|
| 112 |
+
+ 0.3*np.sin(2*np.pi*1220*t))
|
| 113 |
+
s = s * (0.6 + 0.4*np.sin(2*np.pi*4*t)) + np.random.randn(k)*0.01
|
| 114 |
+
return (s / np.abs(s).max() * amp).astype(np.float32)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
st = A.new_state()
|
| 118 |
+
stream = np.concatenate([sil(400), sp(1600), sil(1200)])
|
| 119 |
+
step = int(SR * 0.5)
|
| 120 |
+
got_reply = False
|
| 121 |
+
n_skips = 0
|
| 122 |
+
for i in range(0, len(stream), step):
|
| 123 |
+
out = A.on_stream((SR, stream[i:i+step]), st)
|
| 124 |
+
check_len = len(out) == 5
|
| 125 |
+
audio_o, convo_o, status_o, partial_o, st = out
|
| 126 |
+
if audio_o == gr.skip():
|
| 127 |
+
n_skips += 1
|
| 128 |
+
elif audio_o is not None:
|
| 129 |
+
got_reply = True
|
| 130 |
+
check("stream handler returns 5 values", check_len)
|
| 131 |
+
check("VAD endpointed and produced a reply", got_reply)
|
| 132 |
+
check("idle ticks return gr.skip (no player restart)", n_skips > 0,
|
| 133 |
+
f"{n_skips} skipped ticks")
|
| 134 |
+
|
| 135 |
+
print("\n══ Reset ══")
|
| 136 |
+
r = A.reset(st)
|
| 137 |
+
check("reset returns 5 values", len(r) == 5)
|
| 138 |
+
check("history cleared", "Press" in r[1] or "empty" in r[1])
|
| 139 |
+
|
| 140 |
+
print("\n══ Launch smoke test (real server, then shut down) ══")
|
| 141 |
+
try:
|
| 142 |
+
A.demo.queue(max_size=4)
|
| 143 |
+
_, url, _ = A.demo.launch(prevent_thread_lock=True, quiet=True,
|
| 144 |
+
server_port=7899, show_api=False)
|
| 145 |
+
check("Gradio server started", True, url or "local")
|
| 146 |
+
import urllib.request
|
| 147 |
+
code = urllib.request.urlopen("http://127.0.0.1:7899/", timeout=15).getcode()
|
| 148 |
+
check("HTTP 200 from the app root", code == 200, f"status {code}")
|
| 149 |
+
A.demo.close()
|
| 150 |
+
except Exception as e:
|
| 151 |
+
check("Gradio server started", False, str(e)[:110])
|
| 152 |
+
|
| 153 |
+
passed = sum(1 for _, ok in results if ok)
|
| 154 |
+
print(f"\n{'='*62}\n {passed}/{len(results)} checks passed\n{'='*62}")
|
| 155 |
+
sys.exit(0 if passed == len(results) else 1)
|
verify_boot.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Verifies app.py imports cleanly under file-layout problems that have actually
|
| 3 |
+
broken this Space:
|
| 4 |
+
|
| 5 |
+
A integrations/ folder missing entirely (HF web uploader skips folders)
|
| 6 |
+
B integrations/ present but without __init__.py
|
| 7 |
+
C integrations/ complete
|
| 8 |
+
D app started from a different working directory
|
| 9 |
+
|
| 10 |
+
Uses the REAL gradio (a verified dependency) and stubs only model loading, so
|
| 11 |
+
nothing downloads. Never launches a server — that is test_app.py's job.
|
| 12 |
+
"""
|
| 13 |
+
import os
|
| 14 |
+
import shutil
|
| 15 |
+
import subprocess
|
| 16 |
+
import sys
|
| 17 |
+
import tempfile
|
| 18 |
+
|
| 19 |
+
SRC = os.path.dirname(os.path.abspath(__file__))
|
| 20 |
+
GREEN, RED, OFF = "\033[92m", "\033[91m", "\033[0m"
|
| 21 |
+
|
| 22 |
+
HARNESS = '''
|
| 23 |
+
import sys
|
| 24 |
+
sys.path.insert(0, sys.argv[2])
|
| 25 |
+
import pipeline as _pl
|
| 26 |
+
_pl.HausaVoiceAIPipeline._load_asr = lambda self: None
|
| 27 |
+
_pl.HausaVoiceAIPipeline._load_asr_fast = lambda self: None
|
| 28 |
+
_pl.HausaVoiceAIPipeline._load_nllb = lambda self: None
|
| 29 |
+
_pl.HausaVoiceAIPipeline._load_tts = lambda self: None
|
| 30 |
+
import runpy
|
| 31 |
+
runpy.run_path(sys.argv[1], run_name="__not_main__")
|
| 32 |
+
print("IMPORT_OK")
|
| 33 |
+
'''
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def scenario(name, setup, cwd=None):
|
| 37 |
+
tmp = tempfile.mkdtemp()
|
| 38 |
+
for f in os.listdir(SRC):
|
| 39 |
+
if f.endswith(".py"):
|
| 40 |
+
shutil.copy(os.path.join(SRC, f), tmp)
|
| 41 |
+
setup(tmp)
|
| 42 |
+
harness = os.path.join(tmp, "_h.py")
|
| 43 |
+
with open(harness, "w") as fh:
|
| 44 |
+
fh.write(HARNESS)
|
| 45 |
+
r = subprocess.run(
|
| 46 |
+
[sys.executable, harness, os.path.join(tmp, "app.py"), tmp],
|
| 47 |
+
capture_output=True, text=True, cwd=cwd or tmp)
|
| 48 |
+
ok = "IMPORT_OK" in r.stdout
|
| 49 |
+
tag = f"{GREEN}BOOTS{OFF}" if ok else f"{RED}CRASH{OFF}"
|
| 50 |
+
print(f" [{tag}] {name}")
|
| 51 |
+
if not ok:
|
| 52 |
+
tail = (r.stderr or r.stdout).strip().splitlines()
|
| 53 |
+
print(" " + (tail[-1] if tail else "no output"))
|
| 54 |
+
shutil.rmtree(tmp, ignore_errors=True)
|
| 55 |
+
return ok
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def none(tmp):
|
| 59 |
+
pass
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def no_init(tmp):
|
| 63 |
+
d = os.path.join(tmp, "integrations")
|
| 64 |
+
os.makedirs(d, exist_ok=True)
|
| 65 |
+
for f in ("crm.py", "sip.py", "whatsapp.py"):
|
| 66 |
+
shutil.copy(os.path.join(SRC, "integrations", f), d)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def full(tmp):
|
| 70 |
+
d = os.path.join(tmp, "integrations")
|
| 71 |
+
os.makedirs(d, exist_ok=True)
|
| 72 |
+
for f in os.listdir(os.path.join(SRC, "integrations")):
|
| 73 |
+
if f.endswith(".py"):
|
| 74 |
+
shutil.copy(os.path.join(SRC, "integrations", f), d)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
print("\n== Space startup scenarios ==")
|
| 78 |
+
res = [
|
| 79 |
+
scenario("A: integrations/ missing entirely", none),
|
| 80 |
+
scenario("B: integrations/ without __init__.py", no_init),
|
| 81 |
+
scenario("C: integrations/ complete", full),
|
| 82 |
+
scenario("D: different working directory", full, cwd="/"),
|
| 83 |
+
]
|
| 84 |
+
print(f"\n {sum(res)}/{len(res)} scenarios boot cleanly")
|
| 85 |
+
sys.exit(0 if all(res) else 1)
|