Tutorial 14: Playing Sound Effects

Audio  ·  SoundEffect  ·  SoundEffectInstance  ·  3D Audio

CNA implements the XNA 4.0 audio API on top of SDL3_mixer. Short, one-shot sounds — footsteps, explosions, coin pickups — are handled by SoundEffect. For looping or controllable playback you use a SoundEffectInstance. This tutorial covers both.

SoundEffect overview

SoundEffect lives in Microsoft::Xna::Framework::Audio. It wraps a decoded audio buffer and exposes a simple Play() call that fires and forgets — perfect for short one-shot sounds. Multiple simultaneous instances can play from the same SoundEffect object without any extra management.

CNA uses SDL3_mixer under the hood. All common formats are supported: WAV, OGG Vorbis, FLAC, and MP3. WAV is recommended for short effects because it needs no decoding step at play time.

ContentManager::Load<SoundEffect>

Sound assets are loaded through ContentManager like any other asset. The Load method is templated on the asset type:

#include "Microsoft/Xna/Framework/Audio/SoundEffect.hpp"
#include "Microsoft/Xna/Framework/Content/ContentManager.hpp"

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

// In LoadContent():
auto explosionSfx = Content.Load<SoundEffect>("Audio/explosion");

ContentManager appends the platform extension automatically. It first tries Audio/explosion.wav, then .ogg, then .flac.

sound.json descriptor

You can place a sound.json sidecar next to your audio file to control how it is imported. This is optional — omitting it uses sensible defaults:

// assets/Audio/explosion.sound.json
{
  "defaultVolume": 0.85,
  "defaultPitch":  0.0,
  "defaultPan":    0.0,
  "loopStart":     0,
  "loopLength":    0
}

When loopLength is non-zero a SoundEffectInstance created from this effect will loop between loopStart and loopStart + loopLength samples when IsLooped is true.

SoundEffect::Play()

The simplest path is a single fire-and-forget call:

// Fire and forget — volume 1.0, pitch 0.0, pan 0.0
explosionSfx->Play();

// With explicit parameters (volume, pitch, pan)
// volume : 0.0 (silent) … 1.0 (full)
// pitch  : -1.0 (one octave down) … 0.0 (normal) … 1.0 (one octave up)
// pan    : -1.0 (full left) … 0.0 (centre) … 1.0 (full right)
explosionSfx->Play(0.75f, 0.1f, -0.3f);

Play() returns true if playback started and false if the voice pool was exhausted (SDL3_mixer limit).

SoundEffectInstance for control

When you need to pause, resume, stop, or loop a sound you create a SoundEffectInstance via SoundEffect::CreateInstance(). Each instance owns a dedicated mixing channel.

#include "Microsoft/Xna/Framework/Audio/SoundEffectInstance.hpp"

// Create an instance
auto loopInstance = explosionSfx->CreateInstance();

// Configure before playing
loopInstance->setIsLooped(true);
loopInstance->setVolume(0.6f);
loopInstance->setPitch(-0.2f);
loopInstance->setPan(0.0f);

// Play, pause, resume, stop
loopInstance->Play();
loopInstance->Pause();
loopInstance->Resume();
loopInstance->Stop();          // Stop with no fade
loopInstance->Stop(false);     // Stop immediately

Checking playback state

SoundState state = loopInstance->getState();
// SoundState::Playing / SoundState::Paused / SoundState::Stopped
if (state == SoundState::Playing) { ... }

Volume, Pitch, and Pan

All three properties can be changed while a sound is playing — SDL3_mixer applies the change immediately on the next audio buffer callback.

PropertyTypeRangeNotes
VolumeSingle0.0 – 1.0Linear amplitude scale
PitchSingle-1.0 – 1.0Semitone shift via resampling
PanSingle-1.0 – 1.0Left/right stereo position

A Single is float in the sharp-runtime type system (using Single = float;).

3D Positional Audio

CNA provides a subset of XNA's 3D audio API. You need an AudioListener (the camera / player) and an AudioEmitter (the sound source), then call Apply3D() on a SoundEffectInstance:

#include "Microsoft/Xna/Framework/Audio/AudioListener.hpp"
#include "Microsoft/Xna/Framework/Audio/AudioEmitter.hpp"

