Audio engine internals

CNA snapshot 009d40f5  ·  Development › Audio internals  ·  source links pinned to 009d40f5

✓

Evidence basis: source-verified at the pinned commit; tests exist (not executed for this page). Claims on this page were checked by reading the CNA source at commit 009d40f5; unless a sentence says otherwise, nothing here was built or executed. Read from the TARGET sources and CMake files; the named test files and CTest registrations exist and were not executed. ALSA tests use the null and file PCMs, so nothing here establishes behaviour on physical audio hardware or real capture; XACT, music and microphone internals are only touched where they meet the mixer.

CNA's audio path has two layers that are easy to confuse: an XNA-shaped facade (SoundEffect, SoundEffectInstance, dynamic streams) that plays through a mixer behind MixerEngine.hpp, and an output-device layer (IAudioDevice) that only moves finished PCM buffers to a host. This page follows one playback from the public class down to the device callback, states which of the four CNA_AUDIO_PLATFORM implementations really has a mixer, and records the generation counters, callback barriers and deferred-destruction rules that keep native memory alive while an audio thread may still be reading it. It is for anyone changing playback, a mixer backend, a transport or the audio CMake selection. User-level behaviour is on the Audio guide.

Modules, selectors and dependencies

The cna_audio module (modules/audio) owns SoundEffect and SoundEffectInstance, DynamicSoundEffectInstance, the XACT classes (AudioEngine, WaveBank, SoundBank, Cue), Microphone, the WAV helpers, the mixer facade, the device factory and every concrete transport. cna_media is a different module (songs and video), although FrameworkDispatcher::Update is implemented here and calls into it. The output-device abstraction and the XNA facade are deliberately not the same size: the device layer has four implementations, the mixer facade two.

XNA facade      SoundEffect, SoundEffectInstance, DynamicSoundEffectInstance, XACT, Microphone
   |            compiled against a mixer only where SOUND_ENABLED is defined
