Fix loud PSP loop crackle + make Dolphin loop natively in hardware

Root cause of the crackle (confirmed on real PSP hardware): the
persistent feeder thread called stream->onLoop directly in the middle
of its tight chunk-feeding loop. onLoop can do arbitrary work (the
test callback does consolePrint, which locks a mutex, moves the
console history buffer, and fflushes stdout) - if that takes anywhere
close to one chunk's playback time (~23ms), the next chunk isn't
ready and the hardware channel starves. Audio callbacks must never be
invoked directly from a real-time audio thread.

Fixed generically: audiostream_t gains loopCount (incremented by
platform code from whatever context it runs in) and lastLoopCount
(main-thread-only bookkeeping). audioStreamUpdate() detects the
change and fires onLoop safely from the main thread, regardless of
which thread/interrupt actually noticed the loop. PSP's feeder thread
now increments the counter instead of calling onLoop inline.

Also eliminated the ~21.7ms of real silence padding baked into every
PSP loop pass (found while chasing the timing gap that preceded the
crackle fix): the final chunk's padding is now filled with the start
of the next loop instead of zero, avoiding sceAudioSetChannelDataLen
(the likely real cause of an earlier, separate click) while keeping
every output call the same constant size. Loop period measured via
PPSSPP is now ~1.000-1.002s for a 1.000s tone, down from a consistent
~1.02-1.03s before.

The PSP feeder thread is also now persistent for the stream's whole
lifetime (created once in Init, idles between plays) rather than
respawned via threadStartRequest on every single loop restart - real,
avoidable OS thread creation overhead that was contributing to the
gap before the padding was identified as the dominant cause.

Brought Dolphin in line architecturally rather than mirroring PSP/
Linux's restart-and-detect approach: ansnd_pcm_voice_config_t has
native loop_start_offset/loop_end_offset fields, so a looping Dolphin
voice loops entirely in DSP hardware with zero host involvement at
the loop boundary - no restart latency to create a gap in the first
place. Trade-off, clearly documented in code: onLoop never fires for
Dolphin this way (no ANSND_VOICE_STATE for "wrapped") and it requires
cleanly-authored loop content (no per-wrap fade like PSP's, matching
the same assumption). Compiles cleanly for both gamecube and wii;
not yet verified on real hardware.

Confirmed on real PSP hardware: no more gap, crackle fix pending
final hardware confirmation.
This commit is contained in:
2026-08-31 13:02:51 -05:00
parent 8a77001016
commit 9512c22e1f
5 changed files with 185 additions and 73 deletions
@@ -63,6 +63,26 @@ errorret_t audioStreamDolphinBuffer(audiostream_t *stream) {
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.
//
// 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).
if(stream->state & AUDIO_STREAM_STATE_LOOPING) {
config.loop_start_offset = 0;
config.loop_end_offset = config.frame_count - 1;
}
s32 result = ansnd_configure_pcm_voice((u32) stream->platform.voiceId, &config);
if(result != ANSND_ERROR_OK) {
errorThrow(