mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-10 02:34:43 +02:00
Remplace le HUD minimal (cercle CSS) par le design Claude Design : avatar
portrait réactif (anneau + ping d'écoute + spinner de réflexion), transcript
à bulles (tag modèle), indicateur de connexion + reconnexion auto, et un
drawer Réglages.
- HUD (stt/hud/index.html) : HTML/CSS/JS sans dépendance externe, DEMO=false
branché sur le websocket existant (états idle/listening/thinking/speaking,
user/assistant/error). Portraits chargés depuis hud/avatars/ (repli « あ »
si absent — voir avatars/README.md).
- Backend (ui/app.py) : le HUD renvoie {"type":"settings",...} ; le serveur
applique à chaud reset / mode cerveau (modèle LiteLLM) / mot de réveil via
_apply_settings (best-effort, non bloquant).
- engine/cli : tag du modèle courant ajouté aux réponses (events assistant).
- Docs : hud/README + avatars/README + admin/ia/stt.md (phase 4 ✅) + config.
Phase 4 de la roadmap STT. Reste : test sur poste + portraits réels.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qjq5jHiqNepnobJpHYpCa
137 lines
5 KiB
Python
137 lines
5 KiB
Python
"""Serveur asyncio : sert le HUD (HTTP statique) et diffuse les états (websocket).
|
|
|
|
Le moteur vocal tourne dans un thread (audio bloquant) et pousse ses événements via
|
|
`emit()`, relayés sur la boucle asyncio puis diffusés à tous les clients HUD connectés.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import threading
|
|
from functools import partial
|
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
# Le HUD est embarqué DANS le package (stt/hud) pour être livré par pipx.
|
|
HUD_DIR = Path(__file__).resolve().parent.parent / "hud"
|
|
|
|
|
|
def _start_http(host: str, port: int) -> ThreadingHTTPServer:
|
|
handler = partial(SimpleHTTPRequestHandler, directory=str(HUD_DIR))
|
|
httpd = ThreadingHTTPServer((host, port), handler)
|
|
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
|
return httpd
|
|
|
|
|
|
async def serve(cfg: dict[str, Any], make_engine, client) -> None:
|
|
"""Sert le HUD + diffuse les états ; applique les réglages renvoyés par le HUD.
|
|
|
|
make_engine(emit) -> VoiceEngine. emit est thread-safe (depuis le thread voix).
|
|
client : ServerClient — muté à chaud par les réglages (modèle, reset).
|
|
"""
|
|
import websockets
|
|
|
|
ui = cfg["ui"]
|
|
loop = asyncio.get_running_loop()
|
|
clients: set = set()
|
|
last_state = {"type": "state", "state": "idle"}
|
|
|
|
def broadcast(event: dict) -> None:
|
|
nonlocal last_state
|
|
if event.get("type") == "state":
|
|
last_state = event
|
|
data = json.dumps(event, ensure_ascii=False)
|
|
for ws in list(clients):
|
|
asyncio.create_task(_safe_send(ws, data))
|
|
|
|
async def _safe_send(ws, data: str) -> None:
|
|
try:
|
|
await ws.send(data)
|
|
except Exception:
|
|
clients.discard(ws)
|
|
|
|
# emit appelé depuis le thread voix → on repasse sur la boucle asyncio
|
|
def emit(event: dict) -> None:
|
|
loop.call_soon_threadsafe(broadcast, event)
|
|
|
|
async def handler(ws):
|
|
clients.add(ws)
|
|
try:
|
|
await ws.send(json.dumps(last_state, ensure_ascii=False))
|
|
# Le HUD renvoie {"type":"settings","data":{...}} quand on change un réglage.
|
|
async for raw in ws:
|
|
try:
|
|
msg = json.loads(raw)
|
|
except (ValueError, TypeError):
|
|
continue
|
|
if isinstance(msg, dict) and msg.get("type") == "settings":
|
|
_apply_settings(client, engine, msg.get("data") or {}, loop)
|
|
finally:
|
|
clients.discard(ws)
|
|
|
|
_start_http(ui["ws_host"], ui["http_port"])
|
|
|
|
engine = make_engine(emit)
|
|
# chargement Whisper + boucle audio dans un thread dédié
|
|
threading.Thread(target=_run_engine, args=(engine, emit), daemon=True).start()
|
|
|
|
async with websockets.serve(handler, ui["ws_host"], ui["ws_port"]):
|
|
url = f"http://{ui['ws_host']}:{ui['http_port']}/"
|
|
print(f" HUD : {url}")
|
|
if ui.get("kiosk"):
|
|
_open_browser(url, kiosk=True)
|
|
await asyncio.Future() # tourne indéfiniment
|
|
|
|
|
|
def _run_engine(engine, emit) -> None:
|
|
try:
|
|
engine.load()
|
|
engine.run()
|
|
except Exception as e: # noqa: BLE001
|
|
emit({"type": "error", "text": f"moteur vocal : {e}"})
|
|
|
|
|
|
def _open_browser(url: str, kiosk: bool) -> None:
|
|
import shutil
|
|
import subprocess
|
|
import webbrowser
|
|
|
|
for browser in ("chromium", "google-chrome", "chromium-browser"):
|
|
if shutil.which(browser):
|
|
flag = "--kiosk" if kiosk else "--new-window"
|
|
subprocess.Popen([browser, flag, url])
|
|
return
|
|
webbrowser.open(url)
|
|
|
|
|
|
def _apply_settings(client, engine, data: dict, loop) -> None:
|
|
"""Applique les réglages renvoyés par le HUD — best-effort, jamais bloquant ni fatal.
|
|
|
|
Les réglages purement visuels (intensité d'accent, avatar) sont gérés côté
|
|
navigateur (localStorage). Ici on n'applique que ce qui touche le backend :
|
|
• action "reset" → vide la mémoire de session côté STT-server
|
|
• brainMode → bascule le modèle (local = Qwen gratuit / advanced = Claude)
|
|
• wakeword → mot de réveil, relu à chaud par le moteur vocal
|
|
La voix Piper et le no-think ne sont pas applicables à chaud (ignorés ici).
|
|
"""
|
|
if not isinstance(data, dict):
|
|
return
|
|
try:
|
|
if data.get("action") == "reset":
|
|
loop.run_in_executor(None, client.reset) # appel HTTP → hors boucle
|
|
return
|
|
brain = data.get("brainMode")
|
|
if brain in ("local", "advanced"):
|
|
from stt.config import resolve_model
|
|
|
|
client.model = resolve_model("hermes" if brain == "local" else "claude")
|
|
wake = (data.get("wakeword") or "").strip()
|
|
if wake and engine is not None:
|
|
from stt.voice.engine import _deaccent
|
|
|
|
parts = wake.split()
|
|
engine.cfg["wake_word"] = _deaccent(parts[-1] if parts else wake)
|
|
except Exception: # noqa: BLE001 — un réglage invalide ne doit jamais tuer la connexion
|
|
pass
|