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):