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
+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()