Audio: operator re-measure + format override (host<->hook op channel)

Add a per-stream op channel in AudioRingHeader (op_seq + op_* fields, version
2): the host posts re-measure / override commands, the hook applies them and
re-publishes (bumping format_generation), and the host rebuilds its render
client live on the change. The Audio panel (under Debug details) gains a
"Re-measure rate" button and a rate/channels/bit-depth/format override -- for
when detection is wrong or the channels/bit-depth were unrecoverable.

Also add a debug-only IPC test harness (-DCOOP_TEST_HARNESS, off by default,
absent from the shipped host): a file-based command channel that drives the
host's real UI code paths (inject / audio / re-measure / override / screenshot
/ status) for scripted validation, instead of unreliable synthetic mouse input.
Used to validate live: late-attach to coop_tone@44100 -> measured 44100,
promoted to hooked; override -> 2ch state, re-measure -> reconverge.

Trim the README roadmap to what's left; document the harness + rate_estimator_test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 01:46:59 +02:00
parent c24bfdd64f
commit 90f40ae479
13 changed files with 607 additions and 111 deletions

View File

@@ -140,6 +140,34 @@ void AudioMirror::enable_capture(AudioRingHeader* const* rings, bool on)
}
}
void AudioMirror::request_op(unsigned slot, std::uint32_t kind, std::uint32_t rate, std::uint32_t channels,
std::uint32_t bits, std::uint32_t format_tag)
{
if (slot >= kMaxAudioStreams)
{
return;
}
std::lock_guard<std::mutex> lock(ops_mutex_);
pending_ops_.push_back({slot, kind, rate, channels, bits, format_tag});
}
void AudioMirror::drain_ops()
{
std::vector<PendingOp> ops;
{
std::lock_guard<std::mutex> lock(ops_mutex_);
ops.swap(pending_ops_);
}
for (const PendingOp& op : ops)
{
AudioRingHeader* ring = (op.slot < kMaxAudioStreams) ? session_rings_[op.slot] : nullptr;
if (ring != nullptr)
{
audio_ring_post_op(*ring, op.kind, op.rate, op.channels, op.bits, op.format_tag);
}
}
}
bool AudioMirror::start(DWORD pid)
{
stop();
@@ -228,6 +256,7 @@ void AudioMirror::thread_main(DWORD pid)
audio_ring_init(*rings[i], kAudioRingCapacity);
created_primary = created_primary || (i == 0);
}
session_rings_[i] = rings[i]; // visible to drain_ops on this (audio) thread
}
if (!created_primary)
@@ -259,10 +288,15 @@ void AudioMirror::thread_main(DWORD pid)
if (wait_for_format(rings[0], kHookWaitMs))
{
set_fallback_reason({}); // hooked path is taking over
if (run_hooked(rings))
const HookedResult r = run_hooked(rings);
if (r == HookedResult::Stopped)
{
break; // ran to a clean stop
}
if (r == HookedResult::Reinit)
{
continue; // hook re-published (re-measure / override) -> re-read the new format
}
if (stop_requested())
{
break;
@@ -289,6 +323,10 @@ void AudioMirror::thread_main(DWORD pid)
}
enable_capture(rings, false);
for (unsigned i = 0; i < kMaxAudioStreams; ++i)
{
session_rings_[i] = nullptr; // audio thread owns this; cleared before unmapping
}
for (auto& shm : audio_ring_shm_)
{
shm.reset();
@@ -310,23 +348,15 @@ void AudioMirror::thread_main(DWORD pid)
// Consume the render-hook's shared ring and re-render the game's frames. The
// game is silenced locally by the hook, so the operator hears no echo. Returns
// true if it ran to a clean stop; false on setup failure (caller falls back).
bool AudioMirror::run_hooked(AudioRingHeader* const* rings)
AudioMirror::HookedResult AudioMirror::run_hooked(AudioRingHeader* const* rings)
{
AudioRingHeader* primary = rings[0];
enable_capture(rings, true); // hook silences the game + pushes frames into the rings
auto disable_all = [&] {
for (unsigned i = 0; i < kMaxAudioStreams; ++i)
{
if (rings[i] != nullptr)
{
rings[i]->capture_enabled.store(0, std::memory_order_release);
}
}
};
auto fail_to_loopback = [&] {
disable_all(); // let the game play locally again
return false;
};
// Snapshot the format generation up front; if the hook re-publishes (operator
// re-measure / override) it bumps, and we tear down + return Reinit so the caller
// re-reads the new format and rebuilds the render client.
const std::uint32_t start_gen = primary->format_generation.load(std::memory_order_acquire);
const unsigned rate = primary->sample_rate;
const unsigned channels = primary->channels;
@@ -335,7 +365,8 @@ bool AudioMirror::run_hooked(AudioRingHeader* const* rings)
const unsigned block_align = primary->block_align ? primary->block_align : channels * (bits / 8);
if (rate == 0 || channels == 0 || block_align == 0)
{
return fail_to_loopback();
enable_capture(rings, false); // let the game play locally again
return HookedResult::Failed;
}
// Reconstruct the game's WAVEFORMATEX and let shared-mode WASAPI convert it
@@ -379,6 +410,7 @@ bool AudioMirror::run_hooked(AudioRingHeader* const* rings)
IAudioRenderClient* render = nullptr;
HANDLE render_event = nullptr;
bool started = false;
HookedResult result = HookedResult::Stopped;
auto fail = [&](const char* msg, HRESULT hr) {
char buf[160];
@@ -477,6 +509,12 @@ bool AudioMirror::run_hooked(AudioRingHeader* const* rings)
{
break; // stop requested
}
drain_ops(); // post any queued operator ops (re-measure / override) to the hook
if (primary->format_generation.load(std::memory_order_acquire) != start_gen)
{
result = HookedResult::Reinit; // hook re-published -> re-read the new format
break;
}
UINT32 padding = 0;
if (FAILED(render_client->GetCurrentPadding(&padding)))
@@ -552,7 +590,12 @@ bool AudioMirror::run_hooked(AudioRingHeader* const* rings)
render_client->Stop();
} while (false);
disable_all(); // game audible again on stop
// On a re-init (format changed) keep capturing so the rebuilt render client picks up
// seamlessly; otherwise free the game's local playback (stop / fall back to loopback).
if (result != HookedResult::Reinit)
{
enable_capture(rings, false);
}
if (render)
{
@@ -577,11 +620,11 @@ bool AudioMirror::run_hooked(AudioRingHeader* const* rings)
if (!started)
{
// Never got a working render client; let the caller try loopback. Leave
// capture disabled (already cleared above) so loopback hears the game.
return false;
// Never got a working render client; let the caller try loopback. Capture is
// already disabled above so loopback hears the game.
return HookedResult::Failed;
}
return true;
return result;
}
bool AudioMirror::run_loopback(DWORD pid, AudioRingHeader* promote_ring)
@@ -713,6 +756,7 @@ bool AudioMirror::run_loopback(DWORD pid, AudioRingHeader* promote_ring)
set_status(capture.status());
break;
}
drain_ops(); // operator ops (re-measure / override) reach the hook even on loopback
// Auto-promote: the hook published a format -> hand back so the caller switches
// to the no-echo hooked path (the rings stayed live the whole time).
if (promote_ring != nullptr && audio_ring_format_ready(*promote_ring))

View File

@@ -14,6 +14,7 @@
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include <windows.h>
@@ -99,18 +100,32 @@ public:
// panel. Empty when on the hooked path or before any fallback decision.
[[nodiscard]] std::string fallback_reason() const;
// Post an operator command (AudioRingOp) to the hook for stream `slot` -- re-measure
// the rate or override the format. Thread-safe; queued and applied to the ring on the
// audio thread (which owns the ring mappings). For a re-measure the format args are 0.
void request_op(unsigned slot, std::uint32_t kind, std::uint32_t rate = 0, std::uint32_t channels = 0,
std::uint32_t bits = 0, std::uint32_t format_tag = 0);
private:
void thread_main(DWORD pid);
// Returns true if it owned the session to a clean stop; false if setup failed
// and the caller should fall back to the loopback path. `rings[0]` is the primary
// stream; additional non-null rings are mixed in.
bool run_hooked(AudioRingHeader* const* rings);
// Outcome of a hooked render session.
enum class HookedResult
{
Stopped, // clean stop (mirror stopping) -> done
Failed, // setup failed (format not renderable) -> caller falls back to loopback
Reinit, // the hook re-published the format (re-measure/override) -> re-read and retry
};
// Runs the hooked (no-echo) render path until stop, a setup failure, or a format change
// (re-measure/override). `rings[0]` is the primary stream; additional non-null rings are
// mixed in.
HookedResult run_hooked(AudioRingHeader* const* rings);
// Loopback (echo) capture. If `promote_ring` is non-null, returns true the moment
// that ring's format becomes ready (the hook caught up -> caller promotes to hooked);
// returns false when stopped. With a null ring it only returns false (on stop).
bool run_loopback(DWORD pid, AudioRingHeader* promote_ring);
bool wait_for_format(AudioRingHeader* ring, DWORD timeout_ms);
static void enable_capture(AudioRingHeader* const* rings, bool on);
void drain_ops(); // audio thread: post queued operator ops to the session rings
bool stop_requested() const;
void set_status(std::string s);
void set_fallback_reason(std::string s);
@@ -120,6 +135,17 @@ private:
DWORD pid_ = 0;
SharedMemory audio_ring_shm_[kMaxAudioStreams]; // per-stream rings (coop_audio_<pid>[_<i>])
AudioRingHeader* session_rings_[kMaxAudioStreams] = {}; // set on the audio thread for the session
// Operator ops queued by request_op (any thread) and applied to the rings on the
// audio thread (which owns the mappings). Guarded by ops_mutex_.
struct PendingOp
{
unsigned slot;
std::uint32_t kind, rate, channels, bits, format_tag;
};
std::mutex ops_mutex_;
std::vector<PendingOp> pending_ops_;
std::atomic<bool> running_{false};
std::atomic<Source> source_{Source::None};

View File

@@ -72,12 +72,16 @@ ImVec4 audio_format_state_color(std::uint32_t state)
void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details)
{
const bool have_target = target_ != nullptr && IsWindow(target_);
DWORD pid = 0;
if (have_target)
if (target_ != nullptr && IsWindow(target_))
{
GetWindowThreadProcessId(target_, &pid);
}
if (dev_pid_ != 0)
{
pid = dev_pid_; // test harness: a windowless target (e.g. coop_tone) has no HWND
}
const bool have_target = pid != 0;
apply_panel_layout(Panel::Audio);
ImGui::Begin("Audio mirror");
@@ -256,6 +260,44 @@ void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details)
rate_base_time_ = now;
}
// --- Operator controls: re-measure / override the primary stream's format -----
// For when detection is wrong (re-measure) or unrecoverable (override the channels/
// bit-depth the hook had to assume). Only meaningful while mirroring is active.
if (mirror_.running())
{
ImGui::SeparatorText("Fix the primary stream (debug)");
if (ImGui::Button("Re-measure rate"))
{
mirror_.request_op(0, AudioRingOp_Remeasure);
}
ImGui::SameLine();
ImGui::TextDisabled("re-run the rate measurement");
ImGui::SetNextItemWidth(110.0f);
ImGui::InputInt("Hz", &ov_rate_, 0, 0);
ImGui::SameLine();
ImGui::SetNextItemWidth(70.0f);
ImGui::InputInt("ch", &ov_channels_, 0, 0);
ImGui::SameLine();
ImGui::SetNextItemWidth(90.0f);
ImGui::Combo("##ovbits", &ov_bits_idx_, "16-bit\0" "32-bit\0");
ImGui::SameLine();
ImGui::SetNextItemWidth(80.0f);
ImGui::Combo("##ovfmt", &ov_fmt_idx_, "PCM\0" "float\0");
ImGui::SameLine();
if (ImGui::Button("Override"))
{
ov_rate_ = std::clamp(ov_rate_, 8000, 384000);
ov_channels_ = std::clamp(ov_channels_, 1, 8);
const std::uint32_t bits = ov_bits_idx_ == 0 ? 16u : 32u;
const std::uint32_t tag =
ov_fmt_idx_ == 1 ? static_cast<std::uint32_t>(WAVE_FORMAT_IEEE_FLOAT)
: static_cast<std::uint32_t>(WAVE_FORMAT_PCM);
mirror_.request_op(0, AudioRingOp_Override, static_cast<std::uint32_t>(ov_rate_),
static_cast<std::uint32_t>(ov_channels_), bits, tag);
}
}
ImGui::End();
}

