flowchart TB SEQ["Sequencer: 60 bars × 16 steps"] --> K["Kick, hats, claps,<br/>bells, riser"] SEQ --> D["Pad, bass, arpeggio"] K --> DRY["dry bus"] D --> DUCK["duck bus"] K -.->|"send"| SEND["send bus"] D -.->|"send"| SEND SEND --> DL["Ping-pong delay"] --> RV["Reverb"] DUCK --> SC["× (1 − 0.6·beat)<br/>sidechain"] DRY --> MIX["Sum → soft clip → fade-out"] SC --> MIX DL --> MIX RV --> MIX MIX --> OUT["Vec<f32>: stereo, 44.1 kHz"]
A synthesizer in 600 lines: a soundtrack without a single sample
Part 5. From a sine wave to a mixed track with reverb
The nebula is done and renders fast (parts 2–4), but the demo is still silent. A 64k intro cannot hold even one second of recorded sound: 150 seconds of 16-bit stereo is 26 MB. So, like the picture, the sound will be computed.
The result first. These are excerpts of the track generated by the code in this article:
Awakening, 0:04–0:16. A pad with an opening filter and FM bells:
Core, 1:10–1:20. The full rhythm, bass, arpeggio, delay:
The decision: the whole track up front
A synthesizer can run in real time, inside the sound card’s callback. We take the simpler route: generate the whole track at startup into a buffer and afterwards just play it back. It is the same precalc as in part 4:
| Real time | Precalc | |
|---|---|---|
| Startup | instant | 1.4 s (hidden in a thread) |
| Memory | kilobytes | 53 MB (150 s × 44,100 × 2 channels × 4 bytes) |
| Constraints on the code | a hard per-sample budget, no allocations | any loops, any effects |
| Sync with the picture | hard | buffer position = time |
The synthesizer’s architecture is a signal-flow graph:
Math without the standard library
The demo library is #![no_std], so it has no f32::sin or f32::exp: in Rust those live in std. We need our own, fast and accurate enough for audio.
Sine is approximated with parabolas. The argument is measured in turns rather than radians, which suits oscillators better:
y = 8t - 16t\,|t|, \qquad \sin(2\pi x) \approx 0.225\,(y\,|y| - y) + y, \qquad t = x - \lfloor x + 0.5 \rfloor
Exponential goes through e^x = 2^{x \log_2 e}. The integer part of the power is written straight into the float’s exponent bits, and the fractional part f is approximated by a Taylor polynomial for 2^f:
src/music.rs
/// sin(2π·x) with x in turns. Parabolic approximation, ~0.1% error.
pub fn osc(x: f32) -> f32 {
let t = x - floor(x + 0.5);
let y = 8.0 * t - 16.0 * t * t.abs();
0.225 * (y * y.abs() - y) + y
}
/// e^x via 2^i · poly(2^f).
pub fn exp(x: f32) -> f32 {
let x = clamp(x, -87.0, 88.0) * core::f32::consts::LOG2_E;
let i = floor(x);
let f = x - i;
let p = 1.0 + f * (0.693_147_2 + f * (0.240_226_5 + f * (0.055_504_1 + f * 0.009_618_1)));
p * f32::from_bits(((i as i32 + 127) as u32) << 23) // 2^i written straight into the IEEE 754 bits
}A test compares them with the standard functions at 200 points: the sine is within 2\cdot10^{-3}, the exponential within 0.1% relative error. Inaudible in music.
Three more formulas are needed everywhere: MIDI note to frequency, a one-pole low-pass filter, and soft saturation:
f = 440 \cdot 2^{(n-69)/12}, \qquad y \mathrel{+}= (x - y)\left(1 - e^{-2\pi f_c / f_s}\right), \qquad \operatorname{soft}(x) = \frac{x\,(27 + x^2)}{27 + 9x^2}
Tempo as an architectural decision
The script from part 1 cuts the demo into scenes at 25, 55, 90, and 125 seconds. We want the music to change exactly at scene boundaries, on a downbeat. So we pick the tempo: at 96 beats per minute a 4/4 bar lasts
\text{BAR} = 4 \cdot \frac{60}{96} = 2.5 \text{ s},
and every scene boundary lands at the start of a bar:
| Boundary | 25 s | 55 s | 90 s | 125 s | 150 s |
|---|---|---|---|---|---|
| Bar | 10 | 22 | 36 | 50 | 60 |
A test checks this, so the script and the music cannot drift apart in future edits:
src/music.rs
#[test]
fn scenes_fall_on_bar_lines() {
for s in SCENES {
let bars = s / BAR;
assert!((bars - floor(bars + 0.5)).abs() < 1e-4, "{s} s is not on a bar line");
}
}A voice and a bus
Every note is a closure: “time since note-on → stereo sample”. Mix::add calls it for every sample of the note’s duration and distributes the result across the buses, taking pan and effect send into account:
src/music.rs (abridged)
fn add(&mut self, bus: Bus, t0: f32, dur: f32, gain: f32, pan: f32, send: f32,
mut f: impl FnMut(f32) -> (f32, f32)) {
let i0 = (t0 * RATE) as usize;
let gl = gain * (1.0 - pan.max(0.0)); // pan: −1 left, +1 right
let gr = gain * (1.0 + pan.min(0.0));
let dst = match bus { Bus::Dry => &mut self.dry, Bus::Duck => &mut self.duck };
for i in 0..n {
let (l, r) = f(i as f32 / RATE); // the note knows its own time
let k = 2 * (i0 + i);
dst[k] += l * gl; dst[k + 1] += r * gr;
self.send[k] += l * gl * send; self.send[k + 1] += r * gr * send;
}
}The closure can hold state (FnMut): an oscillator’s phase, a filter’s memory, a noise generator. Each instrument becomes a 5–15 line recipe.
Instruments
The kick
A kick drum is a sine whose frequency drops quickly. The frequency f(t) and the phase \varphi(t), the integral of the frequency, are:
f(t) = 48 + 142\,e^{-30t}\ \text{Hz}, \qquad \varphi(t) = \int_0^t f = 48t + \frac{142}{30}\left(1 - e^{-30t}\right)
src/music.rs
fn kick(m: &mut Mix, t0: f32) {
m.add(Bus::Dry, t0, 0.5, 0.9, 0.0, 0.0, |t| {
// Pitch sweeps 190 -> 48 Hz; phase is the integral of frequency.
let ph = 48.0 * t + 142.0 / 30.0 * (1.0 - exp(-30.0 * t));
let v = soft(1.6 * osc(ph) * exp(-t * 7.0)); // overdrive gives it punch
(v, v)
});
}Plugging a changing frequency straight into sin(2π f(t) t) is a classic mistake: the instantaneous frequency of that signal is not f(t), and the pitch “wobbles”. The right way is to integrate the frequency into a phase.
The rest of the orchestra
src/music.rs).
| Instrument | Recipe |
|---|---|
| Clap | Filtered noise; three quick 10 ms hits, then a tail; plus a 185 Hz body |
| Hat | Noise differentiated twice (high frequencies), decay of 35–80 per second |
| Bass | Saw plus a sub sine; the filter closes quickly from 1520 to 120 Hz |
| Pad | 3 detuned saws per voice (±0.35%), stereo, slow attack, filter set by the scene |
| Arpeggio | A saw through a 5300 → 300 Hz filter: a “pluck” |
| Bell | FM: carrier f, modulator 3.5f, index 2.5\,e^{-2t} |
| Riser | Noise with a rising cutoff and volume, cut off on the downbeat |
| Crash | Differentiated noise, decaying at 1.8 per second |
The bell is frequency modulation in Chowning’s classic form. The inharmonic ratio of 3.5 gives a “metallic” spectrum, and the decaying index makes the strike bright and the tail clean [1]:
x(t) = \sin\!\big(2\pi f t + I(t)\,\sin(2\pi \cdot 3.5 f t)\big)\,e^{-1.6t}, \qquad I(t) = 2.5\,e^{-2t}
The saw behind the pad, bass, and arpeggio needs its discontinuity smoothed (polyBLEP). A naive saw 2\varphi - 1 has an infinite spectrum, and the harmonics above 22 kHz “fold back” as false overtones. PolyBLEP subtracts a polynomial “step” from the signal around the discontinuity [2]:
src/music.rs
fn next(&mut self) -> f32 {
let (p, dt) = (self.ph, self.dt); // phase 0…1, phase step = f / 44100
let mut s = 2.0 * p - 1.0; // the naive saw
if p < dt { // just after the jump
let t = p / dt;
s -= t + t - t * t - 1.0;
} else if p > 1.0 - dt { // just before the jump
let t = (p - 1.0) / dt;
s -= t * t + t + t + 1.0;
}
self.ph += dt;
if self.ph >= 1.0 { self.ph -= 1.0; }
s
}Arrangement
The harmony is simple and cinematic: Am – F – C – G, two bars per chord. The bass plays the chord root; the bells pick notes of the A minor pentatonic at random, but deterministically (by a hash of the step number). Who plays when is shown in the chart:
gantt
dateFormat x
axisFormat %M:%S
section Rhythm
Half-time kick : 30000, 55000
Kick on every beat : 55000, 87500
Kick (flow) : 90000, 125000
Hats : 55000, 87500
16th-note hats : 90000, 125000
Claps : 70000, 87500
Claps (flow) : 90000, 125000
section Harmony
Pad : 0, 150000
Bass : 30000, 125000
Arpeggio : 35000, 125000
section Color
Bells : 0, 25000
Bells (sparse) : 55000, 90000
Bells (finale) : 125000, 150000
Riser → core : 52500, 55000
Riser → flow : 87500, 90000
Before the “flow” there is a one-bar break: the kick and hats drop out, the bass holds a single note, and the noise of the riser swells until it is cut. At 1:30 everything returns with a crash. This is easier to hear than to read about:
The kick pattern is a function of the step number, and that matters: in part 6 the picture calls exactly this function.
src/music.rs
/// Kick pattern, shared by the synth and the visuals.
pub fn kick_on(step: u32) -> bool {
let (bar, s) = (step / 16, step % 16);
if bar == RISER_BAR {
return false; // the break before the "flow"
}
match section(bar) {
1 => bar >= 12 && (s == 0 || s == 8 || (s == 10 && bar % 4 == 3)),
2 | 3 => s % 4 == 0, // on every beat
_ => false,
}
}Effects and mixdown
Two effects on the send bus create the sense of space.
Ping-pong delay. Repeats at a dotted eighth (0.47 s) bounce between the channels and get darker with each repeat:
flowchart LR IN["send L+R"] --> L["line L<br/>0.47 s"] L -->|"output L"| R["line R<br/>0.47 s"] R -->|"× 0.45, darker"| L
Reverb follows the Freeverb design: four damped comb filters in parallel, then two all-pass filters in series. The right channel’s lines are 23 samples longer than the left’s, and that difference creates width [3].
src/music.rs
fn tick(&mut self, x: f32) -> f32 {
let x = x * 0.25;
let mut y = 0.0;
for (c, d) in self.comb.iter_mut().zip(&mut self.damp) { // 4 combs in parallel
let o = c.tap();
*d = o * 0.7 + *d * 0.3; // damping: darker every lap
c.push(x + *d * 0.85);
y += o;
}
for a in &mut self.ap { // 2 all-passes in series
let b = a.tap();
a.push(y + b * 0.5);
y = b - y;
}
y
}In the mixdown the duck bus is multiplied by 1 − 0.6·beat(t): the pad, bass, and arpeggio dip under every kick. This is sidechain, the characteristic “pumping” of electronic music, covered in part 6. Then everything is summed, passed through the soft saturation, and faded out over the last 10 seconds.
Checking without ears
Listening to the track after every change is slow, and an automated check cannot listen at all. So a small separate crate renders the track to WAV and computes three things: the peak (is there clipping?), NaN (did the math break?), and the average RMS loudness per scene:
| Section | RMS | Peak |
|---|---|---|
| 0:00–0:25 · awakening | −21.4 dB | 0.40 |
| 0:25–0:55 · entry | −13.5 dB | 0.87 |
| 0:55–1:27.5 · core | −11.4 dB | 0.91 |
| 1:27.5–1:30 · break | −16.0 dB | 0.70 |
| 1:30–2:05 · flow | −11.3 dB | 0.91 |
| 2:05–2:30 · exit | −22.5 dB | 0.42 |
The dramatic arc shows in the numbers: a quiet opening, a dense middle, a dip before the climax, a quiet ending. And a spectrogram shows the shape of the whole track:

ffmpeg -i track.wav -lavfi "showspectrumpic=s=1600x500:legend=1:scale=log:fscale=log:stop=12000" spec.pngOptimizing the precalc
The first version took 4.02 s to compute the track. No profiler was needed; counting calls was enough:
| What | Before | After |
|---|---|---|
| Sidechain envelope | beat(t) on every sample: up to 16 timeline calls |
incremental: on a new step, check for a kick |
| Pad filter | cutoff and exp on every sample |
once every 64 samples |
| Precalc time | 4.02 s | 1.44 s |
There is a third lever: with opt-level = 3 the precalc takes 0.69 s. But the "z" profile (optimize for size) matters more to us, so we simply compute the track on a separate thread while the window and the shader come up. The demo starts with a fade from black anyway.
src/main.rs
// Precalc the soundtrack on a worker while the window and shader come up.
let track = std::thread::spawn(music::render);
// ... window, MTKView, pipeline ...
let audio = Audio::play(track.join().expect("music precalc"), music::RATE);The end of the track, fading out:
Series: Demoscene 2026
Part 5 of 7 · suggested reading order
Previous: Noise in one fetch: a 3D texture instead of eight hashes
Next: Locking the picture to the music: audio clock, sidechain, and beat