Add choice between five background music tracks (0.5.1)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Denis Defolie
2026-10-11 23:02:56 +02:00
parent 2d7001105d
commit 0089ac0c27
23 changed files with 194 additions and 11 deletions
Binary file not shown.
+19
View File
@@ -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
Binary file not shown.
+19
View File
@@ -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
Binary file not shown.
+19
View File
@@ -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
+6
View File
@@ -76,6 +76,12 @@ func play_music(music_id: String) -> void:
_music_player.play() _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: func current_music_id() -> String:
return _current_music_id return _current_music_id
+8
View File
@@ -14,6 +14,7 @@ var animations_enabled := true
var volume := 0.8 var volume := 0.8
var music_volume := 0.5 var music_volume := 0.5
var effects_volume := 0.8 var effects_volume := 0.8
var music_track := SoundCatalog.MUSIC_AUTO
var text_scale_index := 0 var text_scale_index := 0
var _base_font_sizes: Dictionary = {} var _base_font_sizes: Dictionary = {}
@@ -49,6 +50,11 @@ func set_effects_volume(value: float) -> void:
_commit() _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: func set_text_scale_index(index: int) -> void:
text_scale_index = clampi(index, 0, AppSettings.TEXT_SCALES.size() - 1) text_scale_index = clampi(index, 0, AppSettings.TEXT_SCALES.size() - 1)
_commit() _commit()
@@ -69,6 +75,7 @@ func load_from_disk() -> void:
volume = clean["volume"] volume = clean["volume"]
music_volume = clean["music_volume"] music_volume = clean["music_volume"]
effects_volume = clean["effects_volume"] effects_volume = clean["effects_volume"]
music_track = clean["music_track"]
text_scale_index = clean["text_scale_index"] text_scale_index = clean["text_scale_index"]
animations_enabled = clean["animations_enabled"] animations_enabled = clean["animations_enabled"]
@@ -79,6 +86,7 @@ func save_to_disk() -> bool:
data["volume"] = volume data["volume"] = volume
data["music_volume"] = music_volume data["music_volume"] = music_volume
data["effects_volume"] = effects_volume data["effects_volume"] = effects_volume
data["music_track"] = music_track
data["text_scale_index"] = text_scale_index data["text_scale_index"] = text_scale_index
data["animations_enabled"] = animations_enabled data["animations_enabled"] = animations_enabled
var tmp_path := SETTINGS_PATH + ".tmp" var tmp_path := SETTINGS_PATH + ".tmp"
+8 -1
View File
@@ -109,5 +109,12 @@
"settings.animations_on": "Avec animations", "settings.animations_on": "Avec animations",
"settings.animations_off": "Sans animations", "settings.animations_off": "Sans animations",
"settings.music_label": "Musique", "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"
} }
+2
View File
@@ -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 ». **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). **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.
+1 -1
View File
@@ -43,7 +43,7 @@ Tout ce qui suit ne peut pas être vérifié sans écran (rendu, mise en page, a
## Audio ## 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. - `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). - 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] [application]
config/name="Les Aventuriers des mots" config/name="Les Aventuriers des mots"
config/version="0.5.0" config/version="0.5.1"
run/main_scene="res://scenes/boot/boot.tscn" run/main_scene="res://scenes/boot/boot.tscn"
config/features=PackedStringArray("4.6", "Forward Plus") config/features=PackedStringArray("4.6", "Forward Plus")
config/icon="res://icon.svg" config/icon="res://icon.svg"
+1 -1
View File
@@ -66,7 +66,7 @@ func _ready() -> void:
_skill_id = _rule.get("skill", "") _skill_id = _rule.get("skill", "")
_current_difficulty = ProgressManager.next_difficulty_for_skill(_skill_id, 1) _current_difficulty = ProgressManager.next_difficulty_for_skill(_skill_id, 1)
$Backdrop.kind = "marsh" if _rule.get("level", "CE1") == "CE2" else "forest" $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() _show_rule_panel()
+1 -1
View File
@@ -28,7 +28,7 @@ func _ready() -> void:
_new_profile_button.pressed.connect(_on_new_profile_pressed) _new_profile_button.pressed.connect(_on_new_profile_pressed)
_delete_confirm_dialog.confirmed.connect(_on_delete_confirmed) _delete_confirm_dialog.confirmed.connect(_on_delete_confirmed)
AudioManager.play_music("forest") AudioManager.play_region_music("CE1")
_refresh_profile_list() _refresh_profile_list()
+1 -1
View File
@@ -28,7 +28,7 @@ func _ready() -> void:
var mission := ExerciseEngine.get_mission(GameManager.pending_mission_id) var mission := ExerciseEngine.get_mission(GameManager.pending_mission_id)
var rule := ExerciseEngine.get_rule(mission.get("rule_id", "")) var rule := ExerciseEngine.get_rule(mission.get("rule_id", ""))
_backdrop.kind = "marsh" if rule.get("level", "CE1") == "CE2" else "forest" _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( var summary := ProgressManager.complete_mission(
GameManager.pending_mission_id, GameManager.last_outcomes GameManager.pending_mission_id, GameManager.last_outcomes
+18
View File
@@ -11,6 +11,8 @@ extends Control
@onready var _music_slider: HSlider = %MusicSlider @onready var _music_slider: HSlider = %MusicSlider
@onready var _effects_label: Label = %EffectsLabel @onready var _effects_label: Label = %EffectsLabel
@onready var _effects_slider: HSlider = %EffectsSlider @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_label: Label = %TextSizeLabel
@onready var _text_size_box: HBoxContainer = %TextSizeBox @onready var _text_size_box: HBoxContainer = %TextSizeBox
@onready var _animations_label: Label = %AnimationsLabel @onready var _animations_label: Label = %AnimationsLabel
@@ -36,6 +38,7 @@ func _ready() -> void:
_volume_slider.drag_ended.connect(_on_drag_ended) _volume_slider.drag_ended.connect(_on_drag_ended)
_effects_slider.drag_ended.connect(_on_drag_ended) _effects_slider.drag_ended.connect(_on_drag_ended)
_build_track_option()
_build_text_size_buttons() _build_text_size_buttons()
_build_animation_buttons() _build_animation_buttons()
@@ -80,6 +83,21 @@ func _setup_slider(slider: HSlider, value: float, handler: Callable) -> void:
slider.value_changed.connect(handler) 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: func _on_volume_changed(value: float) -> void:
SettingsManager.set_volume(value) SettingsManager.set_volume(value)
+17 -1
View File
@@ -41,7 +41,7 @@ custom_minimum_size = Vector2(640, 0)
[node name="VBox" type="VBoxContainer" parent="Margin/Center/Card"] [node name="VBox" type="VBoxContainer" parent="Margin/Center/Card"]
layout_mode = 2 layout_mode = 2
theme_override_constants/separation = 14 theme_override_constants/separation = 8
[node name="TitleLabel" type="Label" parent="Margin/Center/Card/VBox"] [node name="TitleLabel" type="Label" parent="Margin/Center/Card/VBox"]
unique_name_in_owner = true unique_name_in_owner = true
@@ -100,6 +100,22 @@ size_flags_horizontal = 3
size_flags_vertical = 4 size_flags_vertical = 4
custom_minimum_size = Vector2(0, 40) 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"] [node name="TextSizeLabel" type="Label" parent="Margin/Center/Card/VBox"]
unique_name_in_owner = true unique_name_in_owner = true
layout_mode = 2 layout_mode = 2
+1 -1
View File
@@ -40,7 +40,7 @@ func _ready() -> void:
func _refresh() -> void: func _refresh() -> void:
var is_ce2 := _viewed_level == "CE2" var is_ce2 := _viewed_level == "CE2"
_backdrop.kind = "marsh" if is_ce2 else "forest" _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") _title_label.text = UIText.get_text("world_map.title_ce2" if is_ce2 else "world_map.title")
_level_toggle_button.text = UIText.get_text( _level_toggle_button.text = UIText.get_text(
"world_map.review_ce1_button" if is_ce2 else "world_map.back_to_ce2_button" "world_map.review_ce1_button" if is_ce2 else "world_map.back_to_ce2_button"
+18 -1
View File
@@ -7,7 +7,10 @@ const SFX_DIR := "res://assets/audio/sfx"
const MUSIC_DIR := "res://assets/audio/music" const MUSIC_DIR := "res://assets/audio/music"
const SFX_IDS: Array[String] = ["click", "success", "try_again", "star", "fanfare"] 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: 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. ## Musique d'une region selon le niveau scolaire de la regle.
static func music_for_level(level: String) -> String: static func music_for_level(level: String) -> String:
return "marsh" if level == "CE2" else "forest" 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)
+4
View File
@@ -22,6 +22,7 @@ static func defaults() -> Dictionary:
"volume": 0.8, "volume": 0.8,
"music_volume": 0.5, "music_volume": 0.5,
"effects_volume": 0.8, "effects_volume": 0.8,
"music_track": SoundCatalog.MUSIC_AUTO,
"text_scale_index": 0, "text_scale_index": 0,
"animations_enabled": true, "animations_enabled": true,
} }
@@ -41,6 +42,9 @@ static func sanitize(raw: Variant) -> Dictionary:
var other: Variant = source.get(key) var other: Variant = source.get(key)
if typeof(other) == TYPE_FLOAT or typeof(other) == TYPE_INT: if typeof(other) == TYPE_FLOAT or typeof(other) == TYPE_INT:
result[key] = clampf(float(other), 0.0, 1.0) 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") var index: Variant = source.get("text_scale_index")
if typeof(index) == TYPE_FLOAT or typeof(index) == TYPE_INT: if typeof(index) == TYPE_FLOAT or typeof(index) == TYPE_INT:
result["text_scale_index"] = clampi(int(index), 0, TEXT_SCALES.size() - 1) result["text_scale_index"] = clampi(int(index), 0, TEXT_SCALES.size() - 1)
+17
View File
@@ -23,6 +23,23 @@ func test_music_follows_the_level() -> void:
assert_eq(SoundCatalog.music_for_level("CE2"), "marsh") 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: func test_audio_manager_plays_without_crashing_and_remembers_music() -> void:
AudioManager.play_sfx("success") AudioManager.play_sfx("success")
AudioManager.play_music("marsh") AudioManager.play_music("marsh")
+6
View File
@@ -42,3 +42,9 @@ func test_music_and_effects_volumes_are_bounded() -> void:
assert_eq(clean["music_volume"], 1.0) assert_eq(clean["music_volume"], 1.0)
assert_eq(clean["effects_volume"], 0.0) assert_eq(clean["effects_volume"], 0.0)
assert_eq(AppSettings.sanitize({})["music_volume"], AppSettings.defaults()["music_volume"]) 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")
+27 -2
View File
@@ -68,7 +68,11 @@ def normalise(signal: Signal, peak: float) -> Signal:
def hz(note: str) -> float: def hz(note: str) -> float:
names = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11} 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]) octave = int(note[-1])
midi = 12 * (octave + 1) + pitch midi = 12 * (octave + 1) + pitch
return 440.0 * 2 ** ((midi - 69) / 12) return 440.0 * 2 ** ((midi - 69) / 12)
@@ -149,7 +153,28 @@ def make_all_music() -> dict[str, Signal]:
seed=23, seed=23,
melody_density=0.35, 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: def write_ogg(signal: Signal, destination: Path, quality: str = "3") -> None: