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

@@ -24,7 +24,18 @@ namespace coop
inline constexpr std::uint32_t kAudioRingMagic = 0x47525541u;
// Bump whenever AudioRingHeader's layout changes.
inline constexpr std::uint32_t kAudioRingVersion = 1;
inline constexpr std::uint32_t kAudioRingVersion = 2;
// Operator commands the host issues per stream (host -> hook), applied via the op_*
// fields in the header. The host writes the fields then bumps op_seq; the hook applies
// the command once per new op_seq. Lets the Audio panel re-measure a stream's rate or
// override its format when detection is wrong/unrecoverable.
enum AudioRingOp : std::uint32_t
{
AudioRingOp_None = 0,
AudioRingOp_Remeasure = 1, // re-run the sample-rate measurement for this stream
AudioRingOp_Override = 2, // adopt the op_rate/channels/bits/format_tag verbatim
};
// Per-pid mapping name, mirroring kSharedMemoryPrefix: coop_audio_<pid>.
inline constexpr wchar_t kAudioRingPrefix[] = L"Local\\coop_audio_";
@@ -66,7 +77,17 @@ struct AudioRingHeader
std::atomic<std::uint64_t> frames_produced; // cumulative frames pushed
std::atomic<std::uint64_t> overruns; // packets dropped on a full ring
std::uint8_t reserved[64];
// --- Operator control (host -> hook) ---------------------------------------
// The host writes op_kind + the op_* fields, then bumps op_seq (release); the hook
// applies the command once per new op_seq (acquire). See AudioRingOp.
std::atomic<std::uint32_t> op_seq; // bumped by the host on each new command
std::uint32_t op_kind; // AudioRingOp
std::uint32_t op_rate; // override: sample rate
std::uint32_t op_channels; // override: channel count
std::uint32_t op_bits; // override: bits per sample
std::uint32_t op_format_tag; // override: WAVE_FORMAT_PCM / _IEEE_FLOAT
std::uint8_t reserved[40];
// std::uint8_t data[capacity] follows immediately in the mapping.
};
@@ -104,6 +125,12 @@ inline void audio_ring_init(AudioRingHeader& h, std::uint32_t capacity)
h.read_pos.store(0, std::memory_order_relaxed);
h.frames_produced.store(0, std::memory_order_relaxed);
h.overruns.store(0, std::memory_order_relaxed);
h.op_seq.store(0, std::memory_order_relaxed);
h.op_kind = 0;
h.op_rate = 0;
h.op_channels = 0;
h.op_bits = 0;
h.op_format_tag = 0;
std::memset(h.reserved, 0, sizeof(h.reserved));
}
@@ -185,6 +212,50 @@ inline std::uint32_t audio_ring_pop(AudioRingHeader& h, void* dst, std::uint32_t
return bytes;
}
// Host: post an operator command to the hook for this stream. Writes the fields, then
// bumps op_seq (release) so the hook applies it exactly once. For a re-measure the
// rate/channels/bits are ignored.
inline void audio_ring_post_op(AudioRingHeader& h, std::uint32_t kind, std::uint32_t rate = 0,
std::uint32_t channels = 0, std::uint32_t bits = 0,
std::uint32_t format_tag = 0)
{
h.op_kind = kind;
h.op_rate = rate;
h.op_channels = channels;
h.op_bits = bits;
h.op_format_tag = format_tag;
h.op_seq.fetch_add(1, std::memory_order_release);
}
// One operator command read back by the hook.
struct AudioRingOpCmd
{
std::uint32_t kind = AudioRingOp_None;
std::uint32_t rate = 0;
std::uint32_t channels = 0;
std::uint32_t bits = 0;
std::uint32_t format_tag = 0;
};
// Hook: if a new op was posted since `last_seq`, read it into `out`, advance `last_seq`,
// and return its kind; otherwise returns AudioRingOp_None. Robust to ring re-creation
// (op_seq resets to 0 -> a stale higher last_seq just reads the zeroed None command).
inline std::uint32_t audio_ring_poll_op(AudioRingHeader& h, std::uint32_t& last_seq, AudioRingOpCmd& out)
{
const std::uint32_t seq = h.op_seq.load(std::memory_order_acquire);
if (seq == last_seq)
{
return AudioRingOp_None;
}
last_seq = seq;
out.kind = h.op_kind;
out.rate = h.op_rate;
out.channels = h.op_channels;
out.bits = h.op_bits;
out.format_tag = h.op_format_tag;
return out.kind;
}
// Build the per-pid audio ring name both sides agree on. Stream 0 keeps the bare
// coop_audio_<pid> name (backward compatible / the single-stream case); additional
// streams append _<index> (coop_audio_<pid>_1, _2, ...). The host captures every