Odin't Know That · Chapter 10

Chapter 10: A voice: procedural audio with raylib

This chapter:

  • Initialise the audio device (and the one ordering rule that goes with it).
  • Synthesise one-shot effects into a Wave and play them, no audio files.
  • Generate a continuous wind rumble live with an AudioStream callback.
  • Handle the real trap: that callback runs on another thread.

Where we left off: a complete, silent game.


Every sound in Sköll is computed, not loaded. A sound is just a stream of numbers: samples, thousands per second, each the speaker cone's position at that instant. Make the right numbers and you make the right sound. We'll make them two ways: baked ahead of time for short effects, and live for the endless wind.

10.1 The audio device

Bring the audio device up at boot, and tear it down with defer:

// sound.odin
init_audio :: proc() {
	rl.InitAudioDevice()   // GOTCHA: every audio call below must come AFTER this
	// ... load effect sounds and start the stream ...
}

shutdown_audio :: proc() {
	for s in g_sounds do rl.UnloadSound(s)
	rl.UnloadAudioStream(g_stream)
	rl.CloseAudioDevice()
}

And in main:

init_audio()
defer shutdown_audio()

Gotcha: device first, always. LoadSoundFromWave, PlaySound, LoadAudioStream: every one assumes the audio device is already up. Call any of them before InitAudioDevice() and you get silence at best, a crash at worst. Initialise the device before you touch anything audio.

10.2 One-shot effects, baked into a Wave

A Wave is raw sample data in memory. We fill one with a short synthesised sound, hand it to raylib to turn into a playable Sound, and free our copy. The effects are named on a small enum: the single-source style again.

SAMPLE_RATE :: 22050

Snd :: enum { Jump, Rune, Hit }
g_sounds: [Snd]rl.Sound

The generator. Each effect is an oscillator shaped by an envelope: a fast attack so it doesn't click, then an exponential decay so it fades.

gen_sound :: proc(which: Snd) -> rl.Sound {
	dur, decay: f32
	switch which {
	case .Jump: dur, decay = 0.16, 18
	case .Rune: dur, decay = 0.22, 12
	case .Hit:  dur, decay = 0.35, 8
	}

	frames := int(SAMPLE_RATE * dur)
	samples := make([]i16, frames)
	defer delete(samples)   // raylib copies the frames on load, so we free ours

	for i in 0 ..< frames {
		t := f32(i) / SAMPLE_RATE
		env := math.exp(-t * decay) * min(1, t / 0.004)  // fast attack, exponential decay
		s: f32
		switch which {
		case .Jump:
			freq := 380 + 300 * (t / dur)                 // a rising blip
			s = math.sin(t * freq * math.TAU)
		case .Rune:
			s = math.sin(t * 880 * math.TAU) * 0.7 + math.sin(t * 1320 * math.TAU) * 0.3
		case .Hit:
			s = math.sin(t * 90 * math.TAU) * 0.6 + rand.float32_range(-1, 1) * 0.4  // low tone + noise
		}
		samples[i] = i16(clamp(s * env, -1, 1) * 32000)   // -1..1 → 16-bit range
	}

	wave := rl.Wave{
		frameCount = u32(frames),
		sampleRate = SAMPLE_RATE,
		sampleSize = 16,          // 16-bit samples → our i16 slice
		channels   = 1,           // mono
		data       = rawptr(raw_data(samples)),
	}
	return rl.LoadSoundFromWave(wave)
}

Read the three voices: the jump sweeps its pitch upward (a hopeful blip); the rune stacks two tones for a bright chime; the hit is a low tone mixed with noise for a thud. The envelope is the same idea for all three: min(1, t/0.004) ramps up over the first few milliseconds (no click), exp(-t*decay) fades it out. The final clamp(... , -1, 1) * 32000 maps the wave into the range of a 16-bit sample.

Load them in init_audio, and give the game one tidy way to play one:

g_sounds[.Jump] = gen_sound(.Jump)
g_sounds[.Rune] = gen_sound(.Rune)
g_sounds[.Hit]  = gen_sound(.Hit)

