mirror of
https://github.com/deunix-educ/aventuriers-des-mots.git
synced 2026-10-13 02:11:58 +02:00
Generate provisional dictation audio so dictée missions can be played
Manual testing confirmed the "La clairière de l'écho" dictée mission was unplayable: no audio file existed yet (decisions.md point 8), so there was nothing to listen to before transcribing the sentence. Adds tools/generate_dictation_audio.py (offline eSpeak NG synthesis + ffmpeg encoding to Ogg Vorbis, exposed as `make generate-dictation-audio`) and uses it to generate ce1_accord_adjectif_dictee_01.ogg. A new headless test (tests/data/test_dictation_audio.gd) guards against future dictées being added without regenerating their audio. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Genere l'audio provisoire des dictees a partir de `data/dictations/*.json`.
|
||||
|
||||
Synthese vocale hors ligne (eSpeak NG) + encodage en Ogg Vorbis (ffmpeg), tel
|
||||
que decide dans docs/specs/decisions.md, point 8. 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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
DICTATIONS_DIR = REPO_ROOT / "data" / "dictations"
|
||||
AUDIO_DIR = REPO_ROOT / "assets" / "audio" / "dictations"
|
||||
|
||||
ESPEAK_VOICE = "fr-fr"
|
||||
ESPEAK_SPEED_WPM = "135"
|
||||
|
||||
|
||||
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 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(espeak_bin: str, 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"
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
tmp_wav = Path(tmp_dir) / f"{audio_id}.wav"
|
||||
subprocess.run(
|
||||
[
|
||||
espeak_bin,
|
||||
"-v",
|
||||
ESPEAK_VOICE,
|
||||
"-s",
|
||||
ESPEAK_SPEED_WPM,
|
||||
"-w",
|
||||
str(tmp_wav),
|
||||
target_text,
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
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()
|
||||
|
||||
espeak_bin = _require_tool(
|
||||
"espeak-ng",
|
||||
"A installer via le gestionnaire de paquets du systeme (ex. "
|
||||
"`sudo apt install espeak-ng` sous Debian/Ubuntu, `brew install espeak-ng` sous macOS).",
|
||||
)
|
||||
ffmpeg_bin = _require_tool("ffmpeg", "A installer via le gestionnaire de paquets du systeme.")
|
||||
|
||||
AUDIO_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
generated: list[str] = []
|
||||
skipped: list[str] = []
|
||||
for dictation in load_dictations():
|
||||
if not dictation.get("audio_provisional", False):
|
||||
continue
|
||||
audio_id: str = dictation["audio_id"]
|
||||
output_path = AUDIO_DIR / f"{audio_id}.ogg"
|
||||
if output_path.exists() and not args.force:
|
||||
skipped.append(audio_id)
|
||||
continue
|
||||
generate_one(espeak_bin, ffmpeg_bin, dictation)
|
||||
generated.append(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()
|
||||
Reference in New Issue
Block a user