Spore & Skog devlog · sound

A forest never recorded

Behind the cards of my game Spore & Skog there is a forest: songbirds, a cuckoo, a woodpecker, a bumblebee, crickets, frogs in the rain, an owl in the fog, ravens. None of them was recorded. Each call is made as it happens, sample by sample, by 574 lines of plain Rust running inside a CPAL audio callback, and the game's Bevy systems tell it what weather it is.

This page runs that same Rust, compiled to WebAssembly. Step in.

Play on itch.io Join the beta on iPad and Mac

The forest is quiet until you step in. Headphones help: the creatures are soft, and placed left and right.

The sliders are what the game sends when the weather over the forests changes. The bars are each creature's odds of calling next under that sky. In the game the creatures sit well under the music; alone here, they're brought up 12 dB.

Made as they call

Most game sounds are clips: a buffer made once (recorded, or rendered at start-up) and played back when it's needed. A forest doesn't work that way. A cuckoo that calls exactly the same three times in a row stops being a cuckoo and starts being a sound effect.

So the creatures in Spore & Skog aren't clips. Every call starts from a handful of dice (its pitch, how many notes, how fast, how far away, left or right) and is then made a sample at a time, on the audio thread, while you hear it. No two are the same, and there is nothing to store: the whole forest is 8 recipes and a little arithmetic per sample.

Live synthesis has a cost the clips don't: it runs against the audio deadline. So the forest is kept small. At most 3 creatures call at once, each is a few oscillators and filters, and they sit well under everything else in the mix.

Press a creature

Each press makes a new call of that creature. Here it is rendered first, so the picture above is exactly what you hear; in the game the same code runs live, inside the callback.

One stream, one callback

CPAL is Rust's cross-platform audio output: CoreAudio, WASAPI, ALSA, Web Audio. It does one thing. It opens a stream and keeps calling your closure with an empty buffer to fill, a few milliseconds' worth at a time, on a real-time thread. Miss the deadline and you hear a click.

The game opens exactly one stream and puts a small mixer, the desk, in its callback. The rest of the game never touches the desk's state; it sends it commands over a channel, and the desk reads them at the start of each buffer. Last in its chain is a soft limiter, so a pile-up of calls never clips.

Opening the stream is the usual CPAL routine (host, device, config), with one generic function so the same desk can feed whatever sample format the device wants. It's all in desk.rs and device.rs, and with those a forest is a short program with no game around it at all: open the stream, tell it the weather, listen.

Three small tools

Every creature is made from the same three things. First, noise: a xorshift generator, seeded, so the forest is the same every time the game starts, and with helpers for the dice.

Second, a filter. Birds are mostly pure tones, but a woodpecker's knock, a bee's body, a frog's throat and a raven's croak are all something buzzing through a resonance. The tool for that is a state-variable filter, here in its "topology-preserving" form: stable when its frequency sweeps, cheap, and with a low-pass and a band-pass out of the same two lines of state. Its coefficients are Andrew Simper's (Cytomic, 2013), from the method Vadim Zavalishin sets out in The Art of VA Filter Design.

Try it: noise through the filter

Third, a shape in time. Real calls don't switch on and off; they swell and fade. One smooth bump, a smoothstep up and down, shapes nearly every note in the forest:

The bump, in RustRust · 11 lines

forest/src/wildlife.rs

/// A smooth bump over `0..len`, rising and falling over `edge`.
pub fn window(t: f32, len: f32, edge: f32) -> f32 {
    if t < 0.0 || t > len {
        return 0.0;
    }
    let e = edge.min(len / 2.0).max(1e-4);
    let up = (t / e).min(1.0);
    let down = ((len - t) / e).min(1.0);
    let w = up.min(down);
    w * w * (3.0 - 2.0 * w)
}
window(t, 1, 0.3)

