Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>BarVox — Speech Lab</title> | |
| <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Fira+Code:wght@400;500&display=swap" rel="stylesheet"> | |
| <style> | |
| :root { | |
| --bg: #090d18; | |
| --surface: #111827; | |
| --surface2: #1c2437; | |
| --border: #243050; | |
| --accent: #0df5b8; | |
| --accent-dim: rgba(13,245,184,0.08); | |
| --accent-glow: rgba(13,245,184,0.2); | |
| --warn: #ffaa44; | |
| --warn-dim: rgba(255,170,68,0.10); | |
| --error: #ff5555; | |
| --error-dim: rgba(255,85,85,0.10); | |
| --text: #c8d4f0; | |
| --text-2: #5a6a90; | |
| --text-3: #2d3a58; | |
| --mono: 'Fira Code', monospace; | |
| --sans: 'Outfit', sans-serif; | |
| --r: 12px; | |
| --r-sm: 8px; | |
| } | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { font-family: var(--sans); background: var(--bg); color: var(--text); min-height: 100vh; line-height: 1.5; } | |
| /* ── HEADER ─────────────────────────────────────────────── */ | |
| .header { | |
| position: relative; | |
| background: linear-gradient(135deg, #0d1829 0%, #0a1420 60%, #0b1622 100%); | |
| border-bottom: 1px solid var(--border); | |
| padding: 20px 32px; | |
| overflow: hidden; | |
| } | |
| .header::before { | |
| content: ''; | |
| position: absolute; | |
| inset: 0; | |
| background-image: repeating-linear-gradient(90deg, | |
| transparent 0px, transparent 3px, | |
| rgba(13,245,184,0.025) 3px, rgba(13,245,184,0.025) 4px | |
| ); | |
| pointer-events: none; | |
| } | |
| .wave-bars { | |
| position: absolute; | |
| right: 40px; top: 50%; | |
| transform: translateY(-50%); | |
| display: flex; | |
| align-items: center; | |
| gap: 3px; | |
| height: 44px; | |
| opacity: 0.2; | |
| pointer-events: none; | |
| } | |
| .wave-bar { | |
| width: 3px; | |
| background: var(--accent); | |
| border-radius: 2px; | |
| animation: wavePulse var(--d,0.8s) ease-in-out infinite alternate; | |
| animation-delay: var(--delay,0s); | |
| } | |
| @keyframes wavePulse { from { height: 4px; } to { height: var(--h,30px); } } | |
| .header-inner { | |
| position: relative; | |
| max-width: 860px; | |
| margin: 0 auto; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| } | |
| .logo-text { font-size: 22px; font-weight: 700; letter-spacing: -0.5px; color: #fff; } | |
| .logo-text span { color: var(--accent); } | |
| .logo-sub { font-size: 11px; color: var(--text-2); letter-spacing: 2px; text-transform: uppercase; margin-top: 2px; } | |
| .header-right { display: flex; align-items: center; gap: 12px; } | |
| .status-pill { | |
| display: flex; align-items: center; gap: 8px; | |
| background: rgba(255,255,255,0.04); | |
| border: 1px solid var(--border); | |
| padding: 6px 14px; border-radius: 20px; | |
| font-size: 13px; font-weight: 500; | |
| } | |
| .status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-3); flex-shrink: 0; } | |
| .status-dot.online { background: var(--accent); box-shadow: 0 0 8px var(--accent); } | |
| .status-dot.offline { background: var(--error); } | |
| .review-badge { | |
| background: var(--warn); color: #000; | |
| padding: 6px 14px; border-radius: 20px; | |
| font-size: 11px; font-weight: 700; | |
| cursor: pointer; display: none; | |
| letter-spacing: 0.5px; text-transform: uppercase; | |
| transition: transform 0.1s, box-shadow 0.15s; | |
| } | |
| .review-badge:hover { transform: translateY(-1px); box-shadow: 0 4px 14px rgba(255,170,68,0.4); } | |
| /* ── CONTAINER ───────────────────────────────────────────── */ | |
| .container { max-width: 860px; margin: 0 auto; padding: 28px 20px; } | |
| /* ── STEP CARDS ──────────────────────────────────────────── */ | |
| .step-card { | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: var(--r); | |
| padding: 24px 28px; | |
| margin-bottom: 14px; | |
| transition: border-color 0.2s; | |
| } | |
| .step-card:hover { border-color: rgba(13,245,184,0.18); } | |
| .step-header { display: flex; align-items: center; gap: 14px; margin-bottom: 20px; } | |
| .step-num { | |
| width: 32px; height: 32px; border-radius: var(--r-sm); | |
| background: var(--accent-dim); | |
| border: 1px solid rgba(13,245,184,0.25); | |
| color: var(--accent); | |
| font-size: 12px; font-weight: 700; font-family: var(--mono); | |
| display: flex; align-items: center; justify-content: center; | |
| flex-shrink: 0; | |
| } | |
| .step-title { | |
| font-size: 13px; font-weight: 600; | |
| color: var(--text); letter-spacing: 1px; text-transform: uppercase; | |
| } | |
| /* ── FORM ELEMENTS ───────────────────────────────────────── */ | |
| .row { display: flex; gap: 10px; align-items: center; } | |
| select, input[type="text"] { | |
| font-family: var(--sans); font-size: 14px; | |
| border-radius: var(--r-sm); | |
| border: 1px solid var(--border); | |
| outline: none; transition: border-color 0.2s; | |
| } | |
| select { | |
| flex: 1; padding: 10px 14px; | |
| background: var(--surface2); color: var(--text); cursor: pointer; | |
| } | |
| select:focus { border-color: var(--accent); } | |
| input[type="text"] { | |
| padding: 10px 14px; | |
| background: var(--surface2); color: var(--text); | |
| flex: 1; | |
| } | |
| input[type="text"]:focus { border-color: var(--accent); } | |
| input[type="text"]::placeholder { color: var(--text-2); } | |
| input[type="checkbox"] { accent-color: var(--accent); width: 14px; height: 14px; cursor: pointer; } | |
| .btn { | |
| padding: 10px 22px; | |
| background: var(--accent); color: #000; | |
| border: none; border-radius: var(--r-sm); | |
| font-family: var(--sans); font-size: 14px; font-weight: 700; | |
| cursor: pointer; white-space: nowrap; | |
| transition: opacity 0.15s, transform 0.1s, box-shadow 0.15s; | |
| letter-spacing: 0.2px; | |
| } | |
| .btn:hover { opacity: 0.85; transform: translateY(-1px); box-shadow: 0 4px 16px rgba(13,245,184,0.28); } | |
| .btn:active { transform: translateY(0); } | |
| .btn:disabled { background: var(--text-3); color: var(--text-2); cursor: not-allowed; transform: none; box-shadow: none; opacity: 1; } | |
| .btn-ghost { | |
| background: var(--surface2); color: var(--text); | |
| border: 1px solid var(--border); | |
| } | |
| .btn-ghost:hover { border-color: var(--accent); color: var(--accent); box-shadow: none; background: var(--surface2); } | |
| /* ── FILE DROP ───────────────────────────────────────────── */ | |
| .file-drop { | |
| flex: 1; border: 2px dashed var(--border); | |
| border-radius: var(--r-sm); padding: 20px 16px; | |
| text-align: center; cursor: pointer; | |
| position: relative; | |
| transition: border-color 0.2s, background 0.2s; | |
| } | |
| .file-drop:hover { border-color: var(--accent); background: var(--accent-dim); } | |
| .file-drop input[type="file"] { | |
| position: absolute; inset: 0; opacity: 0; | |
| cursor: pointer; width: 100%; height: 100%; | |
| } | |
| .file-drop input[type="file"]:disabled { cursor: not-allowed; } | |
| .file-drop-icon { font-size: 22px; margin-bottom: 6px; } | |
| .file-drop-text { font-size: 13px; color: var(--text-2); } | |
| .file-drop-text strong { color: var(--accent); display: block; margin-bottom: 2px; font-size: 14px; } | |
| .file-name-tag { | |
| display: inline-block; margin-top: 8px; | |
| background: var(--surface2); border: 1px solid var(--border); | |
| border-radius: 6px; padding: 3px 10px; | |
| font-size: 12px; font-family: var(--mono); color: var(--text-2); | |
| } | |
| /* ── PROGRESS ────────────────────────────────────────────── */ | |
| .progress-wrap { margin-top: 16px; display: none; } | |
| .progress-track { background: var(--surface2); border-radius: 6px; height: 6px; overflow: hidden; margin-bottom: 8px; } | |
| .progress-bar { | |
| height: 100%; width: 0%; | |
| background: linear-gradient(90deg, var(--accent), #00ccff); | |
| border-radius: 6px; transition: width 0.4s ease; | |
| position: relative; overflow: hidden; | |
| } | |
| .progress-bar::after { | |
| content: ''; position: absolute; inset: 0; | |
| background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.35) 50%, transparent 100%); | |
| animation: shimmer 1.4s infinite; transform: translateX(-100%); | |
| } | |
| @keyframes shimmer { to { transform: translateX(200%); } } | |
| .progress-text { font-size: 12px; color: var(--text-2); font-family: var(--mono); } | |
| .bank-info { margin-top: 12px; font-size: 12px; color: var(--text-2); font-family: var(--mono); line-height: 1.8; } | |
| /* ── SETTINGS ────────────────────────────────────────────── */ | |
| .setting-label { font-size: 12px; color: var(--text-2); font-weight: 600; letter-spacing: 0.8px; text-transform: uppercase; margin-bottom: 8px; } | |
| .setting-hint { font-size: 12px; color: var(--text-3); margin-top: 8px; line-height: 1.6; } | |
| .radio-group { display: flex; gap: 8px; flex-wrap: wrap; } | |
| .radio-option { | |
| display: flex; align-items: center; gap: 8px; | |
| background: var(--surface2); border: 1px solid var(--border); | |
| border-radius: var(--r-sm); padding: 9px 16px; | |
| cursor: pointer; transition: border-color 0.2s, background 0.2s; | |
| font-size: 13px; font-weight: 500; user-select: none; | |
| } | |
| .radio-option input[type="radio"] { display: none; } | |
| .radio-option.selected { border-color: var(--accent); background: var(--accent-dim); color: var(--accent); } | |
| .radio-dot { | |
| width: 8px; height: 8px; border-radius: 50%; | |
| border: 1.5px solid currentColor; flex-shrink: 0; | |
| transition: background 0.15s; | |
| } | |
| .radio-option.selected .radio-dot { background: var(--accent); border-color: var(--accent); } | |
| .radio-sub { font-size: 11px; opacity: 0.55; margin-left: 2px; } | |
| .slider-row { | |
| display: flex; align-items: center; gap: 12px; | |
| background: var(--surface2); border: 1px solid var(--border); | |
| border-radius: var(--r-sm); padding: 10px 16px; margin-top: 8px; | |
| } | |
| input[type="range"] { | |
| flex: 1; -webkit-appearance: none; | |
| height: 4px; background: var(--border); border-radius: 2px; | |
| outline: none; cursor: pointer; | |
| } | |
| input[type="range"]::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| width: 16px; height: 16px; border-radius: 50%; | |
| background: var(--accent); cursor: pointer; | |
| box-shadow: 0 0 8px var(--accent-glow); | |
| transition: transform 0.1s; | |
| } | |
| input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.2); } | |
| .slider-edge { font-family: var(--mono); font-size: 11px; color: var(--text-3); flex-shrink: 0; } | |
| .slider-val { font-family: var(--mono); font-size: 17px; font-weight: 500; color: var(--accent); width: 36px; text-align: right; flex-shrink: 0; } | |
| /* ── RUN LAYOUT ──────────────────────────────────────────── */ | |
| .run-row { display: flex; gap: 12px; align-items: stretch; } | |
| .run-row .btn { align-self: stretch; padding: 0 24px; min-width: 110px; } | |
| /* ── RESULTS ─────────────────────────────────────────────── */ | |
| .result-card { display: none; } | |
| .result-card.visible { display: block; } | |
| .result-hero { | |
| text-align: center; | |
| padding: 28px 0 22px; | |
| border-bottom: 1px solid var(--border); | |
| margin-bottom: 20px; | |
| } | |
| .result-prediction { | |
| font-size: 58px; font-weight: 700; | |
| letter-spacing: -1.5px; line-height: 1; | |
| color: #fff; margin-bottom: 14px; | |
| } | |
| .result-prediction.rtl { direction: rtl; font-size: 62px; } | |
| .conf-badge { | |
| display: inline-flex; align-items: center; gap: 8px; | |
| padding: 6px 18px; border-radius: 20px; | |
| font-size: 12px; font-weight: 700; letter-spacing: 0.8px; text-transform: uppercase; | |
| } | |
| .conf-badge.high { | |
| background: rgba(13,245,184,0.1); | |
| border: 1px solid rgba(13,245,184,0.28); | |
| color: var(--accent); | |
| } | |
| .conf-badge.rejected { | |
| background: var(--warn-dim); | |
| border: 1px solid rgba(255,170,68,0.28); | |
| color: var(--warn); | |
| } | |
| .score-line { font-family: var(--mono); font-size: 12px; color: var(--text-2); margin-top: 10px; line-height: 1.8; } | |
| .rejection-block { | |
| display: flex; align-items: flex-start; gap: 10px; | |
| background: var(--warn-dim); border: 1px solid rgba(255,170,68,0.2); | |
| border-radius: var(--r-sm); padding: 10px 14px; | |
| font-size: 13px; color: var(--warn); margin-bottom: 12px; | |
| } | |
| .ensemble-line { | |
| font-size: 13px; font-family: var(--mono); | |
| color: var(--text-2); margin-bottom: 10px; | |
| } | |
| .ensemble-line.disagree { color: var(--error); } | |
| .rankings-label { | |
| font-size: 11px; font-weight: 700; letter-spacing: 1.5px; | |
| text-transform: uppercase; color: var(--text-2); margin-bottom: 12px; | |
| } | |
| .rank-row { display: flex; align-items: center; gap: 10px; margin-bottom: 7px; } | |
| .rank-num { font-family: var(--mono); font-size: 11px; color: var(--text-3); width: 16px; text-align: right; flex-shrink: 0; } | |
| .rank-label { | |
| width: 120px; text-align: right; direction: rtl; | |
| font-size: 14px; font-weight: 500; | |
| overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 0; | |
| } | |
| .rank-bar-wrap { flex: 1; background: var(--surface2); border-radius: 4px; height: 18px; overflow: hidden; } | |
| .rank-bar { height: 100%; border-radius: 4px; transition: width 0.5s cubic-bezier(0.4,0,0.2,1); } | |
| .rank-bar.top { background: linear-gradient(90deg, var(--accent), #00ccff); } | |
| .rank-bar.other { background: var(--border); } | |
| .rank-bar.unknown-cat { background: linear-gradient(90deg, var(--warn), #ff7744); } | |
| .rank-score { font-family: var(--mono); font-size: 12px; color: var(--text-2); width: 56px; text-align: right; flex-shrink: 0; } | |
| .rank-extra { font-size: 11px; color: var(--text-3); font-family: var(--mono); } | |
| .debug-block { | |
| font-family: var(--mono); font-size: 11px; | |
| color: var(--text-3); background: rgba(0,0,0,0.25); | |
| border-radius: var(--r-sm); padding: 8px 12px; | |
| margin-top: 12px; line-height: 2; | |
| } | |
| .feedback-row { | |
| display: flex; gap: 10px; | |
| margin-top: 20px; padding-top: 20px; | |
| border-top: 1px solid var(--border); | |
| } | |
| .btn-correct { background: rgba(13,245,184,0.1); color: var(--accent); border: 1px solid rgba(13,245,184,0.28); } | |
| .btn-correct:hover { background: rgba(13,245,184,0.18); box-shadow: none; transform: translateY(-1px); } | |
| .btn-wrong { background: rgba(255,85,85,0.08); color: var(--error); border: 1px solid rgba(255,85,85,0.25); } | |
| .btn-wrong:hover { background: rgba(255,85,85,0.16); box-shadow: none; transform: translateY(-1px); } | |
| .feedback-msg { font-size: 13px; margin-top: 10px; font-weight: 600; font-family: var(--mono); } | |
| .feedback-msg.ok { color: var(--accent); } | |
| .feedback-msg.logged { color: var(--warn); } | |
| audio { width: 100%; margin: 10px 0 4px; border-radius: 6px; } | |
| /* ── HISTORY ─────────────────────────────────────────────── */ | |
| .history-table { width: 100%; border-collapse: collapse; font-size: 12px; } | |
| .history-table th { | |
| text-align: left; padding: 8px 12px; | |
| border-bottom: 1px solid var(--border); | |
| color: var(--text-2); font-weight: 600; | |
| font-size: 10px; letter-spacing: 1.2px; text-transform: uppercase; | |
| } | |
| .history-table td { padding: 8px 12px; border-bottom: 1px solid var(--border); font-family: var(--mono); font-size: 12px; } | |
| .history-table tr:last-child td { border-bottom: none; } | |
| .history-table .rtl-cell { direction: rtl; text-align: right; font-family: var(--sans); font-size: 14px; } | |
| .tag { | |
| display: inline-block; padding: 2px 10px; | |
| border-radius: 20px; font-size: 10px; font-weight: 700; letter-spacing: 0.5px; text-transform: uppercase; | |
| } | |
| .tag.ok { background: rgba(13,245,184,0.1); color: var(--accent); border: 1px solid rgba(13,245,184,0.2); } | |
| .tag.rej { background: var(--warn-dim); color: var(--warn); border: 1px solid rgba(255,170,68,0.2); } | |
| .tag.wrong{ background: var(--error-dim); color: var(--error); border: 1px solid rgba(255,85,85,0.2); } | |
| /* ── REVIEW QUEUE ────────────────────────────────────────── */ | |
| .section-hint { font-size: 12px; color: var(--text-2); margin-bottom: 16px; line-height: 1.7; } | |
| .review-entry { | |
| border: 1px solid var(--border); border-radius: var(--r); | |
| padding: 18px 20px; margin-bottom: 12px; | |
| background: var(--surface2); transition: border-color 0.2s; | |
| } | |
| .review-entry:hover { border-color: rgba(255,170,68,0.28); } | |
| .review-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 10px; } | |
| .review-filename { font-family: var(--mono); font-size: 12px; color: var(--text); margin-bottom: 5px; } | |
| .review-meta { font-size: 11px; color: var(--text-2); flex-shrink: 0; margin-left: 16px; } | |
| .review-prediction { font-size: 18px; font-weight: 600; } | |
| .review-resolve-row { display: flex; gap: 8px; align-items: center; margin-top: 14px; flex-wrap: wrap; } | |
| .review-resolve-row label { font-size: 13px; display: flex; align-items: center; gap: 6px; white-space: nowrap; color: var(--text-2); cursor: pointer; } | |
| /* ── SPINNER ─────────────────────────────────────────────── */ | |
| .loading-spinner { | |
| display: inline-block; width: 13px; height: 13px; | |
| border: 2px solid rgba(0,0,0,0.25); border-top-color: rgba(0,0,0,0.8); | |
| border-radius: 50%; animation: spin 0.6s linear infinite; | |
| margin-right: 8px; vertical-align: middle; | |
| } | |
| .btn-ghost .loading-spinner { border-color: rgba(13,245,184,0.2); border-top-color: var(--accent); } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| /* ── MISC ────────────────────────────────────────────────── */ | |
| .step-num-alt-warn { background: var(--warn-dim) ; border-color: rgba(255,170,68,0.3) ; color: var(--warn) ; } | |
| .step-num-alt-muted { background: rgba(90,106,144,0.12) ; border-color: rgba(90,106,144,0.2) ; color: var(--text-2) ; } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- ── HEADER ── --> | |
| <div class="header"> | |
| <div class="wave-bars" aria-hidden="true"> | |
| <div class="wave-bar" style="--h:8px; --d:0.62s; --delay:0.00s"></div> | |
| <div class="wave-bar" style="--h:24px; --d:0.78s; --delay:0.10s"></div> | |
| <div class="wave-bar" style="--h:38px; --d:0.70s; --delay:0.05s"></div> | |
| <div class="wave-bar" style="--h:18px; --d:0.90s; --delay:0.15s"></div> | |
| <div class="wave-bar" style="--h:44px; --d:0.66s; --delay:0.20s"></div> | |
| <div class="wave-bar" style="--h:30px; --d:0.84s; --delay:0.08s"></div> | |
| <div class="wave-bar" style="--h:14px; --d:0.73s; --delay:0.22s"></div> | |
| <div class="wave-bar" style="--h:40px; --d:0.68s; --delay:0.12s"></div> | |
| <div class="wave-bar" style="--h:22px; --d:0.88s; --delay:0.04s"></div> | |
| <div class="wave-bar" style="--h:36px; --d:0.75s; --delay:0.18s"></div> | |
| <div class="wave-bar" style="--h:12px; --d:0.64s; --delay:0.25s"></div> | |
| <div class="wave-bar" style="--h:48px; --d:0.80s; --delay:0.07s"></div> | |
| <div class="wave-bar" style="--h:26px; --d:0.72s; --delay:0.13s"></div> | |
| <div class="wave-bar" style="--h:16px; --d:0.92s; --delay:0.19s"></div> | |
| <div class="wave-bar" style="--h:34px; --d:0.67s; --delay:0.03s"></div> | |
| <div class="wave-bar" style="--h:20px; --d:0.82s; --delay:0.16s"></div> | |
| <div class="wave-bar" style="--h:42px; --d:0.76s; --delay:0.09s"></div> | |
| <div class="wave-bar" style="--h:10px; --d:0.61s; --delay:0.23s"></div> | |
| <div class="wave-bar" style="--h:28px; --d:0.87s; --delay:0.06s"></div> | |
| <div class="wave-bar" style="--h:32px; --d:0.71s; --delay:0.21s"></div> | |
| <div class="wave-bar" style="--h:6px; --d:0.65s; --delay:0.14s"></div> | |
| <div class="wave-bar" style="--h:46px; --d:0.79s; --delay:0.02s"></div> | |
| <div class="wave-bar" style="--h:18px; --d:0.69s; --delay:0.17s"></div> | |
| <div class="wave-bar" style="--h:36px; --d:0.85s; --delay:0.11s"></div> | |
| </div> | |
| <div class="header-inner"> | |
| <div> | |
| <div class="logo-text">Bar<span>Vox</span></div> | |
| <div class="logo-sub">Speech Recognition Lab</div> | |
| </div> | |
| <div class="header-right"> | |
| <span class="review-badge" id="reviewBadge" onclick="scrollToReview()">0 need review</span> | |
| <div class="status-pill"> | |
| <span class="status-dot" id="statusDot"></span> | |
| <span id="statusText">Checking...</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="container"> | |
| <!-- 01 LOAD BANK --> | |
| <div class="step-card"> | |
| <div class="step-header"> | |
| <div class="step-num">01</div> | |
| <div class="step-title">Load Word Bank</div> | |
| </div> | |
| <div class="row"> | |
| <select id="bankSelect"><option value="">Loading banks...</option></select> | |
| <button id="loadBankBtn" class="btn" onclick="loadBank()" disabled>Load Bank</button> | |
| </div> | |
| <div class="progress-wrap" id="progressWrap"> | |
| <div class="progress-track"> | |
| <div class="progress-bar" id="progressBar"></div> | |
| </div> | |
| <div class="progress-text" id="progressText"></div> | |
| </div> | |
| <div class="bank-info" id="bankInfo"></div> | |
| </div> | |
| <!-- 02 SETTINGS --> | |
| <div class="step-card"> | |
| <div class="step-header"> | |
| <div class="step-num">02</div> | |
| <div class="step-title">Settings</div> | |
| </div> | |
| <div class="setting-label">Similarity Mode</div> | |
| <div class="radio-group" id="simModeGroup"> | |
| <label class="radio-option" onclick="selectMode(this)"> | |
| <input type="radio" name="simMode" value="mean"> | |
| <span class="radio-dot"></span> | |
| Mean Cosine<span class="radio-sub">fast</span> | |
| </label> | |
| <label class="radio-option selected" onclick="selectMode(this)"> | |
| <input type="radio" name="simMode" value="hybrid" checked> | |
| <span class="radio-dot"></span> | |
| Hybrid<span class="radio-sub">recommended</span> | |
| </label> | |
| <label class="radio-option" onclick="selectMode(this)"> | |
| <input type="radio" name="simMode" value="dtw"> | |
| <span class="radio-dot"></span> | |
| DTW Only<span class="radio-sub">slow</span> | |
| </label> | |
| </div> | |
| <div class="setting-hint">Hybrid: fast mean cosine filters top 5 candidates, then DTW re-ranks them for accurate prediction.</div> | |
| <div style="margin-top:20px"> | |
| <div class="setting-label">Z-Score Threshold <span style="color:var(--text-3);font-weight:400;text-transform:none;letter-spacing:0">(expert override)</span></div> | |
| <div class="slider-row"> | |
| <span class="slider-edge">0.5</span> | |
| <input type="range" id="zThresholdSlider" min="0.5" max="4" step="0.1" value="2.0" | |
| oninput="document.getElementById('zThresholdVal').textContent=parseFloat(this.value).toFixed(1)"> | |
| <span class="slider-edge">4.0</span> | |
| <span class="slider-val" id="zThresholdVal">2.0</span> | |
| </div> | |
| <div class="setting-hint">Top word must be this many std devs above the mean to be accepted. Higher = stricter (more _unknown). Default 2.0 works for most banks.</div> | |
| </div> | |
| </div> | |
| <!-- 03 TEST AUDIO --> | |
| <div class="step-card"> | |
| <div class="step-header"> | |
| <div class="step-num">03</div> | |
| <div class="step-title">Test Audio</div> | |
| </div> | |
| <div class="run-row"> | |
| <div class="file-drop" id="fileDrop"> | |
| <input type="file" id="testFile" accept=".wav,.mp3,.ogg,.flac" disabled onchange="updateFileLabel(this)"> | |
| <div class="file-drop-icon">🎵</div> | |
| <div class="file-drop-text"> | |
| <strong id="fileDropTitle">Drop audio file here</strong> | |
| .wav · .mp3 · .ogg · .flac | |
| </div> | |
| <div id="fileNameTag" style="display:none" class="file-name-tag"></div> | |
| </div> | |
| <button id="runTestBtn" class="btn" onclick="runTest()" disabled>Run Test</button> | |
| </div> | |
| </div> | |
| <!-- RESULTS --> | |
| <div class="step-card result-card" id="resultCard"> | |
| <div id="resultContent"></div> | |
| </div> | |
| <!-- HISTORY --> | |
| <div class="step-card" id="historyCard" style="display:none"> | |
| <div class="step-header"> | |
| <div class="step-num step-num-alt-muted">▤</div> | |
| <div class="step-title">Test History</div> | |
| </div> | |
| <table class="history-table"> | |
| <thead> | |
| <tr> | |
| <th>File</th><th>Prediction</th><th>Score</th> | |
| <th>Gap</th><th>HuBERT</th><th>W2V</th><th>Status</th> | |
| </tr> | |
| </thead> | |
| <tbody id="historyBody"></tbody> | |
| </table> | |
| </div> | |
| <!-- PARENT REVIEW QUEUE --> | |
| <div class="step-card" id="reviewCard" style="display:none"> | |
| <div class="step-header"> | |
| <div class="step-num step-num-alt-warn">!</div> | |
| <div class="step-title">Parent Review Queue</div> | |
| </div> | |
| <p class="section-hint">Listen to the audio, enter the correct word, and optionally add the recording to the bank.</p> | |
| <div id="reviewEntries"></div> | |
| <div id="reviewEmpty" style="display:none;text-align:center;padding:28px;font-size:14px;color:var(--text-2)"> | |
| No items to review. | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| let bankDictionary = null; | |
| let calibrationThreshold = null; | |
| let dtwCalibrationThreshold = null; | |
| let globalMeanEmbedding = null; | |
| let ctcEntropyAutoThreshold = null; | |
| let lastResultData = null; | |
| const API = ''; | |
| const defaultParams = { | |
| use_silero_vad: true, | |
| threshold: 0.35, | |
| min_speech_ms: 60, | |
| selected_embedding_models: ['hubert_embedding', 'wav2vec2_embedding', 'trill'], | |
| selected_transcription_models: ['allosaurus'], | |
| selected_acoustic_models: [], | |
| hubert_layer: 12 | |
| }; | |
| function selectMode(el) { | |
| document.querySelectorAll('.radio-option').forEach(o => o.classList.remove('selected')); | |
| el.classList.add('selected'); | |
| el.querySelector('input[type="radio"]').checked = true; | |
| } | |
| function updateFileLabel(input) { | |
| const title = document.getElementById('fileDropTitle'); | |
| const tag = document.getElementById('fileNameTag'); | |
| if (input.files.length) { | |
| title.textContent = 'File ready'; | |
| tag.textContent = input.files[0].name; | |
| tag.style.display = 'inline-block'; | |
| } | |
| } | |
| async function checkStatus() { | |
| try { | |
| const resp = await fetch(API + '/status'); | |
| await resp.json(); | |
| document.getElementById('statusDot').className = 'status-dot online'; | |
| document.getElementById('statusText').textContent = 'Online'; | |
| return true; | |
| } catch { | |
| document.getElementById('statusDot').className = 'status-dot offline'; | |
| document.getElementById('statusText').textContent = 'Offline'; | |
| return false; | |
| } | |
| } | |
| async function loadBanks() { | |
| try { | |
| const resp = await fetch(API + '/banks'); | |
| const data = await resp.json(); | |
| const select = document.getElementById('bankSelect'); | |
| select.innerHTML = '<option value="">— Select a bank —</option>'; | |
| for (const bank of data.banks) { | |
| const opt = document.createElement('option'); | |
| opt.value = bank.name; | |
| opt.textContent = `${bank.name} (${bank.words.length} words · ${bank.total_samples} samples)`; | |
| select.appendChild(opt); | |
| } | |
| document.getElementById('loadBankBtn').disabled = false; | |
| } catch { | |
| document.getElementById('bankSelect').innerHTML = '<option value="">Failed to load banks</option>'; | |
| } | |
| } | |
| async function loadBank() { | |
| const bankName = document.getElementById('bankSelect').value; | |
| if (!bankName) return; | |
| const btn = document.getElementById('loadBankBtn'); | |
| btn.disabled = true; | |
| btn.innerHTML = '<span class="loading-spinner"></span>Loading...'; | |
| const progressWrap = document.getElementById('progressWrap'); | |
| const progressBar = document.getElementById('progressBar'); | |
| const progressText = document.getElementById('progressText'); | |
| progressWrap.style.display = 'block'; | |
| progressBar.style.width = '0%'; | |
| const pollId = setInterval(async () => { | |
| try { | |
| const resp = await fetch(API + '/extract_bank_progress'); | |
| const prog = await resp.json(); | |
| if (prog.total > 0) { | |
| const pct = Math.round((prog.done / prog.total) * 100); | |
| progressBar.style.width = pct + '%'; | |
| progressText.textContent = `${prog.done} / ${prog.total} — ${prog.current_word}`; | |
| } | |
| } catch {} | |
| }, 1500); | |
| try { | |
| const resp = await fetch(API + '/extract_bank', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({ bank_name: bankName, silero_params: defaultParams }) | |
| }); | |
| const data = await resp.json(); | |
| clearInterval(pollId); | |
| if (data.success) { | |
| bankDictionary = data.dictionary_entries; | |
| calibrationThreshold = data.calibration_threshold ?? null; | |
| dtwCalibrationThreshold = data.dtw_calibration_threshold ?? null; | |
| globalMeanEmbedding = data.global_mean_embedding ?? null; | |
| ctcEntropyAutoThreshold = data.ctc_entropy_auto_threshold ?? null; | |
| progressBar.style.width = '100%'; | |
| progressText.textContent = 'Done!'; | |
| const cosineInfo = calibrationThreshold !== null ? ` cosine floor: ${calibrationThreshold.toFixed(4)}` : ''; | |
| const dtwInfo = dtwCalibrationThreshold !== null ? ` dtw floor: ${dtwCalibrationThreshold.toFixed(4)}` : ''; | |
| const zFloors = data.word_z_floors || {}; | |
| const zFloorCount = Object.keys(zFloors).length; | |
| const zFloorInfo = zFloorCount > 0 ? ` z-floors: ${zFloorCount} words` : ' z-floors: none (< 4 words)'; | |
| const whiteningInfo = globalMeanEmbedding ? ' whitening: ON' : ' whitening: OFF'; | |
| const ctcInfo = ctcEntropyAutoThreshold !== null ? ` ctc-thr: ${ctcEntropyAutoThreshold.toFixed(4)}` : ' ctc-thr: none'; | |
| const dtwZFloors = data.word_dtw_z_floors || {}; | |
| const dtwZVals = Object.values(dtwZFloors); | |
| const dtwZInfo = dtwZVals.length > 0 | |
| ? ` dtw-z-floors: ${dtwZVals.length} words (${Math.min(...dtwZVals).toFixed(2)}–${Math.max(...dtwZVals).toFixed(2)})` | |
| : ' dtw-z-floors: none'; | |
| document.getElementById('bankInfo').textContent = | |
| `✓ ${data.total_words} words · ${data.total_recordings} recordings${cosineInfo}${dtwInfo}${zFloorInfo}${whiteningInfo}${ctcInfo}${dtwZInfo}`; | |
| document.getElementById('testFile').disabled = false; | |
| document.getElementById('runTestBtn').disabled = false; | |
| } else { | |
| progressText.textContent = 'Error: ' + data.error; | |
| } | |
| } catch (e) { | |
| clearInterval(pollId); | |
| progressText.textContent = 'Error: ' + e.message; | |
| } | |
| btn.disabled = false; | |
| btn.textContent = 'Load Bank'; | |
| } | |
| async function runTest() { | |
| const fileInput = document.getElementById('testFile'); | |
| if (!fileInput.files.length || !bankDictionary) return; | |
| const btn = document.getElementById('runTestBtn'); | |
| btn.disabled = true; | |
| btn.innerHTML = '<span class="loading-spinner"></span>Testing...'; | |
| const file = fileInput.files[0]; | |
| try { | |
| const formData = new FormData(); | |
| formData.append('file', file); | |
| formData.append('silero_params', JSON.stringify(defaultParams)); | |
| const extractResp = await fetch(API + '/extract', { method: 'POST', body: formData }); | |
| const extractData = await extractResp.json(); | |
| if (!extractData.success) { | |
| showResult({ error: extractData.error }); | |
| btn.disabled = false; btn.textContent = 'Run Test'; return; | |
| } | |
| const simMode = document.querySelector('input[name="simMode"]:checked').value; | |
| const testFeatures = {}; | |
| if (extractData.features.hubert_embedding_mean) testFeatures.embedding = extractData.features.hubert_embedding_mean; | |
| if (extractData.features.hubert_embedding_sequence) testFeatures.embedding_sequence = extractData.features.hubert_embedding_sequence; | |
| if (extractData.features.wav2vec2_embedding_mean) testFeatures.wav2vec2_embedding = extractData.features.wav2vec2_embedding_mean; | |
| if (extractData.features.wav2vec2_embedding_sequence) testFeatures.wav2vec2_embedding_sequence = extractData.features.wav2vec2_embedding_sequence; | |
| if (extractData.features.trill_embedding_mean) testFeatures.trill_embedding_mean = extractData.features.trill_embedding_mean; | |
| if (extractData.features.trill_embedding_sequence) testFeatures.trill_embedding_sequence = extractData.features.trill_embedding_sequence; | |
| if (extractData.features.allosaurus_ipa) testFeatures.allosaurus_ipa = extractData.features.allosaurus_ipa; | |
| if (extractData.features.hubert_ctc_entropy !== undefined) testFeatures.hubert_ctc_entropy = extractData.features.hubert_ctc_entropy; | |
| if (!testFeatures.embedding && !testFeatures.embedding_sequence) Object.assign(testFeatures, extractData.features); | |
| const zThreshold = parseFloat(document.getElementById('zThresholdSlider').value) || 2.0; | |
| const simResp = await fetch(API + '/compute_similarities', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({ | |
| test_features: testFeatures, | |
| dictionary_entries: bankDictionary, | |
| similarity_mode: simMode, | |
| unknown_threshold: calibrationThreshold, | |
| dtw_calibration_threshold: dtwCalibrationThreshold, | |
| unknown_z_threshold: zThreshold, | |
| global_mean_embedding: globalMeanEmbedding, | |
| ctc_entropy_threshold: ctcEntropyAutoThreshold | |
| }) | |
| }); | |
| const simData = await simResp.json(); | |
| if (simData.success) { | |
| showResult({ | |
| results: simData.results, | |
| rejected: simData.rejected_to_unknown, | |
| rejectionReason: simData.rejection_reason, | |
| rejectionDebug: simData.rejection_debug, | |
| topScore: simData.top_score, | |
| scoreGap: simData.score_gap, | |
| filename: file.name, | |
| audio: extractData.processed_audio_base64, | |
| ensemble: simData.ensemble, | |
| consensusWinner: simData.consensus_winner, | |
| consensusVotes: simData.consensus_votes, | |
| }); | |
| } else { showResult({ error: simData.error }); } | |
| } catch (e) { showResult({ error: e.message }); } | |
| btn.disabled = false; | |
| btn.textContent = 'Run Test'; | |
| } | |
| function showResult(data) { | |
| const card = document.getElementById('resultCard'); | |
| const content = document.getElementById('resultContent'); | |
| card.classList.add('visible'); | |
| if (data.error) { | |
| content.innerHTML = `<div style="color:var(--error);font-family:var(--mono);padding:16px 0">Error: ${data.error}</div>`; | |
| return; | |
| } | |
| lastResultData = data; | |
| const results = data.results; | |
| const rejected = data.rejected; | |
| const rejectionReason = data.rejectionReason || ''; | |
| const scoreGap = data.scoreGap; | |
| const top = results[0]; | |
| const consensusWinner = data.consensusWinner || null; | |
| const consensusVotes = data.consensusVotes || 0; | |
| // Use consensus winner when 2+ channels agree; fall back to weighted-score winner | |
| const prediction = rejected ? '_unknown' : (consensusWinner || top.label); | |
| const consensusOverride = !rejected && consensusWinner && consensusWinner !== top.label; | |
| const isRTL = /[\u0590-\u05FF\u0600-\u06FF]/.test(prediction); | |
| const confClass = rejected ? 'rejected' : 'high'; | |
| const confText = rejected | |
| ? 'Rejected — Unknown' | |
| : consensusOverride | |
| ? `Consensus (${consensusVotes} channels agree)` | |
| : 'Confident Match'; | |
| const audioHtml = data.audio | |
| ? `<audio controls src="${data.audio}"></audio>` : ''; | |
| const maxScore = results[0].score; | |
| let rankHtml = ''; | |
| for (let i = 0; i < Math.min(results.length, 5); i++) { | |
| const r = results[i]; | |
| const pct = maxScore > 0 ? (r.score / maxScore * 100) : 0; | |
| const barClass = i === 0 ? 'top' : (r.label === '_unknown' ? 'unknown-cat' : 'other'); | |
| let extra = ''; | |
| if (r.dtw_score != null) | |
| extra += ` <span class="rank-extra">(dtw:${r.dtw_score.toFixed(4)} mean:${r.mean_score.toFixed(4)})</span>`; | |
| if (r.hubert_score != null && r.w2v_score != null) | |
| extra += ` <span class="rank-extra" style="color:#3b82f6">H:${r.hubert_score.toFixed(4)} W:${r.w2v_score.toFixed(4)}</span>`; | |
| else if (r.w2v_score != null) | |
| extra += ` <span class="rank-extra" style="color:#3b82f6">W2V:${r.w2v_score.toFixed(4)}</span>`; | |
| rankHtml += ` | |
| <div class="rank-row"> | |
| <span class="rank-num">${i+1}</span> | |
| <span class="rank-label">${r.label}</span> | |
| <div class="rank-bar-wrap"><div class="rank-bar ${barClass}" style="width:${pct}%"></div></div> | |
| <span class="rank-score">${r.score.toFixed(4)}${extra}</span> | |
| </div>`; | |
| } | |
| const rejBlock = rejectionReason | |
| ? `<div class="rejection-block"><span>⚠</span><span>Rejected: ${rejectionReason}</span></div>` : ''; | |
| const ensBlock = data.ensemble ? (() => { | |
| const e = data.ensemble; | |
| const dis = e.hubert_winner !== e.w2v_winner; | |
| return `<div class="ensemble-line${dis ? ' disagree' : ''}">${dis ? '⚠ Models disagree — ' : ''}Ensemble (${e.weights}): HuBERT=<strong>${e.hubert_winner}</strong> W2V=<strong>${e.w2v_winner}</strong></div>`; | |
| })() : ''; | |
| const debugBlock = data.rejectionDebug ? (() => { | |
| const d = data.rejectionDebug; | |
| return `<div class="debug-block">mean: ${d.mean_score}${d.cosine_floor != null ? ` (floor:${d.cosine_floor})` : ''} · z: ${d.z_score ?? '—'} (thr:${d.z_floor_used ?? d.z_threshold}${d.z_floor_source ? ' ' + d.z_floor_source : ''}) · w2v_z: ${d.w2v_z_score ?? '—'} · agree: ${d.models_agree == null ? '—' : d.models_agree ? '✓' : '✗'} · gap: ${d.raw_gap ?? '—'}</div>`; | |
| })() : ''; | |
| content.innerHTML = ` | |
| <div class="result-hero"> | |
| <div class="result-prediction${isRTL ? ' rtl' : ''}">${prediction}</div> | |
| <div><span class="conf-badge ${confClass}">${confText}</span></div> | |
| <div class="score-line">score: ${top.score.toFixed(4)}${scoreGap !== null ? ` · gap: ${scoreGap.toFixed(4)}` : ''}${dtwCalibrationThreshold !== null ? ` · dtw floor: ${dtwCalibrationThreshold.toFixed(4)}` : ''}${calibrationThreshold !== null ? ` · cosine floor: ${calibrationThreshold.toFixed(4)}` : ''}</div> | |
| </div> | |
| ${audioHtml} | |
| ${rejBlock}${ensBlock} | |
| <div style="margin-top:16px"> | |
| <div class="rankings-label">Top Candidates</div> | |
| ${rankHtml} | |
| </div> | |
| ${debugBlock} | |
| <div class="feedback-row"> | |
| <button class="btn btn-correct" onclick="feedbackCorrect()">✓ Correct</button> | |
| <button class="btn btn-wrong" onclick="feedbackWrong()">✗ Wrong</button> | |
| </div> | |
| <div id="feedbackMsg"></div>`; | |
| addToHistory(data.filename, prediction, top.score, scoreGap, rejected, data.ensemble); | |
| card.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); | |
| } | |
| function feedbackCorrect() { | |
| const msg = document.getElementById('feedbackMsg'); | |
| msg.className = 'feedback-msg ok'; | |
| msg.textContent = 'Marked as correct.'; | |
| document.querySelector('.btn-correct').disabled = true; | |
| document.querySelector('.btn-wrong').disabled = true; | |
| } | |
| async function feedbackWrong() { | |
| if (!lastResultData) return; | |
| const d = lastResultData; | |
| const bankName = document.getElementById('bankSelect').value || 'Bank_New'; | |
| document.querySelector('.btn-correct').disabled = true; | |
| document.querySelector('.btn-wrong').disabled = true; | |
| try { | |
| const resp = await fetch(API + '/review_log', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({ | |
| filename: d.filename, | |
| prediction: d.rejected ? '_unknown' : d.results[0].label, | |
| score: d.results[0].score, | |
| results_top5: d.results.slice(0, 5), | |
| audio_base64: d.audio, | |
| status: d.rejected ? 'unknown' : 'wrong', | |
| bank_name: bankName | |
| }) | |
| }); | |
| const result = await resp.json(); | |
| if (result.success) { | |
| const msg = document.getElementById('feedbackMsg'); | |
| msg.className = 'feedback-msg logged'; | |
| msg.textContent = 'Logged for parent review.'; | |
| loadReviewQueue(); | |
| } | |
| } catch (e) { | |
| document.getElementById('feedbackMsg').textContent = 'Error: ' + e.message; | |
| } | |
| } | |
| function addToHistory(filename, prediction, score, gap, rejected, ensemble) { | |
| document.getElementById('historyCard').style.display = 'block'; | |
| const tbody = document.getElementById('historyBody'); | |
| const isRTL = /[\u0590-\u05FF\u0600-\u06FF]/.test(prediction); | |
| const tag = rejected ? '<span class="tag rej">UNKNOWN</span>' : '<span class="tag ok">OK</span>'; | |
| const hubertTop = ensemble ? ensemble.hubert_winner : '—'; | |
| const w2vTop = ensemble ? ensemble.w2v_winner : '—'; | |
| const row = document.createElement('tr'); | |
| row.innerHTML = ` | |
| <td>${filename}</td> | |
| <td class="${isRTL ? 'rtl-cell' : ''}">${prediction}</td> | |
| <td>${score.toFixed(4)}</td> | |
| <td>${gap !== null ? gap.toFixed(4) : '—'}</td> | |
| <td class="${/[\u0590-\u05FF]/.test(hubertTop) ? 'rtl-cell' : ''}">${hubertTop}</td> | |
| <td class="${/[\u0590-\u05FF]/.test(w2vTop) ? 'rtl-cell' : ''}">${w2vTop}</td> | |
| <td>${tag}</td>`; | |
| tbody.insertBefore(row, tbody.firstChild); | |
| } | |
| async function loadReviewQueue() { | |
| try { | |
| const resp = await fetch(API + '/review_log'); | |
| const data = await resp.json(); | |
| const badge = document.getElementById('reviewBadge'); | |
| const card = document.getElementById('reviewCard'); | |
| const container = document.getElementById('reviewEntries'); | |
| const empty = document.getElementById('reviewEmpty'); | |
| if (data.total > 0) { | |
| badge.style.display = 'inline-block'; | |
| badge.textContent = `${data.total} need review`; | |
| card.style.display = 'block'; | |
| empty.style.display = 'none'; | |
| container.innerHTML = ''; | |
| for (const entry of data.entries) { | |
| const div = document.createElement('div'); | |
| div.className = 'review-entry'; | |
| div.id = `review-${entry.id}`; | |
| const isRTL = /[\u0590-\u05FF\u0600-\u06FF]/.test(entry.prediction); | |
| div.innerHTML = ` | |
| <div class="review-header"> | |
| <div> | |
| <div class="review-filename"> | |
| ${entry.filename} | |
| <span class="tag ${entry.status === 'wrong' ? 'wrong' : 'rej'}" style="margin-left:8px">${entry.status.toUpperCase()}</span> | |
| </div> | |
| <div class="review-prediction" ${isRTL ? 'dir="rtl"' : ''}> | |
| ${entry.prediction} | |
| <span style="font-size:13px;color:var(--text-2);font-family:var(--mono)">(${entry.score.toFixed(4)})</span> | |
| </div> | |
| </div> | |
| <span class="review-meta">${new Date(entry.timestamp).toLocaleString()}</span> | |
| </div> | |
| <div id="audio-${entry.id}" style="margin:8px 0"> | |
| <button class="btn btn-ghost" style="font-size:12px;padding:7px 16px" | |
| onclick="loadReviewAudio('${entry.id}')">Load Audio</button> | |
| </div> | |
| <div class="review-resolve-row"> | |
| <input type="text" id="word-${entry.id}" placeholder="Correct word..." dir="rtl"> | |
| <label><input type="checkbox" id="addbank-${entry.id}" checked> Add to bank</label> | |
| <button class="btn" style="padding:9px 20px;font-size:13px" | |
| onclick="resolveEntry('${entry.id}', '${entry.bank_name || 'Bank_New'}')">Save</button> | |
| </div>`; | |
| container.appendChild(div); | |
| } | |
| } else { | |
| badge.style.display = 'none'; | |
| if (card.style.display !== 'none') { | |
| empty.style.display = 'block'; | |
| container.innerHTML = ''; | |
| } | |
| } | |
| } catch {} | |
| } | |
| async function loadReviewAudio(entryId) { | |
| const container = document.getElementById(`audio-${entryId}`); | |
| container.innerHTML = '<span style="color:var(--text-2);font-size:12px;font-family:var(--mono)">Loading...</span>'; | |
| try { | |
| const resp = await fetch(API + `/review_log/${entryId}/audio`); | |
| const data = await resp.json(); | |
| container.innerHTML = data.audio_base64 | |
| ? `<audio controls src="${data.audio_base64}" style="margin:4px 0"></audio>` | |
| : '<span style="color:var(--text-2);font-size:12px">No audio available</span>'; | |
| } catch { | |
| container.innerHTML = '<span style="color:var(--error);font-size:12px">Failed to load audio</span>'; | |
| } | |
| } | |
| async function resolveEntry(entryId, bankName) { | |
| const wordInput = document.getElementById(`word-${entryId}`); | |
| const addToBank = document.getElementById(`addbank-${entryId}`).checked; | |
| const correctWord = wordInput.value.trim(); | |
| if (!correctWord) { wordInput.style.borderColor = 'var(--error)'; return; } | |
| try { | |
| const resp = await fetch(API + '/review_log/resolve', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({ id: entryId, correct_word: correctWord, add_to_bank: addToBank, bank_name: bankName }) | |
| }); | |
| const result = await resp.json(); | |
| if (result.success) { | |
| const el = document.getElementById(`review-${entryId}`); | |
| el.style.opacity = '0.5'; | |
| el.innerHTML = `<div style="text-align:center;padding:14px;color:var(--accent);font-weight:600;font-family:var(--mono)">✓ Resolved: "${correctWord}"${addToBank ? ' — added to bank' : ''}</div>`; | |
| setTimeout(() => { el.remove(); loadReviewQueue(); }, 2000); | |
| } | |
| } catch (e) { alert('Error: ' + e.message); } | |
| } | |
| function scrollToReview() { | |
| document.getElementById('reviewCard').scrollIntoView({ behavior: 'smooth' }); | |
| } | |
| // Initialize | |
| (async () => { | |
| const online = await checkStatus(); | |
| if (online) { | |
| await loadBanks(); | |
| await loadReviewQueue(); | |
| } | |
| })(); | |
| </script> | |
| </body> | |
| </html> | |