import gradio as gr
import os, uuid, librosa, threading, time, tempfile, wave, io, csv
import numpy as np
from huggingface_hub import HfApi, CommitOperationAdd
from openwakeword.model import Model
# --- CẤU HÌNH ---
DATASET_ID = "lumiwakeword/lumioiv1"
HF_TOKEN = os.getenv("HF_TOKEN")
MODEL_PATH = "loo_mee_oy_v2.onnx"
api = HfApi()
# --- KHỞI TẠO MODEL ---
oww_model = None
model_ready = threading.Event()
def load_model():
global oww_model
try:
t_start = time.perf_counter()
dummy_wav = np.zeros(16000, dtype=np.float32)
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
tmp_path = f.name
with wave.open(tmp_path, "w") as wf:
wf.setnchannels(1); wf.setsampwidth(2); wf.setframerate(16000)
wf.writeframes((dummy_wav * 32767).astype(np.int16).tobytes())
librosa.load(tmp_path, sr=16000)
os.unlink(tmp_path)
print(f" librosa warm-up: {(time.perf_counter()-t_start)*1000:.0f}ms")
t2 = time.perf_counter()
m = Model(wakeword_model_paths=[MODEL_PATH])
print(f" Model load: {(time.perf_counter()-t2)*1000:.0f}ms")
stable_count = 0
for i in range(80):
t_iter = time.perf_counter()
m.predict(np.zeros(1280, dtype=np.int16))
elapsed_ms = (time.perf_counter() - t_iter) * 1000
if i < 3: continue
if elapsed_ms < 5.0:
stable_count += 1
if stable_count >= 5:
print(f" ONNX JIT stable sau {i+1} iters ({elapsed_ms:.1f}ms/iter)")
break
else:
stable_count = 0
oww_model = m
print(f"✅ Fully ready — tổng boot: {(time.perf_counter()-t_start):.1f}s")
except Exception as e:
print(f"❌ Model Error: {e}")
finally:
model_ready.set()
threading.Thread(target=load_model, daemon=True).start()
# --- PYTHON LOGIC ---
def verify_audio(audio_path):
if not model_ready.wait(timeout=30):
return "⏳ Model đang khởi động, thử lại sau vài giây...", gr.update(interactive=False), 0.0
if not audio_path:
return "⚠️ Hãy ghi âm trước khi kiểm tra!", gr.update(interactive=False), 0.0
if oww_model is None:
return "❌ Model lỗi, vui lòng reload trang.", gr.update(interactive=False), 0.0
try:
y, sr = librosa.load(audio_path, sr=16000)
audio_int16 = (y * 32767).astype(np.int16)
oww_model.reset()
max_score = 0.0
chunk_size = 1280
for i in range(0, len(audio_int16), chunk_size):
chunk = audio_int16[i: i + chunk_size]
if len(chunk) < chunk_size:
chunk = np.pad(chunk, (0, chunk_size - len(chunk)))
prediction = oww_model.predict(chunk)
score_val = list(prediction.values())[0]
current_score = float(score_val[0]) if isinstance(score_val, (np.ndarray, list)) else float(score_val)
if current_score > max_score:
max_score = current_score
for _ in range(7):
oww_model.predict(np.zeros(1280, dtype=np.int16))
if max_score >= 0.8:
return f"✅ Hợp lệ! Điểm: {max_score:.2f} — Nhấn GỬI để lưu mẫu", gr.update(interactive=True), max_score
else:
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
except Exception as e:
return f"❌ Lỗi xử lý: {str(e)}", gr.update(interactive=False), 0.0
def upload_final(audio_path, score):
if not audio_path:
return "❌ Không có file để gửi.", gr.update(interactive=False), None
try:
filename = f"lumi_{score:.2f}_{uuid.uuid4().hex[:4]}.wav"
audio_repo_path = f"audios/{filename}"
try:
existing = api.hf_hub_download(
repo_id=DATASET_ID,
filename="metadata.csv",
repo_type="dataset",
token=HF_TOKEN
)
with open(existing, "r", encoding="utf-8") as f:
rows = list(csv.reader(f))
except Exception:
rows = [["file_name"]]
rows.append([audio_repo_path])
buf = io.StringIO()
csv.writer(buf).writerows(rows)
csv_bytes = buf.getvalue().encode("utf-8")
operations = [
CommitOperationAdd(
path_in_repo=audio_repo_path,
path_or_fileobj=audio_path,
),
CommitOperationAdd(
path_in_repo="metadata.csv",
path_or_fileobj=io.BytesIO(csv_bytes),
),
]
api.create_commit(
repo_id=DATASET_ID,
repo_type="dataset",
operations=operations,
commit_message=f"add {filename}",
token=HF_TOKEN,
)
return "🎉 Gửi thành công! Bạn có thể thu mẫu tiếp theo.", gr.update(interactive=False), None
except Exception as e:
return f"❌ Lỗi gửi: {e}", gr.update(interactive=True), audio_path
def reset_ui():
return (
None,
'Sẵn sàng — Nhấn mic và đọc "Lumi ơi"',
gr.update(interactive=False),
0.0
)
# ============================================================
# CSS — Premium dark aesthetic, editorial feel
# ============================================================
CSS = """
@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');
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f4f6fb;
--surface: #ffffff;
--surface2:#eef1f8;
--border: #dde2ef;
--border2: #c8d0e4;
--accent: #3b7ef4;
--accent2: #1a5fe0;
--text: #1a2236;
--text2: #4a5778;
--text3: #8a96b0;
--mono: 'DM Mono', monospace;
--fs-xs: clamp(0.72rem, 1.8vw, 0.85rem);
--fs-sm: clamp(0.85rem, 2vw, 1rem);
--fs-base: clamp(0.95rem, 2.2vw, 1.15rem);
--fs-lg: clamp(1.1rem, 2.6vw, 1.4rem);
--fs-xl: clamp(1.3rem, 3vw, 1.7rem);
--sp-xs: clamp(4px, 1vw, 8px);
--sp-sm: clamp(8px, 2vw, 14px);
--sp-md: clamp(12px, 2.5vw,20px);
--sp-lg: clamp(16px, 3vw, 28px);
}
body, .gradio-container {
background: var(--bg) !important;
font-family: 'DM Sans', sans-serif !important;
color: var(--text) !important;
font-size: var(--fs-base);
}
.gradio-container {
max-width: min(96vw, 860px) !important;
margin: 0 auto !important;
padding: clamp(16px,4vw,40px) clamp(12px,4vw,36px) 60px !important;
}
/* ── HEADER ── */
.lumi-header {
display: flex; align-items: center; gap: var(--sp-md);
background: linear-gradient(135deg, #eef3ff 0%, #f4f6fb 100%);
border: 1px solid var(--border);
border-radius: clamp(10px,2vw,16px);
padding: var(--sp-md) var(--sp-lg);
margin-bottom: var(--sp-md);
}
.lumi-icon-wrap {
width: clamp(38px,8vw,52px); height: clamp(38px,8vw,52px);
background: var(--surface2); border: 1px solid var(--border2);
border-radius: clamp(8px,2vw,13px);
display: flex; align-items: center; justify-content: center;
font-size: clamp(16px,4vw,24px); flex-shrink: 0;
position: relative; overflow: hidden;
}
.lumi-icon-wrap::before {
content: ''; position: absolute; inset: 0;
background: radial-gradient(circle at 30% 30%, rgba(59,126,244,0.12), transparent 70%);
}
.lumi-header-text h1 {
font-size: var(--fs-xl); font-weight: 600; color: var(--text);
letter-spacing: -0.3px; line-height: 1.25;
}
.lumi-header-text p {
font-size: var(--fs-sm); color: var(--text3); margin-top: 2px;
}
/* ── PROGRESS ── */
.lumi-progress {
display: flex; align-items: center;
margin-bottom: var(--sp-md);
}
.lumi-prog-step { display: flex; align-items: center; gap: var(--sp-xs); flex: 1; }
.lumi-prog-dot {
width: clamp(24px,5vw,32px); height: clamp(24px,5vw,32px);
border-radius: 50%; background: var(--surface2); border: 1px solid var(--border);
display: flex; align-items: center; justify-content: center;
font-size: var(--fs-xs); font-weight: 600; color: var(--text3);
font-family: var(--mono); flex-shrink: 0;
}
.lumi-prog-info { flex: 1; }
.lumi-prog-title { font-size: var(--fs-xs); font-weight: 600; color: var(--text3); letter-spacing: 0.4px; }
.lumi-prog-line {
height: 1px; background: var(--border);
width: clamp(14px,3vw,28px); flex-shrink: 0; margin: 0 var(--sp-xs);
}
/* ── GUIDE ── */
.lumi-guide {
background: var(--surface); border: 1px solid var(--border);
border-radius: clamp(10px,2vw,14px);
padding: var(--sp-md) var(--sp-lg);
margin-bottom: var(--sp-md);
display: flex; gap: var(--sp-sm); align-items: flex-start;
}
.lumi-guide-icon { font-size: var(--fs-lg); margin-top: 2px; flex-shrink: 0; opacity: 0.8; }
.lumi-guide-body { flex: 1; }
.lumi-guide-title {
font-size: var(--fs-xs); font-weight: 700; color: var(--text3);
text-transform: uppercase; letter-spacing: 0.9px; margin-bottom: var(--sp-xs);
}
.lumi-guide-steps { display: flex; flex-direction: column; gap: var(--sp-xs); }
.lumi-guide-step {
display: flex; align-items: baseline; gap: var(--sp-xs);
font-size: var(--fs-base); color: var(--text2); line-height: 1.5;
}
.lumi-guide-num {
font-family: var(--mono); font-size: var(--fs-xs);
color: var(--accent); background: rgba(59,126,244,0.08);
border: 1px solid rgba(59,126,244,0.2);
border-radius: 4px; padding: 1px 5px; flex-shrink: 0;
}
.lumi-keyword {
color: var(--accent2); font-weight: 600;
background: rgba(59,126,244,0.08); border-radius: 4px; padding: 0 4px;
}
/* ── TIPS ── */
.lumi-tips {
margin-top: var(--sp-sm); padding-top: var(--sp-sm);
border-top: 1px solid var(--border);
display: flex; flex-direction: column; gap: var(--sp-xs);
}
.lumi-tip {
display: flex; align-items: center; gap: var(--sp-xs);
font-size: var(--fs-sm); color: var(--text2);
background: rgba(59,126,244,0.05);
border: 1px solid rgba(59,126,244,0.12);
border-radius: 7px; padding: var(--sp-xs) var(--sp-sm); line-height: 1.45;
}
.lumi-tip b { color: var(--text); font-weight: 600; }
.tip-icon { font-size: var(--fs-base); flex-shrink: 0; }
/* ── RECORDER CARD ── */
.lumi-recorder-card {
background: linear-gradient(145deg, #1e2d5a 0%, #162447 55%, #1a3060 100%);
border: 1px solid rgba(99,140,255,0.25);
border-radius: clamp(12px,3vw,20px);
padding: var(--sp-lg);
margin-bottom: var(--sp-md);
box-shadow: 0 6px 28px rgba(30,45,90,0.16), inset 0 1px 0 rgba(255,255,255,0.06);
position: relative; overflow: hidden;
}
.lumi-recorder-card::before {
content: ''; position: absolute; top: -50px; right: -50px;
width: clamp(100px,20vw,180px); height: clamp(100px,20vw,180px);
border-radius: 50%;
background: radial-gradient(circle, rgba(99,140,255,0.1) 0%, transparent 70%);
pointer-events: none;
}
.lumi-recorder-card::after {
content: ''; position: absolute; bottom: -30px; left: -30px;
width: clamp(80px,16vw,140px); height: clamp(80px,16vw,140px);
border-radius: 50%;
background: radial-gradient(circle, rgba(139,92,246,0.07) 0%, transparent 70%);
pointer-events: none;
}
#lumi-recorder-ui {
display: flex; flex-direction: column;
align-items: center; gap: var(--sp-md);
position: relative; z-index: 1;
}
/* Mic button */
#lumi-mic-btn {
width: clamp(60px,13vw,88px); height: clamp(60px,13vw,88px);
border-radius: 50%;
background: rgba(255,255,255,0.09);
border: 1.5px solid rgba(255,255,255,0.18);
cursor: pointer; display: flex; align-items: center; justify-content: center;
font-size: clamp(24px,5.5vw,36px);
outline: none; transition: all 0.2s cubic-bezier(.4,0,.2,1);
position: relative;
}
#lumi-mic-btn::after {
content: ''; position: absolute; inset: -1px;
border-radius: 50%; border: 1px solid transparent; transition: all 0.3s ease;
}
#lumi-mic-btn:hover {
background: rgba(99,140,255,0.22); border-color: rgba(99,140,255,0.6);
transform: scale(1.06); box-shadow: 0 0 20px rgba(99,140,255,0.28);
}
#lumi-mic-btn:hover::after { border-color: rgba(99,140,255,0.18); inset: -6px; }
#lumi-mic-btn.recording {
background: rgba(248,113,113,0.2); border-color: rgba(248,113,113,0.6);
animation: rec-pulse 1.8s ease infinite;
}
@keyframes rec-pulse {
0%,100% { box-shadow: 0 0 0 0 rgba(248,113,113,0.4); }
50% { box-shadow: 0 0 0 14px rgba(248,113,113,0); }
}
/* Rec label */
#lumi-rec-label {
font-size: var(--fs-sm); color: rgba(255,255,255,0.45);
font-weight: 500; letter-spacing: 0.3px; text-align: center;
}
#lumi-rec-label.recording { color: #fca5a5; }
/* Canvas */
#lumi-canvas {
width: 100%; height: clamp(44px,7vw,68px);
border-radius: 8px; background: rgba(0,0,0,0.22);
border: 1px solid rgba(255,255,255,0.07); display: block;
}
/* Processing bar */
#lumi-proc-bar { display: none; width: 100%; }
#lumi-proc-bar .p-label {
font-size: var(--fs-xs); font-family: var(--mono);
color: rgba(255,255,255,0.4); margin-bottom: 5px;
display: flex; align-items: center; gap: 6px;
}
#lumi-proc-bar .p-dot {
width: 6px; height: 6px; border-radius: 50%;
background: #7eb8ff; animation: blink 1s ease infinite;
}
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.2} }
#lumi-proc-bar .p-track {
height: 2px; background: rgba(255,255,255,0.1);
border-radius: 99px; overflow: hidden;
}
#lumi-proc-bar .p-fill {
height: 100%; background: linear-gradient(90deg, #638cff, #a78bfa);
border-radius: 99px; animation: slide 1.4s ease-in-out infinite;
}
@keyframes slide {
0% {width:0%; margin-left:0%;}
50% {width:50%; margin-left:25%;}
100%{width:0%; margin-left:100%;}
}
/* Playback */
#lumi-audio-playback { width: 100%; display: none; flex-direction: column; gap: 5px; }
#lumi-audio-playback .play-label {
font-size: var(--fs-xs); font-family: var(--mono); color: rgba(255,255,255,0.35);
}
#lumi-audio-playback audio {
width: 100%; height: 30px; border-radius: 6px; outline: none; accent-color: #638cff;
}
/* ── STATUS ── */
.lumi-status {
background: var(--surface) !important; border: 1px solid var(--border) !important;
border-radius: 10px !important;
padding: var(--sp-sm) var(--sp-md) !important;
color: var(--text2) !important; font-size: var(--fs-base) !important;
font-weight: 500 !important; min-height: 48px !important;
font-family: var(--mono) !important;
}
.lumi-status .output-class {
font-size: var(--fs-base) !important; color: var(--text2) !important;
font-family: var(--mono) !important;
}
/* ── BUTTONS ── */
.lumi-btn-row {
display: grid; grid-template-columns: 1fr 1fr;
gap: var(--sp-xs); margin-bottom: var(--sp-xs);
}
button.lb-check, button.lb-reset, button.lb-send {
border-radius: 9px !important;
font-family: 'DM Sans', sans-serif !important;
font-weight: 500 !important; font-size: var(--fs-base) !important;
padding: clamp(10px,2vw,14px) clamp(12px,3vw,20px) !important;
border: none !important; cursor: pointer !important;
transition: all 0.18s ease !important; width: 100% !important;
}
button.lb-check {
background: var(--surface2) !important; color: var(--text2) !important;
border: 1px solid var(--border2) !important;
}
button.lb-check:hover:not(:disabled) {
background: #dce8ff !important; border-color: var(--accent) !important;
color: var(--accent2) !important;
}
button.lb-reset {
background: transparent !important; color: var(--text3) !important;
border: 1px solid var(--border) !important;
}
button.lb-reset:hover:not(:disabled) {
color: var(--text2) !important; border-color: var(--border2) !important;
background: var(--surface2) !important;
}
button.lb-send {
background: var(--accent) !important; color: #fff !important;
font-weight: 600 !important;
}
button.lb-send:hover:not(:disabled) {
background: var(--accent2) !important; transform: translateY(-1px) !important;
box-shadow: 0 4px 14px rgba(59,126,244,0.3) !important;
}
button.lb-send:disabled {
background: var(--surface2) !important; color: var(--text3) !important;
cursor: not-allowed !important;
}
.hidden-audio-wrap {
position: absolute; width: 1px; height: 1px;
overflow: hidden; opacity: 0; pointer-events: none;
}
footer { display: none !important; }
.contain { background: transparent !important; }
"""
# ============================================================
# RECORDER HTML
# ============================================================
RECORDER_HTML = """
"""
# ============================================================
# GRADIO BLOCKS
# ============================================================
with gr.Blocks(theme=gr.themes.Base(), css=CSS, title="Lumi Voice Collector") as demo:
gr.HTML("""
""")
gr.HTML("""
""")
gr.HTML("""
📋
Hướng dẫn
01
Nhấn nút mic để bắt đầu ghi âm
02
Nói rõ Lumi ơi vào micro
03
Nhấn lại để dừng — đợi xử lý xong
04
Kiểm tra → đạt ≥ 0.8 → Gửi lên
🔂 Chỉ nói 1 lần duy nhất trong mỗi lần thu âm
⏱ Độ dài lý tưởng: 1 – 3 giây mỗi mẫu
📍 Có thể thu ở bất kỳ đâu — nhiều môi trường càng tốt
""")
with gr.Group(elem_classes="lumi-recorder-card"):
gr.HTML(RECORDER_HTML)
with gr.Group(elem_classes="hidden-audio-wrap"):
audio_inst = gr.Audio(sources=["upload"], type="filepath", label="hidden", visible=False)
status_txt = gr.Label(
value='Sẵn sàng — Nhấn mic và đọc "Lumi ơi"',
elem_classes="lumi-status",
show_label=False
)
with gr.Row(elem_classes="lumi-btn-row"):
btn_check = gr.Button("Kiểm tra mẫu", variant="secondary", elem_classes="lb-check")
btn_reset = gr.Button("Ghi lại", variant="stop", elem_classes="lb-reset")
btn_send = gr.Button(
"Gửi lên hệ thống →",
variant="primary",
interactive=False,
elem_classes="lb-send"
)
score_state = gr.State(0.0)
btn_check.click(fn=verify_audio, inputs=audio_inst, outputs=[status_txt, btn_send, score_state])
btn_send.click( fn=upload_final, inputs=[audio_inst, score_state], outputs=[status_txt, btn_send, audio_inst])
btn_reset.click(fn=reset_ui, outputs=[audio_inst, status_txt, btn_send, score_state])
audio_inst.change(lambda: gr.update(interactive=False), None, btn_send)
demo.launch()
# import gradio as gr
# import os, uuid, librosa, threading, time, tempfile, wave, io, csv
# import numpy as np
# from huggingface_hub import HfApi, CommitOperationAdd
# from openwakeword.model import Model
# # --- CẤU HÌNH ---
# DATASET_ID = "lumiwakeword/lumioiv1"
# HF_TOKEN = os.getenv("HF_TOKEN")
# MODEL_PATH = "loo_mee_oy_v2.onnx"
# api = HfApi()
# # --- KHỞI TẠO MODEL ---
# oww_model = None
# model_ready = threading.Event()
# def load_model():
# global oww_model
# try:
# t_start = time.perf_counter()
# dummy_wav = np.zeros(16000, dtype=np.float32)
# with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
# tmp_path = f.name
# with wave.open(tmp_path, "w") as wf:
# wf.setnchannels(1); wf.setsampwidth(2); wf.setframerate(16000)
# wf.writeframes((dummy_wav * 32767).astype(np.int16).tobytes())
# librosa.load(tmp_path, sr=16000)
# os.unlink(tmp_path)
# print(f" librosa warm-up: {(time.perf_counter()-t_start)*1000:.0f}ms")
# t2 = time.perf_counter()
# m = Model(wakeword_model_paths=[MODEL_PATH])
# print(f" Model load: {(time.perf_counter()-t2)*1000:.0f}ms")
# stable_count = 0
# for i in range(80):
# t_iter = time.perf_counter()
# m.predict(np.zeros(1280, dtype=np.int16))
# elapsed_ms = (time.perf_counter() - t_iter) * 1000
# if i < 3: continue
# if elapsed_ms < 5.0:
# stable_count += 1
# if stable_count >= 5:
# print(f" ONNX JIT stable sau {i+1} iters ({elapsed_ms:.1f}ms/iter)")
# break
# else:
# stable_count = 0
# oww_model = m
# print(f"✅ Fully ready — tổng boot: {(time.perf_counter()-t_start):.1f}s")
# except Exception as e:
# print(f"❌ Model Error: {e}")
# finally:
# model_ready.set()
# threading.Thread(target=load_model, daemon=True).start()
# # --- PYTHON LOGIC ---
# def verify_audio(audio_path):
# if not model_ready.wait(timeout=30):
# return "⏳ Model đang khởi động, thử lại sau vài giây...", gr.update(interactive=False), 0.0
# if not audio_path:
# return "⚠️ Hãy ghi âm trước khi kiểm tra!", gr.update(interactive=False), 0.0
# if oww_model is None:
# return "❌ Model lỗi, vui lòng reload trang.", gr.update(interactive=False), 0.0
# try:
# y, sr = librosa.load(audio_path, sr=16000)
# audio_int16 = (y * 32767).astype(np.int16)
# oww_model.reset()
# max_score = 0.0
# chunk_size = 1280
# for i in range(0, len(audio_int16), chunk_size):
# chunk = audio_int16[i: i + chunk_size]
# if len(chunk) < chunk_size:
# chunk = np.pad(chunk, (0, chunk_size - len(chunk)))
# prediction = oww_model.predict(chunk)
# score_val = list(prediction.values())[0]
# current_score = float(score_val[0]) if isinstance(score_val, (np.ndarray, list)) else float(score_val)
# if current_score > max_score:
# max_score = current_score
# for _ in range(7):
# oww_model.predict(np.zeros(1280, dtype=np.int16))
# if max_score >= 0.8:
# return f"✅ Hợp lệ! Điểm: {max_score:.2f} — Nhấn GỬI để lưu mẫu", gr.update(interactive=True), max_score
# else:
# 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
# except Exception as e:
# return f"❌ Lỗi xử lý: {str(e)}", gr.update(interactive=False), 0.0
# def upload_final(audio_path, score):
# if not audio_path:
# return "❌ Không có file để gửi.", gr.update(interactive=False), None
# try:
# filename = f"lumi_{score:.2f}_{uuid.uuid4().hex[:4]}.wav"
# audio_repo_path = f"audios/{filename}"
# try:
# existing = api.hf_hub_download(
# repo_id=DATASET_ID,
# filename="metadata.csv",
# repo_type="dataset",
# token=HF_TOKEN
# )
# with open(existing, "r", encoding="utf-8") as f:
# rows = list(csv.reader(f))
# except Exception:
# rows = [["file_name"]]
# rows.append([audio_repo_path])
# buf = io.StringIO()
# csv.writer(buf).writerows(rows)
# csv_bytes = buf.getvalue().encode("utf-8")
# operations = [
# CommitOperationAdd(
# path_in_repo=audio_repo_path,
# path_or_fileobj=audio_path,
# ),
# CommitOperationAdd(
# path_in_repo="metadata.csv",
# path_or_fileobj=io.BytesIO(csv_bytes),
# ),
# ]
# api.create_commit(
# repo_id=DATASET_ID,
# repo_type="dataset",
# operations=operations,
# commit_message=f"add {filename}",
# token=HF_TOKEN,
# )
# return "🎉 Gửi thành công! Bạn có thể thu mẫu tiếp theo.", gr.update(interactive=False), None
# except Exception as e:
# return f"❌ Lỗi gửi: {e}", gr.update(interactive=True), audio_path
# def reset_ui():
# return (
# None,
# 'Sẵn sàng — Nhấn mic và đọc "Lumi ơi"',
# gr.update(interactive=False),
# 0.0
# )
# # ============================================================
# # CSS — Premium dark aesthetic, editorial feel
# # ============================================================
# CSS = """
# @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');
# *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
# :root {
# --bg: #f4f6fb;
# --surface: #ffffff;
# --surface2: #eef1f8;
# --border: #dde2ef;
# --border2: #c8d0e4;
# --accent: #3b7ef4;
# --accent2: #1a5fe0;
# --success: #16a34a;
# --danger: #dc2626;
# --warn: #d97706;
# --text: #1a2236;
# --text2: #4a5778;
# --text3: #8a96b0;
# --mono: 'DM Mono', monospace;
# }
# body, .gradio-container {
# background: var(--bg) !important;
# font-family: 'DM Sans', sans-serif !important;
# min-height: 100vh;
# color: var(--text) !important;
# }
# .gradio-container {
# max-width: 520px !important;
# margin: 0 auto !important;
# padding: 40px 20px 80px !important;
# }
# /* ── HEADER ── */
# .lumi-header {
# margin-bottom: 36px;
# padding-bottom: 28px;
# border-bottom: 1px solid var(--border);
# display: flex;
# align-items: center;
# gap: 16px;
# background: linear-gradient(135deg, #eef3ff 0%, #f4f6fb 100%);
# border-radius: 16px;
# padding: 20px;
# margin-bottom: 24px;
# border: 1px solid var(--border);
# }
# .lumi-icon-wrap {
# width: 48px; height: 48px;
# background: var(--surface2);
# border: 1px solid var(--border2);
# border-radius: 14px;
# display: flex; align-items: center; justify-content: center;
# font-size: 22px;
# flex-shrink: 0;
# position: relative;
# overflow: hidden;
# }
# .lumi-icon-wrap::before {
# content: '';
# position: absolute; inset: 0;
# background: radial-gradient(circle at 30% 30%, rgba(59,126,244,0.12), transparent 70%);
# }
# .lumi-header-text h1 {
# font-size: 17px;
# font-weight: 600;
# color: var(--text);
# letter-spacing: -0.2px;
# line-height: 1.3;
# }
# .lumi-header-text p {
# font-size: 12.5px;
# color: var(--text3);
# margin-top: 3px;
# font-weight: 400;
# }
# /* ── PROGRESS STEPS ── */
# .lumi-progress {
# display: flex;
# align-items: center;
# gap: 0;
# margin-bottom: 28px;
# }
# .lumi-prog-step {
# display: flex;
# align-items: center;
# gap: 8px;
# flex: 1;
# }
# .lumi-prog-dot {
# width: 28px; height: 28px;
# border-radius: 50%;
# background: var(--surface2);
# border: 1px solid var(--border);
# display: flex; align-items: center; justify-content: center;
# font-size: 11px;
# font-weight: 600;
# color: var(--text3);
# font-family: var(--mono);
# flex-shrink: 0;
# }
# .lumi-prog-info { flex: 1; }
# .lumi-prog-title { font-size: 11px; font-weight: 600; color: var(--text3); letter-spacing: 0.3px; }
# .lumi-prog-line { height: 1px; background: var(--border); width: 24px; flex-shrink: 0; margin: 0 4px; }
# /* ── INSTRUCTION BOX ── */
# .lumi-guide {
# background: var(--surface);
# border: 1px solid var(--border);
# border-radius: 12px;
# padding: 16px 18px;
# margin-bottom: 20px;
# display: flex;
# gap: 14px;
# align-items: flex-start;
# }
# .lumi-guide-icon {
# font-size: 16px;
# margin-top: 1px;
# flex-shrink: 0;
# opacity: 0.8;
# }
# .lumi-guide-body { flex: 1; }
# .lumi-guide-title {
# font-size: 10.5px;
# font-weight: 600;
# color: var(--text3);
# text-transform: uppercase;
# letter-spacing: 0.8px;
# margin-bottom: 8px;
# }
# .lumi-guide-steps {
# display: flex;
# flex-direction: column;
# gap: 5px;
# }
# .lumi-guide-step {
# display: flex;
# align-items: baseline;
# gap: 8px;
# font-size: 13px;
# color: var(--text2);
# line-height: 1.5;
# }
# .lumi-guide-num {
# font-family: var(--mono);
# font-size: 10px;
# color: var(--accent);
# background: rgba(59,126,244,0.08);
# border: 1px solid rgba(59,126,244,0.2);
# border-radius: 4px;
# padding: 1px 5px;
# flex-shrink: 0;
# }
# .lumi-keyword {
# color: var(--accent2);
# font-weight: 600;
# background: rgba(59,126,244,0.08);
# border-radius: 4px;
# padding: 0 4px;
# }
# /* ── RECORDER CARD ── */
# .lumi-recorder-card {
# background: linear-gradient(145deg, #1e2d5a 0%, #162447 50%, #1a3060 100%);
# border: 1px solid rgba(99,140,255,0.25);
# border-radius: 20px;
# padding: 28px 24px;
# margin-bottom: 14px;
# box-shadow: 0 8px 32px rgba(30,45,90,0.18), inset 0 1px 0 rgba(255,255,255,0.06);
# position: relative;
# overflow: hidden;
# }
# .lumi-recorder-card::before {
# content: '';
# position: absolute;
# top: -60px; right: -60px;
# width: 200px; height: 200px;
# border-radius: 50%;
# background: radial-gradient(circle, rgba(99,140,255,0.12) 0%, transparent 70%);
# pointer-events: none;
# }
# .lumi-recorder-card::after {
# content: '';
# position: absolute;
# bottom: -40px; left: -40px;
# width: 160px; height: 160px;
# border-radius: 50%;
# background: radial-gradient(circle, rgba(139,92,246,0.08) 0%, transparent 70%);
# pointer-events: none;
# }
# #lumi-recorder-ui {
# display: flex;
# flex-direction: column;
# align-items: center;
# gap: 18px;
# position: relative;
# z-index: 1;
# }
# /* Mic button */
# #lumi-mic-btn {
# width: 80px; height: 80px;
# border-radius: 50%;
# background: rgba(255,255,255,0.09);
# border: 1.5px solid rgba(255,255,255,0.18);
# cursor: pointer;
# display: flex; align-items: center; justify-content: center;
# font-size: 30px;
# outline: none;
# transition: all 0.2s cubic-bezier(.4,0,.2,1);
# position: relative;
# }
# #lumi-mic-btn::after {
# content: '';
# position: absolute;
# inset: -1px;
# border-radius: 50%;
# border: 1px solid transparent;
# transition: all 0.3s ease;
# }
# #lumi-mic-btn:hover {
# background: rgba(99,140,255,0.22);
# border-color: rgba(99,140,255,0.6);
# transform: scale(1.06);
# box-shadow: 0 0 24px rgba(99,140,255,0.3);
# }
# #lumi-mic-btn:hover::after {
# border-color: rgba(99,140,255,0.2);
# inset: -7px;
# }
# #lumi-mic-btn.recording {
# background: rgba(248,113,113,0.2);
# border-color: rgba(248,113,113,0.6);
# animation: rec-pulse 1.8s ease infinite;
# }
# @keyframes rec-pulse {
# 0%,100% { box-shadow: 0 0 0 0 rgba(248,113,113,0.4); }
# 50% { box-shadow: 0 0 0 16px rgba(248,113,113,0); }
# }
# /* Rec label */
# #lumi-rec-label {
# font-size: 12.5px;
# color: rgba(255,255,255,0.45);
# font-weight: 500;
# letter-spacing: 0.3px;
# text-align: center;
# }
# #lumi-rec-label.recording {
# color: #fca5a5;
# }
# /* Canvas */
# #lumi-canvas {
# width: 100%;
# height: 64px;
# border-radius: 10px;
# background: rgba(0,0,0,0.22);
# border: 1px solid rgba(255,255,255,0.07);
# display: block;
# }
# /* Processing bar */
# #lumi-proc-bar {
# display: none;
# width: 100%;
# }
# #lumi-proc-bar .p-label {
# font-size: 11px;
# font-family: var(--mono);
# color: rgba(255,255,255,0.4);
# margin-bottom: 6px;
# display: flex; align-items: center; gap: 6px;
# }
# #lumi-proc-bar .p-dot {
# width: 6px; height: 6px;
# border-radius: 50%;
# background: #7eb8ff;
# animation: blink 1s ease infinite;
# }
# @keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.2} }
# #lumi-proc-bar .p-track {
# height: 2px;
# background: rgba(255,255,255,0.1);
# border-radius: 99px;
# overflow: hidden;
# }
# #lumi-proc-bar .p-fill {
# height: 100%;
# background: linear-gradient(90deg, #638cff, #a78bfa);
# border-radius: 99px;
# animation: slide 1.4s ease-in-out infinite;
# }
# @keyframes slide {
# 0% { width:0%; margin-left:0%; }
# 50% { width:50%; margin-left:25%; }
# 100% { width:0%; margin-left:100%; }
# }
# /* Playback */
# #lumi-audio-playback {
# width: 100%;
# display: none;
# flex-direction: column;
# gap: 6px;
# }
# #lumi-audio-playback .play-label {
# font-size: 11px;
# font-family: var(--mono);
# color: rgba(255,255,255,0.35);
# letter-spacing: 0.3px;
# }
# #lumi-audio-playback audio {
# width: 100%;
# height: 32px;
# border-radius: 6px;
# outline: none;
# accent-color: #638cff;
# }
# /* ── STATUS ── */
# .lumi-status {
# background: var(--surface) !important;
# border: 1px solid var(--border) !important;
# border-radius: 10px !important;
# padding: 12px 16px !important;
# color: var(--text2) !important;
# font-size: 13px !important;
# font-weight: 500 !important;
# min-height: 46px !important;
# font-family: var(--mono) !important;
# }
# .lumi-status .output-class {
# font-size: 13px !important;
# color: var(--text2) !important;
# font-family: var(--mono) !important;
# }
# /* ── BUTTONS ── */
# .lumi-btn-row {
# display: grid;
# grid-template-columns: 1fr 1fr;
# gap: 8px;
# margin-bottom: 8px;
# }
# button.lb-check, button.lb-reset, button.lb-send {
# border-radius: 8px !important;
# font-family: 'DM Sans', sans-serif !important;
# font-weight: 500 !important;
# font-size: 13px !important;
# padding: 11px 16px !important;
# border: none !important;
# cursor: pointer !important;
# transition: all 0.18s ease !important;
# width: 100% !important;
# letter-spacing: 0.1px !important;
# }
# button.lb-check {
# background: var(--surface2) !important;
# color: var(--text2) !important;
# border: 1px solid var(--border2) !important;
# }
# button.lb-check:hover:not(:disabled) {
# background: #dce8ff !important;
# border-color: var(--accent) !important;
# color: var(--accent2) !important;
# }
# button.lb-reset {
# background: transparent !important;
# color: var(--text3) !important;
# border: 1px solid var(--border) !important;
# }
# button.lb-reset:hover:not(:disabled) {
# color: var(--text2) !important;
# border-color: var(--border2) !important;
# background: var(--surface2) !important;
# }
# button.lb-send {
# background: var(--accent) !important;
# color: #fff !important;
# font-weight: 600 !important;
# letter-spacing: -0.1px !important;
# }
# button.lb-send:hover:not(:disabled) {
# background: var(--accent2) !important;
# transform: translateY(-1px) !important;
# box-shadow: 0 4px 16px rgba(59,126,244,0.3) !important;
# }
# button.lb-send:disabled {
# background: var(--surface2) !important;
# color: var(--text3) !important;
# box-shadow: none !important;
# cursor: not-allowed !important;
# }
# /* ── HIDDEN AUDIO WRAP ── */
# .hidden-audio-wrap {
# position: absolute;
# width: 1px; height: 1px;
# overflow: hidden; opacity: 0;
# pointer-events: none;
# }
# /* ── DIVIDER ── */
# .lumi-divider {
# height: 1px;
# background: var(--border);
# margin: 20px 0;
# }
# footer { display: none !important; }
# .contain { background: transparent !important; }
# """
# # ============================================================
# # RECORDER HTML
# # ============================================================
# RECORDER_HTML = """
#
#
#
Nhấn để bắt đầu ghi âm
#
#
#
#
#
# """
# # ============================================================
# # GRADIO BLOCKS
# # ============================================================
# with gr.Blocks(theme=gr.themes.Base(), css=CSS, title="Lumi Voice Collector") as demo:
# gr.HTML("""
#
# """)
# gr.HTML("""
#
# """)
# gr.HTML("""
#
#
📋
#
#
Hướng dẫn
#
#
# 01
# Nhấn nút mic để bắt đầu ghi âm
#
#
# 02
# Nói rõ Lumi ơi vào micro
#
#
# 03
# Nhấn lại để dừng — đợi xử lý xong
#
#
# 04
# Kiểm tra → đạt ≥ 0.8 → Gửi lên
#
#
#
#
# """)
# with gr.Group(elem_classes="lumi-recorder-card"):
# gr.HTML(RECORDER_HTML)
# with gr.Group(elem_classes="hidden-audio-wrap"):
# audio_inst = gr.Audio(sources=["upload"], type="filepath", label="hidden", visible=False)
# status_txt = gr.Label(
# value='Sẵn sàng — Nhấn mic và đọc "Lumi ơi"',
# elem_classes="lumi-status",
# show_label=False
# )
# with gr.Row(elem_classes="lumi-btn-row"):
# btn_check = gr.Button("Kiểm tra mẫu", variant="secondary", elem_classes="lb-check")
# btn_reset = gr.Button("Ghi lại", variant="stop", elem_classes="lb-reset")
# btn_send = gr.Button(
# "Gửi lên hệ thống →",
# variant="primary",
# interactive=False,
# elem_classes="lb-send"
# )
# score_state = gr.State(0.0)
# btn_check.click(fn=verify_audio, inputs=audio_inst, outputs=[status_txt, btn_send, score_state])
# btn_send.click( fn=upload_final, inputs=[audio_inst, score_state], outputs=[status_txt, btn_send, audio_inst])
# btn_reset.click(fn=reset_ui, outputs=[audio_inst, status_txt, btn_send, score_state])
# audio_inst.change(lambda: gr.update(interactive=False), None, btn_send)
# demo.launch()