Files
watch_sceen_ai/wsai/dashboard.py
EJClaw b9c929a73f feat(dashboard): browser STT recognition test on the GPU
The status page was view-only, so there was no way to actually verify Korean
recognition end-to-end. Add a live test: record from the mic (localhost/https)
or upload an audio file (works over LAN http, where browsers block getUserMedia),
POST it to a new /api/stt endpoint that ffmpeg-normalises the blob to 16 kHz
mono and runs the real GPU faster-whisper, then shows the recognised text +
latency + device. Results also land in the live turn feed.

The dashboard now optionally holds a WhisperSTT and drives it from a private
asyncio loop thread. New `python -m wsai --stt-test` serves the page with STT
enabled and pre-warms the GPU worker so the first recognition is instant.
WhisperSTT.resolved_device is exposed for the UI.

Verified: wav and browser-style webm/opus uploads both return the correct
Korean text on device=cuda in ~240-280ms. 12 tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-18 21:49:03 +09:00

531 lines
24 KiB
Python

"""Live status website for the voice loop.
Serves a single self-contained page plus a Server-Sent-Events stream so you can
open a browser and watch, step by step: is it listening, what it heard, what it
thought/answered, how long each stage took, and whether anything errored.
Pure stdlib (``http.server``). Runs in a background thread so it never blocks
the asyncio pipeline.
Endpoints:
GET / -> the dashboard HTML
GET /api/state -> JSON snapshot (initial load / fallback polling)
GET /events -> text/event-stream live push
"""
from __future__ import annotations
import json
import logging
import queue
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from .monitor import Monitor
log = logging.getLogger("wsai.dashboard")
def _make_handler(dash: "Dashboard"):
monitor = dash.monitor
class Handler(BaseHTTPRequestHandler):
# Quiet: don't spam the console with one line per request.
def log_message(self, *args) -> None: # noqa: D401
return
def _send(self, code: int, body: bytes, ctype: str) -> None:
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def do_GET(self) -> None: # noqa: N802
path = self.path.split("?", 1)[0]
if path == "/" or path == "/index.html":
self._send(200, PAGE.encode("utf-8"), "text/html; charset=utf-8")
elif path == "/api/state":
body = json.dumps(monitor.snapshot(), ensure_ascii=False).encode("utf-8")
self._send(200, body, "application/json; charset=utf-8")
elif path == "/events":
self._stream_events()
else:
self._send(404, b"not found", "text/plain; charset=utf-8")
def do_POST(self) -> None: # noqa: N802
path = self.path.split("?", 1)[0]
if path == "/api/stt":
self._handle_stt()
else:
self._send(404, b"not found", "text/plain; charset=utf-8")
def _handle_stt(self) -> None:
"""Accept an uploaded audio blob (mic recording or file), run it
through the real GPU STT, and return the recognised text."""
if dash.stt is None:
self._send(503, json.dumps({"ok": False, "error": "STT not enabled"}).encode(),
"application/json; charset=utf-8")
return
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError:
length = 0
if length <= 0:
self._send(400, json.dumps({"ok": False, "error": "empty upload"}).encode(),
"application/json; charset=utf-8")
return
raw = self.rfile.read(length)
try:
result = dash.transcribe_upload(raw)
body = json.dumps({"ok": True, **result}, ensure_ascii=False).encode("utf-8")
self._send(200, body, "application/json; charset=utf-8")
except Exception as exc: # noqa: BLE001 — surface the reason to the page
log.exception("STT upload failed")
body = json.dumps({"ok": False, "error": f"{type(exc).__name__}: {exc}"},
ensure_ascii=False).encode("utf-8")
self._send(500, body, "application/json; charset=utf-8")
def _stream_events(self) -> None:
self.send_response(200)
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.send_header("Connection", "keep-alive")
self.end_headers()
q = monitor.subscribe()
try:
# Prime the client with a full snapshot so it renders instantly.
first = json.dumps(
{"type": "snapshot", "snapshot": monitor.snapshot()},
ensure_ascii=False,
)
self.wfile.write(f"data: {first}\n\n".encode("utf-8"))
self.wfile.flush()
while True:
try:
data = q.get(timeout=15)
except queue.Empty:
# Heartbeat keeps proxies / the browser from timing out.
self.wfile.write(b": ping\n\n")
self.wfile.flush()
continue
self.wfile.write(f"data: {data}\n\n".encode("utf-8"))
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError):
pass
finally:
monitor.unsubscribe(q)
return Handler
class Dashboard:
"""Owns the HTTP server thread.
Optionally holds a real STT backend so the page can offer a live
recognition test (upload/record audio -> GPU whisper -> text). The STT
backend is async, so the dashboard runs its own asyncio loop in a
background thread and bridges the synchronous HTTP handlers onto it.
"""
def __init__(self, monitor: Monitor, host: str = "0.0.0.0", port: int = 8787,
stt=None) -> None:
self.monitor = monitor
self.host = host
self.port = port
self.stt = stt
self._server: ThreadingHTTPServer | None = None
self._thread: threading.Thread | None = None
self._loop = None
self._loop_thread: threading.Thread | None = None
def start(self) -> None:
if self.stt is not None:
self._start_loop()
handler = _make_handler(self)
self._server = ThreadingHTTPServer((self.host, self.port), handler)
self._server.daemon_threads = True
self._thread = threading.Thread(
target=self._server.serve_forever, name="wsai-dashboard", daemon=True
)
self._thread.start()
log.info("dashboard on http://%s:%d", self.host, self.port)
def stop(self) -> None:
if self._server is not None:
self._server.shutdown()
self._server.server_close()
self._server = None
if self._loop is not None:
self._loop.call_soon_threadsafe(self._loop.stop)
self._loop = None
# -- async bridge (STT test) ----------------------------------------- #
def _start_loop(self) -> None:
import asyncio
self._loop = asyncio.new_event_loop()
self._loop_thread = threading.Thread(
target=self._loop.run_forever, name="wsai-dashboard-loop", daemon=True
)
self._loop_thread.start()
def _submit(self, coro, timeout: float = 120.0):
import asyncio
fut = asyncio.run_coroutine_threadsafe(coro, self._loop)
return fut.result(timeout=timeout)
def warm(self) -> None:
"""Pre-start the STT worker (loads + warms the GPU) so the first web
recognition is instant instead of paying model-load + CUDA autotune."""
if self.stt is not None:
self._submit(self.stt._ensure())
def transcribe_upload(self, audio_bytes: bytes) -> dict:
"""ffmpeg-normalise an uploaded blob to 16 kHz mono wav, transcribe it
on the GPU, and record the result as a monitor turn so it also shows in
the live feed. Returns {text, ms, device}."""
import os
import subprocess
import tempfile
import time
import uuid
updir = os.path.expanduser("~/.cache/wsai/uploads")
os.makedirs(updir, exist_ok=True)
stem = os.path.join(updir, uuid.uuid4().hex)
src, wav = stem + ".bin", stem + ".wav"
with open(src, "wb") as f:
f.write(audio_bytes)
turn = self.monitor.turn(source="web")
t0 = time.monotonic()
try:
# Decode whatever the browser sent (webm/opus, ogg, mp4, wav) to the
# 16 kHz mono wav faster-whisper expects.
subprocess.run(
["ffmpeg", "-y", "-i", src, "-ar", "16000", "-ac", "1", wav],
check=True, capture_output=True,
)
text = self._submit(self.stt.transcribe(wav))
ms = int((time.monotonic() - t0) * 1000)
turn.heard(text or "(빈 결과)")
step = turn.step("STT(GPU)")
step.ok, step.ms = True, float(ms)
turn._steps.append(step)
turn.finish()
return {"text": text, "ms": ms,
"device": getattr(self.stt, "resolved_device", None) or "?"}
except subprocess.CalledProcessError as exc:
turn.finish(error="ffmpeg decode failed")
err = exc.stderr.decode("utf-8", "replace")[-300:] if exc.stderr else str(exc)
raise RuntimeError(f"ffmpeg: {err}") from exc
except Exception as exc:
turn.finish(error=str(exc))
raise
finally:
for p in (src, wav):
try:
os.remove(p)
except OSError:
pass
# --------------------------------------------------------------------------- #
# The page. One file, no external assets, so it works offline / behind a LAN.
# --------------------------------------------------------------------------- #
PAGE = r"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>watch_sceen_ai · 실시간 상태</title>
<style>
:root{
--bg:#0b0f14; --panel:#131a22; --panel2:#0f151c; --line:#223040;
--fg:#e6edf3; --muted:#8aa0b2; --accent:#3fb6ff; --ok:#37d67a;
--err:#ff5c6c; --warn:#ffc857; --heard:#7aa2ff; --reply:#b28bff;
}
*{box-sizing:border-box}
body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Apple SD Gothic Neo","Malgun Gothic",sans-serif;
background:var(--bg);color:var(--fg);line-height:1.5}
header{position:sticky;top:0;z-index:5;background:linear-gradient(180deg,#0d141c,#0b0f14);
border-bottom:1px solid var(--line);padding:14px 20px;display:flex;flex-wrap:wrap;gap:16px;align-items:center}
h1{font-size:16px;margin:0;font-weight:650;letter-spacing:.2px}
.sub{color:var(--muted);font-size:12px}
.pill{display:inline-flex;align-items:center;gap:7px;padding:5px 11px;border-radius:999px;
background:var(--panel);border:1px solid var(--line);font-size:12.5px;color:var(--muted)}
.dot{width:9px;height:9px;border-radius:50%;background:#556}
.dot.live{background:var(--ok);box-shadow:0 0 0 0 rgba(55,214,122,.6);animation:pulse 1.6s infinite}
.dot.off{background:#556}
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(55,214,122,.55)}70%{box-shadow:0 0 0 9px rgba(55,214,122,0)}100%{box-shadow:0 0 0 0 rgba(55,214,122,0)}}
.stats{display:flex;gap:10px;flex-wrap:wrap;margin-left:auto}
.stat{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:6px 12px;min-width:78px}
.stat b{display:block;font-size:16px}
.stat span{color:var(--muted);font-size:11px}
main{max-width:1000px;margin:0 auto;padding:18px 20px 60px}
.comp{display:flex;gap:8px;flex-wrap:wrap;margin:2px 0 18px}
.comp .pill{font-size:11.5px}
.turn{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:14px 16px;margin:12px 0;
animation:rise .25s ease}
@keyframes rise{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
.turn.active{border-color:#2b5d86;box-shadow:0 0 0 1px #14324a inset}
.turn.error{border-color:#5c2530}
.trow{display:flex;align-items:baseline;gap:10px;margin-bottom:8px}
.badge{font-size:11px;padding:2px 9px;border-radius:999px;border:1px solid var(--line);color:var(--muted);white-space:nowrap}
.badge.ok{color:var(--ok);border-color:#1f5236}
.badge.error{color:var(--err);border-color:#5c2530}
.badge.active{color:var(--accent);border-color:#234a63}
.time{color:var(--muted);font-size:11.5px;margin-left:auto}
.line{display:flex;gap:9px;margin:5px 0;align-items:flex-start}
.tag{flex:0 0 42px;font-size:11px;color:var(--muted);padding-top:2px}
.heard{color:var(--heard);font-weight:550}
.reply{color:var(--reply);font-weight:550}
.steps{margin-top:10px;border-top:1px dashed var(--line);padding-top:10px;display:flex;flex-direction:column;gap:6px}
.step{display:grid;grid-template-columns:120px 1fr 66px;gap:10px;align-items:center;font-size:12.5px}
.step .sname{color:var(--muted)}
.step .sbar{height:8px;background:var(--panel2);border-radius:6px;overflow:hidden;border:1px solid var(--line)}
.step .sfill{height:100%;background:linear-gradient(90deg,#2b7bb0,#3fb6ff)}
.step.err .sfill{background:linear-gradient(90deg,#7a2531,#ff5c6c)}
.step .sms{text-align:right;color:var(--fg);font-variant-numeric:tabular-nums}
.step .serr{grid-column:1 / -1;color:var(--err);font-size:11.5px}
.total{margin-top:8px;font-size:12px;color:var(--muted)}
.total b{color:var(--fg)}
.empty{color:var(--muted);text-align:center;padding:50px 0;font-size:14px}
.events{margin-top:26px}
.events h2{font-size:13px;color:var(--muted);font-weight:600;margin:0 0 8px}
.ev{font-size:12px;color:var(--muted);padding:3px 0;border-bottom:1px solid #16202b;display:flex;gap:10px}
.ev.error{color:var(--err)}
.ev .et{flex:0 0 68px;color:#5f7488}
code{background:#0c1219;padding:1px 5px;border-radius:5px;border:1px solid var(--line)}
.demobar{background:#2a2210;border:1px solid #6b5417;color:var(--warn);border-radius:12px;
padding:11px 15px;margin:0 0 16px;font-size:13px;line-height:1.55}
.demobar b{color:#ffe08a}
.sttbox{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:14px 16px;margin:0 0 16px}
.sttbox h2{font-size:13px;margin:0 0 10px;font-weight:600;color:var(--fg)}
.sttrow{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
.btn{background:#173042;border:1px solid #234a63;color:var(--fg);border-radius:10px;padding:8px 14px;font-size:13px;cursor:pointer}
.btn:hover{background:#1d3d54}
.btn.rec{background:#4a1f27;border-color:#7a2531;color:#ffb3bb}
.sttstat{color:var(--muted);font-size:12.5px}
.sttres{margin-top:12px;font-size:15px;min-height:1px}
.sttres .txt{color:var(--heard);font-weight:600;line-height:1.5}
.sttres .meta{color:var(--muted);font-size:12px;margin-top:5px}
</style>
</head>
<body>
<header>
<div>
<h1>watch_sceen_ai · 실시간 상태</h1>
<div class="sub">STT → 두뇌 → TTS 음성 루프를 단계별로 관찰</div>
</div>
<div class="pill"><span id="dot" class="dot off"></span><span id="listen">연결 대기</span></div>
<div class="stats">
<div class="stat"><b id="s-turns">0</b><span>대화 수</span></div>
<div class="stat"><b id="s-errors">0</b><span>오류</span></div>
<div class="stat"><b id="s-up">0초</b><span>가동시간</span></div>
<div class="stat"><b id="s-conn">·</b><span>연결</span></div>
</div>
</header>
<main>
<section class="sttbox" id="sttbox" style="display:none">
<h2>🎤 음성 인식(STT) 테스트 · GPU</h2>
<div class="sttrow">
<button id="recbtn" class="btn">🎤 녹음 시작</button>
<label class="btn" for="fileinp">📁 오디오 파일 올리기</label>
<input id="fileinp" type="file" accept="audio/*" hidden>
<span id="ststat" class="sttstat">녹음하거나 오디오 파일을 올리면 GPU로 인식합니다.</span>
</div>
<div id="sttres" class="sttres"></div>
</section>
<div class="demobar" id="demobar" style="display:none"></div>
<div class="comp" id="comp"></div>
<div id="turns"></div>
<div id="empty" class="empty">아직 대화가 없습니다. 사용자가 말하면 여기에 단계별로 나타납니다.</div>
<div class="events">
<h2>이벤트 / 오류 로그</h2>
<div id="events"></div>
</div>
</main>
<script>
const $ = (id)=>document.getElementById(id);
const turns = new Map(); // id -> turn object
let statusData = null;
function fmtTime(wall){
const d = new Date(wall*1000);
return d.toLocaleTimeString('ko-KR',{hour12:false}) +
'.' + String(d.getMilliseconds()).padStart(3,'0');
}
function fmtUptime(s){
s = Math.floor(s||0);
const h=Math.floor(s/3600), m=Math.floor(s%3600/60), sec=s%60;
if(h) return h+'시간 '+m+'';
if(m) return m+''+sec+'';
return sec+'';
}
function esc(t){const d=document.createElement('div');d.textContent=t==null?'':t;return d.innerHTML;}
function renderStatus(s){
statusData = s;
$('s-turns').textContent = s.turns_total ?? 0;
$('s-errors').textContent = s.errors_total ?? 0;
$('s-up').textContent = fmtUptime(s.uptime_s);
const listening = s.listening;
$('dot').className = 'dot ' + (listening ? 'live' : 'off');
$('listen').textContent = listening ? '듣는 중' : (s.running ? '실행 중 (대기)' : '중지됨');
const comps = s.components || {};
// Demo banner: if the ears/brain/mouth are still mock, everything below is
// replayed sample data, not a real conversation. Say so loudly.
const sttReal = comps.stt && comps.stt!=='mock' && comps.stt!=='none';
$('sttbox').style.display = sttReal ? 'block' : 'none';
const mockParts = ['stt','brain','tts'].filter(k => comps[k]==='mock');
const bar = $('demobar');
if(mockParts.length){
bar.style.display='block';
bar.innerHTML = '⚠ <b>데모 모드</b> — 실제 음성/STT/두뇌/TTS가 아직 연결되지 않아, 아래 대화는 '
+ '실제로 들은 내용이 아니라 <b>목(mock) 예시 스크립트</b>입니다. '
+ '실제 엔진(faster-whisper·Claude·MeloTTS)을 붙이면 이 자리에 진짜 발화·지연·오류가 표시됩니다.';
} else {
bar.style.display='none';
}
const el = $('comp'); el.innerHTML = '';
const names = {source:'눈(소스)', vision:'시각', stt:'귀(STT)', brain:'두뇌', tts:'입(TTS)', text:'텍스트'};
for(const k of Object.keys(names)){
if(!(k in comps)) continue;
const v = comps[k];
const p = document.createElement('span');
p.className = 'pill';
p.innerHTML = '<span class="dot '+(v && v!=='none'?'live':'off')+'"></span>'+names[k]+': <b>&nbsp;'+esc(v||'off')+'</b>';
el.appendChild(p);
}
}
function setConn(ok){ $('s-conn').textContent = ok ? '' : ''; $('s-conn').style.color = ok ? 'var(--ok)':'var(--err)'; }
function maxMs(steps){ let m=1; for(const s of steps) m=Math.max(m, s.ms||0); return m; }
function turnEl(t){
const wrap = document.createElement('div');
wrap.className = 'turn ' + (t.status||'active');
wrap.id = 'turn-'+t.id;
const mx = maxMs(t.steps);
let steps = '';
for(const s of (t.steps||[])){
const pct = Math.max(3, Math.round((s.ms||0)/mx*100));
const err = s.ok===false;
steps += '<div class="step'+(err?' err':'')+'">'
+ '<span class="sname">'+esc(s.name)+'</span>'
+ '<span class="sbar"><span class="sfill" style="width:'+pct+'%"></span></span>'
+ '<span class="sms">'+ (s.ms!=null? s.ms.toFixed(0)+' ms':'') +'</span>'
+ (err && s.error ? '<span class="serr">⚠ '+esc(s.error)+'</span>':'')
+ '</div>';
}
const badge = t.status==='ok' ? '<span class="badge ok">정상</span>'
: t.status==='error' ? '<span class="badge error">오류</span>'
: '<span class="badge active">진행 중…</span>';
wrap.innerHTML =
'<div class="trow">'+badge
+'<span class="badge">#'+t.id+' · '+esc(t.source||'voice')+'</span>'
+'<span class="time">'+fmtTime(t.wall)+'</span></div>'
+'<div class="line"><span class="tag">들음</span><span class="heard">'+(t.heard?esc(t.heard):'<i style="color:var(--muted)">(수신 대기)</i>')+'</span></div>'
+'<div class="line"><span class="tag">답변</span><span class="reply">'+(t.reply?esc(t.reply):'<i style="color:var(--muted)">…생각 중</i>')+'</span></div>'
+(t.error?'<div class="line"><span class="tag">오류</span><span style="color:var(--err)">'+esc(t.error)+'</span></div>':'')
+'<div class="steps">'+steps+'</div>'
+'<div class="total">총 소요 <b>'+(t.total_ms?t.total_ms.toFixed(0)+' ms':'')+'</b></div>';
return wrap;
}
function upsertTurn(t){
turns.set(t.id, t);
$('empty').style.display = 'none';
const cont = $('turns');
const existing = $('turn-'+t.id);
const fresh = turnEl(t);
if(existing){ existing.replaceWith(fresh); }
else { cont.prepend(fresh); }
}
function addEvent(e){
const box = $('events');
const row = document.createElement('div');
row.className = 'ev ' + (e.level==='error'?'error':'');
row.innerHTML = '<span class="et">'+fmtTime(e.wall)+'</span><span>'+esc(e.message)+'</span>';
box.prepend(row);
while(box.childElementCount>60) box.removeChild(box.lastChild);
}
function applySnapshot(snap){
renderStatus(snap.status);
turns.clear(); $('turns').innerHTML='';
const list = (snap.turns||[]);
for(const t of list) upsertTurn(t);
if(list.length===0){ $('empty').style.display='block'; }
$('events').innerHTML='';
for(const e of (snap.events||[])) addEvent(e);
}
function connect(){
const es = new EventSource('/events');
es.onopen = ()=> setConn(true);
es.onerror = ()=> setConn(false);
es.onmessage = (m)=>{
let ev; try{ ev = JSON.parse(m.data); }catch(_){ return; }
if(ev.type==='snapshot') applySnapshot(ev.snapshot);
else if(ev.type==='status') renderStatus(ev.status);
else if(ev.type==='turn') upsertTurn(ev.turn);
else if(ev.type==='log') { addEvent(ev); if(statusData){ statusData.errors_total=(statusData.errors_total||0)+(ev.level==='error'?1:0); $('s-errors').textContent=statusData.errors_total; } }
};
}
// --- STT recognition test (upload / mic record -> GPU whisper) ----------- #
let mediaRec=null, chunks=[];
async function sendBlob(blob){
$('ststat').textContent='인식 중… (GPU)';
$('sttres').innerHTML='';
try{
const r=await fetch('/api/stt',{method:'POST',
headers:{'Content-Type':blob.type||'application/octet-stream'},body:blob});
const j=await r.json();
if(j.ok){
$('sttres').innerHTML='<div class="txt">'+esc(j.text||'(빈 결과)')+'</div>'
+'<div class="meta">인식 '+j.ms+' ms · '+esc(j.device)+'</div>';
$('ststat').textContent='완료. 다시 녹음하거나 파일을 올릴 수 있습니다.';
}else{
$('sttres').innerHTML='<div class="meta" style="color:var(--err)">오류: '+esc(j.error)+'</div>';
$('ststat').textContent='실패.';
}
}catch(e){ $('ststat').textContent='요청 실패: '+e; }
}
(function(){
const fi=$('fileinp'); if(fi) fi.onchange=()=>{ if(fi.files[0]) sendBlob(fi.files[0]); };
const rb=$('recbtn'); if(!rb) return;
rb.onclick=async()=>{
if(mediaRec && mediaRec.state==='recording'){ mediaRec.stop(); return; }
if(!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia){
$('ststat').textContent='이 주소(원격 http)에서는 브라우저 마이크가 막혀 있습니다. 파일 업로드를 사용하세요.';
return;
}
try{
const stream=await navigator.mediaDevices.getUserMedia({audio:true});
chunks=[]; mediaRec=new MediaRecorder(stream);
mediaRec.ondataavailable=(e)=>{ if(e.data.size) chunks.push(e.data); };
mediaRec.onstop=()=>{
stream.getTracks().forEach(t=>t.stop());
rb.textContent='🎤 녹음 시작'; rb.classList.remove('rec');
sendBlob(new Blob(chunks,{type:mediaRec.mimeType||'audio/webm'}));
};
mediaRec.start();
rb.textContent='⏹ 녹음 중지'; rb.classList.add('rec');
$('ststat').textContent='녹음 중… 말한 뒤 중지를 누르세요.';
}catch(e){ $('ststat').textContent='마이크 접근 실패: '+e; }
};
})();
connect();
// Refresh uptime label every second from the last known status.
setInterval(()=>{ if(statusData){ statusData.uptime_s=(statusData.uptime_s||0)+1; $('s-up').textContent=fmtUptime(statusData.uptime_s);} }, 1000);
</script>
</body>
</html>
"""