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]