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 = """
Nhấn để bắt đầu ghi âm
Đang xử lý...
// PLAYBACK
""" # ============================================================ # GRADIO BLOCKS # ============================================================ with gr.Blocks(theme=gr.themes.Base(), css=CSS, title="Lumi Voice Collector") as demo: gr.HTML("""
🎙

Lumi Wake Word Collector

Thu thập giọng nói để huấn luyện trợ lý Lumi

""") gr.HTML("""
01
GHI ÂM
02
KIỂM TRA
03
GỬI LÊN
""") 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
# #
#
Đang xử lý...
#
#
#
#
// PLAYBACK
# #
#
# # """ # # ============================================================ # # GRADIO BLOCKS # # ============================================================ # with gr.Blocks(theme=gr.themes.Base(), css=CSS, title="Lumi Voice Collector") as demo: # gr.HTML(""" #
#
🎙
#
#

Lumi Wake Word Collector

#

Thu thập giọng nói để huấn luyện trợ lý Lumi

#
#
# """) # gr.HTML(""" #
#
#
01
#
#
GHI ÂM
#
#
#
#
#
02
#
#
KIỂM TRA
#
#
#
#
#
03
#
#
GỬI LÊN
#
#
#
# """) # 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()