Audio Spectrum

FFT bars over a synthesised arpeggio.

▶ Run in browser

Tags: audio spectrum fft synthesis

An AudioSynth bakes a looping A-minor arpeggio over a bass drone, an AudioPlayer plays it, and a bar spectrum moves in time with it.

The clip is built from synthesis alone. Each note is its own AudioSynth with a sine and a triangle voice under one ADSR envelope, baked with synth.bake(seconds, sample_rate=...) so the envelope spans exactly that note. A second synth bakes a saw drone through a LowPass filter across the whole loop. A baked clip’s backend_data is its float32 interleaved stereo PCM, so the notes are joined and the drone mixed in with numpy, and AudioClip.from_pcm wraps the result as a playable clip:

synth = AudioSynth()
synth.add(Oscillator.sine(440.0), envelope=ADSR(attack=0.02, decay=0.06, sustain=0.7, release=0.08))
note = synth.bake(0.25, sample_rate=44100).backend_data

AudioPlayer(clip, loop=True, autoplay=True) starts it looping as soon as it enters the tree. The engine has no tap on the audio output, so each frame the example analyses its own copy of the clip: it reads player.get_playback_position(), takes a 2048-sample Hann-windowed slice of the mono mixdown around that point (wrapping at the loop), runs np.fft.rfft, and folds the bins into 48 log-spaced bands from 60 Hz to 8 kHz. Each band’s loudest bin in decibels, above a -60 dB floor, sets the bar’s height. Bars jump up at once and fall back slowly, and a thin cap above each one holds its recent peak. This only works for a clip built in memory: a clip loaded from a file is decoded by the audio backend, which offers no public way to read its samples.

Space, a click or a tap pauses and resumes the player, and the bars sink while it is paused.

Source

  1# /// simvx
  2# tags = ["audio", "spectrum", "fft", "synthesis"]
  3# ///
  4"""Audio Spectrum: FFT bars over a synthesised arpeggio.
  5
  6An `AudioSynth` bakes a looping A-minor arpeggio over a bass drone, an
  7`AudioPlayer` plays it, and a bar spectrum moves in time with it.
  8
  9The clip is built from synthesis alone. Each note is its own `AudioSynth` with
 10a sine and a triangle voice under one `ADSR` envelope, baked with
 11`synth.bake(seconds, sample_rate=...)` so the envelope spans exactly that note.
 12A second synth bakes a saw drone through a `LowPass` filter across the whole
 13loop. A baked clip's `backend_data` is its float32 interleaved stereo PCM, so
 14the notes are joined and the drone mixed in with numpy, and
 15`AudioClip.from_pcm` wraps the result as a playable clip:
 16
 17```python
 18synth = AudioSynth()
 19synth.add(Oscillator.sine(440.0), envelope=ADSR(attack=0.02, decay=0.06, sustain=0.7, release=0.08))
 20note = synth.bake(0.25, sample_rate=44100).backend_data
 21```
 22
 23`AudioPlayer(clip, loop=True, autoplay=True)` starts it looping as soon as it
 24enters the tree. The engine has no tap on the audio output, so each frame the
 25example analyses its own copy of the clip: it reads
 26`player.get_playback_position()`, takes a 2048-sample Hann-windowed slice of the
 27mono mixdown around that point (wrapping at the loop), runs `np.fft.rfft`, and
 28folds the bins into 48 log-spaced bands from 60 Hz to 8 kHz. Each band's
 29loudest bin in decibels, above a -60 dB floor, sets the bar's height. Bars jump
 30up at once and fall back slowly, and a thin cap above each one holds its recent
 31peak. This only works for a clip built in memory: a clip loaded from a file is
 32decoded by the audio backend, which offers no public way to read its samples.
 33
 34Space, a click or a tap pauses and resumes the player, and the bars sink while
 35it is paused.
 36"""
 37
 38import numpy as np
 39
 40from simvx.core import ADSR, AudioClip, AudioPlayer, AudioSynth, Key, LowPass, Node, Oscillator, Pointer, on_input
 41from simvx.graphics import App
 42
 43SAMPLE_RATE = 44100
 44NOTES_HZ = (220.0, 261.63, 329.63, 440.0, 523.25, 440.0, 329.63, 261.63)  # A3 C4 E4 A4 C5 A4 E4 C4
 45NOTE_SECONDS = 0.25
 46LOOP_SECONDS = NOTE_SECONDS * len(NOTES_HZ)
 47DRONE_HZ = 110.0  # a whole number of cycles per loop, so the loop point is seamless
 48
 49WINDOW = 2048  # samples per FFT, about 46 ms
 50BANDS = 48
 51F_MIN, F_MAX = 60.0, 8000.0
 52FLOOR_DB = -60.0
 53BAR_FALL = 1.8  # fraction of full height per second
 54PEAK_FALL = 0.35
 55LABEL_HZ = (100, 250, 500, 1000, 2000, 4000, 8000)
 56
 57FREQS = np.fft.rfftfreq(WINDOW, 1.0 / SAMPLE_RATE)
 58BAND_BINS = np.searchsorted(FREQS, np.geomspace(F_MIN, F_MAX, BANDS + 1))
 59TAPER = np.hanning(WINDOW)
 60
 61
 62def bake_arpeggio() -> AudioClip:
 63    """The looping clip: one baked synth per note, joined, over a drone baked across the loop."""
 64    notes = []
 65    for hz in NOTES_HZ:
 66        envelope = ADSR(attack=0.02, decay=0.06, sustain=0.7, release=0.08)
 67        synth = AudioSynth()
 68        synth.add(Oscillator.sine(hz), envelope=envelope, gain=0.5)
 69        synth.add(Oscillator.triangle(hz * 2), envelope=envelope, gain=0.15)
 70        notes.append(synth.bake(NOTE_SECONDS, sample_rate=SAMPLE_RATE).backend_data)
 71    drone = AudioSynth()
 72    drone.add(Oscillator.saw(DRONE_HZ), filter=LowPass(400.0), gain=0.25)
 73    pcm = np.concatenate(notes) + drone.bake(LOOP_SECONDS, sample_rate=SAMPLE_RATE).backend_data
 74    return AudioClip.from_pcm(np.clip(pcm, -1.0, 1.0), sample_rate=SAMPLE_RATE, channels=2)
 75
 76
 77def band_levels(mono: np.ndarray, seconds: float) -> np.ndarray:
 78    """Per-band heights in [0, 1] for the window centred on `seconds` of a looping mono buffer."""
 79    start = int(seconds * SAMPLE_RATE) - WINDOW // 2
 80    chunk = mono.take(np.arange(start, start + WINDOW), mode="wrap")
 81    spectrum = np.abs(np.fft.rfft(chunk * TAPER)) / (WINDOW / 4)  # a full-scale sine peaks near 1
 82    # The loudest bin in each band; a band narrower than one bin reads the bin it starts on.
 83    loudest = np.maximum.reduceat(spectrum[: BAND_BINS[-1]], BAND_BINS[:-1])
 84    db = 20.0 * np.log10(loudest + 1e-9)
 85    return np.clip(1.0 - db / FLOOR_DB, 0.0, 1.0)
 86
 87
 88class Spectrum(Node):
 89    dynamic = True
 90    input_context = {"pause": [Key.SPACE, Pointer.PRIMARY]}
 91
 92    def on_ready(self):
 93        clip = bake_arpeggio()
 94        self.mono = clip.backend_data.reshape(-1, 2).mean(axis=1)
 95        self.bars = np.zeros(BANDS)
 96        self.peaks = np.zeros(BANDS)
 97        self.player = self.add_child(AudioPlayer(clip, loop=True, autoplay=True))
 98
 99    @on_input("pause")
