mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-08 04:54:43 +02:00
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:
parent
af98b47018
commit
d83091a350
7 changed files with 223 additions and 50 deletions
|
|
@ -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-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-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 |
|
||||
|
|
|
|||
|
|
@ -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 »)
|
||||
|
||||
Côté client, pilotable depuis `stt/client/config/` + l'écran de réglages du HUD :
|
||||
|
|
|
|||
50
progress/2026-06-22.md
Normal file
50
progress/2026-06-22.md
Normal 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).
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,16 +109,29 @@ 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:
|
||||
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:
|
||||
if self.no_tts or self._interrupt.is_set():
|
||||
|
|
@ -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
|
||||
|
||||
# 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"})
|
||||
captured = list(pre_roll)
|
||||
tail = record_until_silence()
|
||||
if tail is None:
|
||||
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
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue