feat(stt): entrée texte HUD + finitions client + doc incident LLM/GPU (#19)

Regroupe le travail postérieur à la PR #18 (squash-mergée), rebasé proprement
sur main :

- HUD : composer texte (chat clavier sans micro) — {"type":"text"} →
  VoiceEngine.respond_text (hors boucle audio), lock _respond (voix ⟂ texte)
- App autonome : fenêtre app/kiosk via profil + WM class dédiés (hors Brave)
- Auto-update non destructif : `stt --update` = pipx upgrade (ne laisse plus
  l'install cassée en cas d'échec réseau)
- Terminal propre : --log-level=3 + sortie navigateur vers DEVNULL
- Docs : admin/incidents-llm-gpu.md (journal d'inférence dédié) — incident
  2026-06-19 llama-server gelé (/health OK mais génération bloquée → 502),
  + HUD README / stt.md (composer), bump 0.4.x → 0.5.1

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ALI YESILKAYA 2026-06-19 14:56:36 +02:00 committed by GitHub
parent 5f1e34dad9
commit 7025d7ae70
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 293 additions and 40 deletions

3
.gitignore vendored
View file

@ -25,3 +25,6 @@ keys.txt
# Divers # Divers
.DS_Store .DS_Store
*.swp *.swp
# Playwright MCP session artifacts
.playwright-mcp/

View file

@ -132,7 +132,7 @@ au prompt système (`STT_DISABLE_THINKING`, défaut `true` ; inoffensif hors Qwe
| Client `stt` | `stt/client/` (pipx) | `stt` (voix+HUD), `stt --text` (chat texte), `stt --setup`, `stt --server <url>`, `stt --model <hermes\|claude\|qwen\|opus>` | | Client `stt` | `stt/client/` (pipx) | `stt` (voix+HUD), `stt --text` (chat texte), `stt --setup`, `stt --server <url>`, `stt --model <hermes\|claude\|qwen\|opus>` |
| — voix | `stt/client/stt/voice/` | wake word, STT faster-whisper, TTS Piper | | — voix | `stt/client/stt/voice/` | wake word, STT faster-whisper, TTS Piper |
| — api | `stt/client/stt/api.py` | `ServerClient``POST /v1/ask` | | — api | `stt/client/stt/api.py` | `ServerClient``POST /v1/ask` |
| — UI/HUD | `stt/client/stt/ui/` + `hud/` | HTTP statique + websocket bidirectionnel (états → HUD, réglages → backend) ; HUD embarqué dans le package | | — UI/HUD | `stt/client/stt/ui/` + `hud/` | HTTP statique + websocket bidirectionnel (états → HUD ; réglages **et messages texte** → backend) ; HUD embarqué dans le package |
| STT-server | `stt/server/` (conteneur) | FastAPI : `/healthz`, `/v1/ask` ; `brain.py` → LiteLLM | | STT-server | `stt/server/` (conteneur) | FastAPI : `/healthz`, `/v1/ask` ; `brain.py` → LiteLLM |
| Image | `ghcr.io/alkatrazz24/funk-stt-server` | construite par `.github/workflows/build-stt-server.yml` | | Image | `ghcr.io/alkatrazz24/funk-stt-server` | construite par `.github/workflows/build-stt-server.yml` |
| Manifests | `k8s/apps/stt/` | Deployment, Service, IngressRoute (`stt.lab.local`) | | Manifests | `k8s/apps/stt/` | Deployment, Service, IngressRoute (`stt.lab.local`) |
@ -231,7 +231,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 + 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) + 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) |
@ -246,6 +246,8 @@ Serveur déployé in-cluster via ArgoCD (image `sha-<commit>` gérée par CI). M
validées en prod** : court-terme (historique de session) et long-terme (rappel cross-session validées en prod** : court-terme (historique de session) et long-terme (rappel cross-session
« Felix », `points_count` confirmé via `/v1/memory/health`). HUD avancé (4) **implémenté** « Felix », `points_count` confirmé via `/v1/memory/health`). HUD avancé (4) **implémenté**
(design Claude Design, câblé au websocket backend) ; auto-start (6) via `stt --install-service`. (design Claude Design, câblé au websocket backend) ; auto-start (6) via `stt --install-service`.
Le HUD inclut un **composer texte** (`{"type":"text"}``VoiceEngine.respond_text`) pour
dialoguer au clavier sans micro — utile tant que le bout-en-bout audio n'est pas validé.
Reste côté poste : test audio bout-en-bout (micro → STT → serveur → TTS) et test du HUD/kiosk Reste côté poste : test audio bout-en-bout (micro → STT → serveur → TTS) et test du HUD/kiosk
avec portraits réels. avec portraits réels.

View file

@ -0,0 +1,87 @@
# Incidents LLM / GPU — llama-server (gpu-01)
Journal dédié des incidents d'**inférence** : `llama-server` (ROCm / RX 6700XT sur
gpu-01) et chaîne LLM `STT-server / Hermes → LiteLLM → llama-server`. Voir aussi
`incidents.md` (matériel & services storage-01/k8s) et `admin/ia/llama_server.md`.
---
## 2026-06-19 — llama-server gelé : `/health` OK mais inférence bloquée → 502 sur tout le LLM
### Symptômes
- STT (HUD, **composer texte**) : `502 Server Error: Bad Gateway for url: http://stt.lab.local/v1/ask`
- Petite **activité GPU** visible sur gpu-01 (le slot d'inférence tourne en rond)
- STT-server : `GET /healthz` → 200, mais `POST /v1/ask` → **502**
- llama-server `:1234` : `/health``{"status":"ok"}` et `/v1/models``qwen3-8b` listé
**l'apparence est « up »**, ce qui masque la panne
- Mais une **génération réelle ne renvoie jamais rien** : `curl /v1/chat/completions`
hang (45 s sans un seul token)
- LiteLLM (storage-01) :
`litellm.Timeout: APITimeoutError - Request timed out. timeout value=600.0, time taken=600.0s.
Received Model Group=hermes-default` (après 2 retries)
### Cause
Le `llama-server` (gpu-01, `:1234`, `qwen3-8b`) était dans un état **wedgé** après
~2 jours d'uptime (démarré le 2026-06-17 21:04). Les endpoints de contrôle
(`/health`, `/v1/models`) répondaient encore, mais le **slot d'inférence ROCm était
bloqué** (vraisemblablement une requête avortée laissée dans le slot). Chaîne du 502 :
```
HUD composer → STT-server (/v1/ask) → LiteLLM :4000 (hermes-default) → llama-server gpu-01 :1234 ← GELÉ
```
LiteLLM attend `request_timeout = 600 s`, échoue, et le STT-server renvoie 502.
### Diagnostic (commandes utiles)
```bash
# 1) STT-server : confirmer que le 502 vient de lui (et pas de Traefik)
kubectl -n ai logs <pod stt-server> --tail=50 # → "POST /v1/ask HTTP/1.1" 502
# 2) LiteLLM : voir le timeout
ssh storage-01 'journalctl -u litellm -n 40 --no-pager --output=cat'
# → litellm.Timeout ... timeout value=600.0 ... Model Group=hermes-default
# 3) llama-server : /health MENT — tester une VRAIE génération
ssh gpu-01 'curl -s -m 30 http://127.0.0.1:1234/v1/chat/completions \
-H "Content-Type: application/json" \
-d "{\"model\":\"qwen3-8b\",\"messages\":[{\"role\":\"user\",\"content\":\"Dis bonjour. /no_think\"}],\"max_tokens\":30}"'
# gelé (aucune sortie) = wedgé | réponse JSON = sain
```
### Résolution
Redémarrer le service d'inférence sur gpu-01 :
```bash
ssh gpu-01 'sudo systemctl restart llama-server'
```
- L'ancienne instance gelée met **~40 s à mourir** (état `deactivating` le temps que
le slot bloqué se libère) — `systemctl restart` peut dépasser un timeout SSH court,
c'est normal ; vérifier avec `systemctl is-active llama-server` jusqu'à `active`.
- Au redémarrage, `/v1/chat/completions` renvoie `503 {"error":"Loading model"}`
pendant ~30 s (rechargement du modèle en VRAM) — **normal, attendre**.
- **Validation** : génération directe (`"Bonjour."` en ~80 ms) puis bout-en-bout
`POST http://stt.lab.local/v1/ask``{"reply":"Bonjour.","model":"hermes-default"}`.
### Observations / suites (hors présent incident)
- ⚠️ **Piège du health-check** : `/health` et `/v1/models` restent verts quand le
serveur est wedgé → un check qui ne fait **pas une vraie génération** ne voit pas la
panne (même angle mort que le crash-loop Qdrant en `activating`, cf. `incidents.md`).
- **Latence cerveau** : post-restart, 30-45 s pour une réponse triviale via STT-server
alors que le llama-server direct est à ~80 ms → le surcoût vient du pipeline du
cerveau (mémoire/embeddings Qdrant `stt-memory` + LiteLLM). À investiguer séparément.
- **Bruit LiteLLM** : `Invalid model name model=qwen3-1.7b-monitor` (profil monitor
Hermes) dans les logs — pré-existant, non lié à cet incident.
### Prévention (pistes)
- **Probe applicative** : sonde qui fait une vraie génération `/v1/chat/completions`
(pas seulement `/health`) → alerte si le LLM ne produit plus de tokens.
- **Watchdog** : si LiteLLM accumule des `Timeout` sur `hermes-default`, redémarrer
automatiquement `llama-server` sur gpu-01.
- Réduire le `request_timeout` LiteLLM (600 s est très long → un 502 tardif et opaque).