100    def toggle_pause(self, _event):
101        if self.player.is_paused():
102            self.player.play()
103        else:
104            self.player.pause()
105
106    def on_update(self, dt):
107        target = band_levels(self.mono, self.player.get_playback_position()) if self.player.is_playing() else 0.0
108        self.bars = np.maximum(target, self.bars - BAR_FALL * dt)
109        self.peaks = np.maximum(self.bars, self.peaks - PEAK_FALL * dt)
110
111    def on_draw(self, renderer):
112        width, height = self.viewport_size
113        left, right, top, base = 50.0, width - 30.0, 90.0, height - 60.0
114        gap = 3.0
115        bar_w = (right - left - gap * (BANDS - 1)) / BANDS
116        for i, (bar, peak) in enumerate(zip(self.bars, self.peaks, strict=True)):
117            x = left + i * (bar_w + gap)
118            t = i / (BANDS - 1)
119            h = bar * (base - top)
120            renderer.rect((x, base - h), (bar_w, h), fill=(0.25 + 0.75 * t, 0.55 - 0.15 * t, 1.0 - 0.7 * t, 0.95))
121            renderer.rect((x, base - peak * (base - top) - 2), (bar_w, 2), fill=(0.95, 0.95, 1.0, 0.8))
122
123        axis = (0.5, 0.5, 0.6, 1.0)
124        renderer.line((left, base), (right, base), stroke=axis)
125        for hz in LABEL_HZ:
126            x = left + (right - left) * np.log(hz / F_MIN) / np.log(F_MAX / F_MIN)
127            renderer.line((x, base), (x, base + 6), stroke=axis)
128            renderer.text(f"{hz // 1000}k" if hz >= 1000 else str(hz), (x, base + 12), fill=axis, align="centre")
129
130        renderer.text("Audio Spectrum", (left, 18), fill=(1.0, 1.0, 1.0), scale=2)
131        state = "playing" if self.player.is_playing() else "paused"
132        renderer.text(
133            f"Synthesised arpeggio, {state} at {self.player.get_playback_position():4.2f}s",
134            (left, 52),
135            fill=(0.7, 0.7, 0.75),
136        )
137        # Example help overlay: not part of the technique shown.
138        renderer.text("SPACE or click: pause", (right, 52), fill=(0.7, 0.7, 0.75), align="right")
139
140
141if __name__ == "__main__":
142    App(title="Audio Spectrum", width=960, height=540).run(Spectrum())