Audio Spectrum¶
FFT bars over a synthesised arpeggio.
▶ Run in browserTags: 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())