Skip to content

Audio

Wyrd.Ecs.Audio plays sounds through SDL_mixer. AddAudio() needs AddWindow called somewhere in the same chain - not for its own sake, but because audio-device hot-plug depends on PlatformSystem being the one process pumping SDL’s event queue. Construction order doesn’t matter.

public sealed partial class GunfireSystem : QuerySystem
{
[Resource] public AudioPlayer Audio { get; private set; }
protected override IQuery DefineQuery(Query query) => query.With<ShotFired>();
public void Update(Time time, ref ShotFired shot)
{
Audio.Play("audio/shot.wav", volume: 0.8f);
}
}

AudioPlayer is a resource registered automatically by AddAudio(), so systems reach it with [Resource] injection like any other. Play(string path) streams straight off disk - fine for fire-and-forget effects, but nothing is cached or dedup’d, every call re-opens the file.

For sounds played often, load once and reuse:

Handle<Sound> shot = Audio.LoadSound("audio/shot.wav");
await Audio.WaitForLoadAsync(shot);
Audio.Play(shot); // decodes once in the background, then plays from memory

Loading is dedup’d by path: a second LoadSound for a file already reserved returns the existing handle without decoding again. Unload(handle) decrements a use-count, the underlying decode is destroyed when the last handle goes.

Every Play returns a Playback, a cheap-to-copy reference to that live instance:

Playback music = Audio.Play(shot, loop: true);
Audio.SetVolume(music, 0.5f); // clamped to [0, 1]
if (Audio.IsPlaying(music)) { /* ... */ }
Audio.Stop(music, fadeOut: TimeSpan.FromSeconds(2));

A stale Playback (already stopped) throws rather than silently doing nothing.

Volume control routes through buses. Three are built in: Master (everything), Music, and Sfx (Play’s default). Custom ones are just names:

Audio.SetBusVolume(Audio.Bus(BusKind.Master), 0.9f);
var dialogue = Audio.CustomBus("dialogue");
Audio.Play(shot, bus: dialogue);

Buses are scoped per output device - Bus(BusKind.Sfx) on two different outputs are two genuinely different buses.

By default everything plays through the OS’s default output device. For more - a second headset, split music to speakers and voice chat to headphones - enumerate what’s connected and add outputs explicitly:

foreach (var device in Audio.GetAvailableOutputDevices())
Console.WriteLine(device.Name);
AudioOutput headphones = Audio.AddOutput(device.Id);
Audio.SetDefaultOutput(headphones);

Devices connecting and disconnecting mid-session are picked up by the same event pump that drives windowing and surface as DeviceChange events (DeviceKind.AudioOutput), so GetAvailableOutputDevices reflects whatever is connected when you call it.

Give Play a world position and it plays there relative to the listener:

Audio.Play(shot, position: new Vector3(10f, 0f, 0f));

The listener is an entity whose Transform positions the output’s ear:

Audio.SetListener(Audio.DefaultOutput, cameraEntity);

For a sound attached to a moving entity, Follow tracks its interpolated transform every tick instead of a fixed point:

Playback engineHum = Audio.Play(engineSound, loop: true);
Audio.Follow(engineHum, enemyEntity);

Follow ties the sound to that entity’s life: if the entity is destroyed mid-playback, the playback stops with it. A sound that should outlive its trigger takes a fixed position instead. If no listener was ever set, positions fall back to the origin; if the listener entity dies, sources freeze relative to its last known transform rather than snapping anywhere.

Assets covers the dedup-and-use-count loading pattern LoadSound is built on, reusable for loaders of your own.