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

@@ -199,6 +199,10 @@ RateEstimator g_rate_estimator[kMaxAudioStreams] = {};
// Per-stream AudioFormatState (how its format was determined), mirrored to the host UI.
std::uint32_t g_stream_format_state[kMaxAudioStreams] = {};
// Last operator-op sequence applied per stream (host posts re-measure / override via the
// ring's op channel; we apply each new op once). Guarded by g_setup_mutex.
std::uint32_t g_last_op_seq[kMaxAudioStreams] = {};
// GetBuffer/ReleaseBuffer are paired on one thread, never nested: stash the
// pointer the game just got so ReleaseBuffer can copy it before releasing.
thread_local IAudioRenderClient* t_gb_client = nullptr;
@@ -404,6 +408,58 @@ bool publish_stream_format_locked(std::uint32_t slot)
return true;
}
// Apply an operator command (host -> hook via the ring op channel) to stream `slot`:
// re-run the rate measurement, or override the format. Both clear the ring's published
// format so publish_stream_format_locked re-publishes (bumping format_generation, which
// makes the host rebuild its render client at the new format). Caller holds g_setup_mutex.
void apply_audio_op_locked(std::uint32_t slot, const AudioRingOpCmd& cmd)
{
AudioRingHeader* ring = g_rings[slot].load(std::memory_order_acquire);
if (ring == nullptr || g_stream_formats[slot].rate == 0)
{
return; // no ring / no stream in this slot
}
if (cmd.kind == AudioRingOp_Remeasure)
{
logw("audio stream %u: operator requested re-measure", slot);
g_stream_rate_guess[slot] = true;
g_rate_estimator[slot] = RateEstimator{};
g_stream_formats[slot].rate = g_mix_format.rate; // back to the device-mix guess while measuring
g_stream_format_state[slot] = AudioFormat_Measuring;
g_streams[slot].assumed_format.store(1, std::memory_order_relaxed);
ring->format_valid.store(0, std::memory_order_release); // force re-publish after measuring
publish_stream_info_locked(slot, g_stream_formats[slot], AudioFormat_Measuring,
g_streams[slot].frames.load(std::memory_order_relaxed));
}
else if (cmd.kind == AudioRingOp_Override)
{
CapturedFormat cf;
cf.rate = cmd.rate;
cf.channels = cmd.channels;
cf.bits = cmd.bits;
cf.tag = cmd.format_tag ? cmd.format_tag : WAVE_FORMAT_PCM;
cf.block_align = cmd.channels * (cmd.bits / 8);
if (cf.rate == 0 || cf.channels == 0 || cf.block_align == 0)
{
logw("audio stream %u: ignoring invalid override %uHz/%uch/%ubit", slot, cf.rate, cf.channels,
cf.bits);
return;
}
logw("audio stream %u: operator override -> %uHz/%uch/%ubit tag=%u", slot, cf.rate, cf.channels,
cf.bits, cf.tag);
g_stream_formats[slot] = cf;
g_stream_rate_guess[slot] = false;
g_stream_format_state[slot] = AudioFormat_Override;
// Keep the over-read clamp on: a too-large operator block is capped to the real
// buffer (garbled but safe); a correct override makes the clamp a no-op.
g_streams[slot].assumed_format.store(1, std::memory_order_relaxed);
g_streams[slot].block_align.store(cf.block_align, std::memory_order_relaxed);
ring->format_valid.store(0, std::memory_order_release); // re-publish at the new format
publish_stream_info_locked(slot, cf, AudioFormat_Override,
g_streams[slot].frames.load(std::memory_order_relaxed));
}
}
// Registers a newly created render client: assigns it a debug slot, marks the
// first as primary (the one we capture), publishes it to HookStatus, and hooks
// the render-client vtable on first sight. `rate_is_guess` is true when `cf` is the
@@ -700,24 +756,20 @@ bool install_audio_hooks(IpcClient& ipc, AudioRingHeader* ring)
void republish_audio_format()
{
// Nothing to do if no ring needs a format yet (cheap pre-check, no lock).
bool any_pending = false;
for (std::uint32_t i = 0; i < kMaxAudioStreams; ++i)
{
AudioRingHeader* ring = g_rings[i].load(std::memory_order_acquire);
if (ring != nullptr && !audio_ring_format_ready(*ring))
{
any_pending = true;
break;
}
}
if (!any_pending)
{
return;
}
std::scoped_lock lock(g_setup_mutex);
for (std::uint32_t i = 0; i < kMaxAudioStreams; ++i)
{
AudioRingHeader* ring = g_rings[i].load(std::memory_order_acquire);
if (ring == nullptr)
{
continue;
}
// Apply any operator command (re-measure / override) the host posted on this ring.
AudioRingOpCmd cmd;
if (audio_ring_poll_op(*ring, g_last_op_seq[i], cmd) != AudioRingOp_None)
{
apply_audio_op_locked(i, cmd);
}
// Publishes an exact format immediately; a guessed rate is measured first and
// published once a measurement window completes (retried on the next tick).
publish_stream_format_locked(i);
@@ -784,6 +836,7 @@ void remove_audio_hooks()
g_stream_rate_guess[i] = false;
g_stream_format_state[i] = AudioFormat_Unknown;
g_rate_estimator[i] = RateEstimator{};
g_last_op_seq[i] = 0;
g_rings[i].store(nullptr, std::memory_order_release);
}
g_client_formats.clear();