Since guessed streams are now captured-but-not-silenced (the over-write fix), the hooked path is only no-echo for an exact/override format. The panel inferred "Hooked (no echo)" unconditionally, which was misleading. Show "Hooked (echo -- guessed format)" (amber) for a guessed primary stream and "Hooked (no echo)" (green) only for exact/override; drop the unconditional "no echo" from source_name() and the mirror status string. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
162 lines
5.5 KiB
C++
162 lines
5.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 <vector>
|
|
|
|
#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"; // 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_<pid>[_<i>])
|
|
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<PendingOp> pending_ops_;
|
|
|
|
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_;
|
|
std::string fallback_reason_; // why loopback is active (shown in the panel); guarded by status_mutex_
|
|
};
|
|
|
|
} // namespace coop
|