⚡ Zig Guide LiveUnofficialbut fully verified
✓ Zig 0.17.0-dev.1941+71115f0abWhat's newOn an older Zig?

Sound Without Sound Files

There are no audio files in this project. The five effects are arithmetic, generated when the game starts.

Recording or licensing five blips would have been faster than writing a synthesiser. It also would have added a folder of binaries nobody can diff, a licence question attached to each one, and a tuning loop that runs through an audio editor. Generating them means a sound changes when a number changes.

One oscillator with an envelope

examples/lane-dodger/src/audio/synth.zigLayer
/// One oscillator with an envelope. Sounds are built by stacking a few.
pub const Layer = struct {
    shape: Shape,
    /// Frequency at the start and end of the layer, swept exponentially so the
    /// slide is even in pitch rather than in hertz. Ignored by `noise`.
    from: f32,
    to: f32,
    /// Seconds. `delay` staggers layers, which is how the arpeggios are made.
    duration: f32,
    delay: f32 = 0,
    gain: f32 = 0.5,
    /// Seconds of fade in. Never zero: a waveform that begins at full
    /// amplitude begins with a step, and a step is a click.
    attack: f32 = 0.004,
    /// Decay shape. 1 is a straight line to silence, higher is a sharper
    /// initial drop and a longer tail.
    curve: f32 = 2.5,
    /// One-pole low pass, 1 for none. Takes the fizz off the noise burst.
    lowpass: f32 = 1,
};

Each layer has a frequency sweep, an amplitude envelope, and an optional low pass to take the fizz off noise. delay staggers a layer, which is how the arpeggios are built with no sequencing code.

The coin is two layers, a triangle and a quieter sine an octave up:

examples/lane-dodger/src/audio/synth.zigcoin
/// The six sounds the game makes. Pitch for the combo ladder is applied at
/// playback rather than baked in, so there is one coin sound and not eight.
pub const coin: []const Layer = &.{
    .{ .shape = .triangle, .from = 900, .to = 1350, .duration = 0.085, .gain = 0.55 },
    .{ .shape = .sine, .from = 1800, .to = 2700, .duration = 0.07, .gain = 0.22 },
};

The crash is six. The last three are the falling triad that plays over the game over screen:

examples/lane-dodger/src/audio/synth.zigcrash
/// Impact, then a falling triad.
///
/// One event, one sound. The alternative was to play the thud on the crash and
/// the sting when the retry prompt appears, which means the game has to
/// remember whether it has played the sting yet, for a run it has already lost.
/// Layers take a delay, so the pause is in the waveform instead.
pub const crash: []const Layer = &.{
    .{ .shape = .noise, .from = 1, .to = 1, .duration = 0.45, .gain = 0.5, .lowpass = 0.12, .curve = 2 },
    .{ .shape = .square, .from = 220, .to = 55, .duration = 0.4, .gain = 0.28, .curve = 1.6 },
    .{ .shape = .sine, .from = 130, .to = 40, .duration = 0.5, .gain = 0.35, .curve = 1.4 },
    .{ .shape = .triangle, .from = 587, .to = 587, .duration = 0.12, .delay = 0.38, .gain = 0.3 },
    .{ .shape = .triangle, .from = 466, .to = 466, .duration = 0.12, .delay = 0.49, .gain = 0.3 },
    .{ .shape = .triangle, .from = 349, .to = 349, .duration = 0.3, .delay = 0.6, .gain = 0.34, .curve = 1.8 },
};

Those six layers are one sound rather than two. Playing the thud on impact and the sting when the retry prompt appears would mean tracking whether the sting had been played yet, for a run that is already over. Layers take a delay, so the pause sits in the waveform instead.

The renderer

