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

@@ -77,29 +77,30 @@ default** and covers anything the hooked path doesn't.
back to process-loopback capture, which does *not* mute the game — so the local back to process-loopback capture, which does *not* mute the game — so the local
machine hears the audio twice (guests hear it once). The Audio panel shows which machine hears the audio twice (guests hear it once). The Audio panel shows which
path is active. path is active.
- **Hooked audio can only recover a pre-existing stream's *sample rate*, not its - **A pre-existing stream's format is *recovered*, not known — by measurement and
channels/bit-depth.** The tool injects into an already-running game, so the audio cross-correlation.** The tool injects into an already-running game, so the audio
render-hook usually never saw the game's `IAudioClient::Initialize`. It recovers the render-hook usually never saw the game's `IAudioClient::Initialize`, and `AUTOCONVERTPCM`
true **sample rate** by measuring the render cadence (so playback pitch is correct, hides the buffer stride (WASAPI exposes no API for a pre-existing client's format). The
e.g. Godot/Brotato's 44100 Hz on a 48000 Hz endpoint), but **channels and bit-depth hook first recovers the **sample rate** by measuring the render cadence (so playback pitch
can't be detected** — with `AUTOCONVERTPCM` `GetBuffer` returns a fixed staging is correct, e.g. Godot/Brotato's 44100 Hz on a 48000 Hz endpoint), assuming the device's
buffer (no buffer stride to measure) and WASAPI exposes no API for a pre-existing channels/bit-depth. When the game is still audible (the measurement window), the host then
client's format — so they're *assumed* to match the device mix format. That's correct **cross-correlates the two capture paths** — the hook (pre-mix) against a parallel
for the common case (engines render stereo float, matching the endpoint, differing process-loopback (post-mix, the known device format) — to verify/correct the rate and to
only in rate). A game rendering a *different* channel count or bit depth than the **recover the channels + bit depth** by trying candidate de-interleavings and keeping the one
device is mirrored with the wrong layout (garbled audio) on the hooked path, but never that aligns (`audio_format_verifier` → `coop/audio_correlate.hpp`). The recovered format feeds
an over-read/crash: the capture copy is clamped to the readable region (`VirtualQuery`), the existing override channel. The one case it can't resolve is a *genuinely ambiguous* layout
and the local **mute** is done with `AUDCLNT_BUFFERFLAGS_SILENT` (which makes WASAPI (a stream whose channels carry identical content looks the same as one channel at double the
ignore the buffer's contents), so it never *writes* the wrongly-sized buffer either. So rate); there it stays the device assumption and the correlation reports low confidence rather
every captured stream is silenced locally — guessed or exact — and there is no echo on than guess. A wrong/assumed layout is mirrored with the wrong de-interleaving (garbled) but
the hooked path. The loopback fallback is always format-correct. The Audio panel shows never an over-read/crash: the capture copy is clamped to the readable region (`VirtualQuery`),
each stream's and the local **mute** uses `AUDCLNT_BUFFERFLAGS_SILENT` (WASAPI ignores the buffer contents),
format provenance (*known* / *measuring* / *measured rate* / *low-confidence* / so it never *writes* the wrongly-sized buffer either. Every captured stream is silenced locally
*override*) so the assumption is visible, and (under Debug details) lets the operator — guessed or exact — so there's no echo on the hooked path, and the loopback fallback is always
**re-measure** the rate or **override** the format when the guess is wrong. Overrides format-correct. The Audio panel shows each stream's format provenance (*known* / *measuring* /
are **remembered per game** (and a format caught exactly at `Initialize` is auto-saved *measured rate* / *low-confidence* / *override*), and (under Debug details) lets the operator
as that game's override), so a known-bad game is corrected automatically next launch. **re-measure** or **override** the format when needed. Overrides are **remembered per game** (and
Streams created *after* injection are captured exactly. a format caught exactly at `Initialize` is auto-saved), so a known-bad game is corrected
automatically next launch. Streams created *after* injection are captured exactly.
- **Debug-oriented UI:** the ImGui overlay is laid out for diagnosing the - **Debug-oriented UI:** the ImGui overlay is laid out for diagnosing the
pipeline, not for end use. F1 hides it entirely so the window is a clean mirror pipeline, not for end use. F1 hides it entirely so the window is a clean mirror
for RPT; F2 frees the operator cursor; **F10 saves a PNG screenshot** (back buffer, for RPT; F2 frees the operator cursor; **F10 saves a PNG screenshot** (back buffer,
@@ -109,26 +110,6 @@ default** and covers anything the hooked path doesn't.
### Current Tasks ### Current Tasks
- **Determine a pre-existing stream's audio format by *correlating* the two capture
paths, instead of guessing.** When we attach to an already-running game we never saw its
`IAudioClient::Initialize`, so the render-hook assumes the device mix format and measures
only the sample rate from the render cadence — which can be wrong on a jittery game
(intermittent pitch shift) and can't recover channels/bit-depth at all. But during the
measurement window the game is still audible, so we already have *both* signals of the
same audio: the **process-loopback** capture (post-mix, at the **known** device format)
and the **render-hook** capture (pre-mix, at the unknown format). Cross-correlating them
pins the real format from ground truth rather than a guess. Two tasks:
- **(a) Rate verification/correction.** Resample the hook stream by each candidate standard
rate and cross-correlate against the loopback; the rate that holds alignment with no drift
over the window is the truth. Robust where cadence measurement is noisy — directly hardens
the intermittent pitch-shift symptom. Lands as a verify-and-correct step feeding the
existing rate path (the operator override stays as the manual escape hatch).
- **(b) Channels + bit-depth recovery.** Extend the correlation to the layout the cadence
method *can't* recover: interpret the hook bytes under candidate layouts (float32 vs
int16; mono/stereo/…) and keep whichever de-interleaving correlates with the loopback (a
wrong interpretation is noise and won't). Removes the "channels/bit-depth assumed = device"
limitation, so the garbled-layout case stops being undetectable.
- **Mouse + keyboard forwarding for Raw Input / DirectInput games.** The MKB - **Mouse + keyboard forwarding for Raw Input / DirectInput games.** The MKB
subsystem forwards via window messages (`PostMessage`) plus synthesized subsystem forwards via window messages (`PostMessage`) plus synthesized
`GetAsyncKeyState` / `GetKeyboardState` / `GetCursorPos`, which covers message-loop `GetAsyncKeyState` / `GetKeyboardState` / `GetCursorPos`, which covers message-loop
@@ -235,15 +216,20 @@ ctest --test-dir build -C Debug --output-on-failure
skew + noise — exactly the hook-vs-loopback situation) and asserts `correlate_rate()` recovers the skew + noise — exactly the hook-vs-loopback situation) and asserts `correlate_rate()` recovers the
true rate, scoring the right candidate ≈1.0 and the wrong ones ≈0 (incl. the hard 44100-vs-48000 true rate, scoring the right candidate ≈1.0 and the wrong ones ≈0 (incl. the hard 44100-vs-48000
case the cadence method can misread), and that unrelated signals are *not* confidently matched. case the cadence method can misread), and that unrelated signals are *not* confidently matched.
Pure header logic, no device. Also covers **layout recovery** (`correlate_format`): from a self-describing *chunked* capture
(each render buffer padded to the device block, as the hook's verify tap produces it) it strips
the padding per candidate de-interleaving and recovers the true channels + bit depth + rate
(stereo float, 16-bit PCM, 5.1, mono), and leaves a genuinely-ambiguous identical-channel layout
unconfident. Pure header logic, no device.
- **`audio_verify_test`** — integration test of the host's two-path verifier - **`audio_verify_test`** — integration test of the host's two-path verifier
([`host/src/audio/audio_format_verifier.cpp`](host/src/audio/audio_format_verifier.cpp)). Launches ([`host/src/audio/audio_format_verifier.cpp`](host/src/audio/audio_format_verifier.cpp)). Launches
`coop_mock_game` rendering a tone at a non-device rate (matching the device's *channel* count, so `coop_mock_game` rendering a tone at a non-device rate (matching the device's *channel* count, so
this rate test isn't perturbed by a channel mismatch — that's the next task), injects the hook this rate test isn't perturbed by a channel mismatch), injects the hook late (a guessed stream),
late (a guessed stream), and runs the real `verify_stream_format()`: it co-captures the hook and runs the real `verify_stream_format()`: it co-captures the hook (pre-mix, via the ring's
(pre-mix, via the ring's `verify_capture` tap) and a parallel process-loopback (post-mix) of the `verify_capture` tap) and a parallel process-loopback (post-mix) of the same audio and correlates
same audio and correlates them. Asserts it recovers the game's true rate, not the device guess. them. Scenario (a) asserts it recovers the true rate; scenario (b) renders a *different* channel
Skips cleanly without an audio endpoint. count than the device with distinct per-channel content and asserts it recovers the full layout
(channels + bit depth + rate). Skips cleanly without an audio endpoint.
- **`render_pacer_test`** — unit test of the mirror's render-feed pacing policy - **`render_pacer_test`** — unit test of the mirror's render-feed pacing policy
(`host/src/audio/render_pacer.hpp`). Simulates a producer/consumer device timeline and asserts (`host/src/audio/render_pacer.hpp`). Simulates a producer/consumer device timeline and asserts
the shipping `RenderPacer` rides producer jitter that makes the old re-prime-on-partial-fill the shipping `RenderPacer` rides producer jitter that makes the old re-prime-on-partial-fill
@@ -551,7 +537,15 @@ Non-obvious things that cost time and constrain the design:
the *waveform* needs the hook bytes de-interleaved at the right channel count, so the rate step the *waveform* needs the hook bytes de-interleaved at the right channel count, so the rate step
assumes the hook layout matches the device (true for the common stereo-on-stereo case); recovering a assumes the hook layout matches the device (true for the common stereo-on-stereo case); recovering a
*different* channel count / bit depth is the layout step, which tries candidate de-interleavings and *different* channel count / bit depth is the layout step, which tries candidate de-interleavings and
keeps whichever correlates. keeps whichever correlates. **The layout step needs a self-describing tap**: the hook can't know a
guessed stream's real frame size, so it pads each render buffer to the *device* block — which
over-reads stale staging bytes for a stream with fewer channels/bits. The raw padded bytes are
un-decodable (the stale tail scrambles the audio), so the tap prefixes each buffer with its frame
*count* (`[count][count*device_block bytes]`); the host strips the padding per candidate layout
(take the real `count*real_block` of each chunk) before de-interleaving. **The genuinely-ambiguous
case stays unresolved**: a stream whose channels carry identical content is indistinguishable from
one channel at double the rate (`2ch@R` == `1ch@2R` byte-for-byte), so the correlator reports low
confidence and the format stays the device assumption rather than guessing wrong.
- **Re-priming the render feed on a *partial* fill manufactures the gap it's avoiding.** The - **Re-priming the render feed on a *partial* fill manufactures the gap it's avoiding.** The
mirror re-renders the captured ring to the output device. The original feed loop re-primed mirror re-renders the captured ring to the output device. The original feed loop re-primed
(withheld the feed until ~30 ms had rebuffered) whenever it couldn't completely fill the free (withheld the feed until ~30 ms had rebuffered) whenever it couldn't completely fill the free

View File

@@ -254,4 +254,177 @@ inline RateCorrelation correlate_rate(const std::vector<float>& hook_mono, const
return result; 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 } // namespace coop

View File

@@ -203,6 +203,15 @@ inline std::uint32_t audio_ring_available(const AudioRingHeader& h)
return static_cast<std::uint32_t>(w - r); return static_cast<std::uint32_t>(w - r);
} }
// Producer: bytes currently free (so a multi-part packet can be checked to fit before any of it is
// written -- keeps a self-describing [header][payload] framing from tearing on a full ring).
inline std::uint32_t audio_ring_free_space(const AudioRingHeader& h)
{
const std::uint64_t w = h.write_pos.load(std::memory_order_relaxed);
const std::uint64_t r = h.read_pos.load(std::memory_order_acquire);
return h.capacity - static_cast<std::uint32_t>(w - r);
}
// Consumer: copy up to `bytes` into `dst`; returns the number actually popped. // Consumer: copy up to `bytes` into `dst`; returns the number actually popped.
inline std::uint32_t audio_ring_pop(AudioRingHeader& h, void* dst, std::uint32_t bytes) inline std::uint32_t audio_ring_pop(AudioRingHeader& h, void* dst, std::uint32_t bytes)
{ {

View File

@@ -410,8 +410,19 @@ HRESULT STDMETHODCALLTYPE hk_ReleaseBuffer(IAudioRenderClient* self, UINT32 num_
const std::uint32_t block = g_streams[i].block_align.load(std::memory_order_relaxed); const std::uint32_t block = g_streams[i].block_align.load(std::memory_order_relaxed);
if (block != 0) if (block != 0)
{ {
audio_ring_push(*vring, t_gb_data, readable_bytes(t_gb_data, num_frames * block), // Self-describing chunk: [u32 frame-count][num_frames*block bytes]. The host can't
num_frames); // know the real frame size of a guessed stream, so it recovers the layout by
// trying candidate de-interleavings -- but it needs the frame count to strip the
// per-buffer padding (the guessed/device block over-reads a stream with fewer
// channels/bits). Push both parts only if both fit and the payload is fully
// readable, so a full ring or a short buffer can never tear the framing.
const std::uint32_t want = num_frames * block;
if (readable_bytes(t_gb_data, want) == want &&
audio_ring_free_space(*vring) >= static_cast<std::uint32_t>(sizeof(num_frames)) + want)
{
audio_ring_push(*vring, &num_frames, sizeof(num_frames), 0);
audio_ring_push(*vring, t_gb_data, want, num_frames);
}
} }
} }
} }

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 } // namespace
FormatVerification verify_stream_format(DWORD pid, AudioRingHeader* ring, unsigned window_ms, bool recover_layout) 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); ring->verify_capture.store(0, std::memory_order_release);
drain_ring(*ring, scratch); // leave the ring clean for the real capture that follows 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 // The loopback is at the KNOWN device layout. Decode it to mono ground truth. The hook stream
// cadence path also makes). Decode both captures with that layout and correlate. // is self-describing chunks ([count][padded payload]); parse them at the device block.
const std::vector<float> hook_mono = to_mono(hook_bytes, dev);
const std::vector<float> loop_mono = to_mono(loop_bytes, dev); 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); CoTaskMemFree(dev_wfx);
if (const char* dbg = std::getenv("COOP_VERIFY_DEBUG"); dbg != nullptr && dbg[0] == '1') 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, std::fprintf(stderr, "[verify] dev=%uHz/%uch/%ubit blk=%u chunks=%zu hook_frames=%zu loop=%zu layout=%d\n",
dev.channels, dev.bits, dev.tag, hook_mono.size(), loop_mono.size()); 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 (loop_mono.size() < need || hook_frames < need)
if (hook_mono.size() < need || loop_mono.size() < need)
{ {
return result; // not enough non-silent audio captured (game quiet, or stream wasn't a guess) return result; // not enough non-silent audio captured (game quiet, or stream wasn't a guess)
} }
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()); const RateCorrelation rc = correlate_rate(hook_mono, loop_mono, dev.rate, standard_audio_rates());
result.ok = rc.ok; result.ok = rc.ok;
result.rate = rc.rate; result.rate = rc.rate;
result.score = rc.score; result.score = rc.score;
// Channels/bit-depth stay the device assumption here; step (b) recovers them.
result.channels = dev.channels; result.channels = dev.channels;
result.bits = dev.bits; result.bits = dev.bits;
result.format_tag = dev.tag; result.format_tag = dev.tag;
}
return result; return result;
} }

