fix(stt): détecte « Asa » même découpé par l'ASR (« a so »/« à ça ») + log diagnostic (#46)

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 <noreply@anthropic.com>
This commit is contained in:
ALI YESILKAYA 2026-06-22 00:54:46 +02:00 committed by GitHub
parent d83091a350
commit 1e01caf634
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 58 additions and 13 deletions

3
.gitignore vendored
View file

@ -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

View file

@ -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, <question> » exécute
directement la question. Variantes ASR tolérées via `[voice].wake_aliases`.
- **Réveil** — dire **« Asa »** la passe en éveil. « Asa, <question> » 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.

View file

@ -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)

View file

@ -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)

View file

@ -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"})