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
+3
View File
@@ -7,6 +7,9 @@ build/
addons/gut/
.venv/
# Modeles de voix telecharges par tools/generate_dictation_audio.py, non versionnes
.cache/
# Python
__pycache__/
*.pyc
+1 -1
View File
@@ -17,7 +17,7 @@ Le `Makefile` est le seul point d'entrée pour les opérations système. Tenir c
- `make test` : tests en mode headless
- `make check` : lint + validate-data + test (à passer avant chaque commit)
- `make content-review` : régénère `docs/review/content-review.md` à partir de `data/`
- `make generate-dictation-audio` : génère l'audio provisoire des dictées (eSpeak-ng + ffmpeg, requis sur la machine) dans `assets/audio/dictations/`, sans écraser l'existant (`--force` pour régénérer)
- `make generate-dictation-audio` : génère l'audio provisoire des dictées (voix neuronale Piper via `.venv`, `ffmpeg` requis sur la machine) dans `assets/audio/dictations/`, sans écraser l'existant (`--force` pour régénérer)
- `make export-linux` / `make export-windows` : exports dans `build/`
## Conventions de code
+3 -1
View File
@@ -5,6 +5,8 @@ Tous les graphismes et sons actuels sont provisoires, crees pour ce projet, sans
| Fichier | Origine | Licence |
|---|---|---|
| `icon.svg` | Forme geometrique simple, creee pour ce projet | AGPL-3.0 (code et assets du depot) |
| `audio/dictations/ce1_accord_adjectif_dictee_01.ogg` | Voix de synthese generee hors ligne avec eSpeak NG 1.52 (voix `fr-fr`), via `tools/generate_dictation_audio.py` | GPL-3.0 (eSpeak NG) ; fichier genere verse en AGPL-3.0 avec le reste du depot |
| `audio/dictations/ce1_accord_adjectif_dictee_01.ogg` | Voix de synthese neuronale generee hors ligne avec Piper (modele `fr_FR-siwis-medium`, entraine sur le corpus SIWIS de l'EPFL), via `tools/generate_dictation_audio.py` | MIT (Piper) ; dataset SIWIS en CC-BY 4.0 (attribution requise) ; fichier genere verse en AGPL-3.0 avec le reste du depot |
Chaque nouvelle dictee ajoutee a `data/dictations/` doit avoir son audio genere avec `make generate-dictation-audio`, puis une ligne ajoutee ici. Toutes les voix actuelles sont provisoires (`audio_provisional: true` dans la donnee, badge « voix provisoire » affiche a l'enfant) : a remplacer par une vraie voix humaine avant diffusion.
Attribution requise par la licence CC-BY 4.0 du corpus SIWIS : Honnet, P.-E., Lazaridis, A., Garner, P. N., & Yamagishi, J. (2017). *The SIWIS French Speech Synthesis Database*. Idiap Research Institute / EPFL.
+12
View File
@@ -172,3 +172,15 @@ Ajouts à l'arborescence proposée :
**Généré** : `ce1_accord_adjectif_dictee_01.ogg` (seule dictée existante). Source notée dans `assets/CREDITS.md`.
**Vérification** : nouveau test headless (`tests/data/test_dictation_audio.gd`) qui échoue si une dictée `audio_provisional` n'a pas de fichier `.ogg` chargeable — garde-fou contre un futur ajout de dictée sans régénération de l'audio, repéré plus tôt que par un test manuel. La qualité d'écoute elle-même (volume, clarté) reste non vérifiable sans écran/haut-parleur, donc à faire à la main (voir `docs/testing/manual-checklist.md`).
**Suite** : la voix eSpeak NG utilisée ici s'est révélée inintelligible à l'écoute (voir point 20) et a été remplacée.
## 20. Audio provisoire des dictées : eSpeak NG remplacé par une voix neuronale Piper
**Symptôme** : test manuel sur `ce1_accord_adjectif_dictee_01.ogg` (point 19, voix eSpeak NG par synthèse de formants) : « voix inintelligible ». Ralentir le débit ou ajuster les paramètres d'eSpeak NG ne change pas la nature du problème — une synthèse par formants reste mécanique par construction, ce n'est pas qu'une question de vitesse.
**Remplacement** : `tools/generate_dictation_audio.py` utilise maintenant une voix neuronale hors ligne via la bibliothèque Python `piper-tts` (modèle `fr_FR-siwis-medium`, un locuteur, qualité medium, licence MIT pour Piper / CC-BY 4.0 pour le corpus SIWIS sous-jacent — attribution dans `assets/CREDITS.md`). Toujours aucun accès réseau à l'exécution du jeu : le modèle de voix (~60 Mo) est téléchargé une seule fois, à la demande, dans `.cache/piper-voices/` (non versionné, voir `.gitignore`), au moment de la rédaction de contenu, jamais par le jeu lui-même. `LENGTH_SCALE = 1.1` ralentit légèrement le débit par rapport à une diction adulte par défaut. eSpeak NG n'est plus une dépendance du projet ; `ffmpeg` (déjà présent sur la machine) reste utilisé pour l'encodage final en Ogg Vorbis. `piper-tts` est ajouté à `tools/setup.sh` (environnement Python partagé avec gdtoolkit/pyright).
**Effet de bord** : `pyright` ne résolvait pas l'import `piper` en ciblant l'environnement Python par défaut plutôt que `.venv/`. Ajout de `pyrightconfig.json` (`venvPath`/`venv`) à la racine pour que `make lint` continue de fonctionner sans option supplémentaire.
**Régénéré** : `ce1_accord_adjectif_dictee_01.ogg` avec la nouvelle voix. Non encore réécouté par un humain au moment d'écrire ceci — à confirmer en test manuel que l'intelligibilité est bien résolue, pas seulement supposée meilleure.
+2 -2
View File
@@ -14,7 +14,7 @@ Tout ce qui suit ne peut pas être vérifié sans écran (rendu, mise en page, a
8. Mission « Le pré des petits mots » (choix multiple) : les réponses sont cliquables, une réponse juste affiche « Bravo ! », une réponse fausse affiche « Pas tout à fait » avec un indice.
9. Refaire une erreur sur le même exercice : after la 2e erreur, l'explication remplace l'indice ; après la 3e, la bonne réponse est montrée et l'exercice passe au suivant sans blocage.
10. Mission « Le pont des phrases » (correction de phrase) : toucher un mot le fait apparaître comme sélectionné (état visuel distinct, pas seulement une couleur) ; un champ de saisie apparaît sous le mot sélectionné ; toucher à nouveau le mot fait disparaître le champ.
11. Mission « La clairière de l'écho » (dictée) : à l'arrivée sur l'exercice, la voix de synthèse doit se faire entendre automatiquement ; le bouton « Réécouter » la rejoue sans planter ; le badge « Voix provisoire » est visible ; la zone de saisie accepte le texte et les accents. Le volume et la clarté de cette voix provisoire (eSpeak NG) sont à juger à l'oreille — rien d'étonnant à ce qu'elle sonne robotique, c'est volontairement provisoire (`audio_provisional: true`).
11. Mission « La clairière de l'écho » (dictée) : à l'arrivée sur l'exercice, la voix de synthèse doit se faire entendre automatiquement ; le bouton « Réécouter » la rejoue sans planter ; le badge « Voix provisoire » est visible ; la zone de saisie accepte le texte et les accents. La voix (Piper, point 20 de `decisions.md`) doit être clairement intelligible — une première version avec eSpeak NG a été jugée inintelligible et remplacée ; à reconfirmer à l'oreille que ce n'est plus le cas.
12. Après 3 exercices, écran de récompense avec le nombre d'étoiles, puis retour à la carte.
13. Quitter le jeu puis relancer `make run` : le profil et sa progression doivent être conservés (sélectionner le même profil, vérifier que l'état affiché sur la carte a changé par rapport à l'état initial).
14. Depuis la sélection de profil, supprimer un profil : une confirmation explicite doit apparaître avant la suppression effective ; annuler doit ne rien supprimer.
@@ -28,7 +28,7 @@ Tout ce qui suit ne peut pas être vérifié sans écran (rendu, mise en page, a
## Audio
- `ce1_accord_adjectif_dictee_01` a un fichier audio provisoire généré par `make generate-dictation-audio` (eSpeak NG + ffmpeg, voir `assets/CREDITS.md`). À vérifier à l'oreille : la voix se déclenche à l'arrivée sur l'exercice et au clic sur « Réécouter », le volume est audible, la phrase reste comprise malgré l'accent robotique de la synthèse.
- `ce1_accord_adjectif_dictee_01` a un fichier audio provisoire généré par `make generate-dictation-audio` (voix neuronale Piper, voir `assets/CREDITS.md`). À vérifier à l'oreille : la voix se déclenche à l'arrivée sur l'exercice et au clic sur « Réécouter », le volume est audible, la phrase est clairement comprise (pas seulement devinée).
- Toute dictée ajoutée sans régénérer son audio se dégrade silencieusement (avertissement en console, pas de plantage) : vérifier qu'aucune dictée manquante ne passe inaperçue avant diffusion (`make generate-dictation-audio` ne régénère pas l'existant, `--force` pour forcer).
## Exports
+4
View File
@@ -0,0 +1,4 @@
{
"venvPath": ".",
"venv": ".venv"
}
+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:
+5 -3
View File
@@ -1,7 +1,9 @@
#!/usr/bin/env bash
# Installe l'outillage de developpement : binaire Godot pinne, addon GUT,
# environnement Python pour gdtoolkit. Rien de ceci n'est commite (voir .gitignore) :
# ce script est la seule source de verite pour reconstruire l'outillage.
# environnement Python pour gdtoolkit/pyright/piper-tts (ce dernier pour
# tools/generate_dictation_audio.py). Rien de ceci n'est commite (voir
# .gitignore) : ce script est la seule source de verite pour reconstruire
# l'outillage.
set -euo pipefail
cd "$(dirname "$0")/.."
@@ -66,7 +68,7 @@ if [ ! -d ".venv" ]; then
python3 -m venv .venv
fi
./.venv/bin/pip install --quiet --upgrade pip
./.venv/bin/pip install --quiet "gdtoolkit==${GDTOOLKIT_VERSION}" "pyright==${PYRIGHT_VERSION}"
./.venv/bin/pip install --quiet "gdtoolkit==${GDTOOLKIT_VERSION}" "pyright==${PYRIGHT_VERSION}" piper-tts
echo "Import du projet (enregistre les class_name globaux : SchemaValidator, DataLoader...)."
"${GODOT_BIN}" --headless --path . --import >/dev/null