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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user