diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index babd21c6..798da385 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -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() \ No newline at end of file diff --git a/src/dusk/audio/stream/audiostream.c b/src/dusk/audio/stream/audiostream.c index ed3d55f9..550faabb 100644 --- a/src/dusk/audio/stream/audiostream.c +++ b/src/dusk/audio/stream/audiostream.c @@ -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; diff --git a/src/dusk/audio/stream/audiostream.h b/src/dusk/audio/stream/audiostream.h index 1ef4c342..e7eb4371 100644 --- a/src/dusk/audio/stream/audiostream.h +++ b/src/dusk/audio/stream/audiostream.h @@ -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. * diff --git a/src/dusk/thread/thread.c b/src/dusk/thread/thread.c index 96e928a5..f0d09195 100644 --- a/src/dusk/thread/thread.c +++ b/src/dusk/thread/thread.c @@ -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 \ No newline at end of file diff --git a/src/duskdolphin/audio/CMakeLists.txt b/src/duskdolphin/audio/CMakeLists.txt index 2fc45395..a692cdb5 100644 --- a/src/duskdolphin/audio/CMakeLists.txt +++ b/src/duskdolphin/audio/CMakeLists.txt @@ -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. diff --git a/src/duskdolphin/audio/audiostreamdolphin.c b/src/duskdolphin/audio/audiostreamdolphin.c index 80d9fa76..a54ffbab 100644 --- a/src/duskdolphin/audio/audiostreamdolphin.c +++ b/src/duskdolphin/audio/audiostreamdolphin.c @@ -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, ¬ReadyYet )); + 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); diff --git a/src/duskdolphin/audio/audiostreamdolphin.h b/src/duskdolphin/audio/audiostreamdolphin.h index 363e3a23..f0afff80 100644 --- a/src/duskdolphin/audio/audiostreamdolphin.h +++ b/src/duskdolphin/audio/audiostreamdolphin.h @@ -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. */ diff --git a/src/duskdolphin/audio/audiostreammp3decoder.h b/src/duskdolphin/audio/audiostreammp3decoder.h index 7d77039c..f39288d6 100644 --- a/src/duskdolphin/audio/audiostreammp3decoder.h +++ b/src/duskdolphin/audio/audiostreammp3decoder.h @@ -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" diff --git a/src/dusklinux/audio/CMakeLists.txt b/src/dusklinux/audio/CMakeLists.txt index c6c014d7..a2accbc6 100644 --- a/src/dusklinux/audio/CMakeLists.txt +++ b/src/dusklinux/audio/CMakeLists.txt @@ -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" -) diff --git a/src/dusklinux/audio/audiostreamlinux.c b/src/dusklinux/audio/audiostreamlinux.c index 61960b51..ac963946 100644 --- a/src/dusklinux/audio/audiostreamlinux.c +++ b/src/dusklinux/audio/audiostreamlinux.c @@ -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, ¬ReadyYet + ); 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; diff --git a/src/dusklinux/audio/audiostreamlinux.h b/src/dusklinux/audio/audiostreamlinux.h index a10f4139..6d6dfb39 100644 --- a/src/dusklinux/audio/audiostreamlinux.h +++ b/src/dusklinux/audio/audiostreamlinux.h @@ -7,6 +7,7 @@ #pragma once #include "error/error.h" +#include "audiostreammp3ring.h" #include 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 diff --git a/src/dusklinux/audio/audiostreammp3decoder.h b/src/dusklinux/audio/audiostreammp3decoder.h index 8a268837..2cb931d8 100644 --- a/src/dusklinux/audio/audiostreammp3decoder.h +++ b/src/dusklinux/audio/audiostreammp3decoder.h @@ -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" diff --git a/src/duskmad/CMakeLists.txt b/src/duskmad/CMakeLists.txt new file mode 100644 index 00000000..94fb2d7a --- /dev/null +++ b/src/duskmad/CMakeLists.txt @@ -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 +) diff --git a/src/dusk/audio/stream/audiostreammp3decodersw.c b/src/duskmad/audiostreammp3decodersw.c similarity index 98% rename from src/dusk/audio/stream/audiostreammp3decodersw.c rename to src/duskmad/audiostreammp3decodersw.c index c99b922f..bc8a6857 100644 --- a/src/dusk/audio/stream/audiostreammp3decodersw.c +++ b/src/duskmad/audiostreammp3decodersw.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" diff --git a/src/dusk/audio/stream/audiostreammp3decodersw.h b/src/duskmad/audiostreammp3decodersw.h similarity index 100% rename from src/dusk/audio/stream/audiostreammp3decodersw.h rename to src/duskmad/audiostreammp3decodersw.h diff --git a/src/duskmad/audiostreammp3ring.c b/src/duskmad/audiostreammp3ring.c new file mode 100644 index 00000000..342bf88a --- /dev/null +++ b/src/duskmad/audiostreammp3ring.c @@ -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 + +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(); +} diff --git a/src/duskmad/audiostreammp3ring.h b/src/duskmad/audiostreammp3ring.h new file mode 100644 index 00000000..c1a5562b --- /dev/null +++ b/src/duskmad/audiostreammp3ring.h @@ -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 +);