diff --git a/src/duskpsp/audio/audiostreampsp.c b/src/duskpsp/audio/audiostreampsp.c index 5d3edf4a..17246b6c 100644 --- a/src/duskpsp/audio/audiostreampsp.c +++ b/src/duskpsp/audio/audiostreampsp.c @@ -15,17 +15,17 @@ // 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. +// 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 either -// worker thread would starve and the hardware channel underruns, heard as +// 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 -// both the player and reader threads above main so audio feeding always -// wins scheduling contention. +// 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, @@ -34,12 +34,9 @@ // just stopping outright on an exact-multiple-length one). #define AUDIO_PSP_FADE_FRAMES 32 -// How long the persistent player/reader threads sleep between polls - both -// for a new play request while idle, and for queue slots while active. -// Cheap compared to spawning a whole new OS thread per play (what this -// 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. +// 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) { @@ -76,42 +73,31 @@ errorret_t audioStreamPSPInit(audiostream_t *stream) { stream->platform.channel = channel; stream->platform.finished = 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 = - AUDIO_PSP_CHUNK_FRAMES * stream->pcm.channels * sizeof(int16_t); - for(size_t i = 0; i < AUDIO_PSP_QUEUE_DEPTH; i++) { - stream->platform.queueChunk[i] = memoryAllocate(chunkSize); - } - threadMutexInit(&stream->platform.queueLock); + 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); - threadInit(&stream->platform.readerThread, audioStreamPSPThreadRead); - stream->platform.readerThread.data = stream; - threadStartRequest(&stream->platform.readerThread); - errorOk(); } errorret_t audioStreamPSPDispose(audiostream_t *stream) { assertNotNull(stream, "Stream cannot be NULL."); - threadStop(&stream->platform.readerThread); threadStop(&stream->platform.thread); sceAudioChRelease(stream->platform.channel); - for(size_t i = 0; i < AUDIO_PSP_QUEUE_DEPTH; i++) { - memoryFree(stream->platform.queueChunk[i]); - stream->platform.queueChunk[i] = NULL; - } - threadMutexDispose(&stream->platform.queueLock); + memoryFree(stream->platform.ring); + stream->platform.ring = NULL; + memoryFree(stream->platform.scratch); + stream->platform.scratch = NULL; + threadMutexDispose(&stream->platform.ringLock); errorOk(); } @@ -120,222 +106,161 @@ errorret_t audioStreamPSPBuffer(audiostream_t *stream) { assertNotNull(stream, "Stream cannot be NULL."); stream->platform.finished = false; - stream->platform.startFrame = stream->startFrame; - stream->startFrame = 0; - stream->seeking = false; + stream->platform.readReachedEnd = false; stream->platform.readFailed = false; - threadMutexLock(&stream->platform.queueLock); - stream->platform.queueHead = 0; - stream->platform.queueTail = 0; - stream->platform.queueCount = 0; - threadMutexUnlock(&stream->platform.queueLock); + stream->platform.totalFrames = audioStreamPcmGetTotalFrames(stream); - // Give the reader a head start filling the queue before the player - // starts draining it - not required for correctness (the player just - // waits for the first ready slot either way), but avoids guaranteeing an - // initial stall on every pass. - stream->platform.readRequested = true; + // 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(); } -bool_t audioStreamPSPIsFinished(audiostream_t *stream) { +void audioStreamPSPTopUp(audiostream_t *stream) { assertNotNull(stream, "Stream cannot be NULL."); - return stream->platform.finished; -} + if(stream->platform.readReachedEnd || stream->platform.readFailed) return; -void audioStreamPSPThreadRead(thread_t *thread) { - assertNotNull(thread, "Thread cannot be NULL."); + threadMutexLock(&stream->platform.ringLock); + 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 frameSize = channels * sizeof(int16_t); - // Runs for the stream's whole lifetime - idles here between plays rather - // than exiting, same as the player thread. - while(!threadShouldStop(thread)) { - if(!stream->platform.readRequested) { - sceKernelDelayThread(AUDIO_PSP_IDLE_POLL_MICROS); - continue; + // 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 + ); } - 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, - // 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 - ); + stream->platform.readPosition += framesToRead; + } - // Only the very first pass honors an explicit seek (audioStreamSetPosition, - // captured into platform.startFrame by audioStreamPSPBuffer()) - every - // subsequent loop wraps to loopToFrame instead. - 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; + if(reachesSegmentEnd) { + if(willLoop) { + if(errorIsNotOk(audioStreamPcmSeek(stream, stream->platform.loopToFrame))) { stream->platform.readFailed = true; - break; + return; } - if(framesRead < framesThisChunk) { - // The asset is shorter than its declared header size (corrupt or - // truncated) - pad what's missing with silence rather than play - // whatever was left in `chunk` from a previous pass. - memoryZero( - chunk + (framesRead * channels), - (framesThisChunk - framesRead) * frameSize - ); - } - - 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; + 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 @@ -352,32 +277,25 @@ void audioStreamPSPThreadFeed(thread_t *thread) { // 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 (the likely real cause of an earlier click). 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 - the generic - // 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. + // 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 for the reader thread to have a chunk ready. sceAudioOutput- - // PannedBlocking() below blocks this thread for the full duration of - // whatever chunk it's given, so there is no spare time here to also - // do the PCM read/seek itself without stalling the hardware channel - // - that's exactly what the reader thread (audioStreamPSPThreadRead) - // exists to do concurrently, ahead of what's currently playing. + // 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; - size_t slot = 0; for(;;) { - threadMutexLock(&stream->platform.queueLock); - bool_t hasChunk = stream->platform.queueCount > 0; - bool_t gaveUp = !hasChunk && stream->platform.readFailed; - slot = stream->platform.queueTail; - threadMutexUnlock(&stream->platform.queueLock); - if(hasChunk) break; - // The reader already failed and has nothing left queued - it will - // never produce another chunk for this pass, so stop waiting. - if(gaveUp || threadShouldStop(thread)) { + 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; } @@ -385,9 +303,57 @@ void audioStreamPSPThreadFeed(thread_t *thread) { } if(stopRequested) break; - int16_t *chunk = stream->platform.queueChunk[slot]; - const bool_t chunkReachedEnd = stream->platform.queueReachedEnd[slot]; - const bool_t chunkLooped = stream->platform.queueLooped[slot]; + // 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. @@ -402,29 +368,32 @@ void audioStreamPSPThreadFeed(thread_t *thread) { stream->platform.channel, leftVolume, rightVolume, chunk ); - // Only freed (queueCount decremented) after the blocking output call - // 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); + stream->platform.framesOutput += framesThisChunk; - if(chunkReachedEnd) { - if(chunkLooped) { - // Never call stream->onLoop directly from this thread - it may do - // arbitrary, possibly-slow work (this is exactly what caused a - // loud crackle: onLoop console-printing took long enough to - // starve the next chunk). audioStreamUpdate() picks this up and - // fires onLoop safely from the main thread instead. - stream->loopCount++; - } else { - reachedEnd = true; - } + // 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); } diff --git a/src/duskpsp/audio/audiostreampsp.h b/src/duskpsp/audio/audiostreampsp.h index 94e3ca67..36249cfa 100644 --- a/src/duskpsp/audio/audiostreampsp.h +++ b/src/duskpsp/audio/audiostreampsp.h @@ -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);