Add sound effects, ambient music and volume mixing (0.5.0)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Denis Defolie
2026-10-11 22:57:37 +02:00
parent dbf513e686
commit 2d7001105d
40 changed files with 665 additions and 26 deletions
+1
View File
@@ -18,6 +18,7 @@ Le `Makefile` est le seul point d'entrée pour les opérations système. Tenir c
- `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 (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 generate-sounds` : génère les effets sonores et musiques d'ambiance (synthèse procédurale numpy + `ffmpeg`) dans `assets/audio/sfx/` et `assets/audio/music/`, sans écraser l'existant (`--force` pour régénérer)
- `make export-linux` / `make export-windows` : exports dans `build/`
- `make screenshot SCENE=… OUT=… ARGS=…` : capture le rendu réel d'une scène dans une fenêtre (outil de développement, ouvre brièvement une fenêtre)
- `make package` : réexporte puis produit les archives distribuables (exécutable unique + licence + crédits + notice) et `SHA256SUMS` dans `dist/`
+4 -1
View File
@@ -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 generate-dictation-audio export-linux export-windows package screenshot
.PHONY: setup run lint format validate-data test check content-review generate-dictation-audio generate-sounds export-linux export-windows package screenshot
setup:
tools/setup.sh
@@ -35,6 +35,9 @@ content-review:
generate-dictation-audio:
$(PYTHON) tools/generate_dictation_audio.py
generate-sounds:
$(PYTHON) tools/generate_sounds.py
export-linux:
mkdir -p build/linux
$(GODOT_BIN) --headless --path . --export-release "Linux" build/linux/aventuriers_des_mots.x86_64
+1 -1
View File
@@ -2,7 +2,7 @@
An educational French-language game for 7-9 year olds (French grades CE1-CE2): an offline 2D adventure with an explorable map, a guide character, and grammar, spelling, and dictation exercises.
Open source project, in development. Current status: version 0.4.0, playable offline on Linux and Windows — profiles, saves (with export and restore in the adult area), stars and an item collection, settings (volume, text size, animations), a progress dashboard for adults, three exercise types (multiple choice, sentence correction, dictation), a CE1 chapter on noun-group agreement (3 rules) and a CE2 chapter on grammatical homophones (a/à, et/est, son/sont, on/ont), 21 missions. All pedagogical content is still `draft` (awaiting validation) and dictation voices are placeholders (see `assets/CREDITS.md`; see `docs/specs/decisions.md` for scoping decisions and `docs/specs/cahier-des-charges.md` for the full specification, both in French — this is a French-curriculum product).
Open source project, in development. Current status: version 0.5.0, playable offline on Linux and Windows — profiles, saves (with export and restore in the adult area), stars and an item collection, sound effects and music, settings (volumes, text size, animations), a progress dashboard for adults, three exercise types (multiple choice, sentence correction, dictation), a CE1 chapter on noun-group agreement (3 rules) and a CE2 chapter on grammatical homophones (a/à, et/est, son/sont, on/ont), 21 missions. All pedagogical content is still `draft` (awaiting validation) and dictation voices are placeholders (see `assets/CREDITS.md`; see `docs/specs/decisions.md` for scoping decisions and `docs/specs/cahier-des-charges.md` for the full specification, both in French — this is a French-curriculum product).
## Tech stack
+1 -1
View File
@@ -2,7 +2,7 @@
Jeu éducatif de français pour les CE1-CE2 (7-9 ans) : une aventure 2D hors ligne, avec une carte à explorer, un personnage guide, et des exercices de grammaire, d'orthographe et de dictées.
Projet open source, en développement. Statut actuel : version 0.4.0, jouable hors ligne sur Linux et Windows — profils, sauvegardes (avec export et restauration dans l'espace adulte), étoiles et collection d'objets, réglages (volume, taille du texte, animations), tableau de progrès pour l'adulte, trois types d'exercices (choix multiple, correction de phrase, dictée), chapitre CE1 « accords dans le groupe nominal » (3 règles) et chapitre CE2 « homophones grammaticaux » (a/à, et/est, son/sont, on/ont), 21 missions. Contenu pédagogique entièrement en brouillon (`draft`, à valider) et voix des dictées provisoires (voir `assets/CREDITS.md` ; voir `docs/specs/decisions.md` pour les décisions de cadrage et `docs/specs/cahier-des-charges.md` pour la spécification complète).
Projet open source, en développement. Statut actuel : version 0.5.0, jouable hors ligne sur Linux et Windows — profils, sauvegardes (avec export et restauration dans l'espace adulte), étoiles et collection d'objets, sons et musique, réglages (volumes, taille du texte, animations), tableau de progrès pour l'adulte, trois types d'exercices (choix multiple, correction de phrase, dictée), chapitre CE1 « accords dans le groupe nominal » (3 règles) et chapitre CE2 « homophones grammaticaux » (a/à, et/est, son/sont, on/ont), 21 missions. Contenu pédagogique entièrement en brouillon (`draft`, à valider) et voix des dictées provisoires (voir `assets/CREDITS.md` ; voir `docs/specs/decisions.md` pour les décisions de cadrage et `docs/specs/cahier-des-charges.md` pour la spécification complète).
## Pile technique
+1
View File
@@ -7,6 +7,7 @@ Tous les graphismes et sons actuels sont provisoires et crees pour ce projet ; s
| `icon.svg` | Forme geometrique simple, creee pour ce projet | AGPL-3.0 (code et assets du depot) |
| `fonts/AtkinsonHyperlegible-*.ttf` | Atkinson Hyperlegible, police conçue pour la lisibilité (Braille Institute) | SIL Open Font License 1.1 (`fonts/OFL.txt`) |
| `environments/*.svg`, `characters/*.svg`, `interface/*.svg`, `interface/items/*.svg` | Illustrations vectorielles originales créées pour ce projet (décors, hibou Plume, étoiles, pictogrammes, objets de collection) ; provisoires | AGPL-3.0 (code et assets du dépôt) |
| `audio/sfx/*.ogg`, `audio/music/*.ogg` | Effets sonores et musiques d'ambiance synthétisés de façon procédurale (sinus et partiels, aucun échantillon tiers) par `tools/generate_sounds.py` ; provisoires | AGPL-3.0 (code et assets du dépôt) |
| `audio/dictations/*.ogg` (toutes les dictees) | 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) ; fichiers generes verses 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.
Binary file not shown.
+19
View File
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://8bw07yd5rev0"
path="res://.godot/imported/forest.ogg-20cf5b466391bab72010a390c5fe2635.oggvorbisstr"
[deps]
source_file="res://assets/audio/music/forest.ogg"
dest_files=["res://.godot/imported/forest.ogg-20cf5b466391bab72010a390c5fe2635.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
Binary file not shown.
+19
View File
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://xgpmpw4t7c21"
path="res://.godot/imported/marsh.ogg-5b5643dcdd68a2b954773bebef7cfaaf.oggvorbisstr"
[deps]
source_file="res://assets/audio/music/marsh.ogg"
dest_files=["res://.godot/imported/marsh.ogg-5b5643dcdd68a2b954773bebef7cfaaf.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
Binary file not shown.
+19
View File
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://be5vn175scho"
path="res://.godot/imported/click.ogg-3cad29b0a7122f4b91444dafe5dd3781.oggvorbisstr"
[deps]
source_file="res://assets/audio/sfx/click.ogg"
dest_files=["res://.godot/imported/click.ogg-3cad29b0a7122f4b91444dafe5dd3781.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
Binary file not shown.
+19
View File
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://chuo8dy555dx3"
path="res://.godot/imported/fanfare.ogg-323fa4678481f7f0141d4e4a99bdee7b.oggvorbisstr"
[deps]
source_file="res://assets/audio/sfx/fanfare.ogg"
dest_files=["res://.godot/imported/fanfare.ogg-323fa4678481f7f0141d4e4a99bdee7b.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
Binary file not shown.
+19
View File
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://p80kx4xv8kay"
path="res://.godot/imported/star.ogg-035d9bb23b7e8e1a33f675e0a50b326e.oggvorbisstr"
[deps]
source_file="res://assets/audio/sfx/star.ogg"
dest_files=["res://.godot/imported/star.ogg-035d9bb23b7e8e1a33f675e0a50b326e.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
Binary file not shown.
+19
View File
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://deei1e3jakk8h"
path="res://.godot/imported/success.ogg-9a43de578b0ebd8a0f2f9ab78212bc97.oggvorbisstr"
[deps]
source_file="res://assets/audio/sfx/success.ogg"
dest_files=["res://.godot/imported/success.ogg-9a43de578b0ebd8a0f2f9ab78212bc97.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
Binary file not shown.
+19
View File
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://be8oh2svk2iw6"
path="res://.godot/imported/try_again.ogg-cc39f10d8585af126002feade92b9083.oggvorbisstr"
[deps]
source_file="res://assets/audio/sfx/try_again.ogg"
dest_files=["res://.godot/imported/try_again.ogg-cc39f10d8585af126002feade92b9083.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
+94 -12
View File
@@ -1,26 +1,108 @@
extends Node
## Lecture des voix, dictees, musiques et effets sonores, y compris l'audio
## provisoire des dictees. Aucun fichier audio n'est encore fourni (etape
## "Graphismes et audio provisoires", a venir) : play_dictation se degrade
## silencieusement (avertissement journalise) si le fichier est absent,
## plutot que de faire planter le jeu.
## Lecture des voix (dictees), des effets sonores et de la musique d'ambiance.
## Les sons viennent d'identifiants resolus par SoundCatalog dans un
## repertoire fixe. Un fichier absent se degrade silencieusement
## (avertissement journalise) plutot que de faire planter le jeu. La musique
## baisse pendant qu'une dictee est lue, pour ne jamais couvrir la voix.
const DICTATION_AUDIO_DIR := "res://assets/audio/dictations"
const MUSIC_DUCK_DB := -12.0
const MUSIC_BASE_DB := -6.0
var _player: AudioStreamPlayer
var _voice_player: AudioStreamPlayer
var _music_player: AudioStreamPlayer
var _effect_players: Array[AudioStreamPlayer] = []
var _current_music_id := ""
var _stream_cache: Dictionary = {}
func _ready() -> void:
_player = AudioStreamPlayer.new()
add_child(_player)
AudioBuses.ensure()
_voice_player = AudioStreamPlayer.new()
_voice_player.finished.connect(_on_voice_finished)
add_child(_voice_player)
_music_player = AudioStreamPlayer.new()
_music_player.bus = AudioBuses.MUSIC
_music_player.volume_db = MUSIC_BASE_DB
add_child(_music_player)
for _i in range(4):
var player := AudioStreamPlayer.new()
player.bus = AudioBuses.EFFECTS
add_child(player)
_effect_players.append(player)
# Un clic sur n'importe quel bouton du jeu produit le meme petit son.
get_tree().node_added.connect(_on_node_added)
func play_dictation(audio_id: String) -> void:
if audio_id.is_empty():
return
var path := "%s/%s.ogg" % [DICTATION_AUDIO_DIR, audio_id]
var path := "%s/%s.ogg" % ["res://assets/audio/dictations", audio_id]
if not ResourceLoader.exists(path):
push_warning("Audio de dictee introuvable (pas encore genere) : %s" % path)
return
_player.stream = load(path)
_player.play()
_voice_player.stream = load(path)
_voice_player.play()
_music_player.volume_db = MUSIC_BASE_DB + MUSIC_DUCK_DB
func play_sfx(sfx_id: String) -> void:
var stream := _load_stream(SoundCatalog.sfx_path(sfx_id))
if stream == null:
return
for player in _effect_players:
if not player.playing:
player.stream = stream
player.play()
return
# Tous les lecteurs sont occupes : on coupe le premier plutot que d'ignorer.
_effect_players[0].stream = stream
_effect_players[0].play()
## Lance la musique d'ambiance en boucle. Sans effet si c'est deja celle-ci.
func play_music(music_id: String) -> void:
if music_id == _current_music_id and _music_player.playing:
return
var stream := _load_stream(SoundCatalog.music_path(music_id))
if stream == null:
return
if stream is AudioStreamOggVorbis:
(stream as AudioStreamOggVorbis).loop = true
_current_music_id = music_id
_music_player.stream = stream
_music_player.play()
func current_music_id() -> String:
return _current_music_id
func stop_music() -> void:
_current_music_id = ""
_music_player.stop()
func _load_stream(path: String) -> AudioStream:
if path.is_empty():
return null
if _stream_cache.has(path):
return _stream_cache[path]
if not ResourceLoader.exists(path):
push_warning("Son introuvable : %s" % path)
return null
var stream: AudioStream = load(path)
_stream_cache[path] = stream
return stream
func _on_voice_finished() -> void:
_music_player.volume_db = MUSIC_BASE_DB
func _on_node_added(node: Node) -> void:
var button := node as BaseButton
if button != null and not button.has_meta("silent"):
button.pressed.connect(play_sfx.bind("click"))
+25 -1
View File
@@ -1,5 +1,6 @@
extends Node
## Volume, taille du texte et animations. Les reglages sont ceux de l'appareil
## Volumes (general, musique, effets), taille du texte et animations. Les
## reglages sont ceux de l'appareil
## (user://settings.json), pas ceux d'un profil. Les valeurs sont nettoyees par
## AppSettings a chaque lecture ; un fichier absent ou abime redonne les valeurs
## par defaut sans erreur. Le volume agit sur le bus principal ; la taille du
@@ -11,6 +12,8 @@ const SETTINGS_PATH := "user://settings.json"
var animations_enabled := true
var volume := 0.8
var music_volume := 0.5
var effects_volume := 0.8
var text_scale_index := 0
var _base_font_sizes: Dictionary = {}
@@ -36,6 +39,16 @@ func set_volume(value: float) -> void:
_commit()
func set_music_volume(value: float) -> void:
music_volume = clampf(value, 0.0, 1.0)
_commit()
func set_effects_volume(value: float) -> void:
effects_volume = clampf(value, 0.0, 1.0)
_commit()
func set_text_scale_index(index: int) -> void:
text_scale_index = clampi(index, 0, AppSettings.TEXT_SCALES.size() - 1)
_commit()
@@ -54,6 +67,8 @@ func load_from_disk() -> void:
raw = JSON.parse_string(file.get_as_text())
var clean := AppSettings.sanitize(raw)
volume = clean["volume"]
music_volume = clean["music_volume"]
effects_volume = clean["effects_volume"]
text_scale_index = clean["text_scale_index"]
animations_enabled = clean["animations_enabled"]
@@ -62,6 +77,8 @@ func load_from_disk() -> void:
func save_to_disk() -> bool:
var data := AppSettings.defaults()
data["volume"] = volume
data["music_volume"] = music_volume
data["effects_volume"] = effects_volume
data["text_scale_index"] = text_scale_index
data["animations_enabled"] = animations_enabled
var tmp_path := SETTINGS_PATH + ".tmp"
@@ -79,7 +96,14 @@ func save_to_disk() -> bool:
## Applique les reglages au moteur : volume du bus principal et tailles du
## theme global.
func apply() -> void:
AudioBuses.ensure()
AudioServer.set_bus_volume_db(0, AppSettings.volume_db(volume))
AudioServer.set_bus_volume_db(
AudioServer.get_bus_index(AudioBuses.MUSIC), AppSettings.volume_db(music_volume)
)
AudioServer.set_bus_volume_db(
AudioServer.get_bus_index(AudioBuses.EFFECTS), AppSettings.volume_db(effects_volume)
)
_apply_text_scale()
+4 -2
View File
@@ -75,7 +75,7 @@
"world_map.settings_button": "Réglages",
"profile_select.settings_button": "Réglages",
"settings.title": "Réglages",
"settings.volume_label": "Volume du son",
"settings.volume_label": "Volume général",
"settings.text_size_label": "Taille du texte",
"settings.text_size_0": "Normal",
"settings.text_size_1": "Grand",
@@ -107,5 +107,7 @@
"skill.accord_groupe_nominal": "Accords dans le groupe nominal",
"skill.homophones_grammaticaux": "Homophones grammaticaux",
"settings.animations_on": "Avec animations",
"settings.animations_off": "Sans animations"
"settings.animations_off": "Sans animations",
"settings.music_label": "Musique",
"settings.effects_label": "Effets sonores"
}
+12
View File
@@ -314,3 +314,15 @@ Ajouts à l'arborescence proposée :
**Export et restauration** : l'export écrit la sauvegarde du profil telle quelle (JSON versionné) dans `Documents/AventuriersDesMots/` (repli `user://exports` si la plateforme n'expose pas Documents), sans jamais écraser un export précédent (`_2`, `_3`...). La restauration liste ce dossier, valide chaque fichier (migration + schéma ; les illisibles sont signalés et non restaurables) et crée toujours un NOUVEAU profil avec un identifiant neuf : aucun profil existant n'est jamais modifié. Aucun accès réseau, aucun sélecteur de fichier système (limite assumée : l'adulte dépose le fichier dans le dossier indiqué).
**Tests** : AppSettings, ProgressReport, AdultGate, SaveTransfer (export, non-écrasement, restauration non destructive, fichiers invalides, dossier absent), intitulés de notions présents dans la traduction, barrière de l'espace adulte. **Non vérifié** (sans humain) : curseur de volume à l'oreille (aucun son d'interface n'existe encore, phase suivante), tactile, export vers un vrai dossier Documents sous Windows, résolutions autres que 1152x648.
## 33. Phase « Son et musique » (version 0.5.0)
**Sons** : 5 effets (`click`, `success`, `try_again`, `star`, `fanfare`) et 2 musiques d'ambiance en boucle (`forest` pour le CE1, `marsh` pour le CE2), synthétisés de façon procédurale par `tools/generate_sounds.py` (`make generate-sounds`, numpy + ffmpeg, aucun échantillon tiers, donc aucune attribution à ajouter). Résolus par identifiant dans un répertoire fixe (`SoundCatalog`). Fichier absent : avertissement, pas de plantage.
**Intentions** : le retour « à retenter » est une courte phrase de deux notes douces qui montent, jamais un buzzer (une erreur n'est jamais punie) ; la fanfare ne retentit que pour 3 étoiles ou un objet gagné, sans lien avec la vitesse. Musiques calmes, sans percussions. Un clic sur n'importe quel bouton produit le même petit son (`AudioManager` observe les nouveaux boutons ; méta `silent` pour en exclure un).
**Mixage** : bus `Music` et `Effects` créés à l'exécution, enfants du bus principal. Réglages : volume général, musique, effets (trois curseurs, `user://settings.json`, format des sauvegardes de profil inchangé). La musique baisse de 12 dB pendant la lecture d'une dictée pour ne jamais couvrir la voix.
**Voix plus naturelles** : non traité. La voix Piper `fr_FR-siwis-medium` reste la voix provisoire (point 19) ; une voix plus naturelle suppose un enregistrement humain ou un autre modèle à évaluer à l'oreille, ce que je ne peux pas faire ici. Les dictées gardent le badge « voix provisoire ».
**Tests** : catalogue (fichiers présents, aucun chemin libre), `AudioManager` (lecture, musique mémorisée), bus idempotents, bornes des volumes. **Non vérifié** : tout ce qui s'entend (timbre, niveau relatif musique/effets/voix, boucle sans claquement, agrément) ; vérifié seulement : durées et niveaux de crête des fichiers (aucun écrêtage).
+2
View File
@@ -43,6 +43,8 @@ Tout ce qui suit ne peut pas être vérifié sans écran (rendu, mise en page, a
## Audio
- Son et musique (point 33 de `decisions.md`) : clic sur les boutons, « Bravo » (accord montant) et « Pas tout à fait » (deux notes douces, jamais agressives), étoiles une à une puis fanfare à 3 étoiles ou objet gagné ; musique de forêt (CE1) et de marais (CE2), en boucle sans coupure audible, qui baisse pendant une dictée ; trois curseurs de réglages qui agissent bien chacun sur leur bus ; tout reste agréable à volume moyen et il n'y a jamais d'écrasement de la voix.
- `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`). Testé manuellement : voix claire et bien comprise.
- 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).
+1 -1
View File
@@ -11,7 +11,7 @@ config_version=5
[application]
config/name="Les Aventuriers des mots"
config/version="0.4.0"
config/version="0.5.0"
run/main_scene="res://scenes/boot/boot.tscn"
config/features=PackedStringArray("4.6", "Forward Plus")
config/icon="res://icon.svg"
+2
View File
@@ -66,6 +66,7 @@ func _ready() -> void:
_skill_id = _rule.get("skill", "")
_current_difficulty = ProgressManager.next_difficulty_for_skill(_skill_id, 1)
$Backdrop.kind = "marsh" if _rule.get("level", "CE1") == "CE2" else "forest"
AudioManager.play_music(SoundCatalog.music_for_level(_rule.get("level", "CE1")))
_show_rule_panel()
@@ -171,6 +172,7 @@ func _handle_result(result: CorrectionResult) -> void:
ProgressManager.record_attempt(
_skill_id, result.is_correct, _attempt_number > 1, _mission["exercise_type"]
)
AudioManager.play_sfx("success" if result.is_correct else "try_again")
_show_feedback(result, support_level, is_terminal)
+1
View File
@@ -28,6 +28,7 @@ func _ready() -> void:
_new_profile_button.pressed.connect(_on_new_profile_pressed)
_delete_confirm_dialog.confirmed.connect(_on_delete_confirmed)
AudioManager.play_music("forest")
_refresh_profile_list()
+8
View File
@@ -28,6 +28,7 @@ func _ready() -> void:
var mission := ExerciseEngine.get_mission(GameManager.pending_mission_id)
var rule := ExerciseEngine.get_rule(mission.get("rule_id", ""))
_backdrop.kind = "marsh" if rule.get("level", "CE1") == "CE2" else "forest"
AudioManager.play_music(SoundCatalog.music_for_level(rule.get("level", "CE1")))
var summary := ProgressManager.complete_mission(
GameManager.pending_mission_id, GameManager.last_outcomes
@@ -47,6 +48,10 @@ func _ready() -> void:
"%s %d" % [UIText.get_text("reward_screen.total_label"), summary["total_stars"]]
)
_show_new_items(summary["new_items"])
if summary["stars"] == MissionScoring.MAX_STARS or not summary["new_items"].is_empty():
get_tree().create_timer(0.25 + 0.2 * MissionScoring.MAX_STARS).timeout.connect(
AudioManager.play_sfx.bind("fanfare")
)
_collection_button.text = UIText.get_text("reward_screen.collection_button")
_collection_button.pressed.connect(_on_collection_pressed)
@@ -64,6 +69,9 @@ func _show_stars(earned: int) -> void:
_stars_row.add_child(star)
if i < earned:
Motion.pop_in(star, 0.25 + 0.2 * i)
get_tree().create_timer(0.25 + 0.2 * i).timeout.connect(
AudioManager.play_sfx.bind("star")
)
func _show_new_items(new_items: Array) -> void:
+32 -5
View File
@@ -7,6 +7,10 @@ extends Control
@onready var _title_label: Label = %TitleLabel
@onready var _volume_label: Label = %VolumeLabel
@onready var _volume_slider: HSlider = %VolumeSlider
@onready var _music_label: Label = %MusicLabel
@onready var _music_slider: HSlider = %MusicSlider
@onready var _effects_label: Label = %EffectsLabel
@onready var _effects_slider: HSlider = %EffectsSlider
@onready var _text_size_label: Label = %TextSizeLabel
@onready var _text_size_box: HBoxContainer = %TextSizeBox
@onready var _animations_label: Label = %AnimationsLabel
@@ -23,11 +27,14 @@ func _ready() -> void:
_adult_button.text = UIText.get_text("settings.adult_button")
_back_button.text = UIText.get_text("settings.back_button")
_volume_slider.min_value = 0.0
_volume_slider.max_value = 1.0
_volume_slider.step = 1.0 / AppSettings.VOLUME_STEPS
_volume_slider.value = SettingsManager.volume
_volume_slider.value_changed.connect(_on_volume_changed)
_music_label.text = UIText.get_text("settings.music_label")
_effects_label.text = UIText.get_text("settings.effects_label")
_setup_slider(_volume_slider, SettingsManager.volume, _on_volume_changed)
_setup_slider(_music_slider, SettingsManager.music_volume, _on_music_changed)
_setup_slider(_effects_slider, SettingsManager.effects_volume, _on_effects_changed)
# Un petit son a la fin du geste fait entendre le niveau choisi.
_volume_slider.drag_ended.connect(_on_drag_ended)
_effects_slider.drag_ended.connect(_on_drag_ended)
_build_text_size_buttons()
@@ -65,10 +72,30 @@ func _build_animation_buttons() -> void:
_animations_box.add_child(button)
func _setup_slider(slider: HSlider, value: float, handler: Callable) -> void:
slider.min_value = 0.0
slider.max_value = 1.0
slider.step = 1.0 / AppSettings.VOLUME_STEPS
slider.value = value
slider.value_changed.connect(handler)
func _on_volume_changed(value: float) -> void:
SettingsManager.set_volume(value)
func _on_music_changed(value: float) -> void:
SettingsManager.set_music_volume(value)
func _on_effects_changed(value: float) -> void:
SettingsManager.set_effects_volume(value)
func _on_drag_ended(_value_changed: bool) -> void:
AudioManager.play_sfx("success")
func _on_text_size_pressed(index: int) -> void:
SettingsManager.set_text_scale_index(index)
+43 -2
View File
@@ -49,14 +49,55 @@ layout_mode = 2
theme_type_variation = &"CardTitle"
horizontal_alignment = 1
[node name="VolumeLabel" type="Label" parent="Margin/Center/Card/VBox"]
[node name="VolumeRow" type="HBoxContainer" parent="Margin/Center/Card/VBox"]
layout_mode = 2
theme_override_constants/separation = 16
[node name="VolumeLabel" type="Label" parent="Margin/Center/Card/VBox/VolumeRow"]
unique_name_in_owner = true
layout_mode = 2
custom_minimum_size = Vector2(230, 0)
theme_type_variation = &"Emphasis"
[node name="VolumeSlider" type="HSlider" parent="Margin/Center/Card/VBox"]
[node name="VolumeSlider" type="HSlider" parent="Margin/Center/Card/VBox/VolumeRow"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 4
custom_minimum_size = Vector2(0, 40)
[node name="MusicRow" type="HBoxContainer" parent="Margin/Center/Card/VBox"]
layout_mode = 2
theme_override_constants/separation = 16
[node name="MusicLabel" type="Label" parent="Margin/Center/Card/VBox/MusicRow"]
unique_name_in_owner = true
layout_mode = 2
custom_minimum_size = Vector2(230, 0)
theme_type_variation = &"Emphasis"
[node name="MusicSlider" type="HSlider" parent="Margin/Center/Card/VBox/MusicRow"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 4
custom_minimum_size = Vector2(0, 40)
[node name="EffectsRow" type="HBoxContainer" parent="Margin/Center/Card/VBox"]
layout_mode = 2
theme_override_constants/separation = 16
[node name="EffectsLabel" type="Label" parent="Margin/Center/Card/VBox/EffectsRow"]
unique_name_in_owner = true
layout_mode = 2
custom_minimum_size = Vector2(230, 0)
theme_type_variation = &"Emphasis"
[node name="EffectsSlider" type="HSlider" parent="Margin/Center/Card/VBox/EffectsRow"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 4
custom_minimum_size = Vector2(0, 40)
[node name="TextSizeLabel" type="Label" parent="Margin/Center/Card/VBox"]
+1
View File
@@ -40,6 +40,7 @@ func _ready() -> void:
func _refresh() -> void:
var is_ce2 := _viewed_level == "CE2"
_backdrop.kind = "marsh" if is_ce2 else "forest"
AudioManager.play_music(SoundCatalog.music_for_level(_viewed_level))
_title_label.text = UIText.get_text("world_map.title_ce2" if is_ce2 else "world_map.title")
_level_toggle_button.text = UIText.get_text(
"world_map.review_ce1_button" if is_ce2 else "world_map.back_to_ce2_button"
+20
View File
@@ -0,0 +1,20 @@
class_name AudioBuses
extends RefCounted
## Bus audio du jeu, crees a l'execution (pas de fichier de disposition a
## maintenir) : « Music » et « Effects » sont des enfants du bus principal,
## dont le volume est le volume general. Les voix des dictees restent sur le
## bus principal : elles ne sont jamais couvertes par la musique.
const MUSIC := "Music"
const EFFECTS := "Effects"
## Cree les bus manquants. Idempotent.
static func ensure() -> void:
for bus_name in [MUSIC, EFFECTS]:
if AudioServer.get_bus_index(bus_name) != -1:
continue
AudioServer.add_bus()
var index := AudioServer.bus_count - 1
AudioServer.set_bus_name(index, bus_name)
AudioServer.set_bus_send(index, "Master")
+1
View File
@@ -0,0 +1 @@
uid://04s3ocs22q7g
+27
View File
@@ -0,0 +1,27 @@
class_name SoundCatalog
extends RefCounted
## Identifiants des effets sonores et des musiques, resolus vers un fichier
## d'un repertoire fixe (jamais de chemin libre : voir CLAUDE.md). Code pur.
const SFX_DIR := "res://assets/audio/sfx"
const MUSIC_DIR := "res://assets/audio/music"
const SFX_IDS: Array[String] = ["click", "success", "try_again", "star", "fanfare"]
const MUSIC_IDS: Array[String] = ["forest", "marsh"]
static func sfx_path(sfx_id: String) -> String:
if not SFX_IDS.has(sfx_id):
return ""
return "%s/%s.ogg" % [SFX_DIR, sfx_id]
static func music_path(music_id: String) -> String:
if not MUSIC_IDS.has(music_id):
return ""
return "%s/%s.ogg" % [MUSIC_DIR, music_id]
## Musique d'une region selon le niveau scolaire de la regle.
static func music_for_level(level: String) -> String:
return "marsh" if level == "CE2" else "forest"
+1
View File
@@ -0,0 +1 @@
uid://dvg367cd47sam
+6
View File
@@ -20,6 +20,8 @@ static func defaults() -> Dictionary:
return {
"format_version": FORMAT_VERSION,
"volume": 0.8,
"music_volume": 0.5,
"effects_volume": 0.8,
"text_scale_index": 0,
"animations_enabled": true,
}
@@ -35,6 +37,10 @@ static func sanitize(raw: Variant) -> Dictionary:
var volume: Variant = source.get("volume")
if typeof(volume) == TYPE_FLOAT or typeof(volume) == TYPE_INT:
result["volume"] = clampf(float(volume), 0.0, 1.0)
for key: String in ["music_volume", "effects_volume"]:
var other: Variant = source.get(key)
if typeof(other) == TYPE_FLOAT or typeof(other) == TYPE_INT:
result[key] = clampf(float(other), 0.0, 1.0)
var index: Variant = source.get("text_scale_index")
if typeof(index) == TYPE_FLOAT or typeof(index) == TYPE_INT:
result["text_scale_index"] = clampi(int(index), 0, TEXT_SCALES.size() - 1)
+44
View File
@@ -0,0 +1,44 @@
extends GutTest
## Chaque identifiant sonore pointe vers un fichier present ; un identifiant
## inconnu ne produit jamais de chemin libre.
func test_every_sfx_file_exists() -> void:
for sfx_id in SoundCatalog.SFX_IDS:
assert_true(ResourceLoader.exists(SoundCatalog.sfx_path(sfx_id)), sfx_id)
func test_every_music_file_exists() -> void:
for music_id in SoundCatalog.MUSIC_IDS:
assert_true(ResourceLoader.exists(SoundCatalog.music_path(music_id)), music_id)
func test_unknown_ids_give_no_path() -> void:
assert_eq(SoundCatalog.sfx_path("../../etc/passwd"), "")
assert_eq(SoundCatalog.music_path("inconnue"), "")
func test_music_follows_the_level() -> void:
assert_eq(SoundCatalog.music_for_level("CE1"), "forest")
assert_eq(SoundCatalog.music_for_level("CE2"), "marsh")
func test_audio_manager_plays_without_crashing_and_remembers_music() -> void:
AudioManager.play_sfx("success")
AudioManager.play_music("marsh")
assert_eq(AudioManager.current_music_id(), "marsh")
AudioManager.play_music("marsh")
AudioManager.stop_music()
assert_eq(AudioManager.current_music_id(), "")
func test_buses_exist_and_are_idempotent() -> void:
AudioBuses.ensure()
AudioBuses.ensure()
assert_ne(AudioServer.get_bus_index(AudioBuses.MUSIC), -1)
assert_ne(AudioServer.get_bus_index(AudioBuses.EFFECTS), -1)
var count := 0
for i in AudioServer.bus_count:
if AudioServer.get_bus_name(i) == AudioBuses.MUSIC:
count += 1
assert_eq(count, 1)
+1
View File
@@ -0,0 +1 @@
uid://c61j06k8itgcq
+7
View File
@@ -35,3 +35,10 @@ func test_zero_volume_is_real_silence() -> void:
assert_eq(AppSettings.volume_db(0.0), -80.0)
assert_eq(AppSettings.volume_db(1.0), 0.0)
assert_lt(AppSettings.volume_db(0.5), 0.0)
func test_music_and_effects_volumes_are_bounded() -> void:
var clean := AppSettings.sanitize({"music_volume": 3, "effects_volume": -1})
assert_eq(clean["music_volume"], 1.0)
assert_eq(clean["effects_volume"], 0.0)
assert_eq(AppSettings.sanitize({})["music_volume"], AppSettings.defaults()["music_volume"])
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""Genere les effets sonores et les musiques d'ambiance du jeu.
Synthese entierement procedurale (numpy, aucune banque de sons, aucun
echantillon tiers) puis encodage en Ogg Vorbis par ffmpeg. Les sons sont donc
originaux et sous la meme licence que le depot. Outil de redaction de
contenu : il ne tourne jamais pendant le jeu. Ne regenere pas un fichier deja
present, sauf avec --force.
Intentions sonores : un retour « a retenter » reste doux et interrogatif
(jamais de buzzer : une erreur n'est pas punie) ; les musiques sont calmes,
sans percussions, pour ne pas distraire de la regle etudiee.
"""
from __future__ import annotations
import argparse
import subprocess
import tempfile
import wave
from pathlib import Path
import numpy as np
from numpy.typing import NDArray
REPO_ROOT = Path(__file__).resolve().parent.parent
SFX_DIR = REPO_ROOT / "assets" / "audio" / "sfx"
MUSIC_DIR = REPO_ROOT / "assets" / "audio" / "music"
SAMPLE_RATE = 32000
Signal = NDArray[np.float64]
def _time(duration: float) -> Signal:
return np.arange(int(duration * SAMPLE_RATE)) / SAMPLE_RATE
def bell(freq: float, duration: float, decay: float = 5.0, brightness: float = 0.4) -> Signal:
"""Note de cloche douce : fondamentale + deux partiels qui s'eteignent."""
t = _time(duration)
tone = np.sin(2 * np.pi * freq * t)
tone += brightness * np.sin(2 * np.pi * freq * 2.0 * t) * np.exp(-decay * 1.6 * t)
tone += brightness * 0.5 * np.sin(2 * np.pi * freq * 3.01 * t) * np.exp(-decay * 2.2 * t)
attack = np.minimum(t / 0.004, 1.0)
return tone * np.exp(-decay * t) * attack
def pad(freq: float, duration: float, attack: float = 1.2, release: float = 1.5) -> Signal:
"""Note tenue, douce (sinus + octave legere), a attaque et fin lentes."""
t = _time(duration)
tone = np.sin(2 * np.pi * freq * t) + 0.25 * np.sin(2 * np.pi * freq * 2 * t)
tone += 0.12 * np.sin(2 * np.pi * freq * 1.003 * t) # leger battement
env = np.minimum(t / attack, 1.0) * np.minimum((duration - t) / release, 1.0)
return tone * np.clip(env, 0.0, 1.0)
def place(target: Signal, sound: Signal, start: float, gain: float = 1.0) -> None:
begin = int(start * SAMPLE_RATE)
end = min(begin + len(sound), len(target))
if begin < len(target):
target[begin:end] += sound[: end - begin] * gain
def normalise(signal: Signal, peak: float) -> Signal:
top = float(np.max(np.abs(signal)))
return signal * (peak / top) if top > 0 else signal
def hz(note: str) -> float:
names = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}
pitch = names[note[0]] + (1 if note[1] == "#" else 0)
octave = int(note[-1])
midi = 12 * (octave + 1) + pitch
return 440.0 * 2 ** ((midi - 69) / 12)
def make_sfx() -> dict[str, Signal]:
sounds: dict[str, Signal] = {}
click = bell(hz("A5"), 0.12, decay=38.0, brightness=0.2)
sounds["click"] = normalise(click, 0.45)
success = np.zeros(int(1.0 * SAMPLE_RATE))
for index, note in enumerate(["C5", "E5", "G5"]):
place(success, bell(hz(note), 0.8, decay=4.5), 0.1 * index)
sounds["success"] = normalise(success, 0.6)
# Deux notes douces qui montent legerement : une question, pas un reproche.
again = np.zeros(int(0.8 * SAMPLE_RATE))
place(again, bell(hz("E4"), 0.6, decay=6.0, brightness=0.25), 0.0)
place(again, bell(hz("G4"), 0.6, decay=6.0, brightness=0.25), 0.18)
sounds["try_again"] = normalise(again, 0.5)
star = bell(hz("E6"), 0.9, decay=4.0, brightness=0.5)
sounds["star"] = normalise(star, 0.55)
fanfare = np.zeros(int(2.0 * SAMPLE_RATE))
for index, note in enumerate(["C5", "E5", "G5", "C6"]):
place(fanfare, bell(hz(note), 1.2, decay=3.5), 0.14 * index)
for note in ["C5", "E5", "G5", "C6"]:
place(fanfare, bell(hz(note), 1.4, decay=2.8), 0.6, gain=0.7)
sounds["fanfare"] = normalise(fanfare, 0.65)
return sounds
def make_music(
chords: list[list[str]],
melody_scale: list[str],
seconds_per_chord: float,
seed: int,
melody_density: float,
) -> Signal:
"""Boucle : accords tenus + quelques notes de melodie pentatonique.
La queue de la derniere note est repliee sur le debut pour que la boucle
ne presente ni coupure ni claquement.
"""
rng = np.random.default_rng(seed)
body = seconds_per_chord * len(chords)
tail = 3.0
track = np.zeros(int((body + tail) * SAMPLE_RATE))
for index, chord in enumerate(chords):
start = index * seconds_per_chord
for note in chord:
place(track, pad(hz(note), seconds_per_chord + 1.2), start, gain=0.35)
beat = seconds_per_chord / 4
for step in range(len(chords) * 4):
if rng.random() < melody_density:
note = melody_scale[int(rng.integers(len(melody_scale)))]
place(track, bell(hz(note), 2.0, decay=2.4, brightness=0.3), step * beat, gain=0.28)
folded = track[: int(body * SAMPLE_RATE)].copy()
overflow = track[int(body * SAMPLE_RATE) :]
folded[: len(overflow)] += overflow
return normalise(folded, 0.5)
def make_all_music() -> dict[str, Signal]:
forest = make_music(
chords=[["C3", "G3", "E4"], ["A2", "E3", "C4"], ["F2", "C3", "A3"], ["G2", "D3", "B3"]] * 2,
melody_scale=["C5", "D5", "E5", "G5", "A5", "C6"],
seconds_per_chord=4.0,
seed=11,
melody_density=0.45,
)
marsh = make_music(
chords=[["A2", "E3", "C4"], ["F2", "C3", "A3"], ["D3", "A3", "F4"], ["E3", "B3", "G4"]] * 2,
melody_scale=["A4", "C5", "D5", "E5", "G5"],
seconds_per_chord=5.0,
seed=23,
melody_density=0.35,
)
return {"forest": forest, "marsh": marsh}
def write_ogg(signal: Signal, destination: Path, quality: str = "3") -> None:
destination.parent.mkdir(parents=True, exist_ok=True)
pcm = (np.clip(signal, -1.0, 1.0) * 32767).astype(np.int16)
with tempfile.TemporaryDirectory() as tmp:
wav_path = Path(tmp) / "source.wav"
with wave.open(str(wav_path), "wb") as handle:
handle.setnchannels(1)
handle.setsampwidth(2)
handle.setframerate(SAMPLE_RATE)
handle.writeframes(pcm.tobytes())
subprocess.run(
["ffmpeg", "-y", "-loglevel", "error", "-i", str(wav_path),
"-c:a", "libvorbis", "-q:a", quality, str(destination)],
check=True,
)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--force", action="store_true", help="regenere les fichiers existants")
args = parser.parse_args()
jobs: list[tuple[Path, Signal]] = []
for name, signal in make_sfx().items():
jobs.append((SFX_DIR / f"{name}.ogg", signal))
for name, signal in make_all_music().items():
jobs.append((MUSIC_DIR / f"{name}.ogg", signal))
for destination, signal in jobs:
if destination.exists() and not args.force:
print(f"existe deja : {destination.relative_to(REPO_ROOT)}")
continue
write_ogg(signal, destination)
print(f"genere : {destination.relative_to(REPO_ROOT)}")
if __name__ == "__main__":
main()