If you know synthesizers, this is an attack–hold–release envelope with its corners rounded: it rises over edge, holds at full, and falls over edge, all within a length fixed when the call begins. Unlike an ADSR there is no separate decay to a sustain level, and nothing waits for a key to be let go: a creature's call is a gesture of known length, not a note someone holds. In signal processing terms it is a tapered window, of the same family as a Tukey window.

With those three, every creature is the same four steps: a source (a tone, a buzz, a click, noise), a shape in time (when it sounds and how it swells), a filter that colours it, and a place in the forest, near or far, left or right. The dice for all four are rolled once, when a call begins; then the sound is made a sample at a time. The rest of this page is eight such recipes.

Whistles: songbird, cuckoo, owl

The three that sing share one engine: a list of notes, each a sweep from one pitch to another, played by a single sine. What makes them different is the notes they roll. A songbird throws a quick phrase of short sweeps around one high pitch. The cuckoo calls its falling third, two to four times, always from a little way off. The tawny owl, the fog's creature, calls its "hoooo …… hu, hu-hu-hoooo".

Each also has its own timbre. The songbird is a bare sine in an arch. The cuckoo adds a touch of its octave. The owl breathes, with noise through the filter tuned to its octave, and its last note quavers as it starts.

Songbird
  1. a sine, sweeping up or down 400–1300 Hz from 2.6–4.2 kHz
  2. 2 to 6 quick notes, each an arch
  3. none: a bare tone
  4. near or far, left or right
Cuckoo
  1. a sine at 600–700 Hz, with a touch of its octave
  2. "cu-ckoo", a falling third, 2 to 4 times
  3. none
  4. always a little way off
Tawny owl
  1. a sine at 380–440 Hz, and breath
  2. "hoooo … hu, hu-hu-hoooo", the last note quavering
  3. breath through a band an octave up
  4. further off, in the fog
The three voices, in RustRust · 25 lines

forest/src/wildlife.rs

Kind::Song | Kind::Cuckoo | Kind::Owl => {
    let Some(&(start, len, from, to)) =
        self.notes.iter().find(|n| t >= n.0 && t < n.0 + n.1)
    else {
        return Some([0.0; 2]);
    };
    let u = (t - start) / len;
    let hz = from + (to - from) * u;
    self.phase = (self.phase + hz * dt).fract();
    let p = self.phase * TAU;
    match self.kind {
        Kind::Song => p.sin() * (u * PI).sin(),
        Kind::Cuckoo => (p.sin() + 0.18 * (2.0 * p).sin()) * window(u, 1.0, 0.18),
        _ => {
            // The owl's last note quavers at its start.
            let quaver = if start > 1.5 {
                1.0 - 0.6 * (1.0 - u).powi(3) * (0.5 + 0.5 * (t * 2.0 * TAU * 6.0).sin())
            } else {
                1.0
            };
            let breath = self.air.next(noise.next(), hz * 2.0, 2.0, rate).1 * 0.25;
            (p.sin() + breath) * window(u, 1.0, 0.3) * quaver
        }
    }
}

Knocks and buzzes: woodpecker, bumblebee, crickets

A woodpecker's roll is a train of knocks, each a burst of filtered noise with a ring of the dead wood's pitch, coming slightly faster as it goes and dying away.

