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]>
- Fixed PSP pause taking up to ~1.1s to actually go silent: its output
thread ran independently of stream->state, draining its whole software
ring regardless. audiostream_t.state is now volatile and the output
thread checks AUDIO_STREAM_STATE_PLAYING before each hardware chunk,
skipping output (without consuming the ring) while paused - pause now
goes silent within about one chunk (~23ms) and resume has no gap.
- Added audioMixerPause/Resume/SetPan/SetLoop/FadeTo/IsFading to the
mixer - immediate, channel-indexed primitives for cutscenes to drive.
Fade transitions (fadeFrom/To/Duration/Time/Easing) live on
audiomixerchannelstate_t and advance every frame in
audioMixerChannelApplyVolume(), reusing the same easingApply()
interpolation uifullbox_t already uses for screen fades.
- New src/dusk/rpg/cutscene/item/audio/ with 9 cutscene item types:
AUDIO_PLAY (+ AUDIO_PLAY_SIMPLE/AUDIO_PLAY_LOOPED shorthands),
AUDIO_STOP, AUDIO_PAUSE, AUDIO_RESUME, AUDIO_FADE (+ FADE_OUT/FADE_IN
shorthands), AUDIO_FADE_WAIT, AUDIO_SET_PAN, AUDIO_SET_LOOP, and the
combined AUDIO_SET - registered through the same enum/union/callback
table/macro mechanism every other item type uses.
- Wired the same 9 types into the JSON-based (offline JSONC -> binary
.cts) cutscene asset pipeline: tools/asset/cutscene/__main__.py's
encoder and assetcutsceneloader.c's decoder. Verified round-trip by
hand-encoding/decoding a test file covering all 9 types, and confirmed
the two existing real cutscene files re-encode byte-identical.
Built and verified on Linux, PSP (Docker), GameCube (Docker) and Wii
(Docker).
Co-Authored-By: Claude Sonnet 5 <[email protected]>
- PSP output thread now caches the last computed leftVolume/rightVolume and
only recomputes them when stream->volume/directionality actually changed
since the previous chunk, instead of recomputing every ~23ms chunk
unconditionally.
- Mixer channels and the mixer itself now carry their own volume
(audioMixerSetChannelVolume()/audioMixerSetMasterVolume()), multiplied
with each sound's own volume every audioMixerUpdateEarly() and applied via
audioStreamSetVolume() - so changing a channel's or the master volume
affects whatever's already playing, not just future sounds. Also fixed
audioMixerUpdateEarly() to tolerate a PLAY command queued outside the
normal per-frame cycle (no loadingAsset yet from a preceding
audioMixerUpdateLate(), e.g. during engine startup) by leaving it queued
for the following frame instead of asserting.
- engine.c now plays boa.mp3 on AUDIO_MIXER_CHANNEL_BGM_0 on loop through
the mixer instead of driving a raw audiostream_t directly, exercising the
new mixer pipeline end to end.
Built and verified on Linux, PSP (Docker) and GameCube (Docker).
Co-Authored-By: Claude Sonnet 5 <[email protected]>
- audioUpdate()/audioMixerUpdate() split into Early (before rendering, per-stream buffering) and Late (end of frame, starts newly-queued mixer sounds) phases, wired into engineUpdate() around displayUpdate(); mixer is now actually initialized/disposed/updated from audio.c.
- New src/dusk/audio/stream/audiostreamtype.{h,c}: asset-loader-type <-> audio-stream-type map (with file extension), plus audioStreamAssetTypeForPath()/audioStreamTypeForAssetType()/audioStreamAssetTypeForStreamType() lookups.
- audiostream_t/audiomixerchanneldata_t volume and directionality/pan switched from integer (0-0xFF / -128..127) to float (0.0-1.0 / -1.0..1.0), removing the manual scaling each platform backend had to do.
- audiostream_t gains loopLimit/loopRestartCount so a stream can be told to stop (fire onEnd) after a fixed number of loop restarts instead of looping forever; wired into the shared restart path (Linux/Dolphin) and PSP's own read-ahead loop decision.
Built and verified on Linux, PSP (Docker) and GameCube (Docker).
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Reorganizes src/dusk/audio/: individual stream implementations
(audiostream, audiostreampcm, audiostreammp3, audiostreammp3decodersw)
move into audio/stream/, and a new audio/mixer/ holds a channel-based
playback queue (audiomixer.c/.h) for future use - not yet wired into
the engine.
Fixes needed to keep the tree buildable after the move:
- A stray duplicate of audiostreammp3decodersw.c/.h was left at the old
flat path; removed in favor of the canonical copy in stream/.
- Updated every #include "audio/audiostream*.h" and the two platform
CMakeLists.txt (dusklinux, duskdolphin) that still pointed at the old
flat location.
- audiomixer.c/.h didn't compile: audiomixerqueue_t was referenced but
never defined (audiomixerchanneldata_t has the matching fields), a
trailing comma in audioMixerPlayLooped's parameter list is illegal in
C, and `file` was declared as an array of pointers instead of a char
buffer.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
audioStreamPSPTopUp() advanced readPosition/ringFilled/framesEnqueued by
the requested framesToRead regardless of how many frames a short read
actually produced - a leftover assumption from the WAV/PCM design, where
a short read only ever means "truly corrupt file, at the real end."
That doesn't hold for MP3: a hardware decoder backend can plausibly
report "nothing ready this instant" without that meaning no more content
exists. Every such short read silently inflated readPosition ahead of
real decode progress, triggering the loop-segment-end check far too
early - restarting the pass again and again well short of the real
runtime, heard as the clip racing through its own content.
Now only ever advances by the actual frames produced (matching
dusklinux's audioStreamLinuxFeed(), which already did this correctly),
and bases the loop/end decision on real position instead of the
originally-requested read size.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
New ASSET_LOADER_TYPE_MP3 (hand-rolled MPEG-1/2/2.5 Layer III header
parser - no third-party dependency needed just for metadata, since PSP's
hardware path doesn't need one at all) plus a shared audiostreammp3.c
stream layer mirroring audiostreampcm.c's shape. Generalized the stream
dispatch (hoisted sampleRate/channels onto audiostream_t, added
audioStreamGetTotalFrames()/Seek()/Read()) so all three platform audio
backends keep working unchanged, just calling the generic names instead
of PCM-specific ones.
Two decoder backends behind one interface: PSP uses the real sceMp3
hardware decoder (firmware-offloaded, lazily initialized on first use);
Linux and Dolphin share one minimp3-based software decoder (public
domain, vendored via CMake FetchContent) - libogc's own MP3Player wraps
libmad (GPL) and drives its own output pipeline, not a fit for the
ansnd-based architecture already in place, so skipped in favor of the
shared minimp3 path.
Fixed three real bugs found via hardware/runtime testing along the way:
- LAME's Xing header counts its own placeholder frame in the declared
total, which made playback stall permanently one frame short of the
declared end (looked like "never loops") - fixed by subtracting it.
- sceMp3Decode() can return more PCM than one MPEG frame's worth in a
single call (PSP's pcmBuf is provisioned for 2x), overflowing the
shared per-frame decode buffer with no bound check - very intermittent
corruption/clicking on real hardware. Widened the buffer to the real
worst case and added an assertion.
- sceMp3ResetPlayPosition()'s exact internal reset semantics aren't
documented precisely enough to trust for looping - occasionally
disagreed with the fresh stream position fed right after, clicking at
the loop boundary about 1 in 3-4 loops. Rewind now fully tears down and
recreates the decoder instead, the same path already proven correct at
first Init. Also widened the PSP ring buffer to absorb that now-heavier
operation, capping each top-up call's own work so the bigger buffer
doesn't turn into one long blocking decode burst instead.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
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 <[email protected]>
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 <[email protected]>
- 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.