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:
- What the body did.
CharacterController.eventslists this step's.stride(foot:speed:),.landed(impactSpeed:)and.jumped, in order. The list is cleared at the start of every step, so read it afterstep. - What is underfoot.
CharacterController.groundSolidsays which solid the feet are on.Level.surface(standingOn:)turns the matching placement into itssurface, or the level'sgroundSurfacefornil.
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:
- Strides are counted from the distance the feet actually covered, not the distance asked for. A player pushing into a wall doesn't march on the spot.
- Strides are longer than a person's because the speeds are faster.
walkStride,sprintStrideandcrouchStride(2.6, 5 and 1.6 m) give about 2.3, 3.6 and 1.7 steps a second. The first footstep comes partway into a stride (firstStepPhase), so a walk doesn't start silent. - Each stair tread gets a footstep. A step up or down counts as a foot landing, whatever the stride says. A step down a stair is not a
.landed, and a slide makes no footsteps. surface(standingOn:)returnsnilfor a placement that names no surface. It doesn't fall back to the ground's, so the gap can be noticed. The sample logs it once and plays stone.
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.