Tutorial 118: Procedural Audio with DynamicSoundEffectInstance

CNA Tutorials  ·  Audio

What you’ll learn

  • Generating audio sample-by-sample and pushing it into DynamicSoundEffectInstance.
  • What the BufferNeeded event actually is, and what pumps it.
  • Why PendingBufferCount does not drop the moment you submit.
  • CNA’s float-buffer extension, and the one way to make it throw.

Before you startTutorial 14: Playing Sound Effects covers SoundEffect and SoundEffectInstance, which this class derives from.

Everything in Tutorial 14 starts from a file on disk. DynamicSoundEffectInstance starts from nothing: you hand it raw PCM sample data, frame after frame, for as long as you want it to keep playing. That makes it the class you reach for when the sound does not exist until the game runs — a synthesised engine note that tracks RPM, a wind layer driven by the weather simulation, a chiptune generated from a tracker pattern, or audio arriving from a decoder or network stream that CNA itself does not know about.

Creating an instance

There is no Content.Load path here. You construct the instance directly, fixing its sample rate and channel layout for its whole lifetime:

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

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

// 44.1 kHz stereo. Both values are fixed for the life of the instance.
DynamicSoundEffectInstance tone(44100, AudioChannels::Stereo);

AudioChannels has exactly two values, Mono and Stereo. The instance is non-copyable and non-movable, so hold it by value in a member, or in a std::unique_ptr if its lifetime is dynamic.

Submitting sample data

Audio goes in through SubmitBuffer, which takes a byte vector:

void SubmitBuffer(const std::vector<SharpRuntime::bytecs>& buffer);
void SubmitBuffer(const std::vector<SharpRuntime::bytecs>& buffer,
                  SharpRuntime::intcs offset,
                  SharpRuntime::intcs count);

The bytes must be headerless, little-endian, signed 16-bit PCM, with channels interleaved. Not a WAV file, not an OGG file, not an XNB asset. Passing a whole file’s bytes here makes its container header get mixed as if it were audio — you get a burst of noise, not a clean error.

Here is a complete generator for a stereo sine tone. Note the interleaving and the little-endian split, which you have to do by hand:

#include <cmath>
#include <vector>

static constexpr int kSampleRate = 44100;
static double phase = 0.0;

std::vector<SharpRuntime::bytecs> MakeTone(double frequencyHz, int frameCount)
{
    // 2 channels x 2 bytes per sample
    std::vector<SharpRuntime::bytecs> pcm(static_cast<std::size_t>(frameCount) * 4);

    const double step = 2.0 * 3.14159265358979 * frequencyHz / kSampleRate;

    for (int i = 0; i < frameCount; ++i)
    {
        const auto sample = static_cast<std::int16_t>(std::sin(phase) * 0.25 * 32767.0);
        phase += step;

        const auto lo = static_cast<SharpRuntime::bytecs>(sample & 0xFF);
        const auto hi = static_cast<SharpRuntime::bytecs>((sample >> 8) & 0xFF);

        pcm[i * 4 + 0] = lo;  // left,  low byte
        pcm[i * 4 + 1] = hi;  // left,  high byte
        pcm[i * 4 + 2] = lo;  // right, low byte
        pcm[i * 4 + 3] = hi;  // right, high byte
    }
    return pcm;
}

Keeping phase outside the function matters. Restarting it at zero for every buffer puts a discontinuity at each seam, which you will hear as a click at your buffer rate.

BufferNeeded, and what pumps it

BufferNeeded is a plain System::EventHandler<System::EventArgs>. Subscribe with +=, exactly as XNA code does with += on a C# event:

tone.BufferNeeded += [this](System::Object* sender, const System::EventArgs&)
{
    auto* instance = static_cast<DynamicSoundEffectInstance*>(sender);
    instance->SubmitBuffer(MakeTone(currentFrequency_, 1024));
};

tone.SubmitBuffer(MakeTone(currentFrequency_, 1024)); // prime it
tone.Play();

The event does not fire on its own. It is raised from DynamicSoundEffectInstance::Update(), and that is driven by FrameworkDispatcher::Update(), which walks every dynamic instance currently playing. Play() adds the instance to that list; stopping or disposing removes it.

