Rework PSP audio into a single output thread + main-thread top-up

The reader+player two-thread design (previous commit) still crackled -
both threads shared the same elevated real-time priority and could
contend for the PSP's single core right at the moment the player thread
needed to resume after its blocking output call returned, worse the
bigger the hardware chunk. Confirmed fixed on real hardware (Memory Stick
and pspsh) by removing the second real-time thread entirely.

PCM data now lives in a ring buffer topped up from the MAIN thread once
per engine Update(), mirroring dusklinux's own already-working
audioStreamLinuxFeed()/IsFinished() pattern (same lead/window sizing)
instead of a bespoke second thread. The sole remaining PSP-specific
thread only drains the ring and calls sceAudioOutputPannedBlocking(),
never touching the asset/PCM layer. Loop wraps are now just a transparent
seek-and-continue while filling the ring (it holds one seamless sample
stream, no per-chunk splicing needed) - a small FIFO of loop markers is
the only thing still needed to fire onLoop at the correct audible moment.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-08-31 20:09:15 -05:00
co-authored by Claude Sonnet 5
parent 769f2f5702
commit ac8023d50f
2 changed files with 359 additions and 361 deletions
+129 -100
View File
@@ -11,17 +11,28 @@
typedef struct audiostream_s audiostream_t;
// Depth of the read-ahead queue between the reader and player threads (see
// audiostreampsp_t below) - how many fully-prepared hardware chunks the
// reader is allowed to get ahead of what the player is currently
// outputting. Each slot is one AUDIO_PSP_CHUNK_FRAMES chunk (a few KB), so
// this costs very little memory; it exists purely to give PCM I/O (now a
// real Memory Stick/zip read per chunk, not a RAM copy - see
// audioStreamPcmRead()) enough of a cushion to never stall the player
// thread's real-time output loop. 3 was picked as "more than one" (so a
// single slow read doesn't immediately starve playback) without holding
// much more than necessary.
#define AUDIO_PSP_QUEUE_DEPTH 3
// 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.
#define AUDIO_PSP_RING_FRAMES 16384
// 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.
#define AUDIO_PSP_LEAD_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
@@ -29,74 +40,88 @@ typedef struct {
// expects continuous small-chunk feeding, not one large buffer per call.
int channel;
// Persistent "player" thread, created once in Init and alive for the
// stream's whole lifetime - the only thread that ever calls
// sceAudioOutputPannedBlocking(), taking chunks off the queue below
// rather than reading PCM data itself. Re-spawning a thread on every
// Buffer() call (e.g. every loop restart) was real, avoidable overhead -
// a plain OS thread creation, on top of everything else - heard as a
// small gap between loops; this thread just idles (polling
// playRequested) between plays instead of exiting.
// 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;
// Persistent "reader" thread, created once in Init alongside `thread` -
// does all PCM I/O (audioStreamPcmSeek()/audioStreamPcmRead(), plus loop
// wrap/fade preparation) into the queue below, running ahead of what
// `thread` is currently outputting. sceAudioOutputPannedBlocking() blocks
// the calling thread for the full duration of the chunk it just
// submitted, so there is no spare time on that thread to also do I/O
// in between calls without stalling the hardware channel - that stall is
// exactly what was heard as severe crackling once PCM reads stopped
// being a fully-resident-buffer memcpy (fast, always well inside the
// ~23ms chunk budget) and started being real, sometimes-slow reads. This
// second thread is what buys that time back.
thread_t readerThread;
// Set by audioStreamPSPBuffer() to wake the idling player thread into a
// new pass; cleared by that thread once it picks it up.
// 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;
// Same as playRequested, but for the reader thread - set/cleared
// independently since the two threads pick up a new pass at slightly
// different times (whichever wakes from its idle poll first).
volatile bool_t readRequested;
// Frame offset the next pass should start from - captured synchronously
// from audiostream_t.startFrame by audioStreamPSPBuffer() (which also
// resets that field to 0) rather than read directly by the reader
// thread, since that thread only wakes up asynchronously and
// audiostream_t's shared field may already have moved on to a different
// value by then.
size_t startFrame;
// Set by the player thread once it has output the last chunk of a pass.
// Set by the thread once it has output the last chunk of a pass.
volatile bool_t finished;
// Bounded queue of fully-prepared, constant-size (AUDIO_PSP_CHUNK_FRAMES)
// hardware chunks handed from the reader thread to the player thread.
// queueHead/queueTail/queueCount are only ever touched while holding
// queueLock. Each queueChunk[] buffer is allocated once (in Init) and
// reused for the stream's whole lifetime.
int16_t *queueChunk[AUDIO_PSP_QUEUE_DEPTH];
// 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;
// Per-slot bookkeeping the player thread needs once it plays that chunk:
// whether this was the pass's true final chunk (queueReachedEnd) and, if
// so, whether it was a loop wrap (queueLooped, bump loopCount and keep
// going) or the genuine end (stop and set finished).
bool_t queueReachedEnd[AUDIO_PSP_QUEUE_DEPTH];
bool_t queueLooped[AUDIO_PSP_QUEUE_DEPTH];
// 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;
size_t queueHead; // Next slot index the reader thread will fill.
size_t queueTail; // Next slot index the player thread will consume.
size_t queueCount; // Number of filled-and-ready slots.
threadmutex_t queueLock;
// 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;
// Set by the reader thread if a seek/read fails mid-pass (corrupt or
// truncated asset, I/O error) - observed by the player thread once it
// drains whatever was already queued, so the pass still ends cleanly
// instead of the player waiting forever for a chunk that will never
// arrive.
volatile bool_t readFailed;
// 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;
/**
@@ -110,7 +135,7 @@ errorret_t audioStreamPSPInit(audiostream_t *stream);
/**
* Disposes the PSP-specific playback state of an audio stream, stopping its
* feeder thread and releasing its hardware output channel.
* output thread and releasing its hardware output channel.
*
* @param stream The audio stream to dispose.
* @return Error state if any.
@@ -118,10 +143,11 @@ errorret_t audioStreamPSPInit(audiostream_t *stream);
errorret_t audioStreamPSPDispose(audiostream_t *stream);
/**
* Wakes the stream's persistent reader and player threads to stream PCM
* data (read ahead from the stream's asset via audioStreamPcmRead() by the
* reader thread) to its reserved hardware output channel in small chunks
* until exhausted.
* 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.
@@ -129,8 +155,27 @@ errorret_t audioStreamPSPDispose(audiostream_t *stream);
errorret_t audioStreamPSPBuffer(audiostream_t *stream);
/**
* Checks whether the stream's player thread has finished outputting its
* currently buffered data.
* 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.
@@ -138,32 +183,16 @@ errorret_t audioStreamPSPBuffer(audiostream_t *stream);
bool_t audioStreamPSPIsFinished(audiostream_t *stream);
/**
* Player thread entry point, run once for the stream's whole lifetime.
* Idles (polling playRequested) until woken by audioStreamPSPBuffer(), then
* takes fully-prepared chunks off the queue (filled by the reader thread -
* see audioStreamPSPThreadRead()) and outputs them to the hardware channel
* one at a time, blocking naturally on each sceAudioOutputPannedBlocking()
* call, until the pass's final chunk has been sent - then goes back to
* 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);
/**
* Reader thread entry point, run once for the stream's whole lifetime.
* Idles (polling readRequested) until woken by audioStreamPSPBuffer(), then
* reads the stream's asset PCM data (via audioStreamPcmSeek()/
* audioStreamPcmRead()) and prepares fixed-size hardware chunks (applying
* loop wrap/fade, same as the player thread used to do inline), pushing
* each onto the queue for the player thread to consume - running ahead of
* playback rather than in lockstep with it, so PCM I/O latency never
* stalls the player thread's real-time output loop. Stops producing once
* it queues the pass's final chunk (or a read/seek fails - see
* audiostreampsp_t.readFailed), then goes back to idling until the thread
* is asked to stop.
*
* @param thread The running thread_t, with data set to the audiostream_t.
*/
void audioStreamPSPThreadRead(thread_t *thread);