From 73440ba226507c9224fe4c3633835d857c42d041 Mon Sep 17 00:00:00 2001 From: Denis Defolie Date: Sun, 11 Oct 2026 18:40:11 +0200 Subject: [PATCH] =?UTF-8?q?Generate=20provisional=20dictation=20audio=20so?= =?UTF-8?q?=20dict=C3=A9e=20missions=20can=20be=20played?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 1 + Makefile | 5 +- assets/CREDITS.md | 3 +- .../ce1_accord_adjectif_dictee_01.ogg | Bin 0 -> 12546 bytes .../ce1_accord_adjectif_dictee_01.ogg.import | 19 +++ docs/specs/decisions.md | 10 ++ docs/testing/manual-checklist.md | 5 +- tests/data/test_dictation_audio.gd | 26 ++++ tests/data/test_dictation_audio.gd.uid | 1 + tools/generate_dictation_audio.py | 118 ++++++++++++++++++ 10 files changed, 184 insertions(+), 4 deletions(-) create mode 100644 assets/audio/dictations/ce1_accord_adjectif_dictee_01.ogg create mode 100644 assets/audio/dictations/ce1_accord_adjectif_dictee_01.ogg.import create mode 100644 tests/data/test_dictation_audio.gd create mode 100644 tests/data/test_dictation_audio.gd.uid create mode 100644 tools/generate_dictation_audio.py diff --git a/CLAUDE.md b/CLAUDE.md index fb7b053..f8aab7b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 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 export-linux` / `make export-windows` : exports dans `build/` ## Conventions de code diff --git a/Makefile b/Makefile index f04f740..4764b66 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ PYRIGHT := $(VENV)/bin/pyright PYTHON := $(VENV)/bin/python3 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: tools/setup.sh @@ -32,6 +32,9 @@ check: lint validate-data test content-review: $(PYTHON) tools/generate_content_review.py +generate-dictation-audio: + $(PYTHON) tools/generate_dictation_audio.py + export-linux: mkdir -p build/linux $(GODOT_BIN) --headless --path . --export-release "Linux" build/linux/aventuriers_des_mots.x86_64 diff --git a/assets/CREDITS.md b/assets/CREDITS.md index 3f58ce8..22fa2e3 100644 --- a/assets/CREDITS.md +++ b/assets/CREDITS.md @@ -5,5 +5,6 @@ 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 | -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. diff --git a/assets/audio/dictations/ce1_accord_adjectif_dictee_01.ogg b/assets/audio/dictations/ce1_accord_adjectif_dictee_01.ogg new file mode 100644 index 0000000000000000000000000000000000000000..746997d19474d812ca26e8915d33282905c19c40 GIT binary patch literal 12546 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 literal 0 HcmV?d00001 diff --git a/assets/audio/dictations/ce1_accord_adjectif_dictee_01.ogg.import b/assets/audio/dictations/ce1_accord_adjectif_dictee_01.ogg.import new file mode 100644 index 0000000..aaa8c38 --- /dev/null +++ b/assets/audio/dictations/ce1_accord_adjectif_dictee_01.ogg.import @@ -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 diff --git a/docs/specs/decisions.md b/docs/specs/decisions.md index 845d178..2ff949c 100644 --- a/docs/specs/decisions.md +++ b/docs/specs/decisions.md @@ -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). **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/.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`). diff --git a/docs/testing/manual-checklist.md b/docs/testing/manual-checklist.md index 0eea0c8..e0a2b81 100644 --- a/docs/testing/manual-checklist.md +++ b/docs/testing/manual-checklist.md @@ -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) : 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. 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,8 @@ Tout ce qui suit ne peut pas être vérifié sans écran (rendu, mise en page, a ## 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 diff --git a/tests/data/test_dictation_audio.gd b/tests/data/test_dictation_audio.gd new file mode 100644 index 0000000..d64fe95 --- /dev/null +++ b/tests/data/test_dictation_audio.gd @@ -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 + ) diff --git a/tests/data/test_dictation_audio.gd.uid b/tests/data/test_dictation_audio.gd.uid new file mode 100644 index 0000000..2030ce2 --- /dev/null +++ b/tests/data/test_dictation_audio.gd.uid @@ -0,0 +1 @@ +uid://cjjn1kkkc0is6 diff --git a/tools/generate_dictation_audio.py b/tools/generate_dictation_audio.py new file mode 100644 index 0000000..8893811 --- /dev/null +++ b/tools/generate_dictation_audio.py @@ -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()