// 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 #include #include #include #include #include #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"; // echo depends on the format provenance; the panel shows it case Source::Loopback: return "Loopback (echo)"; default: return "—"; } } [[nodiscard]] std::string status() const; // Why the loopback (echo) path is active instead of the hooked one, for the Audio // panel. Empty when on the hooked path or before any fallback decision. [[nodiscard]] std::string fallback_reason() const; // Post an operator command (AudioRingOp) to the hook for stream `slot` -- re-measure // the rate or override the format. Thread-safe; queued and applied to the ring on the // audio thread (which owns the ring mappings). For a re-measure the format args are 0. void request_op(unsigned slot, std::uint32_t kind, std::uint32_t rate = 0, std::uint32_t channels = 0, std::uint32_t bits = 0, std::uint32_t format_tag = 0); private: void thread_main(DWORD pid); // Outcome of a hooked render session. enum class HookedResult { Stopped, // clean stop (mirror stopping) -> done Failed, // setup failed (format not renderable) -> caller falls back to loopback Reinit, // the hook re-published the format (re-measure/override) -> re-read and retry }; // Runs the hooked (no-echo) render path until stop, a setup failure, or a format change // (re-measure/override). `rings[0]` is the primary stream; additional non-null rings are // mixed in. HookedResult run_hooked(AudioRingHeader* const* rings); // Loopback (echo) capture. If `promote_ring` is non-null, returns true the moment // that ring's format becomes ready (the hook caught up -> caller promotes to hooked); // returns false when stopped. With a null ring it only returns false (on stop). bool run_loopback(DWORD pid, AudioRingHeader* promote_ring); bool wait_for_format(AudioRingHeader* ring, DWORD timeout_ms); static void enable_capture(AudioRingHeader* const* rings, bool on); void drain_ops(); // audio thread: post queued operator ops to the session rings bool stop_requested() const; void set_status(std::string s); void set_fallback_reason(std::string s); std::thread thread_; HANDLE stop_event_ = nullptr; DWORD pid_ = 0; SharedMemory audio_ring_shm_[kMaxAudioStreams]; // per-stream rings (coop_audio_[_]) AudioRingHeader* session_rings_[kMaxAudioStreams] = {}; // set on the audio thread for the session // Operator ops queued by request_op (any thread) and applied to the rings on the // audio thread (which owns the mappings). Guarded by ops_mutex_. struct PendingOp { unsigned slot; std::uint32_t kind, rate, channels, bits, format_tag; }; std::mutex ops_mutex_; std::vector pending_ops_; std::atomic running_{false}; std::atomic source_{Source::None}; std::atomic sample_rate_{0}; std::atomic channels_{0}; std::atomic buffered_ms_{0}; mutable std::mutex status_mutex_; std::string status_; std::string fallback_reason_; // why loopback is active (shown in the panel); guarded by status_mutex_ }; } // namespace coop