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/ .venv/
__pycache__/ __pycache__/
*.pyc *.pyc
# artefacts de build Python (générés par pip/pipx install — ex. install locale du client stt)
build/
*.egg-info/
# Ansible # Ansible
.vault_pass .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 - **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 »). 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 - **Réveil** — dire **« Asa »** la passe en éveil. « Asa, <question> » exécute directement la
directement la question. Variantes ASR tolérées via `[voice].wake_aliases`. 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 », - **Éveil (`listening`)** — elle répond à tout. **« Asa stop »** (ou « stop », « au revoir »,
« dors »… — `[voice].sleep_words`) la **remet en veille** ; un timeout d'inactivité « dors »… — `[voice].sleep_words`) la **remet en veille** ; un timeout d'inactivité
(`chat_timeout_sec`) aussi. (`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). 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 »). 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) ## (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": { "voice": {
# Wake word principal : « Asa ». Dire « Asa » la réveille ; « Asa stop » la rendort. # Wake word principal : « Asa ». Dire « Asa » la réveille ; « Asa stop » la rendort.
"wake_word": "asa", "wake_word": "asa",
# Variantes tolérées (l'ASR FR rend « Asa » diversement). Mots ENTIERS, déaccentués. # Variantes tolérées du wake word « Asa » — formes COMPACTES (déaccentuées, sans
"wake_aliases": ["asa", "aza", "assa", "aca", "haza", "hassa"], # 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). # 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"], "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)

View file

@ -16,8 +16,10 @@ Dépendances lourdes (sounddevice, faster_whisper, webrtcvad) importées à la v
from __future__ import annotations from __future__ import annotations
import os
import queue import queue
import subprocess import subprocess
import sys
import tempfile import tempfile
import threading import threading
import time import time
@ -28,6 +30,15 @@ from typing import Any, Callable
from .asr import make_backend 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 SAMPLE_RATE = 16000
PRE_ROLL = 15 PRE_ROLL = 15
# Mots de mise en veille (« Asa stop ») — déaccentués, cherchés en sous-chaîne quand # 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 = ( DEFAULT_SLEEP_WORDS = (
"stop", "dors", "au revoir", "bonne nuit", "fin", "silence", "tais-toi", "chut", "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 # Variantes du wake word « Asa » tolérées — formes COMPACTES (déaccentuées, sans espace).
# via [voice].wake_aliases ; le wake word principal ([voice].wake_word) s'y ajoute. # L'ASR (Parakeet FR) découpe et rend « Asa » très diversement : « a so », « à ça », « ah ça »,
DEFAULT_WAKE_ALIASES = ("asa", "aza", "assa", "aca", "aza", "haza", "hassa") # « 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_BIN = Path.home() / ".local/share/piper-runtime/piper"
PIPER_VOICE_DIR = Path.home() / ".local/share/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} 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]:
"""Cherche le wake word comme **mot entier** (pas sous-chaîne : « asa » est court """Repère le wake word et renvoie (trouvé, reste de la phrase).
et matcherait « phrase », « casa »). 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() 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]
for i, w in enumerate(low): for i in range(len(low)):
if w in aliases: for n in (2, 1):
return True, " ".join(words[i + 1:]).strip() 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, "" return False, ""
def _is_sleep_command(self, text: str) -> bool: def _is_sleep_command(self, text: str) -> bool:
@ -337,6 +361,7 @@ class VoiceEngine:
self.emit({"type": "state", "state": "asleep" if asleep else "listening"}) self.emit({"type": "state", "state": "asleep" if asleep else "listening"})
continue continue
text = self._transcribe(audio) text = self._transcribe(audio)
_log(f"({'veille' if asleep else 'eveil'}) entendu: {text!r}")
if not text: if not text:
self.emit({"type": "state", "state": "asleep" if asleep else "listening"}) self.emit({"type": "state", "state": "asleep" if asleep else "listening"})
continue continue
@ -344,8 +369,10 @@ class VoiceEngine:
if asleep: if asleep:
found, command = self._detect_wake(text) found, command = self._detect_wake(text)
if not found: if not found:
_log(f" pas de wake word (alias={sorted(self._wake_aliases())})")
self.emit({"type": "state", "state": "asleep"}) self.emit({"type": "state", "state": "asleep"})
continue continue
_log(f" RÉVEIL (commande={command!r})")
asleep = False asleep = False
last_activity = time.time() last_activity = time.time()
self.emit({"type": "state", "state": "listening"}) self.emit({"type": "state", "state": "listening"})