diff --git a/src/dusk/audio/mixer/CMakeLists.txt b/src/dusk/audio/mixer/CMakeLists.txt index edfdb04b..fb7309c3 100644 --- a/src/dusk/audio/mixer/CMakeLists.txt +++ b/src/dusk/audio/mixer/CMakeLists.txt @@ -7,4 +7,5 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME} PUBLIC audiomixer.c + audiomixerchannel.c ) \ No newline at end of file diff --git a/src/dusk/audio/mixer/audiomixer.c b/src/dusk/audio/mixer/audiomixer.c index 5a424bf0..2024a1b1 100644 --- a/src/dusk/audio/mixer/audiomixer.c +++ b/src/dusk/audio/mixer/audiomixer.c @@ -6,12 +6,9 @@ */ #include "audiomixer.h" -#include "audio/audio.h" #include "audio/stream/audiostream.h" -#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; @@ -19,117 +16,39 @@ 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; + audiomixerchannel_t channel = 0; + while(channel < AUDIO_MIXER_CHANNEL_COUNT) { + AUDIO_MIXER.channels[channel].fade = 1.0f; + channel++; } errorOk(); } -errorret_t audioMixerChannelStopStream(audiomixerchannelstate_t *state) { - assertNotNull(state, "State cannot be NULL."); - - if(state->stream != NULL) { - errorChain(audioStreamDispose(state->stream)); - state->stream = NULL; - } - - 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]; - audiomixercommand_t *command = &state->pending; - - if(command->command == AUDIO_MIXER_COMMAND_STOP) { - errorChain(audioMixerChannelStopStream(state)); - 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. - errorret_t loadRet = assetRequireLoaded(command->loadingAsset); - if(errorIsNotOk(loadRet)) { - assetUnlockEntry(command->loadingAsset); - command->loadingAsset = NULL; - command->command = AUDIO_MIXER_COMMAND_NONE; - errorChain(loadRet); - } - - errorChain(audioMixerChannelStopStream(state)); - - audiostream_t *stream = audioAquireStream(command->loadingAsset); - assertNotNull(stream, "No free audio stream slots available."); - - errorret_t initRet = audioStreamInit(stream, command->loadingAsset); - // The stream holds its own lock on the asset once initialized (or, - // on failure, audioStreamInit() has already released it itself) - - // either way this call's own lock is no longer needed. - assetUnlockEntry(command->loadingAsset); - command->loadingAsset = NULL; - command->command = AUDIO_MIXER_COMMAND_NONE; - errorChain(initRet); - - audioStreamSetDirectionality(stream, command->pan); - if(command->looping) { - audioStreamSetLooping(stream, true); - audioStreamSetLoopPoints(stream, command->loopStart, command->loopTo); - audioStreamSetLoopLimit(stream, command->loopCount); - } - 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); + audiomixerchannel_t channel = 0; + while(channel < AUDIO_MIXER_CHANNEL_COUNT) { + // One channel's error (e.g. a failed asset load) must not stop every + // other channel from being updated this frame - caught and logged + // rather than propagated, same as audioStream*IsFinished()'s own + // per-stream error handling. + errorret_t ret = audioMixerChannelUpdateEarly( + channel, &AUDIO_MIXER.channels[channel] + ); + if(errorIsNotOk(ret)) errorCatch(errorPrint(ret)); + channel++; } errorOk(); } errorret_t audioMixerUpdateLate() { - for(audiomixerchannel_t channel = 0; channel < AUDIO_MIXER_CHANNEL_COUNT; channel++) { - audiomixercommand_t *command = &AUDIO_MIXER.channels[channel].pending; - - if(command->command != AUDIO_MIXER_COMMAND_PLAY) continue; - - const assetloadertype_t assetType = audioStreamAssetTypeForPath(command->file); - assertTrue(assetType != ASSET_LOADER_TYPE_NULL, "Unsupported audio file extension."); - - // Begins loading now - kicked off at the end of this frame so it has - // until the start of the next (audioMixerUpdateEarly()) to finish - // before that has to block on it. - command->loadingAsset = assetLock(command->file, assetType, NULL); + audiomixerchannel_t channel = 0; + while(channel < AUDIO_MIXER_CHANNEL_COUNT) { + errorret_t ret = + audioMixerChannelUpdateLate(&AUDIO_MIXER.channels[channel]); + if(errorIsNotOk(ret)) errorCatch(errorPrint(ret)); + channel++; } errorOk(); @@ -153,7 +72,9 @@ void audioMixerPlayLooped( const float_t loopStart, const float_t loopTo ) { - audioMixerQueuePlay(file, channel, volume, pan, true, loopCount, loopStart, loopTo); + audioMixerQueuePlay( + file, channel, volume, pan, true, loopCount, loopStart, loopTo + ); } void audioMixerQueuePlay( @@ -196,25 +117,11 @@ 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++) { + audiomixerchannel_t channel = 0; + while(channel < AUDIO_MIXER_CHANNEL_COUNT) { errorChain(audioMixerChannelStopStream(&AUDIO_MIXER.channels[channel])); + channel++; } errorOk(); diff --git a/src/dusk/audio/mixer/audiomixer.h b/src/dusk/audio/mixer/audiomixer.h index 6f842277..eb540404 100644 --- a/src/dusk/audio/mixer/audiomixer.h +++ b/src/dusk/audio/mixer/audiomixer.h @@ -6,86 +6,10 @@ */ #pragma once -#include "asset/asset.h" - -typedef struct audiostream_s audiostream_t; - -typedef enum { - AUDIO_MIXER_CHANNEL_BGM_0, - AUDIO_MIXER_CHANNEL_VOICE_0, - AUDIO_MIXER_CHANNEL_VOICE_1, - AUDIO_MIXER_CHANNEL_SFX_0, - AUDIO_MIXER_CHANNEL_SFX_1, - AUDIO_MIXER_CHANNEL_SFX_2, - AUDIO_MIXER_CHANNEL_SFX_3, - AUDIO_MIXER_CHANNEL_COUNT -} audiomixerchannel_t; - -#define AUDIO_PATH_MAX 256 - -typedef enum { - AUDIO_MIXER_COMMAND_NONE, - AUDIO_MIXER_COMMAND_PLAY, - AUDIO_MIXER_COMMAND_STOP -} audiomixercommandtype_t; - -typedef struct { - // What audioMixerUpdateEarly() should do to this channel at the start of - // the next frame - AUDIO_MIXER_COMMAND_NONE if nothing's queued. Set by - // audioMixerPlay()/audioMixerPlayLooped()/audioMixerStop(); queuing a new - // command on a channel replaces, rather than stacks on top of, whatever - // was already queued for it this frame - only the most recent survives. - audiomixercommandtype_t command; - - // AUDIO_MIXER_COMMAND_PLAY only, below. - char_t file[AUDIO_PATH_MAX]; - float_t volume; - float_t pan; - bool_t looping; - uint8_t loopCount; - float_t loopStart; - float_t loopTo; - - // Set by audioMixerUpdateLate() (the end of the frame this PLAY command - // was queued on), once it locks the requested file and kicks off its - // load. audioMixerUpdateEarly() (the start of the next frame) blocks on - // this being fully loaded before actually starting playback - see its - // own comment. NULL except in that in-between window. - assetentry_t *loadingAsset; -} audiomixercommand_t; - -typedef struct { - // Queued action for the next audioMixerUpdateEarly() to apply. - audiomixercommand_t pending; - - // This channel's currently playing stream, or NULL if nothing is - - // acquired from AUDIO.streams by audioMixerUpdateEarly() when it applies - // an AUDIO_MIXER_COMMAND_PLAY, disposed the same way on - // 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; +#include "audio/mixer/audiomixerchannel.h" 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; @@ -95,52 +19,16 @@ extern audiomixer_t AUDIO_MIXER; */ errorret_t audioMixerInit(); -/** - * Stops and disposes the given channel's currently playing stream, if any - - * a safe no-op if the channel is already idle. Shared by - * audioMixerUpdateEarly() (AUDIO_MIXER_COMMAND_STOP, and replacing a - * channel's stream before a new AUDIO_MIXER_COMMAND_PLAY starts) and - * audioMixerDispose(). - * - * @param state The channel state to stop. - */ -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, 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. + * frame) - see audioMixerChannelUpdateEarly()'s own comment, run for every + * channel. */ errorret_t audioMixerUpdateEarly(); /** - * Updates the audio mixer, called at the end of each frame, once every - * other system has had a chance to queue a command via - * audioMixerPlay()/audioMixerPlayLooped()/audioMixerStop(). For a queued - * AUDIO_MIXER_COMMAND_PLAY, locks its file and begins loading it (see - * assetLock()) - giving it from now until the next audioMixerUpdateEarly() - * to finish before that has to block on it. Actually starting/stopping - * playback happens there, not here. + * Updates the audio mixer, called at the end of each frame - see + * audioMixerChannelUpdateLate()'s own comment, run for every channel. */ errorret_t audioMixerUpdateLate(); @@ -228,32 +116,6 @@ 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. */ diff --git a/src/dusk/audio/mixer/audiomixerchannel.c b/src/dusk/audio/mixer/audiomixerchannel.c new file mode 100644 index 00000000..f2657a74 --- /dev/null +++ b/src/dusk/audio/mixer/audiomixerchannel.c @@ -0,0 +1,174 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "audiomixerchannel.h" +#include "audio/audio.h" +#include "audio/stream/audiostream.h" +#include "audio/stream/audiostreamtype.h" +#include "save/save.h" +#include "assert/assert.h" + +errorret_t audioMixerChannelStopStream(audiomixerchannelstate_t *state) { + assertNotNull(state, "State cannot be NULL."); + + if(state->stream == NULL) errorOk(); + + errorChain(audioStreamDispose(state->stream)); + state->stream = NULL; + + errorOk(); +} + +void audioMixerChannelApplyVolume( + const audiomixerchannel_t channel, + audiomixerchannelstate_t *state +) { + assertNotNull(state, "State cannot be NULL."); + + if(state->stream == NULL) return; + + // state->fade and both SAVE.settings volumes have no setter - written + // directly by callers - so this is the only place any of them ever gets + // validated, rather than at the point they're set. + assertTrue( + state->fade >= 0.0f && state->fade <= 1.0f, + "Channel fade is out of range." + ); + assertTrue( + SAVE.settings.audioChannelVolume[channel] >= 0.0f && + SAVE.settings.audioChannelVolume[channel] <= 1.0f, + "Channel volume is out of range." + ); + assertTrue( + SAVE.settings.audioMasterVolume >= 0.0f && + SAVE.settings.audioMasterVolume <= 1.0f, + "Master volume is out of range." + ); + + // Final computed volume. + float_t volume = ( + state->baseVolume * + state->fade * + SAVE.settings.audioChannelVolume[channel] * + SAVE.settings.audioMasterVolume + ); + audioStreamSetVolume(state->stream, volume); +} + +errorret_t audioMixerChannelUpdateEarly( + const audiomixerchannel_t channel, + audiomixerchannelstate_t *state +) { + assertNotNull(state, "State cannot be NULL."); + + audiomixercommand_t *command = &state->pending; + + if(command->command == AUDIO_MIXER_COMMAND_STOP) { + errorChain(audioMixerChannelStopStream(state)); + command->command = AUDIO_MIXER_COMMAND_NONE; + audioMixerChannelApplyVolume(channel, state); + errorOk(); + } + + if( + command->command != AUDIO_MIXER_COMMAND_PLAY || + command->loadingAsset == NULL + ) { + // A PLAY command with no loadingAsset yet (command->loadingAsset == + // NULL) was queued too late for the audioMixerChannelUpdateLate() 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 + // audioMixerChannelUpdateLate() has run yet. Left alone (still + // AUDIO_MIXER_COMMAND_PLAY) rather than treated as an error: the very + // next audioMixerChannelUpdateLate() will lock it, and the frame after + // that will reach here and apply it normally. + audioMixerChannelApplyVolume(channel, state); + errorOk(); + } + + // Blocks here if audioMixerChannelUpdateLate()'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. + errorret_t loadRet = assetRequireLoaded(command->loadingAsset); + if(errorIsNotOk(loadRet)) { + assetUnlockEntry(command->loadingAsset); + command->loadingAsset = NULL; + command->command = AUDIO_MIXER_COMMAND_NONE; + errorChain(loadRet); + } + + errorChain(audioMixerChannelStopStream(state)); + + audiostream_t *stream = audioAquireStream(command->loadingAsset); + assertNotNull(stream, "No free audio stream slots available."); + + errorret_t initRet = audioStreamInit(stream, command->loadingAsset); + // The stream holds its own lock on the asset once initialized (or, on + // failure, audioStreamInit() has already released it itself) - either + // way this call's own lock is no longer needed. + assetUnlockEntry(command->loadingAsset); + command->loadingAsset = NULL; + command->command = AUDIO_MIXER_COMMAND_NONE; + errorChain(initRet); + + // Forwards to this channel's own onLoop/onEnd (persistent across every + // play on this channel) rather than this one stream instance's own - + // see audiomixerchannelstate_t.onLoop's own comment. + stream->user = state; + stream->onLoop = audioMixerChannelOnStreamLoop; + stream->onEnd = audioMixerChannelOnStreamEnd; + + audioStreamSetDirectionality(stream, command->pan); + if(command->looping) { + audioStreamSetLooping(stream, true); + audioStreamSetLoopPoints(stream, command->loopStart, command->loopTo); + audioStreamSetLoopLimit(stream, command->loopCount); + } + audioStreamPlay(stream); + + state->stream = stream; + state->baseVolume = command->volume; + + audioMixerChannelApplyVolume(channel, state); + + errorOk(); +} + +errorret_t audioMixerChannelUpdateLate(audiomixerchannelstate_t *state) { + assertNotNull(state, "State cannot be NULL."); + + audiomixercommand_t *command = &state->pending; + + if(command->command != AUDIO_MIXER_COMMAND_PLAY) errorOk(); + + assetloadertype_t assetType = audioStreamAssetTypeForPath(command->file); + assertTrue( + assetType != ASSET_LOADER_TYPE_NULL, "Unsupported audio file extension." + ); + + // Begins loading now - kicked off at the end of this frame so it has + // until the start of the next (audioMixerChannelUpdateEarly()) to finish + // before that has to block on it. + command->loadingAsset = assetLock(command->file, assetType, NULL); + + errorOk(); +} + +void audioMixerChannelOnStreamLoop(audiostream_t *stream) { + assertNotNull(stream, "Stream cannot be NULL."); + + audiomixerchannelstate_t *state = (audiomixerchannelstate_t *) stream->user; + if(state->onLoop != NULL) state->onLoop(state); +} + +void audioMixerChannelOnStreamEnd(audiostream_t *stream) { + assertNotNull(stream, "Stream cannot be NULL."); + + audiomixerchannelstate_t *state = (audiomixerchannelstate_t *) stream->user; + if(state->onEnd != NULL) state->onEnd(state); +} diff --git a/src/dusk/audio/mixer/audiomixerchannel.h b/src/dusk/audio/mixer/audiomixerchannel.h new file mode 100644 index 00000000..a10eed87 --- /dev/null +++ b/src/dusk/audio/mixer/audiomixerchannel.h @@ -0,0 +1,188 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "asset/asset.h" + +typedef struct audiostream_s audiostream_t; + +#define AUDIO_PATH_MAX 256 + +typedef enum { + AUDIO_MIXER_CHANNEL_BGM_0, + AUDIO_MIXER_CHANNEL_VOICE_0, + AUDIO_MIXER_CHANNEL_VOICE_1, + AUDIO_MIXER_CHANNEL_SFX_0, + AUDIO_MIXER_CHANNEL_SFX_1, + AUDIO_MIXER_CHANNEL_SFX_2, + AUDIO_MIXER_CHANNEL_SFX_3, + AUDIO_MIXER_CHANNEL_COUNT +} audiomixerchannel_t; + +typedef enum { + AUDIO_MIXER_COMMAND_NONE, + AUDIO_MIXER_COMMAND_PLAY, + AUDIO_MIXER_COMMAND_STOP +} audiomixercommandtype_t; + +typedef struct { + // What audioMixerChannelUpdateEarly() should do to this channel at the + // start of the next frame - AUDIO_MIXER_COMMAND_NONE if nothing's + // queued. Set by audioMixerPlay()/audioMixerPlayLooped()/ + // audioMixerStop(); queuing a new command on a channel replaces, rather + // than stacks on top of, whatever was already queued for it this frame - + // only the most recent survives. + audiomixercommandtype_t command; + + // AUDIO_MIXER_COMMAND_PLAY only, below. + char_t file[AUDIO_PATH_MAX]; + float_t volume; + float_t pan; + bool_t looping; + uint8_t loopCount; + float_t loopStart; + float_t loopTo; + + // Set by audioMixerChannelUpdateLate() (the end of the frame this PLAY + // command was queued on), once it locks the requested file and kicks off + // its load. audioMixerChannelUpdateEarly() (the start of the next frame) + // blocks on this being fully loaded before actually starting playback - + // see its own comment. NULL except in that in-between window. + assetentry_t *loadingAsset; +} audiomixercommand_t; + +typedef struct audiomixerchannelstate_s audiomixerchannelstate_t; + +typedef struct audiomixerchannelstate_s { + // Queued action for the next audioMixerChannelUpdateEarly() to apply. + audiomixercommand_t pending; + + // This channel's currently playing stream, or NULL if nothing is - + // acquired from AUDIO.streams by audioMixerChannelUpdateEarly() when it + // applies an AUDIO_MIXER_COMMAND_PLAY, disposed the same way on + // 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 fade, its own volume + // (SAVE.settings.audioChannelVolume) and the mixer's master volume + // (SAVE.settings.audioMasterVolume), every frame, to get the volume + // actually passed to audioStreamSetVolume(). Meaningless while `stream` + // is NULL. + float_t baseVolume; + + // This channel's own fade multiplier, from 0.0 (fully faded out) to 1.0 + // (no fade applied, the default) - transient runtime state, unlike the + // channel/master volumes below (which are persisted user preferences). + // No setter - write it directly; audioMixerChannelApplyVolume() asserts + // it's in range the next time it reads it, rather than validating at the + // point it's set. + float_t fade; + + // Arbitrary caller data, handed back as-is to onLoop/onEnd below - never + // read or written by the mixer itself. NULL by default. + void *user; + + // Fired (from the main thread - safe to do arbitrary work, same + // guarantee as audiostream_t's own onLoop/onEnd) whenever this channel's + // currently playing stream loops, or reaches its natural end (including + // hitting its loop limit - see audioStreamSetLoopLimit()) and stops. + // Wired onto every stream this channel starts via + // audioMixerChannelOnStreamLoop()/OnStreamEnd() - not called directly by + // anything else. No setter - write these fields directly; NULL (the + // default) means not notified. Unlike `stream`/`pending`, these persist + // across every play on this channel, not just one. + void (*onLoop)(audiomixerchannelstate_t *state); + void (*onEnd)(audiomixerchannelstate_t *state); +} audiomixerchannelstate_t; + +/** + * Stops and disposes the given channel's currently playing stream, if any - + * a safe no-op if the channel is already idle. Shared by + * audioMixerChannelUpdateEarly() (AUDIO_MIXER_COMMAND_STOP, and replacing a + * channel's stream before a new AUDIO_MIXER_COMMAND_PLAY starts) and + * audioMixerDispose(). + * + * @param state The channel state to stop. + */ +errorret_t audioMixerChannelStopStream(audiomixerchannelstate_t *state); + +/** + * Computes the given channel's actual playback volume - its currently + * playing sound's own volume (baseVolume), this channel's fade, this + * channel's own volume (SAVE.settings.audioChannelVolume[channel]) and the + * mixer's master volume (SAVE.settings.audioMasterVolume) - and applies it + * via audioStreamSetVolume(). Asserts fade and both save-settings volumes + * are each within [0.0, 1.0] - the only validation any of them ever gets, + * since none of them have a setter. A safe no-op if the channel isn't + * playing anything. Called every audioMixerChannelUpdateEarly(), not just + * when a PLAY command starts a new stream - see its own comment. + * + * @param channel Which channel `state` is, to index + * SAVE.settings.audioChannelVolume with. + * @param state The channel state to update. + */ +void audioMixerChannelApplyVolume( + const audiomixerchannel_t channel, + audiomixerchannelstate_t *state +); + +/** + * Updates a single mixer channel, called before rendering (the start of + * the frame): applies whatever command (play, stop - including everything + * a play carries, pan/looping) audioMixerChannelUpdateLate() 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 this channel's mixed volume (see + * audioMixerChannelApplyVolume()) every time, regardless of whether a + * command was queued this frame, so a live change to this channel's fade, + * its own volume, or the master volume takes effect on whatever's already + * playing too. + * + * @param channel Which channel `state` is - see + * audioMixerChannelApplyVolume()'s own comment. + * @param state The channel state to update. + */ +errorret_t audioMixerChannelUpdateEarly( + const audiomixerchannel_t channel, + audiomixerchannelstate_t *state +); + +/** + * Updates a single mixer channel, called at the end of each frame, once + * every other system has had a chance to queue a command via + * audioMixerPlay()/audioMixerPlayLooped()/audioMixerStop(). For a queued + * AUDIO_MIXER_COMMAND_PLAY, locks its file and begins loading it (see + * assetLock()) - giving it from now until the next + * audioMixerChannelUpdateEarly() to finish before that has to block on it. + * Actually starting/stopping playback happens there, not here. + * + * @param state The channel state to update. + */ +errorret_t audioMixerChannelUpdateLate(audiomixerchannelstate_t *state); + +/** + * Trampoline wired onto every stream a channel starts (as that stream's + * onLoop) - forwards to the channel's own onLoop (see + * audiomixerchannelstate_t.onLoop's own comment) via stream->user, which + * audioMixerChannelUpdateEarly() points at the channel's own state. + * + * @param stream The stream that looped. + */ +void audioMixerChannelOnStreamLoop(audiostream_t *stream); + +/** + * Same as audioMixerChannelOnStreamLoop() but wired onto onEnd, forwarding + * to the channel's own onEnd. + * + * @param stream The stream that ended. + */ +void audioMixerChannelOnStreamEnd(audiostream_t *stream); diff --git a/src/dusk/engine/engine.c b/src/dusk/engine/engine.c index ebb99e35..d3ad3f5a 100644 --- a/src/dusk/engine/engine.c +++ b/src/dusk/engine/engine.c @@ -55,9 +55,11 @@ errorret_t engineInit(const int32_t argc, const char_t **argv) { // 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. - audioMixerPlayLooped( - "audio/boa.mp3", AUDIO_MIXER_CHANNEL_BGM_0, 1.0f, AUDIO_STREAM_CENTER, - 0, -1.0f, 0.0f + audioMixerPlay( + "audio/boa.mp3", + AUDIO_MIXER_CHANNEL_BGM_0, + 1.0f, + AUDIO_STREAM_CENTER ); consolePrint("Engine initialized"); diff --git a/src/dusk/save/savejson.h b/src/dusk/save/savejson.h index 49e23c91..863145cd 100644 --- a/src/dusk/save/savejson.h +++ b/src/dusk/save/savejson.h @@ -256,6 +256,74 @@ (dest) = hasFloat(key) ? \ (float_t)yyjson_get_real(yyjson_obj_get(object, key)) : (float_t)(def) +/** + * Checks if the given key exists on the current JSON object. + * + * @param key The key to check for. + */ +#define hasFloatArray(key) (yyjson_obj_get(object, key) != NULL) + +/** + * Errors if the given key does not exist on the current JSON object. + * + * @param key The key that must exist. + */ +#define requireFloatArray(key) \ + if(!hasFloatArray(key)) errorThrow("Save JSON missing '%s' key", key) + +/** + * Writes a float_t array to the current JSON object. + * + * @param key The key to write to. + * @param value The array to write. + * @param count Number of elements in the array. + */ +#define writeFloatArray(key, value, count) { \ + yyjson_mut_val *saveJsonArr = yyjson_mut_arr(doc); \ + size_t saveJsonArrIdx = 0; \ + while(saveJsonArrIdx < (size_t)(count)) { \ + yyjson_mut_arr_add_real(doc, saveJsonArr, (double)(value)[saveJsonArrIdx]); \ + saveJsonArrIdx++; \ + } \ + yyjson_mut_obj_add_val(doc, object, key, saveJsonArr); \ +} + +/** + * Reads a float_t array from the current JSON object into dest, falling + * back to filling every element with def if the key is missing. Errors if + * the JSON array's length doesn't exactly match count. + * + * @param key The key to read from. + * @param dest The destination array to assign to. + * @param count Number of elements dest holds. + * @param def The default value to fill every element with if the key is + * missing. + */ +#define readFloatArray(key, dest, count, def) { \ + yyjson_val *saveJsonArr = yyjson_obj_get(object, key); \ + if(saveJsonArr != NULL) { \ + if(yyjson_arr_size(saveJsonArr) != (size_t)(count)) { \ + errorThrow( \ + "Save JSON array '%s' has wrong length, expected %d", \ + key, (int)(count) \ + ); \ + } \ + size_t saveJsonArrIdx, saveJsonArrLen; \ + yyjson_val *saveJsonArrElem; \ + yyjson_arr_foreach( \ + saveJsonArr, saveJsonArrIdx, saveJsonArrLen, saveJsonArrElem \ + ) { \ + (dest)[saveJsonArrIdx] = (float_t)yyjson_get_real(saveJsonArrElem); \ + } \ + } else { \ + size_t saveJsonArrIdx = 0; \ + while(saveJsonArrIdx < (size_t)(count)) { \ + (dest)[saveJsonArrIdx] = (float_t)(def); \ + saveJsonArrIdx++; \ + } \ + } \ +} + /** * Checks if the given key exists on the current JSON object. * diff --git a/src/dusk/save/savesettings.c b/src/dusk/save/savesettings.c index b2488760..91d5afac 100644 --- a/src/dusk/save/savesettings.c +++ b/src/dusk/save/savesettings.c @@ -14,6 +14,13 @@ void saveSettingsInit(savesettings_t *settings) { assertNotNull(settings, "Settings cannot be null"); memorySet(settings, 0, sizeof(savesettings_t)); + + settings->audioMasterVolume = 1.0f; + audiomixerchannel_t channel = 0; + while(channel < AUDIO_MIXER_CHANNEL_COUNT) { + settings->audioChannelVolume[channel] = 1.0f; + channel++; + } } errorret_t saveSettingsWriteJSON( @@ -26,6 +33,11 @@ errorret_t saveSettingsWriteJSON( assertNotNull(object, "Object cannot be null"); writeInt32("someSetting", settings->someSetting); + writeFloat("audioMasterVolume", settings->audioMasterVolume); + writeFloatArray( + "audioChannelVolume", settings->audioChannelVolume, + AUDIO_MIXER_CHANNEL_COUNT + ); errorOk(); } @@ -35,6 +47,11 @@ errorret_t saveSettingsReadJSON(savesettings_t *settings, yyjson_val *object) { assertNotNull(object, "Object cannot be null"); readInt32("someSetting", settings->someSetting, 0); + readFloat("audioMasterVolume", settings->audioMasterVolume, 1.0f); + readFloatArray( + "audioChannelVolume", settings->audioChannelVolume, + AUDIO_MIXER_CHANNEL_COUNT, 1.0f + ); errorOk(); } \ No newline at end of file diff --git a/src/dusk/save/savesettings.h b/src/dusk/save/savesettings.h index 0d83fe30..9ff0be3d 100644 --- a/src/dusk/save/savesettings.h +++ b/src/dusk/save/savesettings.h @@ -9,9 +9,15 @@ #include "time/timeepoch.h" #include "savedevice.h" #include "yyjson.h" +#include "audio/mixer/audiomixerchannel.h" typedef struct savesettings_s { int32_t someSetting; + + // Persisted mixer volume preferences - see audiomixerchannelstate_t's own + // comment on why these live here rather than on the mixer itself. + float_t audioMasterVolume; + float_t audioChannelVolume[AUDIO_MIXER_CHANNEL_COUNT]; } savesettings_t; /**