Replace eSpeak NG with Piper neural voice for dictation audio

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>
This commit is contained in:
Denis Defolie
2026-10-11 19:07:40 +02:00
parent 73440ba226
commit b0dcedb402
9 changed files with 88 additions and 42 deletions
+58 -35
View File
@@ -1,10 +1,17 @@
#!/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.
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
@@ -14,15 +21,28 @@ 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"
ESPEAK_VOICE = "fr-fr"
ESPEAK_SPEED_WPM = "135"
# 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:
@@ -32,6 +52,20 @@ def _require_tool(name: str, install_hint: str) -> str:
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"))
@@ -39,26 +73,16 @@ def load_dictations() -> list[dict[str, Any]]:
]
def generate_one(espeak_bin: str, ffmpeg_bin: str, dictation: dict[str, Any]) -> Path:
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"
subprocess.run(
[
espeak_bin,
"-v",
ESPEAK_VOICE,
"-s",
ESPEAK_SPEED_WPM,
"-w",
str(tmp_wav),
target_text,
],
check=True,
)
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,
@@ -87,27 +111,26 @@ def main() -> None:
)
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] = []
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 load_dictations():
if not dictation.get("audio_provisional", False):
continue
audio_id: str = dictation["audio_id"]
output_path = AUDIO_DIR / f"{audio_id}.ogg"
for dictation in provisional_dictations:
output_path = AUDIO_DIR / f"{dictation['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)
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: