diff --git a/admin/ia/stt.md b/admin/ia/stt.md index 0be624c..794b2cb 100644 --- a/admin/ia/stt.md +++ b/admin/ia/stt.md @@ -257,6 +257,12 @@ Côté client, pilotable depuis `stt/client/config/` + l'écran de réglages du 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. +**Changer de voix Piper (homme/femme)** : `stt --voices` liste les voix FR ; `stt --install-voice +fr_FR-tom-medium` télécharge la voix (`.onnx` + `.onnx.json` depuis HuggingFace rhasspy/piper-voices) +**et la sélectionne** (`tts_engine=piper` + `piper_voice` écrits dans `stt.toml`). Puis `stt --restart`. +> ⚠️ Changer la voix nécessite d'écrire `tts_engine`/`piper_voice` dans le `stt.toml` **existant** : +> un simple `stt --update` ne bascule rien (la config perso prime sur le défaut) — d'où ces commandes. + --- ## Prérequis / dépendances diff --git a/stt/client/pyproject.toml b/stt/client/pyproject.toml index b01ae6d..5f54b1d 100644 --- a/stt/client/pyproject.toml +++ b/stt/client/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "stt" -version = "0.15.0" +version = "0.16.0" description = "STT — client vocal Jarvis du homelab Funk (voix + HUD, parle au STT-server)" requires-python = ">=3.11" readme = "README.md" diff --git a/stt/client/stt/cli.py b/stt/client/stt/cli.py index d496dbf..9974c92 100644 --- a/stt/client/stt/cli.py +++ b/stt/client/stt/cli.py @@ -161,6 +161,7 @@ def _running_stt_pids() -> list[int]: "--stop", "--start", "--restart", "--update", "--version", "--setup", "--text", "--install-service", "--uninstall-service", "--install-desktop", "--uninstall-desktop", "--install-kokoro", + "--voices", "--install-voice", } pids: list[int] = [] try: @@ -472,6 +473,87 @@ def _cmd_install_kokoro() -> int: return 0 +# Voix Piper FR (vérifiées sur HuggingFace rhasspy/piper-voices). Nom → description. +_PIPER_FR_VOICES = { + "fr_FR-upmc-medium": "femme (UPMC) — défaut", + "fr_FR-siwis-medium": "femme (SIWIS)", + "fr_FR-tom-medium": "homme (Tom)", + "fr_FR-gilles-low": "homme (Gilles)", + "fr_FR-mls-medium": "multi-locuteurs (MLS)", +} + + +def _cmd_voices() -> int: + print("Voix Piper FR — installe avec : stt --install-voice \n") + for name, desc in _PIPER_FR_VOICES.items(): + print(f" • {name:22} {desc}") + print("\nVoix Kokoro (plus naturelle, FR : ff_siwis) → stt --install-kokoro") + print("Après installation d'une voix : stt --restart") + return 0 + + +def _set_voice_config(updates: dict) -> None: + """Met à jour les clés [voice] dans ~/.config/stt/stt.toml (préserve les autres valeurs).""" + import tomllib + + import tomli_w + from stt.config import CONFIG_DIR, CONFIG_PATH + + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + cfg: dict = {} + if CONFIG_PATH.exists(): + with CONFIG_PATH.open("rb") as f: + cfg = tomllib.load(f) + cfg.setdefault("voice", {}).update(updates) + with CONFIG_PATH.open("wb") as f: + tomli_w.dump(cfg, f) + + +def _cmd_install_voice(name: str) -> int: + """Télécharge une voix Piper FR (.onnx + .onnx.json) et la sélectionne dans la config.""" + import urllib.error + import urllib.request + from pathlib import Path + + parts = name.split("-") + if len(parts) != 3 or "_" not in parts[0]: + print(f"Nom invalide : {name!r} (attendu ex. fr_FR-tom-medium — voir `stt --voices`).") + return 1 + lc, speaker, quality = parts + lang = lc.split("_")[0] + base = ("https://huggingface.co/rhasspy/piper-voices/resolve/main/" + f"{lang}/{lc}/{speaker}/{quality}/{name}") + dest = Path.home() / ".local/share/piper" + dest.mkdir(parents=True, exist_ok=True) + + def hook(blocks: int, bsize: int, total: int) -> None: + if total > 0: + print(f"\r {min(100, blocks * bsize * 100 // total)}%", end="", flush=True) + + for ext in (".onnx", ".onnx.json"): + target = dest / f"{name}{ext}" + if target.exists() and target.stat().st_size > 0: + print(f"✓ {name}{ext} déjà présent") + continue + print(f"⬇ {name}{ext}…") + try: + urllib.request.urlretrieve(f"{base}{ext}", target, hook) # noqa: S310 + print() + except urllib.error.HTTPError as e: + print(f"\n❌ {name}{ext} : HTTP {e.code} (voix introuvable ? `stt --voices`)") + target.unlink(missing_ok=True) + return 1 + except Exception as e: # noqa: BLE001 + print(f"\n❌ {name}{ext} : {e}") + target.unlink(missing_ok=True) + return 1 + + _set_voice_config({"tts_engine": "piper", "piper_voice": name}) + print(f"\n✓ Voix « {name} » installée et sélectionnée (tts_engine=piper).") + print(" Applique : 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") @@ -499,6 +581,10 @@ def main(argv: list[str] | None = None) -> int: 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("--voices", action="store_true", + help="Liste les voix Piper FR installables (homme/femme)") + p.add_argument("--install-voice", metavar="NOM", + help="Télécharge une voix Piper FR et la sélectionne (ex. fr_FR-tom-medium)") p.add_argument("--version", action="store_true", help="Affiche la version installée") args = p.parse_args(argv) @@ -510,6 +596,10 @@ def main(argv: list[str] | None = None) -> int: return _cmd_update() if args.install_kokoro: return _cmd_install_kokoro() + if args.voices: + return _cmd_voices() + if args.install_voice: + return _cmd_install_voice(args.install_voice) if args.start: return _cmd_start() if args.restart: