WINDAGE ENGINE v1.0.0
Contents

Docs / World

Audio

AudioEngine plays sounds at positions in the world: one-shots for things that happen, and loops for things that keep making noise, like a brazier. Footsteps and landings come from the character controller, which reports what the body did, and from the level, which says what each surface is made of.

What it does and does not do

Every sound is spatialized: you give the listener each frame and a world position for each sound. The engine plays one-shots and loops tied to a place. It has no music, no buses and no mixing groups. Those come when a game needs them.

Sixteen one-shots can overlap by default (AudioEngine.Configuration.voiceCount). Beyond that the oldest voice is reused, because the newest sound is the one that just happened.

Setting it up

// your game: with no output device, there is no engine, and the game runs quiet
let audio: AudioEngine?
do { audio = try AudioEngine() } catch { audio = nil; print(class="p">"no audio — \(error)") }
for sound in [class="p">"pickup", class="p">"crackle", class="p">"step_stone_0", class="p">"land_stone"] {
    do { try audio?.load(sound, bundle: .module) } catch { print(error) }   // "no sound file pickup.wav"
}

AudioEngine() throws an AudioEngineError saying why it could not start. load(_:withExtension:bundle:) decodes a sound and keeps it in memory, with wav as the default extension, and throws .soundNotFound or .unreadableSound. bundle has no default on purpose. In an app bundle the sounds are in .main, while in a SwiftPM executable .main holds nothing and the sounds are in .module. load(_:samples:sampleRate:) loads mono samples your game generates.

AudioEngine(configuration:) takes the voice count and both spatializers' distance curves (Configuration.Falloff: reference distance, maximum distance, rolloff). The defaults are the curves described below.

Move the listener once a frame:

audio?.updateListener(position: camera.position, forward: camera.forward, up: SIMD3(0, 1, 0))

One-shots

// your game
audio.play(class="p">"pickup", at: cratePosition, volume: 0.8)
audio.play(class="p">"impact", at: target, delay: distance / 343)   // sound takes time to travel
audio.play(class="p">"win")                                         // not positioned

play(_:at:volume:delay:) plays a sound at a world position. delay is in seconds. At about 343 m/s, an impact 800 m away is heard nearly two and a half seconds after it happens. The delay is counted on the audio clock, so it holds while the engine is paused and stop() cancels a sound still waiting. play(_:volume:) plays without a position, for sounds that happen to the player.

Loops that belong to a place

if let loop = audio.startLoop(class="p">"crackle", at: position, volume: 0.8) {
    audio.moveLoop(loop, to: newPosition)
    audio.stopLoop(loop)
}

startLoop(_:at:volume:) returns an AudioEngine.Loop handle — keep it, since a loop nobody holds can only be stopped by stopAllLoops() — and loopCount covers the rest. Each loop starts at a different point in its sound, so two braziers playing the same crackle don't sound like one crackle in two places.

Loops get their own spatializer with a different falloff. One-shots are tuned for a sound heard across a range: full volume out to 8 m, then 90% of the inverse law. Through that curve, a brazier sounds as loud at 8 m as it does up close, and two of them fill a level with fire. Loops use the physical inverse law from 2 m (Configuration.loopFalloff) and measured 1 : 0.250 : 0.100 at 2, 8 and 20 m.

A placement can carry a sound in its own space, like its lamp. Level.carriedSounds(on:) gives each one's world position, so a brazier moved in the editor takes its crackle with it. When your level changes, reconcile the loops instead of restarting them. The sample matches loops by name and position first, and only then by placement index, because copying a placement renumbers every placement after it.

Footsteps and landings

The engine doesn't play footsteps itself. It gives your game the two facts it needs:

Here is the sample's version, trimmed (variant picks one of three recordings). Footsteps are not positioned, because they happen to the player:

var surface = level.surface(standingOn: placement) ?? class="p">"stone"
for event in body.events {
    switch event {
    case .stride(_, let speed):
        let crouched = body.tuning.walkSpeed * body.tuning.crouchedSpeedFraction
        let t = simd_clamp((speed - crouched) / (body.tuning.sprintSpeed - crouched), 0, 1)
        audio?.play(class="p">"step_\(surface)_\(variant)", volume: 0.3 + 0.7 * t)
    case .landed(let impact):
        audio?.play(class="p">"land_\(surface)", volume: simd_clamp(impact / 9, 0.35, 1))
    case .jumped:
        audio?.play(class="p">"step_\(surface)_0", volume: 0.4)
    }
}

A few details are worth knowing:

Volume

masterVolume scales everything. loopVolume scales only the loops, so a player who finds a fire too loud doesn't lose their own footsteps turning it down. PlayerSettings.ambienceVolume is the matching player setting. Measured: half ambience lowers a loop by 6.02 dB and a one-shot by 0.0.

Pausing and stopping

pause() holds everything — one-shots, loops and delayed one-shots still waiting — and resume() carries on. stop() is final: it frees the output device, and a stopped engine plays nothing again.

Measuring without listening

AudioEngine(offlineSampleRate:) makes an engine with no device, and renderOffline(seconds:) returns the mix as one array per channel. The falloff numbers on this page were measured that way. WINDAGE_SAMPLE_MEASURE_AUDIO=1 swift run WindageSample measures the sample's own crackle at several distances.

Before adding an AVFoundation callback: a closure written inside the engine's methods inherits main-actor isolation, and AVFoundation calls it on a realtime audio thread, which crashes. Mark it @Sendable and give it its own thread-safe storage.