Pure whitespace/line-break reformatting (braces, newlines, and line continuations matching this codebase's existing wrap conventions) - no logic, string content, or identifiers changed anywhere. Confirmed via diff against the pre-change tree and by rebuilding + re-running the affected test suites, which produce identical pass/fail results. Co-Authored-By: Claude Sonnet 5 <[email protected]>
471 lines
18 KiB
C
471 lines
18 KiB
C
/**
|
|
* 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 <pspaudio.h>
|
|
#include <pspthreadman.h>
|
|
|
|
// 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);
|
|
}
|