/** * 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 #include // 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); }