barvox-backend / test_ui.html
RonenShilchikov
Restructure: move Python backend into backend/ directory
423bed8
<!DOCTYPE html>
<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) !important; border-color: rgba(255,170,68,0.3) !important; color: var(--warn) !important; }
.step-num-alt-muted { background: rgba(90,106,144,0.12) !important; border-color: rgba(90,106,144,0.2) !important; color: var(--text-2) !important; }
</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>