feat(stt-client): stt --voices / --install-voice (changer de voix Piper)

Faciliter le changement de voix (le simple --update ne bascule rien : la config
perso prime sur le défaut).

- `stt --voices` : liste les voix Piper FR (homme/femme), URLs vérifiées sur
  HuggingFace rhasspy/piper-voices.
- `stt --install-voice fr_FR-tom-medium` : télécharge .onnx + .onnx.json et
  SÉLECTIONNE la voix (écrit tts_engine=piper + piper_voice dans stt.toml, en
  préservant les autres réglages). Puis `stt --restart`.
- doc : admin/ia/stt.md (note « pourquoi --update ne change pas la voix »).
  Client 0.16.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
alkatrazz 2026-06-23 00:03:12 +02:00
parent 610d32f1e9
commit ed57169653
3 changed files with 97 additions and 1 deletions

View file

@ -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

View file

@ -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"

View file

@ -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 <nom>\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: