Files
aventuriers-des-mots/tools/generate_dictation_audio.py
T
2026-10-12 17:16:34 +02:00

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-11 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()