examples/lane-dodger/src/audio/synth.zigrender
/// Render `layers` into `out`, returning the number of samples written.
///
/// The envelope reaches exactly zero at both ends of every layer. That is not
/// tidiness: a clip that starts or stops partway up a waveform produces a
/// discontinuity, and a discontinuity is a click that is far louder and more
/// annoying than the sound it is attached to.
pub fn render(layers: []const Layer, out: []i16, seed: u64) usize {
    var total: f32 = 0;
    for (layers) |layer| total = @max(total, layer.delay + layer.duration);

    const count = @min(out.len, @as(usize, @intFromFloat(total * @as(f32, sample_rate))));
    if (count == 0) return 0;

    var mixed: [max_samples]f32 = @splat(0);
    const window = mixed[0..@min(count, mixed.len)];

    var rng: Rng = .init(seed, 0x5040);
    for (layers) |layer| {
        var filtered: f32 = 0;
        var phase: f32 = 0;
        const offset: usize = @intFromFloat(layer.delay * @as(f32, sample_rate));
        const length: usize = @intFromFloat(layer.duration * @as(f32, sample_rate));
        if (length == 0) continue;

        for (0..length) |i| {
            const index = offset + i;
            if (index >= window.len) break;

            const t = @as(f32, @floatFromInt(i)) / @as(f32, sample_rate);
            const progress = @as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(length));

            // Exponential sweep: even in pitch rather than in hertz.
            const frequency = layer.from * std.math.pow(f32, layer.to / layer.from, progress);
            phase += std.math.tau * frequency / @as(f32, sample_rate);
            if (phase > std.math.tau) phase -= std.math.tau;

            var value: f32 = switch (layer.shape) {
                .sine => @sin(phase),
                .triangle => 1 - 4 * @abs(@round(phase / std.math.tau) - phase / std.math.tau),
                .square => if (phase < std.math.pi) 1 else -1,
                .noise => rng.float() * 2 - 1,
            };
            if (layer.lowpass < 1) {
                filtered += layer.lowpass * (value - filtered);
                value = filtered;
            }

            mixed[index] += value * layer.gain * envelope(t, layer);
        }
    }

    // Soft clip rather than hard: stacked layers overshoot, and tanh bends the
    // peaks instead of shearing them flat.
    for (window, 0..) |value, i| {
        const shaped = std.math.tanh(value);
        out[i] = @intFromFloat(std.math.clamp(shaped, -1, 1) * 32_600);
    }
    return window.len;
}

The frequency sweep is exponential rather than linear, because pitch is logarithmic and a linear sweep sounds like it accelerates towards the end.

Layers are summed and then soft clipped with tanh rather than clamped. Stacked layers overshoot, and clamping shears the peaks flat. That comes out as distortion.

The envelope, and the click

examples/lane-dodger/src/audio/synth.zigenvelope
fn envelope(t: f32, layer: Layer) f32 {
    if (t < layer.attack) return t / layer.attack;
    const remaining = layer.duration - layer.attack;
    if (remaining <= 0) return 0;
    const decayed = 1 - (t - layer.attack) / remaining;
    if (decayed <= 0) return 0;
    return std.math.pow(f32, decayed, layer.curve);
}

The envelope reaches exactly zero at both ends of every layer.

A clip that begins at full amplitude starts with a step from silence to whatever the waveform happened to be at. A step like that is a broadband impulse. It comes out of the speaker as a click, usually louder than the effect it belongs to. The same applies at the end. Four milliseconds of fade in is enough, and the decay is shaped to land on zero rather than being cut off there.

Testing a waveform

The synthesiser holds no raylib types. It fills a buffer with samples and stops. So the sounds are checked without an audio device. CI has no speakers anyway.

examples/lane-dodger/src/audio/synth.zigno sound begins or ends with a click
test "no sound begins or ends with a click" {
    // A clip that starts or stops partway up a waveform is a step change, and a
    // step change is a click that is louder and more irritating than the effect
    // it is attached to. The envelope is what prevents it, at both ends.
    var buffer: [max_samples]i16 = undefined;
    for (all, 0..) |layers, index| {
        const written = render(layers, &buffer, 2);
        testing.expect(@abs(@as(i32, buffer[0])) < 400) catch |err| {
            std.debug.print("sound {d} starts at {d}\n", .{ index, buffer[0] });
            return err;
        };
        testing.expect(@abs(@as(i32, buffer[written - 1])) < 400) catch |err| {
            std.debug.print("sound {d} ends at {d}\n", .{ index, buffer[written - 1] });
            return err;
        };
    }
}

Then the mix. Absolute frequencies are taste and can move. The arrangement cannot. The crash is the low long loud one. The lane tick fires on every input and must not shout over a pickup.

examples/lane-dodger/src/audio/synth.zigthe sounds sit in the right places against each other
test "the sounds sit in the right places against each other" {
    // Absolute frequencies are a matter of taste and are allowed to move. What
    // must not move is the arrangement: the crash is the low, long, loud one
    // and the lane tick is the short quiet one. Getting that backwards is the
    // kind of mistake that is obvious in a second of listening and invisible in
    // a diff.
    var buffer: [max_samples]i16 = undefined;

    const written_coin = render(coin, &buffer, 7);
    const coin_hz = brightness(buffer[0..written_coin], sample_rate);
    const coin_peak = peak(buffer[0..written_coin]);

    const written_crash = render(crash, &buffer, 7);
    const crash_hz = brightness(buffer[0..written_crash], sample_rate);
    const crash_peak = peak(buffer[0..written_crash]);

    const written_lane = render(lane, &buffer, 7);
    const lane_peak = peak(buffer[0..written_lane]);

    // The coin is a chime and the crash is a thud.
    try testing.expect(coin_hz > crash_hz * 2);
    // The coin sits in the band it is swept across.
    try testing.expect(coin_hz > 800 and coin_hz < 1600);

    // The crash is the longest thing the game plays, and the loudest.
    try testing.expect(written_crash > written_coin * 4);
    try testing.expect(crash_peak >= coin_peak);

    // The lane tick is the shortest, and must not shout over a pickup: it
    // fires on every input, and an input sound as loud as a reward sound is
    // how a game ends up muted.
    try testing.expect(written_lane < written_coin);
    try testing.expect(lane_peak < coin_peak);
}