Inside a Game subclass this is already wired for you. Game::Update() calls FrameworkDispatcher::Update() at the end of its own update pass, so as long as your override calls the base Game::Update(gameTime), dynamic audio pumps automatically. Outside a Game — a tool, a test, a custom loop — you must call FrameworkDispatcher::Update() yourself once per frame, or the sound stops after your primed buffers run out.

The starvation rule is exact, and worth knowing because it governs how much work your handler does per frame. Update() raises BufferNeeded once for each buffer you are short of a minimum of three pending buffers. With one buffer pending it fires twice; with three or more it does not fire at all.

PendingBufferCount lags on purpose

SharpRuntime::intcs pending = tone.getPendingBufferCountProperty();

A submitted chunk stays counted until the audio stream reports that it no longer holds that many bytes queued — that is, until it has genuinely been consumed by playback, not merely handed over to the mixer. So the count does not drop the instant you call SubmitBuffer, and that is what makes the three-buffer rule a real measure of how close you are to running dry.

Sizing your buffers

Two helpers convert between bytes and time for this instance’s own format, so you do not have to redo the sample-rate arithmetic:

System::TimeSpan     GetSampleDuration(SharpRuntime::intcs sizeInBytes) const;
SharpRuntime::intcs  GetSampleSizeInBytes(System::TimeSpan duration) const;
// How many bytes is 50 ms of audio at this instance's rate and channel count?
const auto bytes = tone.GetSampleSizeInBytes(System::TimeSpan::FromMilliseconds(50));

Buffer size is a latency-versus-safety trade. Smaller buffers respond faster to gameplay changes; larger ones survive a frame spike. Around 20–50 ms per buffer, with the three-buffer minimum in front of the hardware, is a reasonable starting point.

Submitting float samples (CNAEXT)

Real XNA has no float submission path at all. CNA adds one, marked CNAEXT, which spares you the int16 conversion if your synthesis already works in floats:

CNAEXT void SubmitFloatBufferEXT(const std::vector<float>& buffer);
CNAEXT void SubmitFloatBufferEXT(const std::vector<float>& buffer,
                                 SharpRuntime::intcs offset,
                                 SharpRuntime::intcs count);

Pick one format per instance and stay with it. Switching between SubmitBuffer and SubmitFloatBufferEXT while the instance is playing or paused throws System::InvalidOperationException. The underlying stream is created for one sample format, and feeding it the other would be silently wrong — so CNA makes it loud instead. Switching is only legal from the Stopped state.

Because these are extensions, a build configured with -DCNA_STRICT_XNA_API=ON turns a call to either one into a compile error. That is the point of the mode: if you intend to stay portable to real XNA semantics, use SubmitBuffer only.

Where a dynamic instance differs from a normal one

MemberBehaviour on DynamicSoundEffectInstance
getIsLoopedProperty()Always returns false.
setIsLoopedProperty(bool)Accepted and ignored — no throw, no effect. There is no authored buffer to loop over.
Stop(false)Throws System::InvalidOperationException. A non-immediate stop releases into an authored loop, and there is not one.
Stop() / Stop(true)Stops and clears every queued buffer.
Play()Requests more buffers if it is short, then starts or continues.
Pause() / Resume()Inherited and fully functional.
Volume, Pitch, Pan, Apply3DInherited from SoundEffectInstance and work normally — see Tutorial 119.

Two more CNAEXT helpers are available when you need finer control: ClearBuffers() drops everything queued without stopping playback — useful when the player skips and the queued audio is now stale — and Update() can be called directly if you are not going through FrameworkDispatcher.

Pitfalls

Doing heavy work inside the handler. BufferNeeded is raised synchronously from your update pass, and can fire more than once in a single frame. Whatever it does happens on your frame budget. Generate into a pre-allocated buffer rather than allocating a fresh vector every call.

Disposing from inside the handler. This is a legitimate XNA pattern — you have no more data, so you dispose the instance from the very handler that asked for some. CNA supports it: the dispatcher snapshots its stream list before calling into your code, precisely so a handler that disposes its own instance does not deadlock.

Assuming there is an audio device. On a machine with no working audio output, playback will not start. Design so silence degrades gracefully rather than stalling a state machine that waits for a sound to finish.

Forgetting to prime. If you call Play() with an empty queue, the first sound you get is whatever your handler manages to produce on the next update. Submit at least one buffer first.

Where to go next