Files
CoopAllTheThings/host/src/audio/audio_loopback.hpp
BlackMark 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

126 lines
3.5 KiB
C++

// Mirrors a target process's audio so Steam Remote Play Together (which streams
// THIS host's audio) carries the real game's sound, re-rendering it on the
// default output endpoint.
//
// Two source paths (see docs/audio-render-hook-plan.md):
// - Hooked: the injected render-hook copies the game's frames into a shared
// audio ring AND silences the game locally, so there is no echo. Preferred.
// - Loopback: WASAPI process-loopback capture of the game (the game still
// plays locally, so the operator hears it twice). Automatic fallback when
// the hook isn't present / doesn't publish a format in time.
#pragma once
#include <atomic>
#include <mutex>
#include <string>
#include <thread>
#include <windows.h>
#include "coop/audio_ring.hpp"
#include "coop/protocol.hpp" // kMaxAudioStreams
#include "coop/shared_memory.hpp"
namespace coop
{
class AudioMirror
{
public:
AudioMirror() = default;
~AudioMirror();
AudioMirror(const AudioMirror&) = delete;
AudioMirror& operator=(const AudioMirror&) = delete;
// Start mirroring audio from `pid` (and its child processes). Replaces any
// running mirror. Returns false only on synchronous setup failure; capture
// errors surface asynchronously via status().
bool start(DWORD pid);
void stop();
// True once the audio thread is actively mirroring (false while starting or
// after a failure).
[[nodiscard]] bool running() const
{
return running_.load(std::memory_order_acquire);
}
// The process currently targeted (0 if stopped). Updated synchronously by
// start()/stop() so the UI can detect target changes without races.
[[nodiscard]] DWORD target_pid() const
{
return pid_;
}
[[nodiscard]] unsigned sample_rate() const
{
return sample_rate_.load(std::memory_order_relaxed);
}
[[nodiscard]] unsigned channels() const
{
return channels_.load(std::memory_order_relaxed);
}
// Audio currently buffered between capture and the output device, in ms — a
// health/latency proxy (rises if the consumer can't keep up). 0 when stopped.
[[nodiscard]] unsigned buffered_ms() const
{
return buffered_ms_.load(std::memory_order_relaxed);
}
// Which capture path is active, for the UI's source indicator.
enum class Source
{
None,
Hooked, // shared audio ring from the render-hook (no echo)
Loopback, // WASAPI process loopback (echo)
};
[[nodiscard]] Source source() const
{
return source_.load(std::memory_order_relaxed);
}
[[nodiscard]] const char* source_name() const
{
switch (source())
{
case Source::Hooked:
return "Hooked (no echo)";
case Source::Loopback:
return "Loopback (echo)";
default:
return "—";
}
}
[[nodiscard]] std::string status() const;
private:
void thread_main(DWORD pid);
// Returns true if it owned the session to a clean stop; false if setup failed
// and the caller should fall back to the loopback path. `rings[0]` is the primary
// stream; additional non-null rings are mixed in.
bool run_hooked(AudioRingHeader* const* rings);
void run_loopback(DWORD pid);
bool wait_for_format(AudioRingHeader* ring, DWORD timeout_ms);
bool stop_requested() const;
void set_status(std::string s);
std::thread thread_;
HANDLE stop_event_ = nullptr;
DWORD pid_ = 0;
SharedMemory audio_ring_shm_[kMaxAudioStreams]; // per-stream rings (coop_audio_<pid>[_<i>])
std::atomic<bool> running_{false};
std::atomic<Source> source_{Source::None};
std::atomic<unsigned> sample_rate_{0};
std::atomic<unsigned> channels_{0};
std::atomic<unsigned> buffered_ms_{0};
mutable std::mutex status_mutex_;
std::string status_;
};
} // namespace coop