New ASSET_LOADER_TYPE_MP3 (hand-rolled MPEG-1/2/2.5 Layer III header parser - no third-party dependency needed just for metadata, since PSP's hardware path doesn't need one at all) plus a shared audiostreammp3.c stream layer mirroring audiostreampcm.c's shape. Generalized the stream dispatch (hoisted sampleRate/channels onto audiostream_t, added audioStreamGetTotalFrames()/Seek()/Read()) so all three platform audio backends keep working unchanged, just calling the generic names instead of PCM-specific ones. Two decoder backends behind one interface: PSP uses the real sceMp3 hardware decoder (firmware-offloaded, lazily initialized on first use); Linux and Dolphin share one minimp3-based software decoder (public domain, vendored via CMake FetchContent) - libogc's own MP3Player wraps libmad (GPL) and drives its own output pipeline, not a fit for the ansnd-based architecture already in place, so skipped in favor of the shared minimp3 path. Fixed three real bugs found via hardware/runtime testing along the way: - LAME's Xing header counts its own placeholder frame in the declared total, which made playback stall permanently one frame short of the declared end (looked like "never loops") - fixed by subtracting it. - sceMp3Decode() can return more PCM than one MPEG frame's worth in a single call (PSP's pcmBuf is provisioned for 2x), overflowing the shared per-frame decode buffer with no bound check - very intermittent corruption/clicking on real hardware. Widened the buffer to the real worst case and added an assertion. - sceMp3ResetPlayPosition()'s exact internal reset semantics aren't documented precisely enough to trust for looping - occasionally disagreed with the fresh stream position fed right after, clicking at the loop boundary about 1 in 3-4 loops. Rewind now fully tears down and recreates the decoder instead, the same path already proven correct at first Init. Also widened the PSP ring buffer to absorb that now-heavier operation, capping each top-up call's own work so the bigger buffer doesn't turn into one long blocking decode burst instead. Co-Authored-By: Claude Sonnet 5 <[email protected]>
262 lines
9.3 KiB
C
262 lines
9.3 KiB
C
/**
|
|
* Copyright (c) 2026 Dominic Masters
|
|
*
|
|
* This software is released under the MIT License.
|
|
* https://opensource.org/licenses/MIT
|
|
*/
|
|
|
|
#include "audiostreammp3decoder.h"
|
|
#include "audio/audiostream.h"
|
|
#include "assert/assert.h"
|
|
#include "util/memory.h"
|
|
#include <pspmp3.h>
|
|
#include <psputility.h>
|
|
|
|
// Per the PSP SDK's mp3 sample and pspmp3.h's own documentation - these
|
|
// are hard minimums sceMp3ReserveMp3Handle() requires, not tunable.
|
|
#define AUDIO_MP3_PSP_STREAM_BUF_SIZE (16 * 1024)
|
|
#define AUDIO_MP3_PSP_PCM_BUF_SIZE (16 * (1152 / 2))
|
|
|
|
// sceMp3 manages its own internal ring buffer of compressed bytes rather
|
|
// than being handed a whole file/buffer up front (unlike, say,
|
|
// zip_source_buffer_create elsewhere in this codebase) - it tells the
|
|
// caller exactly where in the source stream it wants more data from
|
|
// (sceMp3GetInfoToAddStreamData()'s srcpos) and how much room is free to
|
|
// receive it into, and the caller is expected to fetch exactly that and
|
|
// call sceMp3NotifyAddStreamData(). This decoder only ever feeds it
|
|
// forward, sequentially, from stream->mp3.file (never anything sceMp3
|
|
// itself wouldn't naturally request next), so despite the API being built
|
|
// for arbitrary positioning, this driver never needs to seek that file
|
|
// out of sequence - see audioStreamMp3DecoderFillStream()'s own assertion.
|
|
|
|
// End-of-stream sentinel returned by sceMp3Decode() once nothing more can
|
|
// be decoded - not documented in pspmp3.h, but confirmed by the PSP SDK's
|
|
// own mp3 sample (main.c), which explicitly excludes it from its generic
|
|
// "sceMp3Decode failed" error path.
|
|
#define AUDIO_MP3_PSP_DECODE_END_OF_STREAM 0x80671402
|
|
|
|
// Set once audioStreamMp3DecoderInit() has loaded sceMp3's firmware
|
|
// modules and initialized its resource pool - lazily, on the first MP3
|
|
// stream ever created, rather than unconditionally at engine startup, so
|
|
// a game that never plays an MP3 never pays for it. Guards
|
|
// audioStreamMp3DecoderGlobalDispose() so it only tears down what was
|
|
// actually set up.
|
|
static bool_t AUDIO_MP3_PSP_RESOURCES_READY = false;
|
|
|
|
errorret_t audioStreamMp3DecoderFillStream(audiostream_t *stream) {
|
|
audiostreammp3decoder_t *decoder = &stream->mp3.decoder;
|
|
|
|
SceUChar8 *dst = NULL;
|
|
SceInt32 toWrite = 0;
|
|
SceInt32 srcPos = 0;
|
|
SceInt32 status = sceMp3GetInfoToAddStreamData(
|
|
decoder->handle, &dst, &toWrite, &srcPos
|
|
);
|
|
if(status < 0) {
|
|
errorThrow("sceMp3GetInfoToAddStreamData failed: 0x%08X", status);
|
|
}
|
|
|
|
if(toWrite <= 0) {
|
|
errorOk();
|
|
}
|
|
|
|
assertTrue(
|
|
(size_t) srcPos == decoder->streamPosition,
|
|
"sceMp3 requested stream data out of the sequence this decoder feeds it in."
|
|
);
|
|
|
|
errorChain(assetFileRead(&stream->mp3.file, dst, (size_t) toWrite));
|
|
const size_t bytesRead = (size_t) stream->mp3.file.lastRead;
|
|
|
|
status = sceMp3NotifyAddStreamData(decoder->handle, (SceInt32) bytesRead);
|
|
if(status < 0) {
|
|
errorThrow("sceMp3NotifyAddStreamData failed: 0x%08X", status);
|
|
}
|
|
|
|
decoder->streamPosition += bytesRead;
|
|
if(bytesRead < (size_t) toWrite) decoder->endOfFile = true;
|
|
|
|
errorOk();
|
|
}
|
|
|
|
errorret_t audioStreamMp3DecoderInit(audiostream_t *stream) {
|
|
assertNotNull(stream, "Stream cannot be NULL.");
|
|
assertTrue(stream->type == AUDIO_STREAM_TYPE_MP3, "Stream is not MP3.");
|
|
|
|
if(!AUDIO_MP3_PSP_RESOURCES_READY) {
|
|
int status = sceUtilityLoadModule(PSP_MODULE_AV_AVCODEC);
|
|
if(status < 0) {
|
|
errorThrow("Failed to load PSP_MODULE_AV_AVCODEC: 0x%08X", status);
|
|
}
|
|
status = sceUtilityLoadModule(PSP_MODULE_AV_MP3);
|
|
if(status < 0) {
|
|
errorThrow("Failed to load PSP_MODULE_AV_MP3: 0x%08X", status);
|
|
}
|
|
status = sceMp3InitResource();
|
|
if(status < 0) {
|
|
errorThrow("sceMp3InitResource failed: 0x%08X", status);
|
|
}
|
|
AUDIO_MP3_PSP_RESOURCES_READY = true;
|
|
}
|
|
|
|
const assetmp3file_t *mp3 = &stream->asset->data.mp3;
|
|
audiostreammp3decoder_t *decoder = &stream->mp3.decoder;
|
|
|
|
decoder->mp3Buf = memoryAlign(64, AUDIO_MP3_PSP_STREAM_BUF_SIZE);
|
|
decoder->pcmBuf = memoryAlign(64, AUDIO_MP3_PSP_PCM_BUF_SIZE);
|
|
decoder->streamPosition = mp3->dataOffset;
|
|
decoder->endOfFile = false;
|
|
|
|
SceMp3InitArg initArg;
|
|
memoryZero(&initArg, sizeof(initArg));
|
|
initArg.mp3StreamStart = (SceOff) mp3->dataOffset;
|
|
initArg.mp3StreamEnd = (SceOff) (mp3->dataOffset + mp3->dataSize);
|
|
initArg.mp3Buf = decoder->mp3Buf;
|
|
initArg.mp3BufSize = AUDIO_MP3_PSP_STREAM_BUF_SIZE;
|
|
initArg.pcmBuf = decoder->pcmBuf;
|
|
initArg.pcmBufSize = AUDIO_MP3_PSP_PCM_BUF_SIZE;
|
|
|
|
const SceInt32 handle = sceMp3ReserveMp3Handle(&initArg);
|
|
if(handle < 0) {
|
|
memoryFree(decoder->mp3Buf);
|
|
memoryFree(decoder->pcmBuf);
|
|
errorThrow("sceMp3ReserveMp3Handle failed: 0x%08X", handle);
|
|
}
|
|
decoder->handle = handle;
|
|
|
|
// sceMp3Init() needs some stream data already fed before it can
|
|
// determine the stream's format - see the PSP SDK's own mp3 sample.
|
|
errorChain(audioStreamMp3DecoderFillStream(stream));
|
|
|
|
const SceInt32 initStatus = sceMp3Init(decoder->handle);
|
|
if(initStatus < 0) {
|
|
sceMp3ReleaseMp3Handle(decoder->handle);
|
|
memoryFree(decoder->mp3Buf);
|
|
memoryFree(decoder->pcmBuf);
|
|
errorThrow("sceMp3Init failed: 0x%08X", initStatus);
|
|
}
|
|
|
|
errorOk();
|
|
}
|
|
|
|
errorret_t audioStreamMp3DecoderDispose(audiostream_t *stream) {
|
|
assertNotNull(stream, "Stream cannot be NULL.");
|
|
assertTrue(stream->type == AUDIO_STREAM_TYPE_MP3, "Stream is not MP3.");
|
|
|
|
audiostreammp3decoder_t *decoder = &stream->mp3.decoder;
|
|
|
|
sceMp3ReleaseMp3Handle(decoder->handle);
|
|
memoryFree(decoder->mp3Buf);
|
|
decoder->mp3Buf = NULL;
|
|
memoryFree(decoder->pcmBuf);
|
|
decoder->pcmBuf = NULL;
|
|
|
|
errorOk();
|
|
}
|
|
|
|
errorret_t audioStreamMp3DecoderRewind(audiostream_t *stream) {
|
|
assertNotNull(stream, "Stream cannot be NULL.");
|
|
assertTrue(stream->type == AUDIO_STREAM_TYPE_MP3, "Stream is not MP3.");
|
|
|
|
const assetmp3file_t *mp3 = &stream->asset->data.mp3;
|
|
|
|
errorChain(assetFileRewind(&stream->mp3.file));
|
|
errorChain(assetFileRead(&stream->mp3.file, NULL, mp3->dataOffset));
|
|
|
|
// Deliberately not sceMp3ResetPlayPosition(): the SDK doesn't document
|
|
// precisely enough whether it fully clears sceMp3's own internal
|
|
// compressed-data ring buffer or just resets a playback cursor, leaving
|
|
// room for it to disagree with the fresh stream position we just fed it
|
|
// from - a mismatch that would only be audible as an occasional click
|
|
// right at the loop point, depending on how much of sceMp3's internal
|
|
// buffer happened to still hold stale data at reset time. Fully tearing
|
|
// down and recreating the decoder instead guarantees a clean state by
|
|
// construction, via the exact same path already proven correct for the
|
|
// stream's very first Init - one extra handle release/reserve cycle per
|
|
// loop wrap is negligible next to how rarely loops actually happen.
|
|
errorChain(audioStreamMp3DecoderDispose(stream));
|
|
errorChain(audioStreamMp3DecoderInit(stream));
|
|
|
|
errorOk();
|
|
}
|
|
|
|
errorret_t audioStreamMp3DecoderDecodeFrame(
|
|
audiostream_t *stream,
|
|
int16_t *out,
|
|
size_t *outFrames
|
|
) {
|
|
assertNotNull(stream, "Stream cannot be NULL.");
|
|
assertNotNull(out, "Out buffer cannot be NULL.");
|
|
assertNotNull(outFrames, "outFrames cannot be NULL.");
|
|
assertTrue(stream->type == AUDIO_STREAM_TYPE_MP3, "Stream is not MP3.");
|
|
|
|
audiostreammp3decoder_t *decoder = &stream->mp3.decoder;
|
|
const size_t frameSize = stream->channels * sizeof(int16_t);
|
|
|
|
for(;;) {
|
|
if(sceMp3CheckStreamDataNeeded(decoder->handle) > 0 && !decoder->endOfFile) {
|
|
errorChain(audioStreamMp3DecoderFillStream(stream));
|
|
}
|
|
|
|
SceShort16 *pcm = NULL;
|
|
const SceInt32 bytesDecoded = sceMp3Decode(decoder->handle, &pcm);
|
|
|
|
if(bytesDecoded > 0) {
|
|
// `out` (stream->mp3.pending) is sized for
|
|
// AUDIO_MP3_MAX_SAMPLES_PER_FRAME samples - sized specifically to
|
|
// cover sceMp3's pcmBuf provisioning (see this decoder's own
|
|
// AUDIO_MP3_PSP_PCM_BUF_SIZE comment). Asserted rather than trusted:
|
|
// a real hardware overflow here was previously silent, corrupting
|
|
// whatever followed stream->mp3.pending in memory, and only showed
|
|
// up as very intermittent audio clicking.
|
|
assertTrue(
|
|
(size_t) bytesDecoded <= AUDIO_MP3_MAX_SAMPLES_PER_FRAME * sizeof(int16_t),
|
|
"sceMp3Decode returned more PCM than the destination buffer can hold."
|
|
);
|
|
const size_t frames = (size_t) bytesDecoded / frameSize;
|
|
memoryCopy(out, pcm, (size_t) bytesDecoded);
|
|
*outFrames = frames;
|
|
errorOk();
|
|
}
|
|
|
|
if(
|
|
bytesDecoded < 0 &&
|
|
(uint32_t) bytesDecoded != AUDIO_MP3_PSP_DECODE_END_OF_STREAM
|
|
) {
|
|
errorThrow("sceMp3Decode failed: 0x%08X", bytesDecoded);
|
|
}
|
|
|
|
// Decoded nothing this call - if sceMp3 doesn't need more data either,
|
|
// it's genuinely out of frames to produce.
|
|
if(sceMp3CheckStreamDataNeeded(decoder->handle) <= 0) {
|
|
*outFrames = 0;
|
|
errorOk();
|
|
}
|
|
|
|
if(decoder->endOfFile) {
|
|
// sceMp3 wants more data but the file has none left - one last
|
|
// decode attempt already happened above with whatever was fed;
|
|
// nothing further will ever arrive.
|
|
*outFrames = 0;
|
|
errorOk();
|
|
}
|
|
}
|
|
}
|
|
|
|
errorret_t audioStreamMp3DecoderGlobalDispose(void) {
|
|
if(!AUDIO_MP3_PSP_RESOURCES_READY) {
|
|
errorOk();
|
|
}
|
|
|
|
const SceInt32 status = sceMp3TermResource();
|
|
if(status < 0) {
|
|
errorThrow("sceMp3TermResource failed: 0x%08X", status);
|
|
}
|
|
|
|
sceUtilityUnloadModule(PSP_MODULE_AV_MP3);
|
|
sceUtilityUnloadModule(PSP_MODULE_AV_AVCODEC);
|
|
AUDIO_MP3_PSP_RESOURCES_READY = false;
|
|
|
|
errorOk();
|
|
}
|