control.html (8505 bytes)
1 <!doctype html> 2 <html lang="en"> 3 <head> 4 <meta charset="utf-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1"> 6 <title>livecap control</title> 7 <style> 8 :root { 9 --bg: #0f1115; --panel: #171a21; --line: #262b36; 10 --fg: #e8ecf3; --dim: #98a2b3; --accent: #7dd3fc; --ok: #4ade80; --warn: #fbbf24; 11 } 12 * { box-sizing: border-box; } 13 body { 14 margin: 0; padding: 24px; background: var(--bg); color: var(--fg); 15 font: 15px/1.5 "Inter", "Segoe UI", system-ui, sans-serif; 16 } 17 .wrap { max-width: 760px; margin: 0 auto; display: grid; gap: 18px; } 18 h1 { font-size: 18px; margin: 0; font-weight: 650; letter-spacing: .01em; } 19 h1 span { color: var(--dim); font-weight: 400; font-size: 13px; margin-left: 8px; } 20 .card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 16px; } 21 .card h2 { 22 font-size: 11px; text-transform: uppercase; letter-spacing: .09em; 23 color: var(--dim); margin: 0 0 12px; font-weight: 600; 24 } 25 .row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } 26 button { 27 font: inherit; font-weight: 550; color: var(--fg); cursor: pointer; 28 background: #1e2430; border: 1px solid var(--line); border-radius: 7px; 29 padding: 8px 14px; transition: background .12s, border-color .12s; 30 } 31 button:hover:not(:disabled) { background: #273040; border-color: #3a4256; } 32 button.on { background: var(--accent); border-color: var(--accent); color: #06202c; } 33 button:disabled { opacity: .45; cursor: not-allowed; } 34 button .k { 35 font-size: 11px; color: var(--dim); margin-left: 7px; 36 font-variant-numeric: tabular-nums; 37 } 38 button.on .k { color: #06202c; opacity: .65; } 39 select { 40 font: inherit; color: var(--fg); background: #1e2430; 41 border: 1px solid var(--line); border-radius: 7px; padding: 8px 12px; 42 } 43 .status { display: flex; gap: 18px; flex-wrap: wrap; align-items: baseline; } 44 .status b { font-weight: 600; } 45 .dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; margin-right: 7px; } 46 .dot.up { background: var(--ok); box-shadow: 0 0 8px var(--ok); } 47 .dot.down { background: #ef4444; box-shadow: 0 0 8px #ef4444; } 48 .dot.busy { background: var(--warn); box-shadow: 0 0 8px var(--warn); } 49 #feed { display: grid; gap: 7px; min-height: 76px; } 50 .cap { padding: 8px 11px; background: #12161d; border-left: 2px solid var(--line); border-radius: 4px; } 51 .cap.partial { color: var(--dim); font-style: italic; border-left-color: var(--warn); } 52 .cap .tr { display: block; color: var(--accent); font-size: 13px; margin-top: 3px; } 53 .empty { color: var(--dim); font-size: 13px; } 54 .hint { color: var(--dim); font-size: 12.5px; margin-top: 10px; } 55 code { background: #12161d; padding: 2px 6px; border-radius: 4px; font-size: 12.5px; } 56 </style> 57 </head> 58 <body> 59 <div class="wrap"> 60 <h1>livecap<span id="conn">connecting…</span></h1> 61 62 <div class="card"> 63 <h2>Language</h2> 64 <div class="row" id="langs"></div> 65 <div class="hint">Press <code>1</code>–<code>9</code> to switch instantly. Takes 66 effect on the next sentence; no restart, no model reload.</div> 67 </div> 68 69 <div class="card"> 70 <h2>Model</h2> 71 <div class="row"> 72 <select id="model"></select> 73 <span id="loading" class="empty"></span> 74 </div> 75 <div class="hint">Switching reloads the model and pauses captions for a few 76 seconds. Bigger is more accurate but slower.</div> 77 </div> 78 79 <div class="card"> 80 <h2>Options</h2> 81 <div class="row"> 82 <button id="translate">English translation</button> 83 <button id="partials">Live partial text</button> 84 <button id="test">Send test caption</button> 85 <button id="clear">Clear captions</button> 86 </div> 87 </div> 88 89 <div class="card"> 90 <h2>Live output</h2> 91 <div id="feed"><div class="empty">Waiting for speech…</div></div> 92 </div> 93 </div> 94 95 <script> 96 (function () { 97 const q = new URLSearchParams(location.search); 98 const WS_PORT = parseInt(q.get("ws") || "8765", 10); 99 100 // Whisper has no regional variants: Argentine Spanish is served by "es". 101 const NAMES = { 102 fi: "Finnish", ru: "Russian", ja: "Japanese", es: "Spanish", 103 pt: "Portuguese", en: "English", sv: "Swedish", de: "German", 104 fr: "French", it: "Italian", pl: "Polish", uk: "Ukrainian", 105 et: "Estonian", no: "Norwegian", da: "Danish", nl: "Dutch", 106 zh: "Chinese", ko: "Korean", auto: "Auto-detect" 107 }; 108 109 let ws = null, state = null; 110 const feed = []; 111 112 const el = (id) => document.getElementById(id); 113 const send = (cmd, value) => ws && ws.readyState === 1 && 114 ws.send(JSON.stringify({ cmd: cmd, value: value })); 115 116 function renderControls() { 117 if (!state) return; 118 119 const langs = el("langs"); 120 langs.innerHTML = ""; 121 state.langs.forEach((code, i) => { 122 const b = document.createElement("button"); 123 b.className = code === state.lang ? "on" : ""; 124 b.innerHTML = ""; 125 b.appendChild(document.createTextNode(NAMES[code] || code)); 126 if (i < 9) { 127 const k = document.createElement("span"); 128 k.className = "k"; 129 k.textContent = i + 1; 130 b.appendChild(k); 131 } 132 b.onclick = () => send("set_lang", code); 133 langs.appendChild(b); 134 }); 135 136 const sel = el("model"); 137 if (sel.dataset.key !== state.models.join(",")) { 138 sel.dataset.key = state.models.join(","); 139 sel.innerHTML = ""; 140 state.models.forEach((m) => { 141 const o = document.createElement("option"); 142 o.value = m; o.textContent = m; 143 sel.appendChild(o); 144 }); 145 } 146 sel.value = state.model; 147 sel.disabled = state.loading; 148 el("loading").textContent = state.loading ? "loading model…" : ""; 149 150 el("translate").className = state.translate ? "on" : ""; 151 el("partials").className = state.partials ? "on" : ""; 152 153 el("conn").innerHTML = ""; 154 const dot = document.createElement("span"); 155 dot.className = "dot " + (state.loading ? "busy" : "up"); 156 el("conn").appendChild(dot); 157 let label = NAMES[state.lang] || state.lang; 158 if (state.lang === "auto") { 159 label += state.detected 160 ? " → " + (NAMES[state.detected] || state.detected) 161 : " (listening…)"; 162 } 163 el("conn").appendChild(document.createTextNode(label + " · " + state.model)); 164 } 165 166 function renderFeed() { 167 const box = el("feed"); 168 box.innerHTML = ""; 169 if (!feed.length) { 170 box.innerHTML = '<div class="empty">Waiting for speech…</div>'; 171 return; 172 } 173 feed.slice(-6).forEach((m) => { 174 const d = document.createElement("div"); 175 d.className = "cap" + (m.type === "partial" ? " partial" : ""); 176 d.textContent = m.text; 177 if (m.tr) { 178 const s = document.createElement("span"); 179 s.className = "tr"; 180 s.textContent = m.tr; 181 d.appendChild(s); 182 } 183 box.appendChild(d); 184 }); 185 box.scrollTop = box.scrollHeight; 186 } 187 188 function connect() { 189 ws = new WebSocket("ws://127.0.0.1:" + WS_PORT); 190 191 ws.onopen = () => send("status"); 192 193 ws.onclose = () => { 194 el("conn").innerHTML = '<span class="dot down"></span>disconnected — is run.ps1 still going?'; 195 setTimeout(connect, 1500); 196 }; 197 ws.onerror = () => ws.close(); 198 199 ws.onmessage = (ev) => { 200 let m; 201 try { m = JSON.parse(ev.data); } catch (e) { return; } 202 203 if (m.type === "status") { 204 state = m; 205 renderControls(); 206 } else if (m.type === "clear") { 207 feed.length = 0; 208 renderFeed(); 209 } else if (m.type === "partial") { 210 if (feed.length && feed[feed.length - 1].type === "partial") feed.pop(); 211 feed.push(m); 212 renderFeed(); 213 } else if (m.type === "final") { 214 if (feed.length && feed[feed.length - 1].type === "partial") feed.pop(); 215 feed.push(m); 216 if (feed.length > 40) feed.shift(); 217 renderFeed(); 218 } 219 }; 220 } 221 222 el("model").onchange = (e) => send("set_model", e.target.value); 223 el("translate").onclick = () => state && send("set_translate", !state.translate); 224 el("partials").onclick = () => state && send("set_partials", !state.partials); 225 el("clear").onclick = () => send("clear"); 226 el("test").onclick = () => send("test"); 227 228 document.addEventListener("keydown", (e) => { 229 if (e.target.tagName === "SELECT") return; 230 const n = parseInt(e.key, 10); 231 if (state && n >= 1 && n <= 9 && state.langs[n - 1]) { 232 send("set_lang", state.langs[n - 1]); 233 } 234 }); 235 236 connect(); 237 })(); 238 </script> 239 </body> 240 </html>