Tutorial 14: Playing Sound Effects
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.
| Property | Type | Range | Notes |
|---|---|---|---|
Volume | Single | 0.0 – 1.0 | Linear amplitude scale |
Pitch | Single | -1.0 – 1.0 | Semitone shift via resampling |
Pan | Single | -1.0 – 1.0 | Left/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
SoundEffectInstanceholds a shared reference. Safe to destroy theSoundEffect; 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.