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.
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 Rust
/// 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)
}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.
- a sine, sweeping up or down 400–1300 Hz from 2.6–4.2 kHz
- 2 to 6 quick notes, each an arch
- none: a bare tone
- near or far, left or right
- a sine at 600–700 Hz, with a touch of its octave
- "cu-ckoo", a falling third, 2 to 4 times
- none
- always a little way off
- a sine at 380–440 Hz, and breath
- "hoooo … hu, hu-hu-hoooo", the last note quavering
- breath through a band an octave up
- further off, in the fog
The three voices, in Rust
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.
- a click of noise, and a 600–900 Hz ring of dead wood
- 12 to 22 knocks, 15–22 a second, quickening and fading
- the click through a band at 1.6 kHz
- near or far, left or right
- a sawtooth at 140–190 Hz, with a slow vibrato
- swells over 2–3.4 s, its pitch falling as it passes
- a low-pass at 700 Hz
- close, crossing from one side to the other
- a pure tone at 4.1–4.7 kHz
- chirps of 3 pulses, about 3 a second, for 2.5–4 s
- none
- 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.
- clicks: glottal pulses, 26–38 a second
- 2 to 4 croaks, a third of a second each
- a narrow throat at 380–520 Hz, a broad chest an octave down
- near or far, left or right
- a sawtooth near 110 Hz, thrown up a fifth and falling back
- 1 to 3 croaks of 0.4 s, chopped by a 55 Hz tremolo
- "ah" at 1 kHz, a nasal edge at 1.5 kHz, a lift at 2.5–4 kHz
- near or far, left or right
The raven, in Rust
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 Rust
/// 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.
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 Rust
/// 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 Rust
/// 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.





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 − 1a 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::nextuses. - 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
windowis 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
- Andy Farnell, Designing Sound (MIT Press, 2010). Procedural audio from first principles: real-world sounds, animals among them, built from oscillators, noise and filters. The way of thinking this whole forest follows.
- Vadim Zavalishin, The Art of VA Filter Design (free; edition 2.1.2). The topology-preserving transform, and why the state-variable filter behaves when it's swept.
- Julius O. Smith III, Introduction to Digital Filters and Physical Audio Signal Processing (CCRMA, free online). Filters, resonators and physical models, thoroughly.
Papers and articles
- Andrew Simper, Linear trapezoidal integrated state variable filter (Cytomic, 2013).
The exact filter in
Svf::next. - George Marsaglia, Xorshift RNGs, Journal of Statistical Software 8(14), 2003. The forest's noise and its dice.
- Ross Bencina, Real-time audio programming 101: time waits for nothing (2011). The rules the audio callback lives by.
- Robert Bristow-Johnson, Audio EQ Cookbook (W3C). The band-pass behind the game's other sounds: wood, baskets, the fiddle's body.
For the raven, and the rest of the forest
- A thread on r/synthrecipes about synthesizing a raven: the reply whose recipe (a fast tremolo, a pitch envelope, "ah" at 1 kHz, a lift at 2.5–4 kHz) the raven is built on.
- The sounds of Ravens (YouTube): the recording the raven's croaks were checked against.
- xeno-canto: thousands of wild bird recordings, ravens included, free to listen to and study.
Tools
- CPAL, cross-platform audio output for Rust.
- Bevy, the game engine.
- AudioWorklet (MDN): how this page runs the forest in your browser.