View file

@ -1,5 +1,8 @@
# Incidents — Historique et résolutions # Incidents — Historique et résolutions
> Incidents **inférence LLM / GPU** (llama-server, ROCm gpu-01, chaîne LiteLLM) :
> journal dédié dans [`incidents-llm-gpu.md`](incidents-llm-gpu.md).
## 2026-06-05 — Qdrant en crash-loop : segment funk-docs corrompu (RÉSOLU 2026-06-17) ## 2026-06-05 — Qdrant en crash-loop : segment funk-docs corrompu (RÉSOLU 2026-06-17)
### Symptômes ### Symptômes
@ -48,6 +51,7 @@ curl -s http://127.0.0.1:6333/collections # doit répondre
| Date | Composant | Résumé | Sévérité | | Date | Composant | Résumé | Sévérité |
|---|---|---|---| |---|---|---|---|
| 2026-06-19 | llama-server (gpu-01) | Inférence gelée (`/health` OK, génération bloquée) → 502 LLM — **résolu** par restart. Détail : [`incidents-llm-gpu.md`](incidents-llm-gpu.md) | Important |
| 2026-06-05 | Qdrant | **Résolu 2026-06-17** — crash-loop corrigé (drop `funk-docs`) ; RAG à re-ingérer | Important | | 2026-06-05 | Qdrant | **Résolu 2026-06-17** — crash-loop corrigé (drop `funk-docs`) ; RAG à re-ingérer | Important |
| 2026-05-29 | RAID5 / sdb + storage-01 | sdb ATA bus errors sous I/O — migration /srv/data du RAID vers NVMe | Important | | 2026-05-29 | RAID5 / sdb + storage-01 | sdb ATA bus errors sous I/O — migration /srv/data du RAID vers NVMe | Important |
| 2026-05-27 | RAID5 / sdd | Disque sdd défaillant (44 secteurs morts) — corruptions multiples en cascade | Critique | | 2026-05-27 | RAID5 / sdd | Disque sdd défaillant (44 secteurs morts) — corruptions multiples en cascade | Critique |

