nemabruh404 commited on
Commit
31ec81d
·
verified ·
1 Parent(s): f1f8d5d

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +829 -8
app.py CHANGED
@@ -1,11 +1,832 @@
1
- from core.model_loader import start_loading
2
- from ui.layout import build_ui
3
 
4
- # Kick off model loading ngay khi app start
5
- # hoàn toàn non-blocking, chạy trong background thread
6
- start_loading()
7
 
8
- demo = build_ui()
9
 
10
- if __name__ == "__main__":
11
- demo.launch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # from core.model_loader import start_loading
2
+ # from ui.layout import build_ui
3
 
4
+ # # Kick off model loading ngay khi app start
5
+ # # hoàn toàn non-blocking, chạy trong background thread
6
+ # start_loading()
7
 
8
+ # demo = build_ui()
9
 
10
+ # if __name__ == "__main__":
11
+ # demo.launch()
12
+ import gradio as gr
13
+ import os, uuid, librosa, threading, time, tempfile, wave, io, csv
14
+ import numpy as np
15
+ from huggingface_hub import HfApi, CommitOperationAdd
16
+ from openwakeword.model import Model
17
+
18
+ # --- CẤU HÌNH ---
19
+ DATASET_ID = "lumiwakeword/lumioiv1"
20
+ HF_TOKEN = os.getenv("HF_TOKEN")
21
+ MODEL_PATH = "loo_mee_oy_v2.onnx"
22
+
23
+ api = HfApi()
24
+
25
+ # --- KHỞI TẠO MODEL ---
26
+ oww_model = None
27
+ model_ready = threading.Event()
28
+
29
+ def load_model():
30
+ global oww_model
31
+ try:
32
+ t_start = time.perf_counter()
33
+ dummy_wav = np.zeros(16000, dtype=np.float32)
34
+ with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
35
+ tmp_path = f.name
36
+ with wave.open(tmp_path, "w") as wf:
37
+ wf.setnchannels(1); wf.setsampwidth(2); wf.setframerate(16000)
38
+ wf.writeframes((dummy_wav * 32767).astype(np.int16).tobytes())
39
+ librosa.load(tmp_path, sr=16000)
40
+ os.unlink(tmp_path)
41
+ print(f" librosa warm-up: {(time.perf_counter()-t_start)*1000:.0f}ms")
42
+
43
+ t2 = time.perf_counter()
44
+ m = Model(wakeword_model_paths=[MODEL_PATH])
45
+ print(f" Model load: {(time.perf_counter()-t2)*1000:.0f}ms")
46
+
47
+ stable_count = 0
48
+ for i in range(80):
49
+ t_iter = time.perf_counter()
50
+ m.predict(np.zeros(1280, dtype=np.int16))
51
+ elapsed_ms = (time.perf_counter() - t_iter) * 1000
52
+ if i < 3: continue
53
+ if elapsed_ms < 5.0:
54
+ stable_count += 1
55
+ if stable_count >= 5:
56
+ print(f" ONNX JIT stable sau {i+1} iters ({elapsed_ms:.1f}ms/iter)")
57
+ break
58
+ else:
59
+ stable_count = 0
60
+
61
+ oww_model = m
62
+ print(f"✅ Fully ready — tổng boot: {(time.perf_counter()-t_start):.1f}s")
63
+ except Exception as e:
64
+ print(f"❌ Model Error: {e}")
65
+ finally:
66
+ model_ready.set()
67
+
68
+ threading.Thread(target=load_model, daemon=True).start()
69
+
70
+
71
+ # --- PYTHON LOGIC ---
72
+ def verify_audio(audio_path):
73
+ if not model_ready.wait(timeout=30):
74
+ return "⏳ Model đang khởi động, thử lại sau vài giây...", gr.update(interactive=False), 0.0
75
+ if not audio_path:
76
+ return "⚠️ Hãy ghi âm trước khi kiểm tra!", gr.update(interactive=False), 0.0
77
+ if oww_model is None:
78
+ return "❌ Model lỗi, vui lòng reload trang.", gr.update(interactive=False), 0.0
79
+ try:
80
+ y, sr = librosa.load(audio_path, sr=16000)
81
+ audio_int16 = (y * 32767).astype(np.int16)
82
+ oww_model.reset()
83
+ max_score = 0.0
84
+ chunk_size = 1280
85
+ for i in range(0, len(audio_int16), chunk_size):
86
+ chunk = audio_int16[i: i + chunk_size]
87
+ if len(chunk) < chunk_size:
88
+ chunk = np.pad(chunk, (0, chunk_size - len(chunk)))
89
+ prediction = oww_model.predict(chunk)
90
+ score_val = list(prediction.values())[0]
91
+ current_score = float(score_val[0]) if isinstance(score_val, (np.ndarray, list)) else float(score_val)
92
+ if current_score > max_score:
93
+ max_score = current_score
94
+ for _ in range(7):
95
+ oww_model.predict(np.zeros(1280, dtype=np.int16))
96
+ if max_score >= 0.8:
97
+ return f"✅ Hợp lệ! Điểm: {max_score:.2f} — Nhấn GỬI để lưu mẫu", gr.update(interactive=True), max_score
98
+ else:
99
+ return f"❌ Chưa đạt (Điểm: {max_score:.2f}). Hãy nói rõ hơn và thử lại!", gr.update(interactive=False), max_score
100
+ except Exception as e:
101
+ return f"❌ Lỗi xử lý: {str(e)}", gr.update(interactive=False), 0.0
102
+
103
+
104
+ def upload_final(audio_path, score):
105
+ if not audio_path:
106
+ return "❌ Không có file để gửi.", gr.update(interactive=False), None
107
+ try:
108
+ filename = f"lumi_{score:.2f}_{uuid.uuid4().hex[:4]}.wav"
109
+ audio_repo_path = f"audios/{filename}"
110
+
111
+ try:
112
+ existing = api.hf_hub_download(
113
+ repo_id=DATASET_ID,
114
+ filename="metadata.csv",
115
+ repo_type="dataset",
116
+ token=HF_TOKEN
117
+ )
118
+ with open(existing, "r", encoding="utf-8") as f:
119
+ rows = list(csv.reader(f))
120
+ except Exception:
121
+ rows = [["file_name"]]
122
+
123
+ rows.append([audio_repo_path])
124
+
125
+ buf = io.StringIO()
126
+ csv.writer(buf).writerows(rows)
127
+ csv_bytes = buf.getvalue().encode("utf-8")
128
+
129
+ operations = [
130
+ CommitOperationAdd(
131
+ path_in_repo=audio_repo_path,
132
+ path_or_fileobj=audio_path,
133
+ ),
134
+ CommitOperationAdd(
135
+ path_in_repo="metadata.csv",
136
+ path_or_fileobj=io.BytesIO(csv_bytes),
137
+ ),
138
+ ]
139
+
140
+ api.create_commit(
141
+ repo_id=DATASET_ID,
142
+ repo_type="dataset",
143
+ operations=operations,
144
+ commit_message=f"add {filename}",
145
+ token=HF_TOKEN,
146
+ )
147
+
148
+ return "🎉 Gửi thành công! Bạn có thể thu mẫu tiếp theo.", gr.update(interactive=False), None
149
+
150
+ except Exception as e:
151
+ return f"❌ Lỗi gửi: {e}", gr.update(interactive=True), audio_path
152
+
153
+
154
+ def reset_ui():
155
+ return (
156
+ None,
157
+ 'Sẵn sàng — Nhấn mic và đọc "Lumi ơi"',
158
+ gr.update(interactive=False),
159
+ 0.0
160
+ )
161
+
162
+
163
+ # ============================================================
164
+ # CSS — Premium dark aesthetic, editorial feel
165
+ # ============================================================
166
+ CSS = """
167
+ @import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,300&family=DM+Mono:wght@400;500&display=swap');
168
+
169
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
170
+
171
+ :root {
172
+ --bg: #080b10;
173
+ --surface: #0e1219;
174
+ --surface2: #141920;
175
+ --border: #1d2535;
176
+ --border2: #252f42;
177
+ --accent: #4f8ef7;
178
+ --accent2: #7eb8ff;
179
+ --success: #34d399;
180
+ --danger: #f87171;
181
+ --warn: #fbbf24;
182
+ --text: #e8edf5;
183
+ --text2: #8896b0;
184
+ --text3: #4a566e;
185
+ --mono: 'DM Mono', monospace;
186
+ }
187
+
188
+ body, .gradio-container {
189
+ background: var(--bg) !important;
190
+ font-family: 'DM Sans', sans-serif !important;
191
+ min-height: 100vh;
192
+ color: var(--text) !important;
193
+ }
194
+
195
+ .gradio-container {
196
+ max-width: 520px !important;
197
+ margin: 0 auto !important;
198
+ padding: 40px 20px 80px !important;
199
+ }
200
+
201
+ /* ── HEADER ── */
202
+ .lumi-header {
203
+ margin-bottom: 36px;
204
+ padding-bottom: 28px;
205
+ border-bottom: 1px solid var(--border);
206
+ display: flex;
207
+ align-items: center;
208
+ gap: 16px;
209
+ }
210
+
211
+ .lumi-icon-wrap {
212
+ width: 48px; height: 48px;
213
+ background: var(--surface2);
214
+ border: 1px solid var(--border2);
215
+ border-radius: 14px;
216
+ display: flex; align-items: center; justify-content: center;
217
+ font-size: 22px;
218
+ flex-shrink: 0;
219
+ position: relative;
220
+ overflow: hidden;
221
+ }
222
+ .lumi-icon-wrap::before {
223
+ content: '';
224
+ position: absolute; inset: 0;
225
+ background: radial-gradient(circle at 30% 30%, rgba(79,142,247,0.15), transparent 70%);
226
+ }
227
+
228
+ .lumi-header-text h1 {
229
+ font-size: 17px;
230
+ font-weight: 600;
231
+ color: var(--text);
232
+ letter-spacing: -0.2px;
233
+ line-height: 1.3;
234
+ }
235
+ .lumi-header-text p {
236
+ font-size: 12.5px;
237
+ color: var(--text3);
238
+ margin-top: 3px;
239
+ font-weight: 400;
240
+ }
241
+
242
+ /* ── PROGRESS STEPS ── */
243
+ .lumi-progress {
244
+ display: flex;
245
+ align-items: center;
246
+ gap: 0;
247
+ margin-bottom: 28px;
248
+ }
249
+ .lumi-prog-step {
250
+ display: flex;
251
+ align-items: center;
252
+ gap: 8px;
253
+ flex: 1;
254
+ }
255
+ .lumi-prog-dot {
256
+ width: 28px; height: 28px;
257
+ border-radius: 50%;
258
+ background: var(--surface2);
259
+ border: 1px solid var(--border);
260
+ display: flex; align-items: center; justify-content: center;
261
+ font-size: 11px;
262
+ font-weight: 600;
263
+ color: var(--text3);
264
+ font-family: var(--mono);
265
+ flex-shrink: 0;
266
+ }
267
+ .lumi-prog-info { flex: 1; }
268
+ .lumi-prog-title { font-size: 11px; font-weight: 600; color: var(--text3); letter-spacing: 0.3px; }
269
+ .lumi-prog-line { height: 1px; background: var(--border); width: 24px; flex-shrink: 0; margin: 0 4px; }
270
+
271
+ /* ── INSTRUCTION BOX ── */
272
+ .lumi-guide {
273
+ background: var(--surface);
274
+ border: 1px solid var(--border);
275
+ border-radius: 12px;
276
+ padding: 16px 18px;
277
+ margin-bottom: 20px;
278
+ display: flex;
279
+ gap: 14px;
280
+ align-items: flex-start;
281
+ }
282
+ .lumi-guide-icon {
283
+ font-size: 16px;
284
+ margin-top: 1px;
285
+ flex-shrink: 0;
286
+ opacity: 0.8;
287
+ }
288
+ .lumi-guide-body { flex: 1; }
289
+ .lumi-guide-title {
290
+ font-size: 10.5px;
291
+ font-weight: 600;
292
+ color: var(--text3);
293
+ text-transform: uppercase;
294
+ letter-spacing: 0.8px;
295
+ margin-bottom: 8px;
296
+ }
297
+ .lumi-guide-steps {
298
+ display: flex;
299
+ flex-direction: column;
300
+ gap: 5px;
301
+ }
302
+ .lumi-guide-step {
303
+ display: flex;
304
+ align-items: baseline;
305
+ gap: 8px;
306
+ font-size: 13px;
307
+ color: var(--text2);
308
+ line-height: 1.5;
309
+ }
310
+ .lumi-guide-num {
311
+ font-family: var(--mono);
312
+ font-size: 10px;
313
+ color: var(--accent);
314
+ background: rgba(79,142,247,0.1);
315
+ border: 1px solid rgba(79,142,247,0.2);
316
+ border-radius: 4px;
317
+ padding: 1px 5px;
318
+ flex-shrink: 0;
319
+ }
320
+ .lumi-keyword {
321
+ color: var(--text);
322
+ font-weight: 600;
323
+ background: rgba(255,255,255,0.05);
324
+ border-radius: 4px;
325
+ padding: 0 4px;
326
+ }
327
+
328
+ /* ── RECORDER CARD ── */
329
+ .lumi-recorder-card {
330
+ background: var(--surface);
331
+ border: 1px solid var(--border);
332
+ border-radius: 16px;
333
+ padding: 24px;
334
+ margin-bottom: 14px;
335
+ }
336
+
337
+ #lumi-recorder-ui {
338
+ display: flex;
339
+ flex-direction: column;
340
+ align-items: center;
341
+ gap: 18px;
342
+ }
343
+
344
+ /* Mic button */
345
+ #lumi-mic-btn {
346
+ width: 72px; height: 72px;
347
+ border-radius: 50%;
348
+ background: var(--surface2);
349
+ border: 1px solid var(--border2);
350
+ cursor: pointer;
351
+ display: flex; align-items: center; justify-content: center;
352
+ font-size: 26px;
353
+ outline: none;
354
+ transition: all 0.2s cubic-bezier(.4,0,.2,1);
355
+ position: relative;
356
+ }
357
+ #lumi-mic-btn::after {
358
+ content: '';
359
+ position: absolute;
360
+ inset: -1px;
361
+ border-radius: 50%;
362
+ border: 1px solid transparent;
363
+ transition: all 0.3s ease;
364
+ }
365
+ #lumi-mic-btn:hover {
366
+ background: #182030;
367
+ border-color: var(--accent);
368
+ transform: scale(1.04);
369
+ }
370
+ #lumi-mic-btn:hover::after {
371
+ border-color: rgba(79,142,247,0.3);
372
+ inset: -5px;
373
+ }
374
+ #lumi-mic-btn.recording {
375
+ background: rgba(248,113,113,0.1);
376
+ border-color: var(--danger);
377
+ animation: rec-pulse 1.8s ease infinite;
378
+ }
379
+ @keyframes rec-pulse {
380
+ 0%,100% { box-shadow: 0 0 0 0 rgba(248,113,113,0.3); }
381
+ 50% { box-shadow: 0 0 0 14px rgba(248,113,113,0); }
382
+ }
383
+
384
+ /* Rec label */
385
+ #lumi-rec-label {
386
+ font-size: 12px;
387
+ color: var(--text3);
388
+ font-weight: 500;
389
+ letter-spacing: 0.2px;
390
+ text-align: center;
391
+ }
392
+ #lumi-rec-label.recording {
393
+ color: var(--danger);
394
+ }
395
+
396
+ /* Canvas */
397
+ #lumi-canvas {
398
+ width: 100%;
399
+ height: 60px;
400
+ border-radius: 8px;
401
+ background: var(--bg);
402
+ border: 1px solid var(--border);
403
+ display: block;
404
+ }
405
+
406
+ /* Processing bar */
407
+ #lumi-proc-bar {
408
+ display: none;
409
+ width: 100%;
410
+ }
411
+ #lumi-proc-bar .p-label {
412
+ font-size: 11px;
413
+ font-family: var(--mono);
414
+ color: var(--text3);
415
+ margin-bottom: 6px;
416
+ display: flex; align-items: center; gap: 6px;
417
+ }
418
+ #lumi-proc-bar .p-dot {
419
+ width: 6px; height: 6px;
420
+ border-radius: 50%;
421
+ background: var(--accent);
422
+ animation: blink 1s ease infinite;
423
+ }
424
+ @keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.2} }
425
+ #lumi-proc-bar .p-track {
426
+ height: 2px;
427
+ background: var(--border);
428
+ border-radius: 99px;
429
+ overflow: hidden;
430
+ }
431
+ #lumi-proc-bar .p-fill {
432
+ height: 100%;
433
+ background: var(--accent);
434
+ border-radius: 99px;
435
+ animation: slide 1.4s ease-in-out infinite;
436
+ }
437
+ @keyframes slide {
438
+ 0% { width:0%; margin-left:0%; }
439
+ 50% { width:50%; margin-left:25%; }
440
+ 100% { width:0%; margin-left:100%; }
441
+ }
442
+
443
+ /* Playback */
444
+ #lumi-audio-playback {
445
+ width: 100%;
446
+ display: none;
447
+ flex-direction: column;
448
+ gap: 6px;
449
+ }
450
+ #lumi-audio-playback .play-label {
451
+ font-size: 11px;
452
+ font-family: var(--mono);
453
+ color: var(--text3);
454
+ letter-spacing: 0.3px;
455
+ }
456
+ #lumi-audio-playback audio {
457
+ width: 100%;
458
+ height: 32px;
459
+ border-radius: 6px;
460
+ outline: none;
461
+ accent-color: var(--accent);
462
+ }
463
+
464
+ /* ── STATUS ── */
465
+ .lumi-status {
466
+ background: var(--surface) !important;
467
+ border: 1px solid var(--border) !important;
468
+ border-radius: 10px !important;
469
+ padding: 12px 16px !important;
470
+ color: var(--text2) !important;
471
+ font-size: 13px !important;
472
+ font-weight: 500 !important;
473
+ min-height: 46px !important;
474
+ font-family: var(--mono) !important;
475
+ }
476
+ .lumi-status .output-class {
477
+ font-size: 13px !important;
478
+ color: var(--text2) !important;
479
+ font-family: var(--mono) !important;
480
+ }
481
+
482
+ /* ── BUTTONS ── */
483
+ .lumi-btn-row {
484
+ display: grid;
485
+ grid-template-columns: 1fr 1fr;
486
+ gap: 8px;
487
+ margin-bottom: 8px;
488
+ }
489
+
490
+ button.lb-check, button.lb-reset, button.lb-send {
491
+ border-radius: 8px !important;
492
+ font-family: 'DM Sans', sans-serif !important;
493
+ font-weight: 500 !important;
494
+ font-size: 13px !important;
495
+ padding: 11px 16px !important;
496
+ border: none !important;
497
+ cursor: pointer !important;
498
+ transition: all 0.18s ease !important;
499
+ width: 100% !important;
500
+ letter-spacing: 0.1px !important;
501
+ }
502
+
503
+ button.lb-check {
504
+ background: var(--surface2) !important;
505
+ color: var(--text2) !important;
506
+ border: 1px solid var(--border2) !important;
507
+ }
508
+ button.lb-check:hover:not(:disabled) {
509
+ background: #1a2338 !important;
510
+ border-color: var(--accent) !important;
511
+ color: var(--accent2) !important;
512
+ }
513
+
514
+ button.lb-reset {
515
+ background: transparent !important;
516
+ color: var(--text3) !important;
517
+ border: 1px solid var(--border) !important;
518
+ }
519
+ button.lb-reset:hover:not(:disabled) {
520
+ color: var(--text2) !important;
521
+ border-color: var(--border2) !important;
522
+ background: var(--surface2) !important;
523
+ }
524
+
525
+ button.lb-send {
526
+ background: var(--accent) !important;
527
+ color: #fff !important;
528
+ font-weight: 600 !important;
529
+ letter-spacing: -0.1px !important;
530
+ }
531
+ button.lb-send:hover:not(:disabled) {
532
+ background: #6aa0f8 !important;
533
+ transform: translateY(-1px) !important;
534
+ box-shadow: 0 4px 16px rgba(79,142,247,0.35) !important;
535
+ }
536
+ button.lb-send:disabled {
537
+ background: var(--surface2) !important;
538
+ color: var(--text3) !important;
539
+ box-shadow: none !important;
540
+ cursor: not-allowed !important;
541
+ }
542
+
543
+ /* ── HIDDEN AUDIO WRAP ── */
544
+ .hidden-audio-wrap {
545
+ position: absolute;
546
+ width: 1px; height: 1px;
547
+ overflow: hidden; opacity: 0;
548
+ pointer-events: none;
549
+ }
550
+
551
+ /* ── DIVIDER ── */
552
+ .lumi-divider {
553
+ height: 1px;
554
+ background: var(--border);
555
+ margin: 20px 0;
556
+ }
557
+
558
+ footer { display: none !important; }
559
+ .contain { background: transparent !important; }
560
+ """
561
+
562
+
563
+ # ============================================================
564
+ # RECORDER HTML
565
+ # ============================================================
566
+ RECORDER_HTML = """
567
+ <div id="lumi-recorder-ui">
568
+ <button id="lumi-mic-btn" title="Nhấn để ghi âm">🎙</button>
569
+ <div id="lumi-rec-label">Nhấn để bắt đầu ghi âm</div>
570
+ <canvas id="lumi-canvas" width="520" height="60"></canvas>
571
+ <div id="lumi-proc-bar">
572
+ <div class="p-label"><span class="p-dot"></span>Đang xử lý...</div>
573
+ <div class="p-track"><div class="p-fill"></div></div>
574
+ </div>
575
+ <div id="lumi-audio-playback">
576
+ <div class="play-label">// PLAYBACK</div>
577
+ <audio id="lumi-audio-el" controls></audio>
578
+ </div>
579
+ </div>
580
+
581
+ <script>
582
+ (function () {
583
+ const micBtn = document.getElementById('lumi-mic-btn');
584
+ const recLabel = document.getElementById('lumi-rec-label');
585
+ const canvas = document.getElementById('lumi-canvas');
586
+ const ctx2d = canvas.getContext('2d');
587
+ const procBar = document.getElementById('lumi-proc-bar');
588
+ const playWrap = document.getElementById('lumi-audio-playback');
589
+ const audioEl = document.getElementById('lumi-audio-el');
590
+
591
+ let isRecording = false, mediaRecorder = null, audioChunks = [];
592
+ let audioCtx = null, analyser = null, micStream = null, animId = null;
593
+
594
+ micBtn.addEventListener('click', () => isRecording ? stopRecording() : startRecording());
595
+
596
+ async function startRecording() {
597
+ try {
598
+ micStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
599
+ } catch (e) {
600
+ recLabel.textContent = '✕ Không có quyền truy cập micro';
601
+ return;
602
+ }
603
+ audioCtx = new (window.AudioContext || window.webkitAudioContext)();
604
+ analyser = audioCtx.createAnalyser();
605
+ analyser.fftSize = 256;
606
+ audioCtx.createMediaStreamSource(micStream).connect(analyser);
607
+
608
+ audioChunks = [];
609
+ const mime = ['audio/webm;codecs=opus','audio/webm','audio/ogg']
610
+ .find(t => MediaRecorder.isTypeSupported(t)) || '';
611
+ mediaRecorder = new MediaRecorder(micStream, mime ? { mimeType: mime } : {});
612
+ mediaRecorder.ondataavailable = e => { if (e.data.size > 0) audioChunks.push(e.data); };
613
+ mediaRecorder.onstop = onDone;
614
+ mediaRecorder.start(100);
615
+
616
+ isRecording = true;
617
+ micBtn.classList.add('recording');
618
+ micBtn.textContent = '⏹';
619
+ recLabel.textContent = '● Đang ghi — nhấn để dừng';
620
+ recLabel.classList.add('recording');
621
+ playWrap.style.display = 'none';
622
+ procBar.style.display = 'none';
623
+ drawWave();
624
+ }
625
+
626
+ function stopRecording() {
627
+ mediaRecorder && mediaRecorder.state !== 'inactive' && mediaRecorder.stop();
628
+ micStream && micStream.getTracks().forEach(t => t.stop());
629
+ audioCtx && audioCtx.close();
630
+ animId && cancelAnimationFrame(animId);
631
+ micStream = audioCtx = analyser = null;
632
+
633
+ isRecording = false;
634
+ micBtn.classList.remove('recording');
635
+ micBtn.textContent = '🎙';
636
+ recLabel.textContent = 'Nhấn để ghi lại';
637
+ recLabel.classList.remove('recording');
638
+ procBar.style.display = 'block';
639
+ drawFlatLine();
640
+ }
641
+
642
+ function onDone() {
643
+ const mime = audioChunks[0]?.type || 'audio/webm';
644
+ const blob = new Blob(audioChunks, { type: mime });
645
+ const url = URL.createObjectURL(blob);
646
+ audioEl.src = url;
647
+ audioEl.oncanplay = () => {
648
+ procBar.style.display = 'none';
649
+ playWrap.style.display = 'flex';
650
+ };
651
+ injectFile(blob, mime);
652
+ }
653
+
654
+ function injectFile(blob, mime) {
655
+ const ext = mime.includes('ogg') ? 'ogg' : 'webm';
656
+ const file = new File([blob], 'recording.' + ext, { type: mime });
657
+ function attempt(tries) {
658
+ const inp = document.querySelector('.hidden-audio-wrap input[type="file"]');
659
+ if (inp) {
660
+ const dt = new DataTransfer();
661
+ dt.items.add(file);
662
+ inp.files = dt.files;
663
+ inp.dispatchEvent(new Event('change', { bubbles: true }));
664
+ } else if (tries > 0) {
665
+ setTimeout(() => attempt(tries - 1), 300);
666
+ }
667
+ }
668
+ attempt(10);
669
+ }
670
+
671
+ /* ── WAVEFORM ── */
672
+ function drawWave() {
673
+ if (!analyser) return;
674
+ animId = requestAnimationFrame(drawWave);
675
+
676
+ const buf = analyser.frequencyBinCount;
677
+ const data = new Uint8Array(buf);
678
+ analyser.getByteTimeDomainData(data);
679
+
680
+ const W = canvas.width, H = canvas.height;
681
+ ctx2d.clearRect(0, 0, W, H);
682
+
683
+ const rms = Math.sqrt(data.reduce((s,v) => s+(v-128)**2, 0) / buf);
684
+ const intensity = Math.min(rms / 28, 1);
685
+
686
+ /* subtle glow behind wave */
687
+ if (intensity > 0.05) {
688
+ const cx = W/2, cy = H/2;
689
+ const grd = ctx2d.createRadialGradient(cx, cy, 0, cx, cy, W/2);
690
+ grd.addColorStop(0, `rgba(79,142,247,${0.04 * intensity})`);
691
+ grd.addColorStop(1, 'transparent');
692
+ ctx2d.fillStyle = grd;
693
+ ctx2d.fillRect(0, 0, W, H);
694
+ }
695
+
696
+ /* waveform line */
697
+ const grad = ctx2d.createLinearGradient(0, 0, W, 0);
698
+ grad.addColorStop(0, 'rgba(79,142,247,0)');
699
+ grad.addColorStop(0.15, `rgba(79,142,247,${0.4 + intensity * 0.6})`);
700
+ grad.addColorStop(0.5, `rgba(126,184,255,${0.6 + intensity * 0.4})`);
701
+ grad.addColorStop(0.85, `rgba(79,142,247,${0.4 + intensity * 0.6})`);
702
+ grad.addColorStop(1, 'rgba(79,142,247,0)');
703
+
704
+ ctx2d.beginPath();
705
+ ctx2d.lineWidth = 1.5;
706
+ ctx2d.strokeStyle = grad;
707
+ ctx2d.shadowColor = '#4f8ef7';
708
+ ctx2d.shadowBlur = 6 * intensity;
709
+
710
+ const sw = W / buf;
711
+ for (let i = 0; i < buf; i++) {
712
+ const y = ((data[i]/128)*H)/2;
713
+ i === 0 ? ctx2d.moveTo(0, y) : ctx2d.lineTo(i*sw, y);
714
+ }
715
+ ctx2d.stroke();
716
+ ctx2d.shadowBlur = 0;
717
+ }
718
+
719
+ function drawFlatLine() {
720
+ ctx2d.clearRect(0, 0, canvas.width, canvas.height);
721
+ ctx2d.beginPath();
722
+ ctx2d.moveTo(0, canvas.height/2);
723
+ ctx2d.lineTo(canvas.width, canvas.height/2);
724
+ ctx2d.strokeStyle = 'rgba(29,37,53,0.8)';
725
+ ctx2d.lineWidth = 1;
726
+ ctx2d.stroke();
727
+ }
728
+
729
+ drawFlatLine();
730
+ })();
731
+ </script>
732
+ """
733
+
734
+
735
+ # ============================================================
736
+ # GRADIO BLOCKS
737
+ # ============================================================
738
+ with gr.Blocks(theme=gr.themes.Base(), css=CSS, title="Lumi Voice Collector") as demo:
739
+
740
+ gr.HTML("""
741
+ <div class="lumi-header">
742
+ <div class="lumi-icon-wrap">🎙</div>
743
+ <div class="lumi-header-text">
744
+ <h1>Lumi Wake Word Collector</h1>
745
+ <p>Thu thập giọng nói để huấn luyện trợ lý Lumi</p>
746
+ </div>
747
+ </div>
748
+ """)
749
+
750
+ gr.HTML("""
751
+ <div class="lumi-progress">
752
+ <div class="lumi-prog-step">
753
+ <div class="lumi-prog-dot">01</div>
754
+ <div class="lumi-prog-info">
755
+ <div class="lumi-prog-title">GHI ÂM</div>
756
+ </div>
757
+ </div>
758
+ <div class="lumi-prog-line"></div>
759
+ <div class="lumi-prog-step">
760
+ <div class="lumi-prog-dot">02</div>
761
+ <div class="lumi-prog-info">
762
+ <div class="lumi-prog-title">KIỂM TRA</div>
763
+ </div>
764
+ </div>
765
+ <div class="lumi-prog-line"></div>
766
+ <div class="lumi-prog-step">
767
+ <div class="lumi-prog-dot">03</div>
768
+ <div class="lumi-prog-info">
769
+ <div class="lumi-prog-title">GỬI LÊN</div>
770
+ </div>
771
+ </div>
772
+ </div>
773
+ """)
774
+
775
+ gr.HTML("""
776
+ <div class="lumi-guide">
777
+ <div class="lumi-guide-icon">📋</div>
778
+ <div class="lumi-guide-body">
779
+ <div class="lumi-guide-title">Hướng dẫn</div>
780
+ <div class="lumi-guide-steps">
781
+ <div class="lumi-guide-step">
782
+ <span class="lumi-guide-num">01</span>
783
+ Nhấn nút mic để bắt đầu ghi âm
784
+ </div>
785
+ <div class="lumi-guide-step">
786
+ <span class="lumi-guide-num">02</span>
787
+ Nói rõ <span class="lumi-keyword">Lumi ơi</span> vào micro
788
+ </div>
789
+ <div class="lumi-guide-step">
790
+ <span class="lumi-guide-num">03</span>
791
+ Nhấn lại để dừng — đợi xử lý xong
792
+ </div>
793
+ <div class="lumi-guide-step">
794
+ <span class="lumi-guide-num">04</span>
795
+ Kiểm tra → đạt ≥ 0.8 → Gửi lên
796
+ </div>
797
+ </div>
798
+ </div>
799
+ </div>
800
+ """)
801
+
802
+ with gr.Group(elem_classes="lumi-recorder-card"):
803
+ gr.HTML(RECORDER_HTML)
804
+
805
+ with gr.Group(elem_classes="hidden-audio-wrap"):
806
+ audio_inst = gr.Audio(sources=["upload"], type="filepath", label="hidden", visible=False)
807
+
808
+ status_txt = gr.Label(
809
+ value='Sẵn sàng — Nhấn mic và đọc "Lumi ơi"',
810
+ elem_classes="lumi-status",
811
+ show_label=False
812
+ )
813
+
814
+ with gr.Row(elem_classes="lumi-btn-row"):
815
+ btn_check = gr.Button("Kiểm tra mẫu", variant="secondary", elem_classes="lb-check")
816
+ btn_reset = gr.Button("Ghi lại", variant="stop", elem_classes="lb-reset")
817
+
818
+ btn_send = gr.Button(
819
+ "Gửi lên hệ thống →",
820
+ variant="primary",
821
+ interactive=False,
822
+ elem_classes="lb-send"
823
+ )
824
+
825
+ score_state = gr.State(0.0)
826
+
827
+ btn_check.click(fn=verify_audio, inputs=audio_inst, outputs=[status_txt, btn_send, score_state])
828
+ btn_send.click( fn=upload_final, inputs=[audio_inst, score_state], outputs=[status_txt, btn_send, audio_inst])
829
+ btn_reset.click(fn=reset_ui, outputs=[audio_inst, status_txt, btn_send, score_state])
830
+ audio_inst.change(lambda: gr.update(interactive=False), None, btn_send)
831
+
832
+ demo.launch()