play_sound :: proc(which: Snd) {
	if !g_muted do rl.PlaySound(g_sounds[which])
}

Now replace the play_sound stub from Chapter 4 with this: the jump, the rune pickup and the death all get their voice.

10.3 A continuous voice: the AudioStream callback

Short effects can be baked. A continuous sound whose pitch and volume track the run speed can't: it never ends and it changes. For that, raylib gives you an AudioStream that repeatedly asks you to fill a buffer of samples:

g_stream:       rl.AudioStream
g_wind_level:   f32       // amplitude 0..1, smoothed toward a target
g_wind_target:  f32
g_wind_freq:    f32 = 90
g_stream_phase: f32       // oscillator phase, advanced inside the callback

// in init_audio, after the device is up:
g_stream = rl.LoadAudioStream(SAMPLE_RATE, 16, 1)
rl.SetAudioStreamCallback(g_stream, wind_callback)
rl.PlayAudioStream(g_stream)

The callback synthesises the rumble one buffer at a time:

wind_callback :: proc "c" (buffer: rawptr, frames: c.uint) {
	context = runtime.default_context()
	out := ([^]i16)(buffer)
	for i in 0 ..< int(frames) {
		g_stream_phase += g_wind_freq / SAMPLE_RATE
		if g_stream_phase > 1 do g_stream_phase -= 1
		s := math.sin(g_stream_phase * math.TAU) * g_wind_level * 0.5
		out[i] = i16(s * 32000)
	}
}

We keep the amplitude and pitch as plain globals, smoothed on the main thread so the sound glides rather than jumps:

set_wind_target :: proc(v: f32) {
	g_wind_target = clamp(v, 0, 1)
}

update_wind :: proc(dt: f32) {
	target := g_wind_target
	if g_muted do target = 0
	g_wind_level = approach(g_wind_level, target, dt * 1.5)
	frac := (g.speed - START_SPEED) / (MAX_SPEED - START_SPEED)
	g_wind_freq = 70 + 60 * clamp(frac, 0, 1)     // faster run → higher wind
}

The Playing branch raises the wind target with speed; kill_player, the title and pause drop it to 0 (the set_wind_target stub from Chapter 7 becomes real here).

10.4 The one real trap: it's another thread

Look again at that callback's first line:

context = runtime.default_context()

Gotcha: the audio callback runs on raylib's audio thread, not yours. Two consequences. First, it must be proc "c" (raylib calls it as a C function) and therefore starts with no Odin context; so we establish one with runtime.default_context(), exactly as we'll do at any C→Odin boundary. Second, it runs concurrently with your game update, so it must not race on game state. We keep the shared surface to a couple of plain f32 globals that the callback only reads (g_wind_level, g_wind_freq); a slightly stale read is inaudible, and there's nothing to corrupt. Don't reach into g from in here, don't allocate, don't call back into gameplay.

This is the desktop cousin of Yo Odin's "set context at every boundary" rule (https://nofuss.co.za/yo_odin/): whenever code enters Odin from the outside, the C audio thread here, the JavaScript runtime there, the very first thing it does is re-establish a context.

10.5 Mute

One toggle, read by both the one-shots and the wind:

g_muted: bool
toggle_mute :: proc() { g_muted = !g_muted }

Bound to .M in the input handler (Chapter 9). play_sound checks it; update_wind forces the wind target to 0 while muted.

Checkpoint

Sköll has a voice: jumps chirp, runes shimmer, the end-of-run thud lands, and a wind rumble rises in pitch and volume with your speed and fades on death, all synthesised at runtime, not one audio file in the project, and safely handling the audio thread. Two things remain to make it a finished, shippable program: remembering your best score across runs (with reproducible courses), and building it for every desktop. Chapters 11 and 12.

Exercises

  1. Add a .Land sound (a soft low thump) and play it from the landing branch of update_player.
  2. Layer a second detuned oscillator into wind_callback (e.g. + sin(phase * TAU * 1.5) * 0.2) for a richer rumble.
  3. Throttle the Hit sound so a double-death on one frame can't double-trigger it.
  4. Make the rune chime rise slightly in pitch with the current combo count.

Further reading