Add Dolphin MP3 decode-thread ring, share it with Linux, dedupe common code

Fixes the main-thread hitch reported on Dolphin (and, less severely,
elsewhere) whenever the MP3 decode buffer needed refilling:
audioStreamMp3Read() -> audioStreamMp3DecoderDecodeFrame() does real work
synchronously (asset I/O + libmad decode), previously called directly from
each platform's once-per-frame Feed(), blocking rendering for a frame on
every refill.

Adds a background decode-ahead ring (src/duskmad/audiostreammp3ring.c):
a one-shot-per-pass thread reads via the existing audioStreamRead() into a
ring buffer ahead of need; Feed() becomes a cheap, lock-protected drain
instead of a decode call. Reuses this project's own thread_t/threadmutex_t
(mutex+condvar) primitives, mirroring PSP's own background-thread-plus-ring
precedent. Fixes a real (if narrow) race in thread.c along the way:
threadHandler() reset thread->threadId outside the mutex it also used to
signal STOPPED, which a stop-then-immediately-restart pattern (needed once
per pass: play/seek/loop-restart) could hit as a stale-threadId assertion.

Initially built Dolphin-only, then generalized: the ring doesn't touch
ASND at all, so it was straightforward to share with Linux too, moving
both it and the libmad decoder into a new top-level src/duskmad/ - a
shared-capability directory in the same vein as src/duskgl or
src/dusknetwork, pulled in by whichever DUSK_TARGET_SYSTEM values need it
(linux/knulli, wii/gamecube) rather than PSP, which keeps its own
hardware sceMp3 decoder and ring untouched.

