Commit Graph

5 Commits

Author SHA1 Message Date
7264cb2ef4 Audio: detect a pre-existing render stream's true sample rate (fix pitch)
Hooked audio mirroring played back pitch-shifted on games we inject into
that render at a non-device sample rate (e.g. Godot/Brotato render 44100 Hz
on a 48000 Hz endpoint via WASAPI AUTOCONVERTPCM). We attach to an
already-running game, so the render-hook never saw its IAudioClient::
Initialize and assumed the device mix format -- right channels/bits, wrong
rate -- so 44100 audio was rendered as 48000 (+~1.5 semitones).

Fix: treat a pre-existing client's format as a guess and measure its true
sample rate from the render cadence (frames/sec over a steady-state window,
snapped to the nearest standard rate) before publishing it, deferring
capture until verified. Discard the first measurement window so the
buffer-fill burst at attach time doesn't over-count. Streams created after
we inject still carry their exact Initialize format.

Channels/bit-depth genuinely can't be recovered for a pre-existing client:
AUTOCONVERTPCM hands GetBuffer a fixed staging buffer (no buffer stride to
measure -- confirmed empirically) and WASAPI exposes no API for the format.
They stay the device-mix guess, which is correct for the common case
(engines render stereo float, matching the endpoint). To keep a wrong guess
safe, a VirtualQuery clamp stops the capture copy from ever over-reading the
source buffer when the guessed bytes/frame is too large.

Surface all of this: a per-stream AudioFormatState (known / measuring /
measured rate (ch/bits assumed)) in HookStatus, shown in the Audio panel for
the hooked path and as "device endpoint (known)" for loopback; clear hook
logs; and enriched mirror status strings. Documented in README (Limitations
+ Lessons learned). The loopback fallback was always correct (post-mix at
the device format).

Tests: extract a shared, configurable ToneSource (used by coop_tone and the
hook self-test); coop_tone takes rate/channels/bits/format args. Rewrite
audio_hook_test to a format matrix x both code paths -- see-init (exact) and
guess (rate measured) -- plus a byte-incompatible guess that asserts the
clamp keeps capture safe. The matrix caught the attach-burst over-count.
audio_loopback_test now spawns coop_tone at several source formats to
confirm loopback is format-agnostic. 11/11 x64 + 3/3 x86 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 23:41:57 +02:00
784c31a9b5 Capture every audio stream into its own ring and mix them on the host
Games with several concurrent WASAPI render streams (e.g. Spider-Man: Miles
Morales) only had their first ("primary") stream mirrored; the rest kept playing
locally and never reached the guest. Now the render-hook captures + silences EVERY
tracked stream into its own ring (coop_audio_<pid>[_<index>]), each published with
that stream's own detected format (Initialize when caught, else GetMixFormat -- the
per-stream format detection, now actually used per ring rather than only for the
primary). The host creates a ring per stream and mixes the same-format streams with
a soft clip (host/src/audio/audio_mix.hpp); streams whose format differs from the
primary are still silenced (no echo) but skipped from the mix (would need
resampling).

The single-stream case is byte-for-byte unchanged: when only one stream is active
the host passes it through without the mixer, so the common path has no overhead or
fidelity change.

Verified: new audio_mix_test covers the decode/sum/soft-clip/encode math (float32 +
int16); audio_hook_test (x64 + x86) still passes, guarding the primary
capture+silence path against regression; full build x64 + x86 clean; ctest x64
11/11, x86 3/3. Multi-stream mixing against a real multi-stream game needs a live
session to fully confirm.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 05:45:47 +02:00
d19974b17c Generalize UI: general status by default, diagnostics behind Debug details
Rework the spike-era debug readouts into general-purpose status, with the
verbose diagnostics gated behind the menu bar's "Debug details" switch:

- Controllers (was "Phase 0 spike"): always shows slot/source + live
  buttons; the raw stick/trigger numbers are debug-only.
- Injection hook status: general view is attached + focus spoof + a single
  "Game reading controller: N polls/s" summary; the per-slot poll table,
  focus-API counts, and input-path detection are debug-only.
- Audio: general view adds a "Buffered: N ms" health/latency proxy
  (AudioMirror now tracks buffered audio in both render paths) and keeps the
  render-stream count; the per-stream table is debug-only.

Default overlay is now clean general status; flip Debug details for the full
diagnostics. Completes the "generalize the UI" roadmap task. All tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 15:07:42 +02:00
679b243974 Audio render-hook M3: wire DLL + host hooked mode + loopback fallback
End-to-end plumbing of the render-hook audio path.

Hook (coop_hook.dll):
- CMake: build audio_hook.cpp, link ole32/mmdevapi, NTDDI_WIN10_CO.
- dllmain: CoInitializeEx(MTA) on the worker thread; install the audio hooks
  even before the ring exists (so streams are counted); open the host's
  coop_audio_<pid> ring when it appears and attach it (enabling capture+silence);
  remove_audio_hooks on clean detach.

Host (coop_host.exe):
- AudioMirror now creates the shared audio ring (owns capture_enabled) and tries
  the Hooked path first: waits ~1s for the hook to publish a format, then
  re-renders the game's frames from the ring with AUTOCONVERTPCM (no echo, since
  the hook silences the game locally).
- Automatic fallback: if the ring can't be created, no format arrives in time,
  or the render client won't initialize, it disables capture (so the game stays
  audible) and reverts to the existing process-loopback path (echo, no regress).
- Exposes Source (Hooked/Loopback/None) for the upcoming panel indicator.

Loopback render loop kept intact as run_loopback. Manual end-to-end (M5) and the
panel UI (M4) are next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 12:02:29 +02:00
663d86e6ec Phase 2: audio mirror via WASAPI process loopback
Mirror the real game's audio so Steam Remote Play Together (which streams the
host's own audio session) carries it to the guest. The host captures the game
by PID via WASAPI process loopback and re-renders it on the default endpoint;
the game still plays locally too (accepted "double audio" for now).

- ProcessLoopbackCapture: process-loopback capture client, frame-sink + stats.
  The completion handler must be agile (IAgileObject) or
  ActivateAudioInterfaceAsync rejects every call with E_ILLEGAL_METHOD_CALL.
- AudioMirror: wraps capture with an event-driven render client and a primed
  ring buffer; AudioPanel drives it from the injected game's window/PID.
- coop_tone: standalone WASAPI sine-wave process used as a known audio source.
- audio_loopback_test (CTest): captures coop_tone by PID and asserts non-silent
  audio arrives, so the path is verifiable without a second Steam account.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 10:21:27 +02:00