"""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""" watch_sceen_ai · 실시간 상태

watch_sceen_ai · 실시간 상태

STT → 두뇌 → TTS 음성 루프를 단계별로 관찰
연결 대기
0대화 수
0오류
0초가동시간
·연결
아직 대화가 없습니다. 사용자가 말하면 여기에 단계별로 나타납니다.

이벤트 / 오류 로그

"""