Table of Contents

Audio

Sounds are played by name. A game registers each sound once as an event, then posts the event wherever the sound is needed. The engine provides the service as IAudioService; inject it or get it with GetService. It needs no setup: the audio device opens on first use.

protected override void Initialize()
{
  var audio = GetService<IAudioService>();

  audio.RegisterEvent(new AudioEventDefinition
  {
    EventId = "ui.click",
    Asset = "Sounds/click.wav",
  });

  audio.RegisterEvent(new AudioEventDefinition
  {
    EventId = "music.town",
    Asset = "Music/town.ogg",
    Category = AudioEventCategory.Music,
    Loop = true,
    Volume = 0.6f,
  });

  base.Initialize();
}

// Later, from Update or an event handler:
private void PlayTownSounds()
{
  var audio = GetService<IAudioService>();

  audio.PostEvent("ui.click");
  audio.PostEvent("music.town");
}

Registering events

An AudioEventDefinition has:

Property Meaning
EventId The name you post. Required.
Asset The file to play, as a path. Required.
Category An AudioEventCategory: SoundEffect (the default), Music, Ambience or Voice.
Loop Whether the sound repeats until it is stopped. False by default.
Volume The event's own volume, 0 to 1. 1 by default.
FadeInMilliseconds How long the event takes to fade in when posted. 0, the default, starts it at full volume.
IntroAsset, EndingAsset Music only: a file played once before Asset, and one played after it when the music finishes. See Music in parts.
VolumeCategory Which volume setting the event follows, named by category. Null, the default, is its own Category.
DuckMusicDecibels, DuckMilliseconds Not music: how far the music is lowered while the event plays, such as -9, and the ramp time, 250 by default. See Ducking the music.

Registering an id that already exists replaces its definition. Registering the same asset again keeps the sound already loaded for it. If the asset, intro, ending or category changes, the loaded sound is freed and anything the event is playing stops, so the next post plays the new asset. UnregisterEvent also stops whatever that event is playing, and frees its sound.

RegisterEvent throws an ArgumentException for a blank id or asset, an intro or ending on an event that isn't music, and a duck that is positive or set on music.

Files and formats

Asset is resolved in this order. A rooted path is used as it is. A relative path is looked for under the game's output folder, then under the working directory. If the exact name is not there, a file whose name differs only in case is used, which lets content authored on Windows load on Linux.

Copy the audio into the output in the project file, as for other content:

<ItemGroup>
  <Content Include="Sounds\**\*;Music\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

The formats are WAV, FLAC, MP3, Ogg Vorbis, Opus and MOD tracker modules.

Sound effects are decoded into memory when first posted, so they start at once. Music is streamed from disk, so long tracks do not take memory. The category decides this: any event in Music streams.

Playing

PostEvent plays an event:

var audio = GetService<IAudioService>();

audio.PostEvent("ui.click");
audio.PostEvent("ui.click", new AudioEventOptions(0.5f));
audio.PostEvent("music.dungeon", new AudioEventOptions(fadeInMilliseconds: 2000));

AudioEventOptions takes a volume and a fade-in for this post. Each replaces the event's own Volume or FadeInMilliseconds for that post. The category volume still applies on top.

What a post does depends on the category:

  • Music plays one track at a time. Posting a different music event replaces the current track. With a fade-in, the two crossfade: the current track fades out over the same time as the new one fades in. Without one, the current track stops at once. Posting the music that is already playing does nothing while it is still playing, unless its ending has been asked for: then it starts again from its intro.
  • Sound effects, ambience and voice each post starts a new sound, so several can overlap.

A sound's final volume is its volume (the event's, or the one given to the post) times its category's volume. MusicVolume applies to music. EffectsVolume applies to everything else. An event's VolumeCategory changes which one it follows: a jingle that is part of the score is a sound effect with VolumeCategory = AudioEventCategory.Music, so turning the music off silences it too, while it still overlaps the music instead of replacing it.

Music in parts: intro, loop and ending

Music written for games often comes in three files: an intro that plays once, a loop that repeats, and an ending for when the music is over. Give a music event all three and the service joins them with no gap, exact to the sample, as one piece:

audio.RegisterEvent(new AudioEventDefinition
{
  EventId = "music.town",
  Category = AudioEventCategory.Music,
  IntroAsset = "Music/town_intro.ogg",
  Asset = "Music/town_loop.ogg",
  EndingAsset = "Music/town_ending.ogg",
  Loop = true,
});

