Files
Denis Defolie 6625342163 Make the five music tracks clearly distinct (0.5.2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 23:06:11 +02:00

308 lines
12 KiB
Python

#!/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]]
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)
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 pluck(freq: float, duration: float, decay: float = 9.0) -> Signal:
"""Note pincee courte et sautillante (harmoniques qui tombent vite)."""
t = _time(duration)
tone = np.sin(2 * np.pi * freq * t) + 0.5 * np.sin(2 * np.pi * freq * 2 * t) * np.exp(-decay * t)
tone += 0.25 * np.sin(2 * np.pi * freq * 3 * t) * np.exp(-decay * 2 * t)
return tone * np.exp(-decay * t) * np.minimum(t / 0.003, 1.0)
def flow(freq: float, duration: float) -> Signal:
"""Note ronde et liee, a attaque douce : pour les arpeges continus."""
t = _time(duration)
tone = np.sin(2 * np.pi * freq * t) + 0.15 * np.sin(2 * np.pi * freq * 2 * t)
env = np.minimum(t / 0.03, 1.0) * np.exp(-2.2 * t)
return tone * env
def drone(freq: float, duration: float, tremolo_hz: float) -> Signal:
"""Bourdon grave et lent, avec un battement d'amplitude."""
t = _time(duration)
tone = np.sin(2 * np.pi * freq * t) + 0.4 * np.sin(2 * np.pi * freq * 1.5 * t)
wobble = 0.65 + 0.35 * np.sin(2 * np.pi * tremolo_hz * t)
env = np.minimum(t / 3.0, 1.0) * np.minimum((duration - t) / 3.0, 1.0)
return tone * wobble * np.clip(env, 0.0, 1.0)
def fold_loop(track: Signal, body_seconds: float) -> Signal:
"""Replie la queue sur le debut : la boucle ne claque pas."""
length = int(body_seconds * SAMPLE_RATE)
folded = track[:length].copy()
overflow = track[length:]
folded[: len(overflow)] += overflow
return folded
def make_arpeggio(
chords: list[list[str]],
pattern: list[int],
step: float,
voice: str,
bass: bool,
pad_gain: float,
) -> Signal:
"""Motif d'arpege repete sur chaque accord ; `pattern` indexe l'accord."""
steps_per_chord = len(pattern)
chord_seconds = steps_per_chord * step
body = chord_seconds * len(chords)
track = np.zeros(int((body + 3.0) * SAMPLE_RATE))
for index, chord in enumerate(chords):
start = index * chord_seconds
if pad_gain > 0:
for note in chord:
place(track, pad(hz(note), chord_seconds + 1.0), start, gain=pad_gain)
if bass:
place(track, pluck(hz(chord[0]) / 2, 1.2, decay=4.0), start, gain=0.9)
for position, chord_index in enumerate(pattern):
note = chord[chord_index % len(chord)]
octave_up = 2.0 if chord_index >= len(chord) else 1.0
freq = hz(note) * 2.0 * octave_up
sound = pluck(freq, 0.6) if voice == "pluck" else flow(freq, 1.2)
place(track, sound, start + position * step, gain=0.5)
return normalise(fold_loop(track, body), 0.5)
def make_drone_music(seed: int) -> Signal:
"""Marais : bourdon grave qui respire et rares notes de cloche creuses."""
rng = np.random.default_rng(seed)
body = 40.0
track = np.zeros(int((body + 4.0) * SAMPLE_RATE))
place(track, drone(hz("A1"), body + 4.0, 0.11), 0.0, gain=0.8)
place(track, drone(hz("E2"), body + 4.0, 0.17), 0.0, gain=0.5)
for start in np.arange(2.0, body - 3.0, 5.0):
note = ["A3", "C4", "E4", "G3"][int(rng.integers(4))]
place(track, bell(hz(note), 3.5, decay=1.6, brightness=0.15), float(start), gain=0.5)
return normalise(fold_loop(track, body), 0.5)
def make_music_box(seed: int) -> Signal:
"""Nuit : boite a musique aigue en 3 temps, tres lente, sur un lit grave."""
rng = np.random.default_rng(seed)
scale = ["C5", "D5", "E5", "G5", "A5", "C6", "E6"]
beat = 0.95
bars = 16
body = bars * 3 * beat
track = np.zeros(int((body + 3.0) * SAMPLE_RATE))
for bar in range(bars):
root = ["C3", "A2", "F2", "G2"][bar % 4]
place(track, pad(hz(root), 3 * beat + 1.0, attack=0.8), bar * 3 * beat, gain=0.3)
for count in range(3):
if count == 0 or rng.random() < 0.6:
note = scale[int(rng.integers(len(scale)))]
place(
track,
bell(hz(note), 1.8, decay=3.2, brightness=0.7),
(bar * 3 + count) * beat,
gain=0.55,
)
return normalise(fold_loop(track, body), 0.5)
def make_all_music() -> dict[str, Signal]:
"""Cinq ambiances nettement differentes (timbre, rythme, registre)."""
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_drone_music(seed=23)
meadow = make_arpeggio( # prairie : pizzicati allegres, basse sur le temps
chords=[["G3", "B3", "D4"], ["E3", "G3", "B3"], ["C3", "E3", "G3"], ["D3", "F#3", "A3"]] * 2,
pattern=[0, 1, 2, 1, 0, 1, 2, 3],
step=0.25,
voice="pluck",
bass=True,
pad_gain=0.0,
)
river = make_arpeggio( # riviere : arpege continu, lie, qui monte et descend
chords=[["D3", "A3", "F4"], ["G3", "D4", "B4"], ["C3", "G3", "E4"], ["A2", "E3", "C4"]] * 2,
pattern=[0, 1, 2, 3, 4, 3, 2, 1],
step=0.18,
voice="flow",
bass=False,
pad_gain=0.12,
)
night = make_music_box(seed=53)
return {"forest": forest, "marsh": marsh, "meadow": meadow, "river": river, "night": night}
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()