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:
Denis Defolie
2026-10-11 18:40:11 +02:00
parent 04a1b5ee1b
commit 73440ba226
10 changed files with 184 additions and 4 deletions
+1
View File
@@ -17,6 +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 test` : tests en mode headless
- `make check` : lint + validate-data + test (à passer avant chaque commit) - `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 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 export-linux` / `make export-windows` : exports dans `build/` - `make export-linux` / `make export-windows` : exports dans `build/`
## Conventions de code ## Conventions de code
+4 -1
View File
@@ -6,7 +6,7 @@ PYRIGHT := $(VENV)/bin/pyright
PYTHON := $(VENV)/bin/python3 PYTHON := $(VENV)/bin/python3
GD_SOURCES := autoload scripts tests GD_SOURCES := autoload scripts tests
.PHONY: setup run lint format validate-data test check content-review export-linux export-windows .PHONY: setup run lint format validate-data test check content-review generate-dictation-audio export-linux export-windows
setup: setup:
tools/setup.sh tools/setup.sh
@@ -32,6 +32,9 @@ check: lint validate-data test
content-review: content-review:
$(PYTHON) tools/generate_content_review.py $(PYTHON) tools/generate_content_review.py
generate-dictation-audio:
$(PYTHON) tools/generate_dictation_audio.py
export-linux: export-linux:
mkdir -p build/linux mkdir -p build/linux
$(GODOT_BIN) --headless --path . --export-release "Linux" build/linux/aventuriers_des_mots.x86_64 $(GODOT_BIN) --headless --path . --export-release "Linux" build/linux/aventuriers_des_mots.x86_64
+2 -1
View File
@@ -5,5 +5,6 @@ Tous les graphismes et sons actuels sont provisoires, crees pour ce projet, sans
| Fichier | Origine | Licence | | Fichier | Origine | Licence |
|---|---|---| |---|---|---|
| `icon.svg` | Forme geometrique simple, creee pour ce projet | AGPL-3.0 (code et assets du depot) | | `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 |
Les voix de dictee provisoires (a venir) seront generees par synthese vocale hors ligne et listees ici au fur et a mesure, avec la mention `review_status: draft` correspondante dans les donnees. 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.
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://nheq35mknxfw"
path="res://.godot/imported/ce1_accord_adjectif_dictee_01.ogg-74739a16a08bfb592c5dfe269dc458a5.oggvorbisstr"
[deps]
source_file="res://assets/audio/dictations/ce1_accord_adjectif_dictee_01.ogg"
dest_files=["res://.godot/imported/ce1_accord_adjectif_dictee_01.ogg-74739a16a08bfb592c5dfe269dc458a5.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
+10
View File
@@ -162,3 +162,13 @@ Ajouts à l'arborescence proposée :
**Ajout** : trois corrections de phrase supplémentaires pour `regle_accord_adjectif` (`ce1_accord_adjectif_correction_02` à `_04`), portant le total à 4 et couvrant les trois niveaux de difficulté (1, 2, 3) ainsi que les deux axes de l'accord (genre et nombre), avec un cas difficulté 3 où le nom et l'adjectif sont séparés par d'autres mots. Toutes en `review_status: draft` (non relues par un humain, cf. CLAUDE.md). **Ajout** : trois corrections de phrase supplémentaires pour `regle_accord_adjectif` (`ce1_accord_adjectif_correction_02` à `_04`), portant le total à 4 et couvrant les trois niveaux de difficulté (1, 2, 3) ainsi que les deux axes de l'accord (genre et nombre), avec un cas difficulté 3 où le nom et l'adjectif sont séparés par d'autres mots. Toutes en `review_status: draft` (non relues par un humain, cf. CLAUDE.md).
**Reste à faire** : les 8 choix multiples par règle (2 existent) et les 3 dictées par chapitre (1 existe) ne sont pas couverts par ce travail ; le volume minimal du cahier des charges n'est donc pas encore entièrement atteint pour cette règle. **Reste à faire** : les 8 choix multiples par règle (2 existent) et les 3 dictées par chapitre (1 existe) ne sont pas couverts par ce travail ; le volume minimal du cahier des charges n'est donc pas encore entièrement atteint pour cette règle.
## 19. Audio provisoire des dictées : génération effective
**Constat manuel** : testée à la main, la mission « La clairière de l'écho » ne pouvait pas être réellement jouée — aucun fichier audio n'existait (point 8), donc rien à écouter avant de dicter la phrase.
**Mise en œuvre** : `tools/generate_dictation_audio.py` génère chaque dictée marquée `audio_provisional: true` en deux étapes outillées localement, sans accès réseau à l'exécution du jeu ni moteur TTS embarqué (conforme au point 8 et à l'invariant « aucun accès réseau ») : synthèse vocale hors ligne avec eSpeak NG (voix `fr-fr`, débit ralenti à 135 mots/minute pour un enfant de 7-9 ans) vers un `.wav` temporaire, puis encodage en Ogg Vorbis avec ffmpeg vers `assets/audio/dictations/<audio_id>.ogg`. N'écrase jamais un fichier existant sans `--force`. Exposé via `make generate-dictation-audio`. eSpeak NG n'est pas vendorisé (contrairement à Godot/GUT) : il doit être installé au niveau système (`apt install espeak-ng` ou équivalent) avant d'utiliser cette commande ; un message d'erreur explicite guide si l'outil est absent.
**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`).
+3 -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. 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. 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. 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) : le bouton « Réécouter » ne doit rien faire planter (aucun fichier audio n'existe encore : vérifier qu'un avertissement apparaît dans la console, pas un crash) ; le badge « Voix provisoire » est visible ; la zone de saisie accepte le texte et les accents. 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`).
12. Après 3 exercices, écran de récompense avec le nombre d'étoiles, puis retour à la carte. 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). 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. 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,8 @@ Tout ce qui suit ne peut pas être vérifié sans écran (rendu, mise en page, a
## Audio ## Audio
- Aucun fichier audio n'a encore été généré (`assets/audio/dictations/` est vide) : c'est attendu à ce stade (étape « Graphismes et audio provisoires », à venir). Vérifier seulement que l'absence de fichier ne provoque pas de plantage, juste un avertissement en console. - `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.
- 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 ## Exports
+26
View File
@@ -0,0 +1,26 @@
extends GutTest
## L'absence de fichier audio pour une dictee bloque tout test manuel de ce
## type d'exercice (voir docs/specs/decisions.md, point 19) : ce test garantit
## que chaque dictee marquee `audio_provisional: true` a reellement un fichier
## `.ogg` chargeable, pas seulement une entree de donnees qui le promet.
func test_every_provisional_dictation_has_a_loadable_audio_file() -> void:
var dictation_paths := DataLoader.find_json_files("res://data/dictations")
assert_false(dictation_paths.is_empty(), "il doit y avoir au moins une dictee a verifier")
for path: String in dictation_paths:
var dictation: Dictionary = DataLoader.read_json(path)
if not dictation.get("audio_provisional", false):
continue
var audio_id: String = dictation["audio_id"]
var audio_path := "res://assets/audio/dictations/%s.ogg" % audio_id
assert_true(
ResourceLoader.exists(audio_path),
"audio manquant pour %s : %s" % [audio_id, audio_path],
)
var stream: Variant = load(audio_path)
assert_true(
stream is AudioStream, "le fichier charge pour %s doit etre un AudioStream" % audio_id
)
+1
View File
@@ -0,0 +1 @@
uid://cjjn1kkkc0is6
+118
View File
@@ -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()