Recover a guessed stream's channels + bit depth by correlation too (step b)

Extends the two-path correlation from rate-only to the full layout, removing the
"channels/bit-depth assumed = device" limitation. correlate_format tries each
candidate de-interleaving (float32 / int16; mono..7.1) of the hook capture, runs
the rate correlation per layout, and keeps whichever aligns with the loopback;
a wrong de-interleaving is noise and won't.

The catch: the hook can't know a guessed stream's real frame size, so its verify
tap pads each render buffer to the device block -- which over-reads stale staging
bytes for a stream with fewer channels/bits, scrambling the audio. So the tap is
now self-describing: it prefixes each buffer with its frame count
([count][count*device_block bytes]), and the host strips the padding per candidate
layout (take the real count*real_block of each chunk) before de-interleaving.

- audio_correlate.hpp: ChunkedCapture + chunk-aware correlate_format + candidate
  layouts; absolute-margin confidence gate (the true layout scores ~1.0, a truly
  ambiguous alternative within ~0.001 -- 2ch@R == 1ch@2R for identical channels --
  is correctly left unconfident).
- audio_hook.cpp: chunked verify tap (free-space-checked so framing can't tear).
- audio_format_verifier: parse chunks; recover_layout path. AudioMirror now corrects
  the full format.
- audio_correlation_test: layout recovery from padded chunks (stereo float, 16-bit
  PCM, 5.1, mono). audio_verify_test gains scenario (b): 2ch on a multichannel
  endpoint with distinct per-channel content (new env-gated ToneSource mode) ->
  recovers ch=2/32-bit float end-to-end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 02:50:59 +02:00
parent 00244bcfd7
commit 7ada550930
9 changed files with 554 additions and 138 deletions

View File

@@ -99,6 +99,35 @@ void drain_ring(AudioRingHeader& ring, std::vector<BYTE>& scratch)
}
}
// Parse the hook's self-describing verify stream -- a sequence of [u32 frame-count][count*stride
// bytes] chunks -- into a ChunkedCapture. `stride` is the device block_align (what the hook padded
// each buffer to). Stops at the first truncated/garbled record.
ChunkedCapture parse_chunks(const std::vector<BYTE>& raw, unsigned stride)
{
ChunkedCapture cap;
cap.stride = stride;
if (stride == 0)
{
return cap;
}
std::size_t off = 0;
while (off + sizeof(std::uint32_t) <= raw.size())
{
std::uint32_t count = 0;
std::memcpy(&count, raw.data() + off, sizeof(count));
off += sizeof(count);
const std::size_t payload = static_cast<std::size_t>(count) * stride;
if (count == 0 || off + payload > raw.size())
{
break; // truncated or garbled -> stop
}
cap.counts.push_back(count);
cap.bytes.insert(cap.bytes.end(), raw.data() + off, raw.data() + off + payload);
off += payload;
}
return cap;
}
} // namespace
FormatVerification verify_stream_format(DWORD pid, AudioRingHeader* ring, unsigned window_ms, bool recover_layout)
@@ -156,31 +185,53 @@ FormatVerification verify_stream_format(DWORD pid, AudioRingHeader* ring, unsign
ring->verify_capture.store(0, std::memory_order_release);
drain_ring(*ring, scratch); // leave the ring clean for the real capture that follows
// The hook bytes are at the guessed layout = the device channels/bits (the assumption the
// cadence path also makes). Decode both captures with that layout and correlate.
const std::vector<float> hook_mono = to_mono(hook_bytes, dev);
// The loopback is at the KNOWN device layout. Decode it to mono ground truth. The hook stream
// is self-describing chunks ([count][padded payload]); parse them at the device block.
const std::vector<float> loop_mono = to_mono(loop_bytes, dev);
const unsigned dev_block = dev_wfx->nBlockAlign;
const ChunkedCapture cap = parse_chunks(hook_bytes, dev_block);
const std::size_t need = dev.rate / 5; // require >= ~200 ms of usable audio on both sides
const std::size_t hook_frames = dev_block != 0 ? cap.bytes.size() / dev_block : 0;
CoTaskMemFree(dev_wfx);
if (const char* dbg = std::getenv("COOP_VERIFY_DEBUG"); dbg != nullptr && dbg[0] == '1')
{
std::fprintf(stderr, "[verify] dev=%uHz/%uch/%ubit tag=%u hook_frames=%zu loop_frames=%zu\n", dev.rate,
dev.channels, dev.bits, dev.tag, hook_mono.size(), loop_mono.size());
std::fprintf(stderr, "[verify] dev=%uHz/%uch/%ubit blk=%u chunks=%zu hook_frames=%zu loop=%zu layout=%d\n",
dev.rate, dev.channels, dev.bits, dev_block, cap.counts.size(), hook_frames, loop_mono.size(),
recover_layout ? 1 : 0);
}
const std::size_t need = dev.rate / 5; // require >= ~200 ms of usable audio on both sides
if (hook_mono.size() < need || loop_mono.size() < need)
if (loop_mono.size() < need || hook_frames < need)
{
return result; // not enough non-silent audio captured (game quiet, or stream wasn't a guess)
}
const RateCorrelation rc = correlate_rate(hook_mono, loop_mono, dev.rate, standard_audio_rates());
result.ok = rc.ok;
result.rate = rc.rate;
result.score = rc.score;
// Channels/bit-depth stay the device assumption here; step (b) recovers them.
result.channels = dev.channels;
result.bits = dev.bits;
result.format_tag = dev.tag;
if (recover_layout)
{
// Step (b): recover channels + bit depth too, by trying candidate de-interleavings of the
// (de-padded) hook bytes and keeping whichever (layout, rate) correlates with the loopback.
const FormatCorrelation fc =
correlate_format(cap, loop_mono, dev.rate, standard_audio_rates(), standard_audio_layouts());
result.ok = fc.ok;
result.rate = fc.rate;
result.score = fc.score;
result.layout_ok = fc.ok;
result.channels = fc.channels;
result.bits = fc.bits;
result.format_tag = fc.tag;
}
else
{
// Step (a): rate only, assuming the hook layout matches the device (common stereo case), so
// the de-padded payload is already clean device-layout audio.
const std::vector<float> hook_mono = to_mono(cap.bytes, dev);
const RateCorrelation rc = correlate_rate(hook_mono, loop_mono, dev.rate, standard_audio_rates());
result.ok = rc.ok;
result.rate = rc.rate;
result.score = rc.score;
result.channels = dev.channels;
result.bits = dev.bits;
result.format_tag = dev.tag;
}
return result;
}

View File

@@ -323,10 +323,15 @@ void AudioMirror::thread_main(DWORD pid)
if (!format_verified)
{
format_verified = true;
const FormatVerification fv = verify_stream_format(pid, rings[0]);
// recover_layout: correlate the full format (rate AND channels/bit-depth), so a
// game rendering a different layout than the device is corrected too, not just
// the rate. A no-op when nothing correlates confidently (e.g. an exact stream,
// a silent game, or a genuinely ambiguous identical-channel layout).
const FormatVerification fv = verify_stream_format(pid, rings[0], /*window_ms=*/900,
/*recover_layout=*/true);
if (fv.ok)
{
set_status("Verified render-hook sample rate by correlation.");
set_status("Verified render-hook format by correlation.");
audio_ring_post_op(*rings[0], AudioRingOp_Override, fv.rate, fv.channels, fv.bits,
fv.format_tag);
}