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]