With both platforms now sharing real code, deduped what was left:
audioStreamComputeEndFrame() (loop-segment math, byte-identical between
Linux/Dolphin) moved to the generic audio/stream/audiostream.c, and
audioStreamMp3Ring*IfNeeded()/audioStreamReadForPlayback() wrappers (in
duskmad) collapse each platform's own `if(stream->type ==
AUDIO_STREAM_TYPE_MP3)` branches at every Init/Dispose/Buffer/Feed call
site into one check each, living in the ring module itself.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-09-02 14:10:36 -05:00
co-authored by Claude Sonnet 5
parent fc1fc9f170
commit b564d0b1c1
17 changed files with 822 additions and 70 deletions
+7 -10
View File
@@ -10,17 +10,14 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME}
audiostreamlinux.c
)
# No hardware MP3 decoder on Linux - use the shared libmad-based software
# backend (also used by Dolphin), sourced directly from src/dusk/audio
# rather than through its own unconditional CMakeLists.txt so platforms
# with a hardware decoder (PSP) never pull libmad in at all. See
# audiostreammp3decodersw.h's own comment on why libmad (GPL-licensed)
# replaced this project's original minimp3-based implementation.
# No hardware MP3 decoder on Linux - uses the shared libmad-based software
# backend + decode-ahead ring in src/duskmad instead (also used by
# Dolphin). See audiostreammp3decodersw.h's own comment on why libmad
# (GPL-licensed) replaced this project's original minimp3-based
# implementation. Linking stays here (rather than in src/duskmad itself)
# since Linux and Dolphin genuinely obtain libmad differently -
# find_package() here, the bundled devkitPPC/libogc toolchain copy there.
if(NOT libmad_FOUND)
find_package(libmad REQUIRED)
endif()
target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PUBLIC libmad::mad)
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
"${DUSK_SOURCES_DIR}/dusk/audio/stream/audiostreammp3decodersw.c"
)
+44 -12
View File
@@ -39,12 +39,20 @@ errorret_t audioStreamLinuxInit(audiostream_t *stream) {
errorThrow("Failed to open SDL2 audio device: %s", SDL_GetError());
}
errorChain(audioStreamMp3RingInitIfNeeded(stream));
errorOk();
}
errorret_t audioStreamLinuxDispose(audiostream_t *stream) {
assertNotNull(stream, "Stream cannot be NULL.");
// Must happen first: audioStreamMp3Dispose() (called right after this
// function returns, from the shared audioStreamDispose()) tears down
// stream->mp3's decoder/file - the decode thread must be fully parked
// before that, not just before this function's own SDL cleanup below.
errorChain(audioStreamMp3RingDisposeIfNeeded(stream));
SDL_CloseAudioDevice(stream->platform.device);
errorOk();
@@ -63,17 +71,11 @@ errorret_t audioStreamLinuxBuffer(audiostream_t *stream) {
stream->startFrame = 0;
stream->seeking = false;
size_t endFrame = totalFrames;
if((stream->state & AUDIO_STREAM_STATE_LOOPING) && stream->loopStart >= 0) {
endFrame = mathMin(
(size_t) (stream->loopStart * stream->sampleRate), totalFrames
);
}
// A seek (or a loop restart landing exactly on the loop end) can put
// startFrame at or past endFrame - e.g. seeking into an outro after the
// loop point. Play out to the true end of the buffer once instead, same
// as PSP.
if(startFrame >= endFrame) endFrame = totalFrames;
// This pass's stop point - the current loop segment's end, or the true
// end of the clip if not looping (or if startFrame already landed past
// the loop end, e.g. a seek into an outro). See
// audioStreamComputeEndFrame()'s own comment.
const size_t endFrame = audioStreamComputeEndFrame(stream, startFrame, totalFrames);
// Only an explicit seek discards whatever's still queued and jumps -
// a natural loop restart deliberately leaves the previous pass's tail
@@ -84,6 +86,17 @@ errorret_t audioStreamLinuxBuffer(audiostream_t *stream) {
if(seeking) {
SDL_ClearQueuedAudio(stream->platform.device);
}
// For MP3 streams, the background decode thread reads via the same
// stream->mp3 decoder/file audioStreamSeek() below is about to mutate -
// must be fully stopped first, every time a new pass begins. Unlike the
// SDL_ClearQueuedAudio() call above, this is unconditional regardless of
// the seeking flag: that flag only controls whether the SDL queue's
// leftover tail is discarded (for gapless natural loop restarts), not
// whether decode needs to re-seek - MP3 has no cheap seek either way,
// see audioStreamSeek()'s own comment.
errorChain(audioStreamMp3RingStopIfNeeded(stream));
errorChain(audioStreamSeek(stream, startFrame));
stream->platform.position = startFrame;
@@ -91,6 +104,11 @@ errorret_t audioStreamLinuxBuffer(audiostream_t *stream) {
stream->platform.passStartTicks = SDL_GetTicks();
stream->platform.passStartPosition = startFrame;
// Launch this pass's decode thread now that position/endFrame are
// finalized - it starts filling the ring immediately in the background,
// ahead of audioStreamLinuxFeed() below even asking for anything.
errorChain(audioStreamMp3RingStartIfNeeded(stream, startFrame, endFrame));
errorChain(audioStreamLinuxFeed(stream));
SDL_PauseAudioDevice(stream->platform.device, 0);
@@ -150,11 +168,25 @@ errorret_t audioStreamLinuxFeed(audiostream_t *stream) {
int16_t *chunk = memoryAllocate(framesToRead * frameSize);
size_t framesRead = 0;
errorret_t ret = audioStreamRead(stream, chunk, framesToRead, &framesRead);
bool_t notReadyYet = false;
// For MP3 streams, drains already-decoded PCM out of the background ring
// instead of decoding directly - the expensive part (asset I/O + libmad
// decode) already happened on the decode thread, off the main thread.
// See audiostreammp3ring.h's own top-of-file comment for why. For
// everything else, this is just a plain audioStreamRead().
errorret_t ret = audioStreamReadForPlayback(
stream, chunk, framesToRead, &framesRead, &notReadyYet
);
if(errorIsNotOk(ret)) {
memoryFree(chunk);
errorChain(ret);
}
if(notReadyYet) {
// Decode hasn't caught up to a full window yet - not an error, just
// try again next poll.
memoryFree(chunk);
errorOk();
}
const size_t bytesRead = framesRead * frameSize;
int queued;
+26 -5
View File
@@ -7,6 +7,7 @@
#pragma once
#include "error/error.h"
#include "audiostreammp3ring.h"
#include <SDL2/SDL.h>
typedef struct audiostream_s audiostream_t;
@@ -31,11 +32,19 @@ typedef struct {
// SDL_GetQueuedAudioSize() alone).
Uint32 passStartTicks;
size_t passStartPosition;
// Background decode-ahead ring, shared with Dolphin - only
// initialized/used for AUDIO_STREAM_TYPE_MP3 streams (PCM/WAV reads are
// cheap plain copies and don't need this). See audiostreammp3ring.h's
// own comment.
audiostreammp3ring_t mp3Ring;
} audiostreamlinux_t;
/**
* Initializes the Linux-specific playback state of an audio stream by
* opening an SDL2 audio device matching the stream's PCM format.
* opening an SDL2 audio device matching the stream's PCM format. For MP3
* streams, also initializes the background decode-ahead ring
* (audioStreamMp3RingInit()) - see audiostreammp3ring.h's own comment.
*
* @param stream The audio stream to initialize.
* @return Error state if any.
@@ -43,8 +52,10 @@ typedef struct {
errorret_t audioStreamLinuxInit(audiostream_t *stream);
/**
* Disposes the Linux-specific playback state of an audio stream, closing
* its SDL2 audio device.
* Disposes the Linux-specific playback state of an audio stream: for MP3
* streams, first fully stops and frees the background decode ring
* (audioStreamMp3RingDispose()) - must happen before the shared MP3 layer
* tears down its decoder/file - then closes the SDL2 audio device.
*
* @param stream The audio stream to dispose.
* @return Error state if any.
@@ -55,8 +66,14 @@ errorret_t audioStreamLinuxDispose(audiostream_t *stream);
* Starts a new playback pass: seeks the stream's PCM read cursor to
* stream->startFrame (clearing the SDL queue first if this is an explicit
* seek rather than a natural loop restart - see stream->seeking's own
* comment), determines this pass's loop-segment end, and queues the first
* window via audioStreamLinuxFeed().
* comment), determines this pass's loop-segment end. For MP3 streams, also
* stops the previous pass's background decode thread
* (audioStreamMp3RingStop(), which must complete before the seek below -
* see its own comment) and starts a fresh one for this pass
* (audioStreamMp3RingStart()) once position/endFrame are finalized. Finally
* queues the first window via audioStreamLinuxFeed() - for MP3 streams
* this may find the ring still empty and queue nothing yet; the next few
* polls pick it up once decode has caught up.
*
* @param stream The audio stream to output.
* @return Error state if any.
@@ -69,6 +86,10 @@ errorret_t audioStreamLinuxBuffer(audiostream_t *stream);
* advancing platform.position by however many frames were actually read.
* A no-op once platform.position has reached platform.endFrame.
*
* For MP3 streams, the PCM data comes from draining the background decode
* ring (audioStreamMp3RingDrain()) instead of decoding directly - see
* audiostreammp3ring.h's own comment for why.
*
* Kept as a small, bounded read/queue operation (rather than the whole
* pass at once) specifically so a long clip is never fully resident in
* memory at once - called both by audioStreamLinuxBuffer() (the first
+1 -1
View File
@@ -12,4 +12,4 @@
// header's own documentation for the actual struct/interface, including
// why libmad (GPL-licensed) was chosen over the MIT-licensed minimp3 this
// used to be.
#include "audio/stream/audiostreammp3decodersw.h"
#include "audiostreammp3decodersw.h"