Audio Spectrum¶

FFT bars over a synthesised arpeggio

â–¶ Run in browser

Tags: audio spectrum fft synthesis

An AudioSynth-baked arpeggio loops on an AudioPlayer while a log-spaced bar spectrum dances in time with it. Each frame the demo reads get_playback_position(), slices a 2048-sample window out of the clip’s own PCM buffer (backend_data), and runs np.fft.rfft over it. The engine has no audio output tap, so this analyses the source buffer the example itself generated, synchronised by playback position; it cannot analyse file-backed streams (their backend_data is None).

What it demonstrates¶

  • AudioSynth voices (oscillators + ADSR envelopes + a LowPass drone) baked into an AudioClip via bake() / AudioClip.from_pcm.

  • backend_data as a float32 interleaved numpy buffer, indexed at int(get_playback_position() * sample_rate) frames during playback.

  • A windowed rfft per frame, folded into log-spaced bands with peak-hold caps, drawn with the immediate-mode 2D renderer.

Controls: SPACE - Pause / resume ESC - Quit

Run: uv run python examples/features/audio/spectrum.py Headless self-check: uv run python examples/features/audio/spectrum.py –test

Source¶

  1"""Audio Spectrum: FFT bars over a synthesised arpeggio
  2
  3An AudioSynth-baked arpeggio loops on an AudioPlayer while a log-spaced bar
  4spectrum dances in time with it. Each frame the demo reads
  5get_playback_position(), slices a 2048-sample window out of the clip's own
  6PCM buffer (backend_data), and runs np.fft.rfft over it. The engine has no
  7audio output tap, so this analyses the source buffer the example itself
  8generated, synchronised by playback position; it cannot analyse file-backed
  9streams (their backend_data is None).
 10
 11# /// simvx
 12# tags = ["audio", "spectrum", "fft", "synthesis"]
 13# ///
 14
 15## What it demonstrates
 16- AudioSynth voices (oscillators + ADSR envelopes + a LowPass drone) baked
 17  into an AudioClip via bake() / AudioClip.from_pcm.
 18- backend_data as a float32 interleaved numpy buffer, indexed at
 19  int(get_playback_position() * sample_rate) frames during playback.
 20- A windowed rfft per frame, folded into log-spaced bands with peak-hold
 21  caps, drawn with the immediate-mode 2D renderer.
 22
 23Controls:
 24  SPACE - Pause / resume
 25  ESC - Quit
 26
 27Run: uv run python examples/features/audio/spectrum.py
 28Headless self-check: uv run python examples/features/audio/spectrum.py --test
 29"""
 30
 31import numpy as np
 32
 33from simvx.core import (
 34    ADSR,
 35    AudioClip,
 36    AudioPlayer,
 37    AudioSynth,
 38    Input,
 39    Key,
 40    LowPass,
 41    Node,
 42    Oscillator,
 43)
 44from simvx.graphics import App
 45
 46WIDTH, HEIGHT = 960, 540
 47
 48SAMPLE_RATE = 44100
 49CHANNELS = 2
 50
 51# An A-minor arpeggio, up and back: A3 C4 E4 A4 C5 A4 E4 C4.
 52ARPEGGIO_HZ = (220.0, 261.63, 329.63, 440.0, 523.25, 440.0, 329.63, 261.63)
 53STEP_SECONDS = 0.25
 54DRONE_HZ = 110.0  # A2, held under the whole loop
 55
 56FFT_WINDOW = 2048  # ~46 ms of context per frame
 57BANDS = 48
 58F_MIN, F_MAX = 60.0, 8000.0
 59FLOOR_DB = -60.0  # magnitudes at or below this floor draw as zero-height bars
 60
 61BAR_FALL_PER_S = 1.8  # bars rise instantly, fall at this fraction of full scale
 62PEAK_FALL_PER_S = 0.35  # peak-hold caps sink slower, tracing the recent maxima
 63
 64PLOT_LEFT, PLOT_RIGHT = 50, 30
 65PLOT_TOP, PLOT_BOTTOM = 90, 60
 66LABEL_HZ = (100, 250, 500, 1000, 2000, 4000, 8000)
 67
 68
 69def bake_arpeggio() -> AudioClip:
 70    """Bake the looping demo clip: per-note melody steps over a bass drone.
 71
 72    Each melody step is its own bake so the ADSR spans exactly one note;
 73    the release reaches zero at both edges, so concatenating the steps is
 74    click-free. The drone bakes once across the full duration, and at
 75    110 Hz over 2.0 s it completes a whole number of cycles, so the loop
 76    point is seamless too.
 77    """
 78    steps = []
 79    for freq in ARPEGGIO_HZ:
 80        synth = AudioSynth()
 81        env = ADSR(attack=0.02, decay=0.06, sustain=0.7, release=0.08)
 82        synth.add(Oscillator.sine(freq), envelope=env, gain=0.5)
 83        synth.add(Oscillator.triangle(freq * 2), envelope=env, gain=0.15)
 84        steps.append(synth.bake(STEP_SECONDS, sample_rate=SAMPLE_RATE, channels=CHANNELS).backend_data)
 85    melody = np.concatenate(steps)
 86
 87    drone = AudioSynth()
 88    drone.add(Oscillator.saw(DRONE_HZ), filter=LowPass(400.0), gain=0.25)
 89    total = STEP_SECONDS * len(ARPEGGIO_HZ)
 90    drone_pcm = drone.bake(total, sample_rate=SAMPLE_RATE, channels=CHANNELS).backend_data
 91
 92    pcm = np.clip(melody + drone_pcm, -1.0, 1.0).astype(np.float32)
 93    return AudioClip.from_pcm(pcm, sample_rate=SAMPLE_RATE, channels=CHANNELS, name="spectrum_arpeggio")
 94
 95
 96class SpectrumAnalyser:
 97    """FFT band magnitudes over a PCM buffer, addressed by playback position.
 98
 99    Owns the mono mixdown of an interleaved float32 buffer plus the band
100    layout, so ``magnitudes(position)`` is the whole per-frame cost: one
101    windowed rfft and a fold into log-spaced bands.
102    """
103
104    def __init__(
105        self,
106        pcm: np.ndarray,
107        *,
108        sample_rate: int,
109        channels: int,
110        bands: int = BANDS,
111        window: int = FFT_WINDOW,
112        f_min: float = F_MIN,
113        f_max: float = F_MAX,
114        floor_db: float = FLOOR_DB,
115    ):
116        # Interleaved frames -> mono. Indexing this by frame is the same
117        # as indexing the raw buffer at frame * channels.
118        self._mono = pcm.reshape(-1, channels).mean(axis=1).astype(np.float32)
119        self._rate = sample_rate
120        self._window = window
121        self._taper = np.hanning(window).astype(np.float32)
122        self._floor_db = floor_db
123        self.bands = bands
124
125        self._freqs = np.fft.rfftfreq(window, 1.0 / sample_rate)
126        self.band_edges_hz = np.geomspace(f_min, f_max, bands + 1)
127        self._bin_edges = np.searchsorted(self._freqs, self.band_edges_hz)
128
129    @property
130    def bin_width_hz(self) -> float:
131        return self._rate / self._window
132
133    def _spectrum(self, position: float) -> np.ndarray:
134        """Magnitude spectrum of the window centred on `position` seconds."""
135        centre = int(position * self._rate)  # frame index into the buffer
136        offsets = np.arange(self._window) + centre - self._window // 2
137        chunk = self._mono[offsets % self._mono.shape[0]]  # wrap: the clip loops
138        # Hann window, then normalise so a full-scale sine peaks near 1.0.
139        return np.abs(np.fft.rfft(chunk * self._taper)) / (self._window / 4.0)
140
141    def magnitudes(self, position: float) -> np.ndarray:
142        """Per-band heights in [0, 1]: dB magnitude above the floor."""
143        spectrum = self._spectrum(position)
144        out = np.empty(self.bands, dtype=np.float32)
145        for i in range(self.bands):
146            lo = self._bin_edges[i]
147            hi = max(lo + 1, self._bin_edges[i + 1])
148            out[i] = spectrum[lo:hi].max()
149        db = 20.0 * np.log10(out + 1e-9)
150        return np.clip((db - self._floor_db) / -self._floor_db, 0.0, 1.0)
151
152    def peak_hz(self, position: float, *, lo_hz: float = 0.0, hi_hz: float | None = None) -> float:
153        """Frequency of the strongest bin within [lo_hz, hi_hz]."""
154        spectrum = self._spectrum(position)
155        mask = self._freqs >= lo_hz
156        if hi_hz is not None:
157            mask &= self._freqs <= hi_hz
158        idx = np.flatnonzero(mask)
159        return float(self._freqs[idx[np.argmax(spectrum[idx])]])
160
161
162class SpectrumDemo(Node):
163    """Looping arpeggio with a live log-spaced FFT bar display."""
164
165    dynamic = True  # the bars animate every frame
166
167    input_actions = {"quit": [Key.ESCAPE], "pause": [Key.SPACE]}
168
169    def on_ready(self):
170        clip = bake_arpeggio()
171        self._analyser = SpectrumAnalyser(clip.backend_data, sample_rate=SAMPLE_RATE, channels=CHANNELS)
172        self._bars = np.zeros(BANDS, dtype=np.float32)
173        self._peaks = np.zeros(BANDS, dtype=np.float32)
174
175        self._player = self.add_child(AudioPlayer(name="Player"))
176        self._player.loop = True  # takes effect on the next play(), so set first
177        self._player.stream = clip
178        self._player.play()
179
180    def on_update(self, dt: float):
181        if Input.is_action_just_pressed("quit"):
182            self.app.quit()
183            return
184        if Input.is_action_just_pressed("pause"):
185            if self._player.is_playing():
186                self._player.pause()
187            elif self._player.is_paused():
188                self._player.play()
189
190        if self._player.is_playing():
191            target = self._analyser.magnitudes(self._player.get_playback_position())
192        else:
193            target = 0.0
194        self._bars = np.maximum(target, self._bars - BAR_FALL_PER_S * dt)
195        self._peaks = np.maximum(self._bars, self._peaks - PEAK_FALL_PER_S * dt)
196
197    def _band_colour(self, i: int) -> tuple[float, float, float, float]:
198        t = i / (BANDS - 1)
199        return (0.25 + 0.75 * t, 0.55 - 0.15 * t, 1.0 - 0.7 * t, 0.95)
200
201    def on_draw(self, renderer):
202        plot_w = WIDTH - PLOT_LEFT - PLOT_RIGHT
203        plot_h = HEIGHT - PLOT_TOP - PLOT_BOTTOM
204        base_y = HEIGHT - PLOT_BOTTOM
205
206        gap = 3.0
207        bar_w = (plot_w - gap * (BANDS - 1)) / BANDS
208        for i in range(BANDS):
209            x = PLOT_LEFT + i * (bar_w + gap)
210            h = float(self._bars[i]) * plot_h
211            if h > 1.0:
212                renderer.draw_rect((x, base_y - h), (bar_w, h), colour=self._band_colour(i), filled=True)
213            peak_y = base_y - float(self._peaks[i]) * plot_h
214            renderer.draw_rect((x, peak_y - 2), (bar_w, 2), colour=(0.95, 0.95, 1.0, 0.8), filled=True)
215
216        renderer.draw_line((PLOT_LEFT, base_y), (WIDTH - PLOT_RIGHT, base_y), colour=(0.5, 0.5, 0.6, 1.0))
217
218        # Frequency labels along the log axis.
219        span = np.log(F_MAX / F_MIN)
220        for hz in LABEL_HZ:
221            x = PLOT_LEFT + plot_w * np.log(hz / F_MIN) / span
222            label = f"{hz // 1000}k" if hz >= 1000 else str(hz)
223            renderer.draw_line((x, base_y), (x, base_y + 6), colour=(0.5, 0.5, 0.6, 1.0))
224            renderer.draw_text(label, (x - 4 * len(label), base_y + 12), colour=(0.6, 0.6, 0.7))
225
226        renderer.draw_text("Audio Spectrum", (PLOT_LEFT, 18), colour=(1.0, 1.0, 1.0), scale=2)
227        state = "playing" if self._player.is_playing() else "paused"
228        pos = self._player.get_playback_position()
229        renderer.draw_text(
230            f"Synthesised arpeggio, {state} at {pos:4.2f}s   SPACE: pause   ESC: quit",
231            (PLOT_LEFT, 52),
232            colour=(0.7, 0.7, 0.75),
233        )
234
235
236def _selftest() -> bool:
237    """Logic-level checks on the baked clip and the analyser: no window, no audio device."""
238    ok = True
239
240    def check(label: str, passed: bool, detail: str) -> None:
241        nonlocal ok
242        ok = ok and passed
243        print(f"{'ok  ' if passed else 'FAIL'} {label}: {detail}")
244
245    clip = bake_arpeggio()
246    pcm = clip.backend_data
247    frames_per_step = int(STEP_SECONDS * SAMPLE_RATE)
248    expected = frames_per_step * len(ARPEGGIO_HZ) * CHANNELS
249
250    check(
251        "bake produces a float32 interleaved buffer",
252        isinstance(pcm, np.ndarray) and pcm.dtype == np.float32 and pcm.size == expected,
253        f"dtype={getattr(pcm, 'dtype', None)}, size={getattr(pcm, 'size', None)} (expected {expected})",
254    )
255    check(
256        "the clip carries its rate and channel count",
257        clip.sample_rate == SAMPLE_RATE and clip.channels == CHANNELS,
258        f"sample_rate={clip.sample_rate}, channels={clip.channels}",
259    )
260
261    analyser = SpectrumAnalyser(pcm, sample_rate=SAMPLE_RATE, channels=CHANNELS)
262    tolerance = 2.0 * analyser.bin_width_hz
263
264    # At the middle of each step the ADSR is in its sustain phase, so the
265    # dominant frequency above the drone's register must be that step's note.
266    misses = []
267    for i, freq in enumerate(ARPEGGIO_HZ):
268        pos = (i + 0.5) * STEP_SECONDS
269        peak = analyser.peak_hz(pos, lo_hz=150.0)
270        if abs(peak - freq) > tolerance:
271            misses.append(f"step {i}: {peak:.0f}Hz for {freq:.0f}Hz")
272    check(
273        "each arpeggio note dominates its own step",
274        not misses,
275        f"{len(ARPEGGIO_HZ) - len(misses)}/{len(ARPEGGIO_HZ)} within {tolerance:.0f}Hz"
276        + (f"; {misses}" if misses else ""),
277    )
278
279    drone_peak = analyser.peak_hz(0.5 * STEP_SECONDS, lo_hz=60.0, hi_hz=150.0)
280    check(
281        "the bass drone shows up under the melody",
282        abs(drone_peak - DRONE_HZ) <= tolerance,
283        f"low-band peak at {drone_peak:.0f}Hz (drone is {DRONE_HZ:.0f}Hz)",
284    )
285
286    bars = analyser.magnitudes(0.6)
287    check(
288        "band magnitudes are normalised heights",
289        bars.shape == (BANDS,)
290        and bool(np.isfinite(bars).all())
291        and float(bars.min()) >= 0.0
292        and float(bars.max()) <= 1.0,
293        f"shape={bars.shape}, range=[{bars.min():.2f}, {bars.max():.2f}]",
294    )
295    check(
296        "a mid-note window actually lights bars up",
297        float(bars.max()) > 0.5,
298        f"max height {bars.max():.2f}",
299    )
300
301    # The clip loops, so a position past the end (or a window straddling the
302    # loop point) must wrap instead of running off the buffer.
303    total = STEP_SECONDS * len(ARPEGGIO_HZ)
304    wrapped = analyser.magnitudes(total + 0.6)
305    check(
306        "positions wrap at the loop point",
307        bool(np.allclose(wrapped, bars)),
308        f"max deviation {float(np.abs(wrapped - bars).max()):.4f}",
309    )
310
311    print("SELFTEST:", "PASS" if ok else "FAIL")
312    return ok
313
314
315if __name__ == "__main__":
316    import sys
317
318    if "--test" in sys.argv:
319        sys.exit(0 if _selftest() else 1)
320    App(title="Audio Spectrum", width=WIDTH, height=HEIGHT).run(SpectrumDemo())