feat(stt): transcription partielle live + veille/réveil vocal (« Asa » / « Asa stop ») (#45)

* fix(rag): ubatch=2048 pour llama-embed + retry rag-ingest + nettoie defaults rag

L'instance llama-embed (:1238) tournait avec l'ubatch llama.cpp par défaut (512).
En mode --embeddings + pooling, toute l'entrée doit tenir dans un seul ubatch →
tout chunk > 512 tokens échouait en HTTP 500 « input too large to process.
increase the physical batch size ». ~46 chunks du RAG (CHUNK_MAX=2000 chars ≈
700 tokens) n'étaient pas indexés.

- llama_server : ajoute llama_embed_ubatch_size (2048 = ctx) + flags
  --batch-size/--ubatch-size dans le template llama-embed.service.
- rag-ingest : retry (5×, backoff) sur erreurs transitoires 5xx/réseau de l'embed.
- rag/defaults : aligne embed_url/embed_model sur nomic :1238 (étaient des vars
  mortes pointant encore qwen3-8b :1234 — trompeuses).

Résultat : ré-ingestion 436/436 chunks, 0 erreur.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(cluster): aligne la doc sur l'état réel (relevé 2026-06-21) + snapshot

Passe de vérification terrain (SSH s01/g01 + kubectl) pour corriger les écarts
doc↔réalité, et ajout d'un document de référence vérifié.

- admin/ops/etat-cluster.md (nouveau) : snapshot daté — machines, services+ports
  par hôte, workloads k8s, namespaces, IngressRoute, bases PG, modèles LiteLLM,
  RAG/Qdrant, + procédure pour régénérer le relevé.
- OS AlmaLinux 9.7 → 9.8 (s01/g01) ; précise k8s v1.33.1 (CLAUDE/README/install/talos).
- RAG funk-docs : retire le warning périmé « supprimée 2026-06-17 / à re-ingérer » →
  reconstruite (436 chunks, nomic-embed-text :1238 dim 768, CPU). Maj rag.md,
  incidents.md, stt.md.
- Namespace sacrifice (hors-repo, hors ArgoCD) + bases PG grafana/sacrifice +
  services ai via IngressRoute + registry in-cluster (CLAUDE.md, k9s.md).
- Services de résilience (llm-heartbeat, llama-watchdog/embed) et instances CPU
  manuelles :1236/:1237 (souvent éteintes) documentés.
- Journal progress/2026-06-21.md : section relevé + RAG.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(stt): transcription partielle live + veille/réveil vocal (« Asa » / « Asa stop »)

Deux fonctionnalités sur l'assistant vocal Asa (client 0.13.0 → 0.14.0).

Transcription partielle live :
- en éveil, le buffer en cours est re-transcrit périodiquement (partial_interval_sec) et
  poussé au HUD (event `partial`) → le texte se forme en direct dans une bulle dédiée,
  remplacée par la bulle « user » finale.
- auto-activée seulement avec l'ASR onnx (Parakeet, rapide CPU) ; bornée min/max ;
  forçable via [voice].partial_transcription.

Veille / réveil vocal (machine à états asleep ↔ éveil) :
- veille : Asa transcrit chaque énoncé UNIQUEMENT pour repérer le wake word, ne répond à
  rien d'autre (HUD : portrait atténué, « SOMMEIL »).
- « Asa » (mot entier, alias ASR tolérés) réveille ; « Asa, <question> » exécute la question.
- « Asa stop » (ou stop/dors/au revoir… = sleep_words) remet en veille ; timeout aussi.
- wake word par défaut `asa` (était `hermes`) ; détection durcie en mot entier (plus de
  sous-chaîne — « asa » matchait « casa », « phrase »…).

Validé : tests unitaires (wake/sleep/alias) + Playwright (rendu asleep, bulle partielle mise
à jour en place puis remplacée, nettoyage au changement d'état). Doc admin/ia/stt.md + journal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ALI YESILKAYA 2026-06-22 00:27:53 +02:00 committed by GitHub
parent af98b47018
commit d83091a350
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 223 additions and 50 deletions

View file

@ -1,6 +1,6 @@
[project]
name = "stt"
version = "0.13.0"
version = "0.14.0"
description = "STT — client vocal Jarvis du homelab Funk (voix + HUD, parle au STT-server)"
requires-python = ">=3.11"
readme = "README.md"

View file

@ -38,7 +38,12 @@ DEFAULT_CONFIG: dict[str, Any] = {
"model": "hermes-default",
},
"voice": {
"wake_word": "hermes",
# Wake word principal : « Asa ». Dire « Asa » la réveille ; « Asa stop » la rendort.
"wake_word": "asa",
# Variantes tolérées (l'ASR FR rend « Asa » diversement). Mots ENTIERS, déaccentués.
"wake_aliases": ["asa", "aza", "assa", "aca", "haza", "hassa"],
# Mots/phrases qui remettent Asa en veille quand elle est éveillée (sous-chaîne).
"sleep_words": ["stop", "dors", "au revoir", "bonne nuit", "fin", "silence", "tais-toi"],
# ASR : "whisper" (faster-whisper CPU, défaut) | "onnx" (Parakeet/Canary/Nemotron)
"asr_engine": "whisper",
"whisper_model": "large-v3",
@ -51,6 +56,13 @@ DEFAULT_CONFIG: dict[str, Any] = {
"silence_sec": 1.2,
"min_speech_sec": 0.6,
"chat_timeout_sec": 60,
# Transcription partielle live (texte qui se forme dans le HUD pendant qu'on parle).
# Clé `partial_transcription` volontairement ABSENTE → auto : activée seulement avec
# l'ASR "onnx" (Parakeet, assez rapide en CPU). Ajouter `partial_transcription = true`
# (ou false) dans stt.toml pour forcer. Whisper large-v3 CPU est trop lent → laisser auto.
"partial_interval_sec": 0.5, # cadence de re-transcription du buffer
"partial_min_sec": 0.4, # ne transcrit pas en dessous (bruit)
"partial_max_sec": 8.0, # au-delà, on arrête les partiels (coût) — le final suit
},
"ui": {
# theme / avatar : désormais pilotés dans le HUD (Réglages → localStorage),

View file

@ -261,6 +261,17 @@
/* ---- États appliqués sur .stage[data-state] ---- */
/* asleep : veille profonde — portrait atténué/désaturé, anneau effacé. N'écoute
que le wake word « Asa » (dire « Asa » la réveille). */
.stage[data-state="asleep"] .portrait { filter: saturate(.45) brightness(.7); opacity: .72; animation: breathe 7s ease-in-out infinite; }
.stage[data-state="asleep"] .ring circle { stroke: var(--ring-rest); stroke-width: 2; opacity: .5; }
.stage[data-state="asleep"] .state-label { color: var(--ink-soft); opacity: .7; }
/* bulle de transcription partielle (texte en cours de formation) */
.bubble.partial { opacity: .62; font-style: italic; }
.bubble.partial::after { content: '▋'; margin-left: 2px; animation: caret 1s step-end infinite; }
@keyframes caret { 50% { opacity: 0; } }
/* idle : respiration douce, anneau au repos */
.stage[data-state="idle"] .portrait { animation: breathe 6s ease-in-out infinite; }
@keyframes breathe {
@ -917,7 +928,7 @@
<!-- 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>
<textarea id="textInput" rows="1" placeholder="Écris un message à Asa… (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>
@ -1162,14 +1173,15 @@ const imgs = {
/* Libellés d'état FR */
const STATE_LABELS = {
asleep: 'sommeil', // veille profonde : n'écoute que le wake word « Asa »
idle: 'veille',
listening: 'écoute',
thinking: 'réflexion',
speaking: 'réponse',
};
/* Quelle image montrer selon l'état (idle + listening → idle) */
/* Quelle image montrer selon l'état (asleep/idle/listening → idle) */
const STATE_IMG = {
idle: 'idle', listening: 'idle', thinking: 'think', speaking: 'speak',
asleep: 'idle', idle: 'idle', listening: 'idle', thinking: 'think', speaking: 'speak',
};
/* ============================================================================
@ -1236,7 +1248,24 @@ function addBubble(kind, text, modelTag) {
// auto-scroll vers le bas (sans scrollIntoView)
transcript.scrollTop = transcript.scrollHeight;
}
function clearTranscript() { transcript.innerHTML = ''; }
function clearTranscript() { transcript.innerHTML = ''; partialBubble = null; }
/* Bulle de transcription partielle live : une seule, mise à jour en place.
Texte vide → on l'enlève. La bulle « user » finale (case 'user') la remplace. */
let partialBubble = null;
function setPartial(text) {
if (!text) { clearPartial(); return; }
if (!partialBubble) {
partialBubble = document.createElement('div');
partialBubble.className = 'bubble user partial';
transcript.appendChild(partialBubble);
}
partialBubble.textContent = text;
transcript.scrollTop = transcript.scrollHeight;
}
function clearPartial() {
if (partialBubble) { partialBubble.remove(); partialBubble = null; }
}
/* ============================================================================
TRAITEMENT DES ÉVÉNEMENTS ENTRANTS (format imposé)
@ -1244,8 +1273,9 @@ function clearTranscript() { transcript.innerHTML = ''; }
function handleEvent(ev) {
if (!ev || !ev.type) return;
switch (ev.type) {
case 'state': setState(ev.state); break;
case 'user': addBubble('user', ev.text || ''); break;
case 'state': setState(ev.state); if (ev.state !== 'listening') clearPartial(); break;
case 'partial': setPartial(ev.text || ''); break; // transcription en cours
case 'user': clearPartial(); 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

View file

@ -1,8 +1,14 @@
"""Moteur vocal STT.
Refactor de tools/hermes-voice.py en classe avec callbacks d'état (`emit`), pour que
le serveur websocket puisse pousser l'état au HUD. Boucle :
veille wake word "Ok Hermès" conversation (parole libre) retour veille.
le serveur websocket puisse pousser l'état au HUD. Machine à états :
veille (`asleep`) wake word « Asa » éveil (`awake`, parole libre) « Asa stop » veille.
En veille, Asa transcrit chaque énoncé UNIQUEMENT pour y détecter le wake word (elle
« ne répond plus ») ; en éveil, elle répond à tout jusqu'à un mot de veille ou le timeout.
Transcription partielle : en éveil, le buffer en cours est re-transcrit périodiquement
et poussé au HUD (`partial`) l'utilisateur voit le texte se former en direct.
Dépendances lourdes (sounddevice, faster_whisper, webrtcvad) importées à la volée :
`stt --help` et la config fonctionnent sans micro ni modèle installé.
@ -24,7 +30,14 @@ from .asr import make_backend
SAMPLE_RATE = 16000
PRE_ROLL = 15
CHAT_EXIT = {"au revoir", "bonne nuit", "stop hermes", "fin"}
# Mots de mise en veille (« Asa stop ») — déaccentués, cherchés en sous-chaîne quand
# Asa est éveillée. Surchargeable via [voice].sleep_words.
DEFAULT_SLEEP_WORDS = (
"stop", "dors", "au revoir", "bonne nuit", "fin", "silence", "tais-toi", "chut",
)
# Variantes du wake word tolérées (l'ASR FR rend « Asa » diversement). Surchargeable
# via [voice].wake_aliases ; le wake word principal ([voice].wake_word) s'y ajoute.
DEFAULT_WAKE_ALIASES = ("asa", "aza", "assa", "aca", "aza", "haza", "hassa")
PIPER_BIN = Path.home() / ".local/share/piper-runtime/piper"
PIPER_VOICE_DIR = Path.home() / ".local/share/piper"
@ -96,15 +109,28 @@ class VoiceEngine:
def _transcribe(self, audio) -> str:
return self._asr.transcribe(audio)
def _wake_aliases(self) -> set[str]:
"""Ensemble des formes déaccentuées acceptées pour réveiller Asa."""
base = [self.cfg.get("wake_word", "asa")]
extra = self.cfg.get("wake_aliases") or DEFAULT_WAKE_ALIASES
return {_deaccent(w).strip() for w in (*base, *extra) if w}
def _detect_wake(self, text: str) -> tuple[bool, str]:
wake = self.cfg.get("wake_word", "hermes")
"""Cherche le wake word comme **mot entier** (pas sous-chaîne : « asa » est court
et matcherait « phrase », « casa »). Renvoie (trouvé, reste de la phrase)."""
aliases = self._wake_aliases()
words = text.strip().split()
low = [_deaccent(w).strip(",.!?«»") for w in words]
try:
idx = next(i for i, w in enumerate(low) if wake in w)
return True, " ".join(words[idx + 1:]).strip()
except StopIteration:
return False, ""
low = [_deaccent(w).strip(",.!?«»…:;\"'()") for w in words]
for i, w in enumerate(low):
if w in aliases:
return True, " ".join(words[i + 1:]).strip()
return False, ""
def _is_sleep_command(self, text: str) -> bool:
"""Vrai si l'énoncé (éveillé) demande la mise en veille (« Asa stop », « stop »…)."""
low = _deaccent(text)
words = self.cfg.get("sleep_words") or DEFAULT_SLEEP_WORDS
return any(w in low for w in words)
# ── TTS ──────────────────────────────────────────────────────────────
def _speak(self, text: str) -> None:
@ -209,7 +235,7 @@ class VoiceEngine:
self._respond(text)
self.emit({"type": "state", "state": "idle"})
# ── boucle principale (VAD + wake word) ──────────────────────────────
# ── boucle principale (VAD + machine à états veille/éveil) ───────────
def run(self) -> None:
import numpy as np
import sounddevice as sd
@ -223,17 +249,32 @@ class VoiceEngine:
chat_timeout = self.cfg.get("chat_timeout_sec", 60)
required_silent = int(silence_sec * 1000 / frame_ms)
# Transcription partielle : par défaut active seulement avec l'ASR onnx
# (Parakeet ~11× temps réel) ; whisper large-v3 CPU est trop lent pour du live.
engine_name = (self.cfg.get("asr_engine") or "whisper").lower()
partial_enabled = bool(
self.cfg.get("partial_transcription", engine_name in ("onnx", "parakeet"))
)
partial_interval = float(self.cfg.get("partial_interval_sec", 0.5))
partial_min = float(self.cfg.get("partial_min_sec", 0.4))
partial_max = float(self.cfg.get("partial_max_sec", 8.0))
audio_q: queue.Queue = queue.Queue()
pre_roll: deque = deque(maxlen=PRE_ROLL)
chat_mode = False
asleep = True # démarre en veille : attend le wake word « Asa »
last_activity = 0.0
def callback(indata, frames, time_info, status):
chunk = (indata[:, 0] if indata.ndim > 1 else indata.flatten()).copy()
audio_q.put(chunk)
def record_until_silence() -> "np.ndarray | None":
captured, silent = [], 0
def record_until_silence(prefix, emit_partial: bool) -> "np.ndarray | None":
"""Capture jusqu'au silence en partant de `prefix` (pré-roll). Renvoie l'audio
complet (pré-roll + énoncé) ou None si trop court. Si `emit_partial`, re-transcrit
le buffer périodiquement et pousse le texte partiel au HUD."""
captured = list(prefix)
silent = 0
last_partial = time.time()
while not self._stop:
try:
frame = audio_q.get(timeout=3.0)
@ -245,25 +286,40 @@ class VoiceEngine:
except Exception:
speech = True
silent = 0 if speech else silent + 1
if (emit_partial and partial_enabled
and time.time() - last_partial >= partial_interval):
dur = sum(len(c) for c in captured) / SAMPLE_RATE
if partial_min <= dur <= partial_max:
snap = np.concatenate(captured)
txt = self._transcribe(snap)
if txt:
self.emit({"type": "partial", "text": txt})
last_partial = time.time()
if silent >= required_silent:
break
if not captured:
if len(captured) <= len(prefix):
return None
audio = np.concatenate(captured)
return audio if len(audio) / SAMPLE_RATE >= min_speech else None
self.emit({"type": "state", "state": "idle"})
def go_asleep():
nonlocal asleep
asleep = True
self.emit({"type": "partial", "text": ""}) # efface un partiel résiduel
self.emit({"type": "state", "state": "asleep"})
go_asleep()
with sd.InputStream(
samplerate=SAMPLE_RATE, channels=1, dtype="int16",
blocksize=frame_samples, callback=callback,
):
while not self._stop:
if chat_mode and time.time() - last_activity > chat_timeout:
chat_mode = False
self.emit({"type": "state", "state": "idle"})
# timeout d'inactivité en éveil → retour veille
if not asleep and time.time() - last_activity > chat_timeout:
go_asleep()
frame = audio_q.get()
if self._muted: # micro coupé : on vide la trame et on ignore
if self._muted: # micro coupé (bouton HUD) : on ignore
continue
pre_roll.append(frame)
try:
@ -272,37 +328,39 @@ class VoiceEngine:
except Exception:
continue
self.emit({"type": "state", "state": "listening"})
captured = list(pre_roll)
tail = record_until_silence()
if tail is None:
self.emit({"type": "state", "state": "idle" if not chat_mode else "listening"})
# parole détectée. En veille on enregistre en silence (juste pour
# repérer le wake word) ; en éveil on montre l'écoute + les partiels.
if not asleep:
self.emit({"type": "state", "state": "listening"})
audio = record_until_silence(list(pre_roll), emit_partial=not asleep)
if audio is None:
self.emit({"type": "state", "state": "asleep" if asleep else "listening"})
continue
audio = np.concatenate([np.concatenate(captured), tail])
text = self._transcribe(audio)
if not text:
self.emit({"type": "state", "state": "asleep" if asleep else "listening"})
continue
if chat_mode:
if any(w in _deaccent(text) for w in CHAT_EXIT):
chat_mode = False
if asleep:
found, command = self._detect_wake(text)
if not found:
self.emit({"type": "state", "state": "asleep"})
continue
asleep = False
last_activity = time.time()
self.emit({"type": "state", "state": "listening"})
if command: # « Asa, quelle heure est-il ? »
self._respond(command)
self.emit({"type": "state", "state": "listening"})
else:
if self._is_sleep_command(text): # « Asa stop »
self.emit({"type": "user", "text": text})
self._speak("À bientôt !")
self.emit({"type": "state", "state": "idle"})
self._speak("Je me mets en veille.")
go_asleep()
continue
last_activity = time.time()
self._respond(text)
self.emit({"type": "state", "state": "listening"})
else:
found, command = self._detect_wake(text)
if not found:
self.emit({"type": "state", "state": "idle"})
continue
chat_mode = True
last_activity = time.time()
if command:
self._respond(command)
self.emit({"type": "state", "state": "listening"})
def stop(self) -> None:
self._stop = True