From 32028cb2868ef4eb897584efe0debe479377480d Mon Sep 17 00:00:00 2001 From: BlackMark Date: Mon, 22 Jun 2026 01:59:11 +0200 Subject: [PATCH] Audio: per-game persisted format overrides + auto-learn Persist audio overrides keyed by game image name (coop_audio_overrides.ini next to the exe) so a known-bad game is auto-corrected on its next launch: on attach to a guessed stream the host applies any saved override, and a manual Override now saves too. A format the hook catches exactly at IAudioClient::Initialize is auto-saved as that game's override (ground truth); if it overwrites a differing stored value, a warning is logged. Host-originated log lines now reach the Log window via IpcServer::host_log (color-coded). Validated live (harness): a pre-seeded override for coop_tone is auto-applied over the guess (state -> manual override, 48000/2/16). audio_overrides_test covers persist/reload/case-insensitive lookup/differing-overwrite. Trim README. Co-Authored-By: Claude Opus 4.8 --- README.md | 17 ++-- host/CMakeLists.txt | 1 + host/src/audio/audio_overrides.cpp | 153 +++++++++++++++++++++++++++++ host/src/audio/audio_overrides.hpp | 68 +++++++++++++ host/src/audio_panel.cpp | 107 +++++++++++++++++++- host/src/audio_panel.hpp | 28 ++++++ host/src/injection_panel.hpp | 7 ++ host/src/ipc/ipc_server.cpp | 9 ++ host/src/ipc/ipc_server.hpp | 4 + host/src/main.cpp | 3 + tests/CMakeLists.txt | 10 ++ tests/audio_overrides_test.cpp | 88 +++++++++++++++++ 12 files changed, 483 insertions(+), 12 deletions(-) create mode 100644 host/src/audio/audio_overrides.cpp create mode 100644 host/src/audio/audio_overrides.hpp create mode 100644 tests/audio_overrides_test.cpp diff --git a/README.md b/README.md index 5c28fdf..1057d74 100644 --- a/README.md +++ b/README.md @@ -79,8 +79,10 @@ and covers anything the hooked path doesn't (Vulkan, D3D9 — see Roadmap). loopback fallback is always format-correct. The Audio panel shows each stream's format provenance (*known* / *measuring* / *measured rate* / *low-confidence* / *override*) so the assumption is visible, and (under Debug details) lets the operator - **re-measure** the rate or **override** the format when the guess is wrong. Streams - created *after* injection are captured exactly. + **re-measure** the rate or **override** the format when the guess is wrong. Overrides + are **remembered per game** (and a format caught exactly at `Initialize` is auto-saved + as that game's override), so a known-bad game is corrected automatically next launch. + Streams created *after* injection are captured exactly. - **Debug-oriented UI:** the ImGui overlay is laid out for diagnosing the pipeline, not for end use. F1 hides it entirely so the window is a clean mirror for RPT; F2 frees the operator cursor; **F10 saves a PNG screenshot** (back buffer, @@ -90,14 +92,6 @@ and covers anything the hooked path doesn't (Vulkan, D3D9 — see Roadmap). ### Planned (next up) -- **Per-game persisted audio overrides + auto-learn.** The Audio panel already lets - the operator re-measure or override a stream's format (Debug details → *Fix the - primary stream*), but the override is session-only. Persist overrides **keyed by game - image name** to a small store next to the exe so a known-bad game is auto-corrected on - its next launch, and **auto-save a format caught exactly at `IAudioClient::Initialize` - as that game's override** (ground truth) so a later late-attach is corrected - automatically. If a caught format overwrites a stored override that *differs*, log a - warning. - **Auto re-attach the same game on relaunch (session-only).** A checkbox on the attached (or just-terminated) target, default off, *not* persisted, **always visible** (it's the recommended recovery path, not a diagnostic). While on and the target has @@ -191,6 +185,9 @@ ctest --test-dir build -C Debug --output-on-failure converges to the right standard rate, rejects burst windows (never commits to a wrong neighbour, incl. the real 46205 misread), flags a genuinely non-standard rate low-confidence instead of spinning, and ignores idle windows. Pure logic, no device. +- **`audio_overrides_test`** — unit test of the per-game audio override store + (persist/reload, case-insensitive lookup by image name, and the differing-overwrite + detection that drives the warning). No device. - **`audio_hook_test`** — in-process self-test of the WASAPI render-hook's **format detection**, the part that gets pitch right. Using a shared configurable `ToneSource` (the same render helper `coop_tone` uses), it renders tones at a matrix diff --git a/host/CMakeLists.txt b/host/CMakeLists.txt index 6fefa05..291b729 100644 --- a/host/CMakeLists.txt +++ b/host/CMakeLists.txt @@ -19,6 +19,7 @@ add_executable(coop_host WIN32 src/capture/window_capture.cpp src/capture/shared_texture.cpp src/audio/audio_loopback.cpp + src/audio/audio_overrides.cpp src/audio/process_loopback_capture.cpp src/test_harness.cpp) diff --git a/host/src/audio/audio_overrides.cpp b/host/src/audio/audio_overrides.cpp new file mode 100644 index 0000000..0735409 --- /dev/null +++ b/host/src/audio/audio_overrides.cpp @@ -0,0 +1,153 @@ +#include "audio/audio_overrides.hpp" + +#include +#include +#include + +#include + +#include // WAVE_FORMAT_* (needs windows.h first) + +#include "coop/tool_paths.hpp" + +namespace coop +{ +namespace +{ +std::wstring to_lower(std::wstring s) +{ + for (wchar_t& c : s) + { + c = static_cast(::towlower(c)); + } + return s; +} + +std::string narrow(const std::wstring& w) +{ + std::string s; + s.reserve(w.size()); + for (wchar_t c : w) // image names + our format tokens are ASCII + { + s.push_back(static_cast(c & 0x7F)); + } + return s; +} + +std::wstring widen(const std::string& s) +{ + std::wstring w; + w.reserve(s.size()); + for (char c : s) + { + w.push_back(static_cast(static_cast(c))); + } + return w; +} +} // namespace + +AudioOverrideStore::AudioOverrideStore(std::wstring path) : path_(std::move(path)) +{ + if (path_.empty()) + { + path_ = exe_directory() + L"coop_audio_overrides.ini"; + } +} + +std::wstring AudioOverrideStore::key_of(const std::wstring& image_name) +{ + const std::size_t slash = image_name.find_last_of(L"\\/"); + return to_lower(slash == std::wstring::npos ? image_name : image_name.substr(slash + 1)); +} + +void AudioOverrideStore::load() +{ + map_.clear(); + std::ifstream f(path_.c_str()); + if (!f) + { + return; + } + std::string line; + while (std::getline(f, line)) + { + // " = "; skip blank lines and # comments. + const std::size_t hash = line.find('#'); + if (hash != std::string::npos) + { + line.resize(hash); + } + const std::size_t eq = line.find('='); + if (eq == std::string::npos) + { + continue; + } + std::string name = line.substr(0, eq); + // trim trailing/leading whitespace from the name + while (!name.empty() && std::isspace(static_cast(name.back()))) + { + name.pop_back(); + } + std::size_t b = 0; + while (b < name.size() && std::isspace(static_cast(name[b]))) + { + ++b; + } + name = name.substr(b); + if (name.empty()) + { + continue; + } + std::istringstream vs(line.substr(eq + 1)); + AudioFormatOverride fmt; + std::string tag; + vs >> fmt.rate >> fmt.channels >> fmt.bits >> tag; + if (!fmt.valid()) + { + continue; + } + fmt.format_tag = (tag == "float") ? WAVE_FORMAT_IEEE_FLOAT : WAVE_FORMAT_PCM; + map_[key_of(widen(name))] = fmt; + } +} + +bool AudioOverrideStore::find(const std::wstring& image_name, AudioFormatOverride& out) const +{ + const auto it = map_.find(key_of(image_name)); + if (it == map_.end()) + { + return false; + } + out = it->second; + return true; +} + +void AudioOverrideStore::set(const std::wstring& image_name, const AudioFormatOverride& fmt, bool* differed) +{ + const std::wstring key = key_of(image_name); + if (differed != nullptr) + { + const auto it = map_.find(key); + *differed = (it != map_.end() && it->second != fmt); + } + map_[key] = fmt; + save(); +} + +void AudioOverrideStore::save() const +{ + std::ofstream f(path_.c_str(), std::ios::trunc); + if (!f) + { + return; + } + f << "# CoopAllTheThings per-game audio format overrides (auto-managed)\n"; + f << "# = \n"; + for (const auto& [name, fmt] : map_) + { + f << narrow(name) << " = " << fmt.rate << ' ' << fmt.channels << ' ' << fmt.bits << ' ' + << (fmt.format_tag == WAVE_FORMAT_IEEE_FLOAT ? "float" : "pcm") << '\n'; + } +} + +} // namespace coop diff --git a/host/src/audio/audio_overrides.hpp b/host/src/audio/audio_overrides.hpp new file mode 100644 index 0000000..2da8dca --- /dev/null +++ b/host/src/audio/audio_overrides.hpp @@ -0,0 +1,68 @@ +// Per-game audio-format overrides, persisted next to the exe so a game whose hooked +// format was wrong/unrecoverable is auto-corrected on its next launch. Keyed by the +// game's image name (case-insensitive). Two writers: the operator's manual Override, and +// auto-learning a format the hook caught exactly at IAudioClient::Initialize (ground +// truth). A human-readable one-line-per-game text file: +// +// # CoopAllTheThings per-game audio overrides +// brotato.exe = 44100 2 32 float +// snb.exe = 48000 2 16 pcm +#pragma once + +#include +#include +#include + +namespace coop +{ + +struct AudioFormatOverride +{ + std::uint32_t rate = 0; + std::uint32_t channels = 0; + std::uint32_t bits = 0; + std::uint32_t format_tag = 0; // WAVE_FORMAT_PCM (1) / WAVE_FORMAT_IEEE_FLOAT (3) + + [[nodiscard]] bool valid() const + { + return rate != 0 && channels != 0 && bits != 0; + } + bool operator==(const AudioFormatOverride& o) const + { + return rate == o.rate && channels == o.channels && bits == o.bits && format_tag == o.format_tag; + } + bool operator!=(const AudioFormatOverride& o) const + { + return !(*this == o); + } +}; + +class AudioOverrideStore +{ +public: + // `path` empty -> default (exe_dir/coop_audio_overrides.ini). Does not load yet. + explicit AudioOverrideStore(std::wstring path = {}); + + void load(); // (re)read the file; missing file is fine (empty store) + + // Look up an override by image name (case-insensitive, basename). False if none. + [[nodiscard]] bool find(const std::wstring& image_name, AudioFormatOverride& out) const; + + // Store image_name -> fmt and persist immediately. Sets *differed = true when an + // existing entry for that game differed from `fmt` (caller warns the operator). + void set(const std::wstring& image_name, const AudioFormatOverride& fmt, bool* differed = nullptr); + + [[nodiscard]] const std::wstring& path() const + { + return path_; + } + +private: + static std::wstring key_of(const std::wstring& image_name); // lowercased basename + void save() const; + + std::wstring path_; + std::map map_; +}; + +} // namespace coop diff --git a/host/src/audio_panel.cpp b/host/src/audio_panel.cpp index 41f4465..00b92e0 100644 --- a/host/src/audio_panel.cpp +++ b/host/src/audio_panel.cpp @@ -1,6 +1,7 @@ #include "audio_panel.hpp" #include +#include #include "imgui.h" @@ -68,8 +69,101 @@ ImVec4 audio_format_state_color(std::uint32_t state) } } +// Basename of an image path, narrowed to ASCII for a log line. +std::string image_basename(const std::wstring& image_path) +{ + const std::size_t slash = image_path.find_last_of(L"\\/"); + const std::wstring w = slash == std::wstring::npos ? image_path : image_path.substr(slash + 1); + std::string out; + out.reserve(w.size()); + for (wchar_t c : w) + { + out.push_back(static_cast(c & 0x7F)); + } + return out; +} + } // namespace +std::wstring AudioPanel::image_name_from_pid(DWORD pid) +{ + if (pid == 0) + { + return {}; + } + HANDLE h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid); + if (h == nullptr) + { + return {}; + } + wchar_t buf[MAX_PATH] = {}; + DWORD n = MAX_PATH; + std::wstring name; + if (QueryFullProcessImageNameW(h, 0, buf, &n)) + { + name.assign(buf, n); + } + CloseHandle(h); + return name; +} + +void AudioPanel::manage_overrides(const HookStatusView& status, DWORD pid) +{ + if (pid != applied_pid_) // new target -> resolve its image name and reset session flags + { + applied_pid_ = pid; + target_image_ = image_name_from_pid(pid); + override_applied_ = false; + exact_saved_ = false; + } + if (pid == 0 || target_image_.empty() || !mirror_.running() || status.audio_streams_seen == 0) + { + return; + } + + const AudioStreamInfo& s = status.audio_streams[0]; // the primary (mirrored) stream + const std::uint32_t st = s.format_state; + if (st == AudioFormat_Exact) + { + // Ground truth: persist it as this game's override (so a later late-attach is fixed). + if (!exact_saved_) + { + exact_saved_ = true; + const AudioFormatOverride fmt{s.sample_rate, s.channels, s.bits, s.format_tag}; + bool differed = false; + overrides_.set(target_image_, fmt, &differed); + if (differed && logger_) + { + char msg[160]; + std::snprintf(msg, sizeof(msg), + "%s: exact format %uHz/%uch/%ubit caught -> replaced a DIFFERING saved override", + image_basename(target_image_).c_str(), fmt.rate, fmt.channels, fmt.bits); + logger_(LogLevel_Warn, msg); + } + } + } + else if (st == AudioFormat_Measuring || st == AudioFormat_Measured || st == AudioFormat_LowConfidence) + { + // A guessed stream: if we have a saved override for this game, apply it. + if (!override_applied_) + { + override_applied_ = true; + AudioFormatOverride ov; + if (overrides_.find(target_image_, ov)) + { + mirror_.request_op(0, AudioRingOp_Override, ov.rate, ov.channels, ov.bits, ov.format_tag); + if (logger_) + { + char msg[160]; + std::snprintf(msg, sizeof(msg), "%s: applied saved audio override %uHz/%uch/%ubit", + image_basename(target_image_).c_str(), ov.rate, ov.channels, ov.bits); + logger_(LogLevel_Info, msg); + } + } + } + } +} + void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details) { DWORD pid = 0; @@ -110,6 +204,8 @@ void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details) mirror_.stop(); } + manage_overrides(status, pid); // auto-apply a saved override / auto-save a caught format + if (mirror_.running()) { const AudioMirror::Source src = mirror_.source(); @@ -293,8 +389,15 @@ void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details) const std::uint32_t tag = ov_fmt_idx_ == 1 ? static_cast(WAVE_FORMAT_IEEE_FLOAT) : static_cast(WAVE_FORMAT_PCM); - mirror_.request_op(0, AudioRingOp_Override, static_cast(ov_rate_), - static_cast(ov_channels_), bits, tag); + const AudioFormatOverride fmt{static_cast(ov_rate_), + static_cast(ov_channels_), bits, tag}; + mirror_.request_op(0, AudioRingOp_Override, fmt.rate, fmt.channels, fmt.bits, fmt.format_tag); + // Persist it for this game so the correction sticks across launches. + if (!target_image_.empty()) + { + overrides_.set(target_image_, fmt); + override_applied_ = true; // don't let manage_overrides re-apply an older saved value + } } } diff --git a/host/src/audio_panel.hpp b/host/src/audio_panel.hpp index 5d82bc0..d9a9c16 100644 --- a/host/src/audio_panel.hpp +++ b/host/src/audio_panel.hpp @@ -4,10 +4,13 @@ #pragma once #include +#include +#include #include #include "audio/audio_loopback.hpp" +#include "audio/audio_overrides.hpp" #include "ipc/ipc_server.hpp" namespace coop @@ -16,6 +19,11 @@ namespace coop class AudioPanel { public: + AudioPanel() + { + overrides_.load(); + } + // The window whose process audio to mirror (0 if none); typically the // injected game's HWND. void set_target(HWND target) @@ -23,6 +31,13 @@ public: target_ = target; } + // Wire a sink for host-side log lines (override-overwrite warnings etc.). main + // connects this to the injection panel's Log-window channel. + void set_logger(std::function logger) + { + logger_ = std::move(logger); + } + // `status` is the hook's back-channel, for the render-stream view. With // `debug_details` on, the per-stream table is shown. void draw_ui(const HookStatusView& status, bool debug_details); @@ -66,11 +81,24 @@ public: #endif private: + // Auto-apply a stored override over a guessed stream, and auto-save a format the hook + // caught exactly at Initialize (warning if it overwrites a differing stored value). + void manage_overrides(const HookStatusView& status, DWORD pid); + static std::wstring image_name_from_pid(DWORD pid); + HWND target_ = nullptr; DWORD dev_pid_ = 0; // test harness only: force a (windowless) target pid; 0 in production bool enabled_ = false; AudioMirror mirror_; + // Per-game persisted overrides + per-target session bookkeeping. + AudioOverrideStore overrides_; + std::function logger_; + DWORD applied_pid_ = 0; // pid the session flags below were reset for + std::wstring target_image_; // current target's image path (store keys on the basename) + bool override_applied_ = false; // applied (or confirmed none) the stored override this session + bool exact_saved_ = false; // saved the exact Initialize format this session + // Per-stream activity tracking for the "live" column. Audio buffers release in // bursts, so most UI frames see no change; comparing to just the previous frame // flickers. Instead we remember when each stream last advanced and debounce the diff --git a/host/src/injection_panel.hpp b/host/src/injection_panel.hpp index 204f041..0484ea3 100644 --- a/host/src/injection_panel.hpp +++ b/host/src/injection_panel.hpp @@ -94,6 +94,13 @@ public: server_.drain_logs(std::forward(emit)); } + // Emit a host-side line into the Log window (color-coded by level), e.g. an + // override-overwrite warning. No-op if not connected. + void host_log(std::uint32_t level, const char* text) + { + server_.host_log(level, text); + } + // --- Present-hook video path (consumed by the Video mirror panel) ---------- [[nodiscard]] unsigned long target_pid() const diff --git a/host/src/ipc/ipc_server.cpp b/host/src/ipc/ipc_server.cpp index 95855e7..024ba8e 100644 --- a/host/src/ipc/ipc_server.cpp +++ b/host/src/ipc/ipc_server.cpp @@ -126,6 +126,15 @@ void IpcServer::set_subsystem_enabled(std::uint32_t subsystem, bool enabled) } } +void IpcServer::host_log(std::uint32_t level, const char* text) +{ + std::scoped_lock lock(mutex_); + if (log_ring_ != nullptr) + { + log_ring_push(*log_ring_, GetCurrentProcessId(), level, GetTickCount64(), text); + } +} + void IpcServer::stop() { std::scoped_lock lock(mutex_); diff --git a/host/src/ipc/ipc_server.hpp b/host/src/ipc/ipc_server.hpp index 6a7b76c..34c66ba 100644 --- a/host/src/ipc/ipc_server.hpp +++ b/host/src/ipc/ipc_server.hpp @@ -113,6 +113,10 @@ public: } } + // Push a host-originated line into the shared log ring (level = LogLevel), so it + // shows color-coded in the Log window next to the hook's lines. No-op if not started. + void host_log(std::uint32_t level, const char* text); + [[nodiscard]] bool running() const { return block_ != nullptr; diff --git a/host/src/main.cpp b/host/src/main.cpp index b4d038d..2b0a1d4 100644 --- a/host/src/main.cpp +++ b/host/src/main.cpp @@ -285,6 +285,9 @@ int run() return 1; } capture.set_injection(&injection); // for the Present-hook (Hooked) video source + // Route the audio panel's host-side notices (override-overwrite warnings, applied + // saved overrides) into the Log window, color-coded by level. + audio.set_logger([&injection](std::uint32_t level, const char* text) { injection.host_log(level, text); }); // Controller polling + forwarding runs on its own thread so the render frame rate // (which can drop, especially with frame-sync) never throttles input. XInput is the diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index cdab72e..706a5c2 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -36,6 +36,15 @@ add_executable(rate_estimator_test rate_estimator_test.cpp) target_include_directories(rate_estimator_test PRIVATE ${CMAKE_SOURCE_DIR}/hook/src) add_test(NAME rate_estimator_test COMMAND rate_estimator_test) +# Unit test for the per-game audio override store (persist/reload, case-insensitive +# lookup, differing-overwrite detection). Reuses the shipping source. No device. +add_executable(audio_overrides_test + audio_overrides_test.cpp + ${CMAKE_SOURCE_DIR}/host/src/audio/audio_overrides.cpp) +target_include_directories(audio_overrides_test PRIVATE ${CMAKE_SOURCE_DIR}/host/src) +target_link_libraries(audio_overrides_test PRIVATE coop_common) +add_test(NAME audio_overrides_test COMMAND audio_overrides_test) + # Unit test for the host->game mouse coordinate mapping (letterbox inverse + # decorated-window client offset). Header-only, no device. add_executable(mkb_map_test mkb_map_test.cpp) @@ -163,6 +172,7 @@ coop_output_subdir(tests mkb_map_test audio_mix_test rate_estimator_test + audio_overrides_test audio_loopback_test audio_hook_test srgb_format_test diff --git a/tests/audio_overrides_test.cpp b/tests/audio_overrides_test.cpp new file mode 100644 index 0000000..152e0a0 --- /dev/null +++ b/tests/audio_overrides_test.cpp @@ -0,0 +1,88 @@ +// Unit test for the per-game audio override store (save/load round-trip, case-insensitive +// lookup by image name, and the differing-overwrite detection that drives the warning). +#include +#include +#include + +#include + +#include + +#include "audio/audio_overrides.hpp" + +using namespace coop; + +namespace +{ +int g_failures = 0; +void check(bool ok, const char* what) +{ + if (!ok) + { + std::printf("FAIL: %s\n", what); + ++g_failures; + } + else + { + std::printf(" ok: %s\n", what); + } +} + +std::wstring temp_path() +{ + wchar_t dir[MAX_PATH] = {}; + GetTempPathW(MAX_PATH, dir); + return std::wstring(dir) + L"coop_overrides_test_" + std::to_wstring(GetCurrentProcessId()) + L".ini"; +} +} // namespace + +int main() +{ + const std::wstring path = temp_path(); + DeleteFileW(path.c_str()); + + const AudioFormatOverride brotato{44100, 2, 32, WAVE_FORMAT_IEEE_FLOAT}; + const AudioFormatOverride snb{48000, 2, 16, WAVE_FORMAT_PCM}; + + { + AudioOverrideStore store(path); + store.load(); // missing file -> empty + AudioFormatOverride got; + check(!store.find(L"brotato.exe", got), "empty store: no entry"); + + bool differed = true; + store.set(L"C:\\games\\Brotato.exe", brotato, &differed); // full path -> basename key + check(!differed, "first set: not a differing overwrite"); + store.set(L"snb.exe", snb, &differed); + check(!differed, "new game set: not a differing overwrite"); + + // Same value again -> not differing. + store.set(L"brotato.exe", brotato, &differed); + check(!differed, "identical re-set: not differing"); + + // Different value -> differing (drives the warning). + const AudioFormatOverride brotato2{48000, 2, 32, WAVE_FORMAT_IEEE_FLOAT}; + store.set(L"brotato.exe", brotato2, &differed); + check(differed, "changed value: flagged as differing overwrite"); + } + + // Reload from disk in a fresh store: persistence + case-insensitive basename lookup. + { + AudioOverrideStore store(path); + store.load(); + AudioFormatOverride got; + check(store.find(L"BROTATO.EXE", got), "reload: found case-insensitively"); + check(got == AudioFormatOverride{48000, 2, 32, WAVE_FORMAT_IEEE_FLOAT}, "reload: latest value persisted"); + check(store.find(L"D:\\steam\\snb.exe", got) && got == snb, "reload: found by full path basename"); + check(!store.find(L"unknown.exe", got), "reload: unknown game absent"); + } + + DeleteFileW(path.c_str()); + if (g_failures == 0) + { + std::printf("PASS audio_overrides_test\n"); + return 0; + } + std::printf("FAILED audio_overrides_test (%d)\n", g_failures); + return 1; +}