mirror of
https://github.com/deunix-educ/aventuriers-des-mots.git
synced 2026-10-13 02:11:58 +02:00
6625342163
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
308 lines
12 KiB
Python
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()
|