Songs, the media library and video: the public contract

CNA snapshot 3b43bd86  ·  Deep Dives › Input, audio, media & services  ·  source links pinned to 3b43bd86

✓

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 3b43bd86; unless a sentence says otherwise, nothing here was built or executed. Checked by reading modules/media, the Song content reader and the tag parser at c6dec91f and re-checked at 3b43bd86; the two fragments were syntax-checked with g++ -fsyntax-only against the headers at b0e97bb1, and the declarations they use are unchanged at 3b43bd86. Nothing was executed; decoding on the excluded video targets and real audio output remain unverified.

Microsoft::Xna::Framework::Media in CNA is no longer the thin shell older counts described: it holds file-backed songs and libraries, playlists, a queued music player, visualization data, picture libraries and video that decodes wherever the optional FFmpeg backend is built. This page states the public contract a game can rely on, with the cases where CNA follows XNA against FNA, where a file name resolving is not the same as a file playing, and where the platform decides. The scan, queue and decoder internals are traced for maintainers on Media internals and the FFmpeg video boundary; the task guides are Tutorial 15, Tutorial 122 and Video Playback.

Why audio and media form a cycle

cna_audio and cna_media depend on each other on purpose. FrameworkDispatcher::Update lives in the audio module, because the dynamic-stream list it walks is audio state, yet it advances MediaPlayer and raises the deferred media events; in the other direction MediaPlayer and VideoPlayer play through the audio mixer. The CMake files declare this static-archive cycle and let the linker repeat the archives. Removing either edge would mean moving the dispatcher and the playback responsibilities, so a change on one side needs a review of the other.

The Song contract

A Song is created three ways: by ContentManager (a loose music file or an XNB/CNB reference), by a MediaLibrary scan, or directly (Song.cpp).

  • Construction. The CNAEXT constructor Song(fileName, name) throws System::IO::FileNotFoundException naming the path when the file does not exist, including a path the platform cannot even represent. Song::FromUri(name, uri) returns a heap Song* the caller owns. It accepts a plain path or a file: URI; a relative plain path is resolved against the title directory (the configured title path, else the platform's base path), as XNA combines it with TitleLocation.Path, never against the working directory, while an absolute path is kept as written. It refuses every other scheme with InvalidOperationException ("Only local file URIs are supported for now.", FNA's message); it strips the query and fragment before percent-decoding, so an encoded %3F or %23 stays part of the file name, and it turns file://host/path into a UNC path rather than silently dropping the host (only an empty host or localhost means this machine). The System::Uri overload uses the URI's original string, which keeps relative URIs usable.
  • Library links. Album, Artist and Genre are non-owning pointers, null for a directly constructed song and filled only by a library scan from the library's own hierarchy.
  • Ratings. IsRated and the 0–10 Rating come from an ID3v2 POPM frame or a Vorbis RATING comment. A source rating of zero means "unrated" in both, so it leaves IsRated false rather than reporting a rating of 0; a non-zero POPM byte maps to round(byte × 10 / 255) clamped to 1–10, and a Vorbis value is read on a 0–100 scale (round(value / 10) clamped to 1–10; a non-numeric value is ignored). Vorbis RATING has no standard scale, so a tagger that writes 0–5 or 0–10 values is misread: RATING=5 becomes a Rating of 1. The mapping is CNA's policy, not an XNA rule: XNA defines only the 0–10 property.
  • Protection. IsProtected is always false: the library scan indexes only plain, unencrypted containers, so no protected song can exist.
  • Mutable counters. Duration and PlayCount have CNAEXT setters and change during playback: playing replaces the duration with the one the decoder reports and increments the play count, and MediaPlayer::Stop() resets play counts across the current queue. Which object receives those writes depends on the entry point, as traced on Media internals: the queue.
  • Identity. The CNAEXT getHandle() returns the path the song plays from. Equals compares handles and GetHashCode hashes the handle, so two equal songs always hash alike; that deliberately avoids FNA's inconsistency of an identity hash paired with a value comparison. ToString() returns the song's name, as the decompiled XNA 4.0 Song.ToString() does (return Name; after a disposed check); CNA's source comment now cites that assembly instead of inferring the format from Album, Artist, Genre and Playlist.
  • Disposal. A disposed Song refuses its properties, Equals, GetHashCode and ToString with ObjectDisposedException, as XNA's does, and a disposed argument equals only itself. MediaPlayer::Play refuses a disposed song with InvalidOperationException before the queue changes. A song loaded through ContentManager shares its manager's ownership with every copy, so Unload() disposes the copies a game still holds as well. The library types (Album, Artist, Genre, Playlist, Picture, PictureAlbum, the collections and MediaLibrary itself) apply the same guard; disposing a library does not dispose the views taken from it.
