Tutorial 120: XACT: AudioEngine, SoundBank, WaveBank and Cue

CNA Tutorials  ·  Audio

What you’ll learn

  • Wiring up AudioEngine, WaveBank and SoundBank, and why they do not go through ContentManager.
  • Playing cues, both fire-and-forget and held.
  • Driving mixes with categories, global variables and RPC curves.
  • Three honest caveats — including one that makes a sound go missing without any error at all.

Before you startTutorial 14: Playing Sound Effects covers the simpler SoundEffect path. If you are starting a new project with no existing XACT content, that path is the one you want; XACT is for porting titles that already have it.

XACT was XNA’s data-driven audio system: a sound designer authored cues, categories, variation tables and parameter curves in the XACT tool, and the game triggered them by name. Most reimplementations stub it out. CNA does not — AudioEngine, WaveBank and SoundBank contain a genuine binary parser for the XGS, XWB and XSB formats, and Cue plays the parsed result back through SDL3_mixer using FACT’s own volume formula, real RPC curve evaluation, real per-track filters and real instance limits.

That is worth being precise about, because it is the difference between “your project builds” and “your project sounds right”. It is also worth being precise about the three places where it stops, which is the second half of this page.

The three file types

FileContentsLoaded by
.xgsGlobal settings: categories, variables, RPC curvesAudioEngine
.xwbWave bank: the actual encoded audioWaveBank
.xsbSound bank: cues, sounds, tracks, events, variationsSoundBank

These are loaded by file path, not through ContentManager. There is no Content.Load<SoundBank>(). Each constructor takes a plain std::string path, and it is your job to make sure your content directory ships alongside the executable.

CNA cannot author these files — it only reads them. They come from Microsoft’s XACT3 authoring tool. AudioEngine::ContentVersion is the constant 46, the XACT content version CNA targets.

Setting it up

Order matters: the engine first, then the wave banks that hold the audio, then the sound banks that reference them.

#include "Microsoft/Xna/Framework/Audio/AudioEngine.hpp"
#include "Microsoft/Xna/Framework/Audio/SoundBank.hpp"
#include "Microsoft/Xna/Framework/Audio/WaveBank.hpp"

using namespace Microsoft::Xna::Framework::Audio;

class MyGame : public Microsoft::Xna::Framework::Game
{
    std::unique_ptr<AudioEngine> audioEngine_;
    std::unique_ptr<WaveBank>   waveBank_;
    std::unique_ptr<SoundBank>  soundBank_;

    void LoadContent() override
    {
        audioEngine_ = std::make_unique<AudioEngine>("Content/Audio/Demo.xgs");
        waveBank_    = std::make_unique<WaveBank>(audioEngine_.get(), "Content/Audio/Waves.xwb");
        soundBank_   = std::make_unique<SoundBank>(audioEngine_.get(), "Content/Audio/Sounds.xsb");
    }
};

Destroy them in the reverse order. The engine keeps registries of its banks and cascades disposal, so a bank never outlives the engine and a cue never outlives its bank — but explicit reverse-order teardown keeps the intent obvious.

Call audioEngine_->Update() once per frame. This is the same contract XNA games already follow. It reconciles cue state, sweeps finished fire-and-forget cues, and — the audible part — ticks authored fade-outs so they ramp smoothly instead of jumping to silence the next time something happens to query them.

void Update(const GameTime& gameTime) override
{
    Game::Update(gameTime);
    audioEngine_->Update();
}

Playing cues

There are two ways, and the difference is who owns the cue.

Fire and forget — the bank owns it, plays it, and sweeps it away when it finishes. Use this for one-shots:

soundBank_->PlayCue("Explosion");

// Positioned in 3D at the moment it starts:
soundBank_->PlayCue("Explosion", listener, emitter);

Held — you get a Cue* you own and control. Use this for anything you need to pause, stop, re-position or drive with a variable:

Cue* engine = soundBank_->GetCue("EngineLoop");
engine->Play();

// ... later ...
engine->SetVariable("RPM", 4200.0f);
engine->Apply3D(listener, emitter);

// ... eventually ...
engine->Stop(AudioStopOptions::AsAuthored);
engine->Dispose();
delete engine;

GetCue throws System::InvalidOperationException for a name that is not in the bank, System::ObjectDisposedException if the bank is gone, and System::ArgumentNullException for an empty name. PlayCue throws the same three.

You are responsible for disposing a cue you obtained from GetCue. If the bank is disposed first, it force-stops and disposes any cue still associated with it, so a cue can never outlive its bank — but the memory is still yours to free.

Cue state

Cue exposes the full XACT state set as separate boolean properties rather than one enum:

[[nodiscard]] bool getIsCreatedProperty()   const;
[[nodiscard]] bool getIsPreparingProperty() const;
[[nodiscard]] bool getIsPreparedProperty()  const;
[[nodiscard]] bool getIsPlayingProperty()   const;
[[nodiscard]] bool getIsPausedProperty()    const;
[[nodiscard]] bool getIsStoppingProperty()  const;
[[nodiscard]] bool getIsStoppedProperty()   const;
[[nodiscard]] bool getIsDisposedProperty()  const;

