The reader+player two-thread design (previous commit) still crackled -
both threads shared the same elevated real-time priority and could
contend for the PSP's single core right at the moment the player thread
needed to resume after its blocking output call returned, worse the
bigger the hardware chunk. Confirmed fixed on real hardware (Memory Stick
and pspsh) by removing the second real-time thread entirely.
PCM data now lives in a ring buffer topped up from the MAIN thread once
per engine Update(), mirroring dusklinux's own already-working
audioStreamLinuxFeed()/IsFinished() pattern (same lead/window sizing)
instead of a bespoke second thread. The sole remaining PSP-specific
thread only drains the ring and calls sceAudioOutputPannedBlocking(),
never touching the asset/PCM layer. Loop wraps are now just a transparent
seek-and-continue while filling the ring (it holds one seamless sample
stream, no per-chunk splicing needed) - a small FIFO of loop markers is
the only thing still needed to fire onLoop at the correct audible moment.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
sceAudioOutputPannedBlocking() occupies the calling thread for the full
duration of the chunk it just submitted. That was fine while PCM chunks
came out of a fully-resident buffer (a near-instant memcpy), but now that
they're read from the asset on demand, that same read happens in between
output calls on the same thread - any read slower than a memcpy opens a
real gap in the hardware channel, heard as crackle.
Split the single feeder thread in two: a reader thread that does all PCM
I/O (seek/read, loop-wrap, fade prep) ahead of playback into a small
3-slot queue, and a player thread that only pulls ready chunks off the
queue and outputs them. This overlaps I/O with hardware playback instead
of serializing them.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- 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.
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.
The previous fix (shrinking the final chunk's declared length via
sceAudioSetChannelDataLen) still clicked on real hardware. Reverted
that in favor of two changes that don't rely on that call's
undocumented mid-stream behavior: every call now sends a full,
constant AUDIO_PSP_CHUNK_FRAMES buffer with the tail simply
zero-padded (channel length is never changed after the initial
reserve), and a couple of extra all-silence chunks are fed after the
real audio + fade so the channel keeps being actively driven at zero
for a moment rather than stopping outright, in case some of the click
was the channel/DAC settling rather than a pure sample-domain
discontinuity.
Confirmed fixed on real PSP hardware.
The feeder's final chunk zero-padded up to the full 1024-frame
reserved size (e.g. 956 padding samples for a 68-sample remainder),
jumping straight from whatever amplitude the waveform ended at down
to silence - an audible click. Fixed two ways: fade the last 32 real
frames linearly to zero before any padding begins (also covers the
case where total length is an exact multiple of the chunk size, where
the waveform would otherwise just stop abruptly with no padding at
all), and declare only the real sample count via
sceAudioSetChannelDataLen (64-aligned) for the final chunk instead of
padding out to the full reserved length, shrinking the leftover
padding to under 64 samples. Channel length gets reset to the full
chunk size at the start of each feed pass in case a previous
playback left it shortened.
Verified via PPSSPP: sceAudioSetChannelDataLen(7, 1024) then (7, 128)
for the tone's 68-sample remainder, onEnd still fires once.
Root cause of the residual jitter (correlating with framerate, per
real-hardware testing): this project never overrides
PSP_MAIN_THREAD_PRIORITY, so the main/render thread runs at PSPSDK's
default of 32. A pthread created with default attributes (as
thread_t/threadStartRequest does) runs at priority 60 - numerically
higher, meaning LOWER scheduling priority on PSP's inverted scale.
Under load the feeder thread was structurally guaranteed to lose
scheduling contention to the main thread, starving the hardware
channel's double buffer and producing audible jitter that worsens
exactly when frames get slower - confirmed via disassembling
PSPSDK's precompiled pthread glue (pte_osThreadGetDefaultPriority
returns 60) rather than guessing.
audioStreamPSPThreadFeed now self-prioritizes via
sceKernelChangeThreadPriority(sceKernelGetThreadId(), 18) as the
first thing it does, matching the elevated-priority pattern PSP SDK
samples use for their own timing-sensitive auxiliary threads.
The original implementation reserved a channel sized to the whole
buffer (~44160 samples) and sent it in one sceAudioOutputPannedBlocking
call. Confirmed against the PSP SDK's own samples (pspaudiolib,
the mp3 sample) that this deviates from how PSP audio hardware is
actually meant to be driven: small fixed-size chunks (1024 frames,
matching pspaudiolib's own convention) fed continuously, decoupled
from the render loop. Reproduced as crackling on real hardware.
Now reserves a 1024-frame channel and runs a dedicated thread (the
project's existing thread_t abstraction, already used by asset.c and
already working on PSP via DUSK_THREAD_PTHREAD) that streams the
buffer chunk-by-chunk until exhausted, re-reading volume/pan every
chunk so live changes take effect mid-playback. Finish detection
switched from polling sceAudioGetChannelRestLength (which can't tell
"between chunks" from "actually done" once chunked) to a flag the
feeder thread sets on completion, same shape as the Dolphin voice
callback.
Verified via the dusk-psp toolchain and PPSSPP headless (channel now
reserved at 1024 frames instead of 44160, onEnd still fires once).
Real-hardware crackle-free confirmation pending.
Reserves a hardware channel per stream (64-aligned sample count,
mono/stereo format) and outputs through sceAudioOutputPannedBlocking,
giving PSP real stereo panning from directionality. Enforces the
44100Hz hardware-channel constraint with a clear error instead of
silently mispitching, and drops the shared test tone to 44100Hz to
match. Verified via the dusk-psp toolchain and a headless PPSSPP run
(correct channel reservation, onEnd firing once, no errors).
Cross-platform audiostream/audio API with per-platform hooks for
PSP/Dolphin/Linux. Linux is fully wired end-to-end through SDL2
(device open, volume-mixed queueing, finish detection via
SDL_GetQueuedAudioSize) and verified playing a generated test tone
in engine.c. PSP and Dolphin hooks are stubbed for now.