Table of Contents

Wav File

Play a .wav read from disk at runtime, with no compiled asset: LoadWav decodes the file into memory and each CreateInstance is an independent playback. Space fires a chime - press it fast and the instances overlap - L toggles a looping pad, J and K set the volume, N and M the pan. The overlay shows what was decoded and how many instances are alive, and finished instances are disposed once they report Stopped.

The Program.cs file shows how to:

  • Loading a .wav at runtime with game.Audio.LoadWav instead of the asset pipeline
  • One WavSound, many overlapping instances
  • Looping, volume and pan on a SoundInstance
  • Disposing instances once they have played out
  • Why the files are mono: Pan and spatialisation need a single channel
  • Using helpers: SetupBase3DScene, Create3DPrimitive, DebugOverlay

Wav File

View on GitHub.

using Stride.Audio;
using Stride.CommunityToolkit.Audio;
using Stride.CommunityToolkit.Bepu;
using Stride.CommunityToolkit.Engine;
using Stride.CommunityToolkit.Rendering.ProceduralModels;
using Stride.CommunityToolkit.Scripts.Utilities;
using Stride.CommunityToolkit.Skyboxes;
using Stride.Core.Mathematics;
using Stride.Engine;
using Stride.Input;
using Stride.Media;
using System.Reflection;

// Play a .wav from disk with no compiled asset: read the file at runtime, keep the samples in
// memory, and create as many instances as there are things to play.
//
// This is the toolkit's founding pattern - load at runtime instead of through the asset pipeline
// - applied to the one subsystem where it had no answer. LoadWav decodes RIFF/WAVE (8/16/24/32-bit
// PCM or float, mono or stereo) into a WavSound; each CreateInstance is an independent playback,
// which is what a sound effect needs: press Space quickly and the chimes overlap instead of
// cutting each other off. The pad is one instance set to loop, the usual shape for music.
//
// Two engine rules worth knowing: an instance reports Stopped once it has played out and should be
// disposed then (each holds a native source), and Pan only applies to mono sources - which both
// files are, deliberately.

var directory = Path.GetDirectoryName(Assembly.GetEntryAssembly()!.Location)!;

WavSound? chime = null;
WavSound? pad = null;
SoundInstance? padInstance = null;

var chimes = new List<SoundInstance>();
var volume = 0.8f;
var pan = 0f;

Entity? speaker = null;
var bump = 0f;

using var game = new Game();

game.Run(start: Start, update: Update);

void Start(Scene scene)
{
    game.SetupBase3DScene();
    game.AddSkybox();
    game.AddProfiler();

    game.SetCameraPosition(new Vector3(0, 2.5f, -6));
    game.SetCameraRotation(new Vector3(180, -12, 0));

    speaker = game.Create3DPrimitive(PrimitiveModelType.Cube, new()
    {
        Material = game.CreateMaterial(new Color(90, 160, 255)),
        IncludeCollider = false,
        Position = new Vector3(0, 1, 0),
    });
    speaker.Scene = scene;

    // The files are copied next to the executable by the project file; both are mono 16-bit PCM,
    // generated by build/generate-example-sounds.cs.
    chime = game.Audio.LoadWav(Path.Combine(directory, "chime.wav"));
    pad = game.Audio.LoadWav(Path.Combine(directory, "pad-loop.wav"));

    padInstance = pad.CreateInstance();
    padInstance.IsLooping = true;
    padInstance.Volume = volume * 0.5f;

    AddInstructions();
}

void Update(Scene scene, Stride.Games.GameTime time)
{
    var input = game.Input;

    if (input.IsKeyPressed(Keys.Space) && chime is not null)
    {
        var instance = chime.CreateInstance();
        instance.Volume = volume;
        instance.Pan = pan;
        instance.Play();

        chimes.Add(instance);
        bump = 1;
    }

    if (input.IsKeyPressed(Keys.L) && padInstance is not null)
    {
        if (padInstance.PlayState == PlayState.Playing)
            padInstance.Stop();
        else
            padInstance.Play();
    }

    var dt = (float)time.Elapsed.TotalSeconds;
    var volumeChange = (input.IsKeyDown(Keys.K) ? 1 : 0) - (input.IsKeyDown(Keys.J) ? 1 : 0);
    var panChange = (input.IsKeyDown(Keys.M) ? 1 : 0) - (input.IsKeyDown(Keys.N) ? 1 : 0);

    if (volumeChange != 0)
    {
        volume = Math.Clamp(volume + volumeChange * dt, 0, 1);

        if (padInstance is not null)
            padInstance.Volume = volume * 0.5f;
    }

    if (panChange != 0)
    {
        pan = Math.Clamp(pan + panChange * dt, -1, 1);

        if (padInstance is not null)
            padInstance.Pan = pan;
    }

    // A finished instance still holds a native source: dispose it once it reports Stopped.
    chimes.RemoveAll(instance =>
    {
        if (instance.PlayState != PlayState.Stopped)
            return false;

        instance.Dispose();
        return true;
    });

    if (speaker is not null)
    {
        bump = MathF.Max(0, bump - dt * 4);
        speaker.Transform.Scale = new Vector3(1 + bump * 0.35f);
    }
}

void AddInstructions()
{
    var overlay = DebugOverlay.GetOrCreate(game);

    overlay.AddSection("Wav file", () =>
    {
        var padOn = padInstance?.PlayState == PlayState.Playing;

        return
        [
            new("Space", $"Chime, {chimes.Count} playing (press fast: they overlap)", chimes.Count > 0 ? Color.LightGreen : Color.Yellow),
            new("L", $"Pad loop {(padOn ? "playing" : "stopped")}", padOn ? Color.LightGreen : Color.Yellow),
            new(["J", "K"], $"Volume {volume:0.00}", Color.Yellow),
            new(["N", "M"], $"Pan {pan:+0.00;-0.00;0.00} (mono sources only)", Color.Yellow),
            new(""),
            new($"chime.wav: {Describe(chime)}", Color.LightGray),
            new($"pad-loop.wav: {Describe(pad)}", Color.LightGray),
        ];
    });

    static string Describe(WavSound? sound)
        => sound is null ? "not loaded" : $"{sound.SampleRate} Hz, {(sound.Channels == 1 ? "mono" : "stereo")}, {sound.Duration.TotalSeconds:0.00} s, {sound.Samples.Length * 2 / 1024} KB in memory";
}