#!/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()