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
|
||||
// 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.
|
||||
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");
|
||||
|
||||
// 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.
|
||||
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_sink; // ... and that usage has RIDEV_INPUTSINK (bg delivery)
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user