quran-asr-tiny / code /dashboard.html
Abdou's picture
quran-asr-tiny: 16M Quran recitation ASR (step 245k)
38c1604 verified
Raw History Blame Contribute Delete
65.6 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Quran ASR Training</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%230f8a6a'/%3E%3Cpath d='M6 22 L12 14 L17 18 L26 8' stroke='white' stroke-width='3' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E">
<style>
@font-face { font-family: "Hafs"; src: url("/font/hafs.ttf") format("truetype"); font-display: swap; }
:root {
--bg: #f4f6f4; --panel: #ffffff; --panel-2: #f8faf9; --ink: #14201b; --muted: #66736d; --faint: #9aa59f;
--line: #e2e7e4; --grid: #edf1ee; --accent: #0f8a6a; --accent-soft: #e2f4ee;
--good: #128a5c; --warn: #b7770c; --bad: #cf3f35; --info: #2c67d1;
--s1: #0f8a6a; --s2: #2c67d1; --s3: #c2770a; --s4: #8a4fd1; --s5: #d23f6b; --s6: #0f93a8;
--shadow: 0 1px 2px rgba(20, 32, 27, .05), 0 2px 10px rgba(20, 32, 27, .04);
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0c1210; --panel: #131b18; --panel-2: #172120; --ink: #e6eeea; --muted: #8e9c95; --faint: #5f6d67;
--line: #22302b; --grid: #1a2420; --accent: #3cc99d; --accent-soft: #15322a;
--good: #3cc99d; --warn: #e3a33b; --bad: #f0675c; --info: #6e9cf2;
--s1: #3cc99d; --s2: #6e9cf2; --s3: #e3a33b; --s4: #b18cf0; --s5: #f07196; --s6: #4fc6d9;
--shadow: none; color-scheme: dark;
}
}
:root[data-theme="dark"] {
--bg: #0c1210; --panel: #131b18; --panel-2: #172120; --ink: #e6eeea; --muted: #8e9c95; --faint: #5f6d67;
--line: #22302b; --grid: #1a2420; --accent: #3cc99d; --accent-soft: #15322a;
--good: #3cc99d; --warn: #e3a33b; --bad: #f0675c; --info: #6e9cf2;
--s1: #3cc99d; --s2: #6e9cf2; --s3: #e3a33b; --s4: #b18cf0; --s5: #f07196; --s6: #4fc6d9;
--shadow: none; color-scheme: dark;
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html, body { margin: 0; }
body {
background: var(--bg); color: var(--ink);
font: 14px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-variant-numeric: tabular-nums; -webkit-font-smoothing: antialiased;
}
button, input { font: inherit; color: inherit; }
.wrap { max-width: 1320px; margin: 0 auto; padding: 0 24px 40px; }
/* header */
header { position: sticky; top: 0; z-index: 10; background: color-mix(in srgb, var(--bg) 88%, transparent);
backdrop-filter: blur(10px); border-bottom: 1px solid var(--line); }
.bar { display: flex; align-items: center; gap: 16px; padding: 12px 24px; max-width: 1320px; margin: 0 auto; flex-wrap: wrap; }
.brand { display: flex; align-items: center; gap: 10px; font-weight: 650; letter-spacing: -.01em; white-space: nowrap; }
.brand .mark { width: 26px; height: 26px; border-radius: 7px; background: var(--accent); display: grid; place-items: center; }
.brand .mark svg { width: 16px; height: 16px; }
.brand small { color: var(--muted); font-weight: 500; }
.runs { display: flex; gap: 6px; flex-wrap: wrap; flex: 1; min-width: 0; }
.chip { display: inline-flex; align-items: center; gap: 7px; padding: 5px 11px; border-radius: 999px; border: 1px solid var(--line);
background: var(--panel); cursor: pointer; white-space: nowrap; transition: border-color .15s, background .15s; }
.chip:hover { border-color: var(--faint); }
.chip i { width: 8px; height: 8px; border-radius: 50%; flex: none; }
.chip small { color: var(--muted); }
.chip.sel { border-color: var(--ink); }
.chip.primary { background: var(--ink); color: var(--bg); border-color: var(--ink); }
.chip.primary small { color: color-mix(in srgb, var(--bg) 70%, transparent); }
.live { display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font-size: 12.5px; white-space: nowrap; }
.live .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--good); box-shadow: 0 0 0 0 var(--good); animation: pulse 2s infinite; }
.live.off .dot { background: var(--bad); animation: none; }
@keyframes pulse { 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--good) 60%, transparent); } 70% { box-shadow: 0 0 0 7px transparent; } 100% { box-shadow: 0 0 0 0 transparent; } }
.icon-btn { border: 1px solid var(--line); background: var(--panel); border-radius: 8px; width: 32px; height: 32px; display: grid; place-items: center; cursor: pointer; }
.icon-btn:hover { border-color: var(--faint); }
.icon-btn svg { width: 16px; height: 16px; }
/* toolbar */
.toolbar { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; margin: 18px 0 14px; color: var(--muted); font-size: 13px; }
.seg { display: inline-flex; border: 1px solid var(--line); border-radius: 9px; overflow: hidden; background: var(--panel); }
.seg button { border: 0; background: none; padding: 5px 11px; cursor: pointer; color: var(--muted); }
.seg button + button { border-left: 1px solid var(--line); }
.seg button.on { background: var(--accent-soft); color: var(--ink); font-weight: 600; }
.tool { display: inline-flex; align-items: center; gap: 8px; }
.tool input[type=range] { width: 110px; accent-color: var(--accent); }
.tool input[type=checkbox] { accent-color: var(--accent); width: 15px; height: 15px; margin: 0; }
.pill-btn { border: 1px solid var(--line); background: var(--panel); border-radius: 999px; padding: 4px 12px; cursor: pointer; color: var(--ink); }
.pill-btn:hover { border-color: var(--faint); }
.pill-btn.on { background: var(--accent-soft); border-color: var(--accent); }
.pill-btn:disabled { opacity: .45; cursor: not-allowed; }
.spacer { flex: 1; }
/* status strip */
.status { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; box-shadow: var(--shadow); padding: 16px 18px; display: grid;
grid-template-columns: auto 1fr auto; gap: 8px 22px; align-items: center; }
.phase { display: inline-flex; align-items: center; gap: 8px; padding: 5px 12px; border-radius: 999px; font-weight: 600; font-size: 13px; white-space: nowrap; }
.phase i { width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
.phase.training { background: color-mix(in srgb, var(--good) 14%, transparent); color: var(--good); }
.phase.training i, .phase.validating i, .phase.preparing i { animation: blink 1.4s infinite; }
.phase.validating, .phase.preparing, .phase.starting { background: color-mix(in srgb, var(--info) 14%, transparent); color: var(--info); }
.phase.finished { background: color-mix(in srgb, var(--info) 14%, transparent); color: var(--info); }
.phase.stopped, .phase.unknown { background: color-mix(in srgb, var(--muted) 16%, transparent); color: var(--muted); }
.phase.crashed, .phase.stalled { background: color-mix(in srgb, var(--bad) 14%, transparent); color: var(--bad); }
@keyframes blink { 50% { opacity: .35; } }
.progress { min-width: 0; }
.progress .meta { display: flex; justify-content: space-between; gap: 12px; font-size: 13px; color: var(--muted); margin-bottom: 7px; flex-wrap: wrap; }
.progress .meta b { color: var(--ink); font-weight: 600; }
.track { height: 8px; border-radius: 99px; background: var(--grid); overflow: hidden; position: relative; }
.track > div { position: absolute; inset: 0 auto 0 0; border-radius: 99px; background: linear-gradient(90deg, color-mix(in srgb, var(--accent) 70%, transparent), var(--accent)); transition: width .6s; }
.track .ep { position: absolute; top: 0; bottom: 0; width: 1px; background: var(--panel); opacity: .9; }
.eta { text-align: right; white-space: nowrap; }
.eta .big { font-size: 20px; font-weight: 650; letter-spacing: -.02em; }
.eta .sub { color: var(--muted); font-size: 12.5px; }
.eta .pause { margin-top: 6px; }
.status .note { grid-column: 1 / -1; font-size: 12.5px; color: var(--muted); display: flex; gap: 16px; flex-wrap: wrap; }
.status .note .warn { color: var(--warn); }
.status .note .bad { color: var(--bad); }
/* KPI cards */
.kpis { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; margin: 14px 0; }
.kpi { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; box-shadow: var(--shadow); padding: 13px 15px; min-width: 0; }
.kpi .label { color: var(--muted); font-size: 12.5px; display: flex; justify-content: space-between; gap: 6px; }
.kpi .value { font-size: 25px; font-weight: 650; letter-spacing: -.03em; margin: 3px 0 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.kpi .value small { font-size: 14px; font-weight: 500; color: var(--muted); letter-spacing: 0; }
.kpi .sub { color: var(--muted); font-size: 12.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.delta.down { color: var(--good); } .delta.up { color: var(--bad); }
.meter { height: 4px; border-radius: 9px; background: var(--grid); margin-top: 8px; overflow: hidden; display: flex; }
.meter > div { height: 100%; }
/* charts */
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; box-shadow: var(--shadow); padding: 14px 16px 10px; min-width: 0; }
.card.wide { grid-column: 1 / -1; }
.chart-head { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 6px; }
.chart-head h3 { margin: 0; font-size: 14px; font-weight: 620; display: inline; }
.chart-head .hint { color: var(--muted); font-size: 12px; margin-left: 8px; }
.legend { display: flex; gap: 4px; flex-wrap: wrap; }
.legend button { display: inline-flex; align-items: center; gap: 6px; border: 0; background: none; padding: 2px 6px; border-radius: 6px; cursor: pointer; font-size: 12px; color: var(--muted); }
.legend button:hover { background: var(--grid); }
.legend button.off { opacity: .4; text-decoration: line-through; }
.legend svg { width: 16px; height: 8px; }
.plot { position: relative; height: 230px; touch-action: pan-y; }
.plot.short { height: 190px; }
.plot canvas { position: absolute; inset: 0; width: 100%; height: 100%; cursor: crosshair; }
.tip { position: absolute; pointer-events: none; z-index: 2; background: var(--panel); border: 1px solid var(--line); border-radius: 9px;
box-shadow: 0 6px 24px rgba(0,0,0,.14); padding: 7px 10px; font-size: 12px; min-width: 120px; white-space: nowrap; }
.tip .h { color: var(--muted); margin-bottom: 3px; }
.tip .r { display: flex; align-items: center; gap: 7px; }
.tip .r i { width: 8px; height: 8px; border-radius: 2px; }
.tip .r b { margin-left: auto; padding-left: 12px; font-weight: 600; }
.tip .r em { color: var(--muted); font-style: normal; }
.empty { position: absolute; inset: 0; display: grid; place-items: center; text-align: center; color: var(--faint); font-size: 13px; padding: 20px; }
/* transcripts */
section { margin-top: 26px; }
section > h2 { font-size: 16px; margin: 0 0 4px; letter-spacing: -.01em; }
section > p.lead { color: var(--muted); margin: 0 0 12px; font-size: 13px; }
.scrub { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; flex-wrap: wrap; }
.scrub input[type=range] { flex: 1; min-width: 160px; accent-color: var(--accent); }
.scrub .at { font-weight: 600; min-width: 150px; }
.samples { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.sample { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; box-shadow: var(--shadow); padding: 12px 15px; min-width: 0; }
.sample .top { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--muted); flex-wrap: wrap; }
.sample .top .sura { font-family: "Hafs", "Segoe UI", "Noto Naskh Arabic", serif; font-size: 17px; color: var(--ink); }
.tag { font-size: 11px; font-weight: 650; padding: 1px 7px; border-radius: 99px; letter-spacing: .02em; }
.tag.warsh { background: color-mix(in srgb, var(--s4) 16%, transparent); color: var(--s4); }
.tag.hafs { background: color-mix(in srgb, var(--s2) 14%, transparent); color: var(--s2); }
.sample .err { margin-left: auto; font-weight: 650; }
.ar { direction: rtl; text-align: right; font-family: "Hafs", "Segoe UI", "Noto Naskh Arabic", serif; font-size: 23px; line-height: 1.9; word-spacing: .08em; overflow-wrap: anywhere; }
.ar.ref { color: var(--muted); font-size: 19px; line-height: 1.8; }
.line-label { font-size: 11px; color: var(--faint); text-transform: uppercase; letter-spacing: .06em; margin-top: 6px; }
.ar .bad { color: var(--bad); background: color-mix(in srgb, var(--bad) 13%, transparent); border-radius: 3px; }
.ar .del { color: var(--bad); opacity: .55; text-decoration: line-through; }
.ar .blank { color: var(--faint); font-size: 14px; font-family: system-ui, sans-serif; direction: ltr; }
/* data */
.two { display: grid; grid-template-columns: 1.5fr 1fr; gap: 12px; }
.bars { display: grid; gap: 5px; max-height: 420px; overflow: auto; padding-right: 4px; }
.barrow { display: grid; grid-template-columns: minmax(0, 210px) 1fr 62px; gap: 10px; align-items: center; font-size: 12.5px; }
.barrow .n { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.barrow .b { height: 9px; border-radius: 9px; background: var(--grid); overflow: hidden; }
.barrow .b > div { height: 100%; border-radius: 9px; }
.barrow .h { text-align: right; color: var(--muted); }
.kv { display: grid; grid-template-columns: auto 1fr; gap: 7px 16px; font-size: 13px; margin: 0; }
.kv dt { color: var(--muted); }
.kv dd { margin: 0; text-align: right; font-weight: 550; }
.totals { display: flex; gap: 18px; margin-bottom: 12px; flex-wrap: wrap; }
.totals div { font-size: 12.5px; color: var(--muted); }
.totals b { display: block; font-size: 19px; color: var(--ink); letter-spacing: -.02em; }
.hero-empty { text-align: center; padding: 70px 20px; color: var(--muted); }
.hero-empty h2 { color: var(--ink); font-size: 20px; margin: 0 0 8px; }
code { font-family: ui-monospace, "Cascadia Code", Consolas, monospace; font-size: 12.5px; background: var(--grid); padding: 2px 6px; border-radius: 6px; color: var(--ink); }
footer { color: var(--faint); font-size: 12px; margin-top: 28px; text-align: center; overflow-wrap: anywhere; }
@media (max-width: 1100px) { .kpis { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (max-width: 820px) {
.wrap { padding: 0 16px 32px; } .bar { padding: 10px 16px; gap: 10px; }
.grid, .samples, .two { grid-template-columns: minmax(0, 1fr); }
.kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.status { grid-template-columns: 1fr; } .eta { text-align: left; }
.brand small { display: none; }
.plot { height: 200px; }
.barrow { grid-template-columns: minmax(0, 130px) 1fr 54px; }
}
</style>
</head>
<body>
<header>
<div class="bar">
<div class="brand">
<div class="mark"><svg viewBox="0 0 16 16"><path d="M2 12 L6 7 L9 9.5 L14 3" stroke="white" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
Quran ASR <small>training</small>
</div>
<div class="runs" id="runs"></div>
<span class="live" id="live"><span class="dot"></span><span id="liveText">connecting…</span></span>
<button class="icon-btn" id="theme" title="Theme: system / light / dark"></button>
</div>
</header>
<div class="wrap" id="app">
<div class="toolbar" id="toolbar">
<span class="tool">X axis
<span class="seg" id="xmode"></span>
</span>
<label class="tool" title="Exponential moving average, like TensorBoard">Smoothing
<input type="range" id="smooth" min="0" max="0.99" step="0.01"><b id="smoothVal"></b>
</label>
<label class="tool"><input type="checkbox" id="logLoss"> Log-scale loss</label>
<button class="pill-btn" id="compare" title="When on, clicking runs adds them to the charts">Compare runs</button>
<button class="pill-btn" id="resetZoom" hidden>Reset zoom</button>
<span class="spacer"></span>
<span id="dragHint">Drag on a chart to zoom · double-click to reset</span>
</div>
<div id="main"></div>
<footer id="footer"></footer>
</div>
<script>
'use strict';
const SURAHS = ['الفاتحة','البقرة','آل عمران','النساء','المائدة','الأنعام','الأعراف','الأنفال','التوبة','يونس','هود','يوسف','الرعد','إبراهيم','الحجر','النحل','الإسراء','الكهف','مريم','طه','الأنبياء','الحج','المؤمنون','النور','الفرقان','الشعراء','النمل','القصص','العنكبوت','الروم','لقمان','السجدة','الأحزاب','سبأ','فاطر','يس','الصافات','ص','الزمر','غافر','فصلت','الشورى','الزخرف','الدخان','الجاثية','الأحقاف','محمد','الفتح','الحجرات','ق','الذاريات','الطور','النجم','القمر','الرحمن','الواقعة','الحديد','المجادلة','الحشر','الممتحنة','الصف','الجمعة','المنافقون','التغابن','الطلاق','التحريم','الملك','القلم','الحاقة','المعارج','نوح','الجن','المزمل','المدثر','القيامة','الإنسان','المرسلات','النبأ','النازعات','عبس','التكوير','الانفطار','المطففين','الانشقاق','البروج','الطارق','الأعلى','الغاشية','الفجر','البلد','الشمس','الليل','الضحى','الشرح','التين','العلق','القدر','البينة','الزلزلة','العاديات','القارعة','التكاثر','العصر','الهمزة','الفيل','قريش','الماعون','الكوثر','الكافرون','النصر','المسد','الإخلاص','الفلق','الناس'];
const PALETTE = ['--s1', '--s2', '--s3', '--s4', '--s5', '--s6'];
const DASHES = [[], [6, 4], [2, 3], [10, 3, 2, 3], [1, 5]];
const POLL_MS = 3000;
/* ---------- small helpers ---------- */
const $ = (s, el = document) => el.querySelector(s);
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const store = {
get(k, d) { try { const v = localStorage.getItem('qasr.' + k); return v == null ? d : JSON.parse(v); } catch { return d; } },
set(k, v) { try { localStorage.setItem('qasr.' + k, JSON.stringify(v)); } catch { } },
};
const NF = new Intl.NumberFormat('en-US');
const fmtInt = v => NF.format(Math.round(v));
const fmtH = v => v < 10 ? (+v.toFixed(1)).toString() : fmtInt(v);
const fmtPct = v => v == null ? '–' : (v * 100).toFixed(v < 0.1 ? 2 : 1) + '%';
const fmtSteps = v => Math.abs(v) >= 1e6 ? +(v / 1e6).toFixed(2) + 'M' : Math.abs(v) >= 1e3 ? +(v / 1e3).toFixed(1) + 'k' : String(+v.toFixed(2));
const fmtSci = v => v === 0 ? '0' : v.toExponential(1).replace('e-', 'e-').replace('e+', 'e');
function fmtDur(sec) {
sec = Math.max(0, sec);
if (sec < 60) return Math.round(sec) + ' s';
if (sec < 3600) return Math.round(sec / 60) + ' min';
const h = Math.floor(sec / 3600), m = Math.round((sec % 3600) / 60);
if (h >= 48) return Math.floor(h / 24) + ' d ' + (h % 24) + ' h';
return h + ' h' + (m ? ' ' + String(m).padStart(2, '0') + ' min' : '');
}
const fmtHoursClock = h => { const t = Math.round(h * 60); return Math.floor(t / 60) + 'h' + String(t % 60).padStart(2, '0'); };
const fmtClock = t => new Date(t * 1000).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
function fmtWhen(t) {
const d = new Date(t * 1000), now = new Date();
const day = d.toDateString() === now.toDateString() ? '' :
d.toLocaleDateString('en-GB', { weekday: 'short' }) + ' ';
return day + fmtClock(t);
}
function prettyReciter(r) {
return r.replace(/^warsh\//, '').replace(/^warsh_/i, '').replace(/_QuranExplorer\.Com$/i, '')
.replace(/_\d+kbps$/i, '').replace(/_/g, ' ');
}
function ema(ys, w) {
const out = new Array(ys.length); let last = 0;
for (let i = 0; i < ys.length; i++) { last = last * w + (1 - w) * ys[i]; out[i] = last / (1 - Math.pow(w, i + 1)); }
return out;
}
function nearest(xs, x) {
let lo = 0, hi = xs.length - 1;
if (hi < 0) return -1;
while (hi - lo > 1) { const mid = (lo + hi) >> 1; if (xs[mid] < x) lo = mid; else hi = mid; }
return Math.abs(xs[lo] - x) <= Math.abs(xs[hi] - x) ? lo : hi;
}
function niceTicks(a, b, n) {
const span = b - a; if (!(span > 0)) return [a];
const raw = span / Math.max(1, n), mag = Math.pow(10, Math.floor(Math.log10(raw))), norm = raw / mag;
const step = (norm < 1.5 ? 1 : norm < 3 ? 2 : norm < 7 ? 5 : 10) * mag;
const out = [];
for (let v = Math.ceil(a / step) * step; v <= b + step * 1e-9; v += step) out.push(+v.toPrecision(12));
return out;
}
function logTicks(a, b) {
const lo = Math.floor(Math.log10(a)), hi = Math.ceil(Math.log10(b)), out = [];
const mults = hi - lo <= 1 ? [1, 2, 3, 5] : hi - lo <= 3 ? [1, 2, 5] : [1];
for (let e = lo; e <= hi; e++) for (const m of mults) { const v = m * Math.pow(10, e); if (v >= a * 0.999 && v <= b * 1.001) out.push(+v.toPrecision(6)); }
return out;
}
function timeTicks(a, b, n) {
const steps = [10, 30, 60, 120, 300, 600, 900, 1800, 3600, 7200];
const step = steps.find(s => (b - a) / s <= n) || 7200;
const out = []; for (let v = Math.ceil(a / step) * step; v <= b; v += step) out.push(v);
return out;
}
function median(a) { if (!a.length) return null; const s = [...a].sort((x, y) => x - y); return s[s.length >> 1]; }
/* ---------- state ---------- */
const S = {
runs: [], data: {}, root: '',
selected: store.get('selected', []), primary: store.get('primary', null), compare: store.get('compare', false),
x: store.get('x', 'step'), smooth: store.get('smooth', 0.6), logLoss: store.get('logLoss', false),
hidden: store.get('hidden', {}), theme: store.get('theme', 'system'),
zoom: null, hover: null, skew: 0, online: null, lastOk: 0,
sys: { samples: [], name: null, available: true, ram_total: null }, sysSince: 0,
sampleIdx: null, playTimer: null, samplesKey: '',
seeds: store.get('seeds', {}), samplesData: null, samplesFor: '',
};
const nowSec = () => Date.now() / 1000 + S.skew;
const css = {};
function readCss() {
const cs = getComputedStyle(document.documentElement);
for (const k of ['--ink', '--muted', '--faint', '--line', '--grid', '--panel', '--accent', '--bad', '--warn', '--good', ...PALETTE]) css[k] = cs.getPropertyValue(k).trim();
}
const runColor = name => css[PALETTE[Math.max(0, S.runs.findIndex(r => r.name === name)) % PALETTE.length]];
const metricColor = i => css[PALETTE[i % PALETTE.length]];
const XMODES = {
step: { label: 'Step', get: r => r.step, fmt: fmtSteps, tip: v => 'step ' + fmtInt(v) },
hours: { label: 'Audio hours', get: r => r.hours_seen, fmt: v => fmtSteps(v) + ' h', tip: v => (+v.toFixed(1)) + ' h of audio' },
time: { label: 'Time', get: r => r.elapsed_min == null ? null : r.elapsed_min / 60, fmt: fmtHoursClock, tip: v => fmtDur(v * 3600) + ' of training' },
};
/* ---------- charts ---------- */
const gpuTotalGb = () => { const s = S.sys.samples.at(-1); return s && s.mem_total ? s.mem_total / 1024 : null; };
const CHARTS = [
{ id: 'loss', title: 'Loss per letter', hint: 'training CTC loss', rows: 'train', smooth: true, log: () => S.logLoss,
metrics: [{ key: 'loss', label: 'loss' }], fmt: v => v.toFixed(3), tick: v => String(+v.toPrecision(3)) },
{ id: 'valid', title: 'Validation error', hint: 'held-out clips · lower is better', rows: 'evals', zero: true,
metrics: [{ key: 'valid_cer', label: 'letters' }, { key: 'valid_wer', label: 'words' }, { key: 'valid_skeleton', label: 'skeleton' },
{ key: 'valid_hafs_cer', label: 'Hafs letters' }, { key: 'valid_warsh_cer', label: 'Warsh letters' }],
fmt: fmtPct, tick: v => +(v * 100).toFixed(2) + '%', points: true },
{ id: 'lr', title: 'Learning rate', hint: 'warmup + cosine', rows: 'train', zero: true, metrics: [{ key: 'lr', label: 'lr' }], fmt: fmtSci },
{ id: 'grad', title: 'Gradient norm', hint: 'before clipping', rows: 'train', smooth: true, zero: true,
metrics: [{ key: 'grad_norm', label: 'norm' }], fmt: v => v.toFixed(2), tick: v => String(+v.toPrecision(3)),
refs: d => d.status?.train?.grad_clip ? [{ y: d.status.train.grad_clip, label: 'clip' }] : [] },
{ id: 'speed', title: 'Throughput', hint: 'hours of audio trained per hour', rows: 'train', smooth: true, zero: true,
metrics: [{ key: 'audio_h_per_h', label: 'audio h/h' }], fmt: v => fmtInt(v) + ' h/h', tick: v => fmtSteps(v) },
{ id: 'mem', title: 'GPU memory', hint: 'training peak per log interval', rows: 'train', zero: true,
metrics: [{ key: 'gpu_peak_gb', label: 'peak' }], fmt: v => v.toFixed(2) + ' GB', tick: v => +v.toFixed(1) + ' GB',
refs: () => gpuTotalGb() ? [{ y: gpuTotalGb(), label: 'GPU total' }] : [] },
];
class Chart {
constructor(host, cfg, { shared = true, short = false } = {}) {
this.cfg = cfg; this.shared = shared; this.series = []; this.refs = []; this.drag = null; this.localHover = null;
host.innerHTML = `<div class="chart-head"><div><h3>${esc(cfg.title)}</h3>${cfg.hint ? `<span class="hint">${esc(cfg.hint)}</span>` : ''}</div><div class="legend"></div></div>
<div class="plot${short ? ' short' : ''}"><canvas></canvas><div class="tip" hidden></div><div class="empty" hidden></div></div>`;
this.plot = $('.plot', host); this.canvas = $('canvas', host); this.tip = $('.tip', host);
this.emptyEl = $('.empty', host); this.legendEl = $('.legend', host);
new ResizeObserver(() => this.draw()).observe(this.plot);
const local = e => { const r = this.canvas.getBoundingClientRect(); return { px: e.clientX - r.left, py: e.clientY - r.top }; };
this.canvas.addEventListener('pointermove', e => {
if (!this.view) return;
const { px, py } = local(e);
if (this.drag) this.drag.x1 = px;
this.setHover(this.view.inv(px), px, py);
});
this.canvas.addEventListener('pointerleave', () => { if (!this.drag) this.setHover(null); });
this.canvas.addEventListener('pointerdown', e => {
if (!this.shared || e.button !== 0 || e.pointerType !== 'mouse' || !this.view) return;
const { px } = local(e); this.drag = { x0: px, x1: px }; this.canvas.setPointerCapture(e.pointerId);
});
this.canvas.addEventListener('pointerup', () => {
const d = this.drag; this.drag = null;
if (d && Math.abs(d.x1 - d.x0) > 8) {
const a = this.view.inv(Math.min(d.x0, d.x1)), b = this.view.inv(Math.max(d.x0, d.x1));
S.zoom = [a, b]; renderCharts();
} else this.draw();
});
this.canvas.addEventListener('dblclick', () => { if (this.shared && S.zoom) { S.zoom = null; renderCharts(); } });
}
setHover(x, px, py) {
this.mouse = x == null ? null : { px, py };
if (this.shared) { S.hover = x == null ? null : { x, chart: this }; charts.forEach(c => c.draw()); }
else { this.localHover = x; this.draw(); }
}
setData(series, refs) { this.series = series; this.refs = refs || []; this.renderLegend(); this.draw(); }
renderLegend() {
const cfg = this.cfg, seen = new Map();
for (const s of this.series) if (!seen.has(s.key)) seen.set(s.key, s);
if (seen.size <= 1 && !cfg.alwaysLegend) { this.legendEl.innerHTML = ''; return; }
const multi = new Set(this.series.map(s => s.run)).size > 1;
this.legendEl.innerHTML = [...seen.values()].map(s => {
const color = multi ? css['--muted'] : s.color;
return `<button data-k="${esc(s.key)}" class="${s.hidden ? 'off' : ''}"><svg viewBox="0 0 16 8"><line x1="1" y1="4" x2="15" y2="4" stroke="${color}" stroke-width="2.4" stroke-dasharray="${(s.dash || []).join(' ')}" stroke-linecap="round"/></svg>${esc(s.label)}</button>`;
}).join('');
this.legendEl.querySelectorAll('button').forEach(b => b.onclick = () => {
const h = S.hidden[cfg.id] = S.hidden[cfg.id] || {};
h[b.dataset.k] = !h[b.dataset.k]; store.set('hidden', S.hidden);
cfg.id === 'gpu' ? renderGpu() : renderCharts();
});
}
draw() {
const cv = this.canvas, rect = this.plot.getBoundingClientRect(), dpr = window.devicePixelRatio || 1;
const W = Math.max(10, rect.width), H = Math.max(10, rect.height);
if (cv.width !== Math.round(W * dpr) || cv.height !== Math.round(H * dpr)) { cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr); }
const ctx = cv.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, W, H);
const cfg = this.cfg, vis = this.series.filter(s => !s.hidden && s.xs.length);
if (!vis.length) { this.view = null; this.tip.hidden = true; this.emptyEl.hidden = false; this.emptyEl.textContent = this.emptyText || 'No data yet'; return; }
this.emptyEl.hidden = true;
// ranges
let x0 = Infinity, x1 = -Infinity;
for (const s of vis) for (const x of s.xs) { if (x < x0) x0 = x; if (x > x1) x1 = x; }
if (this.shared) [x0, x1] = S.zoom || S.xExtent || [x0, x1]; // all training charts share one x range
if (x1 - x0 < 1e-9) { if (x1 > 0) x0 = 0; else { x0 -= 1; x1 += 1; } }
const log = cfg.log && cfg.log();
let y0 = Infinity, y1 = -Infinity;
const take = y => { if (y == null || !isFinite(y) || (log && y <= 0)) return; if (y < y0) y0 = y; if (y > y1) y1 = y; };
for (const s of vis) for (let i = 0; i < s.xs.length; i++) if (s.xs[i] >= x0 && s.xs[i] <= x1) { take(s.ys[i]); if (s.raw) take(s.raw[i]); }
for (const r of this.refs) take(r.y);
if (cfg.yRange) [y0, y1] = cfg.yRange;
if (!isFinite(y0)) { y0 = 0; y1 = 1; }
if (cfg.zero && !log) y0 = Math.min(0, y0);
if (log) { const a = Math.log10(y0), b = Math.log10(y1), pad = Math.max(0.05, (b - a) * 0.06); y0 = Math.pow(10, a - pad); y1 = Math.pow(10, b + pad); }
else if (!cfg.yRange) { const pad = (y1 - y0) * 0.08 || Math.abs(y1) * 0.1 || 1; y1 += pad; if (!cfg.zero || y0 < 0) y0 -= pad; }
const yTicks = log ? logTicks(y0, y1) : niceTicks(y0, y1, Math.max(2, Math.round(H / 48)));
const tickFmt = cfg.tick || cfg.fmt;
ctx.font = '11px system-ui, "Segoe UI", sans-serif';
const labelW = Math.max(...yTicks.map(t => ctx.measureText(tickFmt(t)).width));
const pad = { l: Math.ceil(labelW) + 12, r: 10, t: 8, b: 22 };
const pw = W - pad.l - pad.r, ph = H - pad.t - pad.b;
const X = v => pad.l + (v - x0) / (x1 - x0) * pw;
const ly0 = log ? Math.log10(y0) : y0, ly1 = log ? Math.log10(y1) : y1;
const Y = v => pad.t + (1 - ((log ? Math.log10(v) : v) - ly0) / (ly1 - ly0)) * ph;
this.view = { inv: px => x0 + (px - pad.l) / pw * (x1 - x0), X, Y, pad, W, H };
// grid and axes
ctx.lineWidth = 1; ctx.strokeStyle = css['--grid']; ctx.fillStyle = css['--muted'];
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
for (const t of yTicks) { const y = Math.round(Y(t)) + .5; ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(W - pad.r, y); ctx.stroke(); ctx.fillText(tickFmt(t), pad.l - 7, y); }
const xFmt = cfg.xFmt || XMODES[S.x].fmt;
const xTicks = cfg.xTime ? timeTicks(x0, x1, Math.max(2, Math.round(pw / 90))) : niceTicks(x0, x1, Math.max(2, Math.round(pw / 90)));
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
for (const t of xTicks) { const x = X(t); if (x < pad.l - 1 || x > W - pad.r + 1) continue; ctx.fillText(xFmt(t), x, H - pad.b + 7); }
ctx.strokeStyle = css['--line']; ctx.beginPath(); ctx.moveTo(pad.l, pad.t + ph + .5); ctx.lineTo(W - pad.r, pad.t + ph + .5); ctx.stroke();
ctx.save(); ctx.beginPath(); ctx.rect(pad.l, pad.t - 2, pw, ph + 4); ctx.clip();
for (const r of this.refs) {
const y = Math.round(Y(r.y)) + .5;
ctx.strokeStyle = css['--bad']; ctx.globalAlpha = .55; ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(W - pad.r, y); ctx.stroke(); ctx.setLineDash([]);
ctx.globalAlpha = .85; ctx.fillStyle = css['--bad']; ctx.textAlign = 'right'; ctx.textBaseline = 'bottom';
ctx.fillText(r.label, W - pad.r - 4, y - 3); ctx.globalAlpha = 1;
}
const path = (s, ys) => {
ctx.beginPath(); let open = false;
for (let i = 0; i < s.xs.length; i++) {
const y = ys[i]; if (y == null || (log && y <= 0)) { open = false; continue; }
const px = X(s.xs[i]), py = Y(y);
if (open) ctx.lineTo(px, py); else { ctx.moveTo(px, py); open = true; }
}
};
ctx.lineJoin = 'round'; ctx.lineCap = 'round';
for (const s of vis) if (s.raw) { ctx.strokeStyle = s.color; ctx.globalAlpha = .18; ctx.lineWidth = 1.2; ctx.setLineDash(s.dash || []); path(s, s.raw); ctx.stroke(); }
ctx.globalAlpha = 1;
for (const s of vis) {
ctx.strokeStyle = s.color; ctx.lineWidth = s.width || 2; ctx.setLineDash(s.dash || []); path(s, s.ys); ctx.stroke();
if (s.fill) {
ctx.setLineDash([]); ctx.lineTo(X(s.xs.at(-1)), pad.t + ph); ctx.lineTo(X(s.xs[0]), pad.t + ph); ctx.closePath();
ctx.globalAlpha = .08; ctx.fillStyle = s.color; ctx.fill(); ctx.globalAlpha = 1;
}
if (cfg.points && s.xs.length < 80) {
ctx.setLineDash([]); ctx.fillStyle = s.color;
for (let i = 0; i < s.xs.length; i++) { ctx.beginPath(); ctx.arc(X(s.xs[i]), Y(s.ys[i]), 2.3, 0, 7); ctx.fill(); }
}
}
ctx.setLineDash([]);
// the newest point of each line
for (const s of vis) {
const i = s.xs.length - 1, px = X(s.xs[i]);
if (px < pad.l || px > W - pad.r || s.ys[i] == null) continue;
ctx.fillStyle = s.color; ctx.beginPath(); ctx.arc(px, Y(s.ys[i]), 3.2, 0, 7); ctx.fill();
}
// hover
const hx = this.shared ? S.hover?.x : this.localHover;
const mine = this.shared ? S.hover?.chart === this : this.localHover != null;
this.tip.hidden = true;
if (hx != null && hx >= x0 && hx <= x1) {
const hits = [];
for (const s of vis) { const i = nearest(s.xs, hx); if (i >= 0 && s.ys[i] != null) hits.push({ s, i }); }
if (hits.length) {
const ref = hits.reduce((a, b) => Math.abs(a.s.xs[a.i] - hx) <= Math.abs(b.s.xs[b.i] - hx) ? a : b);
const sx = X(ref.s.xs[ref.i]);
ctx.strokeStyle = css['--faint']; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(Math.round(sx) + .5, pad.t); ctx.lineTo(Math.round(sx) + .5, pad.t + ph); ctx.stroke();
for (const { s, i } of hits) {
const px = X(s.xs[i]); if (Math.abs(px - sx) > 30) continue;
ctx.fillStyle = css['--panel']; ctx.strokeStyle = s.color; ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(px, Y(s.ys[i]), 4, 0, 7); ctx.fill(); ctx.stroke();
}
if (mine && this.mouse) this.showTip(hits, ref, sx);
}
}
// drag-to-zoom selection
if (this.drag) {
const a = Math.max(pad.l, Math.min(this.drag.x0, this.drag.x1)), b = Math.min(W - pad.r, Math.max(this.drag.x0, this.drag.x1));
ctx.fillStyle = css['--accent']; ctx.globalAlpha = .12; ctx.fillRect(a, pad.t, b - a, ph); ctx.globalAlpha = 1;
}
ctx.restore();
}
showTip(hits, ref, sx) {
const cfg = this.cfg, multi = new Set(hits.map(h => h.s.run)).size > 1;
const head = cfg.tipHead ? cfg.tipHead(ref) : XMODES[S.x].tip(ref.s.xs[ref.i]);
let rows = '';
if (cfg.tipRows) rows = cfg.tipRows(ref);
else for (const { s, i } of hits) {
const raw = s.raw && Math.abs(s.raw[i] - s.ys[i]) > 1e-9 ? ` <em>raw ${cfg.fmt(s.raw[i])}</em>` : '';
const name = (multi ? s.run + ' · ' : '') + s.label;
rows += `<div class="r"><i style="background:${s.color}"></i>${esc(name)}<b>${cfg.fmt(s.ys[i])}${raw}</b></div>`;
}
this.tip.innerHTML = `<div class="h">${esc(head)}</div>${rows}`;
this.tip.hidden = false;
const tw = this.tip.offsetWidth, th = this.tip.offsetHeight, { W, H } = this.view;
let left = sx + 14; if (left + tw > W) left = sx - tw - 14; left = Math.max(0, left);
let top = Math.min(Math.max(0, this.mouse.py - th / 2), H - th);
this.tip.style.left = left + 'px'; this.tip.style.top = top + 'px';
}
}
/* ---------- data ---------- */
function prepare(d) {
const L = d.log || {}, n = (L.step || []).length, keys = Object.keys(L), train = [], evals = [];
for (let i = 0; i < n; i++) {
const row = {}; for (const k of keys) row[k] = L[k][i];
if (row.loss != null) train.push(row);
if (row.valid_cer != null) evals.push(row);
}
return { ...d, train, evals };
}
async function getJSON(url) {
const r = await fetch(url, { cache: 'no-store' });
if (!r.ok && r.status !== 404) throw new Error(r.status);
return r.json();
}
let charts = [], gpuChart = null, built = '';
async function refresh() {
let changed = false;
try {
const r = await getJSON('/api/runs');
S.skew = r.now - Date.now() / 1000; S.root = r.root;
const before = JSON.stringify(S.runs.map(x => [x.name, x.phase]));
S.runs = r.runs;
if (JSON.stringify(S.runs.map(x => [x.name, x.phase])) !== before) changed = true;
const names = new Set(S.runs.map(x => x.name));
S.selected = S.selected.filter(n => names.has(n));
if (!S.selected.length && S.runs.length) S.selected = [[...S.runs].sort((a, b) => b.updated_at - a.updated_at)[0].name];
if (!S.selected.includes(S.primary)) S.primary = S.selected[0] || null;
await Promise.all(S.selected.map(async n => {
const known = S.data[n]?.version, info = S.runs.find(x => x.name === n);
if (known && info && info.version === known) return;
const d = await getJSON('/api/run/' + encodeURIComponent(n) + (known ? '?v=' + encodeURIComponent(known) : ''));
if (!d.unchanged && !d.error) { S.data[n] = prepare(d); changed = true; }
}));
await loadSamples();
const sys = await getJSON('/api/system?since=' + S.sysSince);
S.sys.name = sys.name; S.sys.available = sys.available; S.sys.ram_total = sys.ram_total;
if (sys.samples.length) {
S.sys.samples.push(...sys.samples); S.sysSince = sys.samples.at(-1).t;
const cut = S.sysSince - 3600; while (S.sys.samples.length && S.sys.samples[0].t < cut) S.sys.samples.shift();
}
S.online = true; S.lastOk = Date.now();
} catch (e) {
S.online = false;
}
render(changed);
}
async function loop() { await refresh(); setTimeout(loop, POLL_MS); }
/* ---------- rendering ---------- */
function render(changed) {
renderHeader();
const main = $('#main');
const key = S.runs.length ? 'runs' : 'empty';
if (key !== built) {
built = key;
if (key === 'empty') {
main.innerHTML = `<div class="hero-empty"><h2>No training runs yet</h2>
<p>Start one in another window, for example <code>python train.py --config config/pilot.yaml</code>.<br>
This page picks it up by itself within a few seconds.</p></div>
<div class="card wide" id="gpuCard"></div>`;
charts = [];
} else {
main.innerHTML = `<div class="status" id="status"></div><div class="kpis" id="kpis"></div>
<div class="grid" id="chartGrid">${CHARTS.map(c => `<div class="card" id="c_${c.id}"></div>`).join('')}<div class="card wide" id="gpuCard"></div></div>
<section id="samplesSec"><h2>What the model hears</h2><p class="lead">The same validation ayahs at every evaluation: reference above, the model's transcription below, with its mistakes marked. Drag the slider or press play to watch it learn to read.</p><div id="samples"></div></section>
<section><h2>Data and model</h2><p class="lead" id="dataLead"></p><div class="two"><div class="card" id="reciters"></div><div class="card" id="modelCard"></div></div></section>`;
charts = CHARTS.map(c => new Chart($('#c_' + c.id), c));
}
gpuChart = new Chart($('#gpuCard'), GPU_CHART, { shared: false, short: true });
S.samplesKey = '';
changed = true;
}
renderToolbar();
if (S.runs.length) { renderStatus(); renderKpis(); if (changed) { renderCharts(); renderSamples(); renderData(); } }
renderGpu();
$('#footer').innerHTML = S.root ? `Runs from <code>${esc(S.root)}</code> · updates every ${POLL_MS / 1000} s` : '';
}
function renderHeader() {
const live = $('#live');
live.classList.toggle('off', S.online === false);
$('#liveText').textContent = S.online === false ? 'dashboard server stopped' : S.online ? 'live' : 'connecting…';
const html = S.runs.map(r => {
const sel = S.selected.includes(r.name), prim = r.name === S.primary;
const ph = phaseOf(r.name);
return `<button class="chip${sel ? ' sel' : ''}${prim ? ' primary' : ''}" data-run="${esc(r.name)}" title="Click to view${S.compare ? ' / add to the comparison' : ' · Ctrl+click to compare'}">
<i style="background:${runColor(r.name)}"></i>${esc(r.name)}<small>${esc(ph.short)}</small></button>`;
}).join('');
const el = $('#runs');
if (el.dataset.html !== html) {
el.innerHTML = html; el.dataset.html = html;
el.querySelectorAll('.chip').forEach(b => b.onclick = e => selectRun(b.dataset.run, e.ctrlKey || e.metaKey || e.shiftKey || S.compare));
}
}
function selectRun(name, add) {
if (add) {
if (S.selected.includes(name)) {
if (S.primary !== name) S.primary = name;
else if (S.selected.length > 1) { S.selected = S.selected.filter(n => n !== name); S.primary = S.selected[0]; }
} else { S.selected.push(name); S.primary = name; }
} else { S.selected = [name]; S.primary = name; }
store.set('selected', S.selected); store.set('primary', S.primary);
S.sampleIdx = null; stopPlay(); $('#runs').dataset.html = '';
S.samplesFor = ''; render(true); refresh();
}
function renderToolbar() {
const xm = $('#xmode');
if (!xm.children.length) {
xm.innerHTML = Object.entries(XMODES).map(([k, m]) => `<button data-x="${k}">${m.label}</button>`).join('');
xm.querySelectorAll('button').forEach(b => b.onclick = () => { S.x = b.dataset.x; S.zoom = null; store.set('x', S.x); renderToolbar(); renderCharts(); });
const sm = $('#smooth'); sm.value = S.smooth;
sm.oninput = () => { S.smooth = +sm.value; store.set('smooth', S.smooth); renderToolbar(); renderCharts(); };
const lg = $('#logLoss'); lg.checked = S.logLoss;
lg.onchange = () => { S.logLoss = lg.checked; store.set('logLoss', S.logLoss); renderCharts(); };
$('#compare').onclick = () => { S.compare = !S.compare; store.set('compare', S.compare); $('#runs').dataset.html = ''; renderToolbar(); renderHeader(); };
$('#resetZoom').onclick = () => { S.zoom = null; renderCharts(); };
}
xm.querySelectorAll('button').forEach(b => b.classList.toggle('on', b.dataset.x === S.x));
$('#smoothVal').textContent = S.smooth.toFixed(2);
$('#compare').classList.toggle('on', S.compare);
$('#resetZoom').hidden = !S.zoom;
$('#toolbar').style.display = S.runs.length ? '' : 'none';
}
function phaseOf(name) {
const d = S.data[name], st = d?.status || null, info = S.runs.find(r => r.name === name);
const phase = st?.phase || info?.phase;
if (!phase) return { key: 'unknown', label: 'No status', short: '' };
const LABELS = { starting: 'Starting', preparing: 'Preparing', training: 'Training', validating: 'Validating', finished: 'Finished', stopped: 'Paused', crashed: 'Crashed' };
const res = { key: phase, label: LABELS[phase] || phase, short: (LABELS[phase] || phase).toLowerCase() };
const updated = st?.updated_at || info?.updated_at;
if (['starting', 'preparing', 'training', 'validating'].includes(phase) && updated) {
const age = nowSec() - updated;
const gaps = []; const tr = d?.train || [];
for (let i = Math.max(1, tr.length - 12); i < tr.length; i++) if (tr[i].elapsed_min != null && tr[i - 1].elapsed_min != null) gaps.push((tr[i].elapsed_min - tr[i - 1].elapsed_min) * 60);
const typical = median(gaps.filter(g => g > 0)) || 120;
const limit = phase === 'training' ? Math.max(600, typical * 6) : 3600;
if (age > limit) return { key: 'stalled', label: 'Not responding', short: 'silent', age };
}
return res;
}
function renderStatus() {
const d = S.data[S.primary], st = d?.status, el = $('#status');
if (!d) { el.innerHTML = '<div class="progress">Loading…</div>'; return; }
const ph = phaseOf(S.primary);
const last = d.train.at(-1), step = Math.max(st?.step || 0, last?.step || 0), total = st?.total_steps || 0;
const frac = total ? Math.min(1, step / total) : 0;
const epochs = st?.epochs || 0, epoch = st?.epoch || last?.epoch || 0;
const ticks = epochs > 1 ? Array.from({ length: epochs - 1 }, (_, i) => `<span class="ep" style="left:${(i + 1) / epochs * 100}%"></span>`).join('') : '';
let big = '–', sub = '';
if ((ph.key === 'training' || ph.key === 'validating') && st?.eta_h != null) {
const left = Math.max(0, st.eta_h * 3600 - (nowSec() - st.updated_at));
big = fmtDur(left) + ' left'; sub = 'done around ' + fmtWhen(nowSec() + left);
} else if (ph.key === 'finished') { big = 'Done'; sub = 'after ' + fmtDur((st?.elapsed_min || 0) * 60) + ' of training'; }
else if (ph.key === 'stopped') { big = 'Paused'; sub = (st?.stop_reason ? st.stop_reason + ' · ' : '') + 'run the same train.py command to continue'; }
else if (ph.key === 'crashed') { big = 'Crashed'; sub = 'see the train.py window'; }
else if (ph.key === 'stalled') { big = 'No updates'; sub = 'for ' + fmtDur(ph.age) + ': is train.py still running?'; }
else if (ph.key === 'preparing' || ph.key === 'starting') { big = 'Starting'; sub = 'computing feature statistics'; }
const notes = [];
if (st?.device) notes.push(esc(st.device));
if (st?.params_m) notes.push(st.params_m.toFixed(1) + 'M parameters');
if (st?.elapsed_min != null) notes.push(fmtDur(st.elapsed_min * 60) + ' trained so far');
if (st?.updated_at) notes.push('last update ' + fmtDur(nowSec() - st.updated_at) + ' ago');
const info = S.runs.find(r => r.name === S.primary);
const running = ph.key === 'training' || ph.key === 'validating';
if (running && info?.pause_requested) notes.push('<span class="warn">pausing: saving a checkpoint in a few seconds…</span>');
else if (running && st?.stop_at) notes.push(`pauses by itself at ${fmtWhen(st.stop_at)}`);
if (st?.skipped_batches) notes.push(`<span class="warn">${st.skipped_batches} batch${st.skipped_batches > 1 ? 'es' : ''} skipped (out of GPU memory)</span>`);
if (st?.error) notes.push(`<span class="bad">${esc(st.error)}</span>`);
el.innerHTML = `<span class="phase ${ph.key}"><i></i>${esc(ph.label)}</span>
<div class="progress"><div class="meta"><span><b>${fmtInt(step)}</b> / ${fmtInt(total)} steps · epoch <b>${epoch}</b> / ${epochs}</span><span><b>${(frac * 100).toFixed(1)}%</b></span></div>
<div class="track"><div style="width:${frac * 100}%"></div>${ticks}</div></div>
<div class="eta"><div class="big">${big}</div><div class="sub">${sub}</div>${running
? `<button class="pill-btn pause" id="pauseBtn">${info?.pause_requested ? 'Cancel pause' : 'Pause training'}</button>` : ''}</div>
<div class="note">${notes.map(n => `<span>${n}</span>`).join('')}</div>`;
const btn = $('#pauseBtn');
if (btn) btn.onclick = async () => {
const cancel = !!info?.pause_requested;
if (!cancel && !confirm('Pause training?\n\ntrain.py saves a checkpoint and exits within seconds. Run the same command again to continue exactly where it stopped.')) return;
btn.disabled = true;
try { await fetch('/api/run/' + encodeURIComponent(S.primary) + '/pause' + (cancel ? '?cancel=1' : ''), { method: 'POST' }); } catch { }
refresh();
};
}
function renderKpis() {
const d = S.data[S.primary], el = $('#kpis');
if (!d) { el.innerHTML = ''; return; }
const st = d.status || {}, ev = d.evals, e = ev.at(-1), p = ev.at(-2), tr = d.train, t = tr.at(-1);
const evalEvery = st.train?.eval_every_steps;
const noEval = evalEvery ? 'first check at step ' + fmtInt(evalEvery) : 'after the first validation';
const delta = (a, b) => {
if (a == null || b == null) return '';
const dv = (a - b) * 100; if (Math.abs(dv) < 0.005) return '<span class="delta">±0</span>';
return `<span class="delta ${dv < 0 ? 'down' : 'up'}">${dv < 0 ? '▼' : '▲'} ${Math.abs(dv).toFixed(2)}</span>`;
};
const smoothLoss = tr.length ? ema(tr.map(r => r.loss), S.smooth).at(-1) : null;
const speeds = tr.slice(-5).map(r => r.audio_h_per_h).filter(v => v != null);
const speed = speeds.length ? speeds.reduce((a, b) => a + b) / speeds.length : null;
let stepsPerSec = null;
if (tr.length > 2) { const a = tr.at(-Math.min(6, tr.length)), b = t; if (b.elapsed_min > a.elapsed_min) stepsPerSec = (b.step - a.step) / ((b.elapsed_min - a.elapsed_min) * 60); }
const g = S.sys.samples.at(-1);
const best = st.best_cer != null ? `best ${fmtPct(st.best_cer)} at step ${fmtSteps(st.best_step || 0)}` : '';
const cards = [
{ label: 'Letter error', value: e ? fmtPct(e.valid_cer) : '–', d: e && p ? delta(e.valid_cer, p.valid_cer) : '', sub: e ? best : noEval },
{ label: 'Word error', value: e ? fmtPct(e.valid_wer) : '–', d: e && p ? delta(e.valid_wer, p.valid_wer) : '',
sub: e && e.valid_warsh_cer != null ? `Warsh letters ${fmtPct(e.valid_warsh_cer)}` : e && e.valid_skeleton != null ? `skeleton ${fmtPct(e.valid_skeleton)}` : (e ? '' : noEval) },
{ label: 'Loss per letter', value: smoothLoss != null ? smoothLoss.toFixed(3) : '–', sub: t ? `latest ${t.loss.toFixed(3)} · lr ${fmtSci(t.lr)}` : 'first value after ' + (st.train?.log_every_steps || 100) + ' steps' },
{ label: 'Speed', value: speed != null ? fmtInt(speed) + '<small> h/h</small>' : '–', sub: stepsPerSec ? `${stepsPerSec.toFixed(2)} steps/s` : 'hours of audio per hour' },
{ label: 'Audio heard', value: t?.hours_seen != null ? fmtH(t.hours_seen) + '<small> h</small>' : '–',
sub: st.train_hours ? `${(((t?.hours_seen) || 0) / st.train_hours).toFixed(2)} × the ${fmtH(st.train_hours)} h dataset` : '' },
gpuKpi(g),
];
el.innerHTML = cards.map(c => `<div class="kpi"><div class="label"><span>${c.label}</span>${c.d || ''}</div><div class="value">${c.value}</div><div class="sub">${c.sub || ''}</div>${c.extra || ''}</div>`).join('');
}
function gpuKpi(g) {
if (!g || g.util == null) return { label: 'GPU', value: '–', sub: S.sys.available ? 'waiting for nvidia-smi' : 'nvidia-smi not found' };
const memFrac = g.mem_total ? g.mem / g.mem_total : 0;
const bits = [`${(g.mem / 1024).toFixed(1)} / ${(g.mem_total / 1024).toFixed(1)} GB`];
if (g.temp != null) bits.push(Math.round(g.temp) + '°C');
if (g.power != null) bits.push(Math.round(g.power) + ' W');
return {
label: 'GPU now', value: Math.round(g.util) + '<small>% busy</small>', sub: bits.join(' · '),
extra: `<div class="meter" title="GPU memory"><div style="width:${memFrac * 100}%;background:${memFrac > .92 ? css['--bad'] : css['--s2']}"></div></div>`,
};
}
function renderCharts() {
if (!charts.length) return;
const runs = S.selected.filter(n => S.data[n]), multi = runs.length > 1, xget = XMODES[S.x].get;
$('#resetZoom').hidden = !S.zoom;
for (const chart of charts) {
const cfg = chart.cfg, series = [], refs = [];
runs.forEach(name => {
const d = S.data[name];
cfg.metrics.forEach((m, mi) => {
const xs = [], ys = [];
for (const r of d[cfg.rows] || []) { const x = xget(r), y = r[m.key]; if (x != null && y != null) { xs.push(x); ys.push(y); } }
if (!xs.length) return;
const s = { key: m.key, label: m.label, run: name, xs, ys, color: multi ? runColor(name) : metricColor(mi),
dash: multi ? DASHES[mi % DASHES.length] : [], hidden: !!S.hidden[cfg.id]?.[m.key] };
if (cfg.smooth && S.smooth > 0 && ys.length > 1) { s.raw = ys; s.ys = ema(ys, S.smooth); }
series.push(s);
});
if (cfg.refs && name === S.primary) refs.push(...cfg.refs(d));
});
const d = S.data[S.primary], every = d?.status?.train;
chart.emptyText = cfg.rows === 'evals' ? (every ? `Appears after the first validation (step ${fmtInt(every.eval_every_steps)})` : 'Appears after the first validation')
: (cfg.metrics.some(m => d?.train?.length && !(m.key in d.train[0])) ? 'Not logged by this run (it was started by an older train.py)'
: `Appears after the first ${every?.log_every_steps || 100} steps`);
chart.pending = [series, refs];
}
let lo = Infinity, hi = -Infinity;
for (const c of charts) for (const s of c.pending[0]) if (!s.hidden && s.xs.length) { lo = Math.min(lo, s.xs[0]); hi = Math.max(hi, s.xs.at(-1)); }
S.xExtent = isFinite(lo) ? [lo, hi] : null;
for (const c of charts) c.setData(...c.pending);
}
const GPU_CHART = {
id: 'gpu', title: 'GPU and system, live', hint: 'last hour, sampled every 2 s', xTime: true, xFmt: fmtClock, yRange: [0, 100], alwaysLegend: true,
fmt: v => Math.round(v) + '%', tick: v => v + '%',
tipHead: ref => fmtClock(ref.s.xs[ref.i]) + ':' + String(new Date(ref.s.xs[ref.i] * 1000).getSeconds()).padStart(2, '0'),
tipRows: ref => {
const g = S.sys.samples[nearest(S.sys.samples.map(s => s.t), ref.s.xs[ref.i])]; if (!g) return '';
const row = (c, name, v) => `<div class="r"><i style="background:${c}"></i>${name}<b>${v}</b></div>`;
let out = '';
if (g.util != null) out += row(css['--s1'], 'GPU busy', Math.round(g.util) + '%');
if (g.mem != null) out += row(css['--s2'], 'GPU memory', `${(g.mem / 1024).toFixed(2)} / ${(g.mem_total / 1024).toFixed(1)} GB`);
if (g.cpu != null) out += row(css['--s3'], 'CPU', Math.round(g.cpu) + '%');
if (g.ram != null) out += row(css['--s4'], 'RAM', `${g.ram.toFixed(1)}${S.sys.ram_total ? ' / ' + S.sys.ram_total : ''} GB`);
if (g.temp != null) out += `<div class="r"><i></i>temperature<b>${Math.round(g.temp)}°C</b></div>`;
if (g.power != null) out += `<div class="r"><i></i>power<b>${Math.round(g.power)} W</b></div>`;
return out;
},
};
function renderGpu() {
if (!gpuChart) return;
const smp = S.sys.samples, xs = smp.map(s => s.t), hidden = S.hidden.gpu || {};
const mk = (key, label, ci, get) => {
const sx = [], sy = [];
smp.forEach((s, i) => { const v = get(s); if (v != null) { sx.push(xs[i]); sy.push(v); } });
return sx.length ? { key, label, run: '', xs: sx, ys: sy, color: css[PALETTE[ci]], hidden: !!hidden[key], width: 1.8, fill: key === 'util' } : null;
};
const series = [
mk('util', 'GPU busy', 0, s => s.util),
mk('mem', 'GPU memory', 1, s => s.mem != null && s.mem_total ? s.mem / s.mem_total * 100 : null),
mk('cpu', 'CPU', 2, s => s.cpu),
mk('ram', 'RAM', 3, s => s.ram != null && S.sys.ram_total ? s.ram / S.sys.ram_total * 100 : null),
].filter(Boolean);
gpuChart.cfg.title = 'GPU and system, live' + (S.sys.name ? ' · ' + S.sys.name : '');
$('#gpuCard h3').textContent = gpuChart.cfg.title;
gpuChart.emptyText = S.sys.available ? 'Waiting for the first samples…' : 'nvidia-smi was not found (install the NVIDIA driver) · pip install psutil for CPU and RAM';
if (!gpuChart.drawnOnce || S.online) gpuChart.setData(series, []);
gpuChart.drawnOnce = true;
}
/* ---------- transcripts ---------- */
function align(ref, hyp) {
const n = ref.length, m = hyp.length, D = Array.from({ length: n + 1 }, () => new Uint16Array(m + 1));
for (let i = 0; i <= n; i++) D[i][0] = i;
for (let j = 0; j <= m; j++) D[0][j] = j;
for (let i = 1; i <= n; i++) for (let j = 1; j <= m; j++)
D[i][j] = Math.min(D[i - 1][j] + 1, D[i][j - 1] + 1, D[i - 1][j - 1] + (ref[i - 1] === hyp[j - 1] ? 0 : 1));
const ops = []; let i = n, j = m;
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && D[i][j] === D[i - 1][j - 1] + (ref[i - 1] === hyp[j - 1] ? 0 : 1)) { ops.push([ref[i - 1] === hyp[j - 1] ? 'ok' : 'bad', hyp[j - 1]]); i--; j--; }
else if (j > 0 && D[i][j] === D[i][j - 1] + 1) { ops.push(['bad', hyp[j - 1]]); j--; }
else { ops.push(['del', ref[i - 1]]); i--; }
}
return ops.reverse();
}
function letterError(ref, hyp) {
const a = ref.replace(/ /g, ''), b = hyp.replace(/ /g, '');
if (!a.length) return 0;
const ops = align(a, b);
return ops.filter(o => o[0] !== 'ok').length / a.length;
}
function diffHtml(ref, hyp) {
if (!hyp) return '<span class="blank">(heard nothing yet)</span>';
let out = '', cur = null, buf = '';
const flush = () => { if (buf) out += cur === 'ok' ? esc(buf) : `<span class="${cur}">${esc(buf)}</span>`; buf = ''; };
for (const [op, ch] of align(ref, hyp)) {
const kind = op === 'ok' || ch === ' ' && op !== 'del' ? 'ok' : op;
if (kind !== cur) { flush(); cur = kind; }
buf += ch;
}
flush();
return out;
}
function stopPlay() { if (S.playTimer) { clearInterval(S.playTimer); S.playTimer = null; } }
async function loadSamples(force) {
// The ayahs on the transcription panel: drawn by the server with this run's seed, refetched
// after each validation (the run's version changes) or when "Show other ayahs" changes the seed.
const name = S.primary, d = S.data[name];
if (!d) return;
const seed = S.seeds[name] || 0, want = name + '|' + d.version + '|' + seed;
if (!force && S.samplesFor === want) return;
try {
S.samplesData = await getJSON('/api/run/' + encodeURIComponent(name) + '/samples?seed=' + seed);
S.samplesFor = want;
renderSamples(true);
} catch { }
}
function renderSamples(force) {
const host = $('#samples'); if (!host) return;
const d = S.data[S.primary];
const sm = S.samplesFor.startsWith(S.primary + '|') ? S.samplesData : null;
if (!sm || !sm.evals?.length) {
const every = d?.status?.train?.eval_every_steps;
host.innerHTML = `<div class="card"><div class="empty" style="position:static;padding:30px">Transcriptions appear after the first validation${every ? ' (step ' + fmtInt(every) + ')' : ''}${d && !sm && d.train.length ? '. This run was started by an older train.py, which doesn\'t save them' : ''}.</div></div>`;
S.samplesKey = ''; return;
}
const last = sm.evals.length - 1, idx = S.sampleIdx == null ? last : Math.min(S.sampleIdx, last);
const key = S.samplesFor + '|' + idx;
if (key === S.samplesKey && !force) return;
S.samplesKey = key;
const ev = sm.evals[idx];
const cards = sm.clips.map((c, i) => {
const hyp = ev.hyps[i] || '', err = letterError(c.ref, hyp);
const color = err < 0.05 ? 'var(--good)' : err < 0.25 ? 'var(--warn)' : 'var(--bad)';
return `<div class="sample"><div class="top"><span class="sura">${esc(SURAHS[c.sura - 1] || '')}</span><span>${c.sura}:${c.aya}</span>
<span class="tag ${c.riwaya}">${c.riwaya === 'warsh' ? 'Warsh' : 'Hafs'}</span><span>${esc(prettyReciter(c.reciter))} · ${c.duration.toFixed(1)} s</span>
<span class="err" style="color:${color}">${fmtPct(err)}</span></div>
<div class="line-label">reference</div><div class="ar ref">${esc(c.ref)}</div>
<div class="line-label">heard</div><div class="ar">${diffHtml(c.ref, hyp)}</div></div>`;
}).join('');
const avg = sm.clips.reduce((a, c, i) => a + letterError(c.ref, ev.hyps[i] || ''), 0) / sm.clips.length;
const playing = !!S.playTimer;
host.innerHTML = `<div class="scrub">
<button class="icon-btn" id="play" title="${playing ? 'Pause' : 'Play through the evaluations'}">${playing
? '<svg viewBox="0 0 16 16"><rect x="3" y="2" width="3.5" height="12" rx="1" fill="currentColor"/><rect x="9.5" y="2" width="3.5" height="12" rx="1" fill="currentColor"/></svg>'
: '<svg viewBox="0 0 16 16"><path d="M4 2.5v11l9.5-5.5z" fill="currentColor"/></svg>'}</button>
<span class="at">step ${fmtInt(ev.step)} <small style="color:var(--muted);font-weight:400">· check ${idx + 1} of ${sm.evals.length}</small></span>
<input type="range" id="scrub" min="0" max="${last}" value="${idx}" ${last ? '' : 'disabled'}>
<span style="color:var(--muted)">these ${sm.clips.length} ayahs: <b style="color:var(--ink)">${fmtPct(avg)}</b> letter error</span>
<button class="pill-btn" id="reshuffle" ${sm.reshuffle ? '' : 'disabled'} title="${sm.reshuffle
? 'Draw other ayahs from the validation set'
: 'Pause and resume training once: train.py now saves every validation ayah, from its next validation on'}">Show other ayahs</button></div>
<div class="samples">${cards}</div>`;
$('#reshuffle').onclick = () => {
S.seeds[S.primary] = (S.seeds[S.primary] || 0) + 1;
store.set('seeds', S.seeds);
stopPlay(); S.sampleIdx = null;
loadSamples(true);
};
$('#scrub').oninput = e => { stopPlay(); const v = +e.target.value; S.sampleIdx = v >= last ? null : v; renderSamples(); };
$('#play').onclick = () => {
if (S.playTimer) { stopPlay(); renderSamples(true); return; }
S.sampleIdx = S.sampleIdx == null || S.sampleIdx >= last ? 0 : S.sampleIdx;
S.playTimer = setInterval(() => {
const n = (S.data[S.primary]?.samples?.evals?.length || 1) - 1;
if (S.sampleIdx == null || S.sampleIdx >= n) { stopPlay(); S.sampleIdx = null; renderSamples(true); return; }
S.sampleIdx++; renderSamples(true);
}, 900);
renderSamples(true);
};
}
/* ---------- data and model ---------- */
function renderData() {
const d = S.data[S.primary], st = d?.status, rh = $('#reciters'), mc = $('#modelCard');
if (!st) { rh.innerHTML = mc.innerHTML = '<div class="empty" style="position:static;padding:24px">Shown once train.py starts (status.json)</div>'; $('#dataLead').textContent = ''; return; }
const rows = Object.entries(st.hours_by_reciter || {}).map(([k, h]) => {
const cut = k.indexOf('/'); return { riwaya: k.slice(0, cut), reciter: k.slice(cut + 1), h };
}).sort((a, b) => b.h - a.h);
const max = Math.max(1e-9, ...rows.map(r => r.h));
const sum = rw => rows.filter(r => r.riwaya === rw).reduce((a, r) => a + r.h, 0);
$('#dataLead').textContent = `${fmtInt(st.train_clips)} training clips (${fmtH(st.train_hours)} h) from ${rows.length} reciters; ${fmtInt(st.valid_clips)} validation clips (${(st.valid_hours ?? 0).toFixed(1)} h).`;
rh.innerHTML = `<div class="chart-head"><div><h3>Training hours per reciter</h3></div></div>
<div class="totals"><div><b>${fmtH(sum('hafs'))} h</b>Hafs</div><div><b style="color:var(--s4)">${fmtH(sum('warsh'))} h</b>Warsh (sampled ×${st.train?.warsh_weight ?? '?'})</div><div><b>${rows.length}</b>reciters</div></div>
<div class="bars">${rows.map(r => `<div class="barrow"><span class="n" title="${esc(r.reciter)}">${esc(prettyReciter(r.reciter))}</span>
<span class="b"><div style="width:${r.h / max * 100}%;background:${r.riwaya === 'warsh' ? 'var(--s4)' : 'var(--s2)'}"></div></span><span class="h">${r.h.toFixed(1)} h</span></div>`).join('')}</div>`;
const m = st.model || {}, t = st.train || {};
const kv = [
['Parameters', (st.params_m ?? 0).toFixed(1) + 'M (~' + Math.round(st.params_m ?? 0) + ' MB int8)'],
['Conformer', `${m.num_layers} layers × ${m.d_model} wide, ${m.num_heads} heads`],
['Conv kernel / dropout', `${m.conv_kernel} / ${m.dropout}`],
['Epochs', `${t.epochs} (${fmtInt(st.steps_per_epoch || 0)} steps each)`],
['Batch', `${t.max_batch_seconds} s of audio`],
['Learning rate', `${fmtSci(t.lr || 0)}, warmup ${fmtInt(t.warmup_steps || 0)} steps`],
['Precision', t.precision],
['Logged / validated every', `${t.log_every_steps} / ${fmtInt(t.eval_every_steps || 0)} steps`],
['Device', st.device],
];
mc.innerHTML = `<div class="chart-head"><div><h3>Model and settings</h3><span class="hint">config/${esc(st.name)}.yaml</span></div></div>
<dl class="kv">${kv.map(([k, v]) => `<dt>${k}</dt><dd>${esc(v ?? '–')}</dd>`).join('')}</dl>`;
}
/* ---------- theme ---------- */
const THEME_ICONS = {
system: '<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="5.5" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M8 2.5a5.5 5.5 0 0 1 0 11z" fill="currentColor"/></svg>',
light: '<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="3" fill="currentColor"/><g stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M8 1v1.5M8 13.5V15M1 8h1.5M13.5 8H15M3 3l1 1M12 12l1 1M3 13l1-1M12 4l1-1"/></g></svg>',
dark: '<svg viewBox="0 0 16 16"><path d="M13.5 10A6 6 0 0 1 6 2.5a6 6 0 1 0 7.5 7.5z" fill="currentColor"/></svg>',
};
function applyTheme() {
if (S.theme === 'system') delete document.documentElement.dataset.theme; else document.documentElement.dataset.theme = S.theme;
$('#theme').innerHTML = THEME_ICONS[S.theme];
readCss(); $('#runs').dataset.html = ''; S.samplesKey = '';
if (built) render(true);
}
$('#theme').onclick = () => { S.theme = { system: 'light', light: 'dark', dark: 'system' }[S.theme]; store.set('theme', S.theme); applyTheme(); };
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', applyTheme);
/* ---------- start ---------- */
applyTheme();
setInterval(() => { if (S.runs.length && built === 'runs') { renderStatus(); renderHeader(); } }, 1000);
loop();
</script>
</body>
</html>