View file

@ -1,6 +1,6 @@
[project] [project]
name = "stt" name = "stt"
version = "0.4.2" version = "0.5.1"
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"

View file

@ -180,34 +180,49 @@ def _cmd_version() -> int:
def _cmd_update() -> int: def _cmd_update() -> int:
"""Met à jour STT depuis sa source git (pipx reinstall) — sans désinstall/réinstall manuel.""" """Met à jour STT depuis sa source git, sans désinstall/réinstall manuel.
import re
Utilise `pipx upgrade` (non destructif : conserve l'install actuelle si l'upgrade échoue),
contrairement à `pipx reinstall` qui désinstalle d'abord et peut laisser sans rien.
"""
import json
import shutil import shutil
import subprocess import subprocess
pipx = shutil.which("pipx") pipx = shutil.which("pipx")
if not pipx: if not pipx:
print("pipx introuvable — mise à jour automatique impossible.") print("pipx introuvable — mise à jour automatique impossible.")
print("Réinstalle manuellement depuis la source git.")
return 1 return 1
# Source d'installation (pour la commande de secours en cas d'échec)
spec = None
try:
listing = subprocess.run(
[pipx, "list", "--json"], capture_output=True, text=True
).stdout or "{}"
spec = json.loads(listing)["venvs"]["stt"]["metadata"]["main_package"]["package_or_url"]
except Exception: # noqa: BLE001
pass
before = _installed_version() before = _installed_version()
if before: if before:
print(f"Version installée : {before}") print(f"Version installée : {before}")
print("Récupération de la dernière version depuis git (pipx reinstall)…\n") print("Mise à jour depuis git (pipx upgrade)…\n")
res = subprocess.run([pipx, "reinstall", "stt"], capture_output=True, text=True) res = subprocess.run([pipx, "upgrade", "stt"], capture_output=True, text=True)
out = ((res.stdout or "") + (res.stderr or "")).strip() out = ((res.stdout or "") + (res.stderr or "")).strip()
if out: if out:
print(out) print(out)
m = re.search(r"installed package stt ([0-9][^\s,]*)", out)
after = m.group(1) if m else None
print() print()
if after and before and after != before: if res.returncode != 0 or shutil.which("stt") is None:
print(f"✓ Mis à jour : {before}{after}") print("❌ Échec de la mise à jour — ton install précédente est conservée.")
elif after: print(" Réessaie (souvent réseau/SSH), ou en dernier recours :")
print(f"✓ Déjà à jour ({after}).") print(" uv cache clean")
else: if spec:
print("✓ Réinstallation terminée — vérifie avec : stt --version") print(f' pipx install --force "{spec}"')
return res.returncode return res.returncode or 1
print("✓ Mise à jour terminée — vérifie avec : stt --version")
return 0
def _make_client(args): def _make_client(args):

