Spaces:
Runtime error
Runtime error
| bash -lc cat > /mnt/data/app.py <<'PY' | |
| import atexit | |
| import csv | |
| import io | |
| import os | |
| import re | |
| import shutil | |
| import tempfile | |
| import threading | |
| import time | |
| import unicodedata | |
| import uuid | |
| import wave | |
| from datetime import datetime | |
| import gradio as gr | |
| import librosa | |
| import numpy as np | |
| from huggingface_hub import CommitOperationAdd, HfApi, hf_hub_download | |
| 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" | |
| # Buffer / batch upload | |
| BUFFER_DIR = "buffer" | |
| AUDIO_BUFFER_DIR = os.path.join(BUFFER_DIR, "audios") | |
| PENDING_QUEUE_FILE = os.path.join(BUFFER_DIR, "pending_queue.csv") | |
| FLUSH_INTERVAL_SEC = 600 # 10 phút | |
| MAX_PENDING_BEFORE_FLUSH = 100 # flush sớm nếu đủ nhiều mẫu | |
| # Metadata trên Hub: giữ nguyên schema cũ để tương thích pipeline hiện tại | |
| HUB_METADATA_HEADER = ["file_name"] | |
| api = HfApi(token=HF_TOKEN) | |
| os.makedirs(AUDIO_BUFFER_DIR, exist_ok=True) | |
| buffer_lock = threading.Lock() | |
| flush_in_progress = threading.Event() | |
| shutdown_event = threading.Event() | |
| # --- KHỞI TẠO MODEL --- | |
| oww_model = None | |
| model_ready = threading.Event() | |
| # ============================================================ | |
| # MODEL LOADER | |
| # ============================================================ | |
| 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() | |
| # ============================================================ | |
| # BUFFER / QUEUE HELPERS | |
| # ============================================================ | |
| def ensure_pending_queue(): | |
| if not os.path.exists(PENDING_QUEUE_FILE): | |
| with open(PENDING_QUEUE_FILE, "w", newline="", encoding="utf-8") as f: | |
| writer = csv.writer(f) | |
| writer.writerow(["local_path", "repo_path", "speaker", "score", "created_at"]) | |
| def read_pending_queue(): | |
| ensure_pending_queue() | |
| with open(PENDING_QUEUE_FILE, "r", encoding="utf-8") as f: | |
| rows = list(csv.reader(f)) | |
| if not rows: | |
| return ["local_path", "repo_path", "speaker", "score", "created_at"], [] | |
| return rows[0], rows[1:] | |
| def write_pending_queue(header, rows): | |
| with open(PENDING_QUEUE_FILE, "w", newline="", encoding="utf-8") as f: | |
| writer = csv.writer(f) | |
| writer.writerow(header) | |
| writer.writerows(rows) | |
| def get_pending_count(): | |
| with buffer_lock: | |
| _, rows = read_pending_queue() | |
| return len(rows) | |
| def fetch_existing_metadata_rows(): | |
| try: | |
| existing = 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)) | |
| if not rows: | |
| return [HUB_METADATA_HEADER] | |
| # Nếu file cũ chỉ có 1 cột hoặc header khác, vẫn cố chuẩn hóa tối thiểu. | |
| header = rows[0] if rows[0] else HUB_METADATA_HEADER | |
| if len(header) == 1: | |
| normalized = [HUB_METADATA_HEADER] | |
| for row in rows[1:]: | |
| if row: | |
| normalized.append([row[0]]) | |
| return normalized | |
| return rows | |
| except Exception: | |
| return [HUB_METADATA_HEADER] | |
| def enqueue_sample_local(audio_path, score, speaker_name): | |
| ensure_pending_queue() | |
| spk = slugify(speaker_name) if speaker_name else "unknown" | |
| created_at = datetime.utcnow().replace(microsecond=0).isoformat() + "Z" | |
| ext = ".wav" | |
| filename = f"lumi_{spk}_{uuid.uuid4().hex[:8]}{ext}" | |
| local_buffer_path = os.path.join(AUDIO_BUFFER_DIR, filename) | |
| repo_audio_path = f"audios/{filename}" | |
| # Chuẩn hóa thành WAV 16k mono để dataset đồng nhất. | |
| y, _ = librosa.load(audio_path, sr=16000, mono=True) | |
| y = np.clip(y, -1.0, 1.0) | |
| audio_int16 = (y * 32767).astype(np.int16) | |
| with wave.open(local_buffer_path, "wb") as wf: | |
| wf.setnchannels(1) | |
| wf.setsampwidth(2) | |
| wf.setframerate(16000) | |
| wf.writeframes(audio_int16.tobytes()) | |
| with buffer_lock: | |
| header, rows = read_pending_queue() | |
| rows.append([local_buffer_path, repo_audio_path, spk, f"{score:.4f}", created_at]) | |
| write_pending_queue(header, rows) | |
| return filename, local_buffer_path, repo_audio_path | |
| def flush_buffer_to_hf(): | |
| if flush_in_progress.is_set(): | |
| return False, "flush đang chạy" | |
| if not HF_TOKEN: | |
| return False, "HF_TOKEN chưa được cấu hình" | |
| flush_in_progress.set() | |
| try: | |
| with buffer_lock: | |
| header, pending_rows = read_pending_queue() | |
| if not pending_rows: | |
| return True, "không có mẫu chờ" | |
| snapshot_rows = list(pending_rows) | |
| valid_rows = [] | |
| missing_rows = [] | |
| for row in snapshot_rows: | |
| if not row or len(row) < 2: | |
| continue | |
| local_path = row[0] | |
| if os.path.exists(local_path): | |
| valid_rows.append(row) | |
| else: | |
| missing_rows.append(row) | |
| if not valid_rows and missing_rows: | |
| with buffer_lock: | |
| cur_header, cur_rows = read_pending_queue() | |
| missing_keys = {(r[0], r[1]) for r in missing_rows if len(r) >= 2} | |
| remaining_rows = [ | |
| r for r in cur_rows | |
| if len(r) >= 2 and (r[0], r[1]) not in missing_keys | |
| ] | |
| write_pending_queue(cur_header, remaining_rows) | |
| return False, "có bản ghi pending bị mất file local" | |
| if not valid_rows: | |
| return True, "không có file hợp lệ để flush" | |
| existing_rows = fetch_existing_metadata_rows() | |
| existing_data = existing_rows[1:] if len(existing_rows) > 1 else [] | |
| existing_set = {row[0] for row in existing_data if row} | |
| operations = [] | |
| new_metadata_rows = [] | |
| for row in valid_rows: | |
| local_path, repo_path = row[0], row[1] | |
| operations.append( | |
| CommitOperationAdd( | |
| path_in_repo=repo_path, | |
| path_or_fileobj=local_path, | |
| ) | |
| ) | |
| if repo_path not in existing_set: | |
| new_metadata_rows.append([repo_path]) | |
| merged_rows = [HUB_METADATA_HEADER] + existing_data + new_metadata_rows | |
| buf = io.StringIO() | |
| csv.writer(buf).writerows(merged_rows) | |
| csv_bytes = buf.getvalue().encode("utf-8") | |
| operations.append( | |
| CommitOperationAdd( | |
| path_in_repo="metadata.csv", | |
| path_or_fileobj=io.BytesIO(csv_bytes), | |
| ) | |
| ) | |
| commit_msg = ( | |
| f"batch upload {len(valid_rows)} samples @ " | |
| f"{datetime.utcnow().replace(microsecond=0).isoformat()}Z" | |
| ) | |
| api.create_commit( | |
| repo_id=DATASET_ID, | |
| repo_type="dataset", | |
| operations=operations, | |
| commit_message=commit_msg, | |
| token=HF_TOKEN, | |
| ) | |
| # Chỉ xóa queue/file local sau khi commit thành công. | |
| with buffer_lock: | |
| cur_header, cur_rows = read_pending_queue() | |
| flushed_keys = {(r[0], r[1]) for r in valid_rows if len(r) >= 2} | |
| missing_keys = {(r[0], r[1]) for r in missing_rows if len(r) >= 2} | |
| remaining_rows = [ | |
| r for r in cur_rows | |
| if len(r) >= 2 and (r[0], r[1]) not in flushed_keys and (r[0], r[1]) not in missing_keys | |
| ] | |
| write_pending_queue(cur_header, remaining_rows) | |
| for row in valid_rows: | |
| local_path = row[0] | |
| try: | |
| if os.path.exists(local_path): | |
| os.remove(local_path) | |
| except Exception as rm_err: | |
| print(f"⚠️ Không xóa được file buffer {local_path}: {rm_err}") | |
| print(f"✅ Flush thành công {len(valid_rows)} mẫu lên Hugging Face") | |
| return True, f"đã flush {len(valid_rows)} mẫu" | |
| except Exception as e: | |
| print(f"❌ Flush lỗi: {e}") | |
| return False, str(e) | |
| finally: | |
| flush_in_progress.clear() | |
| def flush_worker(): | |
| while not shutdown_event.is_set(): | |
| try: | |
| shutdown_event.wait(FLUSH_INTERVAL_SEC) | |
| if shutdown_event.is_set(): | |
| break | |
| ok, msg = flush_buffer_to_hf() | |
| print(f"[flush_worker] ok={ok} msg={msg}") | |
| except Exception as e: | |
| print(f"❌ Flush worker error: {e}") | |
| threading.Thread(target=flush_worker, daemon=True).start() | |
| def shutdown_cleanup(): | |
| shutdown_event.set() | |
| try: | |
| pending = get_pending_count() | |
| if pending > 0: | |
| ok, msg = flush_buffer_to_hf() | |
| print(f"[shutdown_flush] ok={ok} msg={msg}") | |
| except Exception as e: | |
| print(f"⚠️ shutdown cleanup error: {e}") | |
| atexit.register(shutdown_cleanup) | |
| # ============================================================ | |
| # 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, speaker_name): | |
| if not audio_path: | |
| return "❌ Không có file để gửi.", gr.update(interactive=False), None | |
| try: | |
| _, _, _ = enqueue_sample_local(audio_path, score, speaker_name) | |
| pending_count = get_pending_count() | |
| # Flush sớm nếu hàng đợi đạt ngưỡng. | |
| if pending_count >= MAX_PENDING_BEFORE_FLUSH and not flush_in_progress.is_set(): | |
| threading.Thread(target=flush_buffer_to_hf, daemon=True).start() | |
| return ( | |
| f"🎉 Đã nhận mẫu! Đang chờ đồng bộ. Số mẫu trong hàng đợi: {pending_count}", | |
| gr.update(interactive=False), | |
| None, | |
| ) | |
| except Exception as e: | |
| return f"❌ Lỗi lưu buffer: {e}", gr.update(interactive=True), audio_path | |
| def slugify(text): | |
| text = unicodedata.normalize('NFD', text or '') | |
| text = ''.join(c for c in text if unicodedata.category(c) != 'Mn') | |
| text = text.lower().strip() | |
| text = re.sub(r'[^a-z0-9]+', '_', text) | |
| return text.strip('_') or 'unknown' | |
| def reset_ui(): | |
| return ( | |
| None, | |
| 'Sẵn sàng — Nhấn mic và đọc "Lumi ơi"', | |
| gr.update(interactive=False), | |
| 0.0, | |
| ) | |
| # ============================================================ | |
| # CSS | |
| # ============================================================ | |
| 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; | |
| } | |
| .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; | |
| } | |
| .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); | |
| } | |
| .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-step b { | |
| color: var(--text); | |
| font-weight: 700; | |
| } | |
| .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; | |
| } | |
| .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; } | |
| .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; | |
| } | |
| #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); } | |
| } | |
| #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; } | |
| #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; | |
| } | |
| #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%;} | |
| } | |
| #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; | |
| } | |
| .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; | |
| } | |
| .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; | |
| } | |
| .lumi-speaker-input { margin-bottom: var(--sp-xs) !important; } | |
| .lumi-speaker-input input { | |
| background: var(--surface) !important; | |
| border: 1px solid var(--border) !important; | |
| border-radius: 10px !important; | |
| font-family: 'DM Sans', sans-serif !important; | |
| font-size: var(--fs-base) !important; | |
| color: var(--text) !important; | |
| padding: var(--sp-sm) var(--sp-md) !important; | |
| } | |
| .lumi-speaker-input input:focus { | |
| border-color: var(--accent) !important; | |
| box-shadow: 0 0 0 3px rgba(59,126,244,0.12) !important; | |
| outline: none !important; | |
| } | |
| .lumi-speaker-input label span { | |
| font-size: var(--fs-xs) !important; | |
| font-weight: 600 !important; | |
| color: var(--text3) !important; | |
| text-transform: uppercase !important; | |
| letter-spacing: 0.8px !important; | |
| } | |
| footer { display: none !important; } | |
| .contain { background: transparent !important; } | |
| """ | |
| # ============================================================ | |
| # RECORDER HTML | |
| # ============================================================ | |
| RECORDER_HTML = """ | |
| <div id="lumi-recorder-ui"> | |
| <button id="lumi-mic-btn" title="Nhấn để ghi âm">🎙</button> | |
| <div id="lumi-rec-label">Nhấn để bắt đầu ghi âm</div> | |
| <canvas id="lumi-canvas" width="520" height="60"></canvas> | |
| <div id="lumi-proc-bar"> | |
| <div class="p-label"><span class="p-dot"></span>Đang xử lý...</div> | |
| <div class="p-track"><div class="p-fill"></div></div> | |
| </div> | |
| <div id="lumi-audio-playback"> | |
| <div class="play-label">// PLAYBACK</div> | |
| <audio id="lumi-audio-el" controls></audio> | |
| </div> | |
| </div> | |
| <script> | |
| (function () { | |
| const micBtn = document.getElementById('lumi-mic-btn'); | |
| const recLabel = document.getElementById('lumi-rec-label'); | |
| const canvas = document.getElementById('lumi-canvas'); | |
| const ctx2d = canvas.getContext('2d'); | |
| const procBar = document.getElementById('lumi-proc-bar'); | |
| const playWrap = document.getElementById('lumi-audio-playback'); | |
| const audioEl = document.getElementById('lumi-audio-el'); | |
| let isRecording = false, mediaRecorder = null, audioChunks = []; | |
| let audioCtx = null, analyser = null, micStream = null, animId = null; | |
| micBtn.addEventListener('click', () => isRecording ? stopRecording() : startRecording()); | |
| async function startRecording() { | |
| try { | |
| micStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); | |
| } catch (e) { | |
| recLabel.textContent = '✕ Không có quyền truy cập micro'; | |
| return; | |
| } | |
| audioCtx = new (window.AudioContext || window.webkitAudioContext)(); | |
| analyser = audioCtx.createAnalyser(); | |
| analyser.fftSize = 256; | |
| audioCtx.createMediaStreamSource(micStream).connect(analyser); | |
| audioChunks = []; | |
| const mime = ['audio/webm;codecs=opus','audio/webm','audio/ogg'] | |
| .find(t => MediaRecorder.isTypeSupported(t)) || ''; | |
| mediaRecorder = new MediaRecorder(micStream, mime ? { mimeType: mime } : {}); | |
| mediaRecorder.ondataavailable = e => { if (e.data.size > 0) audioChunks.push(e.data); }; | |
| mediaRecorder.onstop = onDone; | |
| mediaRecorder.start(100); | |
| isRecording = true; | |
| micBtn.classList.add('recording'); | |
| micBtn.textContent = '⏹'; | |
| recLabel.textContent = '● Đang ghi — nhấn để dừng'; | |
| recLabel.classList.add('recording'); | |
| playWrap.style.display = 'none'; | |
| procBar.style.display = 'none'; | |
| drawWave(); | |
| } | |
| function stopRecording() { | |
| mediaRecorder && mediaRecorder.state !== 'inactive' && mediaRecorder.stop(); | |
| micStream && micStream.getTracks().forEach(t => t.stop()); | |
| audioCtx && audioCtx.close(); | |
| animId && cancelAnimationFrame(animId); | |
| micStream = audioCtx = analyser = null; | |
| isRecording = false; | |
| micBtn.classList.remove('recording'); | |
| micBtn.textContent = '🎙'; | |
| recLabel.textContent = 'Nhấn để ghi lại'; | |
| recLabel.classList.remove('recording'); | |
| procBar.style.display = 'block'; | |
| drawFlatLine(); | |
| } | |
| function onDone() { | |
| const mime = audioChunks[0]?.type || 'audio/webm'; | |
| const blob = new Blob(audioChunks, { type: mime }); | |
| const url = URL.createObjectURL(blob); | |
| audioEl.src = url; | |
| audioEl.oncanplay = () => { | |
| procBar.style.display = 'none'; | |
| playWrap.style.display = 'flex'; | |
| }; | |
| injectFile(blob, mime); | |
| } | |
| function injectFile(blob, mime) { | |
| const ext = mime.includes('ogg') ? 'ogg' : 'webm'; | |
| const file = new File([blob], 'recording.' + ext, { type: mime }); | |
| function attempt(tries) { | |
| const inp = document.querySelector('.hidden-audio-wrap input[type="file"]'); | |
| if (inp) { | |
| const dt = new DataTransfer(); | |
| dt.items.add(file); | |
| inp.files = dt.files; | |
| inp.dispatchEvent(new Event('change', { bubbles: true })); | |
| } else if (tries > 0) { | |
| setTimeout(() => attempt(tries - 1), 300); | |
| } | |
| } | |
| attempt(10); | |
| } | |
| function drawWave() { | |
| if (!analyser) return; | |
| animId = requestAnimationFrame(drawWave); | |
| const buf = analyser.frequencyBinCount; | |
| const data = new Uint8Array(buf); | |
| analyser.getByteTimeDomainData(data); | |
| const W = canvas.width, H = canvas.height; | |
| ctx2d.clearRect(0, 0, W, H); | |
| const rms = Math.sqrt(data.reduce((s,v) => s+(v-128)**2, 0) / buf); | |
| const intensity = Math.min(rms / 28, 1); | |
| if (intensity > 0.05) { | |
| const cx = W/2, cy = H/2; | |
| const grd = ctx2d.createRadialGradient(cx, cy, 0, cx, cy, W/2); | |
| grd.addColorStop(0, `rgba(59,126,244,${0.06 * intensity})`); | |
| grd.addColorStop(1, 'transparent'); | |
| ctx2d.fillStyle = grd; | |
| ctx2d.fillRect(0, 0, W, H); | |
| } | |
| const grad = ctx2d.createLinearGradient(0, 0, W, 0); | |
| grad.addColorStop(0, 'rgba(59,126,244,0)'); | |
| grad.addColorStop(0.15, `rgba(59,126,244,${0.5 + intensity * 0.5})`); | |
| grad.addColorStop(0.5, `rgba(26,95,224,${0.7 + intensity * 0.3})`); | |
| grad.addColorStop(0.85, `rgba(59,126,244,${0.5 + intensity * 0.5})`); | |
| grad.addColorStop(1, 'rgba(59,126,244,0)'); | |
| ctx2d.beginPath(); | |
| ctx2d.lineWidth = 2; | |
| ctx2d.strokeStyle = grad; | |
| ctx2d.shadowColor = '#3b7ef4'; | |
| ctx2d.shadowBlur = 4 * intensity; | |
| const sw = W / buf; | |
| for (let i = 0; i < buf; i++) { | |
| const y = ((data[i]/128)*H)/2; | |
| i === 0 ? ctx2d.moveTo(0, y) : ctx2d.lineTo(i*sw, y); | |
| } | |
| ctx2d.stroke(); | |
| ctx2d.shadowBlur = 0; | |
| } | |
| function drawFlatLine() { | |
| ctx2d.clearRect(0, 0, canvas.width, canvas.height); | |
| ctx2d.beginPath(); | |
| ctx2d.moveTo(0, canvas.height/2); | |
| ctx2d.lineTo(canvas.width, canvas.height/2); | |
| ctx2d.strokeStyle = 'rgba(255,255,255,0.12)'; | |
| ctx2d.lineWidth = 1; | |
| ctx2d.stroke(); | |
| } | |
| drawFlatLine(); | |
| })(); | |
| </script> | |
| """ | |
| # ============================================================ | |
| # GRADIO BLOCKS | |
| # ============================================================ | |
| with gr.Blocks(theme=gr.themes.Base(), css=CSS, title="Lumi Voice Collector") as demo: | |
| gr.HTML(""" | |
| <div class="lumi-header"> | |
| <div class="lumi-icon-wrap">🎙</div> | |
| <div class="lumi-header-text"> | |
| <h1>Lumi Wake Word Collector</h1> | |
| <p>Thu thập giọng nói để huấn luyện trợ lý Lumi</p> | |
| </div> | |
| </div> | |
| """) | |
| gr.HTML(""" | |
| <div class="lumi-progress"> | |
| <div class="lumi-prog-step"> | |
| <div class="lumi-prog-dot">01</div> | |
| <div class="lumi-prog-info"> | |
| <div class="lumi-prog-title">GHI ÂM</div> | |
| </div> | |
| </div> | |
| <div class="lumi-prog-line"></div> | |
| <div class="lumi-prog-step"> | |
| <div class="lumi-prog-dot">02</div> | |
| <div class="lumi-prog-info"> | |
| <div class="lumi-prog-title">KIỂM TRA</div> | |
| </div> | |
| </div> | |
| <div class="lumi-prog-line"></div> | |
| <div class="lumi-prog-step"> | |
| <div class="lumi-prog-dot">03</div> | |
| <div class="lumi-prog-info"> | |
| <div class="lumi-prog-title">GỬI LÊN</div> | |
| </div> | |
| </div> | |
| </div> | |
| """) | |
| gr.HTML(""" | |
| <div class="lumi-guide"> | |
| <div class="lumi-guide-icon">📋</div> | |
| <div class="lumi-guide-body"> | |
| <div class="lumi-guide-title">Hướng dẫn</div> | |
| <div class="lumi-guide-steps"> | |
| <div class="lumi-guide-step"> | |
| <span class="lumi-guide-num">01</span> | |
| Nhấn nút mic để bắt đầu ghi âm | |
| </div> | |
| <div class="lumi-guide-step"> | |
| <span class="lumi-guide-num">02</span> | |
| Nói rõ <span class="lumi-keyword">Lumi ơi</span> vào micro — chỉ <b>1 lần</b> mỗi mẫu | |
| </div> | |
| <div class="lumi-guide-step"> | |
| <span class="lumi-guide-num">03</span> | |
| Nhấn lại để dừng — đợi xử lý xong | |
| </div> | |
| <div class="lumi-guide-step"> | |
| <span class="lumi-guide-num">04</span> | |
| Kiểm tra → đạt ≥ 0.8 → Gửi lên | |
| </div> | |
| </div> | |
| <div class="lumi-tips"> | |
| <div class="lumi-tip"><span class="tip-icon">⏱</span> Độ dài lý tưởng: <b>1 – 3 giây</b> mỗi mẫu</div> | |
| <div class="lumi-tip"><span class="tip-icon">🗣</span> Giữ <b>âm lượng tự nhiên</b>, không cần nói to hay nhỏ hơn bình thường</div> | |
| </div> | |
| </div> | |
| </div> | |
| """) | |
| gr.HTML(""" | |
| <div class="lumi-guide"> | |
| <div class="lumi-guide-icon">📐</div> | |
| <div class="lumi-guide-body"> | |
| <div class="lumi-guide-title">Khoảng cách & Góc thu âm</div> | |
| <div class="lumi-guide-steps"> | |
| <div class="lumi-guide-step"> | |
| <span class="lumi-guide-num">0–1m</span> | |
| Thu khoảng <b>35%</b> tổng số mẫu | |
| </div> | |
| <div class="lumi-guide-step"> | |
| <span class="lumi-guide-num">1–2m</span> | |
| Thu khoảng <b>30%</b> tổng số mẫu | |
| </div> | |
| <div class="lumi-guide-step"> | |
| <span class="lumi-guide-num">2–5m</span> | |
| Thu khoảng <b>25%</b> tổng số mẫu | |
| </div> | |
| <div class="lumi-guide-step"> | |
| <span class="lumi-guide-num">>5m</span> | |
| Thu khoảng <b>10%</b> tổng số mẫu | |
| </div> | |
| </div> | |
| <div class="lumi-tips"> | |
| <div class="lumi-tip"><span class="tip-icon">🔄</span> Nói ở <b>nhiều góc khác nhau</b> — thẳng, lệch trái, lệch phải</div> | |
| <div class="lumi-tip"><span class="tip-icon">🏠</span> Thu trong <b>nhiều phòng / môi trường</b> khác nhau</div> | |
| </div> | |
| </div> | |
| </div> | |
| """) | |
| 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) | |
| speaker_input = gr.Textbox( | |
| placeholder="Nhập tên của bạn...", | |
| label="Tên người thu âm", | |
| max_lines=1, | |
| elem_classes="lumi-speaker-input", | |
| ) | |
| 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, speaker_input], 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) | |
| if __name__ == "__main__": | |
| print("🚀 Starting Lumi Wake Word Collector") | |
| print(f" DATASET_ID={DATASET_ID}") | |
| print(f" FLUSH_INTERVAL_SEC={FLUSH_INTERVAL_SEC}") | |
| print(f" MAX_PENDING_BEFORE_FLUSH={MAX_PENDING_BEFORE_FLUSH}") | |
| print(f" HF_TOKEN={'OK' if HF_TOKEN else 'MISSING'}") | |
| demo.launch() | |
| PY |