View File

@@ -27,8 +27,47 @@ public:
// `debug_details` on, the per-stream table is shown.
void draw_ui(const HookStatusView& status, bool debug_details);
#ifdef COOP_TEST_HARNESS
// Test-harness hooks (debug builds only): drive the real audio code paths and read
// state back, incl. targeting a windowless process by pid (coop_tone has no window).
void dev_set_enabled(bool on)
{
enabled_ = on;
}
void dev_set_pid(DWORD pid)
{
dev_pid_ = pid;
}
void dev_request_op(unsigned slot, std::uint32_t kind, std::uint32_t rate, std::uint32_t ch,
std::uint32_t bits, std::uint32_t tag)
{
mirror_.request_op(slot, kind, rate, ch, bits, tag);
}
[[nodiscard]] bool dev_running() const
{
return mirror_.running();
}
[[nodiscard]] unsigned dev_rate() const
{
return mirror_.sample_rate();
}
[[nodiscard]] unsigned dev_channels() const
{
return mirror_.channels();
}
[[nodiscard]] std::string dev_source() const
{
return mirror_.source_name();
}
[[nodiscard]] std::string dev_reason() const
{
return mirror_.fallback_reason();
}
#endif
private:
HWND target_ = nullptr;
DWORD dev_pid_ = 0; // test harness only: force a (windowless) target pid; 0 in production
bool enabled_ = false;
AudioMirror mirror_;
@@ -41,6 +80,12 @@ private:
std::uint64_t rate_base_frames_[kMaxAudioStreams] = {};
double frames_per_s_[kMaxAudioStreams] = {};
double rate_base_time_ = 0.0;
// Operator format-override editor (debug details). Applies to the primary stream.
int ov_rate_ = 48000;
int ov_channels_ = 2;
int ov_bits_idx_ = 1; // 0 = 16-bit, 1 = 32-bit
int ov_fmt_idx_ = 1; // 0 = PCM, 1 = float
};
} // namespace coop

View File

@@ -254,6 +254,24 @@ void InjectionPanel::reattach()
inject_selected();
}
#ifdef COOP_TEST_HARNESS
unsigned long InjectionPanel::dev_inject_by_name(const std::wstring& image_name)
{
refresh_processes();
for (const ProcessEntry& e : processes_)
{
if (iequals_name(e.exe_name, image_name))
{
selected_pid_ = e.pid;
selected_name_ = e.exe_name;
inject_selected();
return injected_ ? selected_pid_ : 0;
}
}
return 0;
}
#endif
void InjectionPanel::publish(const std::array<PadInfo, kMaxPads>& pads)
{
if (!test_input_.load(std::memory_order_relaxed))

View File

@@ -45,6 +45,12 @@ public:
// focus-API counts, input-path detection); off shows a general summary.
void draw(bool debug_details);
#ifdef COOP_TEST_HARNESS
// Test harness (debug builds only): inject into the first running process whose image
// name matches. Returns the pid on success, 0 otherwise. Same path as the UI button.
unsigned long dev_inject_by_name(const std::wstring& image_name);
#endif
// Forward the latest pad snapshot to the injected hook (if connected). When
// test-input mode is on, a synthetic pattern is sent instead of `pads`.
void publish(const std::array<PadInfo, kMaxPads>& pads);

View File

@@ -11,8 +11,14 @@
#include <windows.h>
#include <mmreg.h>
#include <timeapi.h>
#ifdef COOP_TEST_HARNESS
#include <sstream>
#include <vector>
#endif
#include <winrt/Windows.Foundation.h>
#include "imgui.h"
@@ -27,6 +33,7 @@
#include "injection_panel.hpp"
#include "input/input_worker.hpp"
#include "log_panel.hpp"
#include "test_harness.hpp"
#include "ui/app_chrome.hpp"
namespace
@@ -82,6 +89,102 @@ void draw_screenshot_toast(double seconds_since, const std::string& name)
ImGui::End();
}
#ifdef COOP_TEST_HARNESS
std::wstring widen(const std::string& s)
{
if (s.empty())
{
return {};
}
const int n = MultiByteToWideChar(CP_UTF8, 0, s.c_str(), static_cast<int>(s.size()), nullptr, 0);
std::wstring w(static_cast<std::size_t>(n), L'\0');
MultiByteToWideChar(CP_UTF8, 0, s.c_str(), static_cast<int>(s.size()), w.data(), n);
return w;
}
// Run a test-harness command on the main thread, hitting the same code the UI buttons do.
// Returns a one-line response the driver reads back.
std::string apply_test_command(const std::string& cmd, coop::UiState& ui, coop::InjectionPanel& injection,
coop::AudioPanel& audio, coop::D3D11Window& window)
{
std::vector<std::string> tok;
{
std::istringstream is(cmd);
std::string t;
while (is >> t)
{
tok.push_back(t);
}
}
if (tok.empty())
{
return "empty";
}
const std::string& v = tok[0];
auto arg = [&](std::size_t i) -> std::string { return i < tok.size() ? tok[i] : std::string(); };
auto num = [&](std::size_t i) -> unsigned { return static_cast<unsigned>(std::strtoul(arg(i).c_str(), nullptr, 10)); };
if (v == "inject")
{
const unsigned long pid = injection.dev_inject_by_name(widen(arg(1)));
return pid != 0 ? ("ok pid " + std::to_string(pid)) : "fail no-process-or-inject-failed";
}
if (v == "audio")
{
const bool on = arg(1) == "on";
if (on)
{
audio.dev_set_pid(injection.target_pid());
}
audio.dev_set_enabled(on);
return "ok";
}
if (v == "debug")
{
ui.debug_details = (arg(1) == "on");
return "ok";
}
if (v == "remeasure")
{
audio.dev_request_op(num(1), coop::AudioRingOp_Remeasure, 0, 0, 0, 0);
return "ok";
}
if (v == "override")
{
const std::uint32_t tag = arg(5) == "float" ? static_cast<std::uint32_t>(WAVE_FORMAT_IEEE_FLOAT)
: static_cast<std::uint32_t>(WAVE_FORMAT_PCM);
audio.dev_request_op(num(1), coop::AudioRingOp_Override, num(2), num(3), num(4), tag);
return "ok";
}
if (v == "screenshot")
{
const std::wstring p = screenshot_path();
window.request_screenshot(p);
return "ok";
}
if (v == "quit")
{
ui.request_quit = true;
return "ok";
}
if (v == "status")
{
const coop::HookStatusView st = injection.hook_status();
const std::string reason = audio.dev_reason();
char buf[512];
std::snprintf(buf, sizeof(buf),
"audio_running=%d source=%s rate=%u ch=%u state=%u streams=%u inj_pid=%lu inj_state=%d "
"reason=%s",
audio.dev_running() ? 1 : 0, audio.dev_source().c_str(), audio.dev_rate(),
audio.dev_channels(), st.audio_streams[0].format_state, st.audio_streams_seen,
injection.target_pid(), static_cast<int>(injection.target_state()),
reason.empty() ? "-" : reason.c_str());
return buf;
}
return "unknown-command";
}
#endif // COOP_TEST_HARNESS
#ifdef COOP_WITH_STEAM
// Absolute path to the bundled Steam Input action manifest (next to the exe).
std::string steam_manifest_path()
@@ -208,6 +311,9 @@ int run()
coop::register_ui_settings(ui);
coop::FrameStats stats;
coop::TestHarness harness; // debug builds only; a no-op shim otherwise
harness.init();
// Frame-sync: the hook generation we last presented (so we wait for the next one).
std::uint32_t last_synced_gen = 0;
@@ -249,6 +355,13 @@ int run()
stats.tick(ImGui::GetIO().DeltaTime * 1000.0f);
log.pull(injection); // drain hook log lines even while the Log window is hidden
#ifdef COOP_TEST_HARNESS
if (std::string tcmd = harness.poll_command(); !tcmd.empty())
{
harness.write_response(apply_test_command(tcmd, ui, injection, audio, window));
}
#endif
if (ImGui::IsKeyPressed(ImGuiKey_F1, false))
{
show_overlay = !show_overlay;

51
host/src/test_harness.cpp Normal file
View File

@@ -0,0 +1,51 @@
#include "test_harness.hpp"
#ifdef COOP_TEST_HARNESS
#include <fstream>
#include <windows.h>
namespace coop
{
namespace
{
std::wstring temp_file(const wchar_t* name)
{
wchar_t dir[MAX_PATH] = {};
const DWORD n = GetTempPathW(MAX_PATH, dir);
return (n != 0 && n < MAX_PATH) ? std::wstring(dir) + name : std::wstring(name);
}
} // namespace
void TestHarness::init()
{
cmd_path_ = temp_file(L"coop_test_cmd.txt");
resp_path_ = temp_file(L"coop_test_resp.txt");
DeleteFileW(cmd_path_.c_str()); // drop any stale command from a previous run
DeleteFileW(resp_path_.c_str());
}
std::string TestHarness::poll_command()
{
std::ifstream f(cmd_path_.c_str()); // MSVC accepts a wide path
if (!f)
{
return {};
}
std::string line;
std::getline(f, line);
f.close();
DeleteFileW(cmd_path_.c_str()); // ack: the command has been taken
return line;
}
void TestHarness::write_response(const std::string& resp)
{
std::ofstream f(resp_path_.c_str(), std::ios::trunc);
f << resp;
}
} // namespace coop
#endif // COOP_TEST_HARNESS

43
host/src/test_harness.hpp Normal file
View File

@@ -0,0 +1,43 @@
// Debug-only IPC test harness (compiled only when COOP_TEST_HARNESS is defined; the
// shipped product never contains it). Lets a test script drive the real overlay code
// paths -- inject, enable audio, re-measure, override, etc. -- by writing a command to
// %TEMP%\coop_test_cmd.txt and reading the reply from %TEMP%\coop_test_resp.txt, instead
// of simulating mouse/keyboard input (which ImGui doesn't accept reliably via PostMessage).
//
// Protocol: the driver writes one command line to the cmd file; the host consumes it
// (deleting the cmd file to ack), runs it on the main thread (so it hits the same code
// the UI buttons do), and writes a single response line to the resp file. One command at
// a time. See tools/test_drive notes / the per-phase validation scripts.
#pragma once
#include <string>
namespace coop
{
class TestHarness
{
public:
#ifdef COOP_TEST_HARNESS
void init(); // resolve the %TEMP% file paths and clear any stale command
// Main thread: returns the next pending command line (acking by deleting the cmd
// file), or an empty string if none is waiting.
std::string poll_command();
// Main thread: write the response for the command just handled.
void write_response(const std::string& resp);
private:
std::wstring cmd_path_;
std::wstring resp_path_;
#else
// No-op shims so call sites don't need their own #ifdef.
void init() {}
std::string poll_command()
{
return {};
}
void write_response(const std::string&) {}
#endif
};
} // namespace coop