Brightness here is zero crossings per second. That is a crude pitch estimate, but good enough to tell a chime from a thud.

Measured, the five come out as intended. The coin sits at about 1130 Hz over 85 ms. The crash runs for 900 ms, with 81 percent of its energy below 500 Hz. The lane tick is shortest and quietest. zig build sounds writes them all out as .wav if you would rather listen than read numbers.

Playing them

examples/lane-dodger/src/platform/audio.zigVoice
/// One sound plus a few aliases of it, played round robin.
///
/// raylib restarts a sound that is already playing, so a single handle can only
/// ever make one note. Coins arrive in quick succession and a combo that
/// silences itself is worse than no sound, hence the aliases: they share the
/// sample data and have their own playback position.
const Voice = struct {
    /// Four is enough for the fastest run of coins the spacing allows.
    const max_aliases = 4;

    sounds: [max_aliases]rl.Sound = undefined,
    count: usize = 0,
    next: usize = 0,

    fn load(layers: []const synth.Layer, scratch: []i16, aliases: usize, seed: u64) Voice {
        const written = synth.render(layers, scratch, seed);
        const wave: rl.Wave = .{
            .frameCount = @intCast(written),
            .sampleRate = synth.sample_rate,
            .sampleSize = 16,
            .channels = 1,
            .data = @ptrCast(scratch.ptr),
        };
        // raylib converts and copies the frames into its own buffer, so the
        // scratch array is free to be reused for the next sound.
        var voice: Voice = .{ .count = @min(aliases, max_aliases) };
        voice.sounds[0] = rl.LoadSoundFromWave(wave);
        for (1..voice.count) |i| voice.sounds[i] = rl.LoadSoundAlias(voice.sounds[0]);
        return voice;
    }

    fn play(self: *Voice, volume: f32, pitch: f32) void {
        if (self.count == 0) return;
        const sound = self.sounds[self.next];
        self.next = (self.next + 1) % self.count;
        rl.SetSoundVolume(sound, volume);
        rl.SetSoundPitch(sound, pitch);
        rl.PlaySound(sound);
    }
};

raylib restarts a sound that is already playing, so one handle can only ever make one note at a time. Coins arrive in quick succession during a combo, and each one would cut off the last. Aliases share the sample data and keep their own playback position.

The combo ladder is a pitch multiplier on the one coin sound rather than eight recordings:

examples/lane-dodger/src/platform/audio.zigonEvent
    /// The one entry point the game uses. Takes the same events the particles
    /// and the screen shake take.
    pub fn onEvent(self: *Audio, event: sim.Event) void {
        switch (event) {
            .coin => |c| {
                // The combo ladder, as pitch rather than as eight recordings.
                // A semitone is 2^(1/12); this is a little under one per step,
                // so a full combo lands about a fifth above where it started.
                const step = std.math.pow(f32, 2.0, @as(f32, @floatFromInt(c.combo - 1)) / 14.0);
                self.cue(.coin, 0.85, step);
            },
            .near_miss => self.cue(.near_miss, 0.6, 1),
            .lane_changed => self.cue(.lane, 0.5, 1),
            .crashed => self.cue(.crash, 1, 1),
            .started => self.cue(.start, 0.8, 1),
            .coin_missed => {},
        }
    }

Optional at runtime

A machine with no sound card, a container with no ALSA, a browser tab nobody has clicked in yet: in all of those the device never opens, and every call above turns into nothing.

examples/lane-dodger/src/platform/audio.ziginit
    /// Opens the device and renders every sound into it. Safe to call when
    /// there is no device; `ready` stays false and everything else no-ops.
    pub fn init(seed: u64) Audio {
        var audio: Audio = .{};
        rl.InitAudioDevice();
        if (!rl.IsAudioDeviceReady()) return audio;
        audio.ready = true;

        // One buffer, reused: `LoadSoundFromWave` copies.
        var scratch: [synth.max_samples]i16 = undefined;
        const table = .{
            .{ Cue.coin, synth.coin, 4 },
            .{ Cue.near_miss, synth.near_miss, 2 },
            .{ Cue.lane, synth.lane, 3 },
            .{ Cue.crash, synth.crash, 1 },
            .{ Cue.start, synth.start, 1 },
        };
        inline for (table) |entry| {
            audio.voices.set(entry[0], .load(entry[1], &scratch, entry[2], seed));
        }

        rl.SetMasterVolume(0.55);
        return audio;
    }

Failing to start because a mixer would not open is not acceptable for a game whose sound is decoration. The real binary is run with a deliberately broken ALSA configuration as part of checking that.

None of this changed a rule of the game. Sound reads the same event stream the particles read, and the existing tests kept passing while it was written.