nodes/audio.pyΒΆ
Part of Clumsy Bird.
1"""Procedural audio: built on the engine's AudioSynth API.
2
3Replaces the upstream's bundled theme/wing/hit/lose files (no licence
4grant) with sounds generated at load time. Swept tones keep a small
5amount of bespoke numpy because AudioSynth oscillators have constant
6frequency (same pattern as the other ports).
7"""
8
9from __future__ import annotations
10
11import math
12
13import numpy as np
14
15from simvx.core import ADSR, AudioClip, AudioSynth, Oscillator
16
17SAMPLE_RATE = 44100
18NCHANNELS = 2
19
20
21def _stereo(mono: np.ndarray, name: str) -> AudioClip:
22 out = np.empty(mono.size * NCHANNELS, dtype=np.float32)
23 out[0::NCHANNELS] = mono
24 out[1::NCHANNELS] = mono
25 return AudioClip.from_pcm(out, sample_rate=SAMPLE_RATE, channels=NCHANNELS, name=name)
26
27
28def _sweep(f0: float, f1: float, dur: float, gain: float, release: float) -> np.ndarray:
29 n = int(SAMPLE_RATE * dur)
30 freq = np.linspace(f0, f1, n)
31 sig = np.sin(2 * math.pi * np.cumsum(freq) / SAMPLE_RATE).astype(np.float32) * gain
32 env = np.ones(n, dtype=np.float32)
33 a, r = max(1, int(SAMPLE_RATE * 0.004)), max(1, int(SAMPLE_RATE * release))
34 env[:a] = np.linspace(0, 1, a, dtype=np.float32)
35 env[-r:] = np.linspace(1, 0, r, dtype=np.float32)
36 return sig * env
37
38
39def make_wing() -> AudioClip:
40 """Flap: short airy whoosh (down-swept tone + a touch of noise)."""
41 body = _sweep(620, 280, 0.12, 0.22, 0.06)
42 noise = np.random.default_rng(7).uniform(-1, 1, body.size).astype(np.float32) * 0.05
43 noise *= np.linspace(1, 0, body.size, dtype=np.float32)
44 return _stereo(body + noise, "wing")
45
46
47def make_score() -> AudioClip:
48 """Pipe passed: bright two-note ding."""
49
50 def tone(freq: float, dur: float) -> np.ndarray:
51 synth = AudioSynth()
52 synth.add(
53 Oscillator.sine(freq),
54 envelope=ADSR(attack=0.004, decay=0.0, sustain=1.0, release=0.06),
55 gain=0.26,
56 )
57 return synth.bake(duration=dur, sample_rate=SAMPLE_RATE, channels=1).backend_data
58
59 return _stereo(np.concatenate([tone(784, 0.09), tone(1175, 0.14)]), "score")
60
61
62def make_lose() -> AudioClip:
63 """Crash: descending womp with a noise thud."""
64 body = _sweep(440, 110, 0.55, 0.30, 0.25)
65 thud = np.random.default_rng(11).uniform(-1, 1, int(SAMPLE_RATE * 0.1)).astype(np.float32) * 0.18
66 thud *= np.linspace(1, 0, thud.size, dtype=np.float32)
67 body[: thud.size] += thud
68 return _stereo(body, "lose")
69
70
71def make_theme() -> AudioClip:
72 """Jaunty 8-bar loop: square lead over a triangle bass, baked once."""
73 bpm = 140
74 beat = 60.0 / bpm
75
76 def note(freq: float, dur: float, osc, gain: float) -> np.ndarray:
77 if freq <= 0: # rest
78 return np.zeros(int(SAMPLE_RATE * dur), dtype=np.float32)
79 synth = AudioSynth()
80 synth.add(
81 osc(freq),
82 envelope=ADSR(attack=0.01, decay=0.04, sustain=0.65, release=0.05),
83 gain=gain,
84 )
85 return synth.bake(duration=dur, sample_rate=SAMPLE_RATE, channels=1).backend_data
86
87 # A-minor pentatonic, bouncy phrasing.
88 a, c, d, e, g, a2, rest = 220.0, 262.0, 294.0, 330.0, 392.0, 440.0, 0.0
89 lead_notes = [
90 (a, 1),
91 (c, 1),
92 (e, 1),
93 (a2, 1),
94 (g, 1),
95 (e, 1),
96 (d, 2),
97 (c, 1),
98 (d, 1),
99 (e, 1),
100 (g, 1),
101 (e, 1),
102 (c, 1),
103 (a, 2),
104 (a, 1),
105 (e, 1),
106 (a2, 1),
107 (g, 1),
108 (e, 1),
109 (d, 1),
110 (c, 2),
111 (d, 1),
112 (e, 1),
113 (d, 1),
114 (c, 1),
115 (a, 1),
116 (rest, 1),
117 (a, 2),
118 ]
119 lead = np.concatenate([note(f, b * beat, Oscillator.square, 0.085) for f, b in lead_notes])
120
121 a_b, f_b, g_b, e_b = 110.0, 87.0, 98.0, 82.0
122 bass_notes = [a_b, f_b, g_b, a_b, a_b, f_b, e_b, a_b]
123 bass = np.concatenate([note(f, 4 * beat, Oscillator.triangle, 0.15) for f in bass_notes])
124
125 n = min(lead.size, bass.size)
126 return _stereo(lead[:n] + bass[:n], "theme_loop")
127
128
129__all__ = ["make_wing", "make_score", "make_lose", "make_theme"]