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) <[email protected]>
This commit is contained in:
Henrik RydgårdandClaude Opus 5 committed 2026-09-10 17:03:07 -06:00
1 parent bfbede717f
commit 84bd8459e9
7 files changed
+89 -10

No files matched your search

+5
View File
@@ -307,6 +307,11 @@ template<u32 func(u32, int , int , int, int, int)> void WrapU_UIIIII() {
RETURN(retval);
}
template<u32 func(int, int, int, int, int, u32)> void WrapU_IIIIIU() {
u32 retval = func(PARAM(0), PARAM(1), PARAM(2), PARAM(3), PARAM(4), PARAM(5));
RETURN(retval);
}
template<u32 func(u32, int , int , int, u32)> void WrapU_UIIIU() {
u32 retval = func(PARAM(0), PARAM(1), PARAM(2), PARAM(3), PARAM(4));
RETURN(retval);
+35 -7
View File
@@ -298,6 +298,22 @@ u32 __AudioEnqueue(AudioChannel &chan, u32 samplePtr, int leftVol, int rightVol)
return chan.sampleCount;
}
void __AudioEnqueueOneshot(AudioChannel &chan, u32 samplePtr, u32 sampleCount, u32 format, int leftVol, int rightVol) {
// No reservation and no busy check - handing a channel a second one while the first is
// still playing simply replaces it. sampleCount deliberately stays zero on the channel,
// which is what lets it go back to being free once the buffer runs out.
chan.format = format;
chan.leftVolume = leftVol;
chan.rightVolume = rightVol;
chan.remainingSamples = sampleCount;
const bool startsDMA = samplePtr != 0 && !__AudioAnyChannelPlaying();
chan.sampleAddress = samplePtr;
if (startsDMA) {
__AudioStartMixerDMA();
}
}
u32 __AudioEnqueueBlocking(AudioChannel &chan, u32 samplePtr, int leftVol, int rightVol) {
u32 result = __AudioEnqueue(chan, samplePtr, leftVol, rightVol);
if (result != SCE_ERROR_AUDIO_CHANNEL_BUSY) {
@@ -508,6 +524,8 @@ static bool __AudioMixSRC(AudioSRCChannel &chan) {
// Zero means "whatever the output is running at", so no conversion.
const int inRate = srcFrequency != 0 ? srcFrequency : mixFrequency;
const u32 ratio = (u32)(((u64)(u32)inRate << 16) / (u32)mixFrequency);
// At the output rate the fraction never moves off zero, so there is nothing to interpolate.
const bool resampling = ratio != 0x10000;
const bool mono = chan.format == PSP_AUDIO_FORMAT_MONO;
const u32 stride = mono ? 2 : 4;
const int leftVol = chan.leftVolume;
@@ -523,16 +541,26 @@ static bool __AudioMixSRC(AudioSRCChannel &chan) {
const AudioPendingBuffer &buf = chan.buffers[0];
const u32 addr = buf.address + chan.playedSamples * stride;
// Interpolating against the following sample matters when a game reserved 22050Hz
// or similar; at the native rate the fraction is always zero and this reduces to a
// plain copy.
const u32 avail = std::min(buf.samples - chan.playedSamples, 2u);
if (!chan.mute && Memory::IsValidRange(addr, avail * stride)) {
if (!chan.mute && Memory::IsValidRange(addr, stride)) {
const s16_le *src = (const s16_le *)Memory::GetPointerUnchecked(addr);
const int l0 = src[0];
const int r0 = mono ? l0 : src[1];
const int l1 = avail > 1 ? (int)src[stride / 2] : l0;
const int r1 = avail > 1 ? (mono ? l1 : (int)src[stride / 2 + 1]) : r0;
int l1 = l0;
int r1 = r0;
if (resampling) {
// Interpolate against the sample after this one. At the end of a buffer that
// is the start of the next, since the codec reads the two descriptors as one
// unbroken stream - holding the last sample instead would tick at every join.
u32 nextAddr = addr + stride;
if (chan.playedSamples + 1 >= buf.samples) {
nextAddr = chan.bufferCount > 1 ? chan.buffers[1].address : 0;
}
if (nextAddr != 0 && Memory::IsValidRange(nextAddr, stride)) {
const s16_le *next = (const s16_le *)Memory::GetPointerUnchecked(nextAddr);
l1 = next[0];
r1 = mono ? l1 : next[1];
}
}
// 15 bits of fraction, not 16 - a full 16 would overflow the product against a
// full-scale difference.
const int frac = (int)(chan.frac >> 1);
+3
View File
@@ -45,6 +45,9 @@ void __AudioSetSRCFrequency(int freq);
// count, or SCE_ERROR_AUDIO_CHANNEL_BUSY when a buffer is already in flight. A negative
// volume means "leave it alone".
u32 __AudioEnqueue(AudioChannel &chan, u32 samplePtr, int leftVol, int rightVol);
// sceAudioOneshotOutput's variant: hands over a buffer without a reservation, so it carries its
// own sample count and format and never reports busy.
void __AudioEnqueueOneshot(AudioChannel &chan, u32 samplePtr, u32 sampleCount, u32 format, int leftVol, int rightVol);
// Same, but parks the calling thread until the channel frees up. Only one thread can be
// parked; a second one gets SCE_ERROR_AUDIO_CHANNEL_BUSY straight back.
u32 __AudioEnqueueBlocking(AudioChannel &chan, u32 samplePtr, int leftVol, int rightVol);
+31 -2
View File
@@ -330,6 +330,34 @@ static u32 sceAudioChangeChannelConfig(u32 chan, u32 format) {
return hleLogDebug(Log::sceAudio, 0);
}
// Plays one buffer on a channel without reserving it, so the channel frees itself once the
// buffer runs out. The checks are looser than sceAudioChReserve's - any positive sample count
// goes, aligned or not - and stricter on volume, where a negative one is an error rather than
// meaning "leave it alone".
static u32 sceAudioOneshotOutput(int chan, int sampleCount, int format, int leftvol, int rightvol, u32 samplePtr) {
if ((u32)leftvol > 0xFFFF || (u32)rightvol > 0xFFFF) {
return hleLogError(Log::sceAudio, SCE_ERROR_AUDIO_INVALID_VOLUME, "invalid volume");
}
if (chan < 0) {
chan = GetFreeChannel();
if (chan < 0) {
return hleLogError(Log::sceAudio, SCE_ERROR_AUDIO_NO_CHANNELS_AVAILABLE, "no channels remaining");
}
} else if (chan >= (int)PSP_AUDIO_CHANNEL_MAX || g_audioChans[chan].reserved) {
// A reserved channel is refused with the same error as one that doesn't exist.
return hleLogError(Log::sceAudio, SCE_ERROR_AUDIO_INVALID_CHANNEL, "bad channel %d", chan);
}
if (sampleCount <= 0) {
return hleLogError(Log::sceAudio, SCE_ERROR_AUDIO_OUTPUT_SAMPLE_DATA_SIZE_NOT_ALIGNED, "invalid sample count");
}
if (format != PSP_AUDIO_FORMAT_MONO && format != PSP_AUDIO_FORMAT_STEREO) {
return hleLogError(Log::sceAudio, SCE_ERROR_AUDIO_INVALID_FORMAT, "invalid format");
}
__AudioEnqueueOneshot(g_audioChans[chan], samplePtr, sampleCount, format, leftvol, rightvol);
return hleLogDebug(Log::sceAudio, chan);
}
static u32 sceAudioChangeChannelVolume(u32 chan, int leftvol, int rightvol) {
if (leftvol > 0xFFFF || rightvol > 0xFFFF) {
return hleLogError(Log::sceAudio, SCE_ERROR_AUDIO_INVALID_VOLUME, "invalid chan %d volume %d %d", chan, leftvol, rightvol);
@@ -595,8 +623,9 @@ const HLEFunction sceAudio[] =
{0X5C37C0AE, &WrapU_V<sceAudioSRCChRelease>, "sceAudioSRCChRelease", 'x', "" },
{0XE0727056, &WrapU_UU<sceAudioSRCOutputBlocking>, "sceAudioSRCOutputBlocking", 'x', "xx" },
// Never seen these used
{0X41EFADE7, nullptr, "sceAudioOneshotOutput", '?', "" },
{0X41EFADE7, &WrapU_IIIIIU<sceAudioOneshotOutput>, "sceAudioOneshotOutput", 'x', "iiiiix"},
// Never seen this used
{0XB61595C0, nullptr, "sceAudioLoopbackTest", '?', "" },
// Microphone interface
+12
View File
@@ -150,6 +150,18 @@ checks is observable.
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
+2
View File
@@ -114,9 +114,11 @@ tests_good = [
"audio/mp3/setloopnum",
"audio/mp3/stream",
"audio/blocking/contend",
"audio/blocking/channels",
"audio/blocking/depth",
"audio/blocking/errors",
"audio/blocking/overhead",
"audio/blocking/oneshot",
"audio/blocking/restlen",
"audio/blocking/vaudio",
"audio/sceaudio/datalen",