diff --git a/CMakeLists.txt b/CMakeLists.txt index 1cdda09..c0b70d2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -165,6 +165,8 @@ set (SOURCES ${CMAKE_SOURCE_DIR}/src/game/recomp_data_api.cpp ${CMAKE_SOURCE_DIR}/src/game/rom_decompression.cpp + ${CMAKE_SOURCE_DIR}/src/audio/sound_matrix_decoder.cpp + ${CMAKE_SOURCE_DIR}/src/ui/ui_renderer.cpp ${CMAKE_SOURCE_DIR}/src/ui/ui_state.cpp ${CMAKE_SOURCE_DIR}/src/ui/ui_launcher.cpp diff --git a/assets/config_menu.rml b/assets/config_menu.rml index 3865510..f8491b6 100644 --- a/assets/config_menu.rml +++ b/assets/config_menu.rml @@ -57,7 +57,7 @@ - \ No newline at end of file diff --git a/include/audio_channels.h b/include/audio_channels.h new file mode 100644 index 0000000..94034de --- /dev/null +++ b/include/audio_channels.h @@ -0,0 +1,18 @@ +#pragma once + +typedef enum AudioChannelsSetting { + audioStereo, + audioMatrix51, + audioMax +} AudioChannelsSetting; + +inline const char* AudioChannelsSettingName(AudioChannelsSetting setting) { + switch (setting) { + case audioStereo: + return "Stereo"; + case audioMatrix51: + return "5.1 Matrix"; + default: + return "Unknown"; + } +} diff --git a/include/sound_matrix_decoder.h b/include/sound_matrix_decoder.h new file mode 100644 index 0000000..b800015 --- /dev/null +++ b/include/sound_matrix_decoder.h @@ -0,0 +1,116 @@ +#pragma once + +#include +#include +#include +#include +#include + +#ifndef M_PI +#define M_PI 3.14159265358979323846 +#endif + +/** + * Passive matrix decoder for stereo to 5.1 surround upmixing. + * Implements standard audio matrix decoding techniques using: + * - Linkwitz-Riley crossover filters for frequency band separation + * - All-pass filters for phase manipulation + * - Delay lines for surround channel timing + */ +class SoundMatrixDecoder { + public: + /** + * Construct and initialize the decoder with a specific sample rate. + * @param sampleRate The audio sample rate in Hz + */ + SoundMatrixDecoder(int32_t sampleRate); + ~SoundMatrixDecoder() = default; + + /** + * Reset filter states without recomputing coefficients. + * Useful when audio is interrupted to prevent clicks. + */ + void ResetState(); + + /** + * Decode stereo to 5.1 surround + * @param stereoInput Interleaved stereo samples [L0, R0, L1, R1, ...] + * @param samplePairs Number of stereo sample pairs to process + * @return Pointer to internal buffer with interleaved 5.1 samples [FL, FR, C, LFE, SL, SR, ...] + */ + std::tuple Process(const float* stereoInput, size_t samplePairs); + + private: + // 4th-order IIR filter (Linkwitz-Riley) for 24dB/octave slopes + struct BiquadCascade { + double X[4] = {}; // Input history + double Y[4] = {}; // Output history + }; + + struct FilterCoefficients { + double A[5]; // Feedforward (numerator) + double B[4]; // Feedback (denominator, excluding b0=1) + }; + + // Sweeping all-pass for phase decorrelation + struct AllPassChain { + double Freq = 0; + double FreqMin = 0; + double FreqMax = 0; + double SweepRate = 0; + double XHist[4] = {}; + double YHist[4] = {}; + bool Ready = false; + }; + + // Circular delay buffer + static constexpr int gMaxDelay = 1024; + struct CircularDelay { + std::array Data = {}; + int Head = 0; + int Length = 0; + }; + + // Filter design + FilterCoefficients DesignLowPass(double frequency, int32_t sampleRate); + FilterCoefficients DesignHighPass(double frequency, int32_t sampleRate); + + // Signal processing + float ProcessFilter(float sample, BiquadCascade& state, const FilterCoefficients& coef); + void PrepareAllPass(AllPassChain& chain, int32_t sampleRate); + float ProcessAllPass(float sample, AllPassChain& chain, bool negate); + float ProcessDelay(float sample, CircularDelay& buffer); + + static float Saturate(float value); + + int32_t mDelayLength = 0; + double mAllPassBaseRate = 1.0; // Precomputed for ProcessAllPass + + // Filter coefficients (computed once per sample rate) + FilterCoefficients mCoefCenterHP; + FilterCoefficients mCoefCenterLP; + FilterCoefficients mCoefSurroundHP; + FilterCoefficients mCoefSubLP; + + // Per-channel filter states + BiquadCascade mCenterHighPass; + BiquadCascade mCenterLowPass; + BiquadCascade mSurrLeftMainHP; + BiquadCascade mSurrLeftCrossHP; + BiquadCascade mSurrRightMainHP; + BiquadCascade mSurrRightCrossHP; + BiquadCascade mSubLowPass; + + // Phase processing + AllPassChain mPhaseLeftMain; + AllPassChain mPhaseLeftCross; + AllPassChain mPhaseRightMain; + AllPassChain mPhaseRightCross; + + // Timing + CircularDelay mDelaySurrLeft; + CircularDelay mDelaySurrRight; + + // Output buffer + std::vector mSurroundBuffer; +}; diff --git a/include/zelda_config.h b/include/zelda_config.h index 59b41f0..1e64f8e 100644 --- a/include/zelda_config.h +++ b/include/zelda_config.h @@ -87,6 +87,29 @@ namespace zelda64 { AnalogCamMode get_analog_cam_mode(); void set_analog_cam_mode(AnalogCamMode mode); + // Audio mode setting - mirrors the game's AudioOption enum + enum class AudioMode { + Stereo, + Mono, + Headphones, + Surround, + OptionCount + }; + + NLOHMANN_JSON_SERIALIZE_ENUM(zelda64::AudioMode, { + {zelda64::AudioMode::Stereo, "Stereo"}, + {zelda64::AudioMode::Mono, "Mono"}, + {zelda64::AudioMode::Headphones, "Headphones"}, + {zelda64::AudioMode::Surround, "Surround"} + }); + + // Enhanced surround - adds pan-based rear channel separation + bool get_enhanced_surround_enabled(); + void set_enhanced_surround_enabled(bool enabled); + + AudioMode get_audio_mode(); + void set_audio_mode(AudioMode mode); + void open_quit_game_prompt(); }; diff --git a/include/zelda_sound.h b/include/zelda_sound.h index 33bec01..c296035 100644 --- a/include/zelda_sound.h +++ b/include/zelda_sound.h @@ -1,6 +1,8 @@ #ifndef __ZELDA_SOUND_H__ #define __ZELDA_SOUND_H__ +#include "zelda_config.h" + namespace zelda64 { void reset_sound_settings(); void set_main_volume(int volume); @@ -9,6 +11,8 @@ namespace zelda64 { int get_bgm_volume(); void set_low_health_beeps_enabled(bool enabled); bool get_low_health_beeps_enabled(); + AudioMode get_audio_mode(); + void set_audio_mode(AudioMode mode); } #endif diff --git a/patches/skip_sos.c b/patches/skip_sos.c index 3b1c0fa..0352d5f 100644 --- a/patches/skip_sos.c +++ b/patches/skip_sos.c @@ -24,6 +24,6 @@ RECOMP_PATCH void EnTest7_Update(Actor* thisx, PlayState* play) { // @recomp Allow skipping the Song of Soaring cutscene. Input* input = CONTROLLER1(&play->state); if (CHECK_BTN_ANY(input->press.button, BTN_A | BTN_B) && (OWL_WARP_CS_GET_OCARINA_MODE(&this->actor) != ENTEST7_ARRIVE)) { - func_80AF2350(thisx, play); + func_80AF2350(this, play); } } \ No newline at end of file diff --git a/patches/sound.h b/patches/sound.h index 0e05637..50d1dc2 100644 --- a/patches/sound.h +++ b/patches/sound.h @@ -6,4 +6,10 @@ DECLARE_FUNC(float, recomp_get_bgm_volume); DECLARE_FUNC(u32, recomp_get_low_health_beeps_enabled); +// Surround sound support +// Audio channel settings: 0 = Stereo, 1 = 5.1 Matrix, 2 = 5.1 Raw +DECLARE_FUNC(void, recomp_set_audio_channels, s32 channels); +DECLARE_FUNC(s32, recomp_get_audio_channels); +DECLARE_FUNC(s32, recomp_get_enhanced_surround_enabled); + #endif diff --git a/patches/sound_patches.c b/patches/sound_patches.c index 504f9ec..9cc9bee 100644 --- a/patches/sound_patches.c +++ b/patches/sound_patches.c @@ -5,6 +5,13 @@ void AudioSeq_ProcessSeqCmd(u32 cmd); void AudioThread_QueueCmd(u32 opArgs, void** data); +// Audio channel settings (must match AudioChannelsSetting enum in native code) +typedef enum { + AUDIO_CHANNELS_STEREO = 0, + AUDIO_CHANNELS_MATRIX_51 = 1, + AUDIO_CHANNELS_RAW_51 = 2 +} AudioChannelsSetting; + // Direct audio command (skips the queueing system) #define SEQCMD_SET_SEQPLAYER_VOLUME_NOW(seqPlayerIndex, duration, volume) \ AudioSeq_ProcessSeqCmd((SEQCMD_OP_SET_SEQPLAYER_VOLUME << 28) | ((u8)(seqPlayerIndex) << 24) | \ @@ -360,4 +367,137 @@ RECOMP_PATCH void LifeMeter_UpdateSizeAndBeep(PlayState* play) { } } } +extern s8 sSoundMode; +// @recomp Patched to sync audio channels with the game's audio setting +RECOMP_PATCH void Audio_SetFileSelectSettings(s8 audioSetting) { + s8 soundMode; + + switch (audioSetting) { + case SAVE_AUDIO_STEREO: + soundMode = SOUNDMODE_STEREO; + sSoundMode = SOUNDMODE_STEREO; + // @recomp Sync audio output to stereo + recomp_set_audio_channels(AUDIO_CHANNELS_STEREO); + break; + + case SAVE_AUDIO_MONO: + soundMode = SOUNDMODE_MONO; + sSoundMode = SOUNDMODE_MONO; + // @recomp Sync audio output to stereo (mono is handled by the game's audio engine) + recomp_set_audio_channels(AUDIO_CHANNELS_STEREO); + break; + + case SAVE_AUDIO_HEADSET: + soundMode = SOUNDMODE_HEADSET; + sSoundMode = SOUNDMODE_HEADSET; + // @recomp Sync audio output to stereo (headset mode is handled by the game's audio engine) + recomp_set_audio_channels(AUDIO_CHANNELS_STEREO); + break; + + case SAVE_AUDIO_SURROUND: + soundMode = SOUNDMODE_SURROUND; + sSoundMode = SOUNDMODE_SURROUND_EXTERNAL; + // @recomp Enable 5.1 matrix surround output when game's surround option is selected + recomp_set_audio_channels(AUDIO_CHANNELS_MATRIX_51); + break; + + default: + break; + } + + SEQCMD_SET_SOUND_MODE(soundMode); +} + +// ============================================================================ +// Enhanced Surround Sound - Pan-based RL/RR steering +// ============================================================================ + +// DMEM address constants for audio synthesis (from synthesis.c) +#define DMEM_SURROUND_TEMP 0x4B0 +#define DMEM_HAAS_TEMP 0x5B0 +#define DMEM_LEFT_CH 0x930 +#define DMEM_RIGHT_CH 0xAD0 +#define DMEM_WET_LEFT_CH 0xC70 +#define DMEM_WET_RIGHT_CH 0xE10 + +// Forward declarations +void AudioSynth_DMemMove(Acmd* cmd, s32 dmemIn, s32 dmemOut, size_t size); +extern f32 gDefaultPanVolume[]; + +// @recomp Patched to add enhanced surround with pan-based RL/RR channel steering +// When enhanced surround is enabled, sounds panned left go more to Rear Left, +// and sounds panned right go more to Rear Right, creating better spatial separation. +RECOMP_PATCH Acmd* AudioSynth_ApplySurroundEffect(Acmd* cmd, NoteSampleState* sampleState, NoteSynthesisState* synthState, + s32 numSamplesPerUpdate, s32 haasDmem, s32 flags) { + s32 wetGain; + u16 dryGain; + s64 dmem = DMEM_SURROUND_TEMP; + f32 decayGain; + + AudioSynth_DMemMove(cmd++, haasDmem, DMEM_HAAS_TEMP, numSamplesPerUpdate * SAMPLE_SIZE); + dryGain = synthState->surroundEffectGain; + + if (flags == A_INIT) { + aClearBuffer(cmd++, dmem, sizeof(synthState->synthesisBuffers->surroundEffectState)); + synthState->surroundEffectGain = 0; + } else { + aLoadBuffer(cmd++, synthState->synthesisBuffers->surroundEffectState, dmem, + sizeof(synthState->synthesisBuffers->surroundEffectState)); + + // @recomp Check if enhanced surround is enabled for pan-based RL/RR steering + if (recomp_get_enhanced_surround_enabled()) { + // === Matrix surround encoding: steer surround to RL or RR based on pan === + // Calculate pan position: 0.0 = full left, 0.5 = center, 1.0 = full right + f32 sumVol = sampleState->targetVolLeft + sampleState->targetVolRight; + f32 panPosition = 0.5f; // default: center (mono surround) + if (sumVol > 0.0f) { + panPosition = (f32)sampleState->targetVolRight / sumVol; + } + + // The L/R balance determines RL vs RR steering: + // - L dominant (leftGain > rightGain): surround goes more to Rear Left + // - R dominant (rightGain > leftGain): surround goes more to Rear Right + // - Equal: mono surround to both + s16 leftGain = (s16)(dryGain * (1.0f - panPosition)); + s16 rightGain = (s16)(dryGain * panPosition); + + aMix(cmd++, (numSamplesPerUpdate * (s32)SAMPLE_SIZE) >> 4, leftGain, dmem, DMEM_LEFT_CH); + aMix(cmd++, (numSamplesPerUpdate * (s32)SAMPLE_SIZE) >> 4, (rightGain ^ 0xFFFF), dmem, DMEM_RIGHT_CH); + + wetGain = (dryGain * synthState->curReverbVol) >> 7; + s16 wetLeftGain = (s16)(wetGain * (1.0f - panPosition)); + s16 wetRightGain = (s16)(wetGain * panPosition); + + aMix(cmd++, (numSamplesPerUpdate * (s32)SAMPLE_SIZE) >> 4, wetLeftGain, dmem, DMEM_WET_LEFT_CH); + aMix(cmd++, (numSamplesPerUpdate * (s32)SAMPLE_SIZE) >> 4, (wetRightGain ^ 0xFFFF), dmem, DMEM_WET_RIGHT_CH); + // === End matrix surround encoding === + } else { + // Original behavior: mono surround to both channels + aMix(cmd++, (numSamplesPerUpdate * (s32)SAMPLE_SIZE) >> 4, dryGain, dmem, DMEM_LEFT_CH); + aMix(cmd++, (numSamplesPerUpdate * (s32)SAMPLE_SIZE) >> 4, (dryGain ^ 0xFFFF), dmem, DMEM_RIGHT_CH); + + wetGain = (dryGain * synthState->curReverbVol) >> 7; + + aMix(cmd++, (numSamplesPerUpdate * (s32)SAMPLE_SIZE) >> 4, wetGain, dmem, DMEM_WET_LEFT_CH); + aMix(cmd++, (numSamplesPerUpdate * (s32)SAMPLE_SIZE) >> 4, (wetGain ^ 0xFFFF), dmem, DMEM_WET_RIGHT_CH); + } + } + + aSaveBuffer(cmd++, DMEM_SURROUND_TEMP + (numSamplesPerUpdate * SAMPLE_SIZE), + synthState->synthesisBuffers->surroundEffectState, + sizeof(synthState->synthesisBuffers->surroundEffectState)); + + decayGain = (sampleState->targetVolLeft + sampleState->targetVolRight) * (1.0f / 0x2000); + + if (decayGain > 1.0f) { + decayGain = 1.0f; + } + + decayGain = decayGain * gDefaultPanVolume[127 - sampleState->surroundEffectIndex]; + synthState->surroundEffectGain = ((decayGain * 0x7FFF) + synthState->surroundEffectGain) / 2; + + AudioSynth_DMemMove(cmd++, DMEM_HAAS_TEMP, haasDmem, numSamplesPerUpdate * SAMPLE_SIZE); + + return cmd; +} diff --git a/patches/syms.ld b/patches/syms.ld index 2efa2c9..a1de75f 100644 --- a/patches/syms.ld +++ b/patches/syms.ld @@ -53,3 +53,6 @@ recomp_create_actor_data = 0x8F0000C8; recomp_destroy_actor_data = 0x8F0000CC; recomp_get_actor_data = 0x8F0000D0; recomp_get_actor_spawn_index = 0x8F0000D4; +recomp_set_audio_channels = 0x8F0000D8; +recomp_get_audio_channels = 0x8F0000DC; +recomp_get_enhanced_surround_enabled = 0x8F0000E0; diff --git a/src/audio/sound_matrix_decoder.cpp b/src/audio/sound_matrix_decoder.cpp new file mode 100644 index 0000000..49831f4 --- /dev/null +++ b/src/audio/sound_matrix_decoder.cpp @@ -0,0 +1,277 @@ +#include "sound_matrix_decoder.h" + +// Standard matrix decoding gains derived from psychoacoustic principles +namespace Gains { +constexpr float gCenter = 0.7071067811865476f * 0.5f; // -3dB (1/sqrt(2)) * 0.5 +constexpr float gFront = 0.5f; // -6dB +constexpr float gSurroundPrimary = 0.4359f; // Primary surround contribution +constexpr float gSurroundSecondary = 0.2449f; // Cross-feed surround contribution +} // namespace Gains + +// Timing constants +namespace Timing { +constexpr int gSurroundDelayMs = 10; // ITU-R BS.775 recommends 10-25ms +} + +SoundMatrixDecoder::SoundMatrixDecoder(int32_t sampleRate) { + // Compute delay length from sample rate + mDelayLength = (sampleRate * Timing::gSurroundDelayMs) / 1000; + if (mDelayLength > gMaxDelay) { + mDelayLength = gMaxDelay; + } + + // Precompute base rate for all-pass sweep + mAllPassBaseRate = std::pow(std::pow(2.0, 4.0), 0.1 / (sampleRate / 2.0)); + + // Design filters for this sample rate + mCoefCenterHP = DesignHighPass(70.0, sampleRate); // Remove rumble from center + mCoefCenterLP = DesignLowPass(20000.0, sampleRate); // Anti-alias center + mCoefSurroundHP = DesignHighPass(100.0, sampleRate); // Surround channels high-passed + mCoefSubLP = DesignLowPass(120.0, sampleRate); // LFE low-pass + + // Initialize phase chains with sample rate + mPhaseLeftMain = {}; + mPhaseLeftCross = {}; + mPhaseRightMain = {}; + mPhaseRightCross = {}; + PrepareAllPass(mPhaseLeftMain, sampleRate); + PrepareAllPass(mPhaseLeftCross, sampleRate); + PrepareAllPass(mPhaseRightMain, sampleRate); + PrepareAllPass(mPhaseRightCross, sampleRate); + + // Reset filter states + ResetState(); +} + +void SoundMatrixDecoder::ResetState() { + // Clear all filter states + mCenterHighPass = {}; + mCenterLowPass = {}; + mSurrLeftMainHP = {}; + mSurrLeftCrossHP = {}; + mSurrRightMainHP = {}; + mSurrRightCrossHP = {}; + mSubLowPass = {}; + + // Reset delay lines + mDelaySurrLeft = {}; + mDelaySurrLeft.Length = mDelayLength; + mDelaySurrRight = {}; + mDelaySurrRight.Length = mDelayLength; +} + +SoundMatrixDecoder::FilterCoefficients SoundMatrixDecoder::DesignLowPass(double frequency, int32_t sampleRate) { + FilterCoefficients coef = {}; + + // Clamp to safe range for bilinear transform stability + double maxFreq = sampleRate * 0.475; + if (frequency > maxFreq) { + frequency = maxFreq; + } + + // Linkwitz-Riley 4th order: two cascaded Butterworth 2nd order + double omega = 2.0 * M_PI * frequency; + double omega2 = omega * omega; + double omega3 = omega2 * omega; + double omega4 = omega2 * omega2; + + // Bilinear transform warping + double kVal = omega / std::tan(M_PI * frequency / sampleRate); + double k2 = kVal * kVal; + double k3 = k2 * kVal; + double k4 = k2 * k2; + + double rt2 = std::sqrt(2.0); + double term1 = rt2 * omega3 * kVal; + double term2 = rt2 * omega * k3; + double norm = 4.0 * omega2 * k2 + 2.0 * term1 + k4 + 2.0 * term2 + omega4; + + // Feedback coefficients + coef.B[0] = (4.0 * (omega4 + term1 - k4 - term2)) / norm; + coef.B[1] = (6.0 * omega4 - 8.0 * omega2 * k2 + 6.0 * k4) / norm; + coef.B[2] = (4.0 * (omega4 - term1 + term2 - k4)) / norm; + coef.B[3] = (k4 - 2.0 * term1 + omega4 - 2.0 * term2 + 4.0 * omega2 * k2) / norm; + + // Feedforward coefficients (low-pass response) + coef.A[0] = omega4 / norm; + coef.A[1] = 4.0 * omega4 / norm; + coef.A[2] = 6.0 * omega4 / norm; + coef.A[3] = coef.A[1]; + coef.A[4] = coef.A[0]; + + return coef; +} + +SoundMatrixDecoder::FilterCoefficients SoundMatrixDecoder::DesignHighPass(double frequency, int32_t sampleRate) { + FilterCoefficients coef = {}; + + double omega = 2.0 * M_PI * frequency; + double omega2 = omega * omega; + double omega3 = omega2 * omega; + double omega4 = omega2 * omega2; + + double kVal = omega / std::tan(M_PI * frequency / sampleRate); + double k2 = kVal * kVal; + double k3 = k2 * kVal; + double k4 = k2 * k2; + + double rt2 = std::sqrt(2.0); + double term1 = rt2 * omega3 * kVal; + double term2 = rt2 * omega * k3; + double norm = 4.0 * omega2 * k2 + 2.0 * term1 + k4 + 2.0 * term2 + omega4; + + coef.B[0] = (4.0 * (omega4 + term1 - k4 - term2)) / norm; + coef.B[1] = (6.0 * omega4 - 8.0 * omega2 * k2 + 6.0 * k4) / norm; + coef.B[2] = (4.0 * (omega4 - term1 + term2 - k4)) / norm; + coef.B[3] = (k4 - 2.0 * term1 + omega4 - 2.0 * term2 + 4.0 * omega2 * k2) / norm; + + // Feedforward coefficients (high-pass response) + coef.A[0] = k4 / norm; + coef.A[1] = -4.0 * k4 / norm; + coef.A[2] = 6.0 * k4 / norm; + coef.A[3] = coef.A[1]; + coef.A[4] = coef.A[0]; + + return coef; +} + +float SoundMatrixDecoder::ProcessFilter(float sample, BiquadCascade& state, const FilterCoefficients& coef) { + double in = sample; + double out = coef.A[0] * in + coef.A[1] * state.X[0] + coef.A[2] * state.X[1] + coef.A[3] * state.X[2] + + coef.A[4] * state.X[3] - coef.B[0] * state.Y[0] - coef.B[1] * state.Y[1] - coef.B[2] * state.Y[2] - + coef.B[3] * state.Y[3]; + + // Shift history + state.X[3] = state.X[2]; + state.X[2] = state.X[1]; + state.X[1] = state.X[0]; + state.X[0] = in; + state.Y[3] = state.Y[2]; + state.Y[2] = state.Y[1]; + state.Y[1] = state.Y[0]; + state.Y[0] = out; + + return static_cast(out); +} + +void SoundMatrixDecoder::PrepareAllPass(AllPassChain& chain, int32_t sampleRate) { + // Sweeping all-pass parameters for decorrelation + constexpr double depth = 4.0; + constexpr double baseDelay = 100.0; + constexpr double sweepSpeed = 0.1; + + chain.FreqMin = (M_PI * baseDelay) / sampleRate; + chain.Freq = chain.FreqMin; + + double range = std::pow(2.0, depth); + chain.FreqMax = (M_PI * baseDelay * range) / sampleRate; + chain.SweepRate = std::pow(range, sweepSpeed / (sampleRate / 2.0)); + chain.Ready = true; +} + +float SoundMatrixDecoder::ProcessAllPass(float sample, AllPassChain& chain, bool negate) { + // First-order all-pass coefficient + double c = (1.0 - chain.Freq) / (1.0 + chain.Freq); + double input = static_cast(sample); + + // Cascade of 4 first-order all-pass sections + chain.YHist[0] = c * (chain.YHist[0] + input) - chain.XHist[0]; + chain.XHist[0] = input; + + chain.YHist[1] = c * (chain.YHist[1] + chain.YHist[0]) - chain.XHist[1]; + chain.XHist[1] = chain.YHist[0]; + + chain.YHist[2] = c * (chain.YHist[2] + chain.YHist[1]) - chain.XHist[2]; + chain.XHist[2] = chain.YHist[1]; + + chain.YHist[3] = c * (chain.YHist[3] + chain.YHist[2]) - chain.XHist[3]; + chain.XHist[3] = chain.YHist[2]; + + double result = negate ? -chain.YHist[3] : chain.YHist[3]; + + // Sweep the frequency for time-varying decorrelation + chain.Freq *= chain.SweepRate; + + if (chain.Freq > chain.FreqMax) { + chain.SweepRate = 1.0 / mAllPassBaseRate; + } else if (chain.Freq < chain.FreqMin) { + chain.SweepRate = mAllPassBaseRate; + } + + return static_cast(result); +} + +float SoundMatrixDecoder::ProcessDelay(float sample, CircularDelay& buffer) { + float output = buffer.Data[buffer.Head]; + buffer.Data[buffer.Head] = sample; + buffer.Head = (buffer.Head + 1) % buffer.Length; + return output; +} + +float SoundMatrixDecoder::Saturate(float value) { + if (value > 1.0f) { + return 1.0f; + } + if (value < -1.0f) { + return -1.0f; + } + return value; +} + +std::tuple SoundMatrixDecoder::Process(const float* stereoInput, size_t samplePairs) { + // Resize output buffer if needed (6 channels per sample pair) + size_t samplesNeeded = samplePairs * 6; + if (mSurroundBuffer.size() < samplesNeeded) { + mSurroundBuffer.resize(samplesNeeded); + } + + for (size_t i = 0; i < samplePairs; ++i) { + float inL = stereoInput[i * 2]; + float inR = stereoInput[i * 2 + 1]; + + // Center: sum of L+R, band-limited + float ctr = (inL + inR) * Gains::gCenter; + ctr = ProcessFilter(ctr, mCenterHighPass, mCoefCenterHP); + ctr = ProcessFilter(ctr, mCenterLowPass, mCoefCenterLP); + + // Front channels: attenuated direct signal + float frontL = inL * Gains::gFront; + float frontR = inR * Gains::gFront; + + // Surround Left: L primary (inverted phase) + R secondary (shifted phase) + float slMain = inL * Gains::gSurroundPrimary; + slMain = ProcessFilter(slMain, mSurrLeftMainHP, mCoefSurroundHP); + slMain = ProcessAllPass(slMain, mPhaseLeftMain, true); + + float slCross = inR * Gains::gSurroundSecondary; + slCross = ProcessFilter(slCross, mSurrLeftCrossHP, mCoefSurroundHP); + slCross = ProcessAllPass(slCross, mPhaseLeftCross, false); + + float surrL = ProcessDelay(slMain + slCross, mDelaySurrLeft); + + // Surround Right: R primary (shifted phase) + L secondary (inverted phase) + float srMain = inR * Gains::gSurroundPrimary; + srMain = ProcessFilter(srMain, mSurrRightMainHP, mCoefSurroundHP); + srMain = ProcessAllPass(srMain, mPhaseRightMain, false); + + float srCross = inL * Gains::gSurroundSecondary; + srCross = ProcessFilter(srCross, mSurrRightCrossHP, mCoefSurroundHP); + srCross = ProcessAllPass(srCross, mPhaseRightCross, true); + + float surrR = ProcessDelay(srMain + srCross, mDelaySurrRight); + + // LFE: low-passed sum + float lfe = (inL + inR) * Gains::gCenter; + lfe = ProcessFilter(lfe, mSubLowPass, mCoefSubLP); + + // Output: FL, FR, C, LFE, SL, SR + mSurroundBuffer[i * 6 + 0] = Saturate(frontL); + mSurroundBuffer[i * 6 + 1] = Saturate(frontR); + mSurroundBuffer[i * 6 + 2] = Saturate(ctr); + mSurroundBuffer[i * 6 + 3] = Saturate(lfe); + mSurroundBuffer[i * 6 + 4] = Saturate(surrL); + mSurroundBuffer[i * 6 + 5] = Saturate(surrR); + } + + return { mSurroundBuffer.data(), samplesNeeded }; +} diff --git a/src/game/config.cpp b/src/game/config.cpp index 17ff9e4..ac9143c 100644 --- a/src/game/config.cpp +++ b/src/game/config.cpp @@ -460,6 +460,8 @@ bool save_sound_config(const std::filesystem::path& path) { config_json["main_volume"] = zelda64::get_main_volume(); config_json["bgm_volume"] = zelda64::get_bgm_volume(); config_json["low_health_beeps"] = zelda64::get_low_health_beeps_enabled(); + config_json["audio_mode"] = zelda64::get_audio_mode(); + config_json["enhanced_surround"] = zelda64::get_enhanced_surround_enabled(); return save_json_with_backups(path, config_json); } @@ -474,6 +476,8 @@ bool load_sound_config(const std::filesystem::path& path) { call_if_key_exists(zelda64::set_main_volume, config_json, "main_volume"); call_if_key_exists(zelda64::set_bgm_volume, config_json, "bgm_volume"); call_if_key_exists(zelda64::set_low_health_beeps_enabled, config_json, "low_health_beeps"); + call_if_key_exists(zelda64::set_audio_mode, config_json, "audio_mode"); + call_if_key_exists(zelda64::set_enhanced_surround_enabled, config_json, "enhanced_surround"); return true; } diff --git a/src/game/recomp_api.cpp b/src/game/recomp_api.cpp index 9ad08be..9980bec 100644 --- a/src/game/recomp_api.cpp +++ b/src/game/recomp_api.cpp @@ -13,6 +13,11 @@ #include "../patches/sound.h" #include "ultramodern/ultramodern.hpp" #include "ultramodern/config.hpp" +#include "audio_channels.h" + +// Forward declarations for audio channel functions defined in main.cpp +void set_audio_channels(AudioChannelsSetting channels); +AudioChannelsSetting get_audio_channels(); extern "C" void recomp_update_inputs(uint8_t* rdram, recomp_context* ctx) { recomp::poll_inputs(); @@ -177,3 +182,21 @@ extern "C" void recomp_set_right_analog_suppressed(uint8_t* rdram, recomp_contex recomp::set_right_analog_suppressed(suppressed); } + +// Surround sound support - called by the game when audio settings change +extern "C" void recomp_set_audio_channels(uint8_t* rdram, recomp_context* ctx) { + s32 channels = _arg<0, s32>(rdram, ctx); + + // Validate input and update the audio backend + if (channels >= 0 && channels < audioMax) { + set_audio_channels(static_cast(channels)); + } +} + +extern "C" void recomp_get_audio_channels(uint8_t* rdram, recomp_context* ctx) { + _return(ctx, static_cast(get_audio_channels())); +} + +extern "C" void recomp_get_enhanced_surround_enabled(uint8_t* rdram, recomp_context* ctx) { + _return(ctx, zelda64::get_enhanced_surround_enabled() ? 1 : 0); +} diff --git a/src/main/main.cpp b/src/main/main.cpp index 6c8262a..93eec1e 100644 --- a/src/main/main.cpp +++ b/src/main/main.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include "nfd.h" @@ -45,6 +46,9 @@ #include "../../patches/sound.h" #include "../../patches/misc_funcs.h" +#include "audio_channels.h" +#include "sound_matrix_decoder.h" + #include "mods/mm_recomp_dpad_builtin.h" #ifdef _WIN32 @@ -188,6 +192,10 @@ static uint32_t output_sample_rate = 48000; constexpr uint32_t input_channels = 2; static uint32_t output_channels = 2; +// Surround sound support +static AudioChannelsSetting audio_channel_setting = audioStereo; +static std::unique_ptr sound_matrix_decoder; + // Terminology: a frame is a collection of samples for each channel. e.g. 2 input samples is one input frame. This is unrelated to graphical frames. // Number of frames to duplicate for fixing interpolation at the start and end of a chunk. @@ -241,25 +249,60 @@ void queue_samples(int16_t* audio_data, size_t sample_count) { throw std::runtime_error("Error using SDL audio converter"); } - uint64_t cur_queued_microseconds = uint64_t(SDL_GetQueuedAudioSize(audio_device)) / bytes_per_frame * 1000000 / sample_rate; - uint32_t num_bytes_to_queue = audio_convert.len_cvt - output_channels * discarded_output_frames * sizeof(swap_buffer[0]); - float* samples_to_queue = swap_buffer.data() + output_channels * discarded_output_frames / 2; + // Calculate the number of stereo frames after resampling (for both stereo and surround paths) + size_t resampled_stereo_frames = audio_convert.len_cvt / sizeof(float) / input_channels; + size_t frames_after_discard = resampled_stereo_frames - discarded_output_frames; + // Offset matches stereo path: output_channels * discarded_output_frames / 2 = discarded_output_frames (when output_channels=2) + float* stereo_samples = swap_buffer.data() + input_channels * discarded_output_frames / 2; - // Prevent audio latency from building up by skipping samples in incoming audio when too many samples are already queued. - // Skip samples based on how many microseconds of samples are queued already. - uint32_t skip_factor = cur_queued_microseconds / 100000; - if (skip_factor != 0) { - uint32_t skip_ratio = 1 << skip_factor; - num_bytes_to_queue /= skip_ratio; - for (size_t i = 0; i < num_bytes_to_queue / (output_channels * sizeof(swap_buffer[0])); i++) { - samples_to_queue[2 * i + 0] = samples_to_queue[2 * skip_ratio * i + 0]; - samples_to_queue[2 * i + 1] = samples_to_queue[2 * skip_ratio * i + 1]; + // Handle surround sound matrix decoding + if (audio_channel_setting == audioMatrix51 && sound_matrix_decoder) { + // Process stereo through the matrix decoder to get 5.1 surround + auto [surround_samples, surround_sample_count] = sound_matrix_decoder->Process(stereo_samples, frames_after_discard); + + uint64_t cur_queued_microseconds = uint64_t(SDL_GetQueuedAudioSize(audio_device)) / (6 * sizeof(float)) * 1000000 / output_sample_rate; + uint32_t num_bytes_to_queue = surround_sample_count * sizeof(float); + + // Prevent audio latency from building up by skipping frames + uint32_t skip_factor = cur_queued_microseconds / 100000; + if (skip_factor != 0) { + uint32_t skip_ratio = 1 << skip_factor; + size_t frames_to_queue = surround_sample_count / 6; + size_t new_frames = frames_to_queue / skip_ratio; + // Surround buffer is const, so we need to copy to a temp buffer for skipping + static std::vector skip_buffer; + skip_buffer.resize(new_frames * 6); + for (size_t i = 0; i < new_frames; i++) { + for (size_t ch = 0; ch < 6; ch++) { + skip_buffer[i * 6 + ch] = surround_samples[i * skip_ratio * 6 + ch]; + } + } + SDL_QueueAudio(audio_device, skip_buffer.data(), new_frames * 6 * sizeof(float)); + } else { + SDL_QueueAudio(audio_device, surround_samples, num_bytes_to_queue); } - } + } else { + // Stereo path (original behavior) + uint64_t cur_queued_microseconds = uint64_t(SDL_GetQueuedAudioSize(audio_device)) / bytes_per_frame * 1000000 / sample_rate; + uint32_t num_bytes_to_queue = audio_convert.len_cvt - output_channels * discarded_output_frames * sizeof(swap_buffer[0]); + float* samples_to_queue = swap_buffer.data() + output_channels * discarded_output_frames / 2; - // Queue the swapped audio data. - // Offset the data start by only half the discarded frame count as the other half of the discarded frames are at the end of the buffer. - SDL_QueueAudio(audio_device, samples_to_queue, num_bytes_to_queue); + // Prevent audio latency from building up by skipping samples in incoming audio when too many samples are already queued. + // Skip samples based on how many microseconds of samples are queued already. + uint32_t skip_factor = cur_queued_microseconds / 100000; + if (skip_factor != 0) { + uint32_t skip_ratio = 1 << skip_factor; + num_bytes_to_queue /= skip_ratio; + for (size_t i = 0; i < num_bytes_to_queue / (output_channels * sizeof(swap_buffer[0])); i++) { + samples_to_queue[2 * i + 0] = samples_to_queue[2 * skip_ratio * i + 0]; + samples_to_queue[2 * i + 1] = samples_to_queue[2 * skip_ratio * i + 1]; + } + } + + // Queue the swapped audio data. + // Offset the data start by only half the discarded frame count as the other half of the discarded frames are at the end of the buffer. + SDL_QueueAudio(audio_device, samples_to_queue, num_bytes_to_queue); + } } size_t get_frames_remaining() { @@ -286,7 +329,8 @@ size_t get_frames_remaining() { } void update_audio_converter() { - int ret = SDL_BuildAudioCVT(&audio_convert, AUDIO_F32, input_channels, sample_rate, AUDIO_F32, output_channels, output_sample_rate); + // Always convert to stereo - the SoundMatrixDecoder handles upmixing to surround if needed + int ret = SDL_BuildAudioCVT(&audio_convert, AUDIO_F32, input_channels, sample_rate, AUDIO_F32, input_channels, output_sample_rate); if (ret < 0) { printf("Error creating SDL audio converter: %s\n", SDL_GetError()); @@ -304,6 +348,25 @@ void set_frequency(uint32_t freq) { } void reset_audio(uint32_t output_freq) { + // Close existing audio device if open + if (audio_device != 0) { + SDL_PauseAudioDevice(audio_device, 1); + SDL_ClearQueuedAudio(audio_device); + SDL_CloseAudioDevice(audio_device); + audio_device = 0; + } + + // Set output channels based on audio channel setting + switch (audio_channel_setting) { + case audioMatrix51: + output_channels = 6; + break; + case audioStereo: + default: + output_channels = 2; + break; + } + SDL_AudioSpec spec_desired{ .freq = (int)output_freq, .format = AUDIO_F32, @@ -316,7 +379,6 @@ void reset_audio(uint32_t output_freq) { .userdata = nullptr }; - audio_device = SDL_OpenAudioDevice(nullptr, false, &spec_desired, nullptr, 0); if (audio_device == 0) { exit_error("SDL error opening audio device: %s\n", SDL_GetError()); @@ -325,6 +387,37 @@ void reset_audio(uint32_t output_freq) { output_sample_rate = output_freq; update_audio_converter(); + + printf("Audio initialized: %d channels, %d Hz (%s)\n", output_channels, output_freq, AudioChannelsSettingName(audio_channel_setting)); +} + +AudioChannelsSetting get_audio_channels() { + return audio_channel_setting; +} + +void set_audio_channels(AudioChannelsSetting channels) { + if (audio_channel_setting == channels) { + return; // No change needed + } + + printf("Changing audio channels from %s to %s\n", + AudioChannelsSettingName(audio_channel_setting), + AudioChannelsSettingName(channels)); + + audio_channel_setting = channels; + + // Setup or teardown sound matrix decoder + if (channels == audioMatrix51) { + if (!sound_matrix_decoder) { + sound_matrix_decoder = std::make_unique(output_sample_rate); + } + } else { + // When switching away from matrix mode, release the decoder + sound_matrix_decoder.reset(); + } + + // Reinitialize audio device with new channel count + reset_audio(output_sample_rate); } extern RspUcodeFunc njpgdspMain; @@ -666,6 +759,8 @@ int main(int argc, char** argv) { REGISTER_FUNC(recomp_get_targeting_mode); REGISTER_FUNC(recomp_get_bgm_volume); REGISTER_FUNC(recomp_get_low_health_beeps_enabled); + REGISTER_FUNC(recomp_set_audio_channels); + REGISTER_FUNC(recomp_get_audio_channels); REGISTER_FUNC(recomp_get_gyro_deltas); REGISTER_FUNC(recomp_get_mouse_deltas); REGISTER_FUNC(recomp_get_inverted_axes); diff --git a/src/ui/ui_config.cpp b/src/ui/ui_config.cpp index 6832b22..d2bea43 100644 --- a/src/ui/ui_config.cpp +++ b/src/ui/ui_config.cpp @@ -3,6 +3,7 @@ #include "zelda_sound.h" #include "zelda_config.h" #include "zelda_debug.h" +#include "audio_channels.h" #include "zelda_render.h" #include "zelda_support.h" #include "promptfont.h" @@ -364,10 +365,14 @@ struct SoundOptionsContext { std::atomic main_volume; // Option to control the volume of all sound std::atomic bgm_volume; std::atomic low_health_beeps_enabled; // RmlUi doesn't seem to like "true"/"false" strings for setting variants so an int is used here instead. + zelda64::AudioMode audio_mode; // Audio output mode (Stereo, Mono, Headphones, Surround) + std::atomic enhanced_surround_enabled; // Pan-based rear channel separation void reset() { bgm_volume = 100; main_volume = 100; low_health_beeps_enabled = (int)true; + audio_mode = zelda64::AudioMode::Stereo; + enhanced_surround_enabled = (int)false; } SoundOptionsContext() { reset(); @@ -416,6 +421,34 @@ bool zelda64::get_low_health_beeps_enabled() { return (bool)sound_options_context.low_health_beeps_enabled.load(); } +// Forward declaration from main.cpp +void set_audio_channels(AudioChannelsSetting channels); + +void zelda64::set_audio_mode(zelda64::AudioMode mode) { + printf("UI: Setting audio mode to %d\n", (int)mode); + sound_options_context.audio_mode = mode; + if (sound_options_model_handle) { + sound_options_model_handle.DirtyVariable("audio_mode"); + } + // Update audio backend - Surround mode uses 5.1 matrix decoding + set_audio_channels(mode == zelda64::AudioMode::Surround ? audioMatrix51 : audioStereo); +} + +zelda64::AudioMode zelda64::get_audio_mode() { + return sound_options_context.audio_mode; +} + +void zelda64::set_enhanced_surround_enabled(bool enabled) { + sound_options_context.enhanced_surround_enabled.store((int)enabled); + if (sound_options_model_handle) { + sound_options_model_handle.DirtyVariable("enhanced_surround_enabled"); + } +} + +bool zelda64::get_enhanced_surround_enabled() { + return (bool)sound_options_context.enhanced_surround_enabled.load(); +} + struct DebugContext { Rml::DataModelHandle model_handle; std::vector area_names; @@ -482,7 +515,9 @@ class ConfigTabsetListener : public Rml::EventListener { if (tabs != nullptr) { size_t num_children = tabs->GetNumChildren(); for (size_t i = 0; i < num_children; i++) { - tabs->GetChild(i)->SetProperty(Rml::PropertyId::NavDown, Rml::Style::Nav::Auto); + if (!tabs->GetChild(i)->GetLocalProperty(Rml::PropertyId::NavDown)) { + tabs->GetChild(i)->SetProperty(Rml::PropertyId::NavDown, Rml::Style::Nav::Auto); + } } } } @@ -939,6 +974,19 @@ public: bind_atomic(constructor, sound_options_model_handle, "main_volume", &sound_options_context.main_volume); bind_atomic(constructor, sound_options_model_handle, "bgm_volume", &sound_options_context.bgm_volume); bind_atomic(constructor, sound_options_model_handle, "low_health_beeps_enabled", &sound_options_context.low_health_beeps_enabled); + bind_atomic(constructor, sound_options_model_handle, "enhanced_surround_enabled", &sound_options_context.enhanced_surround_enabled); + + // Custom binding for audio mode that calls the setter to update audio channels + constructor.BindFunc("audio_mode", + [](Rml::Variant& out) { get_option(sound_options_context.audio_mode, out); }, + [](const Rml::Variant& in) { + zelda64::AudioMode mode = zelda64::AudioMode::OptionCount; + from_json(in.Get(), mode); + if (mode != zelda64::AudioMode::OptionCount) { + zelda64::set_audio_mode(mode); + } + } + ); } void make_debug_bindings(Rml::Context* context) {