/** * Copyright (c) 2026 Dominic Masters * * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ #pragma once #include "error/error.h" #include "thread/thread.h" typedef struct audiostream_s audiostream_t; // 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. // // Sized well above what a PCM stream would ever need on its own, because // an MP3 stream's loop-wrap (audioStreamMp3DecoderRewind() on PSP) fully // tears down and recreates the hardware decoder - a real handle release/ // reserve round-trip plus a fresh ~16KB Memory Stick read to re-prime // sceMp3's stream buffer - all synchronously, inside the same TopUp() call // that's supposed to be keeping this ring fed. That's slow enough, // occasionally, to eat into a tighter lead margin and click right at the // loop point (confirmed on real hardware) - this gives it generous room to // do so without the output thread ever catching up to empty. #define AUDIO_PSP_RING_FRAMES 49152 // 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. See AUDIO_PSP_RING_FRAMES's own comment for why this is sized // well beyond dusklinux's equivalent. #define AUDIO_PSP_LEAD_FRAMES 12288 // Max frames audioStreamPSPTopUp() will read in one call, regardless of // how much ring room is actually free - keeps each call's worst-case // blocking time on the main thread bounded and roughly constant even // though AUDIO_PSP_RING_FRAMES/AUDIO_PSP_LEAD_FRAMES are large; reaching // LEAD from an empty ring just takes a few calls (a few engine frames) // instead of one long one. See AUDIO_PSP_RING_FRAMES's own comment. #define AUDIO_PSP_TOPUP_STEP_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 // a small fixed chunk size (AUDIO_PSP_CHUNK_FRAMES) - PSP audio hardware // expects continuous small-chunk feeding, not one large buffer per call. int channel; // 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; // 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; // Set by the thread once it has output the last chunk of a pass. volatile bool_t finished; // 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; // 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; // 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; // 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; // Cache of the last stream->volume/directionality the output thread // computed lastLeftVolume/lastRightVolume from - recomputing // audioStreamGetPanFactors() and both multiplications is wasted work on // every single chunk (~23ms) whenever neither has actually changed since // the previous one, which is the common case outside of an active fade/ // pan. PSP itself has no persistent per-channel volume to cache this // for us (see sceAudioOutputPannedBlocking's own call site) - hence // caching it ourselves. Output-thread-only, like the rest of this // section - see sceAudioOutputPannedBlocking's own call site. float_t lastVolume; float_t lastDirectionality; int lastLeftVolume; int lastRightVolume; bool_t hasLastVolume; } audiostreampsp_t; /** * Initializes the PSP-specific playback state of an audio stream (e.g. * reserving a hardware output channel via sceAudioChReserve). * * @param stream The audio stream to initialize. * @return Error state if any. */ errorret_t audioStreamPSPInit(audiostream_t *stream); /** * Disposes the PSP-specific playback state of an audio stream, stopping its * output thread and releasing its hardware output channel. * * @param stream The audio stream to dispose. * @return Error state if any. */ errorret_t audioStreamPSPDispose(audiostream_t *stream); /** * 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. */ errorret_t audioStreamPSPBuffer(audiostream_t *stream); /** * 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. */ bool_t audioStreamPSPIsFinished(audiostream_t *stream); /** * 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);