Files
dusk/src/duskpsp/audio/audiostreampsp.c
T
YourWishes ac8023d50f 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>
2026-08-31 20:09:15 -05:00

400 lines
15 KiB
C

/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "audiostreampsp.h"
#include "audio/audiostream.h"
#include "assert/assert.h"
#include "util/memory.h"
#include "util/math.h"
#include <pspaudio.h>
#include <pspthreadman.h>
// Matches pspaudiolib's own PSP_NUM_AUDIO_SAMPLES convention - PSP audio
// hardware is meant to be fed small chunks continuously, not one large
// 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
// PSP priorities are inverted (lower = higher priority). This project's
// main thread runs at the PSPSDK default of 32 (PSP_MAIN_THREAD_PRIORITY
// is never overridden). A pthread created with default attributes runs at
// 60 - LOWER priority than the main/render thread - so under load the
// output thread would starve and the hardware channel underruns, heard as
// jitter/crackle that gets worse the busier (lower-fps) a frame is. Raise
// it above main so audio feeding always wins scheduling contention.
#define AUDIO_PSP_THREAD_PRIORITY 18
// How many frames at the very end of a stream get linearly faded to zero,
// avoiding the audible click of the waveform stopping at a non-zero
// amplitude (either into padding on a partial final chunk, or the DAC
// just stopping outright on an exact-multiple-length one).
#define AUDIO_PSP_FADE_FRAMES 32
// How long the persistent output thread sleeps between checks - both for
// a new play request while idle, and for the ring buffer to have a full
// chunk ready while active.
#define AUDIO_PSP_IDLE_POLL_MICROS 500
errorret_t audioStreamPSPInit(audiostream_t *stream) {
assertNotNull(stream, "Stream cannot be NULL.");
if(stream->pcm.channels != 1 && stream->pcm.channels != 2) {
errorThrow(
"PSP audio only supports mono or stereo PCM, got %d channels.",
stream->pcm.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) {
errorThrow(
"PSP audio channels are fixed at 44100Hz, got %uHz.",
stream->pcm.sampleRate
);
}
const int format = stream->pcm.channels == 1
? PSP_AUDIO_FORMAT_MONO
: PSP_AUDIO_FORMAT_STEREO;
const int channel = sceAudioChReserve(
PSP_AUDIO_NEXT_CHANNEL, AUDIO_PSP_CHUNK_FRAMES, format
);
if(channel < 0) {
errorThrow("Failed to reserve PSP audio channel: 0x%08X", channel);
}
stream->platform.channel = channel;
stream->platform.finished = false;
stream->platform.playRequested = false;
const size_t ringSize =
AUDIO_PSP_RING_FRAMES * stream->pcm.channels * sizeof(int16_t);
stream->platform.ring = memoryAllocate(ringSize);
stream->platform.scratch = memoryAllocate(ringSize);
threadMutexInit(&stream->platform.ringLock);
threadInit(&stream->platform.thread, audioStreamPSPThreadFeed);
stream->platform.thread.data = stream;
threadStartRequest(&stream->platform.thread);
errorOk();
}
errorret_t audioStreamPSPDispose(audiostream_t *stream) {
assertNotNull(stream, "Stream cannot be NULL.");
threadStop(&stream->platform.thread);
sceAudioChRelease(stream->platform.channel);
memoryFree(stream->platform.ring);
stream->platform.ring = NULL;
memoryFree(stream->platform.scratch);
stream->platform.scratch = NULL;
threadMutexDispose(&stream->platform.ringLock);
errorOk();
}
errorret_t audioStreamPSPBuffer(audiostream_t *stream) {
assertNotNull(stream, "Stream cannot be NULL.");
stream->platform.finished = false;
stream->platform.readReachedEnd = false;
stream->platform.readFailed = false;
stream->platform.totalFrames = audioStreamPcmGetTotalFrames(stream);
// loopEndFrame/loopToFrame define the loop segment [loopToFrame,
// loopEndFrame) that a looping pass wraps within, once it's reached -
// defaulting to the whole clip (loopStart == -1, loopTo == 0) so
// behaviour is unchanged when no explicit loop points are configured.
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;
errorOk();
}
void audioStreamPSPTopUp(audiostream_t *stream) {
assertNotNull(stream, "Stream cannot be NULL.");
if(stream->platform.readReachedEnd || stream->platform.readFailed) return;
threadMutexLock(&stream->platform.ringLock);
const size_t filled = stream->platform.ringFilled;
threadMutexUnlock(&stream->platform.ringLock);
if(filled >= AUDIO_PSP_LEAD_FRAMES) return;
const size_t channels = stream->pcm.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;
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
);
}
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);
stream->platform.readPosition += framesToRead;
}
if(reachesSegmentEnd) {
if(willLoop) {
if(errorIsNotOk(audioStreamPcmSeek(stream, stream->platform.loopToFrame))) {
stream->platform.readFailed = true;
return;
}
stream->platform.readPosition = stream->platform.loopToFrame;
threadMutexLock(&stream->platform.ringLock);
if(stream->platform.loopMarkerCount < AUDIO_PSP_LOOP_MARKER_MAX) {
const size_t index = (
stream->platform.loopMarkerHead + stream->platform.loopMarkerCount
) % AUDIO_PSP_LOOP_MARKER_MAX;
stream->platform.loopMarkerFrames[index] = stream->platform.framesEnqueued;
stream->platform.loopMarkerCount++;
}
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) {
assertNotNull(thread, "Thread cannot be NULL.");
sceKernelChangeThreadPriority(sceKernelGetThreadId(), AUDIO_PSP_THREAD_PRIORITY);
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
// than exiting, so a loop restart (or any replay) is just a flag flip
// audioStreamPSPBuffer() sets, not a whole new thread being spawned.
while(!threadShouldStop(thread)) {
if(!stream->platform.playRequested) {
sceKernelDelayThread(AUDIO_PSP_IDLE_POLL_MICROS);
continue;
}
stream->platform.playRequested = false;
bool_t reachedEnd = false;
// Every call always sends a full, constant-size AUDIO_PSP_CHUNK_FRAMES
// buffer - the channel is never re-declared to a different length, to
// avoid relying on sceAudioSetChannelDataLen's undocumented behavior
// mid-stream. Loops internally (rather than going idle and waiting for
// the engine's once-per-frame Update() to notice finished and
// re-trigger Buffer()) so a looping stream never has a
// detection-latency gap.
while(!threadShouldStop(thread) && !reachedEnd) {
// Wait until either a full hardware chunk is ready in the ring, or
// the main thread has stopped producing (a genuine end or a read
// failure) and whatever's left (0..one chunk) is all there'll ever
// be - the pass's final, possibly-partial chunk.
size_t available = 0;
bool_t stopRequested = false;
for(;;) {
threadMutexLock(&stream->platform.ringLock);
available = stream->platform.ringFilled;
threadMutexUnlock(&stream->platform.ringLock);
if(available >= AUDIO_PSP_CHUNK_FRAMES) break;
if(stream->platform.readReachedEnd || stream->platform.readFailed) break;
if(threadShouldStop(thread)) {
stopRequested = true;
break;
}
sceKernelDelayThread(AUDIO_PSP_IDLE_POLL_MICROS);
}
if(stopRequested) break;
// Production has stopped and what's left fits in one chunk (0 up to
// AUDIO_PSP_CHUNK_FRAMES - it can never be more, since production
// 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
// take effect mid-playback, unlike the platform's other one-shot calls.
float_t leftFactor, rightFactor;
audioStreamGetPanFactors(stream, &leftFactor, &rightFactor);
const int baseVolume = (stream->volume * PSP_AUDIO_VOLUME_MAX) / 0xFF;
const int leftVolume = (int) (baseVolume * leftFactor);
const int rightVolume = (int) (baseVolume * rightFactor);
sceAudioOutputPannedBlocking(
stream->platform.channel, leftVolume, rightVolume, chunk
);
stream->platform.framesOutput += framesThisChunk;
// Fire any loop markers this chunk just played past - never call
// stream->onLoop directly from this thread, since it may do
// arbitrary, possibly-slow work (this is exactly what caused a loud
// crackle in the past: onLoop console-printing took long enough to
// starve the next chunk). audioStreamUpdate() picks up loopCount
// changes and fires onLoop safely from the main thread instead.
threadMutexLock(&stream->platform.ringLock);
while(
stream->platform.loopMarkerCount > 0 &&
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;
}
memoryFree(chunk);
}