afterglow/assets/audio.py¶

Part of Afterglow.

  1"""Procedural audio for Afterglow: synthesized SFX + per-world ambient music.
  2
  3No copyrighted samples: every clip is generated at runtime with
  4``simvx.core.AudioSynth`` (oscillators + ADSR + per-source filters) or
  5``AudioClip.tone``. Clips are pure numpy data baked into ``AudioClip``s, so
  6they play on either backend (desktop / web) with no file I/O.
  7
  8Bus routing
  9-----------
 10The engine ships three built-in buses with the chain ``SFX -> Master`` and
 11``Music -> Master`` (see ``simvx.core.audio_bus``). This module never plays
 12anything itself: it only builds clips and tells the caller which bus each
 13belongs on, so a single Master volume slider controls everything and Music
 14can be ducked independently.
 15
 16    SFX    bus -> short one-shots (jump, dash, shatter, ...)
 17    Music  bus -> the looping ambient bed (one variant per world)
 18
 19Wiring (game / UI layer)::
 20
 21    from afterglow.assets import audio
 22
 23    player = AudioPlayer(stream=audio.get_sfx("jump"), bus=audio.SFX_BUS)
 24    self.add_child(player)
 25    player.play()
 26
 27    music = AudioPlayer(stream=audio.build_music("glade"), bus=audio.MUSIC_BUS)
 28    music.loop = True
 29    self.add_child(music)
 30    music.play()
 31
 32The sim emits event tags (``'jump'``, ``'dash'``, ``'crystal'``, ``'orb'``,
 33``'shard'``, ``'death'``, ``'land'``, ``'spring'``, ...); map those tags to
 34``get_sfx(name)`` in the view/audio layer. Menu UI uses ``"menu_move"`` /
 35``"menu_confirm"``.
 36"""
 37
 38from __future__ import annotations
 39
 40from functools import cache
 41
 42import numpy as np
 43
 44from simvx.core import ADSR, AudioClip, AudioSynth, Exponential, Linear, LowPass, Oscillator
 45
 46# Built-in bus names (case-sensitive, must match simvx.core.audio_bus).
 47SFX_BUS = "SFX"
 48MUSIC_BUS = "Music"
 49MASTER_BUS = "Master"
 50
 51# Equal-tempered reference: A4 = 440 Hz. Semitone ratio for melodic offsets.
 52_A4 = 440.0
 53_SEMI = 2.0 ** (1.0 / 12.0)
 54
 55
 56def _note(semitones_from_a4: float) -> float:
 57    """Frequency of a note `semitones_from_a4` semitones above (or below) A4."""
 58    return _A4 * (_SEMI**semitones_from_a4)
 59
 60
 61def _edge_fade(data: np.ndarray, channels: int, sample_rate: int, ms: float) -> np.ndarray:
 62    """Multiply a short raised-cosine fade onto both ends of an interleaved buffer.
 63
 64    Guarantees the buffer starts and ends at exact silence so a one-shot never
 65    clicks on its first/last sample, and (for a looping bed) the splice point is
 66    truly silent. Operates in place on a per-frame basis across both channels.
 67    """
 68    frames = data.size // channels
 69    n = min(int(sample_rate * ms / 1000.0), frames // 2)
 70    if n <= 0:
 71        return data
 72    ramp = (0.5 - 0.5 * np.cos(np.linspace(0.0, np.pi, n, dtype=np.float32))).astype(np.float32)
 73    view = data.reshape(frames, channels)
 74    view[:n] *= ramp[:, None]
 75    view[-n:] *= ramp[::-1][:, None]
 76    return data
 77
 78
 79def _shape(clip: AudioClip, *, fade_ms: float = 3.0) -> AudioClip:
 80    """Apply a short clickless edge fade to a baked one-shot clip (in place)."""
 81    data = np.asarray(clip.backend_data, dtype=np.float32)
 82    _edge_fade(data, clip.channels or 2, clip.sample_rate or 48000, fade_ms)
 83    return clip
 84
 85
 86# ===========================================================================
 87# SFX library: name -> builder. Each returns a short one-shot AudioClip.
 88# ===========================================================================
 89
 90
 91def _sfx_jump() -> AudioClip:
 92    # Quick upward chirp: a soft triangle blip with a fast pluck envelope.
 93    # Triangle (not square) so the launch reads as gentle, not buzzy.
 94    s = AudioSynth()
 95    s.add(Oscillator.triangle(_note(3)), gain=0.30, envelope=ADSR(decay=0.09, sustain=0.0, release=0.04))
 96    s.add(Oscillator.sine(_note(15)), gain=0.12, envelope=ADSR(decay=0.05, sustain=0.0, release=0.03))
 97    return _shape(s.bake(duration=0.16))
 98
 99
100def _sfx_dash() -> AudioClip:
101    # Airy whoosh: filtered noise with a fast swell-and-fall.
102    s = AudioSynth()
103    s.add(Oscillator.noise.white(seed=7), envelope=Linear(start=1.0, end=0.0), filter=LowPass(1900.0), gain=0.30)
104    s.add(Oscillator.saw(_note(7)), envelope=ADSR(attack=0.005, decay=0.12, sustain=0.0, release=0.05), gain=0.10)
105    return _shape(s.bake(duration=0.22))
106
107
108def _sfx_crystal_shatter() -> AudioClip:
109    # Bright shimmer: stacked high sines with a short ringing decay + tick.
110    s = AudioSynth()
111    for n, g, pan in ((24, 0.15, -0.3), (28, 0.12, 0.3), (31, 0.09, 0.0)):
112        s.add(Oscillator.sine(_note(n)), envelope=Exponential(start=1.0, end=0.02), gain=g, pan=pan)
113    s.add(
114        Oscillator.noise.white(seed=11),
115        envelope=ADSR(attack=0.001, decay=0.03, sustain=0.0, release=0.02),
116        filter=LowPass(5000.0),
117        gain=0.07,
118    )
119    return _shape(s.bake(duration=0.45))
120
121
122def _sfx_glow_orb() -> AudioClip:
123    # Warm bloom: soft triangle swell, a "lighting up" cue.
124    s = AudioSynth()
125    s.add(Oscillator.triangle(_note(12)), envelope=ADSR(attack=0.06, decay=0.2, sustain=0.4, release=0.25), gain=0.26)
126    s.add(Oscillator.sine(_note(19)), envelope=ADSR(attack=0.08, decay=0.2, sustain=0.3, release=0.25), gain=0.12)
127    return _shape(s.bake(duration=0.6))
128
129
130def _sfx_shard() -> AudioClip:
131    # Reward arpeggio: two ascending bell-like sines.
132    s = AudioSynth()
133    s.add(Oscillator.sine(_note(16)), envelope=ADSR(attack=0.002, decay=0.18, sustain=0.0, release=0.08), gain=0.22)
134    s.add(Oscillator.sine(_note(23)), envelope=ADSR(attack=0.12, decay=0.18, sustain=0.0, release=0.1), gain=0.18)
135    return _shape(s.bake(duration=0.5))
136
137
138def _sfx_death() -> AudioClip:
139    # Falling descent: a saw sliding down via a long pluck + low thump.
140    s = AudioSynth()
141    s.add(Oscillator.saw(_note(-5)), envelope=Exponential(start=1.0, end=0.04), filter=LowPass(1400.0), gain=0.26)
142    s.add(Oscillator.sine(_note(-17)), gain=0.16, envelope=ADSR(decay=0.25, sustain=0.0, release=0.1))
143    return _shape(s.bake(duration=0.5))
144
145
146def _sfx_land() -> AudioClip:
147    # Soft thud: low filtered noise burst.
148    s = AudioSynth()
149    s.add(
150        Oscillator.noise.white(seed=3),
151        envelope=ADSR(attack=0.001, decay=0.05, sustain=0.0, release=0.03),
152        filter=LowPass(650.0),
153        gain=0.26,
154    )
155    s.add(Oscillator.sine(_note(-19)), gain=0.18, envelope=ADSR(decay=0.06, sustain=0.0, release=0.03))
156    return _shape(s.bake(duration=0.12))
157
158
159def _sfx_spring() -> AudioClip:
160    # Boing: soft triangle blip the ear reads as a launch (no buzzy square).
161    s = AudioSynth()
162    s.add(Oscillator.triangle(_note(0)), gain=0.28, envelope=ADSR(decay=0.14, sustain=0.0, release=0.06))
163    s.add(Oscillator.sine(_note(12)), gain=0.14, envelope=ADSR(decay=0.1, sustain=0.0, release=0.05))
164    return _shape(s.bake(duration=0.24))
165
166
167def _sfx_menu_move() -> AudioClip:
168    # Tiny soft tick for cursor movement. A plucked triangle (not a raw sine
169    # tone) so it reads as a UI blip, and synth-baked so it shares the 48 kHz
170    # sample rate of every other clip and the SFX bus (no resample pitch shift).
171    s = AudioSynth()
172    s.add(Oscillator.triangle(_note(7)), gain=0.16, envelope=ADSR(decay=0.05, sustain=0.0, release=0.02))
173    return _shape(s.bake(duration=0.07))
174
175
176def _sfx_menu_confirm() -> AudioClip:
177    # Two-note confirm chime.
178    s = AudioSynth()
179    s.add(Oscillator.triangle(_note(12)), envelope=ADSR(attack=0.002, decay=0.1, sustain=0.0, release=0.05), gain=0.24)
180    s.add(Oscillator.triangle(_note(19)), envelope=ADSR(attack=0.07, decay=0.12, sustain=0.0, release=0.06), gain=0.20)
181    return _shape(s.bake(duration=0.3))
182
183
184# Registry of SFX builders. ``get_sfx`` validates against these keys.
185_SFX_BUILDERS = {
186    "jump": _sfx_jump,
187    "dash": _sfx_dash,
188    "crystal_shatter": _sfx_crystal_shatter,
189    "glow_orb": _sfx_glow_orb,
190    "shard": _sfx_shard,
191    "death": _sfx_death,
192    "land": _sfx_land,
193    "spring": _sfx_spring,
194    "menu_move": _sfx_menu_move,
195    "menu_confirm": _sfx_menu_confirm,
196}
197
198SFX_NAMES = tuple(_SFX_BUILDERS)
199
200
201@cache
202def get_sfx(name: str) -> AudioClip:
203    """Return the cached SFX clip for `name` (one of ``SFX_NAMES``).
204
205    Built lazily on first request and memoised, so repeated plays share a
206    single baked buffer. Route the player to ``SFX_BUS``.
207    """
208    try:
209        builder = _SFX_BUILDERS[name]
210    except KeyError:
211        raise KeyError(f"unknown SFX {name!r}; valid names: {', '.join(SFX_NAMES)}") from None
212    return builder()
213
214
215# ===========================================================================
216# Ambient music: one looping bed per world. Routed to MUSIC_BUS.
217# ===========================================================================
218
219# Per-world mood. Each entry: root note (semitones from A4) + a small chord/
220# pad set layered over a slow drone, plus a low-pass cutoff for warmth.
221# The clip is a soft, quiet ambient bed (target RMS ~0.10-0.12) with a master
222# fade in/out applied to the whole baked buffer (``_LOOP_FADE_MS``) so the loop
223# splice point is exact silence: an AudioPlayer with ``loop=True`` repeats with
224# no click even though the individual voices never perfectly phase-align.
225_WORLD_MOODS = {
226    # The Glade: gentle, open, major-ish. Bright pad over a soft drone.
227    "glade": {"root": -9, "chord": (0, 4, 7, 12), "cutoff": 2600.0, "bars": 8.0},
228    # The Caverns: low, hollow, minor. Darker drone, narrower top.
229    "caverns": {"root": -21, "chord": (0, 3, 7, 10), "cutoff": 1300.0, "bars": 8.0},
230    # The Spire: tense, suspended, shimmering highs.
231    "spire": {"root": -2, "chord": (0, 5, 7, 14), "cutoff": 3200.0, "bars": 8.0},
232}
233
234WORLD_IDS = tuple(_WORLD_MOODS)
235
236# Loop length: one drone cycle == half a bar at this tempo, so `bars` bars of
237# pad sit on a whole number of drone cycles -> click-free loop point.
238_BAR_SECONDS = 2.0
239
240# Master fade applied to each end of the whole baked bed, in milliseconds. Long
241# enough that the silent splice point is inaudible against the slow ambient pad.
242_LOOP_FADE_MS = 180.0
243
244# --- Melodic loop layered over the pad -------------------------------------
245# The pad alone reads as one held chord, so we add an actual tune: a pentatonic
246# melody + a simple bass, arranged over a 4-section (8-bar) chord-root
247# progression. Eighth note = a quarter of a 2.0 s bar.
248_EIGHTH = _BAR_SECONDS / 8.0  # 0.25 s
249_SECTION = _BAR_SECONDS * 2.0  # 2 bars per progression section
250_PENTA = {
251    "glade": (0, 2, 4, 7, 9),  # major pentatonic: bright, open
252    "caverns": (0, 3, 5, 7, 10),  # minor pentatonic: hollow, moody
253    "spire": (0, 2, 3, 7, 9),  # suspended-ish: tense
254}
255# Chord root offset (semitones added to the mood root) for each 2-bar section.
256_PROG = {
257    "glade": (0, 7, 5, 7),
258    "caverns": (0, -2, 3, -2),
259    "spire": (0, 5, 3, 7),
260}
261# 16 eighth-note slots per 2-bar section; values index the pentatonic scale
262# (>=5 wraps up an octave), -1 is a rest. These are singable phrases: stepwise
263# motion with rests to breathe and a resolution back toward the tonic, NOT random
264# leaps. The chord-root progression (_PROG) re-colours the same phrase per section.
265_MOTIF = {
266    # The Glade: gentle, flowing, rises then settles home.
267    "glade": (0, -1, 2, 4, 3, -1, 2, -1, 4, -1, 3, 2, 0, -1, 0, -1),
268    # The Caverns: sparse + contemplative; long held notes between rests.
269    "caverns": (0, -1, -1, 2, -1, -1, 3, -1, 2, -1, -1, 1, -1, -1, 0, -1),
270    # The Spire: tense climb that eases back down to the tonic.
271    "spire": (0, 2, 3, -1, 4, -1, 3, 2, 0, -1, 2, 3, 2, -1, 0, -1),
272}
273# Keep the melody in a comfortable mid register (the old +octave read as shrill,
274# random high tinkling). Caverns lifts one octave off its very deep drone root.
275_MELODY_OCT = {"glade": 0, "caverns": 12, "spire": 0}
276
277
278def _scale_semis(scale: tuple[int, ...], degree: int) -> int:
279    """Semitone offset for a pentatonic ``degree`` (wrapping octaves)."""
280    octave, idx = divmod(degree, len(scale))
281    return scale[idx] + 12 * octave
282
283
284def _pluck_into(
285    mix,
286    sr: int,
287    freq: float,
288    t0: float,
289    dur: float,
290    gain: float,
291    *,
292    wave: str = "sine",
293    pan: float = 0.0,
294    tau: float = 0.18,
295) -> None:
296    """Add one plucked note (fast attack, exponential decay) into ``mix`` (frames, ch)."""
297    f0 = int(t0 * sr)
298    n = int(dur * sr)
299    if n <= 0 or f0 >= mix.shape[0]:
300        return
301    end = min(f0 + n, mix.shape[0])
302    n = end - f0
303    t = np.arange(n, dtype=np.float32) / sr
304    if wave == "triangle":
305        ph = (freq * t) % 1.0
306        w = 2.0 * np.abs(2.0 * ph - 1.0) - 1.0
307    else:
308        w = np.sin(2.0 * np.pi * freq * t)
309    env = (1.0 - np.exp(-t / 0.006)) * np.exp(-t / tau)
310    sig = (w * env * gain).astype(np.float32)
311    if mix.shape[1] >= 2:
312        angle = (pan + 1.0) * (np.pi / 4.0)
313        mix[f0:end, 0] += sig * float(np.cos(angle))
314        mix[f0:end, 1] += sig * float(np.sin(angle))
315    else:
316        mix[f0:end, 0] += sig
317
318
319def _add_melody(data, sr: int, frames: int, ch: int, world_id: str, root: float) -> None:
320    """Layer a pentatonic melody + bass over the baked pad buffer in place."""
321    scale = _PENTA.get(world_id, _PENTA["glade"])
322    prog = _PROG.get(world_id, _PROG["glade"])
323    motif = _MOTIF.get(world_id, _MOTIF["glade"])
324    mel_oct = _MELODY_OCT.get(world_id, 12)
325    mix = np.zeros((frames, ch), dtype=np.float32)
326    for section in range(4):
327        base = root + prog[section]
328        t_sec = section * _SECTION
329        for slot, deg in enumerate(motif):
330            if deg < 0:
331                continue
332            freq = _note(base + mel_oct + _scale_semis(scale, deg))
333            pan = -0.25 if slot % 2 else 0.25
334            _pluck_into(mix, sr, freq, t_sec + slot * _EIGHTH, 0.55, 0.13, wave="triangle", pan=pan, tau=0.24)
335        # Bass: root note every half-bar (beats 1 and 3 of each bar).
336        for beat in (0.0, 1.0, 2.0, 3.0):
337            freq = _note(base - 12)
338            _pluck_into(mix, sr, freq, t_sec + beat * (_BAR_SECONDS / 2.0), 0.9, 0.075, wave="sine", tau=0.38)
339    data.reshape(frames, ch)[:] += mix
340
341
342def _build_music(world_id: str) -> AudioClip:
343    try:
344        mood = _WORLD_MOODS[world_id]
345    except KeyError:
346        raise KeyError(f"unknown world {world_id!r}; valid ids: {', '.join(WORLD_IDS)}") from None
347
348    root = float(mood["root"])
349    chord = mood["chord"]
350    cutoff = float(mood["cutoff"])
351    duration = float(mood["bars"]) * _BAR_SECONDS
352
353    s = AudioSynth()
354    # Soft, quiet ambient bed. Gains are deliberately low (~half the old bed) so
355    # the looping pad sits UNDER gameplay as atmosphere, never a loud held tone.
356    # Pad is now a FAINT backing wash (cut to ~40% of the old levels) so the
357    # melody + bass added later are the foreground, not a dominant held chord.
358    s.add(Oscillator.sine(_note(root - 12)), filter=LowPass(cutoff), gain=0.028)
359    s.add(Oscillator.triangle(_note(root)), filter=LowPass(cutoff), gain=0.016)
360    # Chord pad: detuned sines spread across the stereo field for width.
361    spread = (-0.5, -0.18, 0.18, 0.5)
362    for i, semi in enumerate(chord):
363        pan = spread[i % len(spread)]
364        s.add(Oscillator.sine(_note(root + semi), phase=0.3 * i), filter=LowPass(cutoff), gain=0.012, pan=pan)
365        # Slight detune layer (+ a few cents) for a warm chorus shimmer.
366        s.add(Oscillator.sine(_note(root + semi + 0.06)), filter=LowPass(cutoff), gain=0.008, pan=-pan)
367    # Faint top shimmer for air.
368    s.add(Oscillator.sine(_note(root + chord[-1] + 12)), filter=LowPass(cutoff), gain=0.006)
369    clip = s.bake(duration=duration)
370    data = np.asarray(clip.backend_data, dtype=np.float32)
371    ch = clip.channels or 2
372    sr = clip.sample_rate or 48000
373    # Gentle, whole-cycle tremolo so the pad slowly breathes instead of reading as
374    # one static held tone. One full LFO period spans the whole loop, so the
375    # amplitude at the splice point matches and the loop stays click-free.
376    frames = data.size // ch
377    lfo_period = duration  # exactly one breath per loop -> seamless at the splice
378    t = np.arange(frames, dtype=np.float32) / sr
379    tremolo = (0.82 + 0.18 * (0.5 - 0.5 * np.cos(2.0 * np.pi * t / lfo_period))).astype(np.float32)
380    data.reshape(frames, ch)[:] *= tremolo[:, None]
381    # Layer an actual melody + bass over the (now gently breathing) pad so the
382    # music has movement and a tune, not just a sustained chord.
383    _add_melody(data, sr, frames, ch, world_id, root)
384    # Keep peaks in range after summing melody + pad.
385    peak = float(np.abs(data).max()) if data.size else 0.0
386    if peak > 0.95:
387        data *= 0.95 / peak
388    # Master fade in/out across the whole buffer guarantees an exactly silent
389    # splice point regardless of per-voice phase, so loop=True never clicks.
390    _edge_fade(data, ch, sr, _LOOP_FADE_MS)
391    return clip
392
393
394@cache
395def build_music(world_id: str) -> AudioClip:
396    """Return the cached ambient loop for `world_id` (one of ``WORLD_IDS``).
397
398    Designed to loop seamlessly: play through an ``AudioPlayer`` with
399    ``loop=True`` routed to ``MUSIC_BUS``.
400    """
401    return _build_music(world_id)
402
403
404__all__ = [
405    "SFX_BUS",
406    "MUSIC_BUS",
407    "MASTER_BUS",
408    "SFX_NAMES",
409    "WORLD_IDS",
410    "get_sfx",
411    "build_music",
412]