nodes/audio.pyΒΆ
Part of Claustrowordia.
1"""Procedural audio: built on the engine's AudioSynth API.
2
3Each helper returns a fresh `AudioClip` ready to be wrapped in
4`AudioPlayer` and added to the scene tree. No .wav files needed.
5
6Most SFX use AudioSynth directly (oscillator + ADSR); two
7(``make_pickup``'s frequency sweep, ``make_game_over``'s sequenced
8notes) keep a small amount of bespoke numpy because AudioSynth doesn't
9yet model sweeps or note-concatenation.
10"""
11
12from __future__ import annotations
13
14import math
15
16import numpy as np
17
18from simvx.core import ADSR, AudioClip, AudioSynth, Oscillator
19
20SAMPLE_RATE = 44100
21NCHANNELS = 2
22
23# C-major pentatonic scale (the upstream "notes" collection plays a
24# rising melody as words score; we mimic it with a 9-step major scale).
25NOTE_FREQS = [262, 294, 330, 349, 392, 440, 494, 523, 587, 659]
26
27
28def _stereo(mono: np.ndarray) -> AudioClip:
29 out = np.empty(mono.size * NCHANNELS, dtype=np.float32)
30 out[0::NCHANNELS] = mono
31 out[1::NCHANNELS] = mono
32 return AudioClip.from_pcm(
33 out,
34 sample_rate=SAMPLE_RATE,
35 channels=NCHANNELS,
36 name="anon",
37 )
38
39
40def _envelope(n: int, attack: float, release: float) -> np.ndarray:
41 env = np.ones(n, dtype=np.float32)
42 a = max(1, int(SAMPLE_RATE * attack))
43 r = max(1, int(SAMPLE_RATE * release))
44 a = min(a, n)
45 r = min(r, n)
46 env[:a] = np.linspace(0, 1, a, dtype=np.float32)
47 env[-r:] = np.linspace(1, 0, r, dtype=np.float32)
48 return env
49
50
51def make_pop() -> AudioClip:
52 """Tile-place pop: short low-mid square pulse with quick decay."""
53 synth = AudioSynth()
54 synth.add(
55 Oscillator.square(180.0),
56 envelope=ADSR(attack=0.005, decay=0.0, sustain=1.0, release=0.05),
57 gain=0.25,
58 )
59 return synth.bake(duration=0.08, sample_rate=SAMPLE_RATE, channels=NCHANNELS)
60
61
62def make_pickup() -> AudioClip:
63 """Pick up tile from hand: brisk upward chirp.
64
65 Kept bespoke: AudioSynth's oscillators have constant frequency,
66 swept tones use ``np.cumsum`` integration over a linspace, which
67 the synth API doesn't currently model.
68 """
69 n = int(SAMPLE_RATE * 0.08)
70 freq = np.linspace(440, 660, n)
71 sig = np.sin(2 * math.pi * np.cumsum(freq) / SAMPLE_RATE).astype(np.float32) * 0.22
72 sig *= _envelope(n, 0.003, 0.04)
73 return _stereo(sig)
74
75
76def make_note(idx: int) -> AudioClip:
77 """Per-letter scoring note: fundamental + soft second harmonic."""
78 freq = NOTE_FREQS[min(idx, len(NOTE_FREQS) - 1)]
79 env = ADSR(attack=0.005, decay=0.0, sustain=1.0, release=0.05)
80 synth = AudioSynth()
81 synth.add(Oscillator.sine(freq), envelope=env, gain=0.32)
82 synth.add(Oscillator.sine(freq * 2.0), envelope=env, gain=0.10)
83 return synth.bake(duration=0.18, sample_rate=SAMPLE_RATE, channels=NCHANNELS)
84
85
86def make_word_chime() -> AudioClip:
87 """C-E-G major chord with a long release; plays after a word scores."""
88 env = ADSR(attack=0.003, decay=0.0, sustain=1.0, release=0.30)
89 synth = AudioSynth()
90 for freq in (523, 659, 784):
91 synth.add(Oscillator.sine(freq), envelope=env, gain=0.15)
92 return synth.bake(duration=0.42, sample_rate=SAMPLE_RATE, channels=NCHANNELS)
93
94
95def make_invalid() -> AudioClip:
96 """Buzz played on invalid placement attempt."""
97 synth = AudioSynth()
98 synth.add(
99 Oscillator.square(110.0),
100 envelope=ADSR(attack=0.003, decay=0.0, sustain=1.0, release=0.05),
101 gain=0.18,
102 )
103 return synth.bake(duration=0.18, sample_rate=SAMPLE_RATE, channels=NCHANNELS)
104
105
106def make_game_over() -> AudioClip:
107 """Descending major-third drop with a low rumble.
108
109 Kept bespoke: AudioSynth doesn't model note-concatenation; we
110 bake three single-note synths and stitch them.
111 """
112
113 def _one_note(freq: float, dur: float) -> np.ndarray:
114 synth = AudioSynth()
115 synth.add(
116 Oscillator.sine(freq),
117 envelope=ADSR(attack=0.005, decay=0.0, sustain=1.0, release=0.05),
118 gain=0.30,
119 )
120 return synth.bake(duration=dur, sample_rate=SAMPLE_RATE, channels=1).backend_data
121
122 sig = np.concatenate([_one_note(220, 0.30), _one_note(196, 0.30), _one_note(165, 0.50)])
123 # Light rumble overlay (white noise + slow envelope), kept manual.
124 noise = np.random.uniform(-1, 1, sig.size).astype(np.float32) * 0.05
125 noise *= _envelope(sig.size, 0.001, 0.10)
126 sig = sig + noise
127 return _stereo(sig)
128
129
130__all__ = [
131 "make_pop",
132 "make_pickup",
133 "make_note",
134 "make_word_chime",
135 "make_invalid",
136 "make_game_over",
137]