Files
dusk/src/duskpsp/audio/audiostreampsp.c
T
YourWishesandClaude Sonnet 5 cebd3d81e7 Wrap long lines to fit within 80 columns across the codebase
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]>
2026-09-05 13:13:10 -05:00

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);
}