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>
This commit is contained in:
75
host/src/audio/audio_loopback.hpp
Normal file
75
host/src/audio/audio_loopback.hpp
Normal file
@@ -0,0 +1,75 @@
|
||||
// Mirrors a target process's audio so Steam Remote Play Together (which streams
|
||||
// THIS host's audio) carries the real game's sound. Captures the game via WASAPI
|
||||
// process loopback and re-renders it on the default output endpoint.
|
||||
//
|
||||
// The real game keeps playing locally too, so the host hears it twice ("double
|
||||
// audio"); that's an accepted trade-off for now (see plan, Phase 2).
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
[[nodiscard]] std::string status() const;
|
||||
|
||||
private:
|
||||
void thread_main(DWORD pid);
|
||||
void set_status(std::string s);
|
||||
|
||||
std::thread thread_;
|
||||
HANDLE stop_event_ = nullptr;
|
||||
DWORD pid_ = 0;
|
||||
|
||||
std::atomic<bool> running_{false};
|
||||
std::atomic<unsigned> sample_rate_{0};
|
||||
std::atomic<unsigned> channels_{0};
|
||||
|
||||
mutable std::mutex status_mutex_;
|
||||
std::string status_;
|
||||
};
|
||||
|
||||
} // namespace coop
|
||||
Reference in New Issue
Block a user