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