Files
ppsspp/docs/sceAudio.md
T
Henrik RydgårdandClaude Opus 5 84bd8459e9 sceAudio: implement sceAudioOneshotOutput, and cover the rest of the channel rules
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>
2026-09-10 17:03:07 -06:00

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/frequency and audio/output2/rest are in tests_next for 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.