Download code/dashboard.html from Abdou/quran-asr-tiny: direct link, hf CLI and curl.
- Browser
- Download file 65.6 kB
-
https://huggingface.co/Abdou/quran-asr-tiny/resolve/main/code/dashboard.html
- Command line
-
hf download hf://Abdou/quran-asr-tiny/code/dashboard.html
-
curl -L -o dashboard.html https://huggingface.co/Abdou/quran-asr-tiny/resolve/main/code/dashboard.html
65.6 kB
| <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 ; } | |
| 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> | |
| ; | |
| 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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> | |