"""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 _speech_text(reply: str) -> str: """Return reply text exactly as authored for the TTS backend. The TTS backend itself understands bracketed emotion tags: known emotion tags steer delivery and are not spoken; non-emotion brackets are spoken. Do not rewrite a leading tag here, or the first emotion would be read aloud and lost before ``MeloTTS.synth`` can parse it.""" return reply 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"""