mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-08 13:24:43 +02:00
feat(stt-client): boutons stop réponse + mute micro (HUD) + stt --stop
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 <noreply@anthropic.com>
This commit is contained in:
parent
e811e862ae
commit
2f8ffdbbd1
6 changed files with 232 additions and 13 deletions
|
|
@ -165,11 +165,16 @@ Lancer le HUD + voix automatiquement à l'ouverture de session (écran « Jarvis
|
||||||
```bash
|
```bash
|
||||||
stt --install-service # écrit ~/.config/systemd/user/stt.service, enable + start
|
stt --install-service # écrit ~/.config/systemd/user/stt.service, enable + start
|
||||||
stt --uninstall-service # retire le service
|
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).
|
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`.
|
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
|
**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
|
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
|
`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 | ✅ |
|
| **1 — Client voix + HUD** | `stt` : voix locale + HUD + websocket | ✅ |
|
||||||
| **2 — STT-server** | FastAPI `/v1/ask` → LiteLLM | ✅ |
|
| **2 — STT-server** | FastAPI `/v1/ask` → LiteLLM | ✅ |
|
||||||
| **3 — Déploiement cluster** | image ghcr + manifests k8s + ArgoCD (LiteLLM en IP directe) | ✅ déployé |
|
| **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 | ✅ |
|
| **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) |
|
| **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) |
|
| **5c — Réparation Qdrant** | drop `funk-docs` corrompu + restart (s01) | ✅ (17/06) |
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "stt"
|
name = "stt"
|
||||||
version = "0.5.1"
|
version = "0.6.0"
|
||||||
description = "STT — client vocal Jarvis du homelab Funk (voix + HUD, parle au STT-server)"
|
description = "STT — client vocal Jarvis du homelab Funk (voix + HUD, parle au STT-server)"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,71 @@ def _cmd_uninstall_service() -> int:
|
||||||
return 0
|
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:
|
def _desktop_entry(stt_bin: str, icon_path: str) -> str:
|
||||||
"""Contenu du lanceur .desktop : STT comme application de bureau (fenêtre app)."""
|
"""Contenu du lanceur .desktop : STT comme application de bureau (fenêtre app)."""
|
||||||
return f"""[Desktop Entry]
|
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("--no-tts", action="store_true", help="Réponses texte seulement (voix)")
|
||||||
p.add_argument("--window", choices=["app", "kiosk", "none"],
|
p.add_argument("--window", choices=["app", "kiosk", "none"],
|
||||||
help="Ouverture du HUD : app (fenêtre, type Discord) | kiosk (plein écran) | 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",
|
p.add_argument("--install-service", action="store_true",
|
||||||
help="Installe le service systemd --user (auto-start au login + kiosk)")
|
help="Installe le service systemd --user (auto-start au login + kiosk)")
|
||||||
p.add_argument("--uninstall-service", action="store_true",
|
p.add_argument("--uninstall-service", action="store_true",
|
||||||
|
|
@ -345,6 +412,8 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
return _cmd_setup()
|
return _cmd_setup()
|
||||||
if args.update:
|
if args.update:
|
||||||
return _cmd_update()
|
return _cmd_update()
|
||||||
|
if args.stop:
|
||||||
|
return _cmd_stop()
|
||||||
if args.install_service:
|
if args.install_service:
|
||||||
return _cmd_install_service()
|
return _cmd_install_service()
|
||||||
if args.uninstall_service:
|
if args.uninstall_service:
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,29 @@
|
||||||
.icon-btn svg { width: 20px; height: 20px; display: block; }
|
.icon-btn svg { width: 20px; height: 20px; display: block; }
|
||||||
.icon-btn.gear:hover svg { transform: rotate(40deg); transition: transform .5s var(--ease); }
|
.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 ---- */
|
/* ---- Centre : avatar + anneau + label ---- */
|
||||||
.center {
|
.center {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -631,12 +654,40 @@
|
||||||
<span class="dot" aria-hidden="true"></span>
|
<span class="dot" aria-hidden="true"></span>
|
||||||
<span class="conn-txt">déconnecté</span>
|
<span class="conn-txt">déconnecté</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="icon-btn gear" id="openSettings" aria-label="Ouvrir les réglages" title="Réglages">
|
<div class="controls">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
<!-- Couper la parole de l'IA (stop la lecture/le tour en cours) -->
|
||||||
<circle cx="12" cy="12" r="3.2"></circle>
|
<button class="icon-btn stop" id="stopBtn" aria-label="Stopper la réponse" title="Stopper la réponse de l'IA">
|
||||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
</svg>
|
<rect x="7" y="7" width="10" height="10" rx="2"></rect>
|
||||||
</button>
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Couper / réactiver le micro -->
|
||||||
|
<button class="icon-btn mic" id="micBtn" aria-label="Couper le micro" aria-pressed="false" title="Couper le micro">
|
||||||
|
<!-- micro actif -->
|
||||||
|
<svg class="mic-on" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<rect x="9" y="3" width="6" height="11" rx="3"></rect>
|
||||||
|
<path d="M5 11a7 7 0 0 0 14 0"></path>
|
||||||
|
<path d="M12 18v3"></path>
|
||||||
|
</svg>
|
||||||
|
<!-- micro coupé (barré) -->
|
||||||
|
<svg class="mic-off" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M9 9V6a3 3 0 0 1 5.12-2.12"></path>
|
||||||
|
<path d="M15 11.3V6"></path>
|
||||||
|
<path d="M5 11a7 7 0 0 0 10.7 5.98"></path>
|
||||||
|
<path d="M19 11a6.97 6.97 0 0 1-.42 2.4"></path>
|
||||||
|
<path d="M12 18v3"></path>
|
||||||
|
<path d="M3 3l18 18"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="icon-btn gear" id="openSettings" aria-label="Ouvrir les réglages" title="Réglages">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="3.2"></circle>
|
||||||
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Centre : avatar -->
|
<!-- Centre : avatar -->
|
||||||
|
|
@ -897,6 +948,7 @@ function handleEvent(ev) {
|
||||||
case 'user': addBubble('user', ev.text || ''); break;
|
case 'user': addBubble('user', ev.text || ''); break;
|
||||||
case 'assistant': addBubble('assistant', ev.text || '', ev.mode || ''); break;
|
case 'assistant': addBubble('assistant', ev.text || '', ev.mode || ''); break;
|
||||||
case 'error': addBubble('error', ev.text || 'Erreur'); 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);
|
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
|
RÉGLAGES — collecte, persistance (thème en localStorage), UI
|
||||||
============================================================================ */
|
============================================================================ */
|
||||||
|
|
|
||||||
|
|
@ -54,11 +54,14 @@ async def serve(cfg: dict[str, Any], make_engine, client) -> None:
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
clients: set = set()
|
clients: set = set()
|
||||||
last_state = {"type": "state", "state": "idle"}
|
last_state = {"type": "state", "state": "idle"}
|
||||||
|
last_mic = {"type": "mic", "muted": False}
|
||||||
|
|
||||||
def broadcast(event: dict) -> None:
|
def broadcast(event: dict) -> None:
|
||||||
nonlocal last_state
|
nonlocal last_state, last_mic
|
||||||
if event.get("type") == "state":
|
if event.get("type") == "state":
|
||||||
last_state = event
|
last_state = event
|
||||||
|
elif event.get("type") == "mic":
|
||||||
|
last_mic = event
|
||||||
data = json.dumps(event, ensure_ascii=False)
|
data = json.dumps(event, ensure_ascii=False)
|
||||||
for ws in list(clients):
|
for ws in list(clients):
|
||||||
asyncio.create_task(_safe_send(ws, data))
|
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)
|
clients.add(ws)
|
||||||
try:
|
try:
|
||||||
await ws.send(json.dumps(last_state, ensure_ascii=False))
|
await ws.send(json.dumps(last_state, ensure_ascii=False))
|
||||||
# Le HUD envoie {"type":"settings","data":{...}} (réglage) ou
|
await ws.send(json.dumps(last_mic, ensure_ascii=False)) # état micro courant
|
||||||
# {"type":"text","text":"…"} (message tapé, alternative au vocal).
|
# 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:
|
async for raw in ws:
|
||||||
try:
|
try:
|
||||||
msg = json.loads(raw)
|
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:
|
if txt and engine is not None:
|
||||||
# responder = appel HTTP bloquant → hors boucle asyncio
|
# responder = appel HTTP bloquant → hors boucle asyncio
|
||||||
loop.run_in_executor(None, engine.respond_text, txt)
|
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:
|
finally:
|
||||||
clients.discard(ws)
|
clients.discard(ws)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,16 @@ class VoiceEngine:
|
||||||
self._stop = False
|
self._stop = False
|
||||||
# sérialise un tour (voix ou texte) : pas deux réponses concurrentes
|
# sérialise un tour (voix ou texte) : pas deux réponses concurrentes
|
||||||
self._respond_lock = threading.Lock()
|
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 ────────────────────────────────────────────────
|
# ── chargement modèle ────────────────────────────────────────────────
|
||||||
def load(self) -> None:
|
def load(self) -> None:
|
||||||
|
|
@ -103,7 +113,7 @@ class VoiceEngine:
|
||||||
|
|
||||||
# ── TTS ──────────────────────────────────────────────────────────────
|
# ── TTS ──────────────────────────────────────────────────────────────
|
||||||
def _speak(self, text: str) -> None:
|
def _speak(self, text: str) -> None:
|
||||||
if self.no_tts:
|
if self.no_tts or self._interrupt.is_set():
|
||||||
return
|
return
|
||||||
voice = PIPER_VOICE_DIR / f"{self.cfg['piper_voice']}.onnx"
|
voice = PIPER_VOICE_DIR / f"{self.cfg['piper_voice']}.onnx"
|
||||||
if not voice.exists() or not PIPER_BIN.exists():
|
if not voice.exists() or not PIPER_BIN.exists():
|
||||||
|
|
@ -118,15 +128,40 @@ class VoiceEngine:
|
||||||
check=True,
|
check=True,
|
||||||
env={"LD_LIBRARY_PATH": str(PIPER_BIN.parent)},
|
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:
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
|
self._tts_proc = None
|
||||||
out.unlink(missing_ok=True)
|
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 ─────────────────────────────────────────
|
# ── interrogation du serveur ─────────────────────────────────────────
|
||||||
def _respond(self, text: str) -> None:
|
def _respond(self, text: str) -> None:
|
||||||
with self._respond_lock: # un seul tour à la fois (voix ⟂ texte)
|
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})
|
self.emit({"type": "user", "text": text})
|
||||||
if self.memory:
|
if self.memory:
|
||||||
self.memory.log("user", text)
|
self.memory.log("user", text)
|
||||||
|
|
@ -137,6 +172,9 @@ class VoiceEngine:
|
||||||
self.emit({"type": "error", "text": str(e)})
|
self.emit({"type": "error", "text": str(e)})
|
||||||
self.emit({"type": "state", "state": "idle"})
|
self.emit({"type": "state", "state": "idle"})
|
||||||
return
|
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
|
mode = self.model_label() if self.model_label else None
|
||||||
self.emit({"type": "assistant", "text": resp, "mode": mode})
|
self.emit({"type": "assistant", "text": resp, "mode": mode})
|
||||||
if self.memory:
|
if self.memory:
|
||||||
|
|
@ -210,6 +248,8 @@ class VoiceEngine:
|
||||||
self.emit({"type": "state", "state": "idle"})
|
self.emit({"type": "state", "state": "idle"})
|
||||||
|
|
||||||
frame = audio_q.get()
|
frame = audio_q.get()
|
||||||
|
if self._muted: # micro coupé : on vide la trame et on ignore
|
||||||
|
continue
|
||||||
pre_roll.append(frame)
|
pre_roll.append(frame)
|
||||||
try:
|
try:
|
||||||
if not vad.is_speech(frame.tobytes(), SAMPLE_RATE):
|
if not vad.is_speech(frame.tobytes(), SAMPLE_RATE):
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue