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
+3 -1
View File
@@ -13,6 +13,7 @@ if(DUSK_TARGET_SYSTEM STREQUAL "linux" OR DUSK_TARGET_SYSTEM STREQUAL "knulli")
add_subdirectory(dusklinux)
add_subdirectory(dusksdl2)
add_subdirectory(duskgl)
add_subdirectory(duskmad)
elseif(DUSK_TARGET_SYSTEM STREQUAL "psp")
add_subdirectory(duskpsp)
@@ -26,5 +27,6 @@ elseif(DUSK_TARGET_SYSTEM STREQUAL "vita")
elseif(DUSK_TARGET_SYSTEM STREQUAL "wii" OR DUSK_TARGET_SYSTEM STREQUAL "gamecube")
add_subdirectory(duskdolphin)
add_subdirectory(duskmad)
endif()
+19
View File
@@ -7,6 +7,7 @@
#include "audiostream.h"
#include "assert/assert.h"
#include "util/math.h"
errorret_t audioStreamInit(audiostream_t *stream, assetentry_t *asset) {
assertNotNull(stream, "Stream cannot be NULL.");
@@ -154,6 +155,24 @@ void audioStreamGetPanFactors(
*outRight = pan < 0 ? (1.0f + pan) : 1.0f;
}
size_t audioStreamComputeEndFrame(
const audiostream_t *stream,
const size_t startFrame,
const size_t totalFrames
) {
assertNotNull(stream, "Stream cannot be NULL.");
size_t endFrame = totalFrames;
if((stream->state & AUDIO_STREAM_STATE_LOOPING) && stream->loopStart >= 0) {
endFrame = mathMin(
(size_t) (stream->loopStart * stream->sampleRate), totalFrames
);
}
if(startFrame >= endFrame) endFrame = totalFrames;
return endFrame;
}
void audioStreamSetVolume(audiostream_t *stream, const uint8_t volume) {
assertNotNull(stream, "Stream cannot be NULL.");
stream->volume = volume;
+21
View File
@@ -261,6 +261,27 @@ void audioStreamGetPanFactors(
float_t *outRight
);
/**
* Computes the stop frame for a playback pass starting at startFrame: the
* current loop segment's end (stream->loopStart, converted to frames and
* clamped to totalFrames) if looping is enabled and a loop start is set,
* or totalFrames itself otherwise - including when startFrame has already
* reached or passed the computed loop end (e.g. a seek landing in an
* outro past the loop point), in which case this pass plays out to the
* true end of the stream once instead of only the loop segment.
*
* @param stream The audio stream to compute this for.
* @param startFrame This pass's starting frame offset.
* @param totalFrames The stream's total decoded frame count
* (audioStreamGetTotalFrames()).
* @return This pass's stop frame offset.
*/
size_t audioStreamComputeEndFrame(
const audiostream_t *stream,
const size_t startFrame,
const size_t totalFrames
);
/**
* Sets the playback volume of the given audio stream.
*
+1 -1
View File
@@ -109,9 +109,9 @@ bool_t threadShouldStop(thread_t *thread) {
threadMutexLock(&thread->stateMutex);
thread->state = THREAD_STATE_STOPPED;
thread->threadId = 0;
threadMutexSignal(&thread->stateMutex);
threadMutexUnlock(&thread->stateMutex);
thread->threadId = 0;
return NULL;
}
#endif
+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"
+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"
+33
View File
@@ -0,0 +1,33 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
# libmad-based MP3 decode, shared by every platform that doesn't have (or
# use) a hardware MP3 decoder - currently Linux/knulli and Dolphin
# (Wii/GameCube), both via the same software decoder and background
# decode-ahead ring (audiostreammp3decodersw.c, audiostreammp3ring.c). See
# src/CMakeLists.txt for which DUSK_TARGET_SYSTEM values pull this
# directory in at all - PSP uses its own hardware sceMp3 decoder instead
# (src/duskpsp/audio/audiostreammp3decoder.c) and never references
# anything here.
#
# Note: libmad is GPL-licensed, unlike the rest of this project - see
# audiostreammp3decodersw.h's own comment. Linking/finding libmad itself
# stays with each platform's own audio CMakeLists.txt (src/dusklinux/audio,
# cmake/targets/dolphin.cmake) rather than living here, since Linux and
# Dolphin genuinely obtain it differently (find_package() vs the bundled
# devkitPPC/libogc toolchain copy) - this file only owns the shared source.
# Includes
target_include_directories(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
${CMAKE_CURRENT_LIST_DIR}
)
# Sources
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
audiostreammp3decodersw.c
audiostreammp3ring.c
)
@@ -6,8 +6,8 @@
*/
#include "audiostreammp3decodersw.h"
#include "audiostream.h"
#include "audiostreammp3.h"
#include "audio/stream/audiostream.h"
#include "audio/stream/audiostreammp3.h"
#include "assert/assert.h"
#include "util/memory.h"
+303
View File
@@ -0,0 +1,303 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "audiostreammp3ring.h"
#include "audio/stream/audiostream.h"
#include "assert/assert.h"
#include "util/memory.h"
#include "util/math.h"
#include <unistd.h>
errorret_t audioStreamMp3RingInit(audiostream_t *stream) {
assertNotNull(stream, "Stream cannot be NULL.");
assertTrue(stream->type == AUDIO_STREAM_TYPE_MP3, "Stream is not MP3.");
audiostreammp3ring_t *ring = &stream->platform.mp3Ring;
const size_t frameSize = stream->channels * sizeof(int16_t);
ring->data = (int16_t *) memoryAllocate(AUDIO_MP3_RING_FRAMES * frameSize);
ring->scratch = (int16_t *) memoryAllocate(AUDIO_MP3_RING_STEP_FRAMES * frameSize);
ring->readPos = 0;
ring->writePos = 0;
ring->filled = 0;
ring->reachedEnd = false;
ring->failed = false;
ring->startFrame = 0;
ring->endFrame = 0;
threadMutexInit(&ring->lock);
threadInit(&ring->thread, audioStreamMp3RingThreadFunc);
errorOk();
}
errorret_t audioStreamMp3RingDispose(audiostream_t *stream) {
assertNotNull(stream, "Stream cannot be NULL.");
assertTrue(stream->type == AUDIO_STREAM_TYPE_MP3, "Stream is not MP3.");
audiostreammp3ring_t *ring = &stream->platform.mp3Ring;
// Must fully park the decode thread before freeing anything it might
// still be touching, or before the caller lets the shared MP3 layer
// tear down stream->mp3's decoder/file out from under it.
threadStop(&ring->thread);
threadMutexDispose(&ring->lock);
memoryFree(ring->data);
memoryFree(ring->scratch);
ring->data = NULL;
ring->scratch = NULL;
errorOk();
}
errorret_t audioStreamMp3RingStop(audiostream_t *stream) {
assertNotNull(stream, "Stream cannot be NULL.");
assertTrue(stream->type == AUDIO_STREAM_TYPE_MP3, "Stream is not MP3.");
audiostreammp3ring_t *ring = &stream->platform.mp3Ring;
// Blocking - guarantees the decode thread is fully parked (not mid-read)
// before the caller runs audioStreamSeek(), which mutates the same
// stream->mp3 decoder/file this thread reads via audioStreamRead(). A
// safe no-op if the thread was never started or already finished this
// pass on its own (reached endFrame).
threadStop(&ring->thread);
threadMutexLock(&ring->lock);
ring->readPos = 0;
ring->writePos = 0;
ring->filled = 0;
ring->reachedEnd = false;
ring->failed = false;
threadMutexUnlock(&ring->lock);
errorOk();
}
errorret_t audioStreamMp3RingStart(
audiostream_t *stream, size_t startFrame, size_t endFrame
) {
assertNotNull(stream, "Stream cannot be NULL.");
assertTrue(stream->type == AUDIO_STREAM_TYPE_MP3, "Stream is not MP3.");
audiostreammp3ring_t *ring = &stream->platform.mp3Ring;
// Finalized here, before the thread is launched - thread creation itself
// is the happens-before edge that makes these writes visible to the new
// thread, so no lock is needed around this handoff.
ring->startFrame = startFrame;
ring->endFrame = endFrame;
ring->thread.data = stream;
threadStartRequest(&ring->thread);
errorOk();
}
size_t audioStreamMp3RingDrain(
audiostream_t *stream,
int16_t *out,
size_t maxFrames,
bool_t *outDecodeDone
) {
assertNotNull(stream, "Stream cannot be NULL.");
assertNotNull(out, "Out buffer cannot be NULL.");
assertNotNull(outDecodeDone, "outDecodeDone cannot be NULL.");
assertTrue(stream->type == AUDIO_STREAM_TYPE_MP3, "Stream is not MP3.");
audiostreammp3ring_t *ring = &stream->platform.mp3Ring;
const size_t channels = stream->channels;
threadMutexLock(&ring->lock);
const size_t available = ring->filled;
const bool_t decodeDone = ring->reachedEnd || ring->failed;
// Wait for a full window rather than handing back a partial one, unless
// decode has genuinely nothing more to give this pass - keeps a
// platform's own output handoff cadence matching its previous
// whole-window-per-call behavior instead of fragmenting into many tiny
// calls just because decode happened to be a little behind at this exact
// poll.
if(available < maxFrames && !decodeDone) {
threadMutexUnlock(&ring->lock);
*outDecodeDone = false;
return 0;
}
const size_t toCopy = mathMin(available, maxFrames);
const size_t firstPart = mathMin(toCopy, AUDIO_MP3_RING_FRAMES - ring->readPos);
memoryCopy(out, ring->data + ring->readPos * channels, firstPart * channels * sizeof(int16_t));
if(toCopy > firstPart) {
memoryCopy(
out + firstPart * channels, ring->data,
(toCopy - firstPart) * channels * sizeof(int16_t)
);
}
ring->readPos = (ring->readPos + toCopy) % AUDIO_MP3_RING_FRAMES;
ring->filled -= toCopy;
threadMutexUnlock(&ring->lock);
*outDecodeDone = decodeDone;
return toCopy;
}
bool_t audioStreamMp3RingFailed(const audiostream_t *stream) {
assertNotNull(stream, "Stream cannot be NULL.");
assertTrue(stream->type == AUDIO_STREAM_TYPE_MP3, "Stream is not MP3.");
return stream->platform.mp3Ring.failed;
}
void audioStreamMp3RingThreadFunc(thread_t *thread) {
assertNotNull(thread, "Thread cannot be NULL.");
audiostream_t *stream = (audiostream_t *) thread->data;
audiostreammp3ring_t *ring = &stream->platform.mp3Ring;
const size_t channels = stream->channels;
// Captured once - only ever written by audioStreamMp3RingStart() before
// this thread was launched (a platform's Feed() advances its own
// position field as it drains the ring, never these). Tracks its own
// progress in a thread-local counter rather than re-reading endFrame
// repeatedly, though it wouldn't change mid-pass either way.
const size_t startFrame = ring->startFrame;
const size_t endFrame = ring->endFrame;
size_t decoded = 0;
while(!threadShouldStop(thread)) {
threadMutexLock(&ring->lock);
const size_t room = AUDIO_MP3_RING_FRAMES - ring->filled;
threadMutexUnlock(&ring->lock);
if(room == 0) {
// Never sleeps while holding the lock - re-checks threadShouldStop()
// immediately after waking, bounding a stop request's worst-case
// wait here to one poll interval, not a whole ring's worth of idle.
usleep(AUDIO_MP3_RING_POLL_MICROS);
continue;
}
if(startFrame + decoded >= endFrame) break;
const size_t framesRemaining = endFrame - (startFrame + decoded);
const size_t step = mathMin(
mathMin(room, framesRemaining), (size_t) AUDIO_MP3_RING_STEP_FRAMES
);
size_t framesRead = 0;
// The expensive call (asset I/O + libmad decode) - deliberately
// outside any lock, so it never blocks the main thread's own
// audioStreamMp3RingDrain() calls while it runs.
errorret_t ret = audioStreamRead(stream, ring->scratch, step, &framesRead);
if(errorIsNotOk(ret)) {
errorCatch(errorPrint(ret));
threadMutexLock(&ring->lock);
ring->failed = true;
threadMutexUnlock(&ring->lock);
return;
}
if(framesRead == 0) break; // Genuine early end of underlying data.
threadMutexLock(&ring->lock);
const size_t firstPart = mathMin(
framesRead, AUDIO_MP3_RING_FRAMES - ring->writePos
);
memoryCopy(
ring->data + ring->writePos * channels, ring->scratch,
firstPart * channels * sizeof(int16_t)
);
if(framesRead > firstPart) {
memoryCopy(
ring->data, ring->scratch + firstPart * channels,
(framesRead - firstPart) * channels * sizeof(int16_t)
);
}
ring->writePos = (ring->writePos + framesRead) % AUDIO_MP3_RING_FRAMES;
ring->filled += framesRead;
threadMutexUnlock(&ring->lock);
decoded += framesRead;
// Loops back to the top - threadShouldStop() is rechecked before the
// NEXT audioStreamRead(), not just once at the top of an outer loop,
// so a stop request doesn't have to wait through a whole pass.
}
threadMutexLock(&ring->lock);
ring->reachedEnd = true;
threadMutexUnlock(&ring->lock);
}
errorret_t audioStreamMp3RingInitIfNeeded(audiostream_t *stream) {
assertNotNull(stream, "Stream cannot be NULL.");
if(stream->type == AUDIO_STREAM_TYPE_MP3) {
errorChain(audioStreamMp3RingInit(stream));
}
errorOk();
}
errorret_t audioStreamMp3RingDisposeIfNeeded(audiostream_t *stream) {
assertNotNull(stream, "Stream cannot be NULL.");
if(stream->type == AUDIO_STREAM_TYPE_MP3) {
errorChain(audioStreamMp3RingDispose(stream));
}
errorOk();
}
errorret_t audioStreamMp3RingStopIfNeeded(audiostream_t *stream) {
assertNotNull(stream, "Stream cannot be NULL.");
if(stream->type == AUDIO_STREAM_TYPE_MP3) {
errorChain(audioStreamMp3RingStop(stream));
}
errorOk();
}
errorret_t audioStreamMp3RingStartIfNeeded(
audiostream_t *stream, size_t startFrame, size_t endFrame
) {
assertNotNull(stream, "Stream cannot be NULL.");
if(stream->type == AUDIO_STREAM_TYPE_MP3) {
errorChain(audioStreamMp3RingStart(stream, startFrame, endFrame));
}
errorOk();
}
errorret_t audioStreamReadForPlayback(
audiostream_t *stream,
int16_t *out,
size_t maxFrames,
size_t *outFramesRead,
bool_t *outNotReadyYet
) {
assertNotNull(stream, "Stream cannot be NULL.");
assertNotNull(out, "Out buffer cannot be NULL.");
assertNotNull(outFramesRead, "outFramesRead cannot be NULL.");
assertNotNull(outNotReadyYet, "outNotReadyYet cannot be NULL.");
*outNotReadyYet = false;
if(stream->type == AUDIO_STREAM_TYPE_MP3) {
bool_t decodeDone = false;
*outFramesRead = audioStreamMp3RingDrain(stream, out, maxFrames, &decodeDone);
if(*outFramesRead == 0 && !decodeDone) {
*outNotReadyYet = true;
errorOk();
}
if(*outFramesRead == 0 && audioStreamMp3RingFailed(stream)) {
errorThrow("Background MP3 decode failed.");
}
errorOk();
}
errorChain(audioStreamRead(stream, out, maxFrames, outFramesRead));
errorOk();
}
+288
View File
@@ -0,0 +1,288 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
#include "thread/thread.h"
#include "thread/threadmutex.h"
typedef struct audiostream_s audiostream_t;
// How many frames of decoded PCM the ring holds - deliberately generous
// (~1.5s at 44100Hz stereo, 256KB) so the background decode thread can run
// well ahead of what a platform's Feed() actually needs per poll, without
// being a meaningful fraction of even GameCube's 24MB (~1.1%) total RAM.
#define AUDIO_MP3_RING_FRAMES (64 * 1024)
// How many frames the decode thread reads per audioStreamRead() call -
// bounds both normal decode-thread iteration granularity and, more
// importantly, audioStreamMp3RingStop()'s worst-case blocking wait (roughly
// one of these reads' worth of asset I/O + libmad decode, since the thread
// only rechecks threadShouldStop() between reads, not mid-read).
#define AUDIO_MP3_RING_STEP_FRAMES 4096
// How long the decode thread sleeps between polls when the ring is already
// full - matches the shared asset-load thread's own idle-poll interval
// (src/dusk/asset/asset.c) rather than inventing a new convention.
#define AUDIO_MP3_RING_POLL_MICROS 1000
/**
* Background MP3 decode-ahead ring, shared by every platform whose MP3
* decode is expensive enough to hitch the main thread if done synchronously
* (currently Linux and Dolphin, both via the libmad-based software decoder
* - audiostreammp3decodersw.c; PSP doesn't use this, see below). Moves
* audioStreamRead()'s real cost (asset I/O + libmad decode) off the main
* thread onto a background thread that decodes ahead into this ring - a
* platform's own Feed() then drains already-decoded PCM out of it (a
* cheap, lock-protected copy) instead of decoding directly. Sits upstream
* of whatever a platform does with the PCM afterward (SDL_QueueAudio on
* Linux, ASND_SetVoice()/ASND_AddVoice() on Dolphin) - this struct knows
* nothing about either.
*
* Only used for AUDIO_STREAM_TYPE_MP3 streams - PCM/WAV reads are already
* cheap plain memory copies and don't need this. Not used by PSP: its
* hardware sceMp3 decoder is a completely different API (see
* src/duskpsp/audio/audiostreammp3decoder.c), and PSP's own audio backend
* already has its own background thread + ring - for *output* (draining
* to hardware), not decode - layering this under it would just be a
* second, redundant ring.
*
* The background thread is one-shot per playback pass, not a persistent
* worker (unlike PSP's own output thread, which idles cheaply between
* passes) - a fresh thread is started for every new pass (initial play,
* explicit seek, or loop restart), since those are rare relative to
* per-frame polls and a brief synchronous stop-then-restart at pass
* boundaries is a fine trade for not needing a mid-pass "abandon and
* reset" handshake.
*/
typedef struct {
int16_t *data; // AUDIO_MP3_RING_FRAMES * channels * sizeof(int16_t)
int16_t *scratch; // AUDIO_MP3_RING_STEP_FRAMES * channels * sizeof(int16_t),
// the decode thread's own read target - never touched
// by the main thread, so it needs no lock protection.
// Guards exactly filled/readPos/writePos/reachedEnd/failed below,
// grouped together for simplicity rather than split per-field (same
// rationale as the PSP backend's own ring lock). Never held across
// audioStreamRead() or usleep() (decode thread) or a platform's own
// output call (main thread).
threadmutex_t lock;
// Next frame audioStreamMp3RingDrain() (main thread) reads.
size_t readPos;
// Next frame the decode thread writes.
size_t writePos;
// How many valid frames are currently buffered.
size_t filled;
// Set by the decode thread once it has produced every frame this pass
// will ever have (reached endFrame, or the underlying read returned 0
// frames early) - tells Drain() there's nothing more coming, so it
// should hand back whatever's left rather than waiting for a full
// window that will never arrive.
bool_t reachedEnd;
// Set by the decode thread if audioStreamRead() itself errored - Drain()
// treats this the same as reachedEnd for draining purposes, but a
// platform's Feed() should surface it as a real thrown error once the
// ring is empty.
bool_t failed;
// This pass's frame bounds, set by audioStreamMp3RingStart() before the
// thread is launched and read-only after (thread creation is the
// happens-before edge) - stored here rather than read from a platform's
// own platform_t fields, since this struct is shared across platforms
// whose own position/endFrame-equivalent fields aren't guaranteed to
// exist or be named the same way.
size_t startFrame;
size_t endFrame;
// One-shot per pass - see this struct's own top-of-file comment.
thread_t thread;
} audiostreammp3ring_t;
/**
* Allocates this stream's ring/scratch buffers (sized from its channel
* count) and initializes its mutex and thread_t - does not start decoding
* anything yet, that's audioStreamMp3RingStart()'s job. Call once, from a
* platform's own Init(), only for AUDIO_STREAM_TYPE_MP3 streams.
*
* @param stream The audio stream to initialize. Must be AUDIO_STREAM_TYPE_MP3.
* @return Error state if any.
*/
errorret_t audioStreamMp3RingInit(audiostream_t *stream);
/**
* Blocking-stops the decode thread (a no-op if it was never started or has
* already finished this pass) and frees the ring/scratch buffers. Call
* from a platform's own Dispose() to guarantee the thread is fully parked
* before the rest of this stream's (and the shared MP3 layer's) teardown
* runs.
*
* @param stream The audio stream to dispose. Must be AUDIO_STREAM_TYPE_MP3.
* @return Error state if any.
*/
errorret_t audioStreamMp3RingDispose(audiostream_t *stream);
/**
* Blocking-stops the decode thread (safe no-op if not running) and resets
* the ring to empty, ready for a fresh pass. Must be called (and must
* return) before audioStreamSeek() runs for a new pass, since the decode
* thread reads via the same stream->mp3 decoder/file audioStreamSeek()
* mutates - and before audioStreamMp3RingStart() launches the next one.
*
* @param stream The audio stream to stop. Must be AUDIO_STREAM_TYPE_MP3.
* @return Error state if any.
*/
errorret_t audioStreamMp3RingStop(audiostream_t *stream);
/**
* Starts a fresh one-shot decode thread for the pass a platform's own
* Buffer() just set up (stream->platform.position/endFrame - or
* equivalent, whatever the platform's own struct calls them - already
* finalized) - decodes forward via audioStreamRead() into the ring until
* it reaches endFrame, filling back up whenever audioStreamMp3RingDrain()
* makes room.
*
* @param stream The audio stream to start decoding. Must be AUDIO_STREAM_TYPE_MP3.
* @param startFrame This pass's starting frame offset.
* @param endFrame This pass's stop frame offset.
* @return Error state if any.
*/
errorret_t audioStreamMp3RingStart(
audiostream_t *stream, size_t startFrame, size_t endFrame
);
/**
* Copies up to maxFrames already-decoded frames out of the ring into `out`,
* advancing the ring's read cursor. Only returns fewer than maxFrames (down
* to 0) once *outDecodeDone is also true (the decode thread has produced
* everything it ever will for this pass, or hit an error) - otherwise it
* waits for a full window rather than handing back a partial one, so a
* platform's own output handoff doesn't fragment into many tiny calls just
* because decode happened to be a little behind at this exact poll.
*
* @param stream The audio stream to drain from. Must be AUDIO_STREAM_TYPE_MP3.
* @param out Destination buffer, sized for at least maxFrames frames.
* @param maxFrames Maximum number of frames to copy out.
* @param outDecodeDone Set to true if the decode thread has nothing more to
* give this pass (cleanly finished or failed).
* @return The number of frames actually copied out.
*/
size_t audioStreamMp3RingDrain(
audiostream_t *stream,
int16_t *out,
size_t maxFrames,
bool_t *outDecodeDone
);
/**
* True if the decode thread hit a real read error this pass (see the
* `failed` field's own comment) - a platform's Feed() checks this once
* Drain() has returned 0 frames with decode reported done, to decide
* whether that's a genuine end of stream or something to surface as an
* error.
*
* @param stream The audio stream to check. Must be AUDIO_STREAM_TYPE_MP3.
* @return true if the decode thread failed this pass.
*/
bool_t audioStreamMp3RingFailed(const audiostream_t *stream);
/**
* The decode thread's own entry point (passed to threadInit()). Reads
* forward via audioStreamRead() in AUDIO_MP3_RING_STEP_FRAMES chunks,
* writing into the ring whenever there's room, until it reaches this
* pass's endFrame, the underlying read returns 0 frames, or a real read
* error occurs - never touches stream->onLoop/onEnd or any other
* main-thread-owned stream state, only the ring and its own captured
* startFrame/endFrame.
*
* @param thread This ring's own thread_t (thread->data is the audiostream_t).
*/
void audioStreamMp3RingThreadFunc(thread_t *thread);
// The functions below are thin, type-checking wrappers around the ones
// above, meant to be called unconditionally from a platform's own
// Init()/Dispose()/Buffer()/Feed() - each is a no-op (or, for
// audioStreamReadForPlayback(), falls through to a plain audioStreamRead())
// for anything other than an AUDIO_STREAM_TYPE_MP3 stream, so callers don't
// need their own `if(stream->type == AUDIO_STREAM_TYPE_MP3)` check at every
// call site. This is what actually keeps Linux's and Dolphin's own
// Init/Dispose/Buffer/Feed implementations close to each other - see
// audiostreamlinux.c/audiostreamdolphin.c for how they're used.
/**
* Calls audioStreamMp3RingInit() if stream is an MP3 stream, otherwise a
* no-op.
*
* @param stream The audio stream to initialize.
* @return Error state if any.
*/
errorret_t audioStreamMp3RingInitIfNeeded(audiostream_t *stream);
/**
* Calls audioStreamMp3RingDispose() if stream is an MP3 stream, otherwise a
* no-op.
*
* @param stream The audio stream to dispose.
* @return Error state if any.
*/
errorret_t audioStreamMp3RingDisposeIfNeeded(audiostream_t *stream);
/**
* Calls audioStreamMp3RingStop() if stream is an MP3 stream, otherwise a
* no-op. Safe (and cheap) to call unconditionally at the start of every
* platform Buffer() pass, before audioStreamSeek().
*
* @param stream The audio stream to stop.
* @return Error state if any.
*/
errorret_t audioStreamMp3RingStopIfNeeded(audiostream_t *stream);
/**
* Calls audioStreamMp3RingStart() if stream is an MP3 stream, otherwise a
* no-op. Safe to call unconditionally once a platform Buffer() pass has
* finalized its startFrame/endFrame, after audioStreamSeek().
*
* @param stream The audio stream to start decoding.
* @param startFrame This pass's starting frame offset.
* @param endFrame This pass's stop frame offset.
* @return Error state if any.
*/
errorret_t audioStreamMp3RingStartIfNeeded(
audiostream_t *stream, size_t startFrame, size_t endFrame
);
/**
* Reads up to maxFrames frames of decoded PCM for playback into `out`: for
* MP3 streams, drains the background decode ring
* (audioStreamMp3RingDrain()); for anything else, reads directly via
* audioStreamRead(). The single call a platform's own Feed() needs to get
* its next window's worth of PCM, regardless of stream type.
*
* *outNotReadyYet distinguishes the two ways this can return 0 frames
* without erroring: true means an MP3 stream's decode thread simply hasn't
* produced a full window yet (retry next poll, don't treat as end of
* stream); false with 0 frames means genuinely nothing left to read this
* pass. A background MP3 decode failure surfaces as a real thrown error
* (via errorThrow) rather than through either of these.
*
* @param stream The audio stream to read from.
* @param out Destination buffer, sized for at least maxFrames frames.
* @param maxFrames Maximum number of frames to read.
* @param outFramesRead Set to the number of frames actually read.
* @param outNotReadyYet Set to true if this returned 0 frames only because
* decode hasn't caught up yet, not because playback
* has actually reached the end.
* @return Error state - a background MP3 decode failure surfaces here.
*/
errorret_t audioStreamReadForPlayback(
audiostream_t *stream,
int16_t *out,
size_t maxFrames,
size_t *outFramesRead,
bool_t *outNotReadyYet
);