From 8e93312925d93da3b189f37ecb130f686b44b139 Mon Sep 17 00:00:00 2001 From: BlackMark Date: Fri, 19 Jun 2026 11:49:18 +0200 Subject: [PATCH] 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 --- common/include/coop/audio_ring.hpp | 194 +++++++++++++++++++++++++ common/include/coop/protocol.hpp | 26 +++- docs/audio-render-hook-plan.md | 223 +++++++++++++++++++++++++++++ tests/CMakeLists.txt | 6 + tests/audio_ring_test.cpp | 131 +++++++++++++++++ 5 files changed, 579 insertions(+), 1 deletion(-) create mode 100644 common/include/coop/audio_ring.hpp create mode 100644 docs/audio-render-hook-plan.md create mode 100644 tests/audio_ring_test.cpp diff --git a/common/include/coop/audio_ring.hpp b/common/include/coop/audio_ring.hpp new file mode 100644 index 0000000..bce7abd --- /dev/null +++ b/common/include/coop/audio_ring.hpp @@ -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 +#include +#include +#include +#include + +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_. +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 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 format_valid; + std::atomic 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 write_pos; // producer cursor, free-running + std::atomic read_pos; // consumer cursor, free-running + std::atomic frames_produced; // cumulative frames pushed + std::atomic 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::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(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(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(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(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(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(w - r); + bytes = std::min(bytes, avail); + const std::uint8_t* data = audio_ring_data(&h); + const std::uint32_t off = static_cast(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(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 diff --git a/common/include/coop/protocol.hpp b/common/include/coop/protocol.hpp index 0daefad..71b911e 100644 --- a/common/include/coop/protocol.hpp +++ b/common/include/coop/protocol.hpp @@ -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 diff --git a/docs/audio-render-hook-plan.md b/docs/audio-render-hook-plan.md new file mode 100644 index 0000000..5043c35 --- /dev/null +++ b/docs/audio-render-hook-plan.md @@ -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_`, ~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 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_`, 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. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 38932ba..d0627fb 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -13,6 +13,12 @@ target_link_libraries(hook_selftest PRIVATE 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 # capture code and captures from coop_tone (a known sine-wave render process). add_executable(audio_loopback_test diff --git a/tests/audio_ring_test.cpp b/tests/audio_ring_test.cpp new file mode 100644 index 0000000..d05c0bf --- /dev/null +++ b/tests/audio_ring_test.cpp @@ -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 +#include +#include +#include +#include + +#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& 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 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 storage; + AudioRingHeader* h = make_ring(storage, 4096); + + std::uint8_t src[256]; + for (int i = 0; i < 256; ++i) + { + src[i] = static_cast(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 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 storage; + AudioRingHeader* h = make_ring(storage, cap); + + std::vector 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 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 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; +}