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:
@@ -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