Skip to content
The CNA BibleCNA 0.1.0-alpha.1 Edition

Chapter 45 The Audio System

CNA parses XACT containers with its own XactParser. The default audio implementation plays through SDL3_mixer, but audio is now an independent build axis rather than an implied property of the window/input platform. It does not use Microsoft’s XACT engine or FNA’s FAudio. This choice yields usable sound effects, streaming, capture, and much of the XACT object model, but it also defines the limits: no auxiliary reverb bus, no native 3D graph, and one per-track cooked-callback slot shared by filtering and stereo crossfeed.

45.1 Audio implementation is a separate axis

CNA_AUDIO_PLATFORM accepts implemented values SDL3, SDL2, and NULL; the default is SDL3. It is independent of CNA_PLATFORM and renderer selection. A HEADLESS or TERMINAL program may retain SDL3 audio, while an SDL3 windowed program may select NULL for deterministic no-device behavior. The matching CNA_AUDIO_PLATFORM_* definition selects the implementation sources and links only the needed mixer edge. OPENAL, WASAPI, and ALSA are recognized reserved identifiers that fail configuration; they are not hidden aliases or implemented routes.

This separation is architectural, not a larger set of XNA audio APIs. The public SoundEffect, DynamicSoundEffectInstance, XACT, microphone, and Media-facing contracts remain the same. Capability, device enumeration, playback and capture behavior belong to the chosen audio implementation, while window/event/input behavior belongs to CNA::Platform::IPlatform and pixels belong to the active renderer.

45.2 Mixer architecture and lifetime

Under the SDL3 audio selection, the process-wide MIX_Mixer requests signed 16-bit stereo at 44.1 kHz and logs the format SDL3_mixer negotiates. Decoded MIX_Audio objects hold assets; each playback uses a MIX_Track for gain, rate, looping, and the cooked callback. SoundEffect owns decoded audio, while SoundEffectInstance controls a track.

Mixer creation is serialized. First use pins an SDL_INIT_AUDIO reference so mixer destruction cannot shut down SDL audio while a DynamicSoundEffectInstance still owns an SDL_AudioStream. A generation counter rejects stale tracks after DestroyMixer(), but production code does not call that function; mixer lifetime is effectively process lifetime.

45.3 SoundEffect and instances

SoundEffect is move-only. It can load a file, load a stream, or accept headerless 16-bit PCM. Supplying an entire RIFF/WAV file to a raw-buffer constructor is an error; FromStream(std::istream) is the separate path that expects a WAV container and returns a caller-owned pointer. GetSampleDuration and GetSampleSizeInBytes convert between PCM buffer length and time.

The vendored decoder configuration includes WAVE, AIFF, VOC, AU, FLAC, MP3, Ogg Vorbis, and MIDI routes. It does not make AAC, WMA, Opus, GME, tracker modules, or WavPack decodable. Availability still depends on the configured SDL3_mixer build.

1 std::vector<SharpRuntime::bytecs> pcm =
2 SynthesizeBeep(440.0f, 0.2f); // mono, headerless S16
3 SoundEffect beep(pcm, 44100, AudioChannels::Mono);
4 beep.Play(1.0f, 0.0f, 0.0f); // volume, pitch, pan

SoundEffectInstance exposes volume, pan, pitch, looping, state, transport controls, and Apply3D. Process-wide SoundEffect values—MasterVolume, DistanceScale, DopplerScale, and SpeedOfSound—affect all live instances.

Pitch uses 2pitch. Calling Apply3D latches the instance into its spatialized path. Reapply it when listener or emitter state changes. With normalized distance dn=d/DistanceScale, attenuation is one inside the unit distance and clamp(1/dn,0,1) outside. Pan projects displacement onto Forward×Up, not world X. Doppler uses F3DAudio-style velocity projections, clamps its factor to [0.5,4], and guards NaN. The final rate is 2pitch×doppler×DopplerScale.

This is a defined stereo approximation. It lacks elevation, HRTF, multi-speaker diffusion, and per-sample spatial filtering. The cooked callback crossfeeds channels for pan instead of simply muting the opposite source channel.

1 AudioListener listener;
2 listener.setPositionProperty(cameraPosition);
3 listener.setForwardProperty(cameraForward);
4 listener.setUpProperty(Vector3::Up);
5 listener.setVelocityProperty(cameraVelocity);
6
7 AudioEmitter emitter;
8 emitter.setPositionProperty(explosionPosition);
9 emitter.setVelocityProperty(Vector3::Zero);
10
11 explosion.Apply3D(listener, emitter);
12 explosion.Play();

45.4 Dynamic streaming

DynamicSoundEffectInstance accepts headerless S16 chunks and, through CNAEXT, normalized float chunks. BufferNeeded asks for more data; PendingBufferCount includes queued and submitted chunks. The instance owns its SDL_AudioStream; the mixer does not. An exhausted track remains available for later submissions. As in FNA, Stop(false) throws instead of draining gracefully.

1 DynamicSoundEffectInstance tone(44100, AudioChannels::Mono);
2 tone.BufferNeeded.Add(
3 [&tone](System::Object*, const System::EventArgs&) {
4 while (tone.getPendingBufferCountProperty() < 2) {
5 tone.SubmitBuffer(GenerateNextSineChunk(440.0f));
6 }
7 });
8 tone.Play();

Queue accounting once removed a whole chunk as soon as any of it had been consumed. The current algorithm computes one consumed-byte budget and removes a chunk only when that budget covers its full size. Same-format tests lock in this behavior. Under resampling, submitted sizes are source bytes while SDL_GetAudioStreamQueued reports the converted stream; those units may differ. Pending count is therefore not established as a sample-accurate playback clock across 22.05/44.1/48-kHz conversion. Frame-alignment validation for integer and float submissions is also open.

