/** * Copyright (c) 2026 Dominic Masters * * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ #include "audiostreampsp.h" #include "audio/stream/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->channels != 1 && stream->channels != 2) { errorThrow( "PSP audio only supports mono or stereo PCM, got %d channels.", stream->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->sampleRate != 44100) { errorThrow( "PSP audio channels are fixed at 44100Hz, got %uHz.", stream->sampleRate ); } const int format = stream->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; stream->platform.hasLastVolume = false; const size_t ringSize = AUDIO_PSP_RING_FRAMES * stream->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 = audioStreamGetTotalFrames(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->sampleRate), stream->platform.totalFrames ) : stream->platform.totalFrames; stream->platform.loopToFrame = mathMin( (size_t) (stream->loopTo * stream->sampleRate), stream->platform.loopEndFrame ); const size_t startFrame = mathMin(stream->startFrame, stream->platform.totalFrames); stream->startFrame = 0; stream->seeking = false; errorChain(audioStreamSeek(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->channels; const size_t frameSize = channels * sizeof(int16_t); // Fill however much room the ring has, up to one bounded step, not just // whatever's needed to reach LEAD in a single shot - AUDIO_PSP_RING_FRAMES // is now sized to absorb a slow MP3 loop-wrap (see its own comment), and // greedily trying to fill all of it in one call right after a fresh // Buffer() (when the ring starts empty) would turn the very first // top-up into one long blocking decode burst on the main thread instead // of many small ones - worse for startup smoothness, not better. Still // uncapped per se: reaching LEAD just takes a few calls (a few engine // frames) instead of one, which comfortably keeps up with real-time // consumption the same way an unbounded fill would, since a temporary // frame-rate dip only slows how fast the ring tops up, never how much // room is left to fill on the next call. 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 = mathMin( AUDIO_PSP_RING_FRAMES - filled, (size_t) AUDIO_PSP_TOPUP_STEP_FRAMES ); 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); if(framesToRead > 0) { int16_t *scratch = stream->platform.scratch; size_t framesRead = 0; if(errorIsNotOk( audioStreamRead(stream, scratch, framesToRead, &framesRead) )) { stream->platform.readFailed = true; return; } if(framesRead > 0) { threadMutexLock(&stream->platform.ringLock); for(size_t i = 0; i < framesRead; 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 + framesRead) % AUDIO_PSP_RING_FRAMES; stream->platform.ringFilled += framesRead; stream->platform.framesEnqueued += framesRead; threadMutexUnlock(&stream->platform.ringLock); stream->platform.readPosition += framesRead; } } // Reached (or ran past, which can't actually happen since framesToRead // is itself bounded by framesRemainingInSegment) the segment end only if // readPosition genuinely got there - never inferred from how much was // merely requested. A short read (framesRead < framesToRead, including // 0) does NOT by itself mean the segment is over: unlike PCM (where a // short read only ever happens at a truly corrupt/truncated file's real // end), MP3 decode can plausibly produce fewer frames than asked without // that meaning "no more content" (e.g. a hardware decoder backend // reporting it has nothing ready *this instant*, not that there's // nothing left in the file) - trusting the request size instead of // actual progress here silently let readPosition race ahead of real // decode progress, confirmed as the cause of premature loop-segment-end // detection ("racing through content"). A short read that isn't // genuinely the end just means less got queued this call; the next // TopUp() call (next engine frame) naturally retries. // A limit of 0 means unlimited - see loopRestartCount's own comment for // why counting decided (not yet necessarily audible) restarts here is // still the right thing to compare against loopLimit. const bool_t loopLimitReached = stream->loopLimit > 0 && stream->loopRestartCount >= stream->loopLimit; const bool_t reachesSegmentEnd = stream->platform.readPosition >= currentEndFrame; const bool_t willLoop = reachesSegmentEnd && (stream->state & AUDIO_STREAM_STATE_LOOPING) && !loopLimitReached; if(reachesSegmentEnd) { if(willLoop) { if(errorIsNotOk(audioStreamSeek(stream, stream->platform.loopToFrame))) { stream->platform.readFailed = true; return; } stream->platform.readPosition = stream->platform.loopToFrame; stream->loopRestartCount++; 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->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; // Paused: skip consuming from the ring and skip // sceAudioOutputPannedBlocking() entirely - that call is the only // thing keeping the PSP's own tiny hardware buffer fed, so simply // not calling it silences output within about one hardware chunk // (~23ms) instead of waiting for this thread's own much larger // software ring (AUDIO_PSP_RING_FRAMES, ~1.1s) to drain naturally. // The ring is left completely untouched, so resuming // (audioStreamPlay()) continues exactly where playback left off, // with no skip or gap. See audiostream.h's own comment on why // reading stream->state from this thread is safe. if(!(stream->state & AUDIO_STREAM_STATE_PLAYING)) { sceKernelDelayThread(AUDIO_PSP_IDLE_POLL_MICROS); continue; } // 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); } } } // Checked every chunk (~23ms at 44100Hz) so SetVolume/SetDirectionality // take effect mid-playback, unlike the platform's other one-shot // calls - but only actually recomputed when one of them has changed // since the last chunk (see lastVolume's own comment); otherwise the // previous chunk's already-computed leftVolume/rightVolume are reused // as-is. if( !stream->platform.hasLastVolume || stream->platform.lastVolume != stream->volume || stream->platform.lastDirectionality != stream->directionality ) { float_t leftFactor, rightFactor; audioStreamGetPanFactors(stream, &leftFactor, &rightFactor); const int baseVolume = (int) (stream->volume * PSP_AUDIO_VOLUME_MAX); stream->platform.lastLeftVolume = (int) (baseVolume * leftFactor); stream->platform.lastRightVolume = (int) (baseVolume * rightFactor); stream->platform.lastVolume = stream->volume; stream->platform.lastDirectionality = stream->directionality; stream->platform.hasLastVolume = true; } sceAudioOutputPannedBlocking( stream->platform.channel, stream->platform.lastLeftVolume, stream->platform.lastRightVolume, 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); }