View file

@ -14,6 +14,9 @@ renvoyer les réglages.
du personnage si aucune image (voir [`avatars/README.md`](avatars/README.md)). du personnage si aucune image (voir [`avatars/README.md`](avatars/README.md)).
- **Label d'état** : veille / écoute / réflexion / réponse. - **Label d'état** : veille / écoute / réflexion / réponse.
- **Transcript** : bulles utilisateur / assistant (+ tag du modèle) / erreur. - **Transcript** : bulles utilisateur / assistant (+ tag du modèle) / erreur.
- **Composer texte** (sous le transcript) : champ + bouton « Envoyer » pour
dialoguer **au clavier** sans micro — Entrée envoie, Maj+Entrée saute une ligne.
Le texte passe par le même cerveau que le vocal (modèle local / avancé).
- **Indicateur de connexion** websocket (point + libellé), reconnexion auto. - **Indicateur de connexion** websocket (point + libellé), reconnexion auto.
- **Drawer Réglages** (engrenage) : intensité de l'accent (thème), avatar, voix, - **Drawer Réglages** (engrenage) : intensité de l'accent (thème), avatar, voix,
mot de réveil, mode cerveau (local/avancé), no-think, reset conversation. mot de réveil, mode cerveau (local/avancé), no-think, reset conversation.
@ -29,14 +32,18 @@ Reçus du backend (diffusés à tous les HUD connectés) :
| `{"type":"assistant","text":…,"mode":…}` | bulle assistant (+ tag modèle) | | `{"type":"assistant","text":…,"mode":…}` | bulle assistant (+ tag modèle) |
| `{"type":"error","text":…}` | bulle d'erreur | | `{"type":"error","text":…}` | bulle d'erreur |
Envoyés au backend à chaque changement de réglage : Envoyés au backend :
```json ```json
{"type":"settings","data":{"brainMode":"local","wakeword":"…","action":"reset"}} {"type":"settings","data":{"brainMode":"local","wakeword":"…","action":"reset"}}
{"type":"text","text":"quelle heure est-il ?"}
``` ```
Le serveur applique `action:reset`, `brainMode` (→ modèle LiteLLM) et `wakeword` Le serveur applique `action:reset`, `brainMode` (→ modèle LiteLLM) et `wakeword`
**à chaud** (`_apply_settings` dans `stt/ui/app.py`). Les réglages purement **à chaud** (`_apply_settings` dans `stt/ui/app.py`). Un message `text` est traité
comme un tour de conversation (`VoiceEngine.respond_text`, hors boucle audio) : le
backend renvoie alors les événements `user` puis `assistant` — le HUD n'ajoute donc
pas la bulle utilisateur lui-même quand il est connecté. Les réglages purement
visuels (intensité d'accent, avatar) sont persistés côté navigateur (localStorage). visuels (intensité d'accent, avatar) sont persistés côté navigateur (localStorage).
## Prévisualiser sans backend ## Prévisualiser sans backend

View file