MixerEngine.hpp tracks, audio handles, queued streams, gain, mix/stopped/post-mix callbacks
   |            SDL3 selection: Backend/Sdl3Mixer (SDL3_mixer, memory-backed MIX_Mixer)
   |            ALSA selection: Backend/CnaMixer  (CnaMixer, CNA's own mixer)
   |            SDL2 and NULL selections: no implementation is compiled
IAudioBufferCallback::FillBuffer   one whole interleaved buffer per dispatch
   |
IAudioDevice    Sdl3AudioDevice | Sdl2AudioDevice | NullAudioDevice | AlsaAudioDevice
   |            (Platform/AudioDeviceFactory.cpp returns the selected one)
host audio      SDL audio stream | SDL2 callback device | paced worker that discards | ALSA PCM
SelectionDevice classMixer behind MixerEngine.hppSOUND_ENABLEDRecording providerNative edge
SDL3 (default)Sdl3AudioDeviceSDL3_mixer, through Backend/Sdl3MixerdefinedSdl3AudioRecordingDeviceProviderSDL3::SDL3 and SDL3_mixer::SDL3_mixer, linked privately
SDL2Sdl2AudioDevicenonenot definednone (factory returns null)SDL2::SDL2
NULLNullAudioDevicenonenot definednone (factory returns null)none
ALSAAlsaAudioDeviceCnaMixer, through Backend/CnaMixerdefinedAlsaAudioRecordingDeviceProviderALSA headers at build time and the dynamic-loading library; libasound.so.2 is opened at run time

OPENAL and WASAPI are reserved identifiers, not implementations. Two design points are encoded here: the transport contract can be satisfied without any XNA playback (SDL2, NULL), and the mixer is a second choice that follows from the audio platform (SDL3_mixer for SDL3, CnaMixer for ALSA) rather than a separate option.

Configure-time selection rules

cmake/AudioPlatformSelection.cmake declares CNA_AUDIO_PLATFORM as an independent cache variable (default SDL3; available SDL3 SDL2 NULL ALSA), so a headless or terminal application may still use SDL3 audio and a graphical SDL3 application may deliberately choose deterministic NULL audio. A reserved value (OPENAL, WASAPI) is a FATAL_ERROR that says falling back to SDL3 would build something other than what was asked; any other unknown value is a second FATAL_ERROR. ALSA additionally requires a Linux target in a real configure (the check is skipped in the script-mode selection tests). The file ends by adding CNA_AUDIO_PLATFORM_<NAME> as a compile definition, which is what AudioDeviceFactory.cpp switches on for both CreateSelectedAudioDevice and CreateSelectedAudioRecordingDeviceProvider; a build with none of the four defined stops on an #error.

Two more files constrain the choice. cmake/SdlAvailability.cmake turns CNA_ENABLE_SDL=OFF into a configure error whenever CNA_AUDIO_PLATFORM is SDL3 or SDL2, so an SDL-free build must select NULL or ALSA. cmake/Sdl2OnlyConfiguration.cmake refuses the two pairings that would put SDL2 and SDL3 into one process (SDL2 platform with SDL3 audio, and the reverse), because both export identically named entry points and each backend's calls would bind to whichever library the loader reached first. The reference-level view of these axes is on Selection axes index.

modules/audio/CMakeLists.txt globs every src/*.cpp and then removes what the selection does not use: Platform/Sdl3 and Backend/Sdl3Mixer unless SDL3; Platform/Sdl2 unless SDL2; Platform/Alsa and Backend/CnaMixer unless ALSA. The portable NullAudioDevice is always compiled so the default build can run cross-implementation conformance tests. This filtering, not a preprocessor test, is what guarantees that exactly one implementation of MixerEngine.hpp exists in a binary, and it stops a nominal SDL2-only build from silently retaining an SDL3 or SDL3_mixer link through cna_audio. For ALSA the file checks for alsa/asoundlib.h (a fatal error names the Debian and Fedora packages), adds the vendored stb and dr_libs include directories and links only ${CMAKE_DL_LIBS}; two vendored translation units are compiled with -w.

The link edges are part of the design. cna_audio links cna_core, cna_diagnostics and cna_math publicly, cna_platform privately (XACT title content is read through the selected platform's file system in TitleContentBytes.cpp, which asks the ambient platform accessor, so audio does not require the runtime module to own it), and both cna_media and cna_input privately. The media edge is a declared static-archive cycle, not an accident: FrameworkDispatcher::Update pumps the audio streams and MediaPlayer, while MediaPlayer plays through the audio mixer. The input edge exists because the same function pumps TouchPanel. FrameworkDispatcher.cpp lives in this module: the dynamic-stream list it walks belongs to audio and audio's own sources reference it, so moving it into runtime would make audio depend on runtime while runtime already depends on audio. Game calls it once at the end of construction and again at the end of every Game::Update (see safe extension points on the runtime page).

What SOUND_ENABLED decides

modules/CMakeLists.txt defines SOUND_ENABLED on the shared build-config interface target only when CNA_AUDIO_PLATFORM is SDL3 or ALSA; its comment defines the term as “a mixer exists behind MixerEngine.hpp”. It gates facade code, not source selection: SoundEffect.cpp, SoundEffectInstance.cpp, DynamicSoundEffectInstance.cpp and Microphone.cpp contain complete no-mixer branches, and Microphone only asks for a recording provider inside #ifdef SOUND_ENABLED. A build that succeeds with CNA_AUDIO_PLATFORM=SDL2 or NULL therefore proves only that those branches compile; it does not show that XNA sound plays there. The decoding section lists what each public entry point does without a mixer.

One SoundEffectInstance playback path

The diagram follows call direction. The device layer drives the mixer, not the other way round: a device thread (or, for NULL and ALSA, CNA's own worker) asks the registered callback for one buffer, and that callback pulls the mixer.

content / stream / raw PCM  ->  SoundEffect::Impl        (shared audio + duration metadata)
  SoundEffect::CreateInstance()  ->  SoundEffectInstance  (holds Impl alive)
  SoundEffectInstance::Play()
       EnsureMixer(); CreateMixerTrack(); SetMixerTrackAudio()
       INTERNAL_applyComposedTrackProperties()   gain, pan slot, 2^pitch * Doppler
       PlayMixerTrack(loop options)
  ---- audio thread ----
  SDL3:  SDL audio stream demand -> Sdl3AudioDevice::FeedStream -> FillBuffer
           -> MixerBufferCallback::FillBuffer -> MIX_Generate
           -> SDL_PutAudioStreamData
  ALSA:  AlsaAudioDevice::Run -> FillBuffer -> DeviceCallback::FillBuffer
           -> CnaMixer::Render -> float-to-device format -> snd_pcm_writei
  (each track: resample -> track gain -> track mix callback -> added at master gain)
  later: Stop / Dispose / destructor run under the transport's callback barrier

Ownership of an effect and its instances

SoundEffect.cpp keeps a shared_ptr<Impl> (the class is move-only). Impl stores the mixer audio handle when SOUND_ENABLED is defined, the effect's frame count and rate in every build (duration is a property of the data, so a NULL or SDL2 build still reports it), and raw, non-owning pointers to live instances for the explicit Dispose() cascade. A SoundEffectInstance copies the shared pointer as soundEffectKeepAlive_ and caches the native audio handle at construction (SoundEffectInstance.cpp), so SoundEffect(path).CreateInstance() survives the temporary wrapper's destruction. Move construction and move assignment unregister the old address from the parent registry and register the new one, then mark the moved-from object disposed. The per-track FilterState is separately heap-owned through unique_ptr, so the userdata pointer already registered with the mixer stays valid across a move.

Explicit SoundEffect::Dispose() iterates a snapshot of the instance vector, because each SoundEffectInstance::Dispose() unregisters itself; it then resets its own Impl pointer. The default destructor is not the same operation: it only releases one shared reference, and instances that survive keep Impl alive. An instance's own Dispose stops and destroys its track through DestroyTrackSafe (see generation counters), unregisters, and releases the keep-alive.

Play, Pause, Stop and state

SoundEffectInstance::Play first sets packetSubmitted_, which fixes the 3D-versus-pan choice from that moment until Stop, and then returns immediately if the state is already Playing. Otherwise it sets hasStarted_ (after which IsLooped can no longer be changed and the setter throws InvalidOperationException). A paused instance with a live track resumes instead of restarting. A stopped one takes the full path: it reads the cached native audio handle (no handle leaves the instance stopped), calls CNA::Internal::Audio::EnsureMixer(), creates a track if none is live, records the mixer generation at that moment, binds the audio, applies the composed track properties, builds MixerPlayOptions (loop count -1 when IsLooped) and calls PlayMixerTrack. A loop region authored in the file or given to the raw-buffer constructor is applied only while looped, as a loop-start frame and, when a length is given, a maximum frame equal to start plus length, so the intro plays once and only the region repeats. Any failure (no track, bind refused, play refused) leaves the state Stopped. Stop(false) only sets the track's loop count to zero so it finishes naturally; Stop(true) stops it at once. Resume with no live track calls Play, which for a disposed instance surfaces ObjectDisposedException. getStateProperty asks the mixer track whether it is paused or playing and caches the result; without a mixer it simply returns the stored state.

Volume, pan, pitch and 3D

Master volume is applied once, at the mixer's global gain stage (SetMixerMasterGain), never multiplied into each track. Every property write funnels into INTERNAL_applyComposedTrackProperties: track gain is Volume × attenuation (attenuation is the persisted result of the last Apply3D), pitch is 2^pitch × doppler clamped to at least 0.01, and pan is either Pan or the persisted spatial pan once 3D has latched. Stereo pan is not the mixer's native per-channel gain, which has no crossfeed term: the track's stereo gain is fixed to unity and a crossfeed matrix runs in the track's single cooked-callback slot. That slot is shared with the optional low-pass, high-pass or band-pass filter (FilterState holds both), the filter runs first, then the matrix, and a pan of zero skips the transform.

FilterState fields are written from the game thread under MixerLock and read by the mixing callback, which already holds the same lock while mixing (SDL3_mixer's mixer lock; CnaMixer's recursive engine mutex), so the callback must not lock again. Filter setters use TryEnsureMixer and simply do nothing when no mixer can be created, so they never throw into Cue::Play.

Apply3D is gated the way XNA gates it: before the first Play it latches 3D mode; once a packet is submitted, an instance in pan mode refuses it with InvalidOperationException, and Pan on a playing 3D instance refuses symmetrically. The computation is deliberately approximate where the mixer cannot follow XACT: distance attenuation is full volume inside DistanceScale and inverse-law beyond it; pan is the emitter's displacement along the listener's right axis divided by distance; there is no HRTF and no orientation cone. Doppler is computed explicitly from position and velocity using the F3DAudio formula with XNA's own combination of emitter and global scale, clamped to 0.5–4.0. With several listeners the nearest one decides attenuation, pan and Doppler (a documented approximation, not “listener zero”). A change to any of these paths needs decoded-audio tests, not only property tests with default zero pan and pitch.

Fire-and-forget SoundEffect::Play

SoundEffect::Play(volume, pitch, pan) is a separate path with no instance object. It validates pan (out of range throws ArgumentOutOfRangeException) and clamps pitch, returns false for a disposed effect or one with no native audio, and then creates a mixer track, binds the audio, sets track gain and unity stereo, computes the crossfeed matrix once when pan is non-zero, installs a pan callback and a stopped callback, applies the pitch ratio and plays. Its FireAndForgetPanState is heap-allocated as the callback userdata and is not freed by the stopped callback. The source records why: the mixer can invoke the stopped callback partway through pulling a track's final buffer and still deliver that buffer to the pan callback moments later, and this was caught as a real ASan heap-use-after-free. Instead OnFireAndForgetStopped pushes the state onto a lock-free intrusive stack (a Treiber-style push through compare_exchange_weak, so the mixer thread neither locks nor allocates), destroys the track, and the next Play drains the stack; a static holder's destructor drains what is left at process exit. When PlayMixerTrack itself fails the stopped callback can never fire, so the state is deleted on the spot. Changing this order, or replacing the queue with an immediate delete, is a lifetime regression even if a short sample still sounds correct. (A comment above the struct still says it is freed in the stopped callback; the code is authoritative.)

Dynamic streams

DynamicSoundEffectInstance shares the base class's track_, generation field and composed-property routine. Its constructor never touches the mixer; its first Play calls a local EnsureMixerOrThrowXna, creates a queued MixerStream (an SDL_AudioStream on SDL3, a byte queue in CnaMixer), attaches it to a track that neither halts when exhausted nor stops looping, submits any already queued buffers, and registers itself in FrameworkDispatcher::Streams. Once a track exists, a non-immediate Stop throws InvalidOperationException (there is no authored loop to release into). Track destruction and the read of track_ by a producer thread's SubmitBuffer are serialised by the instance's queue mutex.

Decoding and failure representation

There are three constructors and one content route. The file constructor SoundEffect(const std::string&) calls LoadMixerAudioFile; SoundEffect::FromStream reads the whole stream, passes owned encoded bytes to LoadMixerAudioMemory and returns a heap pointer the caller owns; the two raw-buffer constructors pass PCM16LE with an explicit sample rate and channel count to LoadMixerRawAudio, after checking offset, count and their sum without integer overflow (ArgumentOutOfRangeException("count")). The raw constructors cannot reject whole-file bytes, because a RIFF header read as samples is still syntactically valid, so they only warn on std::cerr: once when the buffer starts with a RIFF, Ogg, ID3 or XNB signature, and once when its byte entropy exceeds 7.9 bits (an advisory that never throws). loopStart and loopLength are deliberately not validated, matching the reference behaviour, so a negative value wraps to a large unsigned one. FromStream also runs a small independent scan for a WAV smpl chunk to recover an authored loop region regardless of which decoder handled the audio.

Assets loaded through the content pipeline arrive by the fourth route: SoundEffectContentTypeReader.cpp decodes the XNB payload, converts it to 16-bit PCM in the content library, and constructs SoundEffect through the raw-buffer constructor with the sample rate, channels and loop region, then sets the asset name. (The header comment in the content module describes a synthetic-WAV route through FromStream; the reader's implementation is what runs.) The content side is covered on Content runtime internals.

Which decoder handles what

On SDL3 the mixer asks SDL3_mixer: LoadMixerAudioFile becomes MIX_LoadAudio (pre-decoded by default), memory loads use MIX_LoadAudioNoCopy over facade-owned bytes, and raw audio uses MIX_LoadRawAudio. Which codecs the vendored SDL3_mixer build accepts is a property of that build (see the decoders section of the guide). On ALSA, CnaMixerDecoders.cpp decides by content, not file name: RIFF/WAVE through WavDecoder.cpp (DecodeWavToPcm16: PCM 8/16/24/32-bit, IEEE float 32/64, including WAVE_FORMAT_EXTENSIBLE, plus MS-ADPCM and IMA-ADPCM), Ogg Vorbis through stb_vorbis, MP3 through dr_mp3 and FLAC (native or in Ogg) through dr_flac; anything else is refused by name. Compressed data is decoded up front when predecode is set and while it plays otherwise; LoadMixerAudioMemory always predecodes on this backend. Do not assume a format accepted by one mixer is accepted by the other without a format-specific test.

Failure representation

SituationWhat the code does
No audio device, or (ALSA) no libasound.so.2The mixer's EnsureMixer throws std::runtime_error (ALSA: the loader's message; SDL3: the SDL error, after releasing partial state so a later call retries from scratch). SoundEffect's constructors, FromStream, the master-volume getter and setter, and DynamicSoundEffectInstance::Play convert it to NoAudioHardwareException. SoundEffectInstance::Play calls EnsureMixer without converting, and filter setters swallow the failure through TryEnsureMixer.
Bytes or file that the mixer cannot decodeThe load function returns null and records a mixer error; the constructor throws System::NotSupportedException with the path or a stream/buffer label plus GetMixerError(). An empty FromStream stream is also NotSupportedException.
Invalid public argumentsArgumentOutOfRangeException (pan, count and offset, scale properties, listener count) or ArgumentNullException for a null listener array.
Disposed objectObjectDisposedException from SoundEffectInstance::Play, Apply3D and the pan setter; SoundEffect::Play returns false.
Track cannot be created, bound or startedPlay returns false (effect) or leaves the instance Stopped; the mixer facade reports through null or false returns and GetMixerError().

For a failing load, first separate malformed bytes or an unsupported codec, mixer initialisation, device open and format negotiation, and callback output; the tests exercise these as different layers.

Without a mixer (SDL2 and NULL)

The XNA classes still compile and keep value semantics where they can, but nothing is audible. SoundEffect(assetName) does nothing beyond creating an empty Impl; the raw-buffer constructors still validate arguments and record duration; FromStream throws only for an empty stream and otherwise returns an effect with no audio; getDurationProperty reports the recorded frames where they exist; SoundEffect::Play validates pan and then returns false; SoundEffectInstance::Play leaves the state Stopped; master volume is stored in a static float instead of reaching a mixer; and Microphone enumerates no devices. This is a capability distinction, not an acoustic test result: a successful SDL2 or NULL build says nothing about whether Play would produce sound.

Transport implementations: similar contract, different timing

The IAudioDevice contract

IAudioDevice.hpp defines AudioFormat (rate, channels, Signed16 or Float32) and IAudioBufferCallback::FillBuffer(output, sampleCount). sampleCount counts scalar interleaved samples, not frames, and output.size() is exactly sampleCount times the sample width. The callback may run on a real-time thread, so it must not block, allocate or throw, must initialise the whole span (silence is all-zero bits in both formats), and must not call lifecycle methods. Open retains shared ownership of the callback until Close returns, returns the format actually delivered (a device may negotiate a different rate, channel count or representation but must never report the request when something else is delivered) and leaves the device paused. Start enables callbacks; Stop is a callback barrier (after it returns no callback is active or begins until the next Start); after Close the callback is never invoked again. Invalid requests, a null callback, a second Open and open failures are exceptional; a failed Open leaves the device closed; Stop and Close are idempotent.

SDL3

Sdl3AudioDevice.cpp takes an SDL_INIT_AUDIO reference in Open (released on failure and in Close), opens the default playback device through SDL_OpenAudioDeviceStream with a demand callback, reads the negotiated application format back with SDL_GetAudioStreamFormat, and publishes every callback-visible field (stream, callback, scratch buffer, format) before anything can resume the stream, preserving Open's no-callback guarantee. Its scratch buffer holds 4,096 frames; the demand callback refuses a request that is not a whole number of frames (setting an error flag) and otherwise fills the scratch in whole-frame chunks, calls FillBuffer and enqueues each chunk with SDL_PutAudioStreamData. Stop disables further CNA callbacks first, pauses native demand, then locks and unlocks the SDL stream: SDL holds that same recursive lock while the callback runs, so the lock and unlock pair is what makes the barrier concrete.

SDL2

Sdl2AudioDevice.cpp uses SDL_OpenAudioDevice with a classic native callback (a 1,024 sample-frame buffer request, permitting frequency, channel and format changes), publishes callback-visible state before unpausing, disables future callbacks before pausing, and forms its barrier with SDL_LockAudioDevice/SDL_UnlockAudioDevice. A disabled or malformed callback request is answered with silence. It is a real transport, but no XNA mixer is compiled with it in this snapshot.

NULL

NullAudioDevice.cpp accepts the requested format unchanged, starts a worker thread that calls FillBuffer for 512 frames and then waits on a condition variable for the buffer's real duration (so it does not spin), and discards the samples. Stop clears the running flag, wakes the worker and joins it, which is its barrier. It tests transport timing and lifecycle, not audibility or XNA playback.

ALSA

AlsaAudioDevice.cpp opens the PCM named default, or the value of the CNA_AUDIO_DEVICE environment variable when it is set and non-empty, in non-blocking playback mode. It asks for interleaved access and the requested sample representation, falling back to the other one (default and every plug device accept both; a bare hardware device may accept one), lets the library adjust channel count and rate, aims for a period of about 10 ms with four periods buffered, sets the start threshold to two periods and the wake threshold to one, and reads the negotiated values back into the AudioFormat. Start prepares the PCM and starts a worker. The worker generates one period by calling FillBuffer only after the previous period has been written in full, writes it with snd_pcm_writei, and keeps the unconsumed remainder after a partial write. On EAGAIN it waits up to 100 ms for room, so Stop never waits long on it; on an underrun, suspend or interruption it calls snd_pcm_recover, and an unrecoverable error sets a failure flag that makes IsRunning report false. For null and file PCM types, which have no clock, the worker paces itself with sleep_until. Stop joins the worker and then drops buffered audio, so a stopped device goes quiet at once. Close closes the PCM after that.

AlsaLibrary.cpp resolves the required libasound.so.2 symbols with dlopen/dlsym into an immortal function table; if the library or a required symbol is missing it records an error string and every later Open throws it, so a machine without ALSA still starts the program. Capture symbols are resolved separately and only set a capture-available flag.

Capture (recording)

Recording is a separate contract, not part of playback: IAudioRecordingDevice.hpp is pull-based (Open starts capture, GetAvailableBytes and Read never block and distinguish Success, WouldBlock, DeviceLost and Error) and is exposed through an optional provider that is null when unsupported, so a playback-only backend cannot pretend to capture with silent no-ops. The factory returns a provider for SDL3 and ALSA and null for SDL2 and NULL; Microphone::All is the provider's list and its default is the first entry. The ALSA provider honours CNA_AUDIO_RECORDING_DEVICE (exactly that PCM, offered as the default) and otherwise lists ALSA's default capture PCM first and then each card's capture devices through plughw. Capture success cannot be inferred from playback tests; it needs its own.

Mixer generation and callback lifetime

SDL3: a re-creatable mixer

AudioMixer.cpp holds the native mixer behind one mutex. GetMixer holds that mutex for its whole body so two first callers cannot both create a mixer, and does this in a fixed order: MIX_Init; create the selected device through the factory; Open it (paused) asking for the fixed reference format of signed 16-bit stereo at 44,100 Hz (a test-only override exists); create a memory-backed mixer with MIX_CreateMixer in the format the device negotiated, so SDL_mixer owns no playback device and MIX_Generate fills the buffers the selected IAudioDevice requests; configure the callback; and only then Start the device. Any failure closes the device, destroys the mixer, calls MIX_Quit and rethrows, and a later call retries from scratch.

DestroyMixer increments an atomic generation counter first, then stops and closes the device (completing the callback barrier before any track or mixer memory is freed), drains deferred track destruction through BeginMixerEngineShutdown, destroys the native mixer, clears its owners and calls MIX_Quit, which destroys every remaining native audio object.

⚠

No production caller. The header comment above DestroyMixer in AudioMixer.hpp records that nothing in the codebase calls it, and a search of the TARGET tree found calls only in the mixer's own files, in tests and in the two out-of-process harnesses under tools/audio. The generation machinery is therefore protection for a teardown that today happens only under test. As read, the selected device is closed at exit by the destructor of the ordinary namespace-scope static g_audioDevice (the native mixer pointer itself is never destroyed there), unlike the deliberately immortal holders elsewhere; anyone wiring a real shutdown (for example from Game disposal) must first ensure no live effect instance, dynamic instance, microphone or XACT object is using audio, because DestroyMixer serialises only the mixer pointer, not those lifetimes.

Generation counters and reloadable audio

A SoundEffectInstance records the generation when it creates a track. GetLiveTrackHandle and DestroyTrackSafe compare the recorded value with GetMixerEngineGeneration() before touching the track; a mismatch means DestroyMixer already freed it, so the pointer is cleared and treated as “no track” instead of being stopped or destroyed a second time. A SoundEffect may survive mixer teardown too: the SDL3 MixerAudio object keeps its own source (path, encoded bytes, or raw bytes plus format), a native pointer and the generation it belongs to, and GetCurrentNative reloads the native audio under the current generation, recreating the mixer if needed, rather than dereferencing a pointer MIX_Quit destroyed. Never hand a raw native track or audio pointer across a generation boundary. The CnaMixer facade has no such boundary: its generation is the constant 1.

Deferred track destruction

SDL3_mixer calls a track's stopped callback while holding the track's own stream lock, so destroying that track from inside the callback would free the lock owner. The SDL3 MixerEngine.cpp therefore gives each track a context (kept in the track's property set, freed by its cleanup function) with atomic flags; DestroyMixerTrack from within the stopped callback pushes the context onto a lock-free intrusive list, and the list is drained at the next safe engine entry (CreateMixerTrack and BeginMixerEngineShutdown). During whole-mixer shutdown the callback does not enqueue at all, because the native destroy loop owns the track immediately afterwards. CnaMixer applies the same rule with a plain vector: a track destroyed from one of its own callbacks, or while a render pass walks the list, leaves the mix at once and is freed by DrainDeferred; capacity for every track is reserved in CreateTrack on the caller's thread so moving one to the deferred list during a mix never allocates on the audio thread. The post-mix callback holders in the SDL3 facade (mutex and context owner) are deliberately immortal, because an output device can still be running while unrelated statics are destroyed; replacing or removing the post-mix callback is documented as a callback barrier.

ALSA: one immortal engine

The CnaMixer MixerEngine.cpp keeps one process-wide Engine (recursive mutex, CnaMixer, selected device, scratch buffer, error string) allocated with new and never freed. EnsureMixer creates the selected device, opens it asking for float stereo at 44,100 Hz (the device may answer otherwise), creates the mixer at the negotiated rate, and then starts the device; the device's first callback waits for the engine lock, so the mixer is complete before it runs. Every facade call, and the device callback, holds the same recursive mutex, which is why MixerLock is recursive-safe and why the callback must not re-lock DSP state. The callback renders float stereo through CnaMixer::Render in chunks of up to 2,048 frames and converts to the negotiated representation: stereo as it is, a mono device receives the average of left and right, channels beyond the second stay silent, and signed-16 output is clamped and rounded. An atexit hook registered on first use closes the device before unrelated statics are destroyed, and it deliberately does so without the engine lock because Close waits for a callback that may itself be waiting for it. Order inside the mixer is fixed: resample (linear interpolation) → track gain → the track's mix callback → add at master gain → the post-mix callback; a track that runs out fires its stopped callback after its last audio has passed through the mix callback. This lifecycle differs from SDL3's, so a change to the shared MixerEngine.hpp contract needs both implementations checked.

FrameworkDispatcher::Update and re-entrancy

FrameworkDispatcher::Update copies the registered dynamic-stream pointers under StreamsMutex, releases the mutex, runs each instance's Update, then removes disposed or null entries under the mutex again; it then calls Microphone::CheckAllBuffers and MediaPlayer::Update, raises the pending active-song and media-state notifications, and updates TouchPanel when a touch device exists. The unlocked Update matters: a BufferNeeded handler may dispose its own stream, and stopping the stream locks StreamsMutex to unregister it, so holding a non-recursive mutex across the call would deadlock (FrameworkDispatcherTests targets that case). Treat every audio callback as re-entrant into public audio code. A derived game whose Update does not call Game::Update also skips this pump.

Tests and safe change route

All test files below are under modules/audio/tests; case counts are static TEST, TEST_F and TEST_P definitions read at this snapshot (parameterised suites run more cases), and none was executed for this page.

File (definitions)What it targetsWhere it is compiled
SoundEffectTests.cpp (61), SoundEffectInstanceTests.cpp (133), DynamicSoundEffectInstanceTests.cpp (57)The XNA facade over SDL3_mixer track handlesSDL3 audio only
OfflineAudioRendererTests.cpp (17)Measured output of a real SDL3_mixer pipeline rendered to memory (frequency, RMS, gain composition, resampling)SDL3 audio only
AudioMixerTests.cpp (8), AudioMixerPlatformTests.cpp (2)Negotiated mixer format and spec override, balanced device-open failure and init/destroy cycles, the selected-device-drives-memory-mixer contract; out-of-process harnesses for no-hardware behaviour and for destroying the mixer with an active static or dynamic voiceSDL3 audio only; AudioMixerTests.cpp also excluded on Windows, Emscripten, Android and iOS
CnaMixerTests.cpp (24)CnaMixer driven directly with sample-exact data and no device or thread: silence, mono doubling, gain before the mix callback and master gain after it, linear resampling, loops and loop regions, pause, deferred destruction from a stopped callback, streams, WAV, Ogg Vorbis, MP3 and FLAC decodingALSA audio only (the file is guarded by CNA_AUDIO_PLATFORM_ALSA)
CnaMixerXnaTests.cpp (7)The XNA classes over CnaMixer and an ALSA null device: fire-and-forget, instance lifecycle, looping, dynamic instance, file formats, and one recording case that plays to ALSA's file device and measures pitch and levelALSA audio
AudioDeviceConformanceTests.cpp (4), IAudioDeviceTests.cpp (6), AudioPlatformSelectionTests.cpp (1)The contract on a small deterministic double (IAudioDeviceTests) and on every compiled implementation (AudioDeviceConformanceTests): open without start, Start/Stop/Close as callback barriers, refused invalid/duplicate/closed operations, NULL pacing (NullAudioDeviceTests.WorkerIsPacedInsteadOfSpinningAsFastAsPossible); the suite is parameterised over Null plus SDL3 and/or ALSA when compiledEvery build
Sdl3AudioDeviceTests.cpp (4), Sdl2AudioDeviceTests.cpp (1), AlsaAudioDeviceTests.cpp (6)Per-transport negotiation, paused Open, complete frames, barriers, and for ALSA the null and file PCMs, unknown-device failure and the CNA_AUDIO_DEVICE defaultOnly under the matching selection; the SDL2 test is its own executable
FrameworkDispatcherTests.cpp (3)Empty pump, initial state, no deadlock when BufferNeeded disposes the instanceEvery build
WavDecoderTests.cpp (19), WavWrapperTests.cpp (6), XactParserTests.cpp (47), XactParserFuzzTests.cpp (3)Container and codec parsers, independent of any deviceEvery build
AlsaAudioRecordingDeviceTests.cpp (6), Sdl3AudioRecordingDeviceTests.cpp (5), IAudioRecordingDeviceTests.cpp (7), MicrophoneTests.cpp (39)Capture contract, providers and the XNA Microphone facade; MicrophoneTests expects enumeration under SDL3 and ALSA and an empty list under SDL2 and NULLALSA and SDL3 files by selection; the contract and Microphone files in every build
SoundEffectDurationTests.cpp (3)Duration is a property of the data and is reported on every audio selection, including the mixer-less onesEvery build

cmake/UnitTests.cmake decides those compile conditions. For a non-SDL3 audio selection it filters out the SDL3 device and recording tests, AudioMixerPlatformTests and the mixer-facade suites that read SDL3_mixer track handles (AudioMixerTests, CueTests, DynamicSoundEffectInstanceTests, OfflineAudioRendererTests, SoundBankTests, SoundEffectInstanceTests, SoundEffectTests, WaveBankTests, and AudioCategoryTests unless the selection is ALSA, whose mixer the public-API category test can use). Consequently the 133-definition instance suite does not run under ALSA; XNA-facade coverage there rests on CnaMixerXnaTests, CnaMixerTests, AudioCategoryTests and the microphone suites. The same file registers CnaAudioPlatformTests (the implementation-neutral device, selection and mixer-platform filter, shuffled and repeated three times, with SDL_AUDIODRIVER=dummy and both ALSA device variables set to null), the script-mode CnaAudioPlatformSelection_* cases for DEFAULT SDL3 SDL2 NULL OPENAL WASAPI ALSA BOGUS, CnaSdl2AudioDeviceTests under SDL2, and for ALSA CnaAudioAlsaTests and CnaAudioAlsaRecordingTest. The out-of-process harness executables are declared in cmake/Harnesses.cmake and their sources are under tools/audio. The workflow .github/workflows/platform-ci.yml declares cells that select SDL3 audio (two cells), SDL2 audio (one) and NULL audio (the Headless and Terminal platforms), plus SDL-free X11 cells for NULL and for ALSA audio, the ALSA cell running a filtered CnaAudioTests selection on ALSA's null device; that is what the file configures, not a record of a run.

Safe change route. For an XNA playback change start with SoundEffectTests.cpp, SoundEffectInstanceTests.cpp and OfflineAudioRendererTests.cpp, choose a fixture that examines decoded output rather than only a property transition, and add CnaMixerXnaTests.cpp when the ALSA mixer is affected. For callbacks and lifetimes run AudioMixerTests, CnaMixerTests, the conformance suite and the device-specific barrier tests, with AddressSanitizer where available. After changing the CMake selection configure at least SDL3, ALSA and NULL (plus SDL2 where its dependencies exist), because conditional compilation hides whole implementations. Before editing a callback write down which thread calls it, which mutex it holds, whether it may allocate and which owner outlives it; before editing disposal check explicit Dispose, move, process shutdown and SDL3 mixer recreation; before changing the device interface exercise every selected transport even though only SDL3 and ALSA back full XNA playback.

✎

What this evidence does not show. ALSA tests use the null and file PCMs so they need no speakers; nothing here establishes behaviour on a user's physical hardware, on any particular PulseAudio or PipeWire configuration, or capture from a real microphone. The XACT (AudioEngine, WaveBank, SoundBank, Cue), music (MediaPlayer, Song) and microphone internals are only touched on this page where they meet the mixer; they need dedicated chapters and are not claimed here as fully documented.

Ownership rules that cross this module are collected on the ownership map; the thread and callback contracts on the thread and callback map; the shutdown-order debugging route on Debug shutdown and lifetime behavior; and the surrounding architecture on Audio and input architecture. Platform-side sibling material is under Platform backends, and the input half of the frame is Input internals.

Source reading order

  1. MixerEngine.hpp and IAudioDevice.hpp — separate the voice and mixer contract from the byte transport.
  2. cmake/AudioPlatformSelection.cmake, the audio CMakeLists.txt and the modules CMakeLists.txt (search for SOUND_ENABLED) — verify selection, source filtering and the meaning of SOUND_ENABLED.
  3. SoundEffect.cpp and SoundEffectInstance.cpp — source ownership, track creation, callbacks, the fire-and-forget queue and the dispose and move rules.
  4. SDL3 AudioMixer.cpp and SDL3 MixerEngine.cpp — lazy native creation, generation, reloadable audio and deferred destruction.
  5. CnaMixer MixerEngine.cpp, CnaMixer.cpp and AlsaAudioDevice.cpp — the own-mixer lock, float rendering, mix order and the PCM worker.
  6. Sdl3AudioDevice.cpp, Sdl2AudioDevice.cpp and NullAudioDevice.cpp — compare the actual callback and Stop barriers.
  7. FrameworkDispatcher.cpp — the cross-module stream update and its re-entrancy obligation.

The same subsystem is explained at four altitudes. These are the neighbouring pages at each one.

Tests and validation
Test architecture