std::unique_ptr<Song> intro(Song::FromUri("Intro Theme", "Content/Audio/intro.ogg"));
MediaPlayer::Play(intro.get());   // the player queues its own copy; the caller still owns intro
// The relative path is resolved against the title directory, not the working directory.

Syntax-checked against the headers at b0e97bb1 with <memory> and the Media header included (not executed); the comment line was added afterwards.

FNA is not the complete API oracle

FNA's Song omits Album, Artist, Genre and ToString(), all of which are part of the XNA 4.0 surface. Reviews that compared CNA only with FNA missed the difference; a comparison with the recorded XNA 4.0 API data found it, and CNA follows XNA. API parity with FNA and API parity with XNA are separate claims, and where they differ the XNA surface is the target.

A name that resolves is not a file that plays

Three lists disagree at this snapshot, and the difference matters when a song is "found" but silent:

ListExtensionsWhere
Loose files ContentManager tries for a Song, in order.mp3, .ogg, .wav, .flac, .opus, .aac, .wmaSongTypeReader in ContentMediaLoaders.cpp
Files a MediaLibrary scan indexes.ogg, .oga, .mp3, .wav, .flac, .opusMediaLibraryIndex.cpp
What the mixers decodeWAV, Ogg Vorbis, MP3 and FLAC on both mixers; no AAC, WMA or Opus on eitherSDL3_mixer as configured by CNA; CNA's own mixer under ALSA

So a loose .aac, .wma or .opus file resolves to a Song and then fails to play, and the library scan, whose comment says it indexes only what the bundled mixer can play, still lists .opus files although CNA's configure switches SDL3_mixer's Opus decoder off and CNA's own mixer refuses Ogg Opus by name. A MediaPlayer::Play of an existing file that cannot be decoded does not throw: the player cleans up and leaves its state as it was, so check getStateProperty() rather than assuming the request reached the speakers. Treat the extension lists as naming rules, not as decoder support; re-encode AAC, WMA and Opus music to Ogg Vorbis, MP3 or FLAC.

MediaLibrary in one paragraph

A MediaLibrary is a synchronous, construction-time snapshot: its constructor scans the platform's music and pictures folders (SDL's user folders on SDL3, the XDG user directories under the Headless and Terminal platforms), builds the SongCollection, Album, Artist, Genre, Playlist and picture graphs it owns, and never rescans; there is no Refresh(), so files added later need a new library. Tags come from CNA's own parser (Vorbis comments, Opus tags, ID3v2.3/2.4 with Latin-1, UTF-16 and UTF-8 text, native FLAC comments and pictures), untagged files fall back to file name, parent folder and grandparent folder for title, album and artist, .m3u/.m3u8 playlists are read as UTF-8 with missing or escaping entries skipped, and album art prefers a folder image over embedded art. SavePicture() is the only write and creates Saved Pictures on first use; its stream overload keeps reading until the stream's advertised length or its end, so a stream that returns short reads is saved whole. Tests redirect the roots with an internal override (MediaLibraryPaths::SetMusicRootOverride) so they do not depend on the CI account's files. One lesson from this code is recorded on Media internals: the tag parser once read the track number correctly while the library dropped it when building the Song, which only a fixture with several distinct track numbers checked after library construction could expose; a parser-only test could not.

MediaPlayer: queue, modes and events

  • Ownership. MediaQueue::Add(Song*) adopts the pointer, while SongCollection and Playlist (read-only, as in XNA) only point into the library. To keep the queue from deleting library-owned songs, MediaPlayer::Play copies every song it is given and queues the copies.
  • Navigation. Without repeat, next and previous clamp at the ends; with IsRepeating the end wraps to the start. Shuffle chooses uniformly from the whole queue, so the same song can play twice in a row.
  • End of a song. In a build with a mixer, the mixer's stopped callback sets a flag that the next dispatcher update turns into advancing the queue. Without a mixer (the NULL audio platform) the player instead compares elapsed time with the duration of the queue's active song and ignores a zero duration. Every queued song is a copy made with the two-argument constructor (MediaPlayer::LoadSong), which carries no duration, and the mixer-less path never sets one, so in those builds a song never auto-advances or ends on its own: the state stays Playing until the game calls Stop or a navigation method.
  • Volume and mute. Volume is clamped to 0–1 and IsMuted is independent state: setting the volume while muted stores the new value for the next unmute, and only the mixer track receives zero gain while muted.
  • Events. MediaStateChanged and ActiveSongChanged are never raised from the audio thread; they are deferred to FrameworkDispatcher::Update() and coalesced to one raise per pump. Game pumps the dispatcher during its normal update, but a caller using the static media API outside Game (a tool, a test) must call FrameworkDispatcher::Update() itself, or songs never advance and no event fires.

Visualization data