AudioListener listener;
listener.Position = Vector3(0.0f, 0.0f, 0.0f);
listener.Forward  = Vector3(0.0f, 0.0f, -1.0f);
listener.Up       = Vector3(0.0f, 1.0f,  0.0f);

AudioEmitter emitter;
emitter.Position = Vector3(5.0f, 0.0f, -3.0f);

auto sfxInstance = explosionSfx->CreateInstance();
sfxInstance->Apply3D(listener, emitter);
sfxInstance->Play();

CNA computes pan and volume attenuation from the listener-relative position. Full HRTF and distance models are not yet implemented — pan and distance attenuation cover most 2D-style positional needs.

Complete example: spacebar plays a sound

#include <memory>
#include "Microsoft/Xna/Framework/Game.hpp"
#include "Microsoft/Xna/Framework/Color.hpp"
#include "Microsoft/Xna/Framework/Input/Keyboard.hpp"
#include "Microsoft/Xna/Framework/Input/Keys.hpp"
#include "Microsoft/Xna/Framework/Graphics/GraphicsDeviceManager.hpp"
#include "Microsoft/Xna/Framework/Graphics/SpriteBatch.hpp"
#include "Microsoft/Xna/Framework/Audio/SoundEffect.hpp"
#include "Microsoft/Xna/Framework/Audio/SoundEffectInstance.hpp"

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

class SoundDemo final : public Game {
public:
    SoundDemo() : graphics_(this) {}

protected:
    void LoadContent() override {
        spriteBatch_ = std::make_unique<SpriteBatch>(getGraphicsDeviceProperty());

        // One-shot explosion sound
        explosion_ = Content.Load<SoundEffect>("Audio/explosion");

        // Looping engine hum
        engine_    = Content.Load<SoundEffect>("Audio/engine_loop");
        engineInst_= engine_->CreateInstance();
        engineInst_->setIsLooped(true);
        engineInst_->setVolume(0.4f);
        engineInst_->Play();
    }

    void Update(GameTime& gameTime) override {
        auto kb = Keyboard::GetState();

        // Play explosion on spacebar press (not hold)
        if (kb.IsKeyDown(Keys::Space) && !prevKb_.IsKeyDown(Keys::Space)) {
            explosion_->Play(1.0f, 0.0f, 0.0f);
        }

        // Toggle engine hum with E key
        if (kb.IsKeyDown(Keys::E) && !prevKb_.IsKeyDown(Keys::E)) {
            if (engineInst_->getState() == SoundState::Playing)
                engineInst_->Pause();
            else
                engineInst_->Resume();
        }

        // Pitch-shift with Up/Down arrows (while held)
        if (kb.IsKeyDown(Keys::Up)) {
            Single p = engineInst_->getPitch();
            engineInst_->setPitch(std::min(1.0f, p + 0.01f));
        }
        if (kb.IsKeyDown(Keys::Down)) {
            Single p = engineInst_->getPitch();
            engineInst_->setPitch(std::max(-1.0f, p - 0.01f));
        }

        prevKb_ = kb;
    }

    void Draw(const GameTime&) override {
        auto& gd = getGraphicsDeviceProperty();
        gd.Clear(Color::CornflowerBlue);
        spriteBatch_->Begin();
        // Draw UI text here using a SpriteFont (see Tutorial 7)
        spriteBatch_->End();
        gd.Present();
    }

private:
    GraphicsDeviceManager               graphics_;
    std::unique_ptr<SpriteBatch>       spriteBatch_;
    std::unique_ptr<SoundEffect>       explosion_;
    std::unique_ptr<SoundEffect>       engine_;
    std::unique_ptr<SoundEffectInstance> engineInst_;
    KeyboardState                       prevKb_;
};

int main() { SoundDemo game; game.Run(); }

Common pitfalls

  • Calling Play() every frame — guard with a previous-frame key state comparison as shown above, otherwise one keypress spawns 60 overlapping instances per second.
  • Destroying a SoundEffect while an instance plays — the SoundEffectInstance holds a shared reference. Safe to destroy the SoundEffect; the audio buffer stays alive until the instance stops.
  • Very short sounds on Android — SDL3_mixer on Android may cut sounds shorter than 100 ms. Pad short effects with silence or use a 0.1 s minimum duration.
  • Volume range — XNA uses a linear 0–1 range, not decibels. 0.5 is half amplitude (about -6 dB), not half perceived loudness.