mirror of
https://github.com/deunix-educ/aventuriers-des-mots.git
synced 2026-10-13 02:11:58 +02:00
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:
@@ -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/`
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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
@@ -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"))
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
uid://04s3ocs22q7g
|
||||
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
uid://dvg367cd47sam
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c61j06k8itgcq
|
||||
@@ -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"])
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user