mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-08 05: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 :
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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`).
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue