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
Assetis the body. WithLoop, 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.
FinishMusiclets 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 overFinishMusic'sfadeOutMilliseconds, or stops at once.- The ending is chosen a few tens of milliseconds before it is heard, so
FinishMusiccalled in the last moment of a pass plays one more pass first. - The crossfade, fade-in, volumes,
StopEventandStopAllwork 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();
StopEventstops the sounds of one event. WithfadeOutMillisecondsthey fade out over that time; without it they stop at once.StopAllwithAudioEventCategory.Musicstops the music, with the fade if one is given.StopAllwithSoundEffect,AmbienceorVoicestops 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:
CurrentMusicEventIdis the music playing now, or null.CurrentMusicPhaseis which part of it is playing:Intro,Loop(also for one-file music),Ending, orNonewithout music.CurrentMusicPositionadds how far into the part, and its length.ActiveSoundEventslists the events with sounds playing.RecentEventsholds the last 64 entries, each with the event id, category, action and UTC time. Actions areRegister,Unregister,Play,Stop,FadeOutandEnding(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.