audio.PostEvent("music.town");                                        // intro, then the loop
audio.PostEvent("music.town", new AudioEventOptions(skipIntro: true)); // straight into the loop
audio.FinishMusic();                                                  // the ending, then silence
  • Asset is the body. With Loop, it repeats until the music is finished or stopped. Without it, the music plays intro, body once, ending, and stops.
  • The intro and the ending are each optional. Music with neither is one file, played as before.
  • FinishMusic lets the current pass of the body, or the intro, play to its end, then plays the ending and stops. A pass can be long, so the ending can be a while coming. Music without an ending fades out over FinishMusic's fadeOutMilliseconds, or stops at once.
  • The ending is chosen a few tens of milliseconds before it is heard, so FinishMusic called in the last moment of a pass plays one more pass first.
  • The crossfade, fade-in, volumes, StopEvent and StopAll work as for any music; a stop cuts through the ending.
  • The three files needn't share a format: the intro and ending are converted to the body's.
  • If any of the three files is missing, the music doesn't play, and the music that was playing stops.

Position and seeking

CurrentMusicPosition says which part of the music is playing, how far into it, and how long that part is, which is enough for a progress bar. For the loop it is how far into the current pass. It runs a few tens of milliseconds ahead of what is heard, so it suits a bar but not syncing to the beat.

SeekMusic moves the music to a point in a part, with no fade, which makes checking a loop's join or an ending quick in an editor:

var loop = audio.CurrentMusicPosition;
audio.SeekMusic(MusicPhase.Loop, loop.Length - TimeSpan.FromSeconds(3)); // hear the loop's join
audio.SeekMusic(MusicPhase.Ending, TimeSpan.Zero);                      // hear the ending now

The position is clamped to the part. An ending already asked for with FinishMusic stays asked for. Music that is one file seeks within itself, as MusicPhase.Loop. SeekMusic returns false without music, for a part the music doesn't have, or when the file can't seek.

Ducking the music

An event can lower the music while it plays, for a jingle or a voice line:

audio.RegisterEvent(new AudioEventDefinition
{
  EventId = "jingle.level_up",
  Asset = "Music/Jingles/level_up.ogg",
  VolumeCategory = AudioEventCategory.Music,
  DuckMusicDecibels = -9,
  DuckMilliseconds = 250,
});

While any ducking event plays, the music is lowered by the strongest of them, ramping down over DuckMilliseconds. When the last one ends, it comes back over the same time. The duck multiplies with the music's volume, fade-in and crossfade.

Stopping

var audio = GetService<IAudioService>();

audio.StopEvent("music.town", fadeOutMilliseconds: 800);
audio.StopAll(AudioEventCategory.Music, fadeOutMilliseconds: 500);
audio.StopAll();
  • StopEvent stops the sounds of one event. With fadeOutMilliseconds they fade out over that time; without it they stop at once.
  • StopAll with AudioEventCategory.Music stops the music, with the fade if one is given.
  • StopAll with SoundEffect, Ambience or Voice stops only the sounds posted with that category, with the fade if one is given. A sound keeps the category it was posted with.
  • StopAll() with no category stops everything.

A fade starts when the stop is called. From then on CurrentMusicEventId no longer names the faded music, while ActiveSoundEvents keeps listing a fading sound until it has finished.

Stopping before anything has played does nothing, and does not open the audio device.

Volume and state

MusicVolume and EffectsVolume range from 0 to 1 and are clamped to that range. Changing MusicVolume changes the music that is playing now. Changing EffectsVolume affects sounds posted after the change.

The service also reports what is happening, which is useful on a debug screen:

  • CurrentMusicEventId is the music playing now, or null.
  • CurrentMusicPhase is which part of it is playing: Intro, Loop (also for one-file music), Ending, or None without music. CurrentMusicPosition adds how far into the part, and its length.
  • ActiveSoundEvents lists the events with sounds playing.
  • RecentEvents holds the last 64 entries, each with the event id, category, action and UTC time. Actions are Register, Unregister, Play, Stop, FadeOut and Ending (an ending began).

MusicFinished is raised with the event's id when music ends by itself: its ending has played, or music that doesn't loop has run out. A stop, or other music replacing it, doesn't raise it. It is raised during the service's update, on the game's thread, so a handler can change scenes:

audio.MusicFinished += eventId =>
{
  if (eventId == "music.credits")
    ShowTitleScreen();
};

When a sound does not play

Posting never throws. A sound that cannot play is skipped, and the reason is written to the log:

  • Posting an id that was never registered logs a warning.
  • An asset that is missing or cannot be decoded logs a warning, and nothing plays. If the failed event is music, the music that was playing stops. The game asked for different music, so the old track does not carry on.
  • If the audio device cannot be opened, posts do nothing, and the log says why.