feat(stt-client): voix Kokoro (TTS enfichable) + commande --install-kokoro (#55)

Ajoute un moteur TTS plus naturel (Kokoro, ONNX/CPU) au choix, Piper restant
le défaut et le repli.

- engine.py : synthèse enfichable (_synthesize → _synth_piper | _synth_kokoro).
  Kokoro chargé une fois (préchargé au boot si sélectionné), WAV via wave stdlib
  (numpy → PCM16), lecture/interruption inchangées. Repli SILENCIEUX si le modèle
  ou le paquet kokoro-onnx manque (jamais de crash).
- config : [voice].tts_engine ("piper"|"kokoro") + kokoro_voice (ff_siwis, FR),
  kokoro_speed, kokoro_lang.
- cli : `stt --install-kokoro` télécharge kokoro-v1.0.onnx + voices-v1.0.bin dans
  ~/.local/share/kokoro et imprime les étapes (pipx inject kokoro-onnx, espeak-ng).
- pyproject : extra optionnel [kokoro] ; client 0.15.0.
- doc : stt.example.toml + admin/ia/stt.md + journal.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ALI YESILKAYA 2026-06-22 23:57:27 +02:00 committed by GitHub
parent 666abe765b
commit 610d32f1e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 161 additions and 19 deletions

View file

@ -247,7 +247,15 @@ Machine à états du moteur vocal (`stt/client/stt/voice/engine.py`) :
Côté client, pilotable depuis `stt/client/config/` + l'écran de réglages du HUD : Côté client, pilotable depuis `stt/client/config/` + l'écran de réglages du HUD :
- **Avatar / image** (`config/avatars/`), **thème** (`config/themes/`), - **Avatar / image** (`config/avatars/`), **thème** (`config/themes/`),
**voix** Piper `.onnx` (`config/voices/`), **wake word**, **URL serveur**. **voix**, **wake word**, **URL serveur**.
**Moteur TTS — `[voice].tts_engine`** : `piper` (défaut, rapide, voix `.onnx` dans
`~/.local/share/piper/`) **ou** `kokoro` (nettement plus naturel, ONNX/CPU). Pour Kokoro :
`stt --install-kokoro` (télécharge `kokoro-v1.0.onnx` + `voices-v1.0.bin` dans
`~/.local/share/kokoro/`), `pipx inject stt kokoro-onnx`, `espeak-ng` (phonémisation FR), puis
`tts_engine = "kokoro"` (voix FR `kokoro_voice = "ff_siwis"`). Le client **précharge** Kokoro au
démarrage et **retombe en silence** proprement si le modèle/paquet manque (`engine._synth_kokoro`).
La synthèse est enfichable (`engine._synthesize`) — Piper reste le repli.
--- ---

View file

@ -111,10 +111,21 @@ appeler, puis répond à partir des résultats.
exposé seulement si `STT_ACTIONS_ENABLED` + jeton. 1 test unitaire de plus. (PR `feat/asa-admin-actions`, exposé seulement si `STT_ACTIONS_ENABLED` + jeton. 1 test unitaire de plus. (PR `feat/asa-admin-actions`,
serveur 0.9.0.) serveur 0.9.0.)
## Voix d'Asa — TTS Kokoro enfichable (client 0.15.0)
- **TTS enfichable** côté client (`engine._synthesize` → piper | kokoro), Piper conservé en **repli**.
Kokoro (ONNX/CPU) = voix bien plus naturelle. Config `[voice].tts_engine` + `kokoro_voice`
(`ff_siwis`, FR femme) / `kokoro_speed` / `kokoro_lang`.
- Nouvelle commande **`stt --install-kokoro`** (télécharge `kokoro-v1.0.onnx` + `voices-v1.0.bin`).
Dépendance optionnelle `kokoro-onnx` (extra `[kokoro]`) ; phonémisation FR via `espeak-ng`.
- Préchargement au boot si sélectionné ; **repli silencieux** si modèle/paquet absent (pas de crash).
- (PR `feat/asa-kokoro-tts`.)
## Prochaines étapes ## Prochaines étapes
- **Tester Kokoro sur le poste** : `stt --install-kokoro` + `pipx inject stt kokoro-onnx` +
`espeak-ng` + `tts_engine = "kokoro"` + `stt --restart`. Ajuster `kokoro_speed` au goût.
- **Vérifier au déploiement** : SearXNG Ready + `web_search` réel ; puis un essai `admin_action` - **Vérifier au déploiement** : SearXNG Ready + `web_search` réel ; puis un essai `admin_action`
(« redémarre … » → confirme → exécution Hermes). (« redémarre … » → confirme → exécution Hermes).
- Enrichir `host_health`/`cluster_status` (GPU via `rocm_scraper`, fs, températures) si utile.
- Si le wake word « asa » génère des faux positifs/négatifs, ajuster `wake_aliases`. - Si le wake word « asa » génère des faux positifs/négatifs, ajuster `wake_aliases`.
- **1er test d'action vocale** (#43, contexte 🤖 Agent → `hermes-exec`). - **1er test d'action vocale** (#43, contexte 🤖 Agent → `hermes-exec`).

View file

@ -12,7 +12,10 @@ model = "hermes-default" # modèle demandé au serveur
[voice] [voice]
wake_word = "hermes" # "Ok Hermès" wake_word = "hermes" # "Ok Hermès"
whisper_model = "large-v3" # STT faster-whisper (CPU int8) — "small" pour tester whisper_model = "large-v3" # STT faster-whisper (CPU int8) — "small" pour tester
piper_voice = "fr_FR-upmc-medium" # TTS — ~/.local/share/piper/<voix>.onnx tts_engine = "piper" # "piper" (rapide) | "kokoro" (plus naturel — voir ci-dessous)
piper_voice = "fr_FR-upmc-medium" # voix Piper — ~/.local/share/piper/<voix>.onnx
# Voix Kokoro (si tts_engine = "kokoro") : `stt --install-kokoro` + `pipx inject stt kokoro-onnx`
# kokoro_voice = "ff_siwis" # voix FR (femme) ; kokoro_speed = 1.0 ; kokoro_lang = "fr-fr"
language = "fr" language = "fr"
silence_sec = 1.2 silence_sec = 1.2
min_speech_sec = 0.6 min_speech_sec = 0.6

View file

@ -1,6 +1,6 @@
[project] [project]
name = "stt" name = "stt"
version = "0.14.0" version = "0.15.0"
description = "STT — client vocal Jarvis du homelab Funk (voix + HUD, parle au STT-server)" description = "STT — client vocal Jarvis du homelab Funk (voix + HUD, parle au STT-server)"
requires-python = ">=3.11" requires-python = ">=3.11"
readme = "README.md" readme = "README.md"
@ -21,6 +21,9 @@ dependencies = [
[project.optional-dependencies] [project.optional-dependencies]
onnx = ["onnx-asr>=0.11", "onnxruntime>=1.18"] onnx = ["onnx-asr>=0.11", "onnxruntime>=1.18"]
onnx-rocm = ["onnx-asr>=0.11", "onnxruntime-rocm>=1.18"] onnx-rocm = ["onnx-asr>=0.11", "onnxruntime-rocm>=1.18"]
# TTS Kokoro (tts_engine="kokoro") : voix nettement plus naturelle, ONNX/CPU.
# Modèle téléchargé par `stt --install-kokoro` ; phonémisation FR via espeak-ng (système).
kokoro = ["kokoro-onnx>=0.4.0"]
[project.scripts] [project.scripts]
stt = "stt.cli:main" stt = "stt.cli:main"

View file

@ -160,7 +160,7 @@ def _running_stt_pids() -> list[int]:
utilitaires = { utilitaires = {
"--stop", "--start", "--restart", "--update", "--version", "--setup", "--text", "--stop", "--start", "--restart", "--update", "--version", "--setup", "--text",
"--install-service", "--uninstall-service", "--install-service", "--uninstall-service",
"--install-desktop", "--uninstall-desktop", "--install-desktop", "--uninstall-desktop", "--install-kokoro",
} }
pids: list[int] = [] pids: list[int] = []
try: try:
@ -433,6 +433,45 @@ def _cmd_voice(args) -> int:
return 0 return 0
def _cmd_install_kokoro() -> int:
"""Télécharge le modèle Kokoro + le pack de voix (TTS naturel, ONNX) en local."""
import urllib.request
from pathlib import Path
base = "https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0"
dest = Path.home() / ".local/share/kokoro"
dest.mkdir(parents=True, exist_ok=True)
def _download(url: str, target: Path) -> None:
def hook(blocks: int, bsize: int, total: int) -> None:
if total > 0:
pct = min(100, blocks * bsize * 100 // total)
print(f"\r {pct}%", end="", flush=True)
urllib.request.urlretrieve(url, target, hook) # noqa: S310 — URL fixe (GitHub release)
print()
for name, size in (("kokoro-v1.0.onnx", "~310 Mo"), ("voices-v1.0.bin", "~26 Mo")):
target = dest / name
if target.exists() and target.stat().st_size > 0:
print(f"{name} déjà présent")
continue
print(f"{name} ({size})…")
try:
_download(f"{base}/{name}", target)
except Exception as e: # noqa: BLE001
print(f"❌ Échec du téléchargement de {name} : {e}")
target.unlink(missing_ok=True)
return 1
print(f"\n✓ Modèle Kokoro installé dans {dest}")
print("Étapes restantes pour activer la voix Kokoro :")
print(" 1) pipx inject stt kokoro-onnx # moteur Python (onnxruntime)")
print(" 2) sudo dnf install espeak-ng # phonémisation FR (si absent)")
print(' 3) ~/.config/stt/stt.toml → [voice] : tts_engine = "kokoro"')
print(" 4) stt --restart")
return 0
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(prog="stt", description="Client vocal Jarvis de Funk") p = argparse.ArgumentParser(prog="stt", description="Client vocal Jarvis de Funk")
p.add_argument("--setup", action="store_true", help="Écrit la config par défaut") p.add_argument("--setup", action="store_true", help="Écrit la config par défaut")
@ -458,6 +497,8 @@ def main(argv: list[str] | None = None) -> int:
help="Supprime le lanceur de bureau") help="Supprime le lanceur de bureau")
p.add_argument("--update", action="store_true", p.add_argument("--update", action="store_true",
help="Met à jour STT depuis git (pipx reinstall)") help="Met à jour STT depuis git (pipx reinstall)")
p.add_argument("--install-kokoro", action="store_true",
help="Télécharge le modèle Kokoro (TTS naturel) dans ~/.local/share/kokoro")
p.add_argument("--version", action="store_true", help="Affiche la version installée") p.add_argument("--version", action="store_true", help="Affiche la version installée")
args = p.parse_args(argv) args = p.parse_args(argv)
@ -467,6 +508,8 @@ def main(argv: list[str] | None = None) -> int:
return _cmd_setup() return _cmd_setup()
if args.update: if args.update:
return _cmd_update() return _cmd_update()
if args.install_kokoro:
return _cmd_install_kokoro()
if args.start: if args.start:
return _cmd_start() return _cmd_start()
if args.restart: if args.restart:

View file

@ -54,7 +54,14 @@ DEFAULT_CONFIG: dict[str, Any] = {
# CPU par défaut ; pour le GPU AMD : ["ROCMExecutionProvider", "CPUExecutionProvider"]. # CPU par défaut ; pour le GPU AMD : ["ROCMExecutionProvider", "CPUExecutionProvider"].
"onnx_model": "nemo-parakeet-tdt-0.6b-v3", "onnx_model": "nemo-parakeet-tdt-0.6b-v3",
"onnx_providers": ["CPUExecutionProvider"], "onnx_providers": ["CPUExecutionProvider"],
# TTS (voix d'Asa) : "piper" (défaut, rapide) | "kokoro" (plus naturel, ONNX/CPU).
# Pour Kokoro : `stt --install-kokoro` (modèle) + `pipx inject stt kokoro-onnx`.
"tts_engine": "piper",
"piper_voice": "fr_FR-upmc-medium", "piper_voice": "fr_FR-upmc-medium",
# Kokoro — voix FR « ff_siwis » (femme). Vitesse 1.0 ; lang fr-fr (phonémisation espeak-ng).
"kokoro_voice": "ff_siwis",
"kokoro_speed": 1.0,
"kokoro_lang": "fr-fr",
"language": "fr", "language": "fr",
"silence_sec": 1.2, "silence_sec": 1.2,
"min_speech_sec": 0.6, "min_speech_sec": 0.6,

View file

@ -58,6 +58,11 @@ DEFAULT_WAKE_ALIASES = (
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"
# Kokoro TTS (ONNX) — modèle + pack de voix téléchargés par `stt --install-kokoro`.
KOKORO_DIR = Path.home() / ".local/share/kokoro"
KOKORO_MODEL = KOKORO_DIR / "kokoro-v1.0.onnx"
KOKORO_VOICES = KOKORO_DIR / "voices-v1.0.bin"
def _deaccent(s: str) -> str: def _deaccent(s: str) -> str:
return ( return (
@ -112,6 +117,8 @@ class VoiceEngine:
self._interrupt = threading.Event() self._interrupt = threading.Event()
# process de lecture TTS courant (aplay) → tuable pour couper la parole # process de lecture TTS courant (aplay) → tuable pour couper la parole
self._tts_proc: subprocess.Popen | None = None self._tts_proc: subprocess.Popen | None = None
# instance Kokoro (chargée à la demande si tts_engine="kokoro") — modèle ONNX ~310 Mo
self._kokoro = None
@property @property
def muted(self) -> bool: def muted(self) -> bool:
@ -120,6 +127,10 @@ class VoiceEngine:
# ── chargement modèle ──────────────────────────────────────────────── # ── chargement modèle ────────────────────────────────────────────────
def load(self) -> None: def load(self) -> None:
self._asr.load() self._asr.load()
# Précharge Kokoro (modèle lourd) au démarrage si sélectionné → évite une
# 1re réponse lente. Repli silencieux si indisponible (le _speak gère).
if (self.cfg.get("tts_engine") or "").lower() == "kokoro":
self._load_kokoro()
# ── STT ────────────────────────────────────────────────────────────── # ── STT ──────────────────────────────────────────────────────────────
def _transcribe(self, audio) -> str: def _transcribe(self, audio) -> str:
@ -160,29 +171,85 @@ class VoiceEngine:
def _speak(self, text: str) -> None: def _speak(self, text: str) -> None:
if self.no_tts or self._interrupt.is_set(): if self.no_tts or self._interrupt.is_set():
return return
voice = PIPER_VOICE_DIR / f"{self.cfg['piper_voice']}.onnx" wav = self._synthesize(text) # WAV temporaire selon le moteur (piper/kokoro)
if not voice.exists() or not PIPER_BIN.exists(): if wav is None or self._interrupt.is_set(): # « stop » pressé pendant la synthèse
if wav is not None:
wav.unlink(missing_ok=True)
return return
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
out = Path(f.name)
try: try:
subprocess.run(
[str(PIPER_BIN), "--model", str(voice), "--output_file", str(out)],
input=text.encode(),
capture_output=True,
check=True,
env={"LD_LIBRARY_PATH": str(PIPER_BIN.parent)},
)
if self._interrupt.is_set(): # « stop » pressé pendant la synthèse
return
# Popen (pas run) → on garde la main pour tuer la lecture sur « stop » # Popen (pas run) → on garde la main pour tuer la lecture sur « stop »
self._tts_proc = subprocess.Popen(["aplay", "-q", str(out)]) self._tts_proc = subprocess.Popen(["aplay", "-q", str(wav)])
self._tts_proc.wait() self._tts_proc.wait()
except FileNotFoundError: except FileNotFoundError:
pass pass
finally: finally:
self._tts_proc = None self._tts_proc = None
wav.unlink(missing_ok=True)
def _synthesize(self, text: str) -> "Path | None":
"""Synthétise `text` en WAV temporaire selon `[voice].tts_engine`. None si KO."""
if (self.cfg.get("tts_engine") or "piper").lower() == "kokoro":
return self._synth_kokoro(text)
return self._synth_piper(text)
def _synth_piper(self, text: str) -> "Path | None":
voice = PIPER_VOICE_DIR / f"{self.cfg['piper_voice']}.onnx"
if not voice.exists() or not PIPER_BIN.exists():
return None
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
out = Path(f.name)
try:
subprocess.run(
[str(PIPER_BIN), "--model", str(voice), "--output_file", str(out)],
input=text.encode(), capture_output=True, check=True,
env={"LD_LIBRARY_PATH": str(PIPER_BIN.parent)},
)
return out
except (FileNotFoundError, subprocess.CalledProcessError):
out.unlink(missing_ok=True) out.unlink(missing_ok=True)
return None
def _load_kokoro(self):
"""Charge (une fois) l'instance Kokoro ONNX. None si modèle/paquet absent."""
if self._kokoro is not None:
return self._kokoro
if not KOKORO_MODEL.exists() or not KOKORO_VOICES.exists():
_log("Kokoro: modèle absent → lance `stt --install-kokoro` (repli silencieux).")
return None
try:
from kokoro_onnx import Kokoro
self._kokoro = Kokoro(str(KOKORO_MODEL), str(KOKORO_VOICES))
return self._kokoro
except Exception as e: # noqa: BLE001 — paquet absent / incompatible
_log(f"Kokoro indisponible ({e}) — installe-le : pipx inject stt kokoro-onnx")
return None
def _synth_kokoro(self, text: str) -> "Path | None":
k = self._load_kokoro()
if k is None:
return None
try:
import wave
import numpy as np
samples, rate = k.create(
text,
voice=self.cfg.get("kokoro_voice", "ff_siwis"),
speed=float(self.cfg.get("kokoro_speed", 1.0)),
lang=self.cfg.get("kokoro_lang", "fr-fr"),
)
pcm = (np.clip(np.asarray(samples), -1.0, 1.0) * 32767).astype("<i2")
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
out = Path(f.name)
with wave.open(str(out), "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(int(rate))
w.writeframes(pcm.tobytes())
return out
except Exception as e: # noqa: BLE001 — synthèse KO → silence plutôt que crash
_log(f"Kokoro: échec de synthèse ({e}).")
return None
def stop_response(self) -> None: def stop_response(self) -> None:
"""Coupe le tour en cours : stoppe la lecture TTS et saute la suite. """Coupe le tour en cours : stoppe la lecture TTS et saute la suite.