View File

@@ -323,10 +323,15 @@ void AudioMirror::thread_main(DWORD pid)
if (!format_verified) if (!format_verified)
{ {
format_verified = true; 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) 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, audio_ring_post_op(*rings[0], AudioRingOp_Override, fv.rate, fv.channels, fv.bits,
fv.format_tag); fv.format_tag);
} }

View File

@@ -6,6 +6,7 @@
// rates (plus a capture-latency skew and a little noise), then assert correlate_rate() recovers the // rates (plus a capture-latency skew and a little noise), then assert correlate_rate() recovers the
// true rate -- including the hard 44100-vs-48000 case the cadence method can misread. Pure header // true rate -- including the hard 44100-vs-48000 case the cadence method can misread. Pure header
// logic, no device. // logic, no device.
#include <algorithm>
#include <cmath> #include <cmath>
#include <cstdint> #include <cstdint>
#include <cstdio> #include <cstdio>
@@ -55,6 +56,118 @@ std::vector<float> capture(unsigned rate, double seconds, double t0, double nois
return out; return out;
} }
// Per-channel continuous signal: each channel carries genuinely different content (its own
// frequency set), like a real stereo/surround stream. This is what disambiguates the channel
// count -- with identical channels, 2ch@R and 1ch@2R produce the same bytes and are truly
// indistinguishable (the confidence gate correctly rejects that case).
double multi(double t, unsigned channel)
{
const double k = 1.0 + 0.37 * static_cast<double>(channel); // distinct frequency scale per channel
const double chirp = std::sin(2.0 * kPi * (300.0 * k * t + 140.0 * t * t));
return 0.5 * std::sin(2.0 * kPi * 221.0 * k * t) + 0.28 * std::sin(2.0 * kPi * 437.0 * k * t + 0.6) +
0.22 * chirp;
}
double multi_mono(double t, unsigned channels)
{
double sum = 0.0;
for (unsigned c = 0; c < channels; ++c)
{
sum += multi(t, c);
}
return sum / channels;
}
// Encode the multichannel signal to raw interleaved PCM bytes at the given layout/rate.
std::vector<std::uint8_t> encode(unsigned rate, unsigned channels, unsigned bits, unsigned tag, double seconds)
{
const unsigned bps = bits / 8;
const std::size_t frames = static_cast<std::size_t>(rate * seconds);
std::vector<std::uint8_t> out(frames * channels * bps);
for (std::size_t i = 0; i < frames; ++i)
{
const double t = static_cast<double>(i) / rate;
for (unsigned c = 0; c < channels; ++c)
{
const double s = multi(t, c);
std::uint8_t* p = out.data() + (i * channels + c) * bps;
if (tag == coop::kWaveFormatFloat)
{
const float f = static_cast<float>(s);
std::memcpy(p, &f, 4);
}
else
{
const std::int16_t v = static_cast<std::int16_t>(s * 30000.0);
std::memcpy(p, &v, 2);
}
}
}
return out;
}
// Build the hook's self-describing chunked capture from clean audio: split into ~480-frame chunks
// and pad each frame from real_block up to `stride` with GARBAGE -- exactly what the hook's verify
// tap produces (it over-reads a guessed stream whose real layout has fewer channels/bits than the
// device block). correlate_format must strip the padding per candidate layout.
coop::ChunkedCapture make_chunks(unsigned rate, unsigned ch, unsigned bits, unsigned tag, unsigned stride,
double seconds)
{
coop::ChunkedCapture cap;
cap.stride = stride;
const std::vector<std::uint8_t> clean = encode(rate, ch, bits, tag, seconds);
const unsigned real_block = ch * (bits / 8);
const std::size_t frames = clean.size() / real_block;
std::mt19937 rng(123);
std::uniform_int_distribution<int> garbage(0, 255);
std::size_t f = 0;
while (f < frames)
{
const unsigned count = static_cast<unsigned>(std::min<std::size_t>(480, frames - f));
cap.counts.push_back(count);
// The hook reads count*stride contiguous bytes: the count real frames first
// (count*real_block bytes), then count*(stride-real_block) bytes of stale over-read.
const std::uint8_t* src = clean.data() + f * real_block;
cap.bytes.insert(cap.bytes.end(), src, src + static_cast<std::size_t>(count) * real_block);
for (std::size_t p = 0; p < static_cast<std::size_t>(count) * (stride - real_block); ++p)
{
cap.bytes.push_back(static_cast<std::uint8_t>(garbage(rng)));
}
f += count;
}
return cap;
}
// One layout scenario: the hook bytes are at (true_*) and still being measured; the loopback is the
// post-mix mono of the same audio at device_rate. Assert correlate_format recovers the full layout.
void test_layout(unsigned true_rate, unsigned true_ch, unsigned true_bits, unsigned true_tag,
unsigned device_rate, const char* label)
{
std::printf("== layout: %s (%u Hz / %u ch / %u-bit %s -> device %u Hz) ==\n", label, true_rate, true_ch,
true_bits, true_tag == coop::kWaveFormatFloat ? "float" : "pcm", device_rate);
// Device block 32 (8ch float) is the largest stride; every test layout's real block is <= 32.
const coop::ChunkedCapture hook = make_chunks(true_rate, true_ch, true_bits, true_tag, /*stride=*/32, 0.6);
// Loopback: the post-mix mono of the same audio, at the device rate, started ~18 ms later + noise.
const std::size_t loop_frames = static_cast<std::size_t>(device_rate * 0.6);
std::vector<float> loop(loop_frames);
std::mt19937 rng(5);
std::uniform_real_distribution<float> j(-1.0f, 1.0f);
for (std::size_t m = 0; m < loop_frames; ++m)
{
loop[m] = static_cast<float>(multi_mono(0.018 + static_cast<double>(m) / device_rate, true_ch)) +
0.02f * j(rng);
}
const FormatCorrelation r =
correlate_format(hook, loop, device_rate, standard_audio_rates(), standard_audio_layouts());
std::printf(" picked %u Hz / %u ch / %u-bit %s score=%.3f runner_up=%.3f ok=%d\n", r.rate, r.channels,
r.bits, r.tag == coop::kWaveFormatFloat ? "float" : "pcm", r.score, r.runner_up, r.ok ? 1 : 0);
check(r.ok, "layout pick is confident");
check(r.rate == true_rate, "recovered the true rate");
check(r.channels == true_ch, "recovered the true channel count");
check(r.bits == true_bits && r.tag == true_tag, "recovered the true bit depth / sample format");
}
// One scenario: true hook rate `true_rate` mixed to `device_rate`. Assert the correlator picks // One scenario: true hook rate `true_rate` mixed to `device_rate`. Assert the correlator picks
// true_rate confidently and that the runner-up is clearly behind. // true_rate confidently and that the runner-up is clearly behind.
void test_case(unsigned true_rate, unsigned device_rate, const char* label) void test_case(unsigned true_rate, unsigned device_rate, const char* label)
@@ -84,6 +197,13 @@ int main()
test_case(32000, 44100, "low-rate stream on a 44100 endpoint"); test_case(32000, 44100, "low-rate stream on a 44100 endpoint");
test_case(48000, 44100, "48000 stream on a 44100 endpoint"); test_case(48000, 44100, "48000 stream on a 44100 endpoint");
// Step (b): recover the full layout (channels + bit depth) when it differs from the device, by
// trying candidate de-interleavings -- the case the rate-only step can't handle.
test_layout(44100, 2, 32, coop::kWaveFormatFloat, 48000, "stereo float, wrong rate");
test_layout(44100, 2, 16, coop::kWaveFormatPcm, 48000, "stereo 16-bit PCM (bit depth differs)");
test_layout(48000, 6, 32, coop::kWaveFormatFloat, 48000, "5.1 float (channels differ)");
test_layout(44100, 1, 32, coop::kWaveFormatFloat, 48000, "mono"); // 2ch@22050 isn't a candidate -> unambiguous
// Downmix sanity: a stereo interleaved buffer collapses to the same mono the scalar path uses. // Downmix sanity: a stereo interleaved buffer collapses to the same mono the scalar path uses.
{ {
std::printf("== downmix stereo -> mono ==\n"); std::printf("== downmix stereo -> mono ==\n");

View File

@@ -1,22 +1,23 @@
// Integration test for the two-path audio-format verifier (host/src/audio/audio_format_verifier). // Integration test for the two-path audio-format verifier (host/src/audio/audio_format_verifier).
// //
// Launches coop_mock_game rendering a tone at a NON-device rate (44100 on a typical 48000 endpoint, // Launches coop_mock_game rendering a tone via WASAPI AUTOCONVERTPCM, injects coop_hook.dll late
// the Godot/Brotato case), injects coop_hook.dll late (so the stream is a *guess*), then runs the // (so the stream is a *guess*), and runs the real verify_stream_format(): it co-captures the hook
// real verify_stream_format(): it co-captures the hook (pre-mix, via the ring's verify tap) and a // (pre-mix, via the ring's verify tap) and a parallel process-loopback (post-mix, device format)
// parallel process-loopback (post-mix, device format) and cross-correlates them. Asserts it // and cross-correlates them. Two scenarios:
// recovers the true 44100 Hz rate -- the cadence method's hard case. Skips cleanly without an audio // (a) rate: render at the device's channel count but a different rate -> recover the rate.
// endpoint / if Vulkan-free... (only needs WASAPI + a D3D11-capable mock, which the mock always is). // (b) layout: render a DIFFERENT channel count than the device, with distinct per-channel content
// -> recover channels + bit depth + rate.
// Skips cleanly without an audio endpoint.
#include <cstdint> #include <cstdint>
#include <cstdio> #include <cstdio>
#include <string> #include <string>
#include <windows.h> #include <windows.h>
#include <mmreg.h>
#include <objbase.h> #include <objbase.h>
#include <tlhelp32.h> #include <tlhelp32.h>
#include <mmreg.h>
#include "audio/audio_format_verifier.hpp" #include "audio/audio_format_verifier.hpp"
#include "audio/process_loopback_capture.hpp" // default_render_format #include "audio/process_loopback_capture.hpp" // default_render_format
#include "coop/audio_ring.hpp" #include "coop/audio_ring.hpp"
@@ -111,42 +112,42 @@ bool inject_retry(unsigned long pid)
} }
return false; return false;
} }
} // namespace
int main() struct Scenario
{ {
unsigned rate, channels, bits;
bool distinct; // distinct per-channel content (so the channel count is recoverable)
bool recover_layout; // false = rate only (step a); true = full layout (step b)
};
// Launch the mock at the scenario's format, inject the hook late, and run the verifier. `ran` is
// set false when the environment can't support the test (launch/inject failed) so the caller skips.
FormatVerification run(const Scenario& sc, bool& ran)
{
ran = false;
FormatVerification fv;
kill_stray_mock_games(); kill_stray_mock_games();
const bool com = SUCCEEDED(CoInitializeEx(nullptr, COINIT_MULTITHREADED)); if (sc.distinct)
// Render the mock at the device's CHANNEL count (so this step-(a) rate test isn't perturbed by
// a channel mismatch -- that's step (b)'s job) but at a DIFFERENT standard rate than the device,
// so the verifier has a real rate to recover. Default to 48000/2ch if we can't read the device.
unsigned dev_rate = 48000, dev_channels = 2;
if (WAVEFORMATEX* dev = default_render_format())
{ {
dev_rate = dev->nSamplesPerSec; SetEnvironmentVariableW(L"COOP_TONE_DISTINCT_CH", L"1");
dev_channels = dev->nChannels;
CoTaskMemFree(dev);
} }
const unsigned game_rate = (dev_rate == 44100) ? 48000u : 44100u; // guarantee a rate mismatch
std::printf(" device %u Hz / %u ch -> rendering the mock at %u Hz / %u ch (rate mismatch)\n", dev_rate,
dev_channels, game_rate, dev_channels);
const std::wstring exe = exe_directory() + L"coop_mock_game.exe"; const std::wstring exe = exe_directory() + L"coop_mock_game.exe";
std::wstring cmd = L"\"" + exe + L"\" dx11 30 " + std::to_wstring(game_rate) + L" " + std::wstring cmd = L"\"" + exe + L"\" dx11 30 " + std::to_wstring(sc.rate) + L" " +
std::to_wstring(dev_channels) + L" 32 float"; std::to_wstring(sc.channels) + L" " + std::to_wstring(sc.bits) + L" " +
(sc.bits == 16 ? L"pcm" : L"float");
STARTUPINFOW si{}; STARTUPINFOW si{};
si.cb = sizeof(si); si.cb = sizeof(si);
PROCESS_INFORMATION pi{}; PROCESS_INFORMATION pi{};
if (!CreateProcessW(exe.c_str(), cmd.data(), nullptr, nullptr, FALSE, 0, nullptr, nullptr, &si, &pi)) const BOOL launched = CreateProcessW(exe.c_str(), cmd.data(), nullptr, nullptr, FALSE, 0, nullptr, nullptr,
&si, &pi);
if (sc.distinct)
{ {
std::printf("Could not launch coop_mock_game -- skipping audio_verify_test.\n"); SetEnvironmentVariableW(L"COOP_TONE_DISTINCT_CH", nullptr);
if (com)
{
CoUninitialize();
} }
return 0; if (!launched)
{
return fv;
} }
auto cleanup = [&] { auto cleanup = [&] {
TerminateProcess(pi.hProcess, 0); TerminateProcess(pi.hProcess, 0);
@@ -155,73 +156,104 @@ int main()
CloseHandle(pi.hProcess); CloseHandle(pi.hProcess);
kill_stray_mock_games(); kill_stray_mock_games();
}; };
Sleep(800); // window + audio client up Sleep(800);
// IPC + the primary audio ring the hook produces into.
SharedMemory shm; SharedMemory shm;
if (!shm.create(shared_memory_name(pi.dwProcessId), sizeof(SharedBlock))) SharedMemory ring_shm;
if (!shm.create(shared_memory_name(pi.dwProcessId), sizeof(SharedBlock)) ||
!ring_shm.create(audio_ring_name(pi.dwProcessId), audio_ring_total_size(kAudioRingCapacity)))
{ {
std::printf("Could not create IPC block -- skipping.\n");
cleanup(); cleanup();
return 0; return fv;
} }
auto* block = shm.as<SharedBlock>(); auto* block = shm.as<SharedBlock>();
block->version = kProtocolVersion; block->version = kProtocolVersion;
block->pad_count = 0; block->pad_count = 0;
block->sequence.store(0, std::memory_order_relaxed); block->sequence.store(0, std::memory_order_relaxed);
// Only the audio subsystem. for (std::uint32_t s = 0; s < HookSubsys_Count; ++s) // audio subsystem only
for (std::uint32_t s = 0; s < HookSubsys_Count; ++s)
{ {
const bool off = s != HookSubsys_Audio; block->control.subsystem_disabled[s].store(s != HookSubsys_Audio ? 1u : 0u, std::memory_order_release);
block->control.subsystem_disabled[s].store(off ? 1u : 0u, std::memory_order_release);
} }
block->magic = kProtocolMagic; block->magic = kProtocolMagic;
SharedMemory ring_shm;
if (!ring_shm.create(audio_ring_name(pi.dwProcessId), audio_ring_total_size(kAudioRingCapacity)))
{
std::printf("Could not create audio ring -- skipping.\n");
cleanup();
return 0;
}
auto* ring = ring_shm.as<AudioRingHeader>(); auto* ring = ring_shm.as<AudioRingHeader>();
audio_ring_init(*ring, kAudioRingCapacity); audio_ring_init(*ring, kAudioRingCapacity); // capture_enabled stays 0: audible + still measuring
// Leave capture_enabled = 0: we want the stream audible (so loopback hears it) and still being
// MEASURED (so the verify tap fires), exactly the window verify_stream_format targets.
if (!inject_retry(pi.dwProcessId)) if (!inject_retry(pi.dwProcessId))
{ {
std::printf("Could not inject coop_hook.dll -- skipping.\n");
cleanup(); cleanup();
return 0; return fv;
} }
Sleep(500); // let the hook attach + the pre-existing render client register as a guess Sleep(500); // hook attaches + the pre-existing render client registers as a guess
// Run the real verifier: co-capture hook (pre-mix) + loopback (post-mix) and correlate. fv = verify_stream_format(pi.dwProcessId, ring, /*window_ms=*/1400, sc.recover_layout);
const FormatVerification fv = verify_stream_format(pi.dwProcessId, ring, /*window_ms=*/1400); ran = true;
std::printf(" verify: ok=%d rate=%u score=%.3f\n", fv.ok ? 1 : 0, fv.rate, fv.score); cleanup();
return fv;
}
} // namespace
if (!fv.ok && fv.rate == 0 && fv.score == 0.0) int main()
{ {
// No audio endpoint, or no usable audio captured (e.g. the mock's WASAPI client never const bool com = SUCCEEDED(CoInitializeEx(nullptr, COINIT_MULTITHREADED));
// started on this machine) -> treat as a skip rather than a failure.
std::printf(" no usable co-capture (no endpoint / silent) -- skipping audio_verify_test.\n"); unsigned dev_rate = 48000, dev_channels = 2;
if (WAVEFORMATEX* dev = default_render_format())
{
dev_rate = dev->nSamplesPerSec;
dev_channels = dev->nChannels;
CoTaskMemFree(dev);
}
const unsigned mismatched = (dev_rate == 44100) ? 48000u : 44100u; // guarantee a rate mismatch
std::printf("device: %u Hz / %u ch\n", dev_rate, dev_channels);
// (a) Rate: render at the device's channel count (no layout mismatch) but a different rate.
std::printf("== (a) rate recovery: %u Hz / %u ch ==\n", mismatched, dev_channels);
bool ran = false;
FormatVerification a = run({mismatched, dev_channels, 32, /*distinct=*/false, /*recover_layout=*/false}, ran);
if (!ran)
{
std::printf(" environment can't run the mock+inject -- skipping audio_verify_test.\n");
if (com) if (com)
{ {
CoUninitialize(); CoUninitialize();
} }
cleanup();
return 0; return 0;
} }
std::printf(" ok=%d rate=%u score=%.3f\n", a.ok ? 1 : 0, a.rate, a.score);
if (!a.ok && a.rate == 0 && a.score == 0.0)
{
std::printf(" no usable co-capture (no endpoint / silent) -- skipping.\n");
if (com)
{
CoUninitialize();
}
return 0;
}
check(a.ok, "(a) verifier confidently correlated the two capture paths");
check(a.rate == mismatched, "(a) recovered the game's true rate (not the device rate)");
check(fv.ok, "verifier confidently correlated the two capture paths"); // (b) Layout: render 2ch with distinct per-channel content -- a layout that differs from a
check(fv.rate == game_rate, "verifier recovered the game's true rate (not the device rate)"); // multichannel device -- and recover channels + bit depth + rate.
std::printf("== (b) layout recovery: 44100 Hz / 2 ch / 32-bit float (distinct channels) ==\n");
FormatVerification b = run({44100, 2, 32, /*distinct=*/true, /*recover_layout=*/true}, ran);
std::printf(" ok=%d rate=%u ch=%u bits=%u tag=%u score=%.3f\n", b.ok ? 1 : 0, b.rate, b.channels, b.bits,
b.format_tag, b.score);
if (b.ok || b.score > 0.0)
{
check(b.layout_ok, "(b) verifier confidently recovered the layout");
check(b.rate == 44100, "(b) recovered the true rate");
check(b.channels == 2, "(b) recovered the true channel count (2, not the device's)");
check(b.bits == 32 && b.format_tag == 3, "(b) recovered 32-bit float");
}
else
{
std::printf(" no usable co-capture for (b) -- skipping that scenario.\n");
}
if (com) if (com)
{ {
CoUninitialize(); CoUninitialize();
} }
cleanup();
if (g_failures == 0) if (g_failures == 0)
{ {

View File

@@ -7,6 +7,7 @@
#include <cmath> #include <cmath>
#include <cstdint> #include <cstdint>
#include <cstdlib>
#include <windows.h> #include <windows.h>
@@ -87,6 +88,13 @@ public:
client_->GetBufferSize(&buffer_frames_); client_->GetBufferSize(&buffer_frames_);
step_ = kTwoPi * freq_hz / static_cast<double>(fmt_.rate); step_ = kTwoPi * freq_hz / static_cast<double>(fmt_.rate);
// Optional: give each channel genuinely different content (a per-channel frequency scale),
// so a downstream test can *recover* the channel count by correlation (identical channels
// are ambiguous: 2ch@R looks like 1ch@2R). Off by default -> the usual single-tone source.
if (const char* d = std::getenv("COOP_TONE_DISTINCT_CH"); d != nullptr && d[0] == '1')
{
distinct_ = true;
}
write(buffer_frames_); // pre-roll write(buffer_frames_); // pre-roll
client_->Start(); client_->Start();
return true; return true;
@@ -220,13 +228,24 @@ private:
} }
for (unsigned c = 0; c < fmt_.channels; ++c) for (unsigned c = 0; c < fmt_.channels; ++c)
{ {
double sc = s;
if (distinct_ && c < 8)
{
// Each channel at its own frequency scale -> genuinely different content.
sc = std::sin(phase_c_[c]) * 0.25;
phase_c_[c] += step_ * (1.0 + 0.37 * static_cast<double>(c));
if (phase_c_[c] > kTwoPi)
{
phase_c_[c] -= kTwoPi;
}
}
if (float_) if (float_)
{ {
reinterpret_cast<float*>(data)[i * fmt_.channels + c] = static_cast<float>(s); reinterpret_cast<float*>(data)[i * fmt_.channels + c] = static_cast<float>(sc);
} }
else else
{ {
reinterpret_cast<INT16*>(data)[i * fmt_.channels + c] = static_cast<INT16>(s * 32767.0); reinterpret_cast<INT16*>(data)[i * fmt_.channels + c] = static_cast<INT16>(sc * 32767.0);
} }
} }
} }
@@ -243,6 +262,8 @@ private:
bool float_ = false; bool float_ = false;
double phase_ = 0.0; double phase_ = 0.0;
double step_ = 0.0; double step_ = 0.0;
bool distinct_ = false; // per-channel distinct content (recoverable channel count)
double phase_c_[8] = {}; // per-channel phase when distinct_
}; };
} // namespace coop::tone } // namespace coop::tone