Add MP3 audio stream support with hardware/software decoder backends

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]>
This commit is contained in:
2026-09-01 08:21:30 -05:00
co-authored by Claude Sonnet 5
parent ac8023d50f
commit f8f8a80a21
31 changed files with 1530 additions and 72 deletions
+33 -25
View File
@@ -42,24 +42,24 @@
errorret_t audioStreamPSPInit(audiostream_t *stream) {
assertNotNull(stream, "Stream cannot be NULL.");
if(stream->pcm.channels != 1 && stream->pcm.channels != 2) {
if(stream->channels != 1 && stream->channels != 2) {
errorThrow(
"PSP audio only supports mono or stereo PCM, got %d channels.",
stream->pcm.channels
stream->channels
);
}
// sceAudioChReserve's hardware channels always run at the PSP's native
// 44100Hz; there is no per-channel sample rate. Arbitrary rates would need
// sceAudioSRCChReserve's single exclusive channel instead.
if(stream->pcm.sampleRate != 44100) {
if(stream->sampleRate != 44100) {
errorThrow(
"PSP audio channels are fixed at 44100Hz, got %uHz.",
stream->pcm.sampleRate
stream->sampleRate
);
}
const int format = stream->pcm.channels == 1
const int format = stream->channels == 1
? PSP_AUDIO_FORMAT_MONO
: PSP_AUDIO_FORMAT_STEREO;
@@ -75,7 +75,7 @@ errorret_t audioStreamPSPInit(audiostream_t *stream) {
stream->platform.playRequested = false;
const size_t ringSize =
AUDIO_PSP_RING_FRAMES * stream->pcm.channels * sizeof(int16_t);
AUDIO_PSP_RING_FRAMES * stream->channels * sizeof(int16_t);
stream->platform.ring = memoryAllocate(ringSize);
stream->platform.scratch = memoryAllocate(ringSize);
threadMutexInit(&stream->platform.ringLock);
@@ -109,7 +109,7 @@ errorret_t audioStreamPSPBuffer(audiostream_t *stream) {
stream->platform.readReachedEnd = false;
stream->platform.readFailed = false;
stream->platform.totalFrames = audioStreamPcmGetTotalFrames(stream);
stream->platform.totalFrames = audioStreamGetTotalFrames(stream);
// loopEndFrame/loopToFrame define the loop segment [loopToFrame,
// loopEndFrame) that a looping pass wraps within, once it's reached -
@@ -117,12 +117,12 @@ errorret_t audioStreamPSPBuffer(audiostream_t *stream) {
// behaviour is unchanged when no explicit loop points are configured.
stream->platform.loopEndFrame = stream->loopStart >= 0
? mathMin(
(size_t) (stream->loopStart * stream->pcm.sampleRate),
(size_t) (stream->loopStart * stream->sampleRate),
stream->platform.totalFrames
)
: stream->platform.totalFrames;
stream->platform.loopToFrame = mathMin(
(size_t) (stream->loopTo * stream->pcm.sampleRate),
(size_t) (stream->loopTo * stream->sampleRate),
stream->platform.loopEndFrame
);
@@ -130,7 +130,7 @@ errorret_t audioStreamPSPBuffer(audiostream_t *stream) {
stream->startFrame = 0;
stream->seeking = false;
errorChain(audioStreamPcmSeek(stream, startFrame));
errorChain(audioStreamSeek(stream, startFrame));
stream->platform.readPosition = startFrame;
threadMutexLock(&stream->platform.ringLock);
@@ -158,20 +158,28 @@ void audioStreamPSPTopUp(audiostream_t *stream) {
threadMutexUnlock(&stream->platform.ringLock);
if(filled >= AUDIO_PSP_LEAD_FRAMES) return;
const size_t channels = stream->pcm.channels;
const size_t channels = stream->channels;
const size_t frameSize = channels * sizeof(int16_t);
// Fill however much room the ring actually has in this one call, not
// just a small fixed step - otherwise a temporary engine frame-rate dip
// (below roughly one hardware chunk's worth of playback time per frame)
// would let production permanently fall behind consumption, since a
// fixed-size top-up per call can never make up lost ground. Bounded by
// the ring's own physical capacity, and by loopEndFrame (the loop
// segment's end) - except a seek can legitimately land past it (e.g.
// into an outro after the loop point), in which case read out to the
// true end of the clip once instead of underflowing
// framesRemainingInSegment.
const size_t room = AUDIO_PSP_RING_FRAMES - filled;
// Fill however much room the ring has, up to one bounded step, not just
// whatever's needed to reach LEAD in a single shot - AUDIO_PSP_RING_FRAMES
// is now sized to absorb a slow MP3 loop-wrap (see its own comment), and
// greedily trying to fill all of it in one call right after a fresh
// Buffer() (when the ring starts empty) would turn the very first
// top-up into one long blocking decode burst on the main thread instead
// of many small ones - worse for startup smoothness, not better. Still
// uncapped per se: reaching LEAD just takes a few calls (a few engine
// frames) instead of one, which comfortably keeps up with real-time
// consumption the same way an unbounded fill would, since a temporary
// frame-rate dip only slows how fast the ring tops up, never how much
// room is left to fill on the next call. Bounded by the ring's own
// physical capacity, and by loopEndFrame (the loop segment's end) -
// except a seek can legitimately land past it (e.g. into an outro after
// the loop point), in which case read out to the true end of the clip
// once instead of underflowing framesRemainingInSegment.
const size_t room = mathMin(
AUDIO_PSP_RING_FRAMES - filled, (size_t) AUDIO_PSP_TOPUP_STEP_FRAMES
);
const size_t currentEndFrame = stream->platform.readPosition < stream->platform.loopEndFrame
? stream->platform.loopEndFrame
: stream->platform.totalFrames;
@@ -186,7 +194,7 @@ void audioStreamPSPTopUp(audiostream_t *stream) {
size_t framesRead = 0;
if(errorIsNotOk(
audioStreamPcmRead(stream, scratch, framesToRead, &framesRead)
audioStreamRead(stream, scratch, framesToRead, &framesRead)
)) {
stream->platform.readFailed = true;
return;
@@ -219,7 +227,7 @@ void audioStreamPSPTopUp(audiostream_t *stream) {
if(reachesSegmentEnd) {
if(willLoop) {
if(errorIsNotOk(audioStreamPcmSeek(stream, stream->platform.loopToFrame))) {
if(errorIsNotOk(audioStreamSeek(stream, stream->platform.loopToFrame))) {
stream->platform.readFailed = true;
return;
}
@@ -258,7 +266,7 @@ void audioStreamPSPThreadFeed(thread_t *thread) {
sceKernelChangeThreadPriority(sceKernelGetThreadId(), AUDIO_PSP_THREAD_PRIORITY);
audiostream_t *stream = (audiostream_t *) thread->data;
const size_t channels = stream->pcm.channels;
const size_t channels = stream->channels;
const size_t frameSize = channels * sizeof(int16_t);
int16_t *chunk = memoryAllocate(AUDIO_PSP_CHUNK_FRAMES * frameSize);