Another pass looking for gaps, all of it now recorded by audio/blocking/channels and audio/blocking/oneshot. sceAudioOneshotOutput was the last unimplemented entry in the module, returning "library not linked" to anyone who called it. It plays one buffer on a channel it never reserves, so the channel frees itself when the buffer runs out, and its argument checks are their own set: any positive sample count, aligned or not, no upper bound, a negative volume rejected rather than skipped, and no busy check at all. No game is known to use it; it is implemented because tracing it turned out to be cheap, not because anything needed it. The channels test confirms four behaviors: sceAudioChReserve(-1) skips a released channel that is still playing while a reserve of it succeeds, a second channel joining a running mixer doesn't lose a block unlike a first, mono counts down in the same 64-sample steps over the same time as stereo, and the panned blocking output has no extra delay on the high channels. Also: the SRC resampler now interpolates into the next buffer at a buffer join instead of holding the last sample, since the codec reads the two descriptors as one stream. That only shows up at non-native rates and no test can see it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
11 KiB
sceAudio: how the PSP's audio output actually behaves
This describes the behavior Core/HLE/sceAudio.cpp and Core/HLE/__sceAudio.cpp model, and
where that behavior came from. Confirmed on a real PSP by pspautotests/tests/audio/blocking.
The headline, because it is the thing most likely to be assumed wrong: the blocking output
calls are not a queue that callers line up behind. Each channel holds one buffer, the
Output2/SRC channel holds two, and a caller who finds no room is told
SCE_ERROR_AUDIO_CHANNEL_BUSY and expected to go away and come back.
Two different pieces of hardware
sceAudio presents nine channels, but they are not the same thing underneath, which is why the
emulator keeps them in two different structures rather than one array of nine: AudioChannel
for the eight the mixer walks, and a single AudioSRCChannel for the ninth.
Channels 0-7 go through a software mixer running as a kernel thread. Every time the audio DMA finishes a block, that thread wakes, takes up to 64 samples from each channel that has a buffer, sums them into a 32-bit accumulator, clamps, and writes 64 stereo frames into the other half of a double buffer.
Channel 8 - sceAudioOutput2*, sceAudioSRC* and sceVaudio* are all the same channel -
never touches the mixer. It builds DMA descriptors that point straight at the game's buffer and
lets the codec resample it. sceAudioOutput2Reserve(n) is literally
sceAudioSRCChReserve(n, 44100, 2), and the other Output2 entry points are one-instruction tail
calls to the SRC ones.
They also have separate DMA channels, so one starting does not disturb the other.
The driver plays out of the game's memory
Nothing is copied when a buffer is handed over. sceAudioOutputBlocking stores the pointer, and
the mixer walks it forward 64 samples at a time until it is spent, then clears it and signals.
sceAudioGetChannelRestLen counts down in steps of 64 for exactly that reason.
The emulator used to copy the whole buffer into a ring of samples at enqueue time, with the volume already applied. That made a game rewriting a buffer it had already handed over invisible, and it made the queue depth an emulator choice rather than a hardware fact.
One buffer per channel
The driver's single enqueue point, which all four sceAudioOutput* functions reach, is:
if (sampleCount == 0) return SCE_ERROR_AUDIO_CHANNEL_NOT_INIT;
if (sampleAddress != 0) return SCE_ERROR_AUDIO_CHANNEL_BUSY;
remaining = sampleCount;
sampleAddress = ptr; // may be 0
return sampleCount;
sceAudioOutput and sceAudioOutputPanned return that result as it stands, so a second
non-blocking output is refused until the first buffer has finished.
The blocking pair adds a wait, but only for the first thread to ask:
r = enqueue(...);
if (r != BUSY) return r;
if (channel->waiting) return BUSY; // somebody else is already parked here
channel->waiting = 1;
wait for this channel's bit in the driver's event flag;
retry the enqueue;
channel->waiting = 0;
Only one thread can be parked on a channel. That single flag is why a game that runs a movie
thread and a sound-effect thread over one output gets sensible behavior on hardware and did not
in the emulator: the loser is told the channel is busy and skips its turn, where an emulator that
blocked it instead made the two threads alternate, halving the movie's audio rate. That is
https://github.com/hrydgard/ppsspp/issues/12888, and it is what
tests/audio/blocking/contend pins down.
The same flag locks out sceAudioChRelease, sceAudioSetChannelDataLen and
sceAudioChangeChannelConfig, which all return SCE_ERROR_AUDIO_CHANNEL_BUSY while a thread is
parked. sceAudioChangeChannelVolume does not check anything.
A null pointer is accepted and sets remaining without setting sampleAddress, so nothing
plays. That is the documented way to wait for a channel to drain, and it is the one case where
the two rest-length calls disagree - see below.
Two buffers on the SRC channel
Channel 8 has two DMA descriptors, so two buffers can be in flight. The third caller is refused outright and does not even get the chance to wait:
if (!reserved) return SCE_ERROR_AUDIO_CHANNEL_NOT_RESERVED;
if (both descriptors armed) return SCE_ERROR_AUDIO_CHANNEL_BUSY;
arm one;
wait for one descriptor to retire;
return sampleCount;
Starting the DMA sets the same event flag bit that a retiring descriptor does, which is why the first output after an idle stretch returns without waiting while every one after it waits exactly one buffer. The emulator models that unclaimed completion with a flag on the channel.
The two rest-length calls are not the same function twice
sceAudioGetChannelRestLen(ch) = remaining + (waiting ? sampleCount : 0)
sceAudioGetChannelRestLength(ch) = (sampleAddress ? remaining : 0) + (waiting ? sampleCount : 0)
sceAudioOutput2GetRestSample() = (armed descriptor count) * current sampleCount
Both channel versions count a parked thread's buffer as well as the one playing. They only
differ after an output with a null pointer, which leaves remaining set with no buffer, and the
hardware duly reports 0x400 from one and 0 from the other.
sceAudioOutput2GetRestSample reports in units of the current length, so after
sceAudioOutput2ChangeLength(64) a 4096-sample buffer armed earlier reads back as 0x40.
Where the emulator has to fake something
The mixer thread outranks whoever called, so on hardware the first 64 samples of a buffer are
consumed before the output call has returned - a channel reserved for exactly 64 samples is free
again immediately. The emulator's mixer is a timer event instead, so it re-phases that event to
the moment the mixer's DMA starts and runs one block right then. The SRC channel gets the phase
reset but no early read, since its DMA feeds the codec directly. Without this the answer to
sceAudioGetChannelRestLen right after an output would depend on where the timer happened to
be, and would differ from run to run.
Two things are still approximate, both below one mix block:
- A descriptor retires when the DMA transfer finishes, which is slightly ahead of the audio being heard, so hardware frees an Output2 buffer about 100us earlier than the emulator does.
- The emulator only retires SRC buffers on block boundaries, so a buffer whose length is not a
multiple of the block can be up to 1.5ms late.
audio/output2/frequencyandaudio/output2/restare intests_nextfor this reason.
Argument checking
Recorded here because several of these were guesses before, and because the order of the checks is observable.
| function | checks, in order |
|---|---|
sceAudioChReserve |
the free-channel search runs 7 down to 0 and needs the channel both released and finished playing; bad channel 80260003; already reserved 80260003; count not a positive multiple of 64 up to 0xFFC0 80260006; format not 0 or 0x10 80260007 |
sceAudioChRelease |
bad channel 80260003; not reserved 80260008; a thread parked 80260002. Clears only the reservation - a buffer already playing plays out. |
sceAudioSetChannelDataLen |
bad channel 80260003; bad length 80260006, before the reservation is looked at; parked thread 80260002; not reserved 80260001 |
sceAudioChangeChannelConfig |
bad channel 80260003; parked thread or buffer in flight 80260002; not reserved 80260008; bad format 80260007 |
sceAudioChangeChannelVolume |
either side above 0xFFFF 8026000b; bad channel 80260003; no reservation check; a negative volume leaves that side alone |
sceAudioOutput, sceAudioOutputPanned |
each volume compared signed, so negatives pass and mean "leave unchanged" |
sceAudioOutputPannedBlocking |
the two volumes are ORed before comparing, so a negative one fails with 8026000b |
sceAudioOutput2ChangeLength |
length outside 17..4111 80260006, before the reservation; not reserved 80260008 |
sceAudioSRCChReserve |
channels not 2 or 4 80000104; channels 4 80000003; count outside 17..4111 80000104; bad frequency 8026000a; already reserved 80268002 |
sceAudioSRCChRelease |
not reserved 80260008; a descriptor still armed 80268002 |
Accepted SRC frequencies are 8000, 11025, 12000, 16000, 22050, 24000, 32000 and 48000, plus whatever the output is currently running at - which is how 44100 and 0 get through.
sceAudioOneshotOutput
The odd one out: it plays a single buffer on a channel it never reserves. The channel stays
free as far as everything else is concerned, so sceAudioChRelease on it answers 80260008,
and it returns to the pool by itself once the buffer runs out. It returns the channel number,
takes any positive sample count - alignment and the 0xFFC0 ceiling do not apply - and unlike
the other outputs a negative volume is an error rather than "leave it alone". There is no busy
check either: a second one-shot while the first is playing just replaces it.
No game is known to call it. It is implemented because the behavior turned out to be simple once traced, not because anything needed it.
sceVaudio is a third shape
sceVaudio reserves the same channel as Output2 and SRC, but its own release is not the same
call. It hands the channel a null pointer first - the drain idiom above - and only then does the
ordinary SRC release. So it blocks for one buffer and returns 0 where the other two would refuse
with 80268002, and whatever was playing is played out rather than cut off. It also ignores its
own reservation flag and releases whatever holds the SRC channel, which is why
tests/audio/sceaudio/reserve gets away with what it calls the "wrong release".
sceVaudioChReserve marks vaudio reserved before delegating and does not undo that when the
delegate fails, so a caller that got 80268002 because Output2 held the channel is told
80000021 next time round until a release clears it.
What a call costs
Worth knowing because a game whose losing thread retries in a loop feels it directly.
tests/audio/blocking/overhead measures it in buckets:
| call | cost |
|---|---|
| Output2/SRC output, both descriptors already armed | 30-100us |
| Output2/SRC output, any other outcome, including an unreserved channel | over 100us |
| mixer output that is refused, blocking or not | under 10us |
| mixer output that starts the DMA | over 100us |
Every SRC output ends up querying the codec, which is the slow part; only the refused case gets out before it. Mixer channels are the other way round - cheap unless the call is the one that brings the DMA up.
Savestates
AudioChannel is at section version 4 and the SRC channel has a section of its own. Anything
older stored a ring of already-mixed samples, which cannot be turned back into a buffer pointer
and a position, so loading one drops the pending audio and wakes any parked threads. That costs
a fraction of a second of silence on load and nothing else. Older states also carry the SRC
channel as a ninth entry in the channel array, which is read and discarded.