f8f8a80a21
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 <noreply@anthropic.com>
181 lines
6.2 KiB
C
181 lines
6.2 KiB
C
/**
|
|
* Copyright (c) 2026 Dominic Masters
|
|
*
|
|
* This software is released under the MIT License.
|
|
* https://opensource.org/licenses/MIT
|
|
*/
|
|
|
|
#include "audiostreamlinux.h"
|
|
#include "audio/audiostream.h"
|
|
#include "assert/assert.h"
|
|
#include "util/memory.h"
|
|
#include "util/math.h"
|
|
#include "error/error.h"
|
|
|
|
// How many frames of lead time to keep queued ahead of playback. Matches
|
|
// SDL_AudioSpec.samples below - the device's own internal buffer size - so
|
|
// this is "never let the queue run drier than one SDL-internal buffer."
|
|
#define AUDIO_LINUX_LEAD_FRAMES 4096
|
|
|
|
// How many frames audioStreamLinuxFeed() reads and queues per call - a
|
|
// few multiples of the lead margin, so one top-up comfortably outlasts
|
|
// the time between Update() calls without ever needing to hold more than
|
|
// this much decoded audio in memory at once, regardless of how long the
|
|
// underlying clip is.
|
|
#define AUDIO_LINUX_WINDOW_FRAMES (AUDIO_LINUX_LEAD_FRAMES * 4)
|
|
|
|
errorret_t audioStreamLinuxInit(audiostream_t *stream) {
|
|
assertNotNull(stream, "Stream cannot be NULL.");
|
|
|
|
SDL_AudioSpec desired;
|
|
memoryZero(&desired, sizeof(SDL_AudioSpec));
|
|
desired.freq = (int) stream->sampleRate;
|
|
desired.format = AUDIO_S16SYS;
|
|
desired.channels = stream->channels;
|
|
desired.samples = AUDIO_LINUX_LEAD_FRAMES;
|
|
|
|
stream->platform.device = SDL_OpenAudioDevice(NULL, 0, &desired, NULL, 0);
|
|
if(stream->platform.device == 0) {
|
|
errorThrow("Failed to open SDL2 audio device: %s", SDL_GetError());
|
|
}
|
|
|
|
errorOk();
|
|
}
|
|
|
|
errorret_t audioStreamLinuxDispose(audiostream_t *stream) {
|
|
assertNotNull(stream, "Stream cannot be NULL.");
|
|
|
|
SDL_CloseAudioDevice(stream->platform.device);
|
|
|
|
errorOk();
|
|
}
|
|
|
|
errorret_t audioStreamLinuxBuffer(audiostream_t *stream) {
|
|
assertNotNull(stream, "Stream cannot be NULL.");
|
|
|
|
const size_t frameSize = stream->channels * sizeof(int16_t);
|
|
const size_t totalFrames = audioStreamGetTotalFrames(stream);
|
|
|
|
// Consumed synchronously (right here, in the same call that decided to
|
|
// (re)buffer) rather than left for later - see startFrame's own comment.
|
|
const size_t startFrame = mathMin(stream->startFrame, totalFrames);
|
|
const bool_t seeking = stream->seeking;
|
|
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;
|
|
|
|
// Only an explicit seek discards whatever's still queued and jumps -
|
|
// a natural loop restart deliberately leaves the previous pass's tail
|
|
// (AUDIO_LINUX_LEAD_FRAMES worth) queued and appends the new pass after
|
|
// it, which is what makes looping gapless (see IsFinished()'s comment).
|
|
// Clearing here on every pass would destroy that overlap and
|
|
// reintroduce the exact gap this was built to avoid.
|
|
if(seeking) {
|
|
SDL_ClearQueuedAudio(stream->platform.device);
|
|
}
|
|
errorChain(audioStreamSeek(stream, startFrame));
|
|
|
|
stream->platform.position = startFrame;
|
|
stream->platform.endFrame = endFrame;
|
|
|
|
errorChain(audioStreamLinuxFeed(stream));
|
|
|
|
SDL_PauseAudioDevice(stream->platform.device, 0);
|
|
|
|
errorOk();
|
|
}
|
|
|
|
errorret_t audioStreamLinuxFeed(audiostream_t *stream) {
|
|
assertNotNull(stream, "Stream cannot be NULL.");
|
|
|
|
const size_t frameSize = stream->channels * sizeof(int16_t);
|
|
const size_t framesRemaining = (
|
|
stream->platform.position < stream->platform.endFrame
|
|
? stream->platform.endFrame - stream->platform.position
|
|
: 0
|
|
);
|
|
const size_t framesToRead = mathMin(framesRemaining, AUDIO_LINUX_WINDOW_FRAMES);
|
|
if(framesToRead == 0) {
|
|
errorOk();
|
|
}
|
|
|
|
int16_t *chunk = memoryAllocate(framesToRead * frameSize);
|
|
size_t framesRead = 0;
|
|
errorret_t ret = audioStreamRead(stream, chunk, framesToRead, &framesRead);
|
|
if(errorIsNotOk(ret)) {
|
|
memoryFree(chunk);
|
|
errorChain(ret);
|
|
}
|
|
|
|
const size_t bytesRead = framesRead * frameSize;
|
|
int queued;
|
|
|
|
if(stream->volume == 0xFF) {
|
|
// Nothing to mix at full volume - queue the window directly instead of
|
|
// allocating/zeroing a same-size scratch buffer just to copy it in.
|
|
queued = SDL_QueueAudio(stream->platform.device, chunk, (Uint32) bytesRead);
|
|
} else {
|
|
uint8_t *mixed = memoryAllocate(bytesRead);
|
|
memoryZero(mixed, bytesRead);
|
|
SDL_MixAudioFormat(
|
|
mixed, (uint8_t *) chunk, AUDIO_S16SYS, (Uint32) bytesRead,
|
|
(stream->volume * SDL_MIX_MAXVOLUME) / 0xFF
|
|
);
|
|
queued = SDL_QueueAudio(stream->platform.device, mixed, (Uint32) bytesRead);
|
|
memoryFree(mixed);
|
|
}
|
|
memoryFree(chunk);
|
|
|
|
if(queued != 0) {
|
|
errorThrow("Failed to queue SDL2 audio data: %s", SDL_GetError());
|
|
}
|
|
|
|
stream->platform.position += framesRead;
|
|
errorOk();
|
|
}
|
|
|
|
bool_t audioStreamLinuxIsFinished(audiostream_t *stream) {
|
|
assertNotNull(stream, "Stream cannot be NULL.");
|
|
|
|
// Reports "finished" (ready to loop/end) with AUDIO_LINUX_LEAD_FRAMES of
|
|
// margin still queued, rather than waiting for the queue to actually run
|
|
// dry - queuing the next pass this early never causes overlap (SDL's
|
|
// queue is a plain FIFO), it just avoids ever going silent while our
|
|
// once-per-frame Update() notices and catches up. Waiting for truly
|
|
// empty guarantees a gap by definition: silence has already started by
|
|
// the time "empty" can be observed.
|
|
const Uint32 leadBytes = (Uint32) (
|
|
AUDIO_LINUX_LEAD_FRAMES * stream->channels * sizeof(int16_t)
|
|
);
|
|
|
|
if(SDL_GetQueuedAudioSize(stream->platform.device) > leadBytes) {
|
|
return false;
|
|
}
|
|
|
|
// Below the lead margin - if more of the current pass is left to read,
|
|
// top up now rather than reporting finished, which would otherwise
|
|
// trigger a full loop-restart/end while still mid-pass, just because
|
|
// the queue happened to run low.
|
|
if(stream->platform.position < stream->platform.endFrame) {
|
|
errorret_t ret = audioStreamLinuxFeed(stream);
|
|
if(errorIsNotOk(ret)) {
|
|
errorCatch(errorPrint(ret));
|
|
return true; // Can't recover - let the shared layer end/loop it.
|
|
}
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|