Cache PSP volume/pan computation, mix channel + master volume into mixer playback, wire boa.mp3 as looping BGM

- 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]>
This commit is contained in:
2026-09-02 15:10:09 -05:00
co-authored by Claude Sonnet 5
parent d49594d3fb
commit d5078b978c
5 changed files with 164 additions and 44 deletions
+54 -8
View File
@@ -11,6 +11,7 @@
#include "audio/stream/audiostreamtype.h"
#include "util/memory.h"
#include "util/string.h"
#include "util/math.h"
#include "assert/assert.h"
audiomixer_t AUDIO_MIXER;
@@ -18,6 +19,11 @@ audiomixer_t AUDIO_MIXER;
errorret_t audioMixerInit() {
memoryZero(&AUDIO_MIXER, sizeof(audiomixer_t));
AUDIO_MIXER.masterVolume = 1.0f;
for(audiomixerchannel_t channel = 0; channel < AUDIO_MIXER_CHANNEL_COUNT; channel++) {
AUDIO_MIXER.channels[channel].volume = 1.0f;
}
errorOk();
}
@@ -32,6 +38,21 @@ errorret_t audioMixerChannelStopStream(audiomixerchannelstate_t *state) {
errorOk();
}
void audioMixerChannelApplyVolume(audiomixerchannelstate_t *state) {
assertNotNull(state, "State cannot be NULL.");
if(state->stream == NULL) return;
// Recomputed and reapplied every frame (not just when a PLAY command
// starts state->stream) so a live audioMixerSetChannelVolume()/
// audioMixerSetMasterVolume() call affects whatever's already playing,
// not just sounds started afterward.
const float_t volume = mathClamp(
state->baseVolume * state->volume * AUDIO_MIXER.masterVolume, 0.0f, 1.0f
);
audioStreamSetVolume(state->stream, volume);
}
errorret_t audioMixerUpdateEarly() {
for(audiomixerchannel_t channel = 0; channel < AUDIO_MIXER_CHANNEL_COUNT; channel++) {
audiomixerchannelstate_t *state = &AUDIO_MIXER.channels[channel];
@@ -39,13 +60,8 @@ errorret_t audioMixerUpdateEarly() {
if(command->command == AUDIO_MIXER_COMMAND_STOP) {
errorChain(audioMixerChannelStopStream(state));
} else if(command->command == AUDIO_MIXER_COMMAND_PLAY) {
assertNotNull(
command->loadingAsset,
"PLAY command reached UpdateEarly without a loadingAsset - "
"UpdateLate should always have locked one by now."
);
command->command = AUDIO_MIXER_COMMAND_NONE;
} else if(command->command == AUDIO_MIXER_COMMAND_PLAY && command->loadingAsset != NULL) {
// Blocks here if audioMixerUpdateLate()'s load (kicked off at the end
// of the previous frame) hasn't finished yet - see this function's
// own doc comment on why that's accepted for now.
@@ -71,7 +87,6 @@ errorret_t audioMixerUpdateEarly() {
command->command = AUDIO_MIXER_COMMAND_NONE;
errorChain(initRet);
audioStreamSetVolume(stream, command->volume);
audioStreamSetDirectionality(stream, command->pan);
if(command->looping) {
audioStreamSetLooping(stream, true);
@@ -81,7 +96,22 @@ errorret_t audioMixerUpdateEarly() {
audioStreamPlay(stream);
state->stream = stream;
state->baseVolume = command->volume;
}
// A PLAY command with no loadingAsset yet (command->loadingAsset ==
// NULL) was queued too late for the audioMixerUpdateLate() that just
// ran immediately before this call to have locked it - the only way
// that happens is a command queued outside the normal per-frame cycle,
// e.g. during engine startup before any audioMixerUpdateLate() has run
// yet. Left alone (still AUDIO_MIXER_COMMAND_PLAY) rather than treated
// as an error: the very next audioMixerUpdateLate() will lock it, and
// the frame after that will reach this branch and apply it normally.
// Determines the actual volume passed to audioStreamSetVolume() from
// this sound's own volume (just set above, for a fresh PLAY) and this
// channel's/the mixer's - see its own comment for why this runs every
// frame regardless of whether a command was queued this frame.
audioMixerChannelApplyVolume(state);
}
errorOk();
@@ -166,6 +196,22 @@ void audioMixerStop(const audiomixerchannel_t channel) {
AUDIO_MIXER.channels[channel].pending.command = AUDIO_MIXER_COMMAND_STOP;
}
void audioMixerSetChannelVolume(
const audiomixerchannel_t channel,
const float_t volume
) {
assertTrue(channel < AUDIO_MIXER_CHANNEL_COUNT, "Channel is out of range");
assertTrue(volume >= 0.0f && volume <= 1.0f, "Volume is out of range");
AUDIO_MIXER.channels[channel].volume = volume;
}
void audioMixerSetMasterVolume(const float_t volume) {
assertTrue(volume >= 0.0f && volume <= 1.0f, "Volume is out of range");
AUDIO_MIXER.masterVolume = volume;
}
errorret_t audioMixerDispose() {
for(audiomixerchannel_t channel = 0; channel < AUDIO_MIXER_CHANNEL_COUNT; channel++) {
errorChain(audioMixerChannelStopStream(&AUDIO_MIXER.channels[channel]));
+64 -5
View File
@@ -64,11 +64,28 @@ typedef struct {
// AUDIO_MIXER_COMMAND_STOP or before a new AUDIO_MIXER_COMMAND_PLAY
// replaces it.
audiostream_t *stream;
// The per-sound volume `stream` was last started with (the `volume`
// argument to whichever audioMixerPlay()/audioMixerPlayLooped() call
// started it) - combined with this channel's own volume and the mixer's
// master volume, every frame, to get the volume actually passed to
// audioStreamSetVolume(). Meaningless while `stream` is NULL.
float_t baseVolume;
// This channel's own volume, from 0.0 (silent) to 1.0 (loudest, the
// default) - independent of any individual sound's own volume. Set via
// audioMixerSetChannelVolume().
float_t volume;
} audiomixerchannelstate_t;
typedef struct {
audiomixerchannelstate_t channels[AUDIO_MIXER_CHANNEL_COUNT];
uint8_t fade[AUDIO_MIXER_CHANNEL_COUNT];
// Global volume multiplier applied on top of every channel's own volume
// and every sound's own volume - from 0.0 (silent) to 1.0 (loudest, the
// default). Set via audioMixerSetMasterVolume().
float_t masterVolume;
} audiomixer_t;
extern audiomixer_t AUDIO_MIXER;
@@ -89,14 +106,30 @@ errorret_t audioMixerInit();
*/
errorret_t audioMixerChannelStopStream(audiomixerchannelstate_t *state);
/**
* Computes the given channel's actual playback volume - its currently
* playing sound's own volume (baseVolume) combined with the channel's own
* volume and the mixer's master volume - and applies it via
* audioStreamSetVolume(). A safe no-op if the channel isn't playing
* anything. Called every audioMixerUpdateEarly(), not just when a PLAY
* command starts a new stream - see audioMixerUpdateEarly()'s own comment.
*
* @param state The channel state to update.
*/
void audioMixerChannelApplyVolume(audiomixerchannelstate_t *state);
/**
* Updates the audio mixer, called before rendering (the start of the
* frame): applies whatever command (play, stop - including everything a
* play carries, volume/pan/looping) audioMixerUpdateLate() queued for
* loading at the end of the previous frame. If that load hasn't finished
* yet, this blocks (via assetRequireLoaded()) until it has - a deliberate
* stall for now rather than skipping a frame, since there's currently no
* way to only partially apply a channel's command.
* play carries, pan/looping) audioMixerUpdateLate() queued for loading at
* the end of the previous frame. If that load hasn't finished yet, this
* blocks (via assetRequireLoaded()) until it has - a deliberate stall for
* now rather than skipping a frame, since there's currently no way to only
* partially apply a channel's command. Also recomputes and reapplies every
* playing channel's mixed volume (see audioMixerChannelApplyVolume()) every
* time, regardless of whether a command was queued this frame, so
* audioMixerSetChannelVolume()/audioMixerSetMasterVolume() take effect on
* whatever's already playing too.
*/
errorret_t audioMixerUpdateEarly();
@@ -195,6 +228,32 @@ void audioMixerQueuePlay(
*/
void audioMixerStop(const audiomixerchannel_t channel);
/**
* Sets the given channel's own volume, independent of whatever individual
* sound is (or will be) playing on it - combined with that sound's own
* volume and the mixer's master volume on the very next
* audioMixerUpdateEarly(), so it takes effect immediately even if nothing
* new is being played.
*
* @param channel Channel to update.
* @param volume The channel's new volume, from 0.0 (silent) to 1.0
* (loudest, the default).
*/
void audioMixerSetChannelVolume(
const audiomixerchannel_t channel,
const float_t volume
);
/**
* Sets the mixer's master volume, combined with every channel's own volume
* and every sound's own volume on the very next audioMixerUpdateEarly(), so
* it takes effect immediately for whatever's already playing.
*
* @param volume The new master volume, from 0.0 (silent) to 1.0 (loudest,
* the default).
*/
void audioMixerSetMasterVolume(const float_t volume);
/**
* Disposes the audio mixer.
*/
+6 -23
View File
@@ -23,17 +23,10 @@
#include "console/console.h"
#include "save/save.h"
#include "audio/audio.h"
#include "audio/mixer/audiomixer.h"
engine_t ENGINE;
void engineTestToneOnEnd(audiostream_t *stream) {
consolePrint("Test tone finished playing");
}
void engineTestToneOnLoop(audiostream_t *stream) {
consolePrint("Test tone looped");
}
errorret_t engineInit(const int32_t argc, const char_t **argv) {
assertInit();
memoryZero(&ENGINE, sizeof(engine_t));
@@ -59,23 +52,13 @@ errorret_t engineInit(const int32_t argc, const char_t **argv) {
#endif
errorChain(sceneInit());
// Smoke-tests the audio subsystem end to end (asset loading -> PCM
// streaming -> platform playback) against a real WAV asset rather than
// Smoke-tests the audio mixer end to end (asset loading -> PCM/MP3
// streaming -> platform playback) against a real MP3 asset rather than
// synthesizing PCM data at runtime.
assetentry_t *testToneEntry = assetLock(
"audio/audiotest.wav", ASSET_LOADER_TYPE_WAV, NULL
audioMixerPlayLooped(
"audio/boa.mp3", AUDIO_MIXER_CHANNEL_BGM_0, 1.0f, AUDIO_STREAM_CENTER,
0, -1.0f, 0.0f
);
errorChain(assetRequireLoaded(testToneEntry));
audiostream_t *stream = audioAquireStream(testToneEntry);
assertNotNull(stream, "No free audio stream slots available.");
errorChain(audioStreamInit(stream, testToneEntry));
assetUnlockEntry(testToneEntry); // The stream now holds its own lock.
stream->onEnd = engineTestToneOnEnd;
stream->onLoop = engineTestToneOnLoop;
audioStreamSetLooping(stream, true);
audioStreamPlay(stream);
consolePrint("Engine initialized");
+22 -5
View File
@@ -73,6 +73,7 @@ errorret_t audioStreamPSPInit(audiostream_t *stream) {
stream->platform.channel = channel;
stream->platform.finished = false;
stream->platform.playRequested = false;
stream->platform.hasLastVolume = false;
const size_t ringSize =
AUDIO_PSP_RING_FRAMES * stream->channels * sizeof(int16_t);
@@ -381,17 +382,33 @@ void audioStreamPSPThreadFeed(thread_t *thread) {
}
}
// Re-read every chunk (~23ms at 44100Hz) so SetVolume/SetDirectionality
// take effect mid-playback, unlike the platform's other one-shot calls.
// Checked every chunk (~23ms at 44100Hz) so SetVolume/SetDirectionality
// take effect mid-playback, unlike the platform's other one-shot
// calls - but only actually recomputed when one of them has changed
// since the last chunk (see lastVolume's own comment); otherwise the
// previous chunk's already-computed leftVolume/rightVolume are reused
// as-is.
if(
!stream->platform.hasLastVolume ||
stream->platform.lastVolume != stream->volume ||
stream->platform.lastDirectionality != stream->directionality
) {
float_t leftFactor, rightFactor;
audioStreamGetPanFactors(stream, &leftFactor, &rightFactor);
const int baseVolume = (int) (stream->volume * PSP_AUDIO_VOLUME_MAX);
const int leftVolume = (int) (baseVolume * leftFactor);
const int rightVolume = (int) (baseVolume * rightFactor);
stream->platform.lastLeftVolume = (int) (baseVolume * leftFactor);
stream->platform.lastRightVolume = (int) (baseVolume * rightFactor);
stream->platform.lastVolume = stream->volume;
stream->platform.lastDirectionality = stream->directionality;
stream->platform.hasLastVolume = true;
}
sceAudioOutputPannedBlocking(
stream->platform.channel, leftVolume, rightVolume, chunk
stream->platform.channel,
stream->platform.lastLeftVolume,
stream->platform.lastRightVolume,
chunk
);
stream->platform.framesOutput += framesThisChunk;
+15
View File
@@ -141,6 +141,21 @@ typedef struct {
// pass's last, possibly-partial chunk.
bool_t readReachedEnd;
bool_t readFailed;
// Cache of the last stream->volume/directionality the output thread
// computed lastLeftVolume/lastRightVolume from - recomputing
// audioStreamGetPanFactors() and both multiplications is wasted work on
// every single chunk (~23ms) whenever neither has actually changed since
// the previous one, which is the common case outside of an active fade/
// pan. PSP itself has no persistent per-channel volume to cache this
// for us (see sceAudioOutputPannedBlocking's own call site) - hence
// caching it ourselves. Output-thread-only, like the rest of this
// section - see sceAudioOutputPannedBlocking's own call site.
float_t lastVolume;
float_t lastDirectionality;
int lastLeftVolume;
int lastRightVolume;
bool_t hasLastVolume;
} audiostreampsp_t;
/**