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"]