From 9f3631adb51bbe16834c294428848a4d4e6d2dda Mon Sep 17 00:00:00 2001 From: alkatrazz Date: Mon, 22 Jun 2026 23:52:47 +0200 Subject: [PATCH] feat(stt-client): voix Kokoro (TTS enfichable) + commande --install-kokoro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- admin/ia/stt.md | 10 +++- progress/2026-06-22.md | 13 +++- stt/client/config/stt.example.toml | 5 +- stt/client/pyproject.toml | 5 +- stt/client/stt/cli.py | 45 +++++++++++++- stt/client/stt/config.py | 7 +++ stt/client/stt/voice/engine.py | 95 +++++++++++++++++++++++++----- 7 files changed, 161 insertions(+), 19 deletions(-) diff --git a/admin/ia/stt.md b/admin/ia/stt.md index b989e9a..0be624c 100644 --- a/admin/ia/stt.md +++ b/admin/ia/stt.md @@ -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 : - **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. --- diff --git a/progress/2026-06-22.md b/progress/2026-06-22.md index 62aa7ee..d92291e 100644 --- a/progress/2026-06-22.md +++ b/progress/2026-06-22.md @@ -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`, 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 +- **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` (« 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`. - **1er test d'action vocale** (#43, contexte 🤖 Agent → `hermes-exec`). diff --git a/stt/client/config/stt.example.toml b/stt/client/config/stt.example.toml index abd271c..077c2d9 100644 --- a/stt/client/config/stt.example.toml +++ b/stt/client/config/stt.example.toml @@ -12,7 +12,10 @@ model = "hermes-default" # modèle demandé au serveur [voice] wake_word = "hermes" # "Ok Hermès" whisper_model = "large-v3" # STT faster-whisper (CPU int8) — "small" pour tester -piper_voice = "fr_FR-upmc-medium" # TTS — ~/.local/share/piper/.onnx +tts_engine = "piper" # "piper" (rapide) | "kokoro" (plus naturel — voir ci-dessous) +piper_voice = "fr_FR-upmc-medium" # voix Piper — ~/.local/share/piper/.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" silence_sec = 1.2 min_speech_sec = 0.6 diff --git a/stt/client/pyproject.toml b/stt/client/pyproject.toml index 7581600..b01ae6d 100644 --- a/stt/client/pyproject.toml +++ b/stt/client/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "stt" -version = "0.14.0" +version = "0.15.0" description = "STT — client vocal Jarvis du homelab Funk (voix + HUD, parle au STT-server)" requires-python = ">=3.11" readme = "README.md" @@ -21,6 +21,9 @@ dependencies = [ [project.optional-dependencies] onnx = ["onnx-asr>=0.11", "onnxruntime>=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] stt = "stt.cli:main" diff --git a/stt/client/stt/cli.py b/stt/client/stt/cli.py index 6f3bf1f..d496dbf 100644 --- a/stt/client/stt/cli.py +++ b/stt/client/stt/cli.py @@ -160,7 +160,7 @@ def _running_stt_pids() -> list[int]: utilitaires = { "--stop", "--start", "--restart", "--update", "--version", "--setup", "--text", "--install-service", "--uninstall-service", - "--install-desktop", "--uninstall-desktop", + "--install-desktop", "--uninstall-desktop", "--install-kokoro", } pids: list[int] = [] try: @@ -433,6 +433,45 @@ def _cmd_voice(args) -> int: 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: 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") @@ -458,6 +497,8 @@ def main(argv: list[str] | None = None) -> int: help="Supprime le lanceur de bureau") p.add_argument("--update", action="store_true", 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") args = p.parse_args(argv) @@ -467,6 +508,8 @@ def main(argv: list[str] | None = None) -> int: return _cmd_setup() if args.update: return _cmd_update() + if args.install_kokoro: + return _cmd_install_kokoro() if args.start: return _cmd_start() if args.restart: diff --git a/stt/client/stt/config.py b/stt/client/stt/config.py index 8c16a2a..161b0be 100644 --- a/stt/client/stt/config.py +++ b/stt/client/stt/config.py @@ -54,7 +54,14 @@ DEFAULT_CONFIG: dict[str, Any] = { # CPU par défaut ; pour le GPU AMD : ["ROCMExecutionProvider", "CPUExecutionProvider"]. "onnx_model": "nemo-parakeet-tdt-0.6b-v3", "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", + # 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", "silence_sec": 1.2, "min_speech_sec": 0.6, diff --git a/stt/client/stt/voice/engine.py b/stt/client/stt/voice/engine.py index 7b82a4d..1907f5d 100644 --- a/stt/client/stt/voice/engine.py +++ b/stt/client/stt/voice/engine.py @@ -58,6 +58,11 @@ DEFAULT_WAKE_ALIASES = ( PIPER_BIN = Path.home() / ".local/share/piper-runtime/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: return ( @@ -112,6 +117,8 @@ class VoiceEngine: self._interrupt = threading.Event() # process de lecture TTS courant (aplay) → tuable pour couper la parole 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 def muted(self) -> bool: @@ -120,6 +127,10 @@ class VoiceEngine: # ── chargement modèle ──────────────────────────────────────────────── def load(self) -> None: 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 ────────────────────────────────────────────────────────────── def _transcribe(self, audio) -> str: @@ -160,29 +171,85 @@ class VoiceEngine: def _speak(self, text: str) -> None: if self.no_tts or self._interrupt.is_set(): return - voice = PIPER_VOICE_DIR / f"{self.cfg['piper_voice']}.onnx" - if not voice.exists() or not PIPER_BIN.exists(): + wav = self._synthesize(text) # WAV temporaire selon le moteur (piper/kokoro) + 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 - 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)}, - ) - 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 » - self._tts_proc = subprocess.Popen(["aplay", "-q", str(out)]) + self._tts_proc = subprocess.Popen(["aplay", "-q", str(wav)]) self._tts_proc.wait() except FileNotFoundError: pass finally: 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) + 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(" None: """Coupe le tour en cours : stoppe la lecture TTS et saute la suite.