mirror of
https://github.com/deunix-educ/aventuriers-des-mots.git
synced 2026-10-13 02:11:58 +02:00
b0dcedb402
Manual test reported the eSpeak NG dictation voice as unintelligible. Formant synthesis is inherently mechanical; slowing it down doesn't fix that. Switched tools/generate_dictation_audio.py to piper-tts (fr_FR-siwis-medium neural voice, MIT/CC-BY 4.0, attribution in assets/CREDITS.md). The voice model (~60MB) downloads once on demand into the untracked .cache/piper-voices/, never at game runtime. Adds piper-tts to tools/setup.sh's venv and a pyrightconfig.json pointing pyright at .venv, needed once tools/ imports a real third-party package (`make lint` was silently checking against the wrong interpreter before). Regenerated ce1_accord_adjectif_dictee_01.ogg with the new voice. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
142 lines
4.8 KiB
Python
142 lines
4.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Genere l'audio provisoire des dictees a partir de `data/dictations/*.json`.
|
|
|
|
Synthese vocale hors ligne (voix neuronale Piper, francaise) + encodage en
|
|
Ogg Vorbis (ffmpeg), tel que decide dans docs/specs/decisions.md, points 8 et
|
|
19. Ne tourne jamais a l'execution du jeu : c'est un outil de redaction de
|
|
contenu, lance a la main. Ne regenere pas un fichier deja present, sauf avec
|
|
--force.
|
|
|
|
Un premier essai avec eSpeak NG (synthese par formants) s'est revele
|
|
inintelligible a l'oreille lors d'un test manuel : remplace par une voix
|
|
neuronale Piper, nettement plus naturelle tout en restant generee hors ligne
|
|
sans aucun acces reseau au moment de jouer (le telechargement du modele de
|
|
voix, ci-dessous, n'a lieu qu'une fois, a la redaction du contenu).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import wave
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from urllib.request import urlretrieve
|
|
|
|
from piper import PiperVoice
|
|
from piper.config import SynthesisConfig
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
DICTATIONS_DIR = REPO_ROOT / "data" / "dictations"
|
|
AUDIO_DIR = REPO_ROOT / "assets" / "audio" / "dictations"
|
|
|
|
# Cache local, non versionne (voir .gitignore) : le modele de voix (~60 Mo)
|
|
# n'a pas sa place dans le depot, seul l'audio genere l'a.
|
|
VOICE_CACHE_DIR = REPO_ROOT / ".cache" / "piper-voices"
|
|
VOICE_NAME = "fr_FR-siwis-medium"
|
|
VOICE_BASE_URL = "https://huggingface.co/rhasspy/piper-voices/resolve/main/fr/fr_FR/siwis/medium"
|
|
|
|
# > 1.0 ralentit legerement le debit (duree des phonemes etiree), plus
|
|
# confortable pour un enfant de 7-9 ans qu'une diction par defaut pensee
|
|
# pour un adulte.
|
|
LENGTH_SCALE = 1.1
|
|
|
|
|
|
def _require_tool(name: str, install_hint: str) -> str:
|
|
path = shutil.which(name)
|
|
if path is None:
|
|
raise SystemExit(f"'{name}' introuvable dans le PATH. {install_hint}")
|
|
return path
|
|
|
|
|
|
def _ensure_voice() -> Path:
|
|
VOICE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
model_path = VOICE_CACHE_DIR / f"{VOICE_NAME}.onnx"
|
|
config_path = VOICE_CACHE_DIR / f"{VOICE_NAME}.onnx.json"
|
|
|
|
if not model_path.exists():
|
|
print(f"Telechargement de la voix {VOICE_NAME} (environ 60 Mo, une seule fois)...")
|
|
urlretrieve(f"{VOICE_BASE_URL}/{VOICE_NAME}.onnx", model_path)
|
|
if not config_path.exists():
|
|
urlretrieve(f"{VOICE_BASE_URL}/{VOICE_NAME}.onnx.json", config_path)
|
|
|
|
return model_path
|
|
|
|
|
|
def load_dictations() -> list[dict[str, Any]]:
|
|
return [
|
|
json.loads(path.read_text(encoding="utf-8"))
|
|
for path in sorted(DICTATIONS_DIR.glob("*.json"))
|
|
]
|
|
|
|
|
|
def generate_one(voice: PiperVoice, ffmpeg_bin: str, dictation: dict[str, Any]) -> Path:
|
|
audio_id: str = dictation["audio_id"]
|
|
target_text: str = dictation["target_text"]
|
|
output_path = AUDIO_DIR / f"{audio_id}.ogg"
|
|
syn_config = SynthesisConfig(length_scale=LENGTH_SCALE)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_wav = Path(tmp_dir) / f"{audio_id}.wav"
|
|
with wave.open(str(tmp_wav), "wb") as wav_file:
|
|
voice.synthesize_wav(target_text, wav_file, syn_config=syn_config)
|
|
subprocess.run(
|
|
[
|
|
ffmpeg_bin,
|
|
"-y",
|
|
"-loglevel",
|
|
"error",
|
|
"-i",
|
|
str(tmp_wav),
|
|
"-c:a",
|
|
"libvorbis",
|
|
"-qscale:a",
|
|
"4",
|
|
str(output_path),
|
|
],
|
|
check=True,
|
|
)
|
|
return output_path
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument(
|
|
"--force",
|
|
action="store_true",
|
|
help="Regenere meme les fichiers deja presents.",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
ffmpeg_bin = _require_tool("ffmpeg", "A installer via le gestionnaire de paquets du systeme.")
|
|
|
|
AUDIO_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
provisional_dictations = [d for d in load_dictations() if d.get("audio_provisional", False)]
|
|
dictations_to_generate: list[dict[str, Any]] = []
|
|
skipped: list[str] = []
|
|
for dictation in provisional_dictations:
|
|
output_path = AUDIO_DIR / f"{dictation['audio_id']}.ogg"
|
|
if output_path.exists() and not args.force:
|
|
skipped.append(dictation["audio_id"])
|
|
else:
|
|
dictations_to_generate.append(dictation)
|
|
|
|
generated: list[str] = []
|
|
if dictations_to_generate:
|
|
voice = PiperVoice.load(str(_ensure_voice()))
|
|
for dictation in dictations_to_generate:
|
|
generate_one(voice, ffmpeg_bin, dictation)
|
|
generated.append(dictation["audio_id"])
|
|
|
|
print(f"Audio genere : {len(generated)} fichier(s) ({', '.join(generated) or 'aucun'}).")
|
|
if skipped:
|
|
print(f"Deja present, ignore (--force pour regenerer) : {', '.join(skipped)}.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|