Audio render-hook M1: shared audio ring + protocol diag fields
First milestone of the injection render-hook audio path (see docs/audio-render-hook-plan.md) that fixes the local audio echo without a virtual device. - common/include/coop/audio_ring.hpp: new lock-free SPSC shared-memory ring for PCM, separate from the input/status SharedBlock. Free-running 64-bit positions (release/acquire), format handshake, host-owned capture_enabled gate, drop-whole-packet overrun policy. - common/include/coop/protocol.hpp: add AudioStreamInfo + audio_streams_seen / audio_streams[] to the always-present HookStatus for the render-stream-count debug view; bump kProtocolVersion 3->4 (new members appended). - tests/audio_ring_test.cpp: in-process unit test (push/pop integrity, wrap-around, format handshake, overrun/drop). No hook or audio device. - docs/audio-render-hook-plan.md: the green-lit design this implements. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
194
common/include/coop/audio_ring.hpp
Normal file
194
common/include/coop/audio_ring.hpp
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
// Shared-memory audio ring for the injection render-hook audio path.
|
||||||
|
//
|
||||||
|
// The injected hook (coop_hook.dll) captures the game's WASAPI render frames and
|
||||||
|
// is the sole *producer*; the host (coop_host.exe) is the sole *consumer* and
|
||||||
|
// re-renders the frames for Steam Remote Play Together. This is a separate,
|
||||||
|
// larger mapping from the input/status SharedBlock (which is only 20-byte pads
|
||||||
|
// and can't hold PCM): a header followed by a byte ring of `capacity` bytes.
|
||||||
|
//
|
||||||
|
// Lock-free SPSC with free-running 64-bit positions (release on publish, acquire
|
||||||
|
// on read) — the same cross-process atomic model as the input seqlock. POD and
|
||||||
|
// version-locked: both modules compile this identical header.
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstring>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace coop
|
||||||
|
{
|
||||||
|
|
||||||
|
// 'AURG' little-endian; sanity-checks the mapping before either side trusts it.
|
||||||
|
inline constexpr std::uint32_t kAudioRingMagic = 0x47525541u;
|
||||||
|
|
||||||
|
// Bump whenever AudioRingHeader's layout changes.
|
||||||
|
inline constexpr std::uint32_t kAudioRingVersion = 1;
|
||||||
|
|
||||||
|
// Per-pid mapping name, mirroring kSharedMemoryPrefix: coop_audio_<pid>.
|
||||||
|
inline constexpr wchar_t kAudioRingPrefix[] = L"Local\\coop_audio_";
|
||||||
|
|
||||||
|
// Byte capacity of the PCM ring. 1 MiB is >1 s even at 48 kHz / 2 ch / 32-bit
|
||||||
|
// float (384 kB/s); the host should always keep up, so this is pure slack.
|
||||||
|
inline constexpr std::uint32_t kAudioRingCapacity = 1u << 20;
|
||||||
|
|
||||||
|
// Header preceding the PCM data. All multi-process-shared counters are atomic;
|
||||||
|
// the format fields are written once by the producer *before* it publishes
|
||||||
|
// format_valid (release), and read by the consumer *after* it observes
|
||||||
|
// format_valid (acquire), so they need no atomicity of their own.
|
||||||
|
struct AudioRingHeader
|
||||||
|
{
|
||||||
|
std::uint32_t magic;
|
||||||
|
std::uint32_t version;
|
||||||
|
|
||||||
|
// Host-owned gate. The hook copies+silences frames only while this is 1;
|
||||||
|
// when 0 the game audio passes through locally and nothing is mirrored
|
||||||
|
// (stream counting in HookStatus still runs regardless of this flag).
|
||||||
|
std::atomic<std::uint32_t> capture_enabled;
|
||||||
|
|
||||||
|
// Producer publishes the captured stream's format once, then sets
|
||||||
|
// format_valid=1 (release). format_generation is reserved so a future
|
||||||
|
// mid-session device re-init can be made forward-compatible; v1 sets once.
|
||||||
|
std::atomic<std::uint32_t> format_valid;
|
||||||
|
std::atomic<std::uint32_t> format_generation;
|
||||||
|
|
||||||
|
std::uint32_t sample_rate;
|
||||||
|
std::uint32_t channels;
|
||||||
|
std::uint32_t bits;
|
||||||
|
std::uint32_t format_tag; // WAVE_FORMAT_* (PCM=1, IEEE_FLOAT=3, EXTENSIBLE=0xFFFE)
|
||||||
|
std::uint32_t block_align; // bytes per frame (all channels)
|
||||||
|
|
||||||
|
std::uint32_t capacity; // bytes in the trailing data region
|
||||||
|
|
||||||
|
std::atomic<std::uint64_t> write_pos; // producer cursor, free-running
|
||||||
|
std::atomic<std::uint64_t> read_pos; // consumer cursor, free-running
|
||||||
|
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];
|
||||||
|
|
||||||
|
// std::uint8_t data[capacity] follows immediately in the mapping.
|
||||||
|
};
|
||||||
|
|
||||||
|
static_assert(std::atomic<std::uint64_t>::is_always_lock_free,
|
||||||
|
"audio ring needs a lock-free 64-bit atomic for cross-process use");
|
||||||
|
|
||||||
|
// Total mapping size for a ring of `capacity` bytes.
|
||||||
|
inline constexpr std::size_t audio_ring_total_size(std::uint32_t capacity)
|
||||||
|
{
|
||||||
|
return sizeof(AudioRingHeader) + capacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pointer to the PCM data region following the header.
|
||||||
|
inline std::uint8_t* audio_ring_data(AudioRingHeader* h)
|
||||||
|
{
|
||||||
|
return reinterpret_cast<std::uint8_t*>(h) + sizeof(AudioRingHeader);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Host side: stamp a freshly created mapping into a valid empty ring.
|
||||||
|
inline void audio_ring_init(AudioRingHeader& h, std::uint32_t capacity)
|
||||||
|
{
|
||||||
|
h.magic = kAudioRingMagic;
|
||||||
|
h.version = kAudioRingVersion;
|
||||||
|
h.capture_enabled.store(0, std::memory_order_relaxed);
|
||||||
|
h.format_valid.store(0, std::memory_order_relaxed);
|
||||||
|
h.format_generation.store(0, std::memory_order_relaxed);
|
||||||
|
h.sample_rate = 0;
|
||||||
|
h.channels = 0;
|
||||||
|
h.bits = 0;
|
||||||
|
h.format_tag = 0;
|
||||||
|
h.block_align = 0;
|
||||||
|
h.capacity = capacity;
|
||||||
|
h.write_pos.store(0, std::memory_order_relaxed);
|
||||||
|
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);
|
||||||
|
std::memset(h.reserved, 0, sizeof(h.reserved));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate a mapping the other side created/opened.
|
||||||
|
inline bool audio_ring_valid(const AudioRingHeader& h)
|
||||||
|
{
|
||||||
|
return h.magic == kAudioRingMagic && h.version == kAudioRingVersion && h.capacity != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Producer (hook): publish the captured stream format, then mark it valid.
|
||||||
|
inline void audio_ring_set_format(AudioRingHeader& h, std::uint32_t sample_rate, std::uint32_t channels,
|
||||||
|
std::uint32_t bits, std::uint32_t format_tag, std::uint32_t block_align)
|
||||||
|
{
|
||||||
|
h.sample_rate = sample_rate;
|
||||||
|
h.channels = channels;
|
||||||
|
h.bits = bits;
|
||||||
|
h.format_tag = format_tag;
|
||||||
|
h.block_align = block_align;
|
||||||
|
h.format_generation.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
h.format_valid.store(1, std::memory_order_release);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consumer (host): true once the producer has published a format.
|
||||||
|
inline bool audio_ring_format_ready(const AudioRingHeader& h)
|
||||||
|
{
|
||||||
|
return h.format_valid.load(std::memory_order_acquire) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Producer: push `bytes` of PCM. Returns false (and bumps overruns) if the ring
|
||||||
|
// can't hold the whole packet, in which case nothing is written — drop the
|
||||||
|
// packet rather than tear a frame. `frames` is recorded for the diagnostics.
|
||||||
|
inline bool audio_ring_push(AudioRingHeader& h, const void* src, std::uint32_t bytes, std::uint32_t frames)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
const std::uint32_t used = static_cast<std::uint32_t>(w - r);
|
||||||
|
if (bytes > h.capacity - used)
|
||||||
|
{
|
||||||
|
h.overruns.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::uint8_t* data = audio_ring_data(&h);
|
||||||
|
const std::uint32_t off = static_cast<std::uint32_t>(w % h.capacity);
|
||||||
|
const std::uint32_t first = std::min(bytes, h.capacity - off);
|
||||||
|
std::memcpy(data + off, src, first);
|
||||||
|
if (bytes > first)
|
||||||
|
{
|
||||||
|
std::memcpy(data, static_cast<const std::uint8_t*>(src) + first, bytes - first);
|
||||||
|
}
|
||||||
|
h.write_pos.store(w + bytes, std::memory_order_release);
|
||||||
|
h.frames_produced.fetch_add(frames, std::memory_order_relaxed);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consumer: bytes currently available to read.
|
||||||
|
inline std::uint32_t audio_ring_available(const AudioRingHeader& h)
|
||||||
|
{
|
||||||
|
const std::uint64_t w = h.write_pos.load(std::memory_order_acquire);
|
||||||
|
const std::uint64_t r = h.read_pos.load(std::memory_order_relaxed);
|
||||||
|
return static_cast<std::uint32_t>(w - r);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
{
|
||||||
|
const std::uint64_t r = h.read_pos.load(std::memory_order_relaxed);
|
||||||
|
const std::uint64_t w = h.write_pos.load(std::memory_order_acquire);
|
||||||
|
const std::uint32_t avail = static_cast<std::uint32_t>(w - r);
|
||||||
|
bytes = std::min(bytes, avail);
|
||||||
|
const std::uint8_t* data = audio_ring_data(&h);
|
||||||
|
const std::uint32_t off = static_cast<std::uint32_t>(r % h.capacity);
|
||||||
|
const std::uint32_t first = std::min(bytes, h.capacity - off);
|
||||||
|
std::memcpy(dst, data + off, first);
|
||||||
|
if (bytes > first)
|
||||||
|
{
|
||||||
|
std::memcpy(static_cast<std::uint8_t*>(dst) + first, data, bytes - first);
|
||||||
|
}
|
||||||
|
h.read_pos.store(r + bytes, std::memory_order_release);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the per-pid audio ring name both sides agree on.
|
||||||
|
inline std::wstring audio_ring_name(unsigned long target_pid)
|
||||||
|
{
|
||||||
|
return std::wstring(kAudioRingPrefix) + std::to_wstring(target_pid);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace coop
|
||||||
@@ -11,7 +11,7 @@ namespace coop
|
|||||||
|
|
||||||
// Bump whenever the layout of SharedBlock or CoopPadState changes. The hook
|
// Bump whenever the layout of SharedBlock or CoopPadState changes. The hook
|
||||||
// refuses to attach to a host with a mismatched version.
|
// refuses to attach to a host with a mismatched version.
|
||||||
inline constexpr std::uint32_t kProtocolVersion = 3;
|
inline constexpr std::uint32_t kProtocolVersion = 4;
|
||||||
|
|
||||||
// 'COOP' little-endian, used to sanity-check the mapping before trusting it.
|
// 'COOP' little-endian, used to sanity-check the mapping before trusting it.
|
||||||
inline constexpr std::uint32_t kProtocolMagic = 0x504F4F43u;
|
inline constexpr std::uint32_t kProtocolMagic = 0x504F4F43u;
|
||||||
@@ -42,6 +42,24 @@ struct CoopPadState
|
|||||||
|
|
||||||
static_assert(sizeof(CoopPadState) == 20, "CoopPadState layout must stay stable across both modules");
|
static_assert(sizeof(CoopPadState) == 20, "CoopPadState layout must stay stable across both modules");
|
||||||
|
|
||||||
|
// Maximum render streams the diagnostics track. The hook captures only the
|
||||||
|
// first ("primary"); the rest are surfaced so a multi-stream game is visible.
|
||||||
|
inline constexpr std::uint32_t kMaxAudioStreams = 4;
|
||||||
|
|
||||||
|
// One render stream the hook observed, for the Audio panel's debug view. Plain
|
||||||
|
// POD (no atomics): diagnostics tolerate benign cross-process races like the
|
||||||
|
// other HookStatus counters. frames_rendered is cumulative; the host derives
|
||||||
|
// "live vs idle" from successive deltas.
|
||||||
|
struct AudioStreamInfo
|
||||||
|
{
|
||||||
|
std::uint32_t is_primary; // 1 = the stream the hook captures/silences
|
||||||
|
std::uint32_t sample_rate;
|
||||||
|
std::uint16_t channels;
|
||||||
|
std::uint16_t bits;
|
||||||
|
std::uint32_t format_tag; // WAVE_FORMAT_* of this stream
|
||||||
|
std::uint64_t frames_rendered;
|
||||||
|
};
|
||||||
|
|
||||||
// Indices into HookStatus::focus_query_calls.
|
// Indices into HookStatus::focus_query_calls.
|
||||||
enum FocusApi : std::uint32_t
|
enum FocusApi : std::uint32_t
|
||||||
{
|
{
|
||||||
@@ -73,6 +91,12 @@ struct HookStatus
|
|||||||
std::uint32_t raw_input_gamepad; // ... for a joystick/gamepad usage page
|
std::uint32_t raw_input_gamepad; // ... for a joystick/gamepad usage page
|
||||||
std::uint32_t raw_input_gamepad_sink; // ... and that usage has RIDEV_INPUTSINK (bg delivery)
|
std::uint32_t raw_input_gamepad_sink; // ... and that usage has RIDEV_INPUTSINK (bg delivery)
|
||||||
std::uint32_t dinput_loaded; // dinput8.dll is present in the process
|
std::uint32_t dinput_loaded; // dinput8.dll is present in the process
|
||||||
|
|
||||||
|
// Audio render-hook diagnostics. Stream counting runs whenever the DLL is
|
||||||
|
// injected, independent of whether audio mirroring is enabled, so a
|
||||||
|
// multi-stream game is visible before/without turning the mirror on.
|
||||||
|
std::uint32_t audio_streams_seen; // distinct render clients ever created
|
||||||
|
AudioStreamInfo audio_streams[kMaxAudioStreams]; // per-slot detail, [0] is primary
|
||||||
};
|
};
|
||||||
|
|
||||||
// Top-level shared block. The host is the sole writer of pad state; the hook is
|
// Top-level shared block. The host is the sole writer of pad state; the hook is
|
||||||
|
|||||||
223
docs/audio-render-hook-plan.md
Normal file
223
docs/audio-render-hook-plan.md
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
# Plan: fix the local audio echo via an injection render-hook (Option B)
|
||||||
|
|
||||||
|
Status: **scoped, not started.** This document is the green-lit design; implement
|
||||||
|
against it.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
`coop_host.exe` mirrors the game's audio so Steam Remote Play Together (which
|
||||||
|
streams only the host process's own audio session) carries game sound to guests.
|
||||||
|
Today the host captures the game via **WASAPI process loopback** and re-renders it
|
||||||
|
on the default endpoint. The game *also* still plays locally, so the operator's
|
||||||
|
default endpoint carries two copies of the audio ("double audio" / echo). The
|
||||||
|
guest hears one copy (the host re-render); only the local operator hears it twice.
|
||||||
|
|
||||||
|
The fix must suppress the game's *direct* local playback **without** killing the
|
||||||
|
signal that feeds the host re-render.
|
||||||
|
|
||||||
|
## Why the cheaper options are out
|
||||||
|
|
||||||
|
- **Option A — per-session mute (`ISimpleAudioVolume` / the Volume Mixer).**
|
||||||
|
Tested manually: muting or mixing down the game's session also mutes/mixes down
|
||||||
|
the mirror. The Windows Volume Mixer *is* the `ISimpleAudioVolume` per-session
|
||||||
|
API (same calls), so there is no API-vs-mixer difference to exploit. This proves
|
||||||
|
the process-loopback tap sits **downstream** of the session volume gate.
|
||||||
|
**Conclusively out.**
|
||||||
|
- **Option C — redirect the game to a separate sink (`IAudioPolicyConfig`).** The
|
||||||
|
per-app endpoint redirect is real and usable (it backs Windows' "App volume and
|
||||||
|
device preferences"), but it only *routes* — it needs a destination endpoint
|
||||||
|
that is silent to the operator yet capturable by us, i.e. a virtual sink.
|
||||||
|
**Windows has no public API to instantiate a virtual audio endpoint at runtime
|
||||||
|
without a driver** (endpoints are driver-backed; every "virtual cable" ships an
|
||||||
|
installed signed kernel driver). The desired *ad-hoc, driverless, auto-removed*
|
||||||
|
virtual device does not exist. **Out.**
|
||||||
|
|
||||||
|
The only place to both grab the audio *and* stop it reaching the shared endpoint
|
||||||
|
is **before it leaves the game process** — i.e. injection, which we already do for
|
||||||
|
input. That is Option B.
|
||||||
|
|
||||||
|
## Approach (Option B)
|
||||||
|
|
||||||
|
In `coop_hook.dll`, hook the game's WASAPI render path:
|
||||||
|
|
||||||
|
```
|
||||||
|
IMMDevice::Activate(IID_IAudioClient) → IAudioClient
|
||||||
|
IAudioClient::Initialize(format) ← capture WAVEFORMATEX here
|
||||||
|
IAudioClient::GetService(IID_IAudioRenderClient)→ IAudioRenderClient (= a "stream")
|
||||||
|
loop: GetBuffer(n,&p) → game writes PCM → ReleaseBuffer(n,flags)
|
||||||
|
```
|
||||||
|
|
||||||
|
On `ReleaseBuffer`, copy the just-written frames into a shared audio ring (the host
|
||||||
|
re-renders them for RPT), then release with `AUDCLNT_BUFFERFLAGS_SILENT` so WASAPI
|
||||||
|
emits silence locally. Result: operator hears one copy (the host re-render), guest
|
||||||
|
hears one copy, no virtual device, no echo.
|
||||||
|
|
||||||
|
### Reaching the vtables
|
||||||
|
|
||||||
|
COM methods aren't exports, so we resolve them via vtable indices (frozen COM ABI)
|
||||||
|
and inline-hook the resolved addresses with SafetyHook (same engine as the XInput
|
||||||
|
hooks):
|
||||||
|
|
||||||
|
- **Anchor (only shared-vtable assumption):** the worker thread `CoCreateInstance`s
|
||||||
|
its *own* `IMMDeviceEnumerator`, gets the default render `IMMDevice`, and hooks
|
||||||
|
`IMMDevice::Activate` (vtable idx 3). All `IMMDevice` instances in the process
|
||||||
|
share that vtable (single coclass), so the game's `Activate` calls are caught.
|
||||||
|
- **Everything else is hooked off the live pointers the game received** (no further
|
||||||
|
assumptions), install-once guarded:
|
||||||
|
- `Activate` hook → if IID is `IAudioClient`/`2`/`3`, hook `Initialize` (idx 3)
|
||||||
|
and `GetService` (idx 13) on that object.
|
||||||
|
- `Initialize` hook → capture the `WAVEFORMATEX` (rate / channels / bits / tag).
|
||||||
|
Fallback for games using `IAudioClient3::InitializeSharedAudioStream`: read the
|
||||||
|
format via the original `GetMixFormat` in the `GetService` hook.
|
||||||
|
- `GetService` hook → if IID is `IAudioRenderClient`, register the stream and hook
|
||||||
|
its `GetBuffer` (idx 3) and `ReleaseBuffer` (idx 4).
|
||||||
|
|
||||||
|
Vtable indices: `IMMDevice::Activate`=3; `IAudioClient::Initialize`=3,
|
||||||
|
`GetService`=13; `IAudioRenderClient::GetBuffer`=3, `ReleaseBuffer`=4.
|
||||||
|
|
||||||
|
The worker thread `CoInitializeEx(MTA)` for the lifetime of the DLL (needed for the
|
||||||
|
enumerator instance); installs are retried on the existing 250 ms worker tick, like
|
||||||
|
the XInput / focus installs.
|
||||||
|
|
||||||
|
### Capture + silence
|
||||||
|
|
||||||
|
- **GetBuffer hook:** call original; stash `pData` + `numFrames` thread-local (the
|
||||||
|
pair is always called on one thread, never nested).
|
||||||
|
- **ReleaseBuffer hook:** act only when `this == primary render client`. If not
|
||||||
|
already silent: `memcpy` `numFrames × nBlockAlign` into the ring, then call the
|
||||||
|
original `ReleaseBuffer(numFrames, flags | AUDCLNT_BUFFERFLAGS_SILENT)`. Copy
|
||||||
|
happens before the original call (buffer is valid until release). memset-to-zero
|
||||||
|
is kept as a fallback if any driver mishandles the SILENT flag.
|
||||||
|
- No allocation and no locks on the game's audio thread — only a lock-free ring
|
||||||
|
write.
|
||||||
|
|
||||||
|
### Hooks always install; only copy+silence is gated
|
||||||
|
|
||||||
|
The Activate/Initialize/GetService/GetBuffer/ReleaseBuffer hooks install whenever
|
||||||
|
the DLL is injected, so **stream counting works even when audio mirroring is off**.
|
||||||
|
A `capture_enabled` flag (host-owned) gates *only* the copy+silence behavior in
|
||||||
|
`ReleaseBuffer`. Flag off → audio passes through untouched (game audible locally,
|
||||||
|
no mirror, but streams are still counted for the debug view).
|
||||||
|
|
||||||
|
## Audio IPC ring (new shared mapping)
|
||||||
|
|
||||||
|
The 20-byte `SharedBlock` can't hold PCM, so a **separate named mapping**
|
||||||
|
`Local\coop_audio_<pid>`, ~1 MB (>1 s at typical formats):
|
||||||
|
|
||||||
|
```
|
||||||
|
AudioRing header: magic, version, capture_enabled,
|
||||||
|
format_valid, format_generation,
|
||||||
|
sample_rate, channels, bits, format_tag, block_align,
|
||||||
|
capacity, atomic<u64> write_pos, read_pos,
|
||||||
|
frames_produced, overruns; // + reserved tail
|
||||||
|
data[capacity]: PCM ring
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Lock-free SPSC** (hook produces, host consumes): free-running 64-bit
|
||||||
|
`write_pos` / `read_pos`, release on publish / acquire on read — same
|
||||||
|
cross-process atomic model as the existing input seqlock. On full → drop the
|
||||||
|
packet and bump `overruns` (host should always keep up).
|
||||||
|
- **Format handshake:** hook sets the format fields + `format_valid` once; the host
|
||||||
|
spins on `format_valid` before creating its render client. `format_generation`
|
||||||
|
is in the layout now so a future mid-session device re-init is forward-compatible
|
||||||
|
(v1 handles format-set-once).
|
||||||
|
- **Ownership / toggle:** the **host creates the mapping and owns `capture_enabled`**;
|
||||||
|
the hook holds it open and copies+silences only while the flag is set. Toggling
|
||||||
|
the Audio panel checkbox just flips the flag — no injection churn, no teardown
|
||||||
|
races. Closing the host's handle while the hook holds it is safe (the section is
|
||||||
|
refcounted by the OS).
|
||||||
|
|
||||||
|
## Stream-count debug visualization (required)
|
||||||
|
|
||||||
|
Goal: easily see when a game emits more than one render stream, since v1 captures
|
||||||
|
only the first. These diagnostics live in the **always-present** `HookStatus`
|
||||||
|
back-channel in `SharedBlock` (not the audio ring), so the count is visible even
|
||||||
|
before/without enabling audio mirror. This bumps `kProtocolVersion` (3 → 4); new
|
||||||
|
members go at the **end** of `HookStatus` so existing offsets never shift.
|
||||||
|
|
||||||
|
Add to `HookStatus`:
|
||||||
|
|
||||||
|
```
|
||||||
|
uint32 audio_streams_seen; // distinct render clients ever created
|
||||||
|
AudioStreamInfo audio_streams[kMaxAudioStreams]; // kMaxAudioStreams = 4
|
||||||
|
|
||||||
|
struct AudioStreamInfo {
|
||||||
|
uint32 is_primary; // 1 = the stream we capture
|
||||||
|
uint32 sample_rate;
|
||||||
|
uint16 channels;
|
||||||
|
uint16 bits;
|
||||||
|
uint32 format_tag;
|
||||||
|
uint64 frames_rendered; // cumulative; host derives "live vs idle" from deltas
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- The hook assigns each distinct `IAudioRenderClient` a slot, marks the first as
|
||||||
|
`is_primary`, and bumps `frames_rendered` on each `ReleaseBuffer`.
|
||||||
|
- **Audio panel UI:** show `Render streams: N`, and a small table — one row per
|
||||||
|
stream with format + a frames counter, the primary row tagged, and rows whose
|
||||||
|
`frames_rendered` is advancing highlighted as live. This makes a multi-stream
|
||||||
|
game obvious at a glance.
|
||||||
|
- If `audio_streams_seen > kMaxAudioStreams` (more streams than slots), still show
|
||||||
|
the total count and note the overflow.
|
||||||
|
|
||||||
|
## Host changes
|
||||||
|
|
||||||
|
`AudioMirror` keeps its event-driven render client and prime/underrun logic almost
|
||||||
|
verbatim; only the **source** changes from the `ProcessLoopbackCapture` callback to
|
||||||
|
popping the shared audio ring. The host initializes its render client with the
|
||||||
|
*game's* published format and lets shared-mode WASAPI convert game-format →
|
||||||
|
endpoint. `audio_panel` gains a source indicator (Hooked vs Loopback) plus the
|
||||||
|
stream table above.
|
||||||
|
|
||||||
|
## Fallback — never regress
|
||||||
|
|
||||||
|
If the render hooks don't install, or `format_valid` never appears within ~1 s
|
||||||
|
(unusual COM setup, an uncaught backend, anti-cheat, etc.), the host
|
||||||
|
**automatically falls back to the existing process-loopback path** (works, but with
|
||||||
|
the echo) and surfaces that in the panel. Worst case = today's behavior.
|
||||||
|
`host/src/audio/process_loopback_capture.*` stays in the tree as the fallback.
|
||||||
|
|
||||||
|
## Known limitations (v1)
|
||||||
|
|
||||||
|
- **Primary stream only.** With multiple simultaneous render streams we capture +
|
||||||
|
silence only the first; secondaries stay local and unmirrored (graceful degrade,
|
||||||
|
no corruption). The debug view exists precisely to detect this. Per-stream rings
|
||||||
|
+ host-side mix is a follow-up if it ever matters.
|
||||||
|
- **`ActivateAudioInterfaceAsync` activation path not hooked in v1** (most games use
|
||||||
|
`IMMDevice::Activate`; the rest hit the loopback fallback).
|
||||||
|
- Exclusive-mode and DirectSound/XAudio2 backends still bottom out in an
|
||||||
|
`IAudioClient`, so they're covered; truly exotic backends fall back.
|
||||||
|
|
||||||
|
## Work breakdown
|
||||||
|
|
||||||
|
| File | Change | Size |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `common/include/coop/audio_ring.hpp` | NEW — ring layout + lock-free push/pop + format/flag | ~120 ln |
|
||||||
|
| `common/include/coop/protocol.hpp` | add `AudioStreamInfo` + audio diag fields to `HookStatus`; bump version 3→4 | small |
|
||||||
|
| `hook/src/audio_hook.{hpp,cpp}` | NEW — vtable discovery, install/remove, the 4 hooks, ring producer, stream counting | ~350 ln (the risk) |
|
||||||
|
| `hook/src/dllmain.cpp` | open `coop_audio_<pid>`, install audio hooks in worker loop | small |
|
||||||
|
| `hook/CMakeLists.txt` | link `ole32 mmdevapi`; COM-init the worker thread | small |
|
||||||
|
| `host/src/audio/audio_loopback.{cpp,hpp}` | "hooked ring" source mode + create mapping + auto-fallback | medium |
|
||||||
|
| `host/src/audio_panel.{cpp,hpp}` | source indicator + stream-count debug table | small |
|
||||||
|
| `host/CMakeLists.txt` | new sources | small |
|
||||||
|
| `tests/audio_hook_test.cpp` | NEW — in-process: render a tone, install hooks, assert ring gets non-silent frames AND local output went silent; assert stream count == 1 | ~150 ln |
|
||||||
|
|
||||||
|
The in-process self-test is the key de-risker: it exercises vtable discovery +
|
||||||
|
GetBuffer/ReleaseBuffer interception with no game and no second Steam account, the
|
||||||
|
same way `hook_selftest` covers the XInput core.
|
||||||
|
|
||||||
|
**Estimate: ~1.5–2 days.** `audio_hook.cpp` is the only real risk; the rest mirrors
|
||||||
|
patterns already in the repo.
|
||||||
|
|
||||||
|
## Milestones (build order)
|
||||||
|
|
||||||
|
1. `audio_ring.hpp` + `HookStatus` audio fields + host ring consumer + ring unit
|
||||||
|
test (no hook yet).
|
||||||
|
2. `audio_hook.cpp` + the in-process tone → ring → silenced self-test, including the
|
||||||
|
stream count. ← *proves the concept*
|
||||||
|
3. Wire into `dllmain` + host "hooked" mode + automatic loopback fallback.
|
||||||
|
4. Audio panel UI: source indicator + stream-count debug table.
|
||||||
|
5. Manual end-to-end in a real game: confirm no local echo, guest still hears audio,
|
||||||
|
and the stream count reads correctly.
|
||||||
|
|
||||||
|
If milestone 2 passes, the rest is plumbing.
|
||||||
@@ -13,6 +13,12 @@ target_link_libraries(hook_selftest PRIVATE
|
|||||||
|
|
||||||
add_test(NAME hook_selftest COMMAND hook_selftest)
|
add_test(NAME hook_selftest COMMAND hook_selftest)
|
||||||
|
|
||||||
|
# Unit test for the shared audio ring (lock-free SPSC push/pop, wrap-around,
|
||||||
|
# format handshake, overrun policy). Header-only, no hook or audio device.
|
||||||
|
add_executable(audio_ring_test audio_ring_test.cpp)
|
||||||
|
target_link_libraries(audio_ring_test PRIVATE coop_common)
|
||||||
|
add_test(NAME audio_ring_test COMMAND audio_ring_test)
|
||||||
|
|
||||||
# Integration test for WASAPI process-loopback capture. Reuses the shipping
|
# Integration test for WASAPI process-loopback capture. Reuses the shipping
|
||||||
# capture code and captures from coop_tone (a known sine-wave render process).
|
# capture code and captures from coop_tone (a known sine-wave render process).
|
||||||
add_executable(audio_loopback_test
|
add_executable(audio_loopback_test
|
||||||
|
|||||||
131
tests/audio_ring_test.cpp
Normal file
131
tests/audio_ring_test.cpp
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
// In-process unit test for the shared audio ring (coop/audio_ring.hpp). No
|
||||||
|
// shared memory, no hook, no audio device: it exercises the lock-free SPSC
|
||||||
|
// push/pop, wrap-around, the format handshake, and the overrun/drop policy in a
|
||||||
|
// single process. Exits 0 on pass, 1 on failure.
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <cstring>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "coop/audio_ring.hpp"
|
||||||
|
|
||||||
|
using namespace coop;
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
int g_failures = 0;
|
||||||
|
|
||||||
|
void check(bool ok, const char* what)
|
||||||
|
{
|
||||||
|
if (!ok)
|
||||||
|
{
|
||||||
|
std::printf(" FAIL: %s\n", what);
|
||||||
|
++g_failures;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allocate a ring of `capacity` data bytes in a plain heap buffer (placement-new
|
||||||
|
// the header so its atomics are constructed) and return it ready to use.
|
||||||
|
AudioRingHeader* make_ring(std::vector<std::uint8_t>& storage, std::uint32_t capacity)
|
||||||
|
{
|
||||||
|
storage.assign(audio_ring_total_size(capacity), 0);
|
||||||
|
auto* h = new (storage.data()) AudioRingHeader();
|
||||||
|
audio_ring_init(*h, capacity);
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main()
|
||||||
|
{
|
||||||
|
// --- Validity + format handshake. ---
|
||||||
|
{
|
||||||
|
std::vector<std::uint8_t> storage;
|
||||||
|
AudioRingHeader* h = make_ring(storage, 4096);
|
||||||
|
check(audio_ring_valid(*h), "freshly inited ring is valid");
|
||||||
|
check(!audio_ring_format_ready(*h), "format not ready before set");
|
||||||
|
audio_ring_set_format(*h, 48000, 2, 32, 3 /*IEEE_FLOAT*/, 8);
|
||||||
|
check(audio_ring_format_ready(*h), "format ready after set");
|
||||||
|
check(h->sample_rate == 48000 && h->channels == 2 && h->bits == 32 && h->format_tag == 3 &&
|
||||||
|
h->block_align == 8,
|
||||||
|
"format fields round-trip");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Basic push/pop integrity. ---
|
||||||
|
{
|
||||||
|
std::vector<std::uint8_t> storage;
|
||||||
|
AudioRingHeader* h = make_ring(storage, 4096);
|
||||||
|
|
||||||
|
std::uint8_t src[256];
|
||||||
|
for (int i = 0; i < 256; ++i)
|
||||||
|
{
|
||||||
|
src[i] = static_cast<std::uint8_t>(i);
|
||||||
|
}
|
||||||
|
check(audio_ring_push(*h, src, sizeof(src), 32), "push 256 bytes");
|
||||||
|
check(audio_ring_available(*h) == sizeof(src), "available == pushed");
|
||||||
|
check(h->frames_produced.load() == 32, "frames_produced tracked");
|
||||||
|
|
||||||
|
std::uint8_t dst[256] = {};
|
||||||
|
check(audio_ring_pop(*h, dst, sizeof(dst)) == sizeof(src), "pop returns all bytes");
|
||||||
|
check(std::memcmp(src, dst, sizeof(src)) == 0, "popped bytes match pushed");
|
||||||
|
check(audio_ring_available(*h) == 0, "ring empty after drain");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Wrap-around: keep pushing/popping past capacity to force a split copy. ---
|
||||||
|
{
|
||||||
|
const std::uint32_t cap = 1024;
|
||||||
|
std::vector<std::uint8_t> storage;
|
||||||
|
AudioRingHeader* h = make_ring(storage, cap);
|
||||||
|
|
||||||
|
std::uint8_t counter = 0;
|
||||||
|
std::uint8_t expect = 0;
|
||||||
|
const std::uint32_t chunk = 300; // not a divisor of cap, so offsets drift across the seam
|
||||||
|
for (int iter = 0; iter < 50; ++iter)
|
||||||
|
{
|
||||||
|
std::uint8_t buf[300];
|
||||||
|
for (std::uint32_t i = 0; i < chunk; ++i)
|
||||||
|
{
|
||||||
|
buf[i] = counter++;
|
||||||
|
}
|
||||||
|
check(audio_ring_push(*h, buf, chunk, chunk), "wrap push fits");
|
||||||
|
|
||||||
|
std::uint8_t out[300] = {};
|
||||||
|
check(audio_ring_pop(*h, out, chunk) == chunk, "wrap pop full chunk");
|
||||||
|
bool ok = true;
|
||||||
|
for (std::uint32_t i = 0; i < chunk; ++i)
|
||||||
|
{
|
||||||
|
ok = ok && out[i] == expect++;
|
||||||
|
}
|
||||||
|
check(ok, "wrap data integrity across the ring seam");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Overrun: a packet that doesn't fit is dropped whole, bumping overruns. ---
|
||||||
|
{
|
||||||
|
const std::uint32_t cap = 512;
|
||||||
|
std::vector<std::uint8_t> storage;
|
||||||
|
AudioRingHeader* h = make_ring(storage, cap);
|
||||||
|
|
||||||
|
std::vector<std::uint8_t> half(cap / 2, 0xAB);
|
||||||
|
check(audio_ring_push(*h, half.data(), cap / 2, 1), "first half fits");
|
||||||
|
check(audio_ring_push(*h, half.data(), cap / 2, 1), "second half fills ring");
|
||||||
|
check(audio_ring_available(*h) == cap, "ring full");
|
||||||
|
|
||||||
|
std::vector<std::uint8_t> more(16, 0xCD);
|
||||||
|
check(!audio_ring_push(*h, more.data(), 16, 1), "push into full ring rejected");
|
||||||
|
check(h->overruns.load() == 1, "overrun counted");
|
||||||
|
check(audio_ring_available(*h) == cap, "rejected push left ring untouched");
|
||||||
|
|
||||||
|
// Draining makes room again; subsequent pushes succeed.
|
||||||
|
std::vector<std::uint8_t> drain(cap, 0);
|
||||||
|
check(audio_ring_pop(*h, drain.data(), cap) == cap, "drain full ring");
|
||||||
|
check(audio_ring_push(*h, more.data(), 16, 1), "push succeeds after drain");
|
||||||
|
check(h->overruns.load() == 1, "overruns not bumped on success");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::printf(g_failures == 0 ? "AUDIO RING TEST PASS\n" : "AUDIO RING TEST FAILED (%d)\n", g_failures);
|
||||||
|
return g_failures == 0 ? 0 : 1;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user