Audio
The mixer lives in crates/audio (src/mixer.rs); its module docs are the full reference for buses, snapshots, music and the voice cap.
The mixer lives in crates/audio (src/mixer.rs); its module docs are the full reference for buses, snapshots, music and the voice cap. This page covers the policy decisions a game author needs to know about without reading the code.
Several listeners (local split screen)
Local co-op has two to four views on one screen and one stereo output device. Each view has a camera, so each has a listener, and the mixer has to decide what one spatial sound is like when the listeners are in different places.
Default: the nearest listener decides. For each spatial voice the listener closest to it sets both its distance attenuation and its pan, exactly as if that player were alone. If two listeners are exactly as close, the one earlier in the list wins, so pass the views in a stable order with player 1 first. A sound next to player 2 sounds the way it would in a one-player game, and a sound far from every player stays quiet.
The alternative: Sum. ListenerPolicy::Sum adds up each listener's left and right contribution, then scales the total down so the voice is never louder than its unattenuated gain. Two players standing on the same sound hear it no louder than one would. The cost is that a sound between two players comes out of both speakers at once, so neither player can tell which side of their own screen it is on. Pick it for a shared-space game where loudness should reflect how many players are near a sound. Set it with Mixer::set_listener_policy.
Considered and rejected:
- The loudest listener decides. This is the same as the nearest one. Every rolloff curve falls as distance grows, and every listener hears a voice through that voice's own curve and range.
- Centred pan when listeners disagree. This throws away the one cue that tells a player which side of their own view a sound is on.
Guarantees.
- One listener gives bit-for-bit the same output as the single-listener mixer under either policy.
crates/audio/tests/listeners.rschecks this every tick, bit for bit, against the old single-listener formula. - Which listener is chosen never affects voice priority, voice stealing or the handle
playreturns, so the simulation stays deterministic. - Listeners are held in a fixed array of
MAX_LISTENERS(4). Setting them every tick allocates nothing (crates/audio/tests/alloc.rs). - Bad input is contained (
crates/audio/tests/listener_adversarial.rs). A listener whose position is not finite never wins the nearest decision, so it cannot silence a real one; a listener with a NaN or zero rotation pans to the centre. Whatever goes in, gain, pan and both ears are finite and no louder than the voice's own gain. - Listeners are cut to the first four in the order given, so the cut is stable.
- When the nearest listener changes, gain stays continuous (it follows the nearest distance) but pan steps to the new listener's side. Smoothing that step belongs to the backend.
- The mixer has no doppler, occlusion or per-listener reverb today. If any is added, it will use the listener the policy chose.
API.
- Mixer:
Mixer::set_listeners(&[Listener])takes 1 to 4 listeners. Extra listeners are ignored, and an empty slice changes nothing.set_listeneris the one-listener case. - Mixer queries:
Mixer::listeners()returns the live listeners, andVoiceGain::listeneris the index of the listener that decided that voice. - App:
World::set_listener_sources(&[(Vec3, Quat)])takes one position and rotation per view and applies them from the next tick. An empty slice goes back to the default below. - Views listen by themselves. With a
ctx.viewslayout of two or more views (left-right,top-bottom,three,grid) the mixer gets one listener per view, view 1 first, at each view's camera, refreshed every tick as the cameras move; a view the layout drops stops listening on that tick. View 1's listener is the camera's, exactly where a one-view game's is. With one view nothing changes: one listener on the camera, bit for bit. A scene'soutput: "viewport"camera (a minimap) is not a view and does not listen. Nothing is allocated per tick.crates/app/tests/split_screen_audio.rscovers this. - Script:
ctx.audio.listenFrom("views" | "camera")."views"is the default above;"camera"keeps one listener on view 1's camera however many views there are.ctx.audio.setListenerPolicy("nearest" | "sum")isMixer::set_listener_policy. Both apply from the next tick, may be called ininitbefore any view exists, and throw aTypeErrorfor any other value, changing nothing. - Who listens, first that applies:
World::set_listener_sources(non-empty); thenlistenFrom("camera"); then one listener per view; then the camera.