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

@ -11,4 +11,5 @@
| [2026-05-14](progress/2026-05-14.md) | Monitoring complet + fix webhook AlertManager→Hermes (BrokenPipeError). RAG opérationnel : Qdrant 284 chunks, rag-ingest/rag-query, skill funk-ai. Souls révisés (4 profils). Docs : alertmanager-webhook, rag, hermes-souls | | [2026-05-14](progress/2026-05-14.md) | Monitoring complet + fix webhook AlertManager→Hermes (BrokenPipeError). RAG opérationnel : Qdrant 284 chunks, rag-ingest/rag-query, skill funk-ai. Souls révisés (4 profils). Docs : alertmanager-webhook, rag, hermes-souls |
| [2026-05-29](progress/2026-05-29.md) | Migration `/srv/data` RAID5 dégradé → NVMe (sdb ATA errors). Tous les services sur NVMe, bind-mount `/home/data` | | [2026-05-29](progress/2026-05-29.md) | Migration `/srv/data` RAID5 dégradé → NVMe (sdb ATA errors). Tous les services sur NVMe, bind-mount `/home/data` |
| [2026-06-18](progress/2026-06-18.md) | STT : HUD avancé (design Claude Design, #17), auto-start systemd, app de bureau (`.desktop` + mode fenêtre), auto-update (`stt --update`), compat AlmaLinux/Debian. Passe de cohérence doc (#15, #16) | | [2026-06-18](progress/2026-06-18.md) | STT : HUD avancé (design Claude Design, #17), auto-start systemd, app de bureau (`.desktop` + mode fenêtre), auto-update (`stt --update`), compat AlmaLinux/Debian. Passe de cohérence doc (#15, #16) |
| [2026-06-21](progress/2026-06-21.md) | STT : portail de services + santé live (#34-36), contextes (visualiseur + presets live, #38/#39), intents vocaux (#40), jeton Ghostfolio unifié (#41), **actions via Hermes** (`hermes -z --yolo`, rôle hermes_exec + contexte agent, #42/#43), ASR Parakeet activé | | [2026-06-21](progress/2026-06-21.md) | STT : portail de services + santé live (#34-36), contextes (visualiseur + presets live, #38/#39), intents vocaux (#40), jeton Ghostfolio unifié (#41), **actions via Hermes** (`hermes -z --yolo`, rôle hermes_exec + contexte agent, #42/#43), ASR Parakeet activé. **Soir** : relevé état réel + RAG re-ingéré 436 chunks + fix batch embed (#44) |
| [2026-06-22](progress/2026-06-22.md) | STT : **transcription partielle live** dans le HUD + **veille/réveil vocal** (« Asa » réveille / « Asa stop » endort, machine à états), wake word défaut `asa`, client 0.14.0 |

View file

@ -182,6 +182,28 @@ au prompt système (`STT_DISABLE_THINKING`, défaut `true` ; inoffensif hors Qwe
--- ---
## Interaction vocale — veille/réveil & transcription live (client ≥ 0.14.0)
Machine à états du moteur vocal (`stt/client/stt/voice/engine.py`) :
- **Veille (`asleep`)** — état par défaut. Asa transcrit chaque énoncé **uniquement** pour y
repérer le wake word ; elle ne répond à rien d'autre (HUD : portrait atténué, « SOMMEIL »).
- **Réveil** — dire **« Asa »** (mot entier) la passe en éveil. « Asa, <question> » exécute
directement la question. Variantes ASR tolérées via `[voice].wake_aliases`.
- **Éveil (`listening`)** — elle répond à tout. **« Asa stop »** (ou « stop », « au revoir »,
« dors »… — `[voice].sleep_words`) la **remet en veille** ; un timeout d'inactivité
(`chat_timeout_sec`) aussi.
- **Transcription partielle live** — en éveil, le buffer en cours est re-transcrit toutes les
`partial_interval_sec` et poussé au HUD (`partial`) → le texte se forme en direct, puis la bulle
finale le remplace. Auto-activée seulement avec l'ASR **onnx** (Parakeet, rapide en CPU) ;
forçable via `[voice].partial_transcription = true|false`. Whisper large-v3 CPU est trop lent.
> Le wake word par défaut est désormais **`asa`** (était `hermes`). Les configs existantes
> (`~/.config/stt/stt.toml`) gardent leur valeur — changer `[voice].wake_word` ou via le HUD
> (réglage « mot de réveil ») / la commande `/wake`.
---
## Personnalisation (« l'image, le design, la voix ») ## Personnalisation (« l'image, le design, la voix »)
Côté client, pilotable depuis `stt/client/config/` + l'écran de réglages du HUD : Côté client, pilotable depuis `stt/client/config/` + l'écran de réglages du HUD :

50
progress/2026-06-22.md Normal file
View file

@ -0,0 +1,50 @@
# 2026-06-22 — STT : transcription live + veille/réveil vocal ; relevé cluster (la veille)
## STT — streaming partiel & contrôle vocal veille/réveil (client 0.13.0 → 0.14.0)
Deux fonctionnalités demandées sur l'assistant vocal Asa.
### Transcription partielle live dans le HUD
En éveil, pendant qu'on parle, le buffer audio en cours est **re-transcrit périodiquement**
(`partial_interval_sec`, défaut 0,5 s) et poussé au HUD via un événement `partial` → le texte
**se forme en direct** dans une bulle dédiée (italique + curseur clignotant), puis la bulle
« user » finale la remplace.
- Auto-activée seulement avec l'ASR **onnx** (Parakeet ~11× temps réel en CPU) ; whisper large-v3
CPU est trop lent → laissé off (forçable via `[voice].partial_transcription`).
- Bornée : `partial_min_sec` (anti-bruit) … `partial_max_sec` (coût ; au-delà, seul le final suit).
- Transcription inline dans la boucle audio (thread unique → pas d'accès concurrent au modèle ASR).
### Veille / réveil vocal (« Asa stop » / « Asa »)
Refonte de la boucle en **machine à états** explicite veille (`asleep`) ↔ éveil :
- **Veille** : Asa transcrit chaque énoncé **uniquement** pour repérer le wake word ; ne répond à
rien d'autre. HUD : portrait atténué/désaturé, label « SOMMEIL ».
- **« Asa »** (mot entier, alias ASR tolérés) → réveil. « Asa, <question> » exécute la question.
- **« Asa stop »** (ou `stop`/`dors`/`au revoir`… = `sleep_words`) → retour veille (+ « Je me mets
en veille »). Timeout d'inactivité aussi.
- Wake word par défaut **`asa`** (était `hermes`) ; `wake_word`/`wake_aliases`/`sleep_words` config.
- Détection wake durcie : **mot entier** (plus de sous-chaîne — « asa » est court et matchait
« casa », « phrase »…).
Validé : tests unitaires (wake/sleep/alias) + Playwright (rendu `asleep`, bulle partielle mise à
jour en place puis remplacée par le final, nettoyage du partiel au changement d'état).
Bump client **0.14.0**. Doc : `admin/ia/stt.md` (section « Interaction vocale »).
---
## (Rappel) Relevé d'état réel + RAG re-ingéré — fait la veille (2026-06-21, PR #44)
Pour mémoire, cette même journée de travail a aussi produit (PR #44, mergée) : alignement de la
doc sur l'état réel du cluster (`admin/ops/etat-cluster.md`), re-ingestion du RAG **436/436 chunks**
et le **fix du batch embed** (`llama-embed` ubatch 512 → 2048). Détail : `progress/2026-06-21.md`.
---
## Prochaines étapes
- Tester sur le poste : « Asa » réveille, « Asa stop » endort, partiels en direct (ASR onnx actif).
- Si le wake word « asa » génère des faux positifs/négatifs, ajuster `wake_aliases`.
- `make apply-gpu` (réconcilier le drop-in `llama-embed` du 21/06) + 1er test d'action vocale (#43).

View file

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

View file

@ -38,7 +38,12 @@ DEFAULT_CONFIG: dict[str, Any] = {
"model": "hermes-default", "model": "hermes-default",
}, },
"voice": { "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 : "whisper" (faster-whisper CPU, défaut) | "onnx" (Parakeet/Canary/Nemotron)
"asr_engine": "whisper", "asr_engine": "whisper",
"whisper_model": "large-v3", "whisper_model": "large-v3",
@ -51,6 +56,13 @@ DEFAULT_CONFIG: dict[str, Any] = {
"silence_sec": 1.2, "silence_sec": 1.2,
"min_speech_sec": 0.6, "min_speech_sec": 0.6,
"chat_timeout_sec": 60, "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": { "ui": {
# theme / avatar : désormais pilotés dans le HUD (Réglages → localStorage), # 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] ---- */ /* ---- É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 */ /* idle : respiration douce, anneau au repos */
.stage[data-state="idle"] .portrait { animation: breathe 6s ease-in-out infinite; } .stage[data-state="idle"] .portrait { animation: breathe 6s ease-in-out infinite; }
@keyframes breathe { @keyframes breathe {
@ -917,7 +928,7 @@
<!-- Composer : entrée texte (alternative au vocal) --> <!-- Composer : entrée texte (alternative au vocal) -->
<form class="composer" id="composer" autocomplete="off"> <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"> <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"> <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> <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 */ /* Libellés d'état FR */
const STATE_LABELS = { const STATE_LABELS = {
asleep: 'sommeil', // veille profonde : n'écoute que le wake word « Asa »
idle: 'veille', idle: 'veille',
listening: 'écoute', listening: 'écoute',
thinking: 'réflexion', thinking: 'réflexion',
speaking: 'réponse', 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 = { 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) // auto-scroll vers le bas (sans scrollIntoView)
transcript.scrollTop = transcript.scrollHeight; 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é) TRAITEMENT DES ÉVÉNEMENTS ENTRANTS (format imposé)
@ -1244,8 +1273,9 @@ function clearTranscript() { transcript.innerHTML = ''; }
function handleEvent(ev) { function handleEvent(ev) {
if (!ev || !ev.type) return; if (!ev || !ev.type) return;
switch (ev.type) { switch (ev.type) {
case 'state': setState(ev.state); break; case 'state': setState(ev.state); if (ev.state !== 'listening') clearPartial(); break;
case 'user': addBubble('user', ev.text || ''); 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 '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 case 'mic': setMicUI(!!ev.muted); break; // état micro renvoyé par le backend

View file

@ -1,8 +1,14 @@
"""Moteur vocal STT. """Moteur vocal STT.
Refactor de tools/hermes-voice.py en classe avec callbacks d'état (`emit`), pour que 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 : le serveur websocket puisse pousser l'état au HUD. Machine à états :
veille wake word "Ok Hermès" conversation (parole libre) retour veille. 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 : Dépendances lourdes (sounddevice, faster_whisper, webrtcvad) importées à la volée :
`stt --help` et la config fonctionnent sans micro ni modèle installé. `stt --help` et la config fonctionnent sans micro ni modèle installé.
@ -24,7 +30,14 @@ from .asr import make_backend
SAMPLE_RATE = 16000 SAMPLE_RATE = 16000
PRE_ROLL = 15 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_BIN = Path.home() / ".local/share/piper-runtime/piper"
PIPER_VOICE_DIR = Path.home() / ".local/share/piper" PIPER_VOICE_DIR = Path.home() / ".local/share/piper"
@ -96,15 +109,28 @@ class VoiceEngine:
def _transcribe(self, audio) -> str: def _transcribe(self, audio) -> str:
return self._asr.transcribe(audio) 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]: 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() words = text.strip().split()
low = [_deaccent(w).strip(",.!?«»") for w in words] low = [_deaccent(w).strip(",.!?«»…:;\"'()") for w in words]
try: for i, w in enumerate(low):
idx = next(i for i, w in enumerate(low) if wake in w) if w in aliases:
return True, " ".join(words[idx + 1:]).strip() return True, " ".join(words[i + 1:]).strip()
except StopIteration: return False, ""
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 ────────────────────────────────────────────────────────────── # ── TTS ──────────────────────────────────────────────────────────────
def _speak(self, text: str) -> None: def _speak(self, text: str) -> None:
@ -209,7 +235,7 @@ class VoiceEngine:
self._respond(text) self._respond(text)
self.emit({"type": "state", "state": "idle"}) self.emit({"type": "state", "state": "idle"})
# ── boucle principale (VAD + wake word) ────────────────────────────── # ── boucle principale (VAD + machine à états veille/éveil) ───────────
def run(self) -> None: def run(self) -> None:
import numpy as np import numpy as np
import sounddevice as sd import sounddevice as sd
@ -223,17 +249,32 @@ class VoiceEngine:
chat_timeout = self.cfg.get("chat_timeout_sec", 60) chat_timeout = self.cfg.get("chat_timeout_sec", 60)
required_silent = int(silence_sec * 1000 / frame_ms) 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() audio_q: queue.Queue = queue.Queue()
pre_roll: deque = deque(maxlen=PRE_ROLL) 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 last_activity = 0.0
def callback(indata, frames, time_info, status): def callback(indata, frames, time_info, status):
chunk = (indata[:, 0] if indata.ndim > 1 else indata.flatten()).copy() chunk = (indata[:, 0] if indata.ndim > 1 else indata.flatten()).copy()
audio_q.put(chunk) audio_q.put(chunk)
def record_until_silence() -> "np.ndarray | None": def record_until_silence(prefix, emit_partial: bool) -> "np.ndarray | None":
captured, silent = [], 0 """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: while not self._stop:
try: try:
frame = audio_q.get(timeout=3.0) frame = audio_q.get(timeout=3.0)
@ -245,25 +286,40 @@ class VoiceEngine:
except Exception: except Exception:
speech = True speech = True
silent = 0 if speech else silent + 1 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: if silent >= required_silent:
break break
if not captured: if len(captured) <= len(prefix):
return None return None
audio = np.concatenate(captured) audio = np.concatenate(captured)
return audio if len(audio) / SAMPLE_RATE >= min_speech else None 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( with sd.InputStream(
samplerate=SAMPLE_RATE, channels=1, dtype="int16", samplerate=SAMPLE_RATE, channels=1, dtype="int16",
blocksize=frame_samples, callback=callback, blocksize=frame_samples, callback=callback,
): ):
while not self._stop: while not self._stop:
if chat_mode and time.time() - last_activity > chat_timeout: # timeout d'inactivité en éveil → retour veille
chat_mode = False if not asleep and time.time() - last_activity > chat_timeout:
self.emit({"type": "state", "state": "idle"}) go_asleep()
frame = audio_q.get() 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 continue
pre_roll.append(frame) pre_roll.append(frame)
try: try:
@ -272,37 +328,39 @@ class VoiceEngine:
except Exception: except Exception:
continue continue
self.emit({"type": "state", "state": "listening"}) # parole détectée. En veille on enregistre en silence (juste pour
captured = list(pre_roll) # repérer le wake word) ; en éveil on montre l'écoute + les partiels.
tail = record_until_silence() if not asleep:
if tail is None: self.emit({"type": "state", "state": "listening"})
self.emit({"type": "state", "state": "idle" if not chat_mode else "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 continue
audio = np.concatenate([np.concatenate(captured), tail])
text = self._transcribe(audio) text = self._transcribe(audio)
if not text: if not text:
self.emit({"type": "state", "state": "asleep" if asleep else "listening"})
continue continue
if chat_mode: if asleep:
if any(w in _deaccent(text) for w in CHAT_EXIT): found, command = self._detect_wake(text)
chat_mode = False 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.emit({"type": "user", "text": text})
self._speak("À bientôt !") self._speak("Je me mets en veille.")
self.emit({"type": "state", "state": "idle"}) go_asleep()
continue continue
last_activity = time.time() last_activity = time.time()
self._respond(text) self._respond(text)
self.emit({"type": "state", "state": "listening"}) 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: def stop(self) -> None:
self._stop = True self._stop = True