- PSP output thread now caches the last computed leftVolume/rightVolume and only recomputes them when stream->volume/directionality actually changed since the previous chunk, instead of recomputing every ~23ms chunk unconditionally. - Mixer channels and the mixer itself now carry their own volume (audioMixerSetChannelVolume()/audioMixerSetMasterVolume()), multiplied with each sound's own volume every audioMixerUpdateEarly() and applied via audioStreamSetVolume() - so changing a channel's or the master volume affects whatever's already playing, not just future sounds. Also fixed audioMixerUpdateEarly() to tolerate a PLAY command queued outside the normal per-frame cycle (no loadingAsset yet from a preceding audioMixerUpdateLate(), e.g. during engine startup) by leaving it queued for the following frame instead of asserting. - engine.c now plays boa.mp3 on AUDIO_MIXER_CHANNEL_BGM_0 on loop through the mixer instead of driving a raw audiostream_t directly, exercising the new mixer pipeline end to end. Built and verified on Linux, PSP (Docker) and GameCube (Docker). Co-Authored-By: Claude Sonnet 5 <[email protected]>
233 lines
11 KiB
C
233 lines
11 KiB
C
/**
|
|
* 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);
|