Files
watch_sceen_ai/wsai/dashboard.py
EJClaw f585ed7b76 feat(voice): bracketed emotion tags, [잡음] for noise, spoken emotion, concise replies
- Brain persona now prefixes every reply with one bracketed emotion tag
  (e.g. [반가움], [궁금]) and keeps replies to one or two short sentences.
- Empty/unrecognised audio (silence/noise) is reported as reply "[잡음]" with
  no TTS playback instead of an empty reply.
- TTS speaks the bracketed emotion too: "[힘차게] 안녕!" is synthesised as
  "힘차게, 안녕!" via a leading-tag -> spoken-word transform.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-22 01:44:25 +09:00

682 lines
30 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 re
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from .monitor import Monitor
log = logging.getLogger("wsai.dashboard")
_EMOTION_RE = re.compile(r"^\s*\[([^\]]+)\]\s*(.*)", re.S)
def _speech_text(reply: str) -> str:
"""Turn a reply like ``[힘차게] 안녕!`` into what the TTS should actually say.
The bracketed emotion is spoken too (per user request), so the leading tag
becomes a natural spoken word: ``[힘차게] 안녕!`` -> ``힘차게, 안녕!``. Replies
without a tag are spoken as-is."""
m = _EMOTION_RE.match(reply or "")
if not m:
return reply
emotion, rest = m.group(1).strip(), m.group(2).strip()
return f"{emotion}, {rest}" if rest else emotion
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()
elif path == "/api/voice-turn":
self._handle_voice_turn()
else:
self._send(404, b"not found", "text/plain; charset=utf-8")
def _read_body(self) -> bytes:
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError:
length = 0
return self.rfile.read(length) if length > 0 else b""
def _handle_voice_turn(self) -> None:
"""Discord voice bridge: utterance wav in -> reply wav out. The
recognised/reply text ride along as URL-encoded response headers so
the bot can log them; the body is the reply audio to play back."""
import urllib.parse
if dash.stt is None or dash.tts is None:
self._send(503, json.dumps({"ok": False, "error": "voice loop not enabled"}).encode(),
"application/json; charset=utf-8")
return
raw = self._read_body()
if not raw:
self._send(400, json.dumps({"ok": False, "error": "empty upload"}).encode(),
"application/json; charset=utf-8")
return
try:
res = dash.voice_turn(raw)
except Exception as exc: # noqa: BLE001
log.exception("voice-turn failed")
self._send(500, json.dumps({"ok": False, "error": f"{type(exc).__name__}: {exc}"},
ensure_ascii=False).encode(), "application/json; charset=utf-8")
return
body = res["wav"]
self.send_response(200 if body else 204)
self.send_header("Content-Type", "audio/wav")
self.send_header("Content-Length", str(len(body)))
self.send_header("X-Heard", urllib.parse.quote(res.get("heard", "")))
self.send_header("X-Reply", urllib.parse.quote(res.get("reply", "")))
self.send_header("Cache-Control", "no-store")
self.end_headers()
if body:
self.wfile.write(body)
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, tts=None, brain=None, history_turns: int = 12) -> None:
self.monitor = monitor
self.host = host
self.port = port
self.stt = stt
self.tts = tts
self.brain = brain
self._history: list[tuple[str, str]] = []
self._history_turns = history_turns
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 or self.tts 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/TTS workers (loads + warms the GPU) so the first
recognition/synth is instant instead of paying model-load + CUDA autotune."""
if self.stt is not None:
self._submit(self.stt._ensure())
if self.tts is not None:
self._submit(self.tts._ensure())
def voice_turn(self, audio_bytes: bytes) -> dict:
"""One Discord voice turn: decode the uploaded utterance, recognise it
on the GPU, think of a reply (Claude brain if wired, else echo),
synthesise it on the GPU, and return {heard, reply, wav} where wav is the
reply audio bytes for the bot to play back into the channel."""
import os
import subprocess
import time
import uuid
if self.stt is None or self.tts is None:
raise RuntimeError("voice_turn needs both STT and TTS")
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="discord")
t0 = time.monotonic()
try:
subprocess.run(
["ffmpeg", "-y", "-i", src, "-ar", "16000", "-ac", "1", wav],
check=True, capture_output=True,
)
heard = (self._submit(self.stt.transcribe(wav)) or "").strip()
turn.heard(heard or "(빈 결과)")
if not heard:
# Nothing recognised (silence/noise): mark it as [잡음] and skip
# the brain/TTS so the bot plays nothing back.
turn.replied("[잡음]")
turn.finish()
return {"heard": heard, "reply": "[잡음]", "wav": b""}
reply_text = self._think(heard)
turn.replied(reply_text)
out_path = self._submit(self.tts.synth(_speech_text(reply_text)))
with open(out_path, "rb") as f:
reply_wav = f.read()
ms = int((time.monotonic() - t0) * 1000)
step = turn.step("STT+두뇌+TTS" if self.brain else "STT+TTS(GPU)")
step.ok, step.ms = True, float(ms)
turn._steps.append(step)
turn.finish()
try:
os.remove(out_path)
except OSError:
pass
return {"heard": heard, "reply": reply_text, "wav": reply_wav}
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
def _think(self, heard: str) -> str:
"""Turn what was heard into a reply. Uses the Claude brain when wired
(with rolling conversation history); falls back to echo if there is no
brain, and to a spoken apology if the brain call fails — so one API hiccup
never kills the voice loop."""
if self.brain is None:
return heard # echo mode
try:
reply = self._submit(self.brain.respond(heard, None, list(self._history)))
text = (reply.text or "").strip()
except Exception as exc: # noqa: BLE001
log.exception("brain failed")
self.monitor.log("error", f"두뇌 응답 실패: {exc}")
blob = f"{getattr(exc, 'status_code', '')} {exc}".lower()
if "529" in blob or "overload" in blob:
# Transient server overload survived the SDK retries.
return "지금 서버가 잠깐 붐벼서 생각이 늦네. 잠시 뒤에 다시 말해줄래?"
return "미안, 지금 잠깐 생각이 안 났어. 다시 말해줄래?"
if not text:
return "음, 뭐라고 해야 할지 모르겠어. 다시 말해줄래?"
self._history.append((heard, text))
if len(self._history) > self._history_turns:
self._history = self._history[-self._history_turns:]
return text
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>
"""