From 1e01caf634d5c5032c0153842e4de7bc82804141 Mon Sep 17 00:00:00 2001 From: ALI YESILKAYA Date: Mon, 22 Jun 2026 00:54:46 +0200 Subject: [PATCH] =?UTF-8?q?fix(stt):=20d=C3=A9tecte=20=C2=AB=20Asa=20?= =?UTF-8?q?=C2=BB=20m=C3=AAme=20d=C3=A9coup=C3=A9=20par=20l'ASR=20(=C2=AB?= =?UTF-8?q?=20a=20so=20=C2=BB/=C2=AB=20=C3=A0=20=C3=A7a=20=C2=BB)=20+=20lo?= =?UTF-8?q?g=20diagnostic=20(#46)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test sur poste après #45 : Parakeet FR ne transcrit pas « Asa » comme un mot mais comme « a so » / « à ça » / « ah ça » (deux tokens) → la détection mot-entier ne matchait jamais (« quand je dis Asa, rien ne se passe »). - _detect_wake : glisse une fenêtre de 1-2 mots, joint (sans espace) + déaccentue, compare en égalité exacte à des alias COMPACTS (asa, aso, aca, ahca…). L'exact évite les faux positifs (« à cause » → « acause » ≠ alias). Extrait la commande après le wake. - wake_aliases (engine + config) : formes compactes reflétant les rendus ASR réels. - Log diagnostic [stt.voice] sur stderr/journalctl (transcription + décision), coupable via STT_VOICE_LOG=0 — précieux pour ajuster les alias selon l'ASR. - .gitignore : build/ + *.egg-info (artefacts d'install locale du client). Validé en live (poste) : « Asa » réveille, « Asa stop » endort, partiels en direct. Co-authored-by: Claude Opus 4.8 --- .gitignore | 3 +++ admin/ia/stt.md | 8 ++++-- progress/2026-06-22.md | 8 ++++++ stt/client/stt/config.py | 7 ++++-- stt/client/stt/voice/engine.py | 45 +++++++++++++++++++++++++++------- 5 files changed, 58 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index 6ea07e8..8edf74f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ .venv/ __pycache__/ *.pyc +# artefacts de build Python (générés par pip/pipx install — ex. install locale du client stt) +build/ +*.egg-info/ # Ansible .vault_pass diff --git a/admin/ia/stt.md b/admin/ia/stt.md index bf8ef8b..d03f123 100644 --- a/admin/ia/stt.md +++ b/admin/ia/stt.md @@ -188,8 +188,12 @@ 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, » exécute - directement la question. Variantes ASR tolérées via `[voice].wake_aliases`. +- **Réveil** — dire **« Asa »** la passe en éveil. « Asa, » exécute directement la + question. ⚠️ L'ASR (Parakeet FR) ne transcrit **pas** « Asa » comme un mot : il l'entend + « a so » / « à ça » / « ah ça »… La détection **joint les 1-2 premiers mots** (déaccentués, + sans espace) et compare en égalité exacte à `[voice].wake_aliases` (formes compactes : + `asa`, `aso`, `aca`, `ahca`…). Ajuster cette liste si l'ASR rend encore autrement — le log + `[stt.voice] (veille) entendu: '…'` (journalctl, cf. `STT_VOICE_LOG`) montre la transcription réelle. - **É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. diff --git a/progress/2026-06-22.md b/progress/2026-06-22.md index fbc394f..d107406 100644 --- a/progress/2026-06-22.md +++ b/progress/2026-06-22.md @@ -33,6 +33,14 @@ Validé : tests unitaires (wake/sleep/alias) + Playwright (rendu `asleep`, bulle 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 »). +> 🐛 **Trouvaille ASR (test sur poste)** : Parakeet FR ne transcrit **pas** « Asa » comme un mot +> — il l'entend **« a so » / « à ça » / « ah ça »** (deux tokens). La détection mot-entier ne +> matchait donc jamais → « rien ne se passe ». **Corrigé** : on **joint les 1-2 premiers mots** +> (déaccentués, sans espace) et on compare en égalité exacte à des alias compacts (`aso`, `aca`, +> `ahca`…). Faux positifs évités (« à cause » → `acause` ≠ alias). Ajout d'un log diagnostic +> `[stt.voice]` (journalctl, `STT_VOICE_LOG=0` pour couper) qui montre la transcription réelle. +> Validé en live : « Asa » réveille, « Asa stop » endort. + --- ## (Rappel) Relevé d'état réel + RAG re-ingéré — fait la veille (2026-06-21, PR #44) diff --git a/stt/client/stt/config.py b/stt/client/stt/config.py index 8fba5c4..8c16a2a 100644 --- a/stt/client/stt/config.py +++ b/stt/client/stt/config.py @@ -40,8 +40,11 @@ DEFAULT_CONFIG: dict[str, Any] = { "voice": { # 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"], + # Variantes tolérées du wake word « Asa » — formes COMPACTES (déaccentuées, sans + # espace). L'ASR découpe « Asa » en « a so » / « à ça » / « ah ça »… → on joint les + # 1-2 premiers mots avant de comparer. Ajuste cette liste si ton ASR rend autrement. + "wake_aliases": ["asa", "aza", "assa", "aca", "aco", "aso", "asso", "azo", + "ahca", "ahsa", "aica", "haza", "hassa", "aha"], # 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) diff --git a/stt/client/stt/voice/engine.py b/stt/client/stt/voice/engine.py index 4948280..7b82a4d 100644 --- a/stt/client/stt/voice/engine.py +++ b/stt/client/stt/voice/engine.py @@ -16,8 +16,10 @@ Dépendances lourdes (sounddevice, faster_whisper, webrtcvad) importées à la v from __future__ import annotations +import os import queue import subprocess +import sys import tempfile import threading import time @@ -28,6 +30,15 @@ from typing import Any, Callable from .asr import make_backend +# Journalise les transcriptions + décisions (veille/réveil) sur stderr → journalctl. +# Activé par défaut (volume faible : une ligne par énoncé) ; STT_VOICE_LOG=0 pour couper. +_VOICE_LOG = os.environ.get("STT_VOICE_LOG", "1") not in ("0", "false", "") + + +def _log(msg: str) -> None: + if _VOICE_LOG: + print(f"[stt.voice] {msg}", file=sys.stderr, flush=True) + SAMPLE_RATE = 16000 PRE_ROLL = 15 # Mots de mise en veille (« Asa stop ») — déaccentués, cherchés en sous-chaîne quand @@ -35,9 +46,14 @@ PRE_ROLL = 15 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") +# Variantes du wake word « Asa » tolérées — formes COMPACTES (déaccentuées, sans espace). +# L'ASR (Parakeet FR) découpe et rend « Asa » très diversement : « a so », « à ça », « ah ça », +# « aza »… On compare donc la jonction des 1-2 premiers mots à ces formes. Surchargeable via +# [voice].wake_aliases ; le wake word principal ([voice].wake_word) s'y ajoute. +DEFAULT_WAKE_ALIASES = ( + "asa", "aza", "assa", "aca", "aco", "aso", "asso", "azo", + "ahca", "ahsa", "aica", "haza", "hassa", "aha", +) PIPER_BIN = Path.home() / ".local/share/piper-runtime/piper" PIPER_VOICE_DIR = Path.home() / ".local/share/piper" @@ -116,14 +132,22 @@ class VoiceEngine: return {_deaccent(w).strip() for w in (*base, *extra) if w} def _detect_wake(self, text: str) -> tuple[bool, str]: - """Cherche le wake word comme **mot entier** (pas sous-chaîne : « asa » est court - et matcherait « phrase », « casa »…). Renvoie (trouvé, reste de la phrase).""" + """Repère le wake word et renvoie (trouvé, reste de la phrase). + + L'ASR découpe souvent « Asa » en deux tokens (« a ça », « a so »…). On glisse une + fenêtre de 1 puis 2 mots, on **joint** (sans espace) et déaccentue, et on compare + en **égalité exacte** à l'ensemble d'alias compacts (l'exact évite les faux positifs + type « à cause » → « acause »). La fenêtre de 2 d'abord → capte « a so » avant « a ».""" aliases = self._wake_aliases() words = text.strip().split() - 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() + low = [_deaccent(w).strip(",.!?«»…:;\"'()-") for w in words] + for i in range(len(low)): + for n in (2, 1): + if i + n > len(low): + continue + head = "".join(low[i:i + n]) + if head in aliases: + return True, " ".join(words[i + n:]).strip() return False, "" def _is_sleep_command(self, text: str) -> bool: @@ -337,6 +361,7 @@ class VoiceEngine: self.emit({"type": "state", "state": "asleep" if asleep else "listening"}) continue text = self._transcribe(audio) + _log(f"({'veille' if asleep else 'eveil'}) entendu: {text!r}") if not text: self.emit({"type": "state", "state": "asleep" if asleep else "listening"}) continue @@ -344,8 +369,10 @@ class VoiceEngine: if asleep: found, command = self._detect_wake(text) if not found: + _log(f" pas de wake word (alias={sorted(self._wake_aliases())})") self.emit({"type": "state", "state": "asleep"}) continue + _log(f" RÉVEIL (commande={command!r})") asleep = False last_activity = time.time() self.emit({"type": "state", "state": "listening"})