@ -341,6 +341,58 @@
max-width: 90%; max-width: 90%;
} }
/* ============================================================
COMPOSER — entrée texte (alternative au vocal)
============================================================ */
.composer {
display: flex;
gap: 10px;
align-items: flex-end;
width: 100%;
max-width: 860px;
margin: 8px auto 0;
padding: 0 26px 4px;
}
.composer textarea {
flex: 1 1 auto;
resize: none;
min-height: 46px;
max-height: 140px;
padding: 12px 16px;
border: 1px solid var(--line);
border-radius: 16px;
background: var(--bg);
color: var(--ink);
font: inherit;
font-size: 15px;
line-height: 1.4;
outline: none;
transition: border-color .2s var(--ease), box-shadow .2s var(--ease);
}
.composer textarea::placeholder { color: var(--ink-soft); }
.composer textarea:focus {
border-color: var(--accent-active);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-active) 22%, transparent);
}
.composer .send {
flex: 0 0 auto;
height: 46px;
padding: 0 18px;
border: none;
border-radius: 16px;
background: var(--accent-active);
color: #fff;
font: inherit;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
transition: filter .15s var(--ease), transform .1s var(--ease);
}
.composer .send:hover { filter: brightness(1.05); }
.composer .send:active { transform: translateY(1px); }
/* ============================================================ /* ============================================================
PANNEAU RÉGLAGES (drawer + voile) PANNEAU RÉGLAGES (drawer + voile)
============================================================ */ ============================================================ */
@ -621,6 +673,17 @@
<div class="transcript-wrap"> <div class="transcript-wrap">
<div class="transcript" id="transcript" aria-label="Conversation" role="log"></div> <div class="transcript" id="transcript" aria-label="Conversation" role="log"></div>
</div> </div>
<!-- Composer : entrée texte (alternative au vocal) -->
<form class="composer" id="composer" autocomplete="off">
<textarea id="textInput" rows="1" placeholder="Écris un message à Hermès… (Entrée pour envoyer, Maj+Entrée = nouvelle ligne)" aria-label="Message texte"></textarea>
<button type="submit" class="send" id="sendBtn" aria-label="Envoyer le message">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M22 2 11 13M22 2l-7 20-4-9-9-4 20-7z"></path>
</svg>
Envoyer
</button>
</form>
</main> </main>
<!-- ===================== RÉGLAGES (voile + drawer) ===================== --> <!-- ===================== RÉGLAGES (voile + drawer) ===================== -->
@ -880,6 +943,43 @@ function sendSettings() {
flashSaved(); flashSaved();
} }
/* ============================================================================
COMPOSER — entrée texte (envoie {"type":"text","text":…} au backend)
Le backend renvoie ensuite les événements user + assistant : pas d'écho local
quand on est connecté (sinon double bulle utilisateur).
============================================================================ */
const composer = document.getElementById('composer');
const textInput = document.getElementById('textInput');
function autosize(el) {
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 140) + 'px';
}
function sendText() {
const text = (textInput.value || '').trim();
if (!text) return;
const payload = { type: 'text', text };
if (DEMO || !ws || ws.readyState !== WebSocket.OPEN) {
console.log('[DÉMO] text →', payload);
addBubble('user', text); // écho local seulement hors-ligne / démo
} else {
ws.send(JSON.stringify(payload)); // connecté : le backend renvoie user + assistant
}
textInput.value = '';
autosize(textInput);
textInput.focus();
}
if (composer) {
composer.addEventListener('submit', (e) => { e.preventDefault(); sendText(); });
textInput.addEventListener('input', () => autosize(textInput));
textInput.addEventListener('keydown', (e) => {
// Entrée = envoyer ; Maj+Entrée = nouvelle ligne
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendText(); }
});
}
/* ============================================================================ /* ============================================================================
RÉGLAGES — collecte, persistance (thème en localStorage), UI RÉGLAGES — collecte, persistance (thème en localStorage), UI
============================================================================ */ ============================================================================ */

View file

