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 <noreply@anthropic.com>
This commit is contained in:
2026-08-31 20:09:15 -05:00
parent 769f2f5702
commit ac8023d50f
2 changed files with 359 additions and 361 deletions
+230 -261
View File
@@ -15,17 +15,17 @@
// Matches pspaudiolib's own PSP_NUM_AUDIO_SAMPLES convention - PSP audio // Matches pspaudiolib's own PSP_NUM_AUDIO_SAMPLES convention - PSP audio
// hardware is meant to be fed small chunks continuously, not one large // hardware is meant to be fed small chunks continuously, not one large
// buffer per call. // buffer per call. This is purely an output granularity now - see
// audioStreamPSPTopUp() for how much it reads per call.
#define AUDIO_PSP_CHUNK_FRAMES 1024 #define AUDIO_PSP_CHUNK_FRAMES 1024
// PSP priorities are inverted (lower = higher priority). This project's // PSP priorities are inverted (lower = higher priority). This project's
// main thread runs at the PSPSDK default of 32 (PSP_MAIN_THREAD_PRIORITY // main thread runs at the PSPSDK default of 32 (PSP_MAIN_THREAD_PRIORITY
// is never overridden). A pthread created with default attributes runs at // is never overridden). A pthread created with default attributes runs at
// 60 - LOWER priority than the main/render thread - so under load either // 60 - LOWER priority than the main/render thread - so under load the
// worker thread would starve and the hardware channel underruns, heard as // output thread would starve and the hardware channel underruns, heard as
// jitter/crackle that gets worse the busier (lower-fps) a frame is. Raise // jitter/crackle that gets worse the busier (lower-fps) a frame is. Raise
// both the player and reader threads above main so audio feeding always // it above main so audio feeding always wins scheduling contention.
// wins scheduling contention.
#define AUDIO_PSP_THREAD_PRIORITY 18 #define AUDIO_PSP_THREAD_PRIORITY 18
// How many frames at the very end of a stream get linearly faded to zero, // How many frames at the very end of a stream get linearly faded to zero,
@@ -34,12 +34,9 @@
// just stopping outright on an exact-multiple-length one). // just stopping outright on an exact-multiple-length one).
#define AUDIO_PSP_FADE_FRAMES 32 #define AUDIO_PSP_FADE_FRAMES 32
// How long the persistent player/reader threads sleep between polls - both // How long the persistent output thread sleeps between checks - both for
// for a new play request while idle, and for queue slots while active. // a new play request while idle, and for the ring buffer to have a full
// Cheap compared to spawning a whole new OS thread per play (what this // chunk ready while active.
// replaced) - worst case this is the latency added between one playback
// pass finishing and the next one starting, or between a queue slot
// freeing up and the waiting thread noticing.
#define AUDIO_PSP_IDLE_POLL_MICROS 500 #define AUDIO_PSP_IDLE_POLL_MICROS 500
errorret_t audioStreamPSPInit(audiostream_t *stream) { errorret_t audioStreamPSPInit(audiostream_t *stream) {
@@ -76,42 +73,31 @@ errorret_t audioStreamPSPInit(audiostream_t *stream) {
stream->platform.channel = channel; stream->platform.channel = channel;
stream->platform.finished = false; stream->platform.finished = false;
stream->platform.playRequested = false; stream->platform.playRequested = false;
stream->platform.readRequested = false;
stream->platform.readFailed = false;
stream->platform.queueHead = 0;
stream->platform.queueTail = 0;
stream->platform.queueCount = 0;
const size_t chunkSize = const size_t ringSize =
AUDIO_PSP_CHUNK_FRAMES * stream->pcm.channels * sizeof(int16_t); AUDIO_PSP_RING_FRAMES * stream->pcm.channels * sizeof(int16_t);
for(size_t i = 0; i < AUDIO_PSP_QUEUE_DEPTH; i++) { stream->platform.ring = memoryAllocate(ringSize);
stream->platform.queueChunk[i] = memoryAllocate(chunkSize); stream->platform.scratch = memoryAllocate(ringSize);
} threadMutexInit(&stream->platform.ringLock);
threadMutexInit(&stream->platform.queueLock);
threadInit(&stream->platform.thread, audioStreamPSPThreadFeed); threadInit(&stream->platform.thread, audioStreamPSPThreadFeed);
stream->platform.thread.data = stream; stream->platform.thread.data = stream;
threadStartRequest(&stream->platform.thread); threadStartRequest(&stream->platform.thread);
threadInit(&stream->platform.readerThread, audioStreamPSPThreadRead);
stream->platform.readerThread.data = stream;
threadStartRequest(&stream->platform.readerThread);
errorOk(); errorOk();
} }
errorret_t audioStreamPSPDispose(audiostream_t *stream) { errorret_t audioStreamPSPDispose(audiostream_t *stream) {
assertNotNull(stream, "Stream cannot be NULL."); assertNotNull(stream, "Stream cannot be NULL.");
threadStop(&stream->platform.readerThread);
threadStop(&stream->platform.thread); threadStop(&stream->platform.thread);
sceAudioChRelease(stream->platform.channel); sceAudioChRelease(stream->platform.channel);
for(size_t i = 0; i < AUDIO_PSP_QUEUE_DEPTH; i++) { memoryFree(stream->platform.ring);
memoryFree(stream->platform.queueChunk[i]); stream->platform.ring = NULL;
stream->platform.queueChunk[i] = NULL; memoryFree(stream->platform.scratch);
} stream->platform.scratch = NULL;
threadMutexDispose(&stream->platform.queueLock); threadMutexDispose(&stream->platform.ringLock);
errorOk(); errorOk();
} }
@@ -120,222 +106,161 @@ errorret_t audioStreamPSPBuffer(audiostream_t *stream) {
assertNotNull(stream, "Stream cannot be NULL."); assertNotNull(stream, "Stream cannot be NULL.");
stream->platform.finished = false; stream->platform.finished = false;
stream->platform.startFrame = stream->startFrame; stream->platform.readReachedEnd = false;
stream->startFrame = 0;
stream->seeking = false;
stream->platform.readFailed = false; stream->platform.readFailed = false;
threadMutexLock(&stream->platform.queueLock); stream->platform.totalFrames = audioStreamPcmGetTotalFrames(stream);
stream->platform.queueHead = 0;
stream->platform.queueTail = 0;
stream->platform.queueCount = 0;
threadMutexUnlock(&stream->platform.queueLock);
// Give the reader a head start filling the queue before the player // loopEndFrame/loopToFrame define the loop segment [loopToFrame,
// starts draining it - not required for correctness (the player just // loopEndFrame) that a looping pass wraps within, once it's reached -
// waits for the first ready slot either way), but avoids guaranteeing an // defaulting to the whole clip (loopStart == -1, loopTo == 0) so
// initial stall on every pass. // behaviour is unchanged when no explicit loop points are configured.
stream->platform.readRequested = true; stream->platform.loopEndFrame = stream->loopStart >= 0
? mathMin(
(size_t) (stream->loopStart * stream->pcm.sampleRate),
stream->platform.totalFrames
)
: stream->platform.totalFrames;
stream->platform.loopToFrame = mathMin(
(size_t) (stream->loopTo * stream->pcm.sampleRate),
stream->platform.loopEndFrame
);
const size_t startFrame = mathMin(stream->startFrame, stream->platform.totalFrames);
stream->startFrame = 0;
stream->seeking = false;
errorChain(audioStreamPcmSeek(stream, startFrame));
stream->platform.readPosition = startFrame;
threadMutexLock(&stream->platform.ringLock);
stream->platform.ringReadPos = 0;
stream->platform.ringWritePos = 0;
stream->platform.ringFilled = 0;
stream->platform.framesEnqueued = 0;
stream->platform.loopMarkerHead = 0;
stream->platform.loopMarkerCount = 0;
threadMutexUnlock(&stream->platform.ringLock);
stream->platform.framesOutput = 0;
stream->platform.playRequested = true; stream->platform.playRequested = true;
errorOk(); errorOk();
} }
bool_t audioStreamPSPIsFinished(audiostream_t *stream) { void audioStreamPSPTopUp(audiostream_t *stream) {
assertNotNull(stream, "Stream cannot be NULL."); assertNotNull(stream, "Stream cannot be NULL.");
return stream->platform.finished; if(stream->platform.readReachedEnd || stream->platform.readFailed) return;
}
void audioStreamPSPThreadRead(thread_t *thread) { threadMutexLock(&stream->platform.ringLock);
assertNotNull(thread, "Thread cannot be NULL."); const size_t filled = stream->platform.ringFilled;
threadMutexUnlock(&stream->platform.ringLock);
if(filled >= AUDIO_PSP_LEAD_FRAMES) return;
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->pcm.channels;
const size_t frameSize = channels * sizeof(int16_t); const size_t frameSize = channels * sizeof(int16_t);
// Runs for the stream's whole lifetime - idles here between plays rather // Fill however much room the ring actually has in this one call, not
// than exiting, same as the player thread. // just a small fixed step - otherwise a temporary engine frame-rate dip
while(!threadShouldStop(thread)) { // (below roughly one hardware chunk's worth of playback time per frame)
if(!stream->platform.readRequested) { // would let production permanently fall behind consumption, since a
sceKernelDelayThread(AUDIO_PSP_IDLE_POLL_MICROS); // fixed-size top-up per call can never make up lost ground. Bounded by
continue; // 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;
const size_t currentEndFrame = stream->platform.readPosition < stream->platform.loopEndFrame
? stream->platform.loopEndFrame
: stream->platform.totalFrames;
const size_t framesRemainingInSegment = currentEndFrame - stream->platform.readPosition;
const size_t framesToRead = mathMin(room, framesRemainingInSegment);
const bool_t reachesSegmentEnd = framesToRead == framesRemainingInSegment;
const bool_t willLoop = reachesSegmentEnd &&
(stream->state & AUDIO_STREAM_STATE_LOOPING);
if(framesToRead > 0) {
int16_t *scratch = stream->platform.scratch;
size_t framesRead = 0;
if(errorIsNotOk(
audioStreamPcmRead(stream, scratch, framesToRead, &framesRead)
)) {
stream->platform.readFailed = true;
return;
}
if(framesRead < framesToRead) {
// The asset is shorter than its declared header size (corrupt or
// truncated) - pad what's missing with silence.
memoryZero(
scratch + (framesRead * channels), (framesToRead - framesRead) * frameSize
);
} }
stream->platform.readRequested = false;
const size_t totalFrames = audioStreamPcmGetTotalFrames(stream); threadMutexLock(&stream->platform.ringLock);
for(size_t i = 0; i < framesToRead; i++) {
const size_t writeIndex = (stream->platform.ringWritePos + i) % AUDIO_PSP_RING_FRAMES;
memoryCopy(
stream->platform.ring + (writeIndex * channels),
scratch + (i * channels),
frameSize
);
}
stream->platform.ringWritePos =
(stream->platform.ringWritePos + framesToRead) % AUDIO_PSP_RING_FRAMES;
stream->platform.ringFilled += framesToRead;
stream->platform.framesEnqueued += framesToRead;
threadMutexUnlock(&stream->platform.ringLock);
// loopEndFrame/loopToFrame define the loop segment [loopToFrame, stream->platform.readPosition += framesToRead;
// loopEndFrame) that a looping pass wraps within, once it's reached - }
// defaulting to the whole buffer (loopStart == -1, loopTo == 0) so
// behaviour is unchanged when no explicit loop points are configured.
const size_t loopEndFrame = stream->loopStart >= 0
? mathMin((size_t) (stream->loopStart * stream->pcm.sampleRate), totalFrames)
: totalFrames;
const size_t loopToFrame = mathMin(
(size_t) (stream->loopTo * stream->pcm.sampleRate), loopEndFrame
);
// Only the very first pass honors an explicit seek (audioStreamSetPosition, if(reachesSegmentEnd) {
// captured into platform.startFrame by audioStreamPSPBuffer()) - every if(willLoop) {
// subsequent loop wraps to loopToFrame instead. if(errorIsNotOk(audioStreamPcmSeek(stream, stream->platform.loopToFrame))) {
size_t position = stream->platform.startFrame;
bool_t reachedEnd = false;
bool_t readFailed = errorIsNotOk(audioStreamPcmSeek(stream, position));
if(readFailed) stream->platform.readFailed = true;
while(!readFailed && !threadShouldStop(thread) && !reachedEnd) {
// Wait for a free slot in the queue before preparing the next chunk -
// this is the only thing that paces the reader against the player.
// If the thread is asked to stop while waiting, give up on this pass
// entirely rather than finishing it.
bool_t stopRequested = false;
for(;;) {
threadMutexLock(&stream->platform.queueLock);
bool_t hasRoom = stream->platform.queueCount < AUDIO_PSP_QUEUE_DEPTH;
threadMutexUnlock(&stream->platform.queueLock);
if(hasRoom) break;
if(threadShouldStop(thread)) {
stopRequested = true;
break;
}
sceKernelDelayThread(AUDIO_PSP_IDLE_POLL_MICROS);
}
if(stopRequested) break;
// Normally bounded by loopEndFrame (the loop segment's end), but a
// seek can legitimately land past it (e.g. into an outro after the
// loop point) - in that case play out to the true end of the buffer
// once instead of underflowing framesRemaining.
const size_t currentEndFrame = position < loopEndFrame
? loopEndFrame
: totalFrames;
const size_t framesRemaining = currentEndFrame - position;
const size_t framesThisChunk = framesRemaining < AUDIO_PSP_CHUNK_FRAMES
? framesRemaining
: AUDIO_PSP_CHUNK_FRAMES;
const bool_t reachesEndThisChunk = framesThisChunk == framesRemaining;
const bool_t willLoop = reachesEndThisChunk &&
(stream->state & AUDIO_STREAM_STATE_LOOPING);
const size_t slot = stream->platform.queueHead;
int16_t *chunk = stream->platform.queueChunk[slot];
size_t framesRead = 0;
if(errorIsNotOk(
audioStreamPcmRead(stream, chunk, framesThisChunk, &framesRead)
)) {
readFailed = true;
stream->platform.readFailed = true; stream->platform.readFailed = true;
break; return;
} }
if(framesRead < framesThisChunk) { stream->platform.readPosition = stream->platform.loopToFrame;
// The asset is shorter than its declared header size (corrupt or
// truncated) - pad what's missing with silence rather than play threadMutexLock(&stream->platform.ringLock);
// whatever was left in `chunk` from a previous pass. if(stream->platform.loopMarkerCount < AUDIO_PSP_LOOP_MARKER_MAX) {
memoryZero( const size_t index = (
chunk + (framesRead * channels), stream->platform.loopMarkerHead + stream->platform.loopMarkerCount
(framesThisChunk - framesRead) * frameSize ) % AUDIO_PSP_LOOP_MARKER_MAX;
); stream->platform.loopMarkerFrames[index] = stream->platform.framesEnqueued;
} stream->platform.loopMarkerCount++;
const size_t remainderFrames = AUDIO_PSP_CHUNK_FRAMES - framesThisChunk;
size_t wrapFrames = 0;
if(reachesEndThisChunk && willLoop) {
// Instead of padding the rest of this constant-size chunk with
// silence, fill it with the start of the loop segment (loopToFrame)
// - a looping stream should never have dead air baked into its
// output at all. Not faded (unlike the true-end case below): a wrap
// only sounds seamless if the source data already loops cleanly
// (true for the shared test tone, deliberately authored at 441Hz
// for exactly this), matching the same assumption Dolphin's native
// hardware looping makes - imperfectly-authored loop content will
// still click here.
//
// NOTE: if the loop segment (loopEndFrame - loopToFrame) is shorter
// than one hardware chunk (AUDIO_PSP_CHUNK_FRAMES, ~23ms @ 44100Hz),
// only a single copy of it fills the remainder here rather than
// repeating it to fill the whole chunk - any leftover space is
// zero-padded (a brief, audible gap) and loopCount still only
// increments once per hardware chunk, not once per actual loop
// repeat. Not hit by anything in this codebase today (the shared
// test tone's default whole-buffer loop is 1 second), but a future
// short music-loop tail would need this generalized to a
// repeat-fill instead.
const size_t loopSegmentFrames = loopEndFrame - loopToFrame;
wrapFrames = remainderFrames < loopSegmentFrames
? remainderFrames
: loopSegmentFrames;
if(errorIsNotOk(audioStreamPcmSeek(stream, loopToFrame))) {
readFailed = true;
stream->platform.readFailed = true;
break;
}
size_t wrapRead = 0;
if(errorIsNotOk(audioStreamPcmRead(
stream, chunk + (framesThisChunk * channels), wrapFrames, &wrapRead
))) {
readFailed = true;
stream->platform.readFailed = true;
break;
}
if(wrapRead < remainderFrames) {
memoryZero(
chunk + ((framesThisChunk + wrapRead) * channels),
(remainderFrames - wrapRead) * frameSize
);
}
} else if(reachesEndThisChunk) {
// True final chunk (not looping again) - pad with silence and fade
// the real tail down to zero so the waveform never stops (or meets
// that padding) at a non-zero amplitude, which is what was heard
// as a click at the end of playback.
memoryZero(chunk + (framesThisChunk * channels), remainderFrames * frameSize);
const size_t fadeFrames = framesThisChunk < AUDIO_PSP_FADE_FRAMES
? framesThisChunk
: AUDIO_PSP_FADE_FRAMES;
for(size_t i = 0; i < fadeFrames; i++) {
const size_t frame = framesThisChunk - fadeFrames + i;
const float_t factor = 1.0f - ((float_t) (i + 1) / (float_t) fadeFrames);
for(size_t c = 0; c < channels; c++) {
int16_t *sample = &chunk[frame * channels + c];
*sample = (int16_t) ((float_t) *sample * factor);
}
}
}
threadMutexLock(&stream->platform.queueLock);
stream->platform.queueReachedEnd[slot] = reachesEndThisChunk;
stream->platform.queueLooped[slot] = reachesEndThisChunk && willLoop;
stream->platform.queueHead = (slot + 1) % AUDIO_PSP_QUEUE_DEPTH;
stream->platform.queueCount++;
threadMutexUnlock(&stream->platform.queueLock);
if(reachesEndThisChunk) {
if(willLoop) {
position = loopToFrame + wrapFrames;
} else {
reachedEnd = true;
}
} else {
position += framesThisChunk;
} }
threadMutexUnlock(&stream->platform.ringLock);
} else {
stream->platform.readReachedEnd = true;
} }
} }
} }
bool_t audioStreamPSPIsFinished(audiostream_t *stream) {
assertNotNull(stream, "Stream cannot be NULL.");
// Only ever called while this stream is actively playing (see
// audioStreamUpdate()) - piggybacking the per-frame top-up here mirrors
// dusklinux's own audioStreamLinuxIsFinished()/audioStreamLinuxFeed()
// pattern, rather than needing a dedicated thread to do PCM I/O.
audioStreamPSPTopUp(stream);
return stream->platform.finished;
}
void audioStreamPSPThreadFeed(thread_t *thread) { void audioStreamPSPThreadFeed(thread_t *thread) {
assertNotNull(thread, "Thread cannot be NULL."); assertNotNull(thread, "Thread cannot be NULL.");
sceKernelChangeThreadPriority(sceKernelGetThreadId(), AUDIO_PSP_THREAD_PRIORITY); sceKernelChangeThreadPriority(sceKernelGetThreadId(), AUDIO_PSP_THREAD_PRIORITY);
audiostream_t *stream = (audiostream_t *) thread->data; audiostream_t *stream = (audiostream_t *) thread->data;
const size_t channels = stream->pcm.channels;
const size_t frameSize = channels * sizeof(int16_t);
int16_t *chunk = memoryAllocate(AUDIO_PSP_CHUNK_FRAMES * frameSize);
// Runs for the stream's whole lifetime - idles here between plays rather // Runs for the stream's whole lifetime - idles here between plays rather
// than exiting, so a loop restart (or any replay) is just a flag flip // than exiting, so a loop restart (or any replay) is just a flag flip
@@ -352,32 +277,25 @@ void audioStreamPSPThreadFeed(thread_t *thread) {
// Every call always sends a full, constant-size AUDIO_PSP_CHUNK_FRAMES // Every call always sends a full, constant-size AUDIO_PSP_CHUNK_FRAMES
// buffer - the channel is never re-declared to a different length, to // buffer - the channel is never re-declared to a different length, to
// avoid relying on sceAudioSetChannelDataLen's undocumented behavior // avoid relying on sceAudioSetChannelDataLen's undocumented behavior
// mid-stream (the likely real cause of an earlier click). Loops // mid-stream. Loops internally (rather than going idle and waiting for
// internally (rather than going idle and waiting for the engine's // the engine's once-per-frame Update() to notice finished and
// once-per-frame Update() to notice finished and re-trigger Buffer()) // re-trigger Buffer()) so a looping stream never has a
// so a looping stream never has a detection-latency gap - the generic // detection-latency gap.
// engine's finished+looping path is designed for platforms with no
// better option (see audiostream.c), but here the thread can just
// keep going and call onLoop itself instead.
while(!threadShouldStop(thread) && !reachedEnd) { while(!threadShouldStop(thread) && !reachedEnd) {
// Wait for the reader thread to have a chunk ready. sceAudioOutput- // Wait until either a full hardware chunk is ready in the ring, or
// PannedBlocking() below blocks this thread for the full duration of // the main thread has stopped producing (a genuine end or a read
// whatever chunk it's given, so there is no spare time here to also // failure) and whatever's left (0..one chunk) is all there'll ever
// do the PCM read/seek itself without stalling the hardware channel // be - the pass's final, possibly-partial chunk.
// - that's exactly what the reader thread (audioStreamPSPThreadRead) size_t available = 0;
// exists to do concurrently, ahead of what's currently playing.
bool_t stopRequested = false; bool_t stopRequested = false;
size_t slot = 0;
for(;;) { for(;;) {
threadMutexLock(&stream->platform.queueLock); threadMutexLock(&stream->platform.ringLock);
bool_t hasChunk = stream->platform.queueCount > 0; available = stream->platform.ringFilled;
bool_t gaveUp = !hasChunk && stream->platform.readFailed; threadMutexUnlock(&stream->platform.ringLock);
slot = stream->platform.queueTail;
threadMutexUnlock(&stream->platform.queueLock); if(available >= AUDIO_PSP_CHUNK_FRAMES) break;
if(hasChunk) break; if(stream->platform.readReachedEnd || stream->platform.readFailed) break;
// The reader already failed and has nothing left queued - it will if(threadShouldStop(thread)) {
// never produce another chunk for this pass, so stop waiting.
if(gaveUp || threadShouldStop(thread)) {
stopRequested = true; stopRequested = true;
break; break;
} }
@@ -385,9 +303,57 @@ void audioStreamPSPThreadFeed(thread_t *thread) {
} }
if(stopRequested) break; if(stopRequested) break;
int16_t *chunk = stream->platform.queueChunk[slot]; // Production has stopped and what's left fits in one chunk (0 up to
const bool_t chunkReachedEnd = stream->platform.queueReachedEnd[slot]; // AUDIO_PSP_CHUNK_FRAMES - it can never be more, since production
const bool_t chunkLooped = stream->platform.queueLooped[slot]; // never adds more once readReachedEnd/readFailed is set) - this is
// the pass's last chunk.
const bool_t isFinalChunk =
(stream->platform.readReachedEnd || stream->platform.readFailed) &&
available <= AUDIO_PSP_CHUNK_FRAMES;
const size_t framesThisChunk = isFinalChunk
? available
: AUDIO_PSP_CHUNK_FRAMES;
threadMutexLock(&stream->platform.ringLock);
for(size_t i = 0; i < framesThisChunk; i++) {
const size_t readIndex = (stream->platform.ringReadPos + i) % AUDIO_PSP_RING_FRAMES;
memoryCopy(
chunk + (i * channels),
stream->platform.ring + (readIndex * channels),
frameSize
);
}
stream->platform.ringReadPos =
(stream->platform.ringReadPos + framesThisChunk) % AUDIO_PSP_RING_FRAMES;
stream->platform.ringFilled -= framesThisChunk;
threadMutexUnlock(&stream->platform.ringLock);
if(framesThisChunk < AUDIO_PSP_CHUNK_FRAMES) {
// True final chunk is shorter than a full hardware chunk - pad the
// rest with silence rather than play whatever was left in `chunk`
// from a previous pass.
memoryZero(
chunk + (framesThisChunk * channels),
(AUDIO_PSP_CHUNK_FRAMES - framesThisChunk) * frameSize
);
}
if(isFinalChunk) {
// Fade the real tail down to zero so the waveform never stops (or
// meets padding) at a non-zero amplitude, which is what was heard
// as a click at the end of playback.
const size_t fadeFrames = framesThisChunk < AUDIO_PSP_FADE_FRAMES
? framesThisChunk
: AUDIO_PSP_FADE_FRAMES;
for(size_t i = 0; i < fadeFrames; i++) {
const size_t frame = framesThisChunk - fadeFrames + i;
const float_t factor = 1.0f - ((float_t) (i + 1) / (float_t) fadeFrames);
for(size_t c = 0; c < channels; c++) {
int16_t *sample = &chunk[frame * channels + c];
*sample = (int16_t) ((float_t) *sample * factor);
}
}
}
// Re-read every chunk (~23ms at 44100Hz) so SetVolume/SetDirectionality // Re-read every chunk (~23ms at 44100Hz) so SetVolume/SetDirectionality
// take effect mid-playback, unlike the platform's other one-shot calls. // take effect mid-playback, unlike the platform's other one-shot calls.
@@ -402,29 +368,32 @@ void audioStreamPSPThreadFeed(thread_t *thread) {
stream->platform.channel, leftVolume, rightVolume, chunk stream->platform.channel, leftVolume, rightVolume, chunk
); );
// Only freed (queueCount decremented) after the blocking output call stream->platform.framesOutput += framesThisChunk;
// above returns - the reader thread must never be able to reuse this
// slot's buffer while sceAudioOutputPannedBlocking is still reading
// from it.
threadMutexLock(&stream->platform.queueLock);
stream->platform.queueTail = (slot + 1) % AUDIO_PSP_QUEUE_DEPTH;
stream->platform.queueCount--;
threadMutexUnlock(&stream->platform.queueLock);
if(chunkReachedEnd) { // Fire any loop markers this chunk just played past - never call
if(chunkLooped) { // stream->onLoop directly from this thread, since it may do
// Never call stream->onLoop directly from this thread - it may do // arbitrary, possibly-slow work (this is exactly what caused a loud
// arbitrary, possibly-slow work (this is exactly what caused a // crackle in the past: onLoop console-printing took long enough to
// loud crackle: onLoop console-printing took long enough to // starve the next chunk). audioStreamUpdate() picks up loopCount
// starve the next chunk). audioStreamUpdate() picks this up and // changes and fires onLoop safely from the main thread instead.
// fires onLoop safely from the main thread instead. threadMutexLock(&stream->platform.ringLock);
stream->loopCount++; while(
} else { stream->platform.loopMarkerCount > 0 &&
reachedEnd = true; stream->platform.loopMarkerFrames[stream->platform.loopMarkerHead] <=
} stream->platform.framesOutput
) {
stream->platform.loopMarkerHead =
(stream->platform.loopMarkerHead + 1) % AUDIO_PSP_LOOP_MARKER_MAX;
stream->platform.loopMarkerCount--;
stream->loopCount++;
} }
threadMutexUnlock(&stream->platform.ringLock);
if(isFinalChunk) reachedEnd = true;
} }
stream->platform.finished = true; stream->platform.finished = true;
} }
memoryFree(chunk);
} }
+129 -100
View File
@@ -11,17 +11,28 @@
typedef struct audiostream_s audiostream_t; typedef struct audiostream_s audiostream_t;
// Depth of the read-ahead queue between the reader and player threads (see // How far ahead of hardware playback the ring buffer is allowed to hold
// audiostreampsp_t below) - how many fully-prepared hardware chunks the // already-decoded PCM - mirrors dusklinux's AUDIO_LINUX_WINDOW_FRAMES.
// reader is allowed to get ahead of what the player is currently // Physical capacity of the ring; must comfortably exceed
// outputting. Each slot is one AUDIO_PSP_CHUNK_FRAMES chunk (a few KB), so // AUDIO_PSP_LEAD_FRAMES plus one top-up step so a single top-up call can
// this costs very little memory; it exists purely to give PCM I/O (now a // never overwrite data the output thread hasn't consumed yet.
// real Memory Stick/zip read per chunk, not a RAM copy - see #define AUDIO_PSP_RING_FRAMES 16384
// 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 // Top-up threshold - audioStreamPSPTopUp() (called once per engine
// single slow read doesn't immediately starve playback) without holding // Update(), see audioStreamPSPIsFinished()) only reads more data once the
// much more than necessary. // ring drops below this, same trigger dusklinux uses for its own lead
#define AUDIO_PSP_QUEUE_DEPTH 3 // 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 { typedef struct {
// Reserved hardware output channel, from sceAudioChReserve. Reserved with // 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. // expects continuous small-chunk feeding, not one large buffer per call.
int channel; int channel;
// Persistent "player" thread, created once in Init and alive for the // Sole persistent thread, created once in Init and alive for the
// stream's whole lifetime - the only thread that ever calls // stream's whole lifetime - the only thing that ever calls
// sceAudioOutputPannedBlocking(), taking chunks off the queue below // sceAudioOutputPannedBlocking(). Never touches the asset/PCM layer
// rather than reading PCM data itself. Re-spawning a thread on every // itself; only ever drains the ring buffer below in fixed
// Buffer() call (e.g. every loop restart) was real, avoidable overhead - // AUDIO_PSP_CHUNK_FRAMES chunks, so hardware output timing is never at
// a plain OS thread creation, on top of everything else - heard as a // the mercy of a Memory Stick/zip read. Idles (polling playRequested)
// small gap between loops; this thread just idles (polling // between plays instead of exiting, same reasoning as before.
// playRequested) between plays instead of exiting.
thread_t thread; thread_t thread;
// Persistent "reader" thread, created once in Init alongside `thread` - // Set by audioStreamPSPBuffer() to wake the idling thread into a new
// does all PCM I/O (audioStreamPcmSeek()/audioStreamPcmRead(), plus loop // pass; cleared by the thread once it picks it up.
// 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.
volatile bool_t playRequested; volatile bool_t playRequested;
// Same as playRequested, but for the reader thread - set/cleared // Set by the thread once it has output the last chunk of a pass.
// 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.
volatile bool_t finished; volatile bool_t finished;
// Bounded queue of fully-prepared, constant-size (AUDIO_PSP_CHUNK_FRAMES) // Ring buffer of already-decoded, contiguous PCM. Filled by
// hardware chunks handed from the reader thread to the player thread. // audioStreamPSPTopUp() (runs on the MAIN thread, called once per engine
// queueHead/queueTail/queueCount are only ever touched while holding // Update() while playing - see audioStreamPSPIsFinished(), same pattern
// queueLock. Each queueChunk[] buffer is allocated once (in Init) and // dusklinux uses for audioStreamLinuxFeed()) and drained by the output
// reused for the stream's whole lifetime. // thread above. A loop wrap is just a seek back to loopToFrame partway
int16_t *queueChunk[AUDIO_PSP_QUEUE_DEPTH]; // 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: // Scratch buffer audioStreamPcmRead() decodes into before it's copied
// whether this was the pass's true final chunk (queueReachedEnd) and, if // into the ring - sized to the ring's full capacity since a single
// so, whether it was a loop wrap (queueLooped, bump loopCount and keep // top-up call can need to fill the entire thing at once (e.g. right at
// going) or the genuine end (stop and set finished). // pass start, or after a frame-rate dip let the ring run dry). Main-
bool_t queueReachedEnd[AUDIO_PSP_QUEUE_DEPTH]; // thread-only, like the rest of the top-up state below.
bool_t queueLooped[AUDIO_PSP_QUEUE_DEPTH]; int16_t *scratch;
size_t queueHead; // Next slot index the reader thread will fill. // Total frames ever written into the ring this pass (monotonic, unlike
size_t queueTail; // Next slot index the player thread will consume. // ringWritePos which wraps) - what loopMarkerFrames[] values are
size_t queueCount; // Number of filled-and-ready slots. // expressed in, so they stay comparable to framesOutput below regardless
threadmutex_t queueLock; // 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 // FIFO of pending loop-wrap events: audioStreamPSPTopUp() records
// truncated asset, I/O error) - observed by the player thread once it // framesEnqueued's value at the moment it seeks back to loopToFrame:
// drains whatever was already queued, so the pass still ends cleanly // once the output thread's own running framesOutput reaches that value,
// instead of the player waiting forever for a chunk that will never // it has just played the last sample before the wrap and bumps
// arrive. // loopCount (never calls onLoop directly - see the loop over these in
volatile bool_t readFailed; // 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; } audiostreampsp_t;
/** /**
@@ -110,7 +135,7 @@ errorret_t audioStreamPSPInit(audiostream_t *stream);
/** /**
* Disposes the PSP-specific playback state of an audio stream, stopping its * 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. * @param stream The audio stream to dispose.
* @return Error state if any. * @return Error state if any.
@@ -118,10 +143,11 @@ errorret_t audioStreamPSPInit(audiostream_t *stream);
errorret_t audioStreamPSPDispose(audiostream_t *stream); errorret_t audioStreamPSPDispose(audiostream_t *stream);
/** /**
* Wakes the stream's persistent reader and player threads to stream PCM * Starts a new playback pass: resets the ring buffer and loop-point
* data (read ahead from the stream's asset via audioStreamPcmRead() by the * bookkeeping, seeks the asset to the pass's start frame, and wakes the
* reader thread) to its reserved hardware output channel in small chunks * persistent output thread. Actual PCM reading happens afterward, driven
* until exhausted. * by audioStreamPSPTopUp() (see audioStreamPSPIsFinished()) rather than
* here.
* *
* @param stream The audio stream to output. * @param stream The audio stream to output.
* @return Error state if any. * @return Error state if any.
@@ -129,8 +155,27 @@ errorret_t audioStreamPSPDispose(audiostream_t *stream);
errorret_t audioStreamPSPBuffer(audiostream_t *stream); errorret_t audioStreamPSPBuffer(audiostream_t *stream);
/** /**
* Checks whether the stream's player thread has finished outputting its * Tops up the stream's ring buffer from the main thread if it has fallen
* currently buffered data. * 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. * @param stream The audio stream to check.
* @return true if playback has finished, false otherwise. * @return true if playback has finished, false otherwise.
@@ -138,32 +183,16 @@ errorret_t audioStreamPSPBuffer(audiostream_t *stream);
bool_t audioStreamPSPIsFinished(audiostream_t *stream); bool_t audioStreamPSPIsFinished(audiostream_t *stream);
/** /**
* Player thread entry point, run once for the stream's whole lifetime. * Output thread entry point, run once for the stream's whole lifetime.
* Idles (polling playRequested) until woken by audioStreamPSPBuffer(), then * Idles (polling playRequested) until woken by audioStreamPSPBuffer(),
* takes fully-prepared chunks off the queue (filled by the reader thread - * then repeatedly waits for a full hardware chunk to become available in
* see audioStreamPSPThreadRead()) and outputs them to the hardware channel * the ring buffer (topped up by the main thread - see
* one at a time, blocking naturally on each sceAudioOutputPannedBlocking() * audioStreamPSPTopUp()) and outputs it, blocking naturally on each
* call, until the pass's final chunk has been sent - then goes back to * 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 * idling, ready for the next play request, until the thread is asked to
* stop. Never touches the asset/PCM layer directly. * stop. Never touches the asset/PCM layer directly.
* *
* @param thread The running thread_t, with data set to the audiostream_t. * @param thread The running thread_t, with data set to the audiostream_t.
*/ */
void audioStreamPSPThreadFeed(thread_t *thread); 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);