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

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