MediaPlayer::GetVisualizationData() reads samples that the mixer's post-mix callback writes into a lock-free single-producer, single-consumer ring of std::atomic<float> with relaxed ordering, so the audio-thread writes and game-thread reads are defined C++ behaviour without locks, allocation or exceptions on the audio thread. A reader may see samples spanning two adjacent callback batches; the resulting one-frame imprecision is accepted. A 512-sample Hann-windowed radix-2 FFT produces XNA's 256 frequency bins (VisualizationFFT.cpp). The magnitudes are scaled by 2/N and the window's coherent gain of one half is deliberately left uncompensated, so by that arithmetic a full-scale sine at a bin centre peaks near 0.5 (the source comment describes the target as about 1.0, and the test asserts only that the peak lies in the tone's own bin and exceeds 0.1). XNA documented no normalization, so treat the values as relative. Disabled or not-yet-fed visualization returns zero-filled arrays. The enabled flag is assigned only from what actually happened when the mixer tap was installed or removed, so it never claims a callback that is absent; the media tests run with a working (dummy-driver) mixer, however, so that failed-installation branch is correct by construction rather than by test, as the test's own comment states.

Video and VideoPlayer

Where the FFmpeg backend was built, VideoPlayer decodes with FFmpeg, uploads each frame into a renderer texture and streams the audio through the mixer. The behaviours a caller sees:

  • Play() refuses a null video with ArgumentNullException and a video whose file no longer exists with ArgumentException (XNA's answer, where FNA throws an I/O exception), and it compares the Video's declared width, height and frame rate with the decoded stream and throws InvalidOperationException on a mismatch (with a one-frame-per-second tolerance on the rate). A stream whose coded resolution changes part-way is refused by the decoder with std::runtime_error rather than read past its smaller frames, because a Video has one fixed size.
  • VideoSoundtrackType is metadata only, in CNA as in FNA: no code path ducks or mutes a track because of it.
  • After disposal, Play, Stop, Pause, Resume, GetTexture, the two track switches, the State and PlayPosition getters and the IsLooped, IsMuted and Volume setters throw ObjectDisposedException("VideoPlayer"), the transport, state and configuration writes XNA guards; IsDisposed, Video and the other getters still answer. Dispose() itself is idempotent, unlike FNA's, because the C++ destructor also calls it and a second explicit disposal must not throw from inside a destructor. The Volume setter refuses a value outside 0–1 with ArgumentOutOfRangeException; NaN passes, as XNA's ordered comparisons let it.
  • Stop() keeps the Video, the decoder and the last decoded frame, as XNA's does; only Dispose() releases them. GetTexture() on a player that has never started a video, or whose last Play failed, throws InvalidOperationException (XNA's guard). Destroying a Video stops every player still associated with it, and that player's GetTexture() then returns null.
  • A track chosen on the Video itself (its SetAudioTrackEXT/SetVideoTrackEXT, even before any player exists) overrides the player's own preference for that video's playback without changing the player's preference for later videos. SetAudioTrackEXT and SetVideoTrackEXT select streams independently: a live audio switch reopens only the audio output at the new track's rate, and a genuine live video switch recreates the frame texture at the new track's size on every switch (ReconfigureVideoOutputForCurrentTrack), whether or not the dimensions differ, and does not seek, so decoding continues from the current position in the new stream. Re-selecting the current track or an out-of-range one does nothing. The queued audio is cleared on every close, even when no audio device or audio track is active, so samples cannot accumulate or leak into a later play.
VideoPlayer player;
player.Play(&introVideo);          // throws NotSupportedException in a build without the backend
player.SetAudioTrackEXT(1);        // switches audio without recreating the video output

Syntax-checked against the headers at b0e97bb1 with introVideo as a Video (not executed); Play and SetAudioTrackEXT keep those signatures at 3b43bd86.

Platform availability, then and now

The FFmpeg backend is optional (CNA_ENABLE_VIDEO=AUTO|ON|OFF) and is never built for Windows (MinGW and MSVC), Emscripten, Android or iOS. The alpha.1-era design excluded the video translation units on those targets, so using Video there failed at link time, and a native-Windows preprocessor guard in ContentManager.cpp did not match the CMake condition. Both are history at this snapshot: Video and VideoPlayer are always compiled, a small unavailable decoder keeps the API link-complete, ContentManager.cpp includes the video header unconditionally, and a file-backed video throws NotSupportedException at run time instead. The configure-time switch, the fixtures and what they prove are on Video Playback: platform availability and the FFmpeg boundary's validation section.

Evidence

Source and focused tests establish tag parsing, hierarchy construction, queue ownership, event timing, visualization state, video disposal and multi-track reconfiguration; FFmpeg fixture tests exercise decoding on a configured host. None of that establishes video on the targets where the backend is never built, nor does an extension list establish decoder support. Nothing on this page was executed.

The same subject is explained at several altitudes. These are the neighbouring pages at each one.