diff --git a/soh/soh/Enhancements/audio/AudioEditor.cpp b/soh/soh/Enhancements/audio/AudioEditor.cpp index 5e94e469fe0..5d829b28272 100644 --- a/soh/soh/Enhancements/audio/AudioEditor.cpp +++ b/soh/soh/Enhancements/audio/AudioEditor.cpp @@ -37,6 +37,7 @@ static WidgetInfo ovlDuration; static WidgetInfo voicePitch; static WidgetInfo randomAudioGenModes; static WidgetInfo lowerOctaves; +static WidgetInfo randomInstruments; namespace SohGui { extern std::shared_ptr mSohMenu; @@ -613,6 +614,8 @@ void AudioEditor::DrawElement() { static_cast(ImGui::GetContentRegionAvail().x), THEME_COLOR); SohGui::mSohMenu->MenuDrawItem(lowerOctaves, static_cast(ImGui::GetContentRegionAvail().x), THEME_COLOR); + SohGui::mSohMenu->MenuDrawItem(randomInstruments, + static_cast(ImGui::GetContentRegionAvail().x), THEME_COLOR); } ImGui::EndChild(); ImGui::EndTable(); @@ -965,6 +968,11 @@ void RegisterAudioWidgets() { "couple of octaves so they can still harmonize with the other notes of the " "sequence.")); SohGui::mSohMenu->AddSearchWidget({ lowerOctaves, "Enhancements", "Audio Editor", "Audio Options" }); + + randomInstruments = { .name = "Randomize Music Instruments", .type = WidgetType::WIDGET_CVAR_CHECKBOX }; + randomInstruments.CVar(CVAR_AUDIO("RandomMusicInstruments")) + .Options(CheckboxOptions().Color(THEME_COLOR).Tooltip("Shuffles music instruments within the same music.")); + SohGui::mSohMenu->AddSearchWidget({ randomInstruments, "Enhancements", "Audio Editor", "Audio Options" }); } static RegisterMenuInitFunc menuInitFunc(RegisterAudioWidgets); diff --git a/soh/soh/Enhancements/audio/MusicInstrumentRandomizer.cpp b/soh/soh/Enhancements/audio/MusicInstrumentRandomizer.cpp new file mode 100644 index 00000000000..1bbd68433c2 --- /dev/null +++ b/soh/soh/Enhancements/audio/MusicInstrumentRandomizer.cpp @@ -0,0 +1,298 @@ +#include +#include +#include + +#include "soh/Enhancements/game-interactor/GameInteractor_Hooks.h" +#include "soh/ShipInit.hpp" + +extern "C" { +#include "global.h" +#include "functions.h" +#include "variables.h" + +u32 Audio_NextRandom(void); +} + +static constexpr int32_t CVAR_RANDOM_MUSIC_INSTRUMENTS_DEFAULT = 0; +#define CVAR_RANDOM_MUSIC_INSTRUMENTS_NAME CVAR_ENHANCEMENT("RandomMusicInstruments") +#define CVAR_RANDOM_MUSIC_INSTRUMENTS_VALUE \ + CVarGetInteger(CVAR_RANDOM_MUSIC_INSTRUMENTS_NAME, CVAR_RANDOM_MUSIC_INSTRUMENTS_DEFAULT) + +namespace RandomMusicInstruments { + +// Sequence instrument IDs below 0x7E are passed to AudioSeq_GetInstrument +// 0x7E and above are special modes such as SFX, drums, synthetic waves, etc +static constexpr uint8_t MIN_NORMAL_INSTRUMENT = 0x00; +static constexpr uint8_t MAX_NORMAL_INSTRUMENT = 0x7D; +static constexpr uint8_t NUM_NORMAL_INSTRUMENTS = 0x7E; +// 0xFF is outside the remappable instrument range +static constexpr uint8_t INVALID_INSTRUMENT = 0xFF; +// SequenceChannel::fontId is a u8 - max font index is 0xFF +static constexpr uint16_t NUM_FONTS = 0x100; + +// Per-sequence remap table. Populated lazily. INVALID_INSTRUMENT means not yet assigned +// I'd love to reduce the size of this from 64k, but would likely require runtime allocation +static uint8_t sInstrumentRemap[NUM_FONTS][NUM_NORMAL_INSTRUMENTS]; + +// Prevents the same replacement from being assigned twice within a sequence (shuffle) +// Moved to a bitset to reduce from 32k to 4k static memory +static constexpr size_t BITS_PER_REPLACEMENT_USED_WORD = sizeof(uint64_t) * 8; +static constexpr size_t NUM_REPLACEMENT_USED_WORDS = + (NUM_NORMAL_INSTRUMENTS + BITS_PER_REPLACEMENT_USED_WORD - 1) / BITS_PER_REPLACEMENT_USED_WORD; +static uint64_t sReplacementUsed[NUM_FONTS][NUM_REPLACEMENT_USED_WORDS]; + +// Bitset helpers +static bool IsReplacementUsed(uint8_t fontId, uint8_t instId) { + const size_t word = instId / BITS_PER_REPLACEMENT_USED_WORD; + const size_t bit = instId % BITS_PER_REPLACEMENT_USED_WORD; + + return (sReplacementUsed[fontId][word] & (1ULL << bit)) != 0; +} + +static void MarkReplacementUsed(uint8_t fontId, uint8_t instId) { + const size_t word = instId / BITS_PER_REPLACEMENT_USED_WORD; + const size_t bit = instId % BITS_PER_REPLACEMENT_USED_WORD; + + sReplacementUsed[fontId][word] |= 1ULL << bit; +} + +// When instrument A is swapped for B, the pitch range boundaries used to select between +// B's low/normal/high samples should still come from A, otherwise B's native split points +// may misroute notes into the wrong sample bucket. +struct RangeOverride { + Instrument* replacement = nullptr; + Instrument* original = nullptr; +}; + +// Preferring static allocation on the audio thread +static constexpr size_t MAX_RANGE_OVERRIDES = 256; +static RangeOverride sRangeOverrides[MAX_RANGE_OVERRIDES]; +static size_t sRangeOverrideCount = 0; + +// Whether a channel belongs to the active main BGM sequence player as opposed to fanfare, etc +static bool IsMainBgmChannel(SequenceChannel* channel) { + if (channel == nullptr || channel->seqPlayer == nullptr) { + return false; + } + + return channel->seqPlayer->playerIdx == SEQ_PLAYER_BGM_MAIN && channel->seqPlayer->enabled; +} + +// Is this instrument ID is a normal melodic instrument +static bool IsNormalInstrument(uint8_t instId) { + return instId >= MIN_NORMAL_INSTRUMENT && instId <= MAX_NORMAL_INSTRUMENT; +} + +// Does this instrument exists in the given sound font +static bool IsValidInstrument(uint8_t fontId, uint8_t instId) { + if (!IsNormalInstrument(instId)) { + return false; + } + + return Audio_GetInstrumentInner(fontId, instId) != nullptr; +} + +// Random instrument from Audio from 0..count +static uint32_t RandomIndex(uint32_t count) { + if (count <= 1) { + return 0; + } + + return Audio_NextRandom() % count; +} + +// Clears all per-sequence remap and range override state +static void ResetState() { + memset(sInstrumentRemap, INVALID_INSTRUMENT, sizeof(sInstrumentRemap)); + memset(sReplacementUsed, 0, sizeof(sReplacementUsed)); + + memset(sRangeOverrides, 0, sizeof(sRangeOverrides)); + sRangeOverrideCount = 0; +} + +// Records that a replacement instrument should use another instrument's range boundaries +static void AddRangeOverride(Instrument* original, Instrument* replacement) { + if (original == nullptr || replacement == nullptr || original == replacement) { + return; + } + + for (size_t i = 0; i < sRangeOverrideCount; i++) { + if (sRangeOverrides[i].replacement == replacement) { + sRangeOverrides[i].original = original; + return; + } + } + + if (sRangeOverrideCount >= MAX_RANGE_OVERRIDES) { + return; + } + + const RangeOverride override = { .replacement = replacement, .original = original }; + sRangeOverrides[sRangeOverrideCount++] = override; +} + +// Finds the original instrument whose range boundaries should be used for a replacement +static Instrument* FindOriginalRangeInstrument(Instrument* replacement) { + for (size_t i = 0; i < sRangeOverrideCount; i++) { + if (sRangeOverrides[i].replacement == replacement) { + return sRangeOverrides[i].original; + } + } + + return nullptr; +} + +// Stores a newly chosen remap and records its range override +static uint8_t CommitRemap(uint8_t fontId, uint8_t originalInstId, uint8_t replacementInstId) { + sInstrumentRemap[fontId][originalInstId] = replacementInstId; + MarkReplacementUsed(fontId, replacementInstId); + + Instrument* original = Audio_GetInstrumentInner(fontId, originalInstId); + Instrument* replacement = Audio_GetInstrumentInner(fontId, replacementInstId); + + AddRangeOverride(original, replacement); + + return replacementInstId; +} + +// Returns the existing remap for an instrument or assigns a new shuffled replacement +static uint8_t GetOrCreateRemappedInstrument(uint8_t fontId, uint8_t originalInstId) { + if (!IsNormalInstrument(originalInstId)) { + return originalInstId; + } + + uint8_t& remapped = sInstrumentRemap[fontId][originalInstId]; + + if (remapped != INVALID_INSTRUMENT) { + return remapped; + } + + uint8_t candidates[NUM_NORMAL_INSTRUMENTS]; + uint8_t candidateCount = 0; + + for (uint8_t instId = MIN_NORMAL_INSTRUMENT; instId <= MAX_NORMAL_INSTRUMENT; instId++) { + if (!IsValidInstrument(fontId, instId) || IsReplacementUsed(fontId, instId)) { + continue; + } + + candidates[candidateCount++] = instId; + } + + // If every valid replacement is already used allow repeats as a fallback + if (candidateCount == 0) { + for (uint8_t instId = MIN_NORMAL_INSTRUMENT; instId <= MAX_NORMAL_INSTRUMENT; instId++) { + if (!IsValidInstrument(fontId, instId)) { + continue; + } + + candidates[candidateCount++] = instId; + } + } + + if (candidateCount == 0) { + remapped = originalInstId; + return remapped; + } + + // Prefer not mapping an instrument to itself + for (int32_t attempt = 0; attempt < 8; attempt++) { + const uint8_t candidate = candidates[RandomIndex(candidateCount)]; + + if (candidate != originalInstId || candidateCount == 1) { + return CommitRemap(fontId, originalInstId, candidate); + } + } + + // Guaranteed non-self fallback if possible + for (uint8_t i = 0; i < candidateCount; i++) { + if (candidates[i] != originalInstId) { + return CommitRemap(fontId, originalInstId, candidates[i]); + } + } + + return CommitRemap(fontId, originalInstId, candidates[0]); +} + +// Uses the original instrument's pitch boundaries to decide which region of the replacement to play +static SoundFontSound* GetReplacementSoundUsingOriginalRange(Instrument* original, Instrument* replacement, + int32_t semitone) { + if (original == nullptr || replacement == nullptr) { + return nullptr; + } + + if (semitone < original->normalRangeLo) { + // Only use the replacement's low sample if it actually has a low range. + if (replacement->normalRangeLo != 0 && replacement->lowNotesSound.sample != nullptr) { + return &replacement->lowNotesSound; + } + + return &replacement->normalNotesSound; + } + + if (semitone > original->normalRangeHi) { + // Only use the replacement's high sample if it actually has a high range. + if (replacement->normalRangeHi != 0x7F && replacement->highNotesSound.sample != nullptr) { + return &replacement->highNotesSound; + } + + return &replacement->normalNotesSound; + } + + return &replacement->normalNotesSound; +} + +// Resets remap state whenever the main BGM sequence player starts a new sequence +static void OnSeqPlayerInit(int32_t playerIdx, int32_t seqId) { + if (playerIdx != SEQ_PLAYER_BGM_MAIN) { + return; + } + + ResetState(); +} + +// Replaces normal music instrument IDs with their shuffled per-sequence remap +static void OnSeqInstrumentSet(void* channelPtr, uint8_t* instId) { + if (!CVAR_RANDOM_MUSIC_INSTRUMENTS_VALUE || instId == nullptr || !IsNormalInstrument(*instId)) { + return; + } + + SequenceChannel* channel = static_cast(channelPtr); + if (!IsMainBgmChannel(channel)) { + return; + } + + const uint8_t fontId = channel->fontId; + const uint8_t originalInstId = *instId; + + *instId = GetOrCreateRemappedInstrument(fontId, originalInstId); +} + +// Overrides replacement instrument sample selection to preserve the original instrument's range behavior +static void OnSeqInstrumentGetSound(void* instrumentPtr, int32_t semitone, void** soundPtr) { + if (!CVAR_RANDOM_MUSIC_INSTRUMENTS_VALUE || instrumentPtr == nullptr || soundPtr == nullptr) { + return; + } + + Instrument* replacement = static_cast(instrumentPtr); + Instrument* original = FindOriginalRangeInstrument(replacement); + + if (original == nullptr) { + return; + } + + SoundFontSound* sound = GetReplacementSoundUsingOriginalRange(original, replacement, semitone); + + if (sound != nullptr) { + *soundPtr = sound; + } +} + +static void RegisterHooks() { + COND_HOOK(OnSeqPlayerInit, CVAR_RANDOM_MUSIC_INSTRUMENTS_VALUE, OnSeqPlayerInit); + COND_HOOK(OnSeqInstrumentSet, CVAR_RANDOM_MUSIC_INSTRUMENTS_VALUE, OnSeqInstrumentSet); + COND_HOOK(OnSeqInstrumentGetSound, CVAR_RANDOM_MUSIC_INSTRUMENTS_VALUE, OnSeqInstrumentGetSound); +} + +} // namespace RandomMusicInstruments + +static RegisterShipInitFunc initFunc(RandomMusicInstruments::RegisterHooks, { CVAR_RANDOM_MUSIC_INSTRUMENTS_NAME }); diff --git a/soh/soh/Enhancements/game-interactor/GameInteractor_HookTable.h b/soh/soh/Enhancements/game-interactor/GameInteractor_HookTable.h index 59faf32de8c..49657e08ed0 100644 --- a/soh/soh/Enhancements/game-interactor/GameInteractor_HookTable.h +++ b/soh/soh/Enhancements/game-interactor/GameInteractor_HookTable.h @@ -91,6 +91,8 @@ DEFINE_HOOK(OnOpenText, (uint16_t * textId, bool* loadFromMessageTable)); // Audio DEFINE_HOOK(OnSeqPlayerInit, (int32_t playerIdx, int32_t seqId)); +DEFINE_HOOK(OnSeqInstrumentSet, (void* channel, uint8_t* instId)); +DEFINE_HOOK(OnSeqInstrumentGetSound, (void* instrument, int32_t semitone, void** sound)); // Rando DEFINE_HOOK(OnRandoSetCheckStatus, (RandomizerCheck rc, RandomizerCheckStatus status)); diff --git a/soh/soh/Enhancements/game-interactor/GameInteractor_Hooks.cpp b/soh/soh/Enhancements/game-interactor/GameInteractor_Hooks.cpp index b754e9c949c..a57dd410ac0 100644 --- a/soh/soh/Enhancements/game-interactor/GameInteractor_Hooks.cpp +++ b/soh/soh/Enhancements/game-interactor/GameInteractor_Hooks.cpp @@ -395,6 +395,14 @@ void GameInteractor_ExecuteOnSeqPlayerInit(int32_t playerIdx, int32_t seqId) { GameInteractor::Instance->ExecuteHooks(playerIdx, seqId); } +void GameInteractor_ExecuteOnSeqInstrumentSet(void* channel, uint8_t* instId) { + GameInteractor::Instance->ExecuteHooks(channel, instId); +} + +void GameInteractor_ExecuteOnSeqInstrumentGetSound(void* instrument, int32_t semitone, void** sound) { + GameInteractor::Instance->ExecuteHooks(instrument, semitone, sound); +} + // MARK: - Rando void GameInteractor_ExecuteOnRandoEntranceDiscovered(u16 entranceIndex, u8 isReversedEntrance) { GameInteractor::Instance->ExecuteHooks(entranceIndex, diff --git a/soh/soh/Enhancements/game-interactor/GameInteractor_Hooks.h b/soh/soh/Enhancements/game-interactor/GameInteractor_Hooks.h index 85a9bb57f29..aff11ee0afd 100644 --- a/soh/soh/Enhancements/game-interactor/GameInteractor_Hooks.h +++ b/soh/soh/Enhancements/game-interactor/GameInteractor_Hooks.h @@ -101,6 +101,8 @@ void GameInteractor_ExecuteOnOpenText(uint16_t* textId, bool* loadFromMessageTab // Mark: - Audio void GameInteractor_ExecuteOnSeqPlayerInit(int32_t playerIdx, int32_t seqId); +void GameInteractor_ExecuteOnSeqInstrumentSet(void* channel, uint8_t* instId); +void GameInteractor_ExecuteOnSeqInstrumentGetSound(void* instrument, int32_t semitone, void** sound); // MARK: - Rando void GameInteractor_ExecuteOnRandoEntranceDiscovered(u16 entranceIndex, u8 isReversedEntrance); diff --git a/soh/src/code/audio_playback.c b/soh/src/code/audio_playback.c index ce6c5d6f19b..ee587c02f2c 100644 --- a/soh/src/code/audio_playback.c +++ b/soh/src/code/audio_playback.c @@ -1,5 +1,7 @@ #include "global.h" #include "soh/ResourceManagerHelpers.h" +#include "soh/Enhancements/game-interactor/GameInteractor_Hooks.h" +#include extern bool gUseLegacySD; @@ -312,15 +314,21 @@ void Audio_ProcessNotes(void) { } SoundFontSound* Audio_InstrumentGetSound(Instrument* instrument, s32 semitone) { - SoundFontSound* sound; + SoundFontSound* overrideSound = NULL; + + GameInteractor_ExecuteOnSeqInstrumentGetSound(instrument, semitone, &overrideSound); + + if (overrideSound != NULL) { + return overrideSound; + } + if (semitone < instrument->normalRangeLo) { - sound = &instrument->lowNotesSound; + return &instrument->lowNotesSound; } else if (semitone <= instrument->normalRangeHi) { - sound = &instrument->normalNotesSound; + return &instrument->normalNotesSound; } else { - sound = &instrument->highNotesSound; + return &instrument->highNotesSound; } - return sound; } Instrument* Audio_GetInstrumentInner(s32 fontId, s32 instId) { diff --git a/soh/src/code/audio_seqplayer.c b/soh/src/code/audio_seqplayer.c index b99d29e1ed0..27ce18c592a 100644 --- a/soh/src/code/audio_seqplayer.c +++ b/soh/src/code/audio_seqplayer.c @@ -4,6 +4,7 @@ #include "global.h" #include "soh/Enhancements/audio/AudioEditor.h" +#include "soh/Enhancements/game-interactor/GameInteractor_Hooks.h" #include "soh/ResourceManagerHelpers.h" extern char** sequenceMap; @@ -539,6 +540,9 @@ s32 AudioSeq_SeqLayerProcessScriptStep2(SequenceLayer* layer) { case 0xC6: // layer_setinstr cmd = AudioSeq_ScriptReadU8(state); + + GameInteractor_ExecuteOnSeqInstrumentSet(channel, &cmd); + if (cmd >= 0x7E) { if (cmd == 0x7E) { layer->instOrWave = 1; @@ -957,6 +961,8 @@ u8 AudioSeq_GetInstrument(SequenceChannel* channel, u8 instId, Instrument** inst } void AudioSeq_SetInstrument(SequenceChannel* channel, u8 instId) { + GameInteractor_ExecuteOnSeqInstrumentSet(channel, &instId); + if (instId >= 0x80) { channel->instOrWave = instId; channel->instrument = NULL;