The bumblebee is the one creature that moves: it crosses from one side to the other, and its buzz (a sawtooth through the filter's low-pass) falls a little in pitch as it passes (the Doppler effect), the way a passing car's does.

Crickets are a high tone gated into chirps of three quick pulses, a few times a second.

Woodpecker
  1. a click of noise, and a 600–900 Hz ring of dead wood
  2. 12 to 22 knocks, 15–22 a second, quickening and fading
  3. the click through a band at 1.6 kHz
  4. near or far, left or right
Bumblebee
  1. a sawtooth at 140–190 Hz, with a slow vibrato
  2. swells over 2–3.4 s, its pitch falling as it passes
  3. a low-pass at 700 Hz
  4. close, crossing from one side to the other
Crickets
  1. a pure tone at 4.1–4.7 kHz
  2. chirps of 3 pulses, about 3 a second, for 2.5–4 s
  3. none
  4. near or far, left or right

All eight recipes are in wildlife.rs: the dice in Call::new, the sound in Call::next.

Throats: frog and raven

These two are made the way voices are: a source and a filter. A frog's croak is a train of clicks (glottal pulses, 26 to 38 a second) each ringing a narrow resonance for the throat and a broader one an octave down for the chest. Nothing in it is a tone; the pitch you hear is the resonance.

The raven took the longest. Shaping a voice like the frog's, with pulses, jitter and the pitch slipping to half itself, kept sounding like a duck, or a kazoo. What finally worked is a synthesizer patch, from a reply on r/synthrecipes, checked against a recording of real ravens: a low sawtooth (about 110 Hz) thrown up a fifth in 15 ms and falling back over 150 ms, chopped by a fast, nearly square tremolo, 55 times a second, the way a raven's throat flutters, its rate wavering so it never ticks like a machine. Then a strong "ah" formant at 1 kHz, a nasal edge just above it, and a lift at 2.5 to 4 kHz.

Frog
  1. clicks: glottal pulses, 26–38 a second
  2. 2 to 4 croaks, a third of a second each
  3. a narrow throat at 380–520 Hz, a broad chest an octave down
  4. near or far, left or right
Raven
  1. a sawtooth near 110 Hz, thrown up a fifth and falling back
  2. 1 to 3 croaks of 0.4 s, chopped by a 55 Hz tremolo
  3. "ah" at 1 kHz, a nasal edge at 1.5 kHz, a lift at 2.5–4 kHz
  4. near or far, left or right
The raven, in RustRust · 32 lines

forest/src/wildlife.rs

Kind::Raven => {
    let croak = (t / RAVEN_GAP).floor();
    let local = t - croak * RAVEN_GAP;
    if croak >= self.count || local > RAVEN_CROAK {
        0.0
    } else {
        // The pitch thrown up a fifth in 15 ms, then falling back
        // over the next 150 ms; nothing held.
        let throw = if local < 0.015 {
            local / 0.015
        } else {
            (-(local - 0.015) / 0.15).exp()
        };
        let hz = self.pitch * 2f32.powf(7.0 * throw / 12.0);
        self.phase = (self.phase + hz * dt).fract();
        let saw = 2.0 * self.phase - 1.0;
        // The throat: a fast tremolo, 55 times a second and nearly
        // square, its rate wavering a little so it never ticks.
        let waver =
            (self.waver.next(noise.next(), 6.0, 0.7, rate).0 * 40.0).clamp(-1.0, 1.0);
        self.tremolo = (self.tremolo + 55.0 * (1.0 + 0.15 * waver) * dt).fract();
        let square = (2.5 * (self.tremolo * TAU).sin()).tanh() / 2.5f32.tanh();
        let src = saw * (1.0 - 0.8 * (0.5 - 0.5 * square));
        // "Ah" at 1 kHz, a nasal edge at 1.5 kHz, and a lift at
        // 2.5 to 4 kHz.
        let ah = self.filter.next(src, 1000.0, 3.5, rate).1
            - self.filter2.next(src, 1550.0, 4.0, rate).1 * 0.45
            + self.bright.next(src, 3200.0, 1.6, rate).1 * 0.55
            + src * 0.08;
        ah * window(local, RAVEN_CROAK, 0.012) * (1.0 - 0.5 * local / RAVEN_CROAK)
    }
}

Near and far

Every call, whatever made it, ends the same way. Each one rolled a distance (dull) when it began: far creatures are low-passed, because air and leaves take the highs first, and quieter. Then an equal-power pan puts it left or right, moving if it's the bee. That's the place column in every recipe above.

Who calls, and when

A forest in the rain shouldn't sound like a forest in the sun. Each creature has a habitat: how much it likes rain, fog, mild weather and a clear sky. Frogs love rain, the owl only comes out in fog, bees and crickets want it mild.

To choose who calls next, the forest weighs each creature by how well it suits the weather about, with one more rule: whoever just called is four times less likely to call again, so you rarely hear the same creature twice running. (The bars at the top of the page are these weights.)

Who calls next, in RustRust · 20 lines

forest/src/wildlife.rs

/// Which creature calls next: weighted by how well the weather suits it,
/// and seldom the same one twice running.
pub fn choose(&mut self) -> Kind {
    let [rain, fog, mild] = self.levels;
    let clear = (1.0 - rain.max(fog).max(mild)).max(0.0);
    let suits = [rain, fog, mild, clear];
    let weights = Kind::ALL.map(|k| {
        let w: f32 = k.habitat().iter().zip(suits).map(|(h, s)| h * s).sum();
        if Some(k) == self.last { w * 0.25 } else { w }
    });
    let total: f32 = weights.iter().sum();
    let mut pick = self.noise.unit() * total;
    for (k, w) in Kind::ALL.into_iter().zip(weights) {
        if pick < w {
            return k;
        }
        pick -= w;
    }
    Kind::Song
}

And when: every frame, a countdown. When it runs out, the next call comes, and the next wait is drawn between a third and nearly twice the mean, so calls neither queue up nor stall.

One honest note on the real-time rules: Call::new builds a small list of notes, an allocation on the audio thread. It happens a few times a minute, for a few bytes, and I've let it be. Everything per sample allocates nothing.

Into Bevy

Spore & Skog is a Bevy game, but its sound doesn't use bevy_audio. The audio lives entirely outside the ECS: one CPAL stream, one desk in its callback. Bevy's side is small, and it has three parts.

Bevy systems call methods on the Sound resource, which sends Commands over a channel to the desk in CPAL's callback on the audio thread, where the wildlife makes each call. Bevy, every frame forest system reads the weather on the table Res<Sound>.wildlife(..) mpsc Command Wildlife { levels, every, gain } Audio thread CPAL data callback Desk::render Wildlife::next_frame ≤ 3 calls, made live no locks · no waiting
Bevy decides what the forest should be; the audio thread decides each sample.

1. The stream is a non-send resource

A cpal::Stream plays for as long as it lives, and it isn't Send. So it's opened once, in an exclusive start-up system, and kept in the world as a non-send resource. Next to it goes a plain Sound resource holding the channel's sender: that is the only way the rest of the game reaches the audio.

The Sound resource is a thin set of methods that each send one command. If there's no audio device (or the game runs muted) the sender is None, and every method quietly does nothing, so no system ever has to ask.

The game's Sound resource, in RustRust · 15 lines

excerpts/sound.rs

/// The creatures about: they suit the weather (rain, fog, mild, 0 to 1
/// each); one calls every `every` seconds or so (0: none), at `gain`.
pub fn wildlife(&self, levels: [f32; 3], every: f32, gain: f32) {
    self.send(Command::Wildlife {
        levels,
        every,
        gain,
    });
}

fn send(&self, command: Command) {
    if let Some(desk) = &self.desk {
        let _ = desk.send(command);
    }
}

2. One system decides what the forest sounds like

The interesting part is on the game's side: working out what the forest should be. In Spore & Skog three forests lie on the table, each with weather cards laid over it. This system runs every frame at the table. It takes the weather over all three, or over just one when you lean in to look at that forest's card, in which case the creatures come closer and call more often. It hushes them behind a menu and once the game is over. It sends a command only when something actually changed, remembered in a Local, so the channel carries a handful of messages a game, not sixty a second.

The game's forest system, in RustRust · 57 lines

excerpts/hooks.rs

/// At the table, a creature calls every this many seconds or so, this loud: the
/// forest is about, not in the way.
const TABLE_CALLS: f32 = 9.0;
const TABLE_GAIN: f32 = 0.7;
/// Looking at a forest card, its creatures call this often, this loud.
const LOOK_CALLS: f32 = 3.0;
const LOOK_GAIN: f32 = 0.9;

/// The forest heard at the table: its creatures suit the weather set over the
/// three forests (as the land shows it), and hush behind a menu and once the
/// game is over. Looking at one forest's card, they are that forest's, and
/// closer.
fn forest(
    app: Res<State<AppState>>,
    overlay: Res<State<Overlay>>,
    game: Option<Res<Match>>,
    shown: Option<Res<Shown>>,
    peek: Res<Peek>,
    mut told: Local<Option<([f32; 3], bool, bool)>>,
    sound: Res<Sound>,
) {
    let (Some(game), Some(shown)) = (game, shown) else {
        return;
    };
    let looking = match peek.look {
        Some(Look::Forest(f)) => shown.state.forests.get(f),
        _ => None,
    };
    let mut levels = [0.0; 3];
    if let Some(forest) = looking {
        levels = crate::table::land::amounts(forest);
    } else {
        for forest in &shown.state.forests {
            for (l, a) in levels.iter_mut().zip(crate::table::land::amounts(forest)) {
                *l += a / 3.0;
            }
        }
    }
    let on = !game.demo && !shown.state.over && *overlay.get() == Overlay::None;
    let close = looking.is_some();
    // Back at the table, tell it afresh: leaving hushed it.
    let same = !app.is_changed()
        && told.is_some_and(|(was, was_on, was_close)| {
            was_on == on
                && was_close == close
                && was.iter().zip(levels).all(|(a, b)| (a - b).abs() < 0.01)
        });
    if !same {
        *told = Some((levels, on, close));
        let (every, gain) = if close {
            (LOOK_CALLS, LOOK_GAIN)
        } else {
            (TABLE_CALLS, TABLE_GAIN)
        };
        sound.wildlife(levels, if on { every } else { 0.0 }, gain);
    }
}

Leaving the table, a system on OnExit sends one last command, and the forest falls quiet. The title screen has its own forest, busier and softer (a call every 4.5 seconds or so, against the table's 9), following the weather that drifts across the logo.

3. The app's lifecycle

On an iPad, the game can be sent to the home screen at any moment. The stream should stop then, and the synthesis with it. Bevy reports this as AppLifecycle messages, and one small system pauses the stream when the app goes away and plays it again when it's back.

That's the whole integration. It's in this repository as a small runnable Bevy app with the game's own numbers, where the keyboard stands in for the table: cargo run -p bevy-forest --release. Keys 1, 2, 3 and 0 set the weather, Space leans in, M opens a "menu". It all fits in one file.

Testing a forest

You can't test that a cuckoo sounds like a cuckoo. You can test that the forest obeys the weather (no owls on a sunny day, frogs in the rain), that every creature is heard and then stops, and that calls come about as often as asked. The tests are at the end of wildlife.rs.

To actually listen, one more test writes every creature, and forty seconds of each weather's forest, to WAV files: FOREST_WAV=/tmp/forest cargo test -p forest.

Hear it where it lives

The code on this page is open-sourced from Spore & Skog: The Divine Forage, a quick card duel of weather and secret mushroom spots.

Two forest gods share one sky over three Swedish forests. Lay rain, fog or mild weather over a forest, then harvest your hidden mushroom spots when the weather over them is right, before your Rival does. First to 35 mushrooms wins, and the forest you've been listening to is there the whole time, changing with every card.

Spore & Skog: The Divine Forage, in playSpore & Skog: The Divine Forage, in playSpore & Skog: The Divine Forage, in playSpore & Skog: The Divine Forage, in playSpore & Skog: The Divine Forage, in play

Play on itch.io Join the beta on iPad and Mac

The beta is on Apple's TestFlight: install the TestFlight app, then open the link.

Made with Rust and Bevy. The forest's code, the desk and both examples are on GitHub under the MIT licence.

Words used here

Sample, sample rate
A sound in a computer is a list of numbers, each the air pressure at one instant: a sample. The forest is made at the output device's rate (often 48,000 samples a second); a frame is one sample for each channel, left and right.
Audio callback
The function the audio driver calls, again and again, to fill the next few milliseconds of samples. It runs on a real-time thread with a deadline: it must not wait on a lock, read a file or allocate much. See Ross Bencina's Real-time audio programming 101.
Oscillator, phase
A repeating wave, kept as a phase that climbs from 0 to 1 once per cycle (adding frequency ÷ sample rate each sample) and wraps. sin(2π·phase) is a sine; 2·phase − 1 a sawtooth.
Sine, sawtooth, harmonics
A sine has one frequency. A sawtooth has every whole multiple of it (the harmonics), each weaker than the last: bright and buzzy, the raw material a filter carves into a voice. Too many high harmonics alias.
Aliasing
A frequency above half the sample rate can't be stored; it folds back as a different, wrong one. One reason the forest builds bright sounds from filters and noise rather than piling up harmonics.
White noise
Random samples: every frequency at once, like a hiss. Filtered, it becomes breath, rasp, wind, the knock of wood.
Filter, resonance, Q
A filter lets some frequencies through and holds others back. A low-pass keeps the lows (a far sound, through leaves); a band-pass keeps a band around one frequency, and a sharp one resonates: kicked, it rings. Q is how sharp: centre frequency over bandwidth. High Q, a narrow band and a long ring. Julius O. Smith's book is the free, thorough way in.
State-variable filter (SVF)
A filter built from two integrators in a loop, which gives low-pass, band-pass and high-pass outputs at once, and can sweep its frequency every sample without blowing up. The forest's is the "topology-preserving transform" form: Andrew Simper's paper has the exact coefficients Svf::next uses.
Formant
A resonance of the throat and mouth (or a bird's), which stays put whatever the pitch. Formants are what tell an "ah" from an "oo": an "ah" has a strong one near 1 kHz. More.
Source–filter
How voices are made, and modelled: a source (buzzing vocal folds, or a bird's syrinx) rich in harmonics, shaped by a filter (the throat and beak's formants). Change the filter and the same buzz says a different vowel. More.
Glottal pulse
One puff of air through vocal folds as they snap open and shut. A train of them, at the voice's pitch, is the source a frog's croak is made from.
Envelope, ADSR
How loudness (or anything else) changes over one sound. A synthesizer's ADSR rises (Attack), drops to a level (Decay, Sustain) and holds it while a key is down, then fades (Release). The forest's window is simpler: up, hold at full, down, over a length set in advance.
Smoothstep
w² (3 − 2w): a ramp from 0 to 1 that starts and ends flat, so a sound swells in without a click. More.
Pitch envelope
An envelope applied to pitch instead of loudness: here the raven's voice jumps up a fifth in 15 ms and falls back, without holding.
Tremolo, vibrato
A rhythmic wobble in loudness (tremolo) or in pitch (vibrato). The raven's throat is a fast tremolo, 55 times a second and nearly square; the bee's buzz has a slow vibrato.
Jitter
Small random changes in the length of each cycle of a voice. A little sounds alive; a lot sounds rough or hoarse.
Doppler effect
A sound coming toward you is a little higher, and drops as it passes and moves away. The bumblebee does a gentle version as it crosses.
Equal-power pan
Placing a mono sound between left and right with gains of cos and sin of one angle, so it stays as loud wherever it is put. More.
Soft limiter
The last stage of the desk: it leaves the signal alone below 0.8 and rounds louder peaks off with tanh, so many sounds at once never clip harshly.

Read more

Books

Papers and articles

For the raven, and the rest of the forest

Tools