From 997cc9207215ea79a9905775ee2c42ec9f552799 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Mon, 9 Nov 2026 21:00:38 +0100 Subject: [PATCH 1/4] ImDebugger: Count a queued granule as half a granule of latency The granules overlap by half, so each one in the queue adds 128 samples, not 256. Co-Authored-By: Claude Opus 5.5 --- UI/ImDebugger/ImDebugger.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UI/ImDebugger/ImDebugger.cpp b/UI/ImDebugger/ImDebugger.cpp index ec3e60b063..24b83a0db1 100644 --- a/UI/ImDebugger/ImDebugger.cpp +++ b/UI/ImDebugger/ImDebugger.cpp @@ -1717,7 +1717,7 @@ void DrawAudioOut(ImConfig &cfg, ImControl &control) { ImGui::Text("Queued (smooth): %0.3f", stats.smoothedQueuedGranules); ImGui::Text("Target: %d", stats.targetQueueSize); ImGui::Text("Max: %d", stats.maxQueuedGranules); - ImGui::Text("Computed queue latency: %d ms", (int)(stats.smoothedQueuedGranules * (GranularMixer::GRANULE_SIZE * 1000.0 / 44100.0))); + ImGui::Text("Computed queue latency: %d ms", (int)(stats.smoothedQueuedGranules * (GranularMixer::GRANULE_SIZE / 2 * 1000.0 / 44100.0))); ImGui::Text("Fade volume: %0.2f", stats.fadeVolume); ImGui::Text("Looping: %s", BoolStr(stats.looping)); ImGui::Text("Under/Over: %d / %d", stats.underruns, stats.overruns); From 5f349fd603f3eab03b770a78fbdca0cdb55bfd12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Mon, 9 Nov 2026 21:00:38 +0100 Subject: [PATCH 2/4] Audio: Size the granular queue from the classic resampler's target The queue cap was four output callbacks plus a frame of audio. Bluetooth and other outputs without a fast path call back with large chunks, so that came to about 100 ms there. Now it's the classic target (which also honors Extra audio buffering), raised for slow games' per-frame bursts and to keep half the queue above one callback's read, plus a granule for the overlap. Co-Authored-By: Claude Opus 5.5 --- Core/HW/GranularMixer.cpp | 19 ++++++++++++------- Core/HW/StereoResampler.cpp | 23 +++++++++++++---------- Core/HW/StereoResampler.h | 3 +++ 3 files changed, 28 insertions(+), 17 deletions(-) diff --git a/Core/HW/GranularMixer.cpp b/Core/HW/GranularMixer.cpp index 2bf1404a02..0636995593 100644 --- a/Core/HW/GranularMixer.cpp +++ b/Core/HW/GranularMixer.cpp @@ -14,6 +14,7 @@ #include "Common/Swap.h" #include "Core/Config.h" #include "Core/HW/Display.h" +#include "Core/HW/StereoResampler.h" #include "Core/Core.h" #include "Core/System.h" #include "Core/Util/AudioFormat.h" // for clamp_u16 @@ -102,13 +103,17 @@ void GranularMixer::Mix(s16 *samples, u32 num_samples, int outSampleRate, float const float fade_in_mul = -std::expm1(-1.0 / (out_sample_rate * FADE_IN_RC)); const float fade_out_mul = -std::expm1(-1.0 / (out_sample_rate * FADE_OUT_RC)); - // Calculate the ideal length of the granule queue. - // NOTE: We must have enough room here for 20fps games, generating all their audio - // in a burst each frame (since we can't force real clock sync). That means 16*3 = 48 or rather 50ms. - // However, in case of faster framerates, we should apply some pressure to reduce this. And if real clock sync - // is on, we should also be able to get away with a shorter buffer here. - // const u32 buffer_size_ms = frameTimeEstimate_ * 44100.0f; - const u32 buffer_size_samples = smoothedReadSize_ * 4 + std::llround(frameTimeEstimate_ * inSampleRate); + // Calculate the length of the granule queue. Dequeue() cuts the queue back to half when it + // goes over, so this is a cap rather than a fill level. + // We aim for the same latency as StereoResampler, but must also have room for slow (like 20fps) + // games, which generate all their audio in a burst each frame (since we can't force real + // clock sync), and keep half the queue above what one output callback reads, or each callback + // runs dry. Bluetooth and other non-fast outputs read in large chunks. + // One more granule on top, for the two half-overlapping granules being played. + const int targetSamples = StereoResampler::TargetBufferSize(); + const int frameSamples = (int)std::llround(frameTimeEstimate_ * inSampleRate); + const int readSamples = (int)(smoothedReadSize_ * inSampleRate / out_sample_rate); + const u32 buffer_size_samples = std::max({ targetSamples, frameSamples + targetSamples / 2, readSamples * 2 }) + GRANULE_SIZE; queuedSamplesTarget_ = buffer_size_samples; // Limit the possible queue sizes to any number between 4 and 64. diff --git a/Core/HW/StereoResampler.cpp b/Core/HW/StereoResampler.cpp index c03926b952..13e00d56cf 100644 --- a/Core/HW/StereoResampler.cpp +++ b/Core/HW/StereoResampler.cpp @@ -74,20 +74,23 @@ StereoResampler::~StereoResampler() { buffer_ = nullptr; } -void StereoResampler::UpdateBufferSize() { +int StereoResampler::TargetBufferSize() { if (g_Config.bExtraAudioBuffering) { + return TARGET_BUFSIZE_EXTRA; + } + int systemBufsize = System_GetPropertyInt(SYSPROP_AUDIO_FRAMES_PER_BUFFER); + if (systemBufsize > 0 && TARGET_BUFSIZE_DEFAULT < systemBufsize + TARGET_BUFSIZE_MARGIN) { + return std::min(4096, systemBufsize + TARGET_BUFSIZE_MARGIN); + } + return TARGET_BUFSIZE_DEFAULT; +} + +void StereoResampler::UpdateBufferSize() { + targetBufsize_ = TargetBufferSize(); + if (g_Config.bExtraAudioBuffering || targetBufsize_ * 2 > MAX_BUFSIZE_DEFAULT) { maxBufsize_ = MAX_BUFSIZE_EXTRA; - targetBufsize_ = TARGET_BUFSIZE_EXTRA; } else { maxBufsize_ = MAX_BUFSIZE_DEFAULT; - targetBufsize_ = TARGET_BUFSIZE_DEFAULT; - - int systemBufsize = System_GetPropertyInt(SYSPROP_AUDIO_FRAMES_PER_BUFFER); - if (systemBufsize > 0 && targetBufsize_ < systemBufsize + TARGET_BUFSIZE_MARGIN) { - targetBufsize_ = std::min(4096, systemBufsize + TARGET_BUFSIZE_MARGIN); - if (targetBufsize_ * 2 > MAX_BUFSIZE_DEFAULT) - maxBufsize_ = MAX_BUFSIZE_EXTRA; - } } } diff --git a/Core/HW/StereoResampler.h b/Core/HW/StereoResampler.h index f12401715f..f7031b43f2 100644 --- a/Core/HW/StereoResampler.h +++ b/Core/HW/StereoResampler.h @@ -44,6 +44,9 @@ public: void GetAudioDebugStats(char *buf, size_t bufSize); void ResetStatCounters(); + // The buffer fill to aim for, in 44.1 kHz stereo frames. GranularMixer sizes its queue from this too. + static int TargetBufferSize(); + private: void UpdateBufferSize(); From e040fa522336507997b62bc745cf567f70c6339a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Mon, 9 Nov 2026 21:03:53 +0100 Subject: [PATCH 3/4] Audio: Show granular mixer stats in the audio debug overlay Queue fill and cap (in granules and ms), under/overruns, output rate, read size and frame time, replacing the "no stats yet" placeholder. Co-Authored-By: Claude Opus 5.5 --- Core/HW/GranularMixer.cpp | 34 ++++++++++++++++++++++++++++++++++ Core/HW/GranularMixer.h | 4 ++++ UI/AudioCommon.cpp | 3 ++- 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/Core/HW/GranularMixer.cpp b/Core/HW/GranularMixer.cpp index 0636995593..847f1394aa 100644 --- a/Core/HW/GranularMixer.cpp +++ b/Core/HW/GranularMixer.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include "Common/CommonTypes.h" @@ -80,6 +81,7 @@ void GranularMixer::Mix(s16 *samples, u32 num_samples, int outSampleRate, float memset(samples, 0, num_samples * 2 * sizeof(s16)); // The estimate is 0 until a second has gone by. frameTimeEstimate_ = 1.0f / std::max(fpsEstimate, 1.0f); + outSampleRate_ = outSampleRate; smoothedReadSize_ = smoothedReadSize_ == 0 ? num_samples : (smoothedReadSize_ * 0.95f + num_samples * 0.05f); @@ -309,3 +311,35 @@ void GranularMixer::GetStats(GranularStats *stats) { queuedGranulesMin_ = 10000; queuedGranulesMax_ = 0; } + +void GranularMixer::GetAudioDebugStats(char *buf, size_t bufSize) { + GranularStats stats; + GetStats(&stats); + // Each queued granule holds GRANULE_OVERLAP new samples. + const double msPerGranule = GRANULE_OVERLAP * 1000.0 / 44100.0; + snprintf(buf, bufSize, + "Granular mixer\n" + "Queued: %0.1f granules (%0.1fms)\n" + "Queued min/max: %d / %d\n" + "Cap: %d granules (%0.1fms, %d samples)\n" + "Underruns: %d\n" + "Overruns: %d\n" + "Output sample rate: %d\n" + "Read size: %0.1f\n" + "Frame time estimate: %0.1fms\n" + "Fade volume: %0.2f%s\n", + stats.smoothedQueuedGranules, stats.smoothedQueuedGranules * msPerGranule, + stats.queuedGranulesMin, stats.queuedGranulesMax, + stats.targetQueueSize, stats.targetQueueSize * msPerGranule, stats.queuedSamplesTarget, + stats.underruns, + stats.overruns, + outSampleRate_, + stats.smoothedReadSize, + stats.frameTimeEstimate * 1000.0f, + stats.fadeVolume, stats.looping ? " (looping)" : ""); +} + +void GranularMixer::ResetStatCounters() { + underruns_ = 0; + overruns_ = 0; +} diff --git a/Core/HW/GranularMixer.h b/Core/HW/GranularMixer.h index 0010d6afd6..c64d393ddb 100644 --- a/Core/HW/GranularMixer.h +++ b/Core/HW/GranularMixer.h @@ -46,6 +46,9 @@ public: void PushSamples(const s32 *samples, u32 num_samples, float volume); void GetStats(GranularStats *stats); + // Text for the audio debug overlay. + void GetAudioDebugStats(char *buf, size_t bufSize); + void ResetStatCounters(); static constexpr u32 GRANULE_SIZE = 256; @@ -107,6 +110,7 @@ private: int queuedSamplesTarget_ = 0; float smoothedReadSize_ = 0.0f; float frameTimeEstimate_ = 0.0f; + int outSampleRate_ = 0; void Enqueue(); void Dequeue(Granule* granule); diff --git a/UI/AudioCommon.cpp b/UI/AudioCommon.cpp index 69e329f5e9..bbc0d1f0e8 100644 --- a/UI/AudioCommon.cpp +++ b/UI/AudioCommon.cpp @@ -47,12 +47,13 @@ void NativeMix(int16_t *outStereo, int numFrames, int sampleRateHz, void *userda void System_AudioGetDebugStats(char *buf, size_t bufSize) { if (buf) { if (g_Config.iAudioPlaybackMode == (int)AudioSyncMode::GRANULAR) { - snprintf(buf, bufSize, "(No stats available for granular yet)"); + g_granular.GetAudioDebugStats(buf, bufSize); } else { g_resampler.GetAudioDebugStats(buf, bufSize); } } else { g_resampler.ResetStatCounters(); + g_granular.ResetStatCounters(); } } From 5fc7f4b957ab41178a24e37199dbad35df0761ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Tue, 10 Nov 2026 10:37:59 +0100 Subject: [PATCH 4/4] Default audio playback mode to smooth. --- Core/Config.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Core/Config.cpp b/Core/Config.cpp index d65ed02c6a..eda707c945 100644 --- a/Core/Config.cpp +++ b/Core/Config.cpp @@ -1130,7 +1130,7 @@ static constexpr ConfigSetting soundSettings[] = { ConfigSetting("FillAudioGaps", SETTING(g_Config, bFillAudioGaps), true, CfgFlag::DEFAULT) .Label(I18NCat::AUDIO, "Fill audio gaps"), - ConfigSetting("AudioSyncMode", SETTING(g_Config, iAudioPlaybackMode), (int)AudioSyncMode::CLASSIC_PITCH, CfgFlag::DEFAULT) + ConfigSetting("AudioSyncMode", SETTING(g_Config, iAudioPlaybackMode), (int)AudioSyncMode::GRANULAR, CfgFlag::DEFAULT) .Label(I18NCat::AUDIO, "Playback mode").Choices(audioPlaybackModes), // Legacy volume settings, these get auto upgraded through default handlers on the new settings. NOTE: Must be before the new ones in the order here.