Files
dusk/src/duskpsp/audio/audiostreampsp.h
T
YourWishesandClaude Sonnet 5 f8f8a80a21 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]>
2026-09-01 08:21:30 -05:00

218 lines
9.9 KiB
C

/**
* 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"
typedef struct audiostream_s audiostream_t;
// How far ahead of hardware playback the ring buffer is allowed to hold
// already-decoded PCM - mirrors dusklinux's AUDIO_LINUX_WINDOW_FRAMES.
// Physical capacity of the ring; must comfortably exceed
// AUDIO_PSP_LEAD_FRAMES plus one top-up step so a single top-up call can
// never overwrite data the output thread hasn't consumed yet.
//
// Sized well above what a PCM stream would ever need on its own, because
// an MP3 stream's loop-wrap (audioStreamMp3DecoderRewind() on PSP) fully
// tears down and recreates the hardware decoder - a real handle release/
// reserve round-trip plus a fresh ~16KB Memory Stick read to re-prime
// sceMp3's stream buffer - all synchronously, inside the same TopUp() call
// that's supposed to be keeping this ring fed. That's slow enough,
// occasionally, to eat into a tighter lead margin and click right at the
// loop point (confirmed on real hardware) - this gives it generous room to
// do so without the output thread ever catching up to empty.
#define AUDIO_PSP_RING_FRAMES 49152
// Top-up threshold - audioStreamPSPTopUp() (called once per engine
// Update(), see audioStreamPSPIsFinished()) only reads more data once the
// ring drops below this, same trigger dusklinux uses for its own lead
// margin. See AUDIO_PSP_RING_FRAMES's own comment for why this is sized
// well beyond dusklinux's equivalent.
#define AUDIO_PSP_LEAD_FRAMES 12288
// Max frames audioStreamPSPTopUp() will read in one call, regardless of
// how much ring room is actually free - keeps each call's worst-case
// blocking time on the main thread bounded and roughly constant even
// though AUDIO_PSP_RING_FRAMES/AUDIO_PSP_LEAD_FRAMES are large; reaching
// LEAD from an empty ring just takes a few calls (a few engine frames)
// instead of one long one. See AUDIO_PSP_RING_FRAMES's own comment.
#define AUDIO_PSP_TOPUP_STEP_FRAMES 4096
// Max number of pending loop-wrap events (see audiostreampsploopmarker_t
// below) the ring can remember at once. Sized generously relative to how
// many loop wraps could conceivably be produced ahead of playback within
// one ring's worth of lookahead; a loop shorter than
// AUDIO_PSP_RING_FRAMES / AUDIO_PSP_LOOP_MARKER_MAX frames could in theory
// overflow this, in which case onLoop simply won't fire for the excess
// wraps until the queue drains - not hit by anything in this codebase
// today (the shared test tone's loop is the whole 1-second clip).
#define AUDIO_PSP_LOOP_MARKER_MAX 8
typedef struct {
// Reserved hardware output channel, from sceAudioChReserve. Reserved with
// a small fixed chunk size (AUDIO_PSP_CHUNK_FRAMES) - PSP audio hardware
// expects continuous small-chunk feeding, not one large buffer per call.
int channel;
// Sole persistent thread, created once in Init and alive for the
// stream's whole lifetime - the only thing that ever calls
// sceAudioOutputPannedBlocking(). Never touches the asset/PCM layer
// itself; only ever drains the ring buffer below in fixed
// AUDIO_PSP_CHUNK_FRAMES chunks, so hardware output timing is never at
// the mercy of a Memory Stick/zip read. Idles (polling playRequested)
// between plays instead of exiting, same reasoning as before.
thread_t thread;
// Set by audioStreamPSPBuffer() to wake the idling thread into a new
// pass; cleared by the thread once it picks it up.
volatile bool_t playRequested;
// Set by the thread once it has output the last chunk of a pass.
volatile bool_t finished;
// Ring buffer of already-decoded, contiguous PCM. Filled by
// audioStreamPSPTopUp() (runs on the MAIN thread, called once per engine
// Update() while playing - see audioStreamPSPIsFinished(), same pattern
// dusklinux uses for audioStreamLinuxFeed()) and drained by the output
// thread above. A loop wrap is just a seek back to loopToFrame partway
// through filling - the ring itself holds one seamless, contiguous
// stream of samples with no seams to splice, unlike the old per-hardware
// -chunk design; the output thread doesn't need to know where a loop
// boundary falls, only when it has *played past* one (see the loop
// marker fields below).
//
// ringReadPos/ringWritePos/ringFilled are the only fields touched by
// both the main thread (producer) and the output thread (consumer);
// both must hold ringLock to touch any of them.
int16_t *ring; // AUDIO_PSP_RING_FRAMES * channels * sizeof(int16_t) bytes
size_t ringReadPos; // next frame index the output thread reads (wraps)
size_t ringWritePos; // next frame index the main thread writes (wraps)
size_t ringFilled; // valid frames currently in the ring
threadmutex_t ringLock;
// Scratch buffer audioStreamPcmRead() decodes into before it's copied
// into the ring - sized to the ring's full capacity since a single
// top-up call can need to fill the entire thing at once (e.g. right at
// pass start, or after a frame-rate dip let the ring run dry). Main-
// thread-only, like the rest of the top-up state below.
int16_t *scratch;
// Total frames ever written into the ring this pass (monotonic, unlike
// ringWritePos which wraps) - what loopMarkerFrames[] values are
// expressed in, so they stay comparable to framesOutput below regardless
// of how many times the physical ring has wrapped around.
size_t framesEnqueued;
// FIFO of pending loop-wrap events: audioStreamPSPTopUp() records
// framesEnqueued's value at the moment it seeks back to loopToFrame:
// once the output thread's own running framesOutput reaches that value,
// it has just played the last sample before the wrap and bumps
// loopCount (never calls onLoop directly - see the loop over these in
// audiostreampsp.c for why). Guarded by ringLock, same as the ring
// itself.
size_t loopMarkerFrames[AUDIO_PSP_LOOP_MARKER_MAX];
size_t loopMarkerHead;
size_t loopMarkerCount;
// Running count of frames the output thread has sent to hardware since
// the current pass started - what loopMarkerFrames[] positions are
// compared against.
size_t framesOutput;
// Main-thread-only sequencing state for the current pass (loop points,
// read position) - only ever touched by audioStreamPSPBuffer()/
// audioStreamPSPTopUp(), both of which only ever run on the main thread,
// so none of this needs locking.
size_t totalFrames;
size_t loopEndFrame;
size_t loopToFrame;
size_t readPosition;
// Set by audioStreamPSPTopUp() once it has written the pass's true final
// frame (not a loop wrap - a genuine, non-looping end or a read/seek
// failure) - it stops reading further once either is set. The output
// thread treats "readReachedEnd (or readFailed) and the ring holds at
// most one hardware chunk" as its cue that whatever's left is the
// pass's last, possibly-partial chunk.
bool_t readReachedEnd;
bool_t readFailed;
} audiostreampsp_t;
/**
* Initializes the PSP-specific playback state of an audio stream (e.g.
* reserving a hardware output channel via sceAudioChReserve).
*
* @param stream The audio stream to initialize.
* @return Error state if any.
*/
errorret_t audioStreamPSPInit(audiostream_t *stream);
/**
* Disposes the PSP-specific playback state of an audio stream, stopping its
* output thread and releasing its hardware output channel.
*
* @param stream The audio stream to dispose.
* @return Error state if any.
*/
errorret_t audioStreamPSPDispose(audiostream_t *stream);
/**
* Starts a new playback pass: resets the ring buffer and loop-point
* bookkeeping, seeks the asset to the pass's start frame, and wakes the
* persistent output thread. Actual PCM reading happens afterward, driven
* by audioStreamPSPTopUp() (see audioStreamPSPIsFinished()) rather than
* here.
*
* @param stream The audio stream to output.
* @return Error state if any.
*/
errorret_t audioStreamPSPBuffer(audiostream_t *stream);
/**
* Tops up the stream's ring buffer from the main thread if it has fallen
* below AUDIO_PSP_LEAD_FRAMES, reading enough of the asset in one call to
* fill whatever room the ring currently has (bounded by the current loop
* segment/clip end) - not just one small fixed step, so a temporary
* engine frame-rate dip can't let production fall permanently behind
* playback's consumption rate. Seeks back to loopToFrame and records a
* loop marker if this read crosses a looping pass's loop point, or marks
* readReachedEnd on a genuine end/failure. Called once per engine Update()
* via audioStreamPSPIsFinished(), same as dusklinux's own feed-on-poll
* pattern. A no-op once readReachedEnd or readFailed is set, since there's
* nothing left to add.
*
* @param stream The audio stream to top up.
*/
void audioStreamPSPTopUp(audiostream_t *stream);
/**
* Checks whether the stream's output thread has finished outputting its
* currently buffered data - also drives audioStreamPSPTopUp() each call,
* since this is invoked exactly once per engine Update() while the stream
* is playing (see audioStreamUpdate()).
*
* @param stream The audio stream to check.
* @return true if playback has finished, false otherwise.
*/
bool_t audioStreamPSPIsFinished(audiostream_t *stream);
/**
* Output thread entry point, run once for the stream's whole lifetime.
* Idles (polling playRequested) until woken by audioStreamPSPBuffer(),
* then repeatedly waits for a full hardware chunk to become available in
* the ring buffer (topped up by the main thread - see
* audioStreamPSPTopUp()) and outputs it, blocking naturally on each
* sceAudioOutputPannedBlocking() call, until the pass's final
* (possibly-partial, faded) chunk has been sent - then goes back to
* idling, ready for the next play request, until the thread is asked to
* stop. Never touches the asset/PCM layer directly.
*
* @param thread The running thread_t, with data set to the audiostream_t.
*/
void audioStreamPSPThreadFeed(thread_t *thread);