From 15bd9fc43c8f17cd0159f8e8b805a75cee2d5f54 Mon Sep 17 00:00:00 2001 From: Dominic Masters Date: Mon, 31 Aug 2026 17:18:49 -0500 Subject: [PATCH] Clean up audio subsystem duplication, implement seeking and loop regions - Extract audioStreamGetPanFactors() into the shared layer, replacing identical pan-to-LR math duplicated in the PSP and Dolphin backends (and dropping a dead clamp - directionality's int8_t range already guarantees pan stays in [-1, 1]). - Implement audioStreamSetPosition() for real (was previously a no-op that computed a value and threw it away) and add audioStreamSetLoopPoints() to actually drive loopStart/loopTo, which were previously dead fields with no setter at all. Both are threaded through all three platform backends: - PSP: the feeder thread now bounds each pass by the loop segment and wraps to loopTo instead of always frame 0, while still filling hardware chunks gaplessly. - Dolphin: loopTo/loopStart map directly onto ansnd's existing loop_start_offset/loop_end_offset, and startFrame onto start_offset. - Linux: Buffer() now queues only the current segment, clearing the SDL queue on an explicit seek but preserving the existing overlap-based gapless loop restart otherwise. - Linux: skip the mix scratch-buffer entirely at full volume, queuing stream->data directly instead of allocating/zeroing/copying into one every buffer call for no reason. Verified no regression in the default (no seek, no loop points) case on Linux/PPSSPP/Dolphin (-a LLE), and verified seek + loop-region behavior manually via a temporary engine.c smoke-test tweak (reverted) showing the expected faster loop cadence and seek-then-loop sequencing. --- src/dusk/audio/audiostream.c | 62 ++++++++++++++--- src/dusk/audio/audiostream.h | 71 ++++++++++++++++++-- src/duskdolphin/audio/audiostreamdolphin.c | 33 +++++----- src/dusklinux/audio/audiostreamlinux.c | 60 ++++++++++++++--- src/duskpsp/audio/audiostreampsp.c | 77 +++++++++++++++++----- src/duskpsp/audio/audiostreampsp.h | 7 ++ 6 files changed, 252 insertions(+), 58 deletions(-) diff --git a/src/dusk/audio/audiostream.c b/src/dusk/audio/audiostream.c index 9c1200b2..bf3afc8a 100644 --- a/src/dusk/audio/audiostream.c +++ b/src/dusk/audio/audiostream.c @@ -19,6 +19,8 @@ errorret_t audioStreamInit(audiostream_t *stream) { stream->loopStart = -1; stream->loopTo = 0; stream->duration = 0; + stream->startFrame = 0; + stream->seeking = false; stream->user = NULL; stream->onLoop = NULL; stream->onEnd = NULL; @@ -43,16 +45,53 @@ void audioStreamPause(audiostream_t *stream) { void audioStreamSetPosition(audiostream_t *stream, const float_t position) { assertNotNull(stream, "Stream cannot be NULL."); - - // Clamp time. + + // Wrap into [0, duration). float_t t = position; while(t < 0) t += stream->duration; while(t >= stream->duration) t -= stream->duration; - // Here I will set the stream time, this will require new data to be fetched - // next update() call. - // To confirm: If this is called late in the frame, will the PCM data be sent - // to the output despite time being adjusted? is this going to cause static? + stream->startFrame = (size_t) (t * stream->pcm.sampleRate); + stream->seeking = true; + + // Force the next audioStreamUpdate() to re-buffer from startFrame instead + // of continuing whatever was already buffered - see the platform Buffer() + // implementations for how each one applies startFrame; PSP is the one + // exception (see audioStreamSetPosition's own doc comment). + stream->state &= ~AUDIO_STREAM_STATE_BUFFERED; +} + +void audioStreamSetLoopPoints( + audiostream_t *stream, + const float_t loopStart, + const float_t loopTo +) { + assertNotNull(stream, "Stream cannot be NULL."); + assertTrue( + loopStart < 0 || loopTo < loopStart, + "loopTo must be before loopStart." + ); + + stream->loopStart = loopStart; + stream->loopTo = loopTo; +} + +void audioStreamGetPanFactors( + const audiostream_t *stream, + float_t *outLeft, + float_t *outRight +) { + assertNotNull(stream, "Stream cannot be NULL."); + assertNotNull(outLeft, "outLeft cannot be NULL."); + assertNotNull(outRight, "outRight cannot be NULL."); + + // directionality is an int8_t clamped to AUDIO_STREAM_LEFT..RIGHT + // (-128..127) by its own type, so pan is always within [-1.0, 0.992] - + // no further clamping needed. + const float_t pan = (float_t) stream->directionality / 128.0f; + + *outLeft = pan > 0 ? (1.0f - pan) : 1.0f; + *outRight = pan < 0 ? (1.0f + pan) : 1.0f; } void audioStreamSetVolume(audiostream_t *stream, const uint8_t volume) { @@ -103,10 +142,7 @@ errorret_t audioStreamUpdate(audiostream_t *stream) { // TODO: Once streamed (rather than fully in-memory) sources exist, this is // where new data would be decoded/read in and re-buffered as playback - // consumes it. Looping currently only supports restarting from the very - // start of the buffer (loopTo is not yet honored) since replaying the - // exact same stream->data/dataSize needs no platform-specific changes; - // an arbitrary loopTo offset would need slicing that buffer instead. + // consumes it. // Checked in this order (finished-check before needs-buffering) so that a // loop restart falls straight through into re-buffering within this same // call, instead of leaving BUFFERED cleared for the caller to notice and @@ -120,6 +156,12 @@ errorret_t audioStreamUpdate(audiostream_t *stream) { if(stream->state & AUDIO_STREAM_STATE_LOOPING) { stream->state &= ~AUDIO_STREAM_STATE_BUFFERED; + // Resume from loopTo rather than the very start of the buffer - only + // matters for platforms that reach this generic restart path at all; + // PSP/Dolphin loop entirely on their own (thread/hardware) and never + // report "finished" while looping, so in practice this is Linux-only. + stream->startFrame = (size_t) (stream->loopTo * stream->pcm.sampleRate); + if(stream->onLoop != NULL) { stream->onLoop(stream); } diff --git a/src/dusk/audio/audiostream.h b/src/dusk/audio/audiostream.h index 4a5fbc89..9b456f1c 100644 --- a/src/dusk/audio/audiostream.h +++ b/src/dusk/audio/audiostream.h @@ -63,15 +63,40 @@ typedef struct audiostream_s { // TODO: Can we use a 3D vector + Pro Logic II? int8_t directionality; - // At what point does the stream loop back, -1 means "at end of stream" + // In seconds, where the loop segment ends and playback jumps back to + // loopTo - -1 (the default) means "at the end of the stream", i.e. loop + // the whole buffer. Set via audioStreamSetLoopPoints(). float_t loopStart; - // When a loop occurs, where do we loop to? Defaults to 0 or start of stream. + // In seconds, where a loop jumps back to once it reaches loopStart. + // Defaults to 0 (start of stream). Set via audioStreamSetLoopPoints(). float_t loopTo; // Cached duration of the stream in seconds. float_t duration; + // Frame offset the next platform Buffer() call should start playback + // from - set by audioStreamSetPosition() (an explicit seek) and by + // audioStreamUpdate() itself (to loopTo's frame offset, for platforms + // that re-enter Buffer() on every loop restart rather than looping + // natively). Each platform's Buffer()-invoking entry point must read and + // reset this to 0 synchronously, in the same call that decided to + // (re)buffer - not later/asynchronously (e.g. from a feeder thread), + // since audioStreamUpdate() may already have moved on to something else + // that touches this field by the time an async reader gets to it. + size_t startFrame; + + // True when startFrame came from an explicit audioStreamSetPosition() + // seek rather than a natural loop restart. Platforms whose Buffer() call + // can leave previously-queued audio still playing underneath the new + // pass (currently just Linux's SDL queue, kept deliberately overlapping + // across a loop restart to avoid a gap) need this to tell "jump now, + // discarding whatever's still playing" (seek) apart from "let the old + // tail keep playing while the new pass queues underneath it" (loop + // restart) - both look identical as just "a pending startFrame" + // otherwise. Consumed (reset to false) the same way as startFrame. + bool_t seeking; + // Callbacks void *user; void (*onLoop)(audiostream_t *stream); @@ -128,12 +153,50 @@ void audioStreamPlay(audiostream_t *stream); void audioStreamPause(audiostream_t *stream); /** - * Sets the playback position of the given audio stream, in seconds. + * Seeks the given audio stream to the given position, in seconds, wrapping + * into range if the position is outside [0, duration). Takes effect on the + * next audioStreamUpdate() call for platforms that re-buffer per frame + * (e.g. Linux); on PSP, which manages an entire playback pass on its own + * feeder thread once started, a seek only takes effect the next time the + * stream begins playing from a stopped state, not instantaneously mid-pass. * - * @param stream The audio stream to rewind. + * @param stream The audio stream to seek. + * @param position The new playback position, in seconds. */ void audioStreamSetPosition(audiostream_t *stream, const float_t position); +/** + * Sets the loop region for the given audio stream: once playback reaches + * loopStart, it jumps back to loopTo instead of continuing (or stopping, + * if not looping). Has no effect unless looping is also enabled via + * audioStreamSetLooping(). + * + * @param stream The audio stream to update. + * @param loopStart Where the loop segment ends, in seconds, or -1 to loop + * the whole stream (the default). + * @param loopTo Where the loop segment starts, in seconds. + */ +void audioStreamSetLoopPoints( + audiostream_t *stream, + const float_t loopStart, + const float_t loopTo +); + +/** + * Computes normalized left/right pan factors (0..1) for the given audio + * stream's directionality. Callers multiply these by their own + * platform-specific base volume representation. + * + * @param stream The audio stream to read directionality from. + * @param outLeft Set to the left channel's pan factor. + * @param outRight Set to the right channel's pan factor. + */ +void audioStreamGetPanFactors( + const audiostream_t *stream, + float_t *outLeft, + float_t *outRight +); + /** * Sets the playback volume of the given audio stream. * diff --git a/src/duskdolphin/audio/audiostreamdolphin.c b/src/duskdolphin/audio/audiostreamdolphin.c index 8a96a3b5..568676d3 100644 --- a/src/duskdolphin/audio/audiostreamdolphin.c +++ b/src/duskdolphin/audio/audiostreamdolphin.c @@ -42,10 +42,8 @@ errorret_t audioStreamDolphinBuffer(audiostream_t *stream) { const size_t frameSize = stream->pcm.channels * sizeof(int16_t); - // Simple linear pan; AUDIO_STREAM_LEFT/CENTER/RIGHT map to -128..0..127. - float_t pan = (float_t) stream->directionality / 128.0f; - if(pan < -1.0f) pan = -1.0f; - if(pan > 1.0f) pan = 1.0f; + float_t leftFactor, rightFactor; + audioStreamGetPanFactors(stream, &leftFactor, &rightFactor); const float_t baseVolume = (float_t) stream->volume / 255.0f; @@ -57,8 +55,8 @@ errorret_t audioStreamDolphinBuffer(audiostream_t *stream) { config.format = ANSND_VOICE_PCM_FORMAT_SIGNED_16_PCM; config.channels = stream->pcm.channels; config.pitch = 1.0f; - config.left_volume = baseVolume * (pan > 0 ? (1.0f - pan) : 1.0f); - config.right_volume = baseVolume * (pan < 0 ? (1.0f + pan) : 1.0f); + config.left_volume = baseVolume * leftFactor; + config.right_volume = baseVolume * rightFactor; // The DSP DMAs frame_data_ptr directly out of main memory, bypassing the // CPU cache, and rejects any pointer in cached virtual address space // (0x8xxxxxxx.. - checked as "negative" internally, returned as @@ -68,28 +66,31 @@ errorret_t audioStreamDolphinBuffer(audiostream_t *stream) { DCFlushRange(stream->data, stream->dataSize); config.frame_data_ptr = MEM_VIRTUAL_TO_PHYSICAL(stream->data); config.frame_count = (u32) (stream->dataSize / frameSize); + config.start_offset = (u32) stream->startFrame; + stream->startFrame = 0; + stream->seeking = false; config.voice_callback = audioStreamDolphinVoiceCallback; config.stream_callback = NULL; // single-buffer playback config.user_pointer = stream; // Loop entirely in hardware rather than mirroring PSP/Linux's // detect-finished-then-restart-from-software approach: ansnd's DSP mixer - // wraps frame_count-1 back to 0 on its own, so there's no restart latency - // to create a gap in the first place (unlike a software restart, which - // always costs at least a little). Only restart-from-the-very-start is - // supported elsewhere in this codebase (loopTo isn't honored yet), which - // matches exactly what loop_start_offset=0 gives here. + // wraps loop_end_offset back to loop_start_offset on its own, so there's + // no restart latency to create a gap in the first place (unlike a + // software restart, which always costs at least a little). // // Trade-off: onLoop never fires for a Dolphin voice looping this way - // there's no ANSND_VOICE_STATE for "wrapped", only state transitions like // FINISHED/STOPPED, which a looping voice never reaches. And unlike // PSP's per-restart tail fade, the DSP does no smoothing at the wrap - // point - it requires the source data's last frame to already flow - // cleanly into its first (true today only because the shared 441Hz test - // tone was deliberately chosen to divide evenly into the sample rate). + // point - it requires loopStart's frame to already flow cleanly into + // loopTo's (true today only because the shared 441Hz test tone was + // deliberately chosen to divide evenly into the sample rate). if(stream->state & AUDIO_STREAM_STATE_LOOPING) { - config.loop_start_offset = 0; - config.loop_end_offset = config.frame_count - 1; + config.loop_start_offset = (u32) (stream->loopTo * stream->pcm.sampleRate); + config.loop_end_offset = stream->loopStart >= 0 + ? (u32) (stream->loopStart * stream->pcm.sampleRate) - 1 + : config.frame_count - 1; } s32 result = ansnd_configure_pcm_voice((u32) stream->platform.voiceId, &config); diff --git a/src/dusklinux/audio/audiostreamlinux.c b/src/dusklinux/audio/audiostreamlinux.c index 9f4bcb7c..e5d742c4 100644 --- a/src/dusklinux/audio/audiostreamlinux.c +++ b/src/dusklinux/audio/audiostreamlinux.c @@ -9,6 +9,7 @@ #include "audio/audiostream.h" #include "assert/assert.h" #include "util/memory.h" +#include "util/math.h" // How many frames of lead time to keep queued ahead of playback. Matches // SDL_AudioSpec.samples below - the device's own internal buffer size - so @@ -44,17 +45,56 @@ errorret_t audioStreamLinuxDispose(audiostream_t *stream) { errorret_t audioStreamLinuxBuffer(audiostream_t *stream) { assertNotNull(stream, "Stream cannot be NULL."); - uint8_t *mixed = memoryAllocate(stream->dataSize); - memoryZero(mixed, stream->dataSize); - SDL_MixAudioFormat( - mixed, stream->data, AUDIO_S16SYS, (Uint32) stream->dataSize, - (stream->volume * SDL_MIX_MAXVOLUME) / 0xFF - ); + const size_t frameSize = stream->pcm.channels * sizeof(int16_t); + const size_t totalFrames = stream->dataSize / frameSize; - int queued = SDL_QueueAudio( - stream->platform.device, mixed, (Uint32) stream->dataSize - ); - memoryFree(mixed); + // Consumed synchronously (right here, in the same call that decided to + // (re)buffer) rather than left for later - see startFrame's own comment. + const size_t startFrame = mathMin(stream->startFrame, totalFrames); + const bool_t seeking = stream->seeking; + stream->startFrame = 0; + stream->seeking = false; + + size_t endFrame = totalFrames; + if((stream->state & AUDIO_STREAM_STATE_LOOPING) && stream->loopStart >= 0) { + endFrame = mathMin( + (size_t) (stream->loopStart * stream->pcm.sampleRate), totalFrames + ); + } + // A seek (or a loop restart landing exactly on the loop end) can put + // startFrame at or past endFrame - e.g. seeking into an outro after the + // loop point. Play out to the true end of the buffer once instead, same + // as PSP. + if(startFrame >= endFrame) endFrame = totalFrames; + + const size_t bytes = (endFrame - startFrame) * frameSize; + const uint8_t *segment = stream->data + (startFrame * frameSize); + + // Only an explicit seek discards whatever's still queued and jumps - + // a natural loop restart deliberately leaves the previous pass's tail + // (AUDIO_LINUX_LEAD_FRAMES worth) queued and appends the new pass after + // it, which is what makes looping gapless (see IsFinished()'s comment). + // Clearing on every Buffer() call would destroy that overlap and + // reintroduce the exact gap this was built to avoid. + if(seeking) { + SDL_ClearQueuedAudio(stream->platform.device); + } + + int queued; + if(stream->volume == 0xFF) { + // Nothing to mix at full volume - queue the segment directly instead of + // allocating/zeroing a same-size scratch buffer just to copy it in. + queued = SDL_QueueAudio(stream->platform.device, segment, (Uint32) bytes); + } else { + uint8_t *mixed = memoryAllocate(bytes); + memoryZero(mixed, bytes); + SDL_MixAudioFormat( + mixed, segment, AUDIO_S16SYS, (Uint32) bytes, + (stream->volume * SDL_MIX_MAXVOLUME) / 0xFF + ); + queued = SDL_QueueAudio(stream->platform.device, mixed, (Uint32) bytes); + memoryFree(mixed); + } if(queued != 0) { errorThrow("Failed to queue SDL2 audio data: %s", SDL_GetError()); diff --git a/src/duskpsp/audio/audiostreampsp.c b/src/duskpsp/audio/audiostreampsp.c index e9c974bf..f56f94ca 100644 --- a/src/duskpsp/audio/audiostreampsp.c +++ b/src/duskpsp/audio/audiostreampsp.c @@ -9,6 +9,7 @@ #include "audio/audiostream.h" #include "assert/assert.h" #include "util/memory.h" +#include "util/math.h" #include #include @@ -92,6 +93,9 @@ 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.playRequested = true; errorOk(); @@ -125,7 +129,22 @@ void audioStreamPSPThreadFeed(thread_t *thread) { stream->platform.playRequested = false; const size_t totalFrames = stream->dataSize / frameSize; - size_t position = 0; + + // 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 + ); + + // 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; // Every call always sends a full, constant-size AUDIO_PSP_CHUNK_FRAMES @@ -139,7 +158,14 @@ void audioStreamPSPThreadFeed(thread_t *thread) { // better option (see audiostream.c), but here the thread can just // keep going and call onLoop itself instead. while(!threadShouldStop(thread) && !reachedEnd) { - const size_t framesRemaining = totalFrames - position; + // 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; @@ -156,17 +182,33 @@ void audioStreamPSPThreadFeed(thread_t *thread) { if(reachesEndThisChunk && willLoop) { // Instead of padding the rest of this constant-size chunk with - // silence, fill it with the start of the next pass - 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. - wrapFrames = remainderFrames < totalFrames ? remainderFrames : totalFrames; + // 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; memoryCopy( - chunk + (framesThisChunk * channels), stream->data, wrapFrames * frameSize + chunk + (framesThisChunk * channels), + stream->data + (loopToFrame * frameSize), + wrapFrames * frameSize ); if(wrapFrames < remainderFrames) { memoryZero( @@ -196,13 +238,12 @@ void audioStreamPSPThreadFeed(thread_t *thread) { // Re-read every chunk (~23ms at 44100Hz) so SetVolume/SetDirectionality // take effect mid-playback, unlike the platform's other one-shot calls. - float_t pan = (float_t) stream->directionality / 128.0f; - if(pan < -1.0f) pan = -1.0f; - if(pan > 1.0f) pan = 1.0f; + float_t leftFactor, rightFactor; + audioStreamGetPanFactors(stream, &leftFactor, &rightFactor); const int baseVolume = (stream->volume * PSP_AUDIO_VOLUME_MAX) / 0xFF; - const int leftVolume = (int) (baseVolume * (pan > 0 ? (1.0f - pan) : 1.0f)); - const int rightVolume = (int) (baseVolume * (pan < 0 ? (1.0f + pan) : 1.0f)); + const int leftVolume = (int) (baseVolume * leftFactor); + const int rightVolume = (int) (baseVolume * rightFactor); sceAudioOutputPannedBlocking( stream->platform.channel, leftVolume, rightVolume, chunk @@ -216,7 +257,7 @@ void audioStreamPSPThreadFeed(thread_t *thread) { // starve the next chunk). audioStreamUpdate() picks this up and // fires onLoop safely from the main thread instead. stream->loopCount++; - position = wrapFrames; + position = loopToFrame + wrapFrames; } else { reachedEnd = true; } diff --git a/src/duskpsp/audio/audiostreampsp.h b/src/duskpsp/audio/audiostreampsp.h index e0811e61..a2d8ac2d 100644 --- a/src/duskpsp/audio/audiostreampsp.h +++ b/src/duskpsp/audio/audiostreampsp.h @@ -30,6 +30,13 @@ typedef struct { // a pass; cleared by the thread once it picks it up. volatile bool_t playRequested; + // 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 feeder thread, + // since the 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 thread once it has fed the last chunk of a pass. volatile bool_t finished; } audiostreampsp_t;