@ -77,14 +77,23 @@ 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 renvoie {"type":"settings","data":{...}} quand on change un réglage. # Le HUD envoie {"type":"settings","data":{...}} (réglage) ou
# {"type":"text","text":"…"} (message tapé, alternative au vocal).
async for raw in ws: async for raw in ws:
try: try:
msg = json.loads(raw) msg = json.loads(raw)
except (ValueError, TypeError): except (ValueError, TypeError):
continue continue
if isinstance(msg, dict) and msg.get("type") == "settings": if not isinstance(msg, dict):
continue
mtype = msg.get("type")
if mtype == "settings":
_apply_settings(client, engine, msg.get("data") or {}, loop) _apply_settings(client, engine, msg.get("data") or {}, loop)
elif mtype == "text":
txt = (msg.get("text") or "").strip()
if txt and engine is not None:
# responder = appel HTTP bloquant → hors boucle asyncio
loop.run_in_executor(None, engine.respond_text, txt)
finally: finally:
clients.discard(ws) clients.discard(ws)
@ -130,13 +139,23 @@ def _open_browser(url: str, mode: str) -> None:
profile = Path.home() / ".local" / "share" / "stt" / "app-profile" profile = Path.home() / ".local" / "share" / "stt" / "app-profile"
profile.mkdir(parents=True, exist_ok=True) profile.mkdir(parents=True, exist_ok=True)
win = ["--kiosk", url] if mode == "kiosk" else [f"--app={url}"] win = ["--kiosk", url] if mode == "kiosk" else [f"--app={url}"]
# --log-level=3 : tait le bruit Chromium (viz/vaapi/RADV) à la source ;
# les flux sont en plus redirigés vers /dev/null (voir _spawn) pour ne pas
# polluer le terminal qui lance `stt`.
quiet = ["--log-level=3"]
# Instance autonome (profil + classe dédiés) — pour les navigateurs natifs. # Instance autonome (profil + classe dédiés) — pour les navigateurs natifs.
native = [ native = [
"--class=STT-Funk", f"--user-data-dir={profile}", "--class=STT-Funk", f"--user-data-dir={profile}",
"--no-first-run", "--no-default-browser-check", *win, "--no-first-run", "--no-default-browser-check", *quiet, *win,
] ]
# Flatpak est déjà sandboxé : pas de --user-data-dir (chemin hôte inaccessible). # Flatpak est déjà sandboxé : pas de --user-data-dir (chemin hôte inaccessible).
flatpak = ["--class=STT-Funk", *win] flatpak = ["--class=STT-Funk", *quiet, *win]
def _spawn(argv: list[str]) -> None:
# Détaché et muet : la sortie du navigateur ne remonte pas dans `stt`.
subprocess.Popen(
argv, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
# 1) Binaire natif (rpm/deb) # 1) Binaire natif (rpm/deb)
for browser in ( for browser in (
@ -146,7 +165,7 @@ def _open_browser(url: str, mode: str) -> None:
"microsoft-edge", "vivaldi", "microsoft-edge", "vivaldi",
): ):
if shutil.which(browser): if shutil.which(browser):
subprocess.Popen([browser, *native]) _spawn([browser, *native])
return return
# 2) Flatpak (universel — souvent la voie sur Fedora/AlmaLinux) # 2) Flatpak (universel — souvent la voie sur Fedora/AlmaLinux)
@ -157,7 +176,7 @@ def _open_browser(url: str, mode: str) -> None:
["flatpak", "info", appid], capture_output=True ["flatpak", "info", appid], capture_output=True
).returncode == 0 ).returncode == 0
if installed: if installed:
subprocess.Popen(["flatpak", "run", appid, *flatpak]) _spawn(["flatpak", "run", appid, *flatpak])
return return
# 3) Repli : onglet dans le navigateur par défaut # 3) Repli : onglet dans le navigateur par défaut

View file

@ -13,6 +13,7 @@ from __future__ import annotations
import queue import queue
import subprocess import subprocess
import tempfile import tempfile
import threading
import time import time
import unicodedata import unicodedata
from collections import deque from collections import deque
@ -64,6 +65,8 @@ class VoiceEngine:
self.model_label = model_label self.model_label = model_label
self._whisper = None self._whisper = None
self._stop = False self._stop = False
# sérialise un tour (voix ou texte) : pas deux réponses concurrentes
self._respond_lock = threading.Lock()
# ── chargement modèle ──────────────────────────────────────────────── # ── chargement modèle ────────────────────────────────────────────────
def load(self) -> None: def load(self) -> None:
@ -123,6 +126,7 @@ class VoiceEngine:
# ── 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)
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)
@ -140,6 +144,18 @@ class VoiceEngine:
self.emit({"type": "state", "state": "speaking"}) self.emit({"type": "state", "state": "speaking"})
self._speak(resp) self._speak(resp)
def respond_text(self, text: str) -> None:
"""Traite une entrée texte du HUD comme un tour de conversation (sans micro).
Appelé hors boucle audio (depuis le serveur websocket, via un executor) : le
moteur n'a pas besoin d'être chargé/démarré pour répondre à du texte.
"""
text = (text or "").strip()
if not text:
return
self._respond(text)
self.emit({"type": "state", "state": "idle"})
# ── boucle principale (VAD + wake word) ────────────────────────────── # ── boucle principale (VAD + wake word) ──────────────────────────────
def run(self) -> None: def run(self) -> None:
import numpy as np import numpy as np