diff --git a/assets/audio/music/meadow.ogg b/assets/audio/music/meadow.ogg new file mode 100644 index 0000000..e2522f5 Binary files /dev/null and b/assets/audio/music/meadow.ogg differ diff --git a/assets/audio/music/meadow.ogg.import b/assets/audio/music/meadow.ogg.import new file mode 100644 index 0000000..28f7c71 --- /dev/null +++ b/assets/audio/music/meadow.ogg.import @@ -0,0 +1,19 @@ +[remap] + +importer="oggvorbisstr" +type="AudioStreamOggVorbis" +uid="uid://bx25q1y3gawje" +path="res://.godot/imported/meadow.ogg-c923775439afb03b78187075ddba7157.oggvorbisstr" + +[deps] + +source_file="res://assets/audio/music/meadow.ogg" +dest_files=["res://.godot/imported/meadow.ogg-c923775439afb03b78187075ddba7157.oggvorbisstr"] + +[params] + +loop=false +loop_offset=0 +bpm=0 +beat_count=0 +bar_beats=4 diff --git a/assets/audio/music/night.ogg b/assets/audio/music/night.ogg new file mode 100644 index 0000000..7ca3c9e Binary files /dev/null and b/assets/audio/music/night.ogg differ diff --git a/assets/audio/music/night.ogg.import b/assets/audio/music/night.ogg.import new file mode 100644 index 0000000..50d97d0 --- /dev/null +++ b/assets/audio/music/night.ogg.import @@ -0,0 +1,19 @@ +[remap] + +importer="oggvorbisstr" +type="AudioStreamOggVorbis" +uid="uid://bn0lqeepcj6v6" +path="res://.godot/imported/night.ogg-662b927d91ff65e14ea877eeff9e3458.oggvorbisstr" + +[deps] + +source_file="res://assets/audio/music/night.ogg" +dest_files=["res://.godot/imported/night.ogg-662b927d91ff65e14ea877eeff9e3458.oggvorbisstr"] + +[params] + +loop=false +loop_offset=0 +bpm=0 +beat_count=0 +bar_beats=4 diff --git a/assets/audio/music/river.ogg b/assets/audio/music/river.ogg new file mode 100644 index 0000000..273b31e Binary files /dev/null and b/assets/audio/music/river.ogg differ diff --git a/assets/audio/music/river.ogg.import b/assets/audio/music/river.ogg.import new file mode 100644 index 0000000..9691962 --- /dev/null +++ b/assets/audio/music/river.ogg.import @@ -0,0 +1,19 @@ +[remap] + +importer="oggvorbisstr" +type="AudioStreamOggVorbis" +uid="uid://4usotc2day54" +path="res://.godot/imported/river.ogg-39ddba8950bdb36622cac8424794d138.oggvorbisstr" + +[deps] + +source_file="res://assets/audio/music/river.ogg" +dest_files=["res://.godot/imported/river.ogg-39ddba8950bdb36622cac8424794d138.oggvorbisstr"] + +[params] + +loop=false +loop_offset=0 +bpm=0 +beat_count=0 +bar_beats=4 diff --git a/autoload/audio_manager.gd b/autoload/audio_manager.gd index b407ac0..b253709 100644 --- a/autoload/audio_manager.gd +++ b/autoload/audio_manager.gd @@ -76,6 +76,12 @@ func play_music(music_id: String) -> void: _music_player.play() +## Musique de fond selon le choix des reglages et le niveau scolaire de la +## scene appelante (« automatique » = celle de la region). +func play_region_music(level: String) -> void: + play_music(SoundCatalog.resolve_music(SettingsManager.music_track, level)) + + func current_music_id() -> String: return _current_music_id diff --git a/autoload/settings_manager.gd b/autoload/settings_manager.gd index 7d96a62..f01a56b 100644 --- a/autoload/settings_manager.gd +++ b/autoload/settings_manager.gd @@ -14,6 +14,7 @@ var animations_enabled := true var volume := 0.8 var music_volume := 0.5 var effects_volume := 0.8 +var music_track := SoundCatalog.MUSIC_AUTO var text_scale_index := 0 var _base_font_sizes: Dictionary = {} @@ -49,6 +50,11 @@ func set_effects_volume(value: float) -> void: _commit() +func set_music_track(choice: String) -> void: + music_track = choice if SoundCatalog.music_choices().has(choice) else SoundCatalog.MUSIC_AUTO + _commit() + + func set_text_scale_index(index: int) -> void: text_scale_index = clampi(index, 0, AppSettings.TEXT_SCALES.size() - 1) _commit() @@ -69,6 +75,7 @@ func load_from_disk() -> void: volume = clean["volume"] music_volume = clean["music_volume"] effects_volume = clean["effects_volume"] + music_track = clean["music_track"] text_scale_index = clean["text_scale_index"] animations_enabled = clean["animations_enabled"] @@ -79,6 +86,7 @@ func save_to_disk() -> bool: data["volume"] = volume data["music_volume"] = music_volume data["effects_volume"] = effects_volume + data["music_track"] = music_track data["text_scale_index"] = text_scale_index data["animations_enabled"] = animations_enabled var tmp_path := SETTINGS_PATH + ".tmp" diff --git a/data/localization/fr.json b/data/localization/fr.json index 44b5d99..08652d1 100644 --- a/data/localization/fr.json +++ b/data/localization/fr.json @@ -109,5 +109,12 @@ "settings.animations_on": "Avec animations", "settings.animations_off": "Sans animations", "settings.music_label": "Musique", - "settings.effects_label": "Effets sonores" + "settings.effects_label": "Effets sonores", + "settings.track_label": "Musique de fond", + "music.auto": "Automatique (selon le lieu)", + "music.forest": "Forêt", + "music.marsh": "Marais", + "music.meadow": "Prairie", + "music.river": "Rivière", + "music.night": "Nuit" } diff --git a/docs/specs/decisions.md b/docs/specs/decisions.md index 2f9a613..f0f836a 100644 --- a/docs/specs/decisions.md +++ b/docs/specs/decisions.md @@ -326,3 +326,5 @@ Ajouts à l'arborescence proposée : **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). + +**Addendum (0.5.1) — choix de la musique de fond** (demande utilisateur) : 5 morceaux au choix, `forest`, `marsh`, `meadow` (prairie), `river` (rivière), `night` (nuit), plus un choix « Automatique » (par défaut : forêt en CE1, marais en CE2, comme avant). Le choix est un réglage de l'appareil (`music_track` dans `user://settings.json`, valeur inconnue ramenée à « auto ») : le format des sauvegardes de profil est inchangé. Sélectionner un morceau dans les réglages le fait entendre aussitôt. Sons toujours provisoires et jamais écoutés ici. diff --git a/docs/testing/manual-checklist.md b/docs/testing/manual-checklist.md index 883b11b..d83fed6 100644 --- a/docs/testing/manual-checklist.md +++ b/docs/testing/manual-checklist.md @@ -43,7 +43,7 @@ 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. +- 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 ; cinq morceaux au choix dans « Musique de fond » (le choix s'entend aussitôt et est conservé après redémarrage) ; 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). diff --git a/project.godot b/project.godot index d67bc16..65c0a62 100644 --- a/project.godot +++ b/project.godot @@ -11,7 +11,7 @@ config_version=5 [application] config/name="Les Aventuriers des mots" -config/version="0.5.0" +config/version="0.5.1" run/main_scene="res://scenes/boot/boot.tscn" config/features=PackedStringArray("4.6", "Forward Plus") config/icon="res://icon.svg" diff --git a/scenes/exercises/exercise_host.gd b/scenes/exercises/exercise_host.gd index b5f4090..6b3e02c 100644 --- a/scenes/exercises/exercise_host.gd +++ b/scenes/exercises/exercise_host.gd @@ -66,7 +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"))) + AudioManager.play_region_music(_rule.get("level", "CE1")) _show_rule_panel() diff --git a/scenes/profiles/profile_select.gd b/scenes/profiles/profile_select.gd index 29cfff0..5e6fa50 100644 --- a/scenes/profiles/profile_select.gd +++ b/scenes/profiles/profile_select.gd @@ -28,7 +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") + AudioManager.play_region_music("CE1") _refresh_profile_list() diff --git a/scenes/ui/reward_screen.gd b/scenes/ui/reward_screen.gd index 0b2f424..a3d7af9 100644 --- a/scenes/ui/reward_screen.gd +++ b/scenes/ui/reward_screen.gd @@ -28,7 +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"))) + AudioManager.play_region_music(rule.get("level", "CE1")) var summary := ProgressManager.complete_mission( GameManager.pending_mission_id, GameManager.last_outcomes diff --git a/scenes/ui/settings.gd b/scenes/ui/settings.gd index 1838e76..2006f19 100644 --- a/scenes/ui/settings.gd +++ b/scenes/ui/settings.gd @@ -11,6 +11,8 @@ extends Control @onready var _music_slider: HSlider = %MusicSlider @onready var _effects_label: Label = %EffectsLabel @onready var _effects_slider: HSlider = %EffectsSlider +@onready var _track_label: Label = %TrackLabel +@onready var _track_option: OptionButton = %TrackOption @onready var _text_size_label: Label = %TextSizeLabel @onready var _text_size_box: HBoxContainer = %TextSizeBox @onready var _animations_label: Label = %AnimationsLabel @@ -36,6 +38,7 @@ func _ready() -> void: _volume_slider.drag_ended.connect(_on_drag_ended) _effects_slider.drag_ended.connect(_on_drag_ended) + _build_track_option() _build_text_size_buttons() _build_animation_buttons() @@ -80,6 +83,21 @@ func _setup_slider(slider: HSlider, value: float, handler: Callable) -> void: slider.value_changed.connect(handler) +func _build_track_option() -> void: + _track_label.text = UIText.get_text("settings.track_label") + var choices := SoundCatalog.music_choices() + for choice in choices: + _track_option.add_item(UIText.get_text("music.%s" % choice)) + _track_option.select(choices.find(SettingsManager.music_track)) + _track_option.item_selected.connect(_on_track_selected) + + +func _on_track_selected(index: int) -> void: + SettingsManager.set_music_track(SoundCatalog.music_choices()[index]) + # On fait entendre le choix tout de suite. + AudioManager.play_region_music(ProgressManager.school_level()) + + func _on_volume_changed(value: float) -> void: SettingsManager.set_volume(value) diff --git a/scenes/ui/settings.tscn b/scenes/ui/settings.tscn index 5452a13..91a0735 100644 --- a/scenes/ui/settings.tscn +++ b/scenes/ui/settings.tscn @@ -41,7 +41,7 @@ custom_minimum_size = Vector2(640, 0) [node name="VBox" type="VBoxContainer" parent="Margin/Center/Card"] layout_mode = 2 -theme_override_constants/separation = 14 +theme_override_constants/separation = 8 [node name="TitleLabel" type="Label" parent="Margin/Center/Card/VBox"] unique_name_in_owner = true @@ -100,6 +100,22 @@ size_flags_horizontal = 3 size_flags_vertical = 4 custom_minimum_size = Vector2(0, 40) +[node name="TrackRow" type="HBoxContainer" parent="Margin/Center/Card/VBox"] +layout_mode = 2 +theme_override_constants/separation = 16 + +[node name="TrackLabel" type="Label" parent="Margin/Center/Card/VBox/TrackRow"] +unique_name_in_owner = true +layout_mode = 2 +custom_minimum_size = Vector2(230, 0) +theme_type_variation = &"Emphasis" + +[node name="TrackOption" type="OptionButton" parent="Margin/Center/Card/VBox/TrackRow"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +custom_minimum_size = Vector2(0, 48) + [node name="TextSizeLabel" type="Label" parent="Margin/Center/Card/VBox"] unique_name_in_owner = true layout_mode = 2 diff --git a/scenes/world/world_map.gd b/scenes/world/world_map.gd index be52827..63e9a16 100644 --- a/scenes/world/world_map.gd +++ b/scenes/world/world_map.gd @@ -40,7 +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)) + AudioManager.play_region_music(_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" diff --git a/scripts/audio/sound_catalog.gd b/scripts/audio/sound_catalog.gd index 2a9c3f0..d143255 100644 --- a/scripts/audio/sound_catalog.gd +++ b/scripts/audio/sound_catalog.gd @@ -7,7 +7,10 @@ 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"] +const MUSIC_IDS: Array[String] = ["forest", "marsh", "meadow", "river", "night"] + +## Choix « automatique » : la musique de la region (voir music_for_level). +const MUSIC_AUTO := "auto" static func sfx_path(sfx_id: String) -> String: @@ -25,3 +28,17 @@ static func music_path(music_id: String) -> String: ## 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" + + +## Choix possibles dans les reglages : automatique puis chaque morceau. +static func music_choices() -> Array[String]: + var choices: Array[String] = [MUSIC_AUTO] + choices.append_array(MUSIC_IDS) + return choices + + +## Morceau a jouer pour le choix de l'utilisateur et le niveau scolaire. +static func resolve_music(choice: String, level: String) -> String: + if MUSIC_IDS.has(choice): + return choice + return music_for_level(level) diff --git a/scripts/settings/app_settings.gd b/scripts/settings/app_settings.gd index c565f0d..3edfe06 100644 --- a/scripts/settings/app_settings.gd +++ b/scripts/settings/app_settings.gd @@ -22,6 +22,7 @@ static func defaults() -> Dictionary: "volume": 0.8, "music_volume": 0.5, "effects_volume": 0.8, + "music_track": SoundCatalog.MUSIC_AUTO, "text_scale_index": 0, "animations_enabled": true, } @@ -41,6 +42,9 @@ static func sanitize(raw: Variant) -> Dictionary: 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 track: Variant = source.get("music_track") + if typeof(track) == TYPE_STRING and SoundCatalog.music_choices().has(track): + result["music_track"] = track 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) diff --git a/tests/audio/test_sound_catalog.gd b/tests/audio/test_sound_catalog.gd index 549f355..382063c 100644 --- a/tests/audio/test_sound_catalog.gd +++ b/tests/audio/test_sound_catalog.gd @@ -23,6 +23,23 @@ func test_music_follows_the_level() -> void: assert_eq(SoundCatalog.music_for_level("CE2"), "marsh") +func test_there_are_five_music_tracks_and_an_automatic_choice() -> void: + assert_eq(SoundCatalog.MUSIC_IDS.size(), 5) + assert_eq(SoundCatalog.music_choices().size(), 6) + assert_eq(SoundCatalog.music_choices()[0], SoundCatalog.MUSIC_AUTO) + + +func test_resolve_music_follows_choice_or_level() -> void: + assert_eq(SoundCatalog.resolve_music("night", "CE1"), "night") + assert_eq(SoundCatalog.resolve_music("auto", "CE2"), "marsh") + assert_eq(SoundCatalog.resolve_music("n_importe_quoi", "CE1"), "forest") + + +func test_every_music_choice_has_a_label() -> void: + for choice in SoundCatalog.music_choices(): + assert_ne(UIText.get_text("music.%s" % choice), "music.%s" % choice) + + func test_audio_manager_plays_without_crashing_and_remembers_music() -> void: AudioManager.play_sfx("success") AudioManager.play_music("marsh") diff --git a/tests/settings/test_app_settings.gd b/tests/settings/test_app_settings.gd index 16ee29a..ffa0843 100644 --- a/tests/settings/test_app_settings.gd +++ b/tests/settings/test_app_settings.gd @@ -42,3 +42,9 @@ func test_music_and_effects_volumes_are_bounded() -> void: assert_eq(clean["music_volume"], 1.0) assert_eq(clean["effects_volume"], 0.0) assert_eq(AppSettings.sanitize({})["music_volume"], AppSettings.defaults()["music_volume"]) + + +func test_music_track_must_be_a_known_choice() -> void: + assert_eq(AppSettings.sanitize({"music_track": "river"})["music_track"], "river") + assert_eq(AppSettings.sanitize({"music_track": "../x"})["music_track"], "auto") + assert_eq(AppSettings.sanitize({"music_track": 3})["music_track"], "auto") diff --git a/tools/generate_sounds.py b/tools/generate_sounds.py index 7bd3e8c..51c31db 100644 --- a/tools/generate_sounds.py +++ b/tools/generate_sounds.py @@ -68,7 +68,11 @@ def normalise(signal: Signal, peak: float) -> 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) + pitch = names[note[0]] + if note[1] == "#": + pitch += 1 + elif note[1] == "b": + pitch -= 1 octave = int(note[-1]) midi = 12 * (octave + 1) + pitch return 440.0 * 2 ** ((midi - 69) / 12) @@ -149,7 +153,28 @@ def make_all_music() -> dict[str, Signal]: seed=23, melody_density=0.35, ) - return {"forest": forest, "marsh": marsh} + meadow = make_music( # prairie : majeur lumineux, un peu plus vif + chords=[["G2", "D3", "B3"], ["E2", "B2", "G3"], ["C3", "G3", "E4"], ["D3", "A3", "F#4"]] * 2, + melody_scale=["G4", "A4", "B4", "D5", "E5", "G5"], + seconds_per_chord=3.5, + seed=37, + melody_density=0.55, + ) + river = make_music( # riviere : mode dorien, melodie qui coule + chords=[["D3", "A3", "F4"], ["G2", "D3", "B3"], ["C3", "G3", "E4"], ["A2", "E3", "C4"]] * 2, + melody_scale=["D5", "E5", "F5", "A5", "C6", "A4"], + seconds_per_chord=3.0, + seed=41, + melody_density=0.65, + ) + night = make_music( # nuit : berceuse lente et rare + chords=[["F2", "C3", "A3"], ["D3", "A3", "E4"], ["Bb2", "F3", "D4"], ["C3", "G3", "E4"]] * 2, + melody_scale=["F5", "G5", "A5", "C6", "E5"], + seconds_per_chord=6.0, + seed=53, + melody_density=0.25, + ) + return {"forest": forest, "marsh": marsh, "meadow": meadow, "river": river, "night": night} def write_ogg(signal: Signal, destination: Path, quality: str = "3") -> None: