From d67d47c4a315e2550ff5eefd1e9a721aa0b33f7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Tue, 10 Nov 2026 11:15:02 +0100 Subject: [PATCH] Headless: Let --audio-sink drive the granular mixer --audio-granular picks the granular mixer (the Audio sync mode setting), and the sink then reports the queue's level and cap, and its underruns. Co-Authored-By: Claude Opus 5.5 --- Core/CmdLine.cpp | 6 ++++ Core/CmdLine.h | 1 + Core/HW/GranularMixer.h | 6 ++++ headless/Headless.cpp | 76 +++++++++++++++++++++++++++++++++++------ 4 files changed, 79 insertions(+), 10 deletions(-) diff --git a/Core/CmdLine.cpp b/Core/CmdLine.cpp index 49bceb2c03..8f069772b2 100644 --- a/Core/CmdLine.cpp +++ b/Core/CmdLine.cpp @@ -5,6 +5,7 @@ #include #include "Core/Config.h" +#include "Core/ConfigValues.h" #include "Core/CmdLine.h" #include "Core/WebServer.h" #include "Core/Util/PathUtil.h" @@ -268,6 +269,7 @@ static const CommandLineParam g_autoParams[] = { {POFF(lagSync), CmdParamType::Bool, "lag-sync", '\0', "Keep emulated time in step with the wall clock every millisecond, not every frame (the Force real clock sync setting)"}, {POFF(frameStats), CmdParamType::Bool, "frame-stats", '\0', "Print flips per emulated second at the end of the run, counted from --state's load", CmdLineMode::Headless}, {POFF(audioSinkPeriod), CmdParamType::Int, "audio-sink", '\0', "Pull audio like an output device, FRAMES at 48 kHz at a time, and print the buffer's stats at the end (with --realtime)", CmdLineMode::Headless}, + {POFF(audioGranular), CmdParamType::Bool, "audio-granular", '\0', "Mix audio with the granular mixer instead of the classic resampler (the Audio sync mode setting)"}, {POFF(maxScreenshotError), CmdParamType::Double, "max-mse", '\0', "Maximum allowed MSE error for screenshot comparison", CmdLineMode::Headless}, {POFF(mountIso), CmdParamType::String, "mount", 'm', "Mount ISO/CSO on umd1: when booting an ELF/PBP or --vsh"}, {POFF(unpackUpdater), CmdParamType::String, "unpack-updater", '\0', "Unpack the firmware in an updater EBOOT.PBP into DIR and exit", CmdLineMode::Headless}, @@ -614,6 +616,10 @@ void CommandLineOptions::ApplyToConfig() const { g_Config.bForceLagSync = lagSync.value(); g_Config.DoNotSaveSetting(&g_Config.bForceLagSync); } + if (audioGranular.has_value()) { + g_Config.iAudioPlaybackMode = (int)(audioGranular.value() ? AudioSyncMode::GRANULAR : AudioSyncMode::CLASSIC_PITCH); + g_Config.DoNotSaveSetting(&g_Config.iAudioPlaybackMode); + } if (fuseId.has_value()) { g_Config.sFuseID = fuseId.value(); g_Config.DoNotSaveSetting(&g_Config.sFuseID); diff --git a/Core/CmdLine.h b/Core/CmdLine.h index 403814d422..d6c7c170cf 100644 --- a/Core/CmdLine.h +++ b/Core/CmdLine.h @@ -197,6 +197,7 @@ struct CommandLineOptions { // Headless: pull audio through the resampler like an output device with this period, in frames // at 48 kHz, and print how full the buffer was and how often it ran dry. std::optional audioSinkPeriod; + std::optional audioGranular; // Headless: which of several instances running at once this is (sets PPSSPP_ID, and with it the // instance's 127.0.0.N address and MAC), and whether to run at PSP speed rather than flat out. // Both are for ad hoc tests where instances talk to each other. diff --git a/Core/HW/GranularMixer.h b/Core/HW/GranularMixer.h index c64d393ddb..11030ce906 100644 --- a/Core/HW/GranularMixer.h +++ b/Core/HW/GranularMixer.h @@ -50,6 +50,12 @@ public: void GetAudioDebugStats(char *buf, size_t bufSize); void ResetStatCounters(); + // In stereo samples at the input rate (each queued granule adds GRANULE_OVERLAP). + int QueuedSamples() const { return (int)(m_queue_head.load() - m_queue_tail.load()) * (int)GRANULE_OVERLAP; } + int QueueCapSamples() const { return (int)m_granule_queue_size.load() * (int)GRANULE_OVERLAP; } + int Underruns() const { return underruns_; } + int Overruns() const { return overruns_; } + static constexpr u32 GRANULE_SIZE = 256; private: diff --git a/headless/Headless.cpp b/headless/Headless.cpp index 286b3d449f..60d397f2c5 100644 --- a/headless/Headless.cpp +++ b/headless/Headless.cpp @@ -69,6 +69,8 @@ #include "Core/HLE/__sceAudio.h" #include "Core/HLE/sceCtrl.h" #include "Core/HLE/sceDisplay.h" +#include "Core/HW/Display.h" +#include "Core/HW/GranularMixer.h" #include "Core/HW/StereoResampler.h" #include "Core/MIPS/MIPSTables.h" #include "Core/System.h" @@ -157,9 +159,13 @@ class AudioSink { public: static constexpr int SAMPLE_RATE = 48000; - void Start(int periodFrames) { + void Start(int periodFrames, bool granular) { periodFrames_ = periodFrames; - resampler_ = std::make_unique(); + if (granular) { + granular_ = std::make_unique(); + } else { + resampler_ = std::make_unique(); + } thread_ = std::thread([this] { Run(); }); } void Stop() { @@ -172,13 +178,31 @@ public: int PeriodFrames() const { return periodFrames_; } // Counting starts once the game runs in real time, not while a state loads. void StartCounting() { counting_ = true; } - void Push(const s32 *audio, int numSamples, float volume) { resampler_->PushSamples(audio, numSamples, volume); } - void Clear() { resampler_->Clear(); } + void Push(const s32 *audio, int numSamples, float volume) { + if (granular_) { + granular_->PushSamples(audio, numSamples, volume); + } else { + resampler_->PushSamples(audio, numSamples, volume); + } + } + // Like the app, which has nothing to clear in the granular mixer. + void Clear() { + if (resampler_) { + resampler_->Clear(); + } + } void Print() const { const double toMs = 1000.0 / 44100.0; + const double avg = pulls_ ? (double)sumBuffered_ / pulls_ * toMs : 0.0; + if (granular_) { + fprintf(stderr, "Audio sink (granular): %d pulls of %d frames, %d with an underrun (%d underruns), queue before a pull %.1f/%.1f/%.1f ms min/avg/max, cap %.1f/%.1f/%.1f ms min/avg/max, %d overruns\n", + pulls_, periodFrames_, shortPulls_, underruns_, minBuffered_ * toMs, avg, maxBuffered_ * toMs, + minCap_ * toMs, pulls_ ? (double)sumCap_ / pulls_ * toMs : 0.0, maxCap_ * toMs, overruns_); + return; + } fprintf(stderr, "Audio sink: %d pulls of %d frames, %d short (%d frames missing), buffer before a pull %.1f/%.1f/%.1f ms min/avg/max, target %.1f ms, %.1f ms of excess dropped\n", - pulls_, periodFrames_, shortPulls_, missingFrames_, minBuffered_ * toMs, pulls_ ? (double)sumBuffered_ / pulls_ * toMs : 0.0, + pulls_, periodFrames_, shortPulls_, missingFrames_, minBuffered_ * toMs, avg, maxBuffered_ * toMs, resampler_->CurrentTargetBufferSize() * toMs, resampler_->DroppedExcess() * toMs); } @@ -196,15 +220,40 @@ private: // Stalled (the debugger, say), start over rather than pulling a backlog. next = now; } - const int buffered = resampler_->BufferedSamples(); - const int mixed = resampler_->Mix(out.data(), periodFrames_, false, SAMPLE_RATE); + int buffered; + bool isShort; + if (granular_) { + buffered = granular_->QueuedSamples(); + const int underrunsBefore = granular_->Underruns(); + const int overrunsBefore = granular_->Overruns(); + // As the app does, see NativeMix. + float vps, fpsEstimate, actualFps; + __DisplayGetFPS(&vps, &fpsEstimate, &actualFps); + granular_->Mix(out.data(), periodFrames_, SAMPLE_RATE, fpsEstimate); + const int newUnderruns = granular_->Underruns() - underrunsBefore; + isShort = newUnderruns > 0; + if (counting_) { + underruns_ += newUnderruns; + overruns_ += granular_->Overruns() - overrunsBefore; + const int cap = granular_->QueueCapSamples(); + sumCap_ += cap; + minCap_ = std::min(minCap_, cap); + maxCap_ = std::max(maxCap_, cap); + } + } else { + buffered = resampler_->BufferedSamples(); + const int mixed = resampler_->Mix(out.data(), periodFrames_, false, SAMPLE_RATE); + isShort = mixed < periodFrames_; + if (counting_ && isShort) { + missingFrames_ += periodFrames_ - mixed; + } + } if (!counting_) { continue; } pulls_++; - if (mixed < periodFrames_) { + if (isShort) { shortPulls_++; - missingFrames_ += periodFrames_ - mixed; } sumBuffered_ += buffered; minBuffered_ = std::min(minBuffered_, buffered); @@ -213,6 +262,7 @@ private: } std::unique_ptr resampler_; + std::unique_ptr granular_; std::thread thread_; std::atomic stop_{}; std::atomic counting_{}; @@ -223,6 +273,12 @@ private: int64_t sumBuffered_ = 0; int minBuffered_ = INT_MAX; int maxBuffered_ = 0; + // Granular only. + int underruns_ = 0; + int overruns_ = 0; + int64_t sumCap_ = 0; + int minCap_ = INT_MAX; + int maxCap_ = 0; }; static AudioSink g_audioSink; @@ -1645,7 +1701,7 @@ int main(int argc, const char* argv[]) { if (cmdLineOptions.audioSinkPeriod.has_value() && cmdLineOptions.audioSinkPeriod.value() > 0) { // Sound on also changes how the frame throttle waits (DoFrameIdleTiming), as in the app. g_Config.bEnableSound = true; - g_audioSink.Start(cmdLineOptions.audioSinkPeriod.value()); + g_audioSink.Start(cmdLineOptions.audioSinkPeriod.value(), g_Config.iAudioPlaybackMode == (int)AudioSyncMode::GRANULAR); } if (cmdLineOptions.replayEnd.has_value()) { GPURecord::SetReplayDrawLimit(cmdLineOptions.replayEnd.value());