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:
YourWishesandClaude Sonnet 5 committed 2026-09-02 14:10:36 -05:00
1 parent fc1fc9f170
commit b564d0b1c1
17 files changed
+822 -70

No files matched your search

+4 -12
View File
@@ -11,15 +11,7 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME}
)
# No hardware MP3 decoder used on Dolphin (see audiostreammp3decoder.h's
# own comment on why libogc's MP3Player wrapper isn't a fit) - use the
# same shared libmad-based software backend as Linux, 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. Unlike Linux, libmad here comes bundled with the
# devkitPPC/libogc toolchain itself (see cmake/targets/dolphin.cmake's
# own "mad" link entry) rather than via find_package() - unnecessary
# for, and not reliable when cross-compiling.
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
"${DUSK_SOURCES_DIR}/dusk/audio/stream/audiostreammp3decodersw.c"
)
# own comment on why libogc's MP3Player wrapper isn't a fit) - uses the
# shared libmad-based software backend + decode-ahead ring in
# src/duskmad instead (also used by Linux), see that directory's own
# CMakeLists.txt for how it's sourced/linked.
+44 -18
View File
@@ -35,12 +35,21 @@ errorret_t audioStreamDolphinInit(audiostream_t *stream) {
stream->platform.volumeLeft = 0;
stream->platform.volumeRight = 0;
errorChain(audioStreamMp3RingInitIfNeeded(stream));
errorOk();
}
errorret_t audioStreamDolphinDispose(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 ASND/buffer cleanup
// below.
errorChain(audioStreamMp3RingDisposeIfNeeded(stream));
ASND_StopVoice(stream->platform.voiceId);
memoryFree(stream->platform.buffer[0]);
memoryFree(stream->platform.buffer[1]);
@@ -53,25 +62,15 @@ errorret_t audioStreamDolphinBuffer(audiostream_t *stream) {
const size_t totalFrames = audioStreamGetTotalFrames(stream);
// endFrame is this pass's stop point - the current loop segment's end,
// or the true end of the clip if not looping. Same math as PSP/Linux -
// see their own comments.
size_t endFrame = totalFrames;
if((stream->state & AUDIO_STREAM_STATE_LOOPING) && stream->loopStart >= 0) {
endFrame = mathMin(
(size_t) (stream->loopStart * stream->sampleRate), totalFrames
);
}
const size_t startFrame = mathMin(stream->startFrame, totalFrames);
stream->startFrame = 0;
stream->seeking = false;
// 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/Linux.
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);
float_t leftFactor, rightFactor;
audioStreamGetPanFactors(stream, &leftFactor, &rightFactor);
@@ -89,16 +88,32 @@ errorret_t audioStreamDolphinBuffer(audiostream_t *stream) {
stream->platform.nextBufferIndex = 0;
stream->platform.primed = false;
// 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 (initial
// play, explicit seek, or loop restart), not just once at stream
// teardown. Blocks until that's guaranteed and resets the ring to empty
// for this fresh pass.
errorChain(audioStreamMp3RingStopIfNeeded(stream));
errorChain(audioStreamSeek(stream, startFrame));
stream->platform.position = startFrame;
stream->platform.endFrame = endFrame;
// Launch this pass's decode thread now that position/endFrame are
// finalized - it starts filling the ring immediately in the background,
// ahead of audioStreamDolphinFeed() below even asking for anything.
errorChain(audioStreamMp3RingStartIfNeeded(stream, startFrame, endFrame));
// Prime only the first buffer here - the second window can't be queued
// back-to-back with this one, since ASND_SetVoice() leaves VOICE_UPDATE
// set until its own audio-DMA-interrupt tick picks it up, and
// ASND_AddVoice() refuses to queue anything while that's still pending
// (see audioStreamDolphinFeed()'s own comment). Later polls queue
// everything after this.
// everything after this. For MP3 streams this first call will likely
// find the ring still empty (the decode thread just started) and do
// nothing - the next few audioStreamDolphinIsFinished() polls pick it
// up once decode has caught up.
errorChain(audioStreamDolphinFeed(stream));
errorOk();
@@ -138,9 +153,20 @@ errorret_t audioStreamDolphinFeed(audiostream_t *stream) {
uint8_t *buffer = stream->platform.buffer[stream->platform.nextBufferIndex];
size_t framesRead = 0;
errorChain(audioStreamRead(
stream, (int16_t *) buffer, 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().
errorChain(audioStreamReadForPlayback(
stream, (int16_t *) buffer, framesToRead, &framesRead, &notReadyYet
));
if(notReadyYet) {
// Decode hasn't caught up to a full window yet - not an error, just
// try again next poll.
errorOk();
}
const size_t bytesRead = framesRead * frameSize;
const size_t alignedBytes = (bytesRead + 31) & ~((size_t) 31);
+25 -7
View File
@@ -7,6 +7,7 @@
#pragma once
#include "error/error.h"
#include "audiostreammp3ring.h"
typedef struct audiostream_s audiostream_t;
@@ -63,13 +64,20 @@ typedef struct {
// call for that pass's first (ASND_SetVoice()) window.
int32_t volumeLeft;
int32_t volumeRight;
// Background decode-ahead ring, shared with Linux - 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;
} audiostreamdolphin_t;
/**
* Initializes the GameCube/Wii-specific playback state of an audio stream:
* allocates an ASND voice slot (ASND_GetFirstUnusedVoice()) and this
* stream's two fixed-size double-buffer windows (sized from its channel
* count, AUDIO_DOLPHIN_WINDOW_FRAMES frames each).
* count, AUDIO_DOLPHIN_WINDOW_FRAMES frames each). 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.
@@ -78,8 +86,10 @@ errorret_t audioStreamDolphinInit(audiostream_t *stream);
/**
* Disposes the GameCube/Wii-specific playback state of an audio stream:
* stops its voice (releasing the slot back to ASND_GetFirstUnusedVoice())
* and frees its two buffers.
* 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 stops the voice (releasing the
* slot back to ASND_GetFirstUnusedVoice()) and frees its two buffers.
*
* @param stream The audio stream to dispose.
* @return Error state if any.
@@ -90,10 +100,14 @@ errorret_t audioStreamDolphinDispose(audiostream_t *stream);
* Starts a new playback pass: seeks the stream's decode position to
* stream->startFrame, determines this pass's loop-segment end, resolves
* this pass's volume/pan, stops whatever the voice was doing before (so a
* fresh pass never plays out stale queued windows from the last one), and
* primes it via audioStreamDolphinFeed() - once for the first
* (ASND_SetVoice()) window, once more to also queue the second
* (ASND_AddVoice()) window ahead of time.
* fresh pass never plays out stale queued windows from the last one). 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 primes the first window via audioStreamDolphinFeed() - for MP3
* streams this may find the ring still empty and do 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.
@@ -110,6 +124,10 @@ errorret_t audioStreamDolphinBuffer(audiostream_t *stream);
* ASND_AddVoice()). A no-op once this pass's 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.
*
* @param stream The audio stream to feed.
* @return Error state if any.
*/
@@ -16,4 +16,4 @@
// see that header's own documentation for the actual struct/interface.
// Conveniently, libmad ships as part of libogc itself (gc/mad.h) rather
// than needing to be fetched separately, unlike on Linux.
#include "audio/stream/audiostreammp3decodersw.h"
#include "audiostreammp3decodersw.h"