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:
@@ -254,4 +254,177 @@ inline RateCorrelation correlate_rate(const std::vector<float>& hook_mono, const
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- Step (b): channels + bit-depth recovery --------------------------------------------------
|
||||
//
|
||||
// The rate step assumes the hook bytes are de-interleaved at the device channel/bit layout. When a
|
||||
// game renders a DIFFERENT layout than the device (e.g. stereo float on a 7.1 endpoint, or 16-bit
|
||||
// PCM), that assumption garbles the waveform and the rate won't lock. We can't measure the layout
|
||||
// (AUTOCONVERTPCM hides the stride), but we can RECOVER it: interpret the raw hook bytes under each
|
||||
// candidate layout, run the rate correlation, and keep whichever (layout, rate) aligns with the
|
||||
// loopback -- a wrong de-interleaving is noise and won't correlate.
|
||||
|
||||
inline constexpr unsigned kWaveFormatPcm = 1; // WAVE_FORMAT_PCM
|
||||
inline constexpr unsigned kWaveFormatFloat = 3; // WAVE_FORMAT_IEEE_FLOAT
|
||||
|
||||
struct LayoutCandidate
|
||||
{
|
||||
unsigned channels;
|
||||
unsigned bits;
|
||||
unsigned tag; // kWaveFormatPcm / kWaveFormatFloat
|
||||
};
|
||||
|
||||
// Candidate de-interleavings a shared-mode WASAPI render stream realistically uses: float32 and
|
||||
// 16-bit PCM, across the common channel counts. Ordered most-likely-first.
|
||||
inline const std::vector<LayoutCandidate>& standard_audio_layouts()
|
||||
{
|
||||
static const std::vector<LayoutCandidate> v = {
|
||||
{2, 32, kWaveFormatFloat}, {1, 32, kWaveFormatFloat}, {6, 32, kWaveFormatFloat},
|
||||
{8, 32, kWaveFormatFloat}, {4, 32, kWaveFormatFloat}, {2, 16, kWaveFormatPcm},
|
||||
{1, 16, kWaveFormatPcm}, {6, 16, kWaveFormatPcm}, {8, 16, kWaveFormatPcm},
|
||||
{4, 16, kWaveFormatPcm},
|
||||
};
|
||||
return v;
|
||||
}
|
||||
|
||||
struct FormatCorrelation
|
||||
{
|
||||
bool ok = false;
|
||||
unsigned rate = 0;
|
||||
unsigned channels = 0;
|
||||
unsigned bits = 0;
|
||||
unsigned tag = 0;
|
||||
double score = 0.0;
|
||||
double runner_up = 0.0;
|
||||
};
|
||||
|
||||
// The hook can't know a guessed stream's real frame size, so its verify tap pushes each render
|
||||
// buffer padded to the device block (`stride`) and prefixes it with the real frame `count`. That
|
||||
// padding is stale staging-buffer bytes, so the host must extract the real `count*real_block` bytes
|
||||
// per buffer (and concatenate) before de-interleaving -- otherwise the padding scrambles the audio.
|
||||
// This carries that self-describing capture: `bytes` holds counts[i]*stride bytes per chunk.
|
||||
struct ChunkedCapture
|
||||
{
|
||||
unsigned stride = 0; // bytes per frame as pushed (the guessed/device block_align)
|
||||
std::vector<std::uint32_t> counts; // real frame count of each chunk
|
||||
std::vector<std::uint8_t> bytes; // concatenated, counts[i]*stride bytes per chunk
|
||||
};
|
||||
|
||||
namespace correlate_detail
|
||||
{
|
||||
// De-interleave raw bytes under (channels/bits/tag) and average to mono float.
|
||||
inline void decode_layout(const std::uint8_t* bytes, std::size_t n, const LayoutCandidate& fmt,
|
||||
std::vector<float>& mono)
|
||||
{
|
||||
mono.clear();
|
||||
const unsigned ch = fmt.channels == 0 ? 1 : fmt.channels;
|
||||
const unsigned bps = fmt.bits / 8;
|
||||
if (bps == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
const std::size_t frame = static_cast<std::size_t>(ch) * bps;
|
||||
const std::size_t frames = n / frame;
|
||||
mono.resize(frames);
|
||||
const bool is_float = fmt.tag == kWaveFormatFloat;
|
||||
for (std::size_t i = 0; i < frames; ++i)
|
||||
{
|
||||
double sum = 0.0;
|
||||
for (unsigned c = 0; c < ch; ++c)
|
||||
{
|
||||
const std::uint8_t* p = bytes + i * frame + static_cast<std::size_t>(c) * bps;
|
||||
float s = 0.0f;
|
||||
if (is_float && fmt.bits == 32)
|
||||
{
|
||||
std::memcpy(&s, p, 4);
|
||||
}
|
||||
else if (fmt.bits == 16)
|
||||
{
|
||||
std::int16_t v;
|
||||
std::memcpy(&v, p, 2);
|
||||
s = v / 32768.0f;
|
||||
}
|
||||
else if (fmt.bits == 32)
|
||||
{
|
||||
std::int32_t v;
|
||||
std::memcpy(&v, p, 4);
|
||||
s = static_cast<float>(v / 2147483648.0);
|
||||
}
|
||||
sum += s;
|
||||
}
|
||||
mono[i] = static_cast<float>(sum / ch);
|
||||
}
|
||||
}
|
||||
} // namespace correlate_detail
|
||||
|
||||
// Recover BOTH the layout and the rate of a guessed stream from its (padded, self-describing) hook
|
||||
// capture: for each candidate layout, extract the real count*real_block bytes from each padded
|
||||
// chunk, de-interleave to mono, and run the rate correlation against the known-format loopback,
|
||||
// keeping the (layout, rate) that aligns best. `ok` when the winner clears the alignment floor and
|
||||
// clearly beats the runner-up (so a coincidental partial match is rejected).
|
||||
//
|
||||
// `min_margin` is an ABSOLUTE gap (not a ratio): the true layout scores near-perfectly while a
|
||||
// truly-ambiguous alternative (e.g. 1ch@2R vs 2ch@R when the channels carry the same content)
|
||||
// scores within ~0.001, so requiring the winner to clear the runner-up by a fixed margin cleanly
|
||||
// separates "recovered" from "genuinely ambiguous, don't guess".
|
||||
inline FormatCorrelation correlate_format(const ChunkedCapture& hook, const std::vector<float>& loop_mono,
|
||||
unsigned device_rate, const std::vector<unsigned>& rates,
|
||||
const std::vector<LayoutCandidate>& layouts, double min_score = 0.55,
|
||||
double min_margin = 0.04)
|
||||
{
|
||||
FormatCorrelation result;
|
||||
if (hook.stride == 0 || hook.counts.empty() || loop_mono.empty() || device_rate == 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
double best = -1.0, second = -1.0;
|
||||
std::vector<std::uint8_t> clean;
|
||||
std::vector<float> hook_mono;
|
||||
for (const LayoutCandidate& layout : layouts)
|
||||
{
|
||||
const unsigned real_block = layout.channels * (layout.bits / 8);
|
||||
if (real_block == 0 || real_block > hook.stride)
|
||||
{
|
||||
continue; // can't extract a frame larger than what was pushed (the guess is the max)
|
||||
}
|
||||
// Pull the real count*real_block bytes out of each padded chunk and concatenate -> contiguous
|
||||
// audio for this candidate layout (the padding, which is stale staging bytes, is dropped).
|
||||
clean.clear();
|
||||
std::size_t off = 0;
|
||||
for (std::uint32_t count : hook.counts)
|
||||
{
|
||||
const std::size_t chunk_bytes = static_cast<std::size_t>(count) * hook.stride;
|
||||
const std::size_t take = static_cast<std::size_t>(count) * real_block;
|
||||
if (off + chunk_bytes <= hook.bytes.size())
|
||||
{
|
||||
clean.insert(clean.end(), hook.bytes.begin() + off, hook.bytes.begin() + off + take);
|
||||
}
|
||||
off += chunk_bytes;
|
||||
}
|
||||
correlate_detail::decode_layout(clean.data(), clean.size(), layout, hook_mono);
|
||||
if (hook_mono.size() < device_rate / 5)
|
||||
{
|
||||
continue; // this layout yields too little audio to judge
|
||||
}
|
||||
const RateCorrelation rc = correlate_rate(hook_mono, loop_mono, device_rate, rates, /*min_score=*/0.0,
|
||||
/*separation=*/1.0);
|
||||
if (rc.score > best)
|
||||
{
|
||||
second = best;
|
||||
best = rc.score;
|
||||
result.rate = rc.rate;
|
||||
result.channels = layout.channels;
|
||||
result.bits = layout.bits;
|
||||
result.tag = layout.tag;
|
||||
}
|
||||
else if (rc.score > second)
|
||||
{
|
||||
second = rc.score;
|
||||
}
|
||||
}
|
||||
result.score = best < 0.0 ? 0.0 : best;
|
||||
result.runner_up = second < 0.0 ? 0.0 : second;
|
||||
result.ok = result.score >= min_score && (result.runner_up <= 1e-6 || result.score - result.runner_up >= min_margin);
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace coop
|
||||
|
||||
Reference in New Issue
Block a user