Skip to main content

A synthesizer in 600 lines: a soundtrack without a single sample

Part 5. From a sine wave to a mixed track with reverb

Demoscene
Audio
Rust
How to write a kick, bass, pad, FM bells, a delay, and a reverb in no_std Rust, make the shape of the track follow the demo’s timeline, and check the sound without listening to it: RMS, peaks, and a spectrogram.
Author

Mikhail

Published

4 November 2026

NoteWhere we are

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:

Table 1: Two ways to synthesize sound.
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:

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&lt;f32&gt;: stereo, 44.1 kHz"]

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:

Table 2: Scene boundaries in bars.
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)
    });
}

Top: a decaying sine wave whose first periods are shorter. Bottom: a frequency curve dropping quickly from 190 to 48 Hz.

The first half-second of the kick: the falling frequency is what you hear as “thoom”.
TipWhy phase, and not sin(2π·f(t)·t)

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

Table 3: The synthesizer’s instruments (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:

Table 4: Track loudness by section. Overall peak 0.912, no NaN.
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:

A 150-second spectrogram: warm bands of the pad in the lower part; from second 30, regular vertical kick hits at the bottom; two bright vertical crash lines at 55 and 90 seconds; after 125 seconds the rhythm disappears.

Spectrogram of the track: time on the horizontal axis, frequency on the vertical (log scale). The kick entering at 0:30 (vertical strokes at the bottom), the crashes at 0:55 and 1:30, the break before 1:30, and the final fade are all visible.
ffmpeg -i track.wav -lavfi "showspectrumpic=s=1600x500:legend=1:scale=log:fscale=log:stop=12000" spec.png

Optimizing the precalc

The first version took 4.02 s to compute the track. No profiler was needed; counting calls was enough:

Table 5: Two changes, a 2.8× speedup.
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:

References

[1]
Chowning J. M. The Synthesis of Complex Audio Spectra by Means of Frequency Modulation. Journal of the Audio Engineering Society. 1973.
[2]
Välimäki V. and Huovilainen A. Antialiasing Oscillators in Subtractive Synthesis. IEEE Signal Processing Magazine. 2007. DOI: 10.1109/MSP.2007.323276
[3]
Back to top