mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-08 04:44:43 +02:00
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:
parent
666abe765b
commit
610d32f1e9
7 changed files with 161 additions and 19 deletions
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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/<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"
|
||||
silence_sec = 1.2
|
||||
min_speech_sec = 0.6
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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("<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:
|
||||
"""Coupe le tour en cours : stoppe la lecture TTS et saute la suite.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue