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};