From 2f8ffdbbd10408c5e557e66edad4e16db169d2b1 Mon Sep 17 00:00:00 2001 From: alkatrazz Date: Fri, 19 Jun 2026 21:13:06 +0200 Subject: [PATCH] =?UTF-8?q?feat(stt-client):=20boutons=20stop=20r=C3=A9pon?= =?UTF-8?q?se=20+=20mute=20micro=20(HUD)=20+=20`stt=20--stop`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HUD : deux contrôles dans la barre du haut. - « stop » coupe la réponse en cours — interrompt la lecture TTS (kill aplay) et saute la synthèse si pressé pendant la génération (Event _interrupt). - « micro » coupe/réactive l'entrée audio (la boucle VAD ignore les trames) ; l'état fait foi côté backend, renvoyé au HUD via {"type":"mic"} (ré-émis à la connexion d'un nouveau client). Protocole WS : nouveau message {"type":"control","action":"stop|mute|unmute"}. CLI : `stt --stop` éteint le service — systemd --user (stt.service) si actif, sinon SIGTERM aux process vocaux trouvés via /proc (s'exclut + ignore les sous-commandes utilitaires). Co-Authored-By: Claude Opus 4.8 --- admin/ia/stt.md | 7 ++- stt/client/pyproject.toml | 2 +- stt/client/stt/cli.py | 69 ++++++++++++++++++++++ stt/client/stt/hud/index.html | 101 +++++++++++++++++++++++++++++++-- stt/client/stt/ui/app.py | 22 ++++++- stt/client/stt/voice/engine.py | 44 +++++++++++++- 6 files changed, 232 insertions(+), 13 deletions(-) diff --git a/admin/ia/stt.md b/admin/ia/stt.md index 39b7c9d..96f56b7 100644 --- a/admin/ia/stt.md +++ b/admin/ia/stt.md @@ -165,11 +165,16 @@ Lancer le HUD + voix automatiquement à l'ouverture de session (écran « Jarvis ```bash stt --install-service # écrit ~/.config/systemd/user/stt.service, enable + start stt --uninstall-service # retire le service +stt --stop # éteint l'instance en cours (service systemd OU process lancé à la main) ``` Le unit est lié à `graphical-session.target` (démarre avec la session graphique, s'arrête avec). Suivi : `systemctl --user status|restart stt`, `journalctl --user -u stt -f`. +`stt --stop` arrête proprement : si le service `stt.service` (systemd --user) est actif il fait +`systemctl --user stop`, sinon il envoie un `SIGTERM` aux process `stt` vocaux trouvés via `/proc` +(en s'excluant lui-même et les sous-commandes utilitaires `--update`/`--version`/…). + **Caveat DISPLAY** : un service `systemd --user` n'hérite de `DISPLAY`/`WAYLAND_DISPLAY` que si la session graphique les a importés dans le gestionnaire user. `--install-service` exécute un `systemctl --user import-environment DISPLAY WAYLAND_DISPLAY XAUTHORITY` (best-effort). Si le kiosk @@ -231,7 +236,7 @@ optionnel — son absence n'empêche rien. | **1 — Client voix + HUD** | `stt` : voix locale + HUD + websocket | ✅ | | **2 — STT-server** | FastAPI `/v1/ask` → LiteLLM | ✅ | | **3 — Déploiement cluster** | image ghcr + manifests k8s + ArgoCD (LiteLLM en IP directe) | ✅ déployé | -| **4 — HUD avancé** | avatar portrait réactif (anneau/ping/spinner) + transcript + **composer texte** (chat clavier sans micro) + drawer réglages (modèle/reset/wake à chaud) + thème accent | ✅ (à tester sur poste) | +| **4 — HUD avancé** | avatar portrait réactif (anneau/ping/spinner) + transcript + **composer texte** (chat clavier sans micro) + **boutons stop (couper la réponse) / mute micro** + drawer réglages (modèle/reset/wake à chaud) + thème accent | ✅ (à tester sur poste) | | **5a — Mémoire court-terme** | historique de session côté serveur | ✅ | | **5b — Mémoire long-terme** | Qdrant `stt-memory` + embeddings (dégrade si down) | ✅ validé 17/06 (rappel cross-session OK) | | **5c — Réparation Qdrant** | drop `funk-docs` corrompu + restart (s01) | ✅ (17/06) | diff --git a/stt/client/pyproject.toml b/stt/client/pyproject.toml index 5abbea5..20c07d5 100644 --- a/stt/client/pyproject.toml +++ b/stt/client/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "stt" -version = "0.5.1" +version = "0.6.0" description = "STT — client vocal Jarvis du homelab Funk (voix + HUD, parle au STT-server)" requires-python = ">=3.11" readme = "README.md" diff --git a/stt/client/stt/cli.py b/stt/client/stt/cli.py index a8c2958..b4c6202 100644 --- a/stt/client/stt/cli.py +++ b/stt/client/stt/cli.py @@ -109,6 +109,71 @@ def _cmd_uninstall_service() -> int: return 0 +def _running_stt_pids() -> list[int]: + """PID des instances `stt` (service voix/HUD) en cours, hors la commande courante. + + Lit /proc (Linux) : on cible les process dont argv[0] se nomme `stt` et qui ne + portent pas un flag utilitaire (--stop, --update, …) → uniquement le service vocal. + """ + me = os.getpid() + utilitaires = { + "--stop", "--update", "--version", "--setup", "--text", + "--install-service", "--uninstall-service", + "--install-desktop", "--uninstall-desktop", + } + pids: list[int] = [] + try: + entries = os.listdir("/proc") + except OSError: + return pids + for entry in entries: + if not entry.isdigit(): + continue + pid = int(entry) + if pid == me: + continue + try: + with open(f"/proc/{pid}/cmdline", "rb") as f: + args = [a.decode("utf-8", "ignore") for a in f.read().split(b"\0") if a] + except OSError: + continue + if not args: + continue + if os.path.basename(args[0]) == "stt" and not (utilitaires & set(args)): + pids.append(pid) + return pids + + +def _cmd_stop() -> int: + """Éteint le service STT : unit systemd --user en priorité, sinon le process vocal.""" + import signal + import subprocess + + # 1) Service systemd --user (cas auto-start) — chemin propre + active = subprocess.run( + ["systemctl", "--user", "is-active", SERVICE_NAME], + capture_output=True, text=True, + ).stdout.strip() + if active == "active": + subprocess.run(["systemctl", "--user", "stop", SERVICE_NAME], check=False) + print("✓ Service STT arrêté (systemctl --user stop stt).") + return 0 + + # 2) Lancé à la main (terminal / .desktop) → SIGTERM au(x) process vocal(aux) + pids = _running_stt_pids() + if not pids: + print("Aucun service ni instance STT en cours.") + return 0 + for pid in pids: + try: + os.kill(pid, signal.SIGTERM) + except OSError: + pass + mot = "instance" if len(pids) == 1 else "instances" + print(f"✓ {len(pids)} {mot} STT arrêtée(s) (PID {', '.join(map(str, pids))}).") + return 0 + + def _desktop_entry(stt_bin: str, icon_path: str) -> str: """Contenu du lanceur .desktop : STT comme application de bureau (fenêtre app).""" return f"""[Desktop Entry] @@ -326,6 +391,8 @@ def main(argv: list[str] | None = None) -> int: p.add_argument("--no-tts", action="store_true", help="Réponses texte seulement (voix)") p.add_argument("--window", choices=["app", "kiosk", "none"], help="Ouverture du HUD : app (fenêtre, type Discord) | kiosk (plein écran) | none") + p.add_argument("--stop", action="store_true", + help="Éteint le service STT en cours (systemd --user ou process vocal)") p.add_argument("--install-service", action="store_true", help="Installe le service systemd --user (auto-start au login + kiosk)") p.add_argument("--uninstall-service", action="store_true", @@ -345,6 +412,8 @@ def main(argv: list[str] | None = None) -> int: return _cmd_setup() if args.update: return _cmd_update() + if args.stop: + return _cmd_stop() if args.install_service: return _cmd_install_service() if args.uninstall_service: diff --git a/stt/client/stt/hud/index.html b/stt/client/stt/hud/index.html index 6f1affb..eeacd0e 100644 --- a/stt/client/stt/hud/index.html +++ b/stt/client/stt/hud/index.html @@ -122,6 +122,29 @@ .icon-btn svg { width: 20px; height: 20px; display: block; } .icon-btn.gear:hover svg { transform: rotate(40deg); transition: transform .5s var(--ease); } + /* Groupe de contrôles à droite (stop · micro · réglages) */ + .controls { display: inline-flex; align-items: center; gap: 10px; } + + /* Bouton « stop » : rouge franc au survol (action d'interruption) */ + .icon-btn.stop:hover { + color: #fff; + background: var(--accent-active); + border-color: var(--accent-active); + } + + /* Bouton micro : on n'affiche qu'une des deux icônes selon l'état */ + .icon-btn.mic .mic-off { display: none; } + .icon-btn.mic.is-muted .mic-on { display: none; } + .icon-btn.mic.is-muted .mic-off { display: block; } + /* Micro coupé : état « actif/alerte » rouge soutenu, pour qu'on le voie d'un coup d'œil */ + .icon-btn.mic.is-muted { + color: #fff; + background: var(--accent-active); + border-color: var(--accent-active); + box-shadow: 0 6px 20px rgba(255,93,108,.28); + } + .icon-btn.mic.is-muted:hover { filter: brightness(1.05); } + /* ---- Centre : avatar + anneau + label ---- */ .center { display: flex; @@ -631,12 +654,40 @@ déconnecté - +
+ + + + + + + +
@@ -897,6 +948,7 @@ function handleEvent(ev) { case 'user': addBubble('user', ev.text || ''); break; case 'assistant': addBubble('assistant', ev.text || '', ev.mode || ''); break; case 'error': addBubble('error', ev.text || 'Erreur'); break; + case 'mic': setMicUI(!!ev.muted); break; // état micro renvoyé par le backend default: console.warn('Événement inconnu :', ev); } } @@ -980,6 +1032,43 @@ if (composer) { }); } +/* ============================================================================ + CONTRÔLES — stopper la réponse de l'IA + couper le micro + Le backend (stt/voice/engine.py) reçoit {"type":"control","action":…} et + renvoie {"type":"mic","muted":…} qui fait foi pour l'état du bouton micro. + ============================================================================ */ +const stopBtn = document.getElementById('stopBtn'); +const micBtn = document.getElementById('micBtn'); +let micMuted = false; + +function setMicUI(muted) { + micMuted = !!muted; + micBtn.classList.toggle('is-muted', micMuted); + micBtn.setAttribute('aria-pressed', String(micMuted)); + micBtn.title = micMuted ? 'Micro coupé — cliquer pour réactiver' : 'Couper le micro'; + micBtn.setAttribute('aria-label', micMuted ? 'Réactiver le micro' : 'Couper le micro'); +} + +function sendControl(action) { + if (DEMO || !ws || ws.readyState !== WebSocket.OPEN) { + console.log('[DÉMO] control →', action); + return false; // pas de backend → l'appelant gère l'état localement + } + ws.send(JSON.stringify({ type: 'control', action })); + return true; +} + +stopBtn.addEventListener('click', () => { + sendControl('stop'); + setState('idle'); // retour visuel immédiat (le backend confirme aussi) +}); + +micBtn.addEventListener('click', () => { + const next = !micMuted; + // En connecté, on attend l'écho {"type":"mic"} ; hors-ligne/démo, on bascule localement. + if (!sendControl(next ? 'mute' : 'unmute')) setMicUI(next); +}); + /* ============================================================================ RÉGLAGES — collecte, persistance (thème en localStorage), UI ============================================================================ */ diff --git a/stt/client/stt/ui/app.py b/stt/client/stt/ui/app.py index c60fa9a..5e5d762 100644 --- a/stt/client/stt/ui/app.py +++ b/stt/client/stt/ui/app.py @@ -54,11 +54,14 @@ async def serve(cfg: dict[str, Any], make_engine, client) -> None: loop = asyncio.get_running_loop() clients: set = set() last_state = {"type": "state", "state": "idle"} + last_mic = {"type": "mic", "muted": False} def broadcast(event: dict) -> None: - nonlocal last_state + nonlocal last_state, last_mic if event.get("type") == "state": last_state = event + elif event.get("type") == "mic": + last_mic = event data = json.dumps(event, ensure_ascii=False) for ws in list(clients): asyncio.create_task(_safe_send(ws, data)) @@ -77,8 +80,10 @@ async def serve(cfg: dict[str, Any], make_engine, client) -> None: clients.add(ws) try: await ws.send(json.dumps(last_state, ensure_ascii=False)) - # Le HUD envoie {"type":"settings","data":{...}} (réglage) ou - # {"type":"text","text":"…"} (message tapé, alternative au vocal). + await ws.send(json.dumps(last_mic, ensure_ascii=False)) # état micro courant + # Le HUD envoie {"type":"settings","data":{...}} (réglage), + # {"type":"text","text":"…"} (message tapé) ou + # {"type":"control","action":"stop|mute|unmute"} (boutons HUD). async for raw in ws: try: msg = json.loads(raw) @@ -94,6 +99,17 @@ async def serve(cfg: dict[str, Any], make_engine, client) -> None: if txt and engine is not None: # responder = appel HTTP bloquant → hors boucle asyncio loop.run_in_executor(None, engine.respond_text, txt) + elif mtype == "control" and engine is not None: + action = msg.get("action") + if action == "stop": + # peut tuer un sous-process (aplay) → hors boucle asyncio + loop.run_in_executor(None, engine.stop_response) + elif action == "mute": + engine.set_muted(True) + elif action == "unmute": + engine.set_muted(False) + elif action == "toggle-mute": + engine.set_muted(not engine.muted) finally: clients.discard(ws) diff --git a/stt/client/stt/voice/engine.py b/stt/client/stt/voice/engine.py index 3b17886..8582521 100644 --- a/stt/client/stt/voice/engine.py +++ b/stt/client/stt/voice/engine.py @@ -67,6 +67,16 @@ class VoiceEngine: self._stop = False # sérialise un tour (voix ou texte) : pas deux réponses concurrentes self._respond_lock = threading.Lock() + # micro coupé : la boucle audio ignore les trames (le HUD pilote via set_muted) + self._muted = False + # interruption d'un tour en cours (bouton « stop » du HUD) + self._interrupt = threading.Event() + # process de lecture TTS courant (aplay) → tuable pour couper la parole + self._tts_proc: subprocess.Popen | None = None + + @property + def muted(self) -> bool: + return self._muted # ── chargement modèle ──────────────────────────────────────────────── def load(self) -> None: @@ -103,7 +113,7 @@ class VoiceEngine: # ── TTS ────────────────────────────────────────────────────────────── def _speak(self, text: str) -> None: - if self.no_tts: + if self.no_tts or self._interrupt.is_set(): return voice = PIPER_VOICE_DIR / f"{self.cfg['piper_voice']}.onnx" if not voice.exists() or not PIPER_BIN.exists(): @@ -118,15 +128,40 @@ class VoiceEngine: check=True, env={"LD_LIBRARY_PATH": str(PIPER_BIN.parent)}, ) - subprocess.run(["aplay", "-q", str(out)], check=False) + if self._interrupt.is_set(): # « stop » pressé pendant la synthèse + return + # Popen (pas run) → on garde la main pour tuer la lecture sur « stop » + self._tts_proc = subprocess.Popen(["aplay", "-q", str(out)]) + self._tts_proc.wait() except FileNotFoundError: pass finally: + self._tts_proc = None out.unlink(missing_ok=True) + def stop_response(self) -> None: + """Coupe le tour en cours : stoppe la lecture TTS et saute la suite. + + Appelé depuis le serveur websocket (bouton « stop » du HUD), hors du thread + qui tient `_respond_lock` → ne bloque pas, agit sur le tour actif. + """ + self._interrupt.set() + proc = self._tts_proc + if proc is not None and proc.poll() is None: + proc.terminate() + self.emit({"type": "state", "state": "idle"}) + + def set_muted(self, muted: bool) -> None: + """Coupe / réactive le micro (la boucle audio ignore les trames si coupé).""" + self._muted = bool(muted) + self.emit({"type": "mic", "muted": self._muted}) + if self._muted: + self.emit({"type": "state", "state": "idle"}) + # ── interrogation du serveur ───────────────────────────────────────── def _respond(self, text: str) -> None: with self._respond_lock: # un seul tour à la fois (voix ⟂ texte) + self._interrupt.clear() # nouveau tour → on repart d'une ardoise propre self.emit({"type": "user", "text": text}) if self.memory: self.memory.log("user", text) @@ -137,6 +172,9 @@ class VoiceEngine: self.emit({"type": "error", "text": str(e)}) self.emit({"type": "state", "state": "idle"}) return + if self._interrupt.is_set(): # « stop » pressé pendant la génération + self.emit({"type": "state", "state": "idle"}) + return mode = self.model_label() if self.model_label else None self.emit({"type": "assistant", "text": resp, "mode": mode}) if self.memory: @@ -210,6 +248,8 @@ class VoiceEngine: self.emit({"type": "state", "state": "idle"}) frame = audio_q.get() + if self._muted: # micro coupé : on vide la trame et on ignore + continue pre_roll.append(frame) try: if not vad.is_speech(frame.tobytes(), SAMPLE_RATE):