IsStopping is the one that earns its keep: a cue told to stop as authored spends its fade-out there, and only then becomes IsStopped.

cue->Stop(AudioStopOptions::AsAuthored);  // honour the authored fade-out
cue->Stop(AudioStopOptions::Immediate);   // cut now

Categories

Categories are the mix buses your sound designer authored in the XGS file. Fetch one by name and operate on every cue in it at once:

AudioCategory music = audioEngine_->GetCategory("Music");
music.SetVolume(0.4f);
music.Pause();
music.Resume();
music.Stop(AudioStopOptions::AsAuthored);

Categories carry more than a volume: each one parses a parent index, an instance limit, fade-in and fade-out durations, and a max-instance behaviour. CNA implements the limit checks, including the eviction and fade behaviour FACT applies when a category is already at capacity. Cue-level instance limits are checked as well, before the category’s.

Variables and RPC curves

This is where XACT earns its complexity. A designer binds a parameter — volume, pitch, filter cutoff, filter Q — to a variable through a curve, and the game just writes the variable.

Variables come in two genuinely separate scopes, and CNA keeps them separate exactly as real XACT does:

// Engine-global variables
audioEngine_->SetGlobalVariable("Tension", 0.8f);
float t = audioEngine_->GetGlobalVariable("Tension");

// Cue-scoped variables, per cue instance
cue->SetVariable("RPM", 4200.0f);
float rpm = cue->GetVariable("RPM");

A variable is one or the other, never both — that is a property of how the XGS file declares it, not a CNA restriction. A cue-scoped variable that was never explicitly set reads back the authored initial value.

The curves themselves are real. Each RPC control point carries an interpolation shape — linear, fast, slow, or sine — and CNA evaluates them continuously, once per AudioEngine::Update(), retargeting volume, pitch and filter parameters live rather than sampling once at play time.

Three honest caveats

1. Only the first PlayWave per track is honoured

When CNA parses a track’s event list, it walks the events until it finds the first PlayWave-family event and stops there. A track authored with several sequenced waves plays only the first of them.

Variation tables are a different mechanism and are supported — weighted selection across a candidate list, in all five authored modes (ordered, ordered-from-random, random, random-no-repeats and shuffle). What is not supported is a single track that fires several waves in sequence.

2. XMA and WMA go missing silently

An XMA/XMA2 or WMA wave-bank entry does not throw. CNA writes a diagnostic to stderr naming the bank, the wave index and the format, then returns no sound at all. Playback continues. Your cue triggers, and nothing is heard.

This is the caveat most likely to cost you an afternoon, because there is no exception to catch and no return code to check — only a line on standard error that is easy to miss behind a game’s own logging. If a ported title is missing specific sounds, check the console first.

A wave bank entry is one of four formats. Two decode and two do not:

XWB formatStatus
PCM, 8-bit and 16-bitDecodes
MS-ADPCMDecodes
XMA / XMA2Logs to stderr, sound is missing
WMALogs to stderr, sound is missing

The fix is to re-encode the affected waves to PCM or MS-ADPCM in the XACT tool and rebuild the bank. Loop regions authored on a wave entry are parsed and applied, so an intro-then-loop wave keeps its loop points through the re-encode.

Note the asymmetry with the XNB path. A SoundEffect loaded from an .xnb asset whose format is XMA2 does throw — the XNB SoundEffectReader rejects it outright. The two paths behave differently for the same codec, so do not generalise from one to the other.

3. Reverb is a deliberate no-op

An authored reverb send is accepted and does nothing. SDL3_mixer has no auxiliary send/return bus — no equivalent of the shared reverb voice XACT routes into — so there is nowhere for the wet signal to go. This is a documented limitation of the backing library, not a gap waiting to be filled.

Everything else in the DSP chain is real: per-track low-pass, band-pass and high-pass filters are implemented with the same state-variable filter algorithm FAudio uses, with the authored frequency and Q converted through FAudio’s own formulas, and live RPC retargeting of both.

3D cues

Cue::Apply3D(listener, emitter) behaves exactly like the SoundEffectInstance version, with the same single-listener restriction. See Tutorial 119 for the attenuation curve, the Doppler model, and what Apply3D takes over once you call it.

Streaming wave banks

The second WaveBank constructor takes the streaming form, matching XNA’s:

WaveBank(AudioEngine* audioEngine,
         const std::string& streamingWaveBankFilename,
         SharpRuntime::intcs offset,
         SharpRuntime::shortcs packetSize);

Two read-only properties tell you where a bank stands: getIsPreparedProperty() reports whether it has finished loading and is ready for playback, and getIsInUseProperty() whether any cue is currently playing audio sourced from it.

Should you use XACT at all?

If you are porting a title that already has XACT content, yes — that is the whole point, and the alternative is re-authoring your audio. If you are starting fresh, SoundEffect and SoundEffectInstance are simpler, integrate with ContentManager, and have none of the caveats above. You can always build the data-driven layer you actually want on top of them.

Where to go next