45.5 XACT objects

AudioEngine parses .xgs settings and owns associated SoundBank, WaveBank, and Cue relationships. Wave banks can be memory-resident or offset/packet streamed. SoundBank disposal stops its cues.

Cue implements created, preparing, prepared, playing, stopping, and stopped states, plus an independent pause flag. It advances fades and Runtime Parameter Control curves and supports Ordered, OrderedFromRandom, Random, RandomNoRepeats, and Shuffle variations. AudioCategory::SetVolume changes cues already playing, not only future cues. AudioEngine::Update() must run every frame to advance fades/RPC state and collect finished fire-and-forget cues.

1 AudioEngine engine("Content/Audio/GameAudio.xgs");
2 SoundBank sounds(&engine, "Content/Audio/Sounds.xsb");
3 WaveBank waves(&engine, "Content/Audio/Sounds.xwb");
4
5 engine.SetGlobalVariable("PlayerSpeed", currentPlayerSpeed);
6 sounds.PlayCue("footstep"); // bank owns this fire-and-forget cue
7
8 std::unique_ptr<Cue> loop(sounds.GetCue("engine_loop"));
9 loop->Play();
10 // ... once per frame ...
11 engine.Update();

A cue registers with its bank in the constructor and unregisters on disposal. Earlier code registered only on Play(), allowing an obtained-but-never-played cue to outlive a destroyed bank. The current lifetime covers the interval from GetCue() through cue disposal.

RendererDetails contains one hardcoded SDL3_mixer entry. It preserves the output renderer enumeration shape but does not enumerate physical audio devices.

45.6 Content formats and historical corrections

The XNB SoundEffectReader accepts mono/stereo PCM 16-bit directly and wraps PCM 8-bit, IEEE float 32-bit, MS-ADPCM 4-bit, and IMA-ADPCM 4-bit for SDL decoding. XMA2 is rejected. For XNA MS-ADPCM with cbSize=0, the wrapper inserts the standard seven coefficient pairs.

Historical note. User reports changed the test boundary Reports of high-pitched, distorted, or missing sound led to three independent corrections: compressed XACT/XNB formats were not decoded, stereo panning discarded the opposite channel, and pitch used a linear approximation instead of 2pitch. Prior tests happened to use pitch values 1, 0, and 1, where the formulas agree; a midpoint exposed the difference.

The resulting OfflineAudioRenderer drives SDL3_mixer’s decode, resample, properties, callbacks, and mix into memory without an audio device. It measured output frequency within 0.1% for 22.05, 44.1, 48, and 96 kHz sources declared at their correct rates. That result excludes SDL’s tested resampler route as the cause of that pitch report; it is not a perceptual quality oracle.

Memory-safety review also corrected a mixer-generation use-after-free, an XACT name-parser buffer overflow, and a WaveBank cache race between disposal and decoding. Two narrower crashes remain recorded: an intermittent late audio-filter failure and a process-teardown failure that depends on Cue/DynamicSoundEffectInstance cross-suite order. ASan and TSan perturb both reproductions. They are open findings, not evidence that every normal run crashes.

45.7 Deliberate and open limits

  • XACT REPLACE_QUIETEST instance policy is incomplete; queue and replace-oldest share behavior, and victim search is not filtered by category/same cue.

  • RPC curves targeting DSP presets are ignored; no DSP preset system exists.

  • Reverb and auxiliary sends are no-ops.

  • Parsed per-track filters can select low-pass or high-pass, not band-pass, following the inherited bit-decoding behavior.

  • InstancePlayLimitException exists but no production path throws it.

  • Production mixer teardown is implemented but unreachable.

The release-blocker entries for high pitch, failing loads, and distorted audio remain unchecked in the pinned plan_audio.md. The corrections narrow those reports but do not authorize this book to mark the campaign complete.

45.8 Microphone

Microphone enumerates SDL capture devices, exposes a process-lifetime default/device list, captures into a stream, and raises BufferReady. GetData drains available bytes. Sample-size/duration helpers use the device sample rate.

The intended BufferDuration range is 100–1000 ms in 10-ms increments. The setter checks the millisecond component, not total milliseconds, so 1,100 ms passes and the upper-bound branch is ineffective. Start() requests mono S16 at 44.1 kHz but marks the state Started even when device opening fails; a later zero-byte read cannot distinguish that failure from a healthy empty stream. Device refresh/hotplug, permission denial, returned-format validation, frame alignment, callback cadence, and concurrent stop/read remain open.

1 Microphone* mic = Microphone::getDefaultProperty();
2 if (mic == nullptr) return;
3
4 DynamicSoundEffectInstance monitor(
5 mic->getSampleRateProperty(), AudioChannels::Mono);
6 auto token = mic->BufferReady.Add(
7 [mic, &monitor](System::Object*, const System::EventArgs&) {
8 std::vector<SharpRuntime::bytecs> bytes(4096);
9 int count = mic->GetData(bytes);
10 if (count > 0) {
11 bytes.resize(count);
12 monitor.SubmitBuffer(bytes);
13 }
14 });
15
16 mic->Start();
17 monitor.Play();
18 // Before monitor leaves scope:
19 mic->Stop();
20 mic->BufferReady.Remove(token);

45.9 Evidence status

Source and focused tests establish the listed format routes, scalar 3D formulas, state and lifetime rules, XACT parsing bounds, and offline waveform measurements. They do not establish perceptual equivalence to XACT/FAudio, hardware capture behavior, resampled pending-count accuracy, or the unresolved release-blocker campaign. “Playback implemented” and “audio conformance complete” are therefore different claims at this revision.

Type at least three characters. Results are ranked by how often and where the words occur.