diff --git a/CMakeLists.txt b/CMakeLists.txt index 4e145af..c737c04 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -44,5 +44,6 @@ if(COOP_BUILD_HOOK) add_subdirectory(third_party/safetyhook) add_subdirectory(hook) enable_testing() + add_subdirectory(tools/audio_tone) # coop_tone: audio source for the loopback test add_subdirectory(tests) endif() diff --git a/host/CMakeLists.txt b/host/CMakeLists.txt index 5ce82db..e863bab 100644 --- a/host/CMakeLists.txt +++ b/host/CMakeLists.txt @@ -5,15 +5,22 @@ add_executable(coop_host WIN32 src/debug_overlay.cpp src/injection_panel.cpp src/capture_panel.cpp + src/audio_panel.cpp src/input/xinput_source.cpp src/inject/process_list.cpp src/inject/injector.cpp src/ipc/ipc_server.cpp src/capture/frame_renderer.cpp - src/capture/window_capture.cpp) + src/capture/window_capture.cpp + src/audio/audio_loopback.cpp + src/audio/process_loopback_capture.cpp) target_include_directories(coop_host PRIVATE src) +# Process-loopback capture (AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK) needs the +# Windows 10 20H1 (NTDDI_WIN10_CO) headers; raise the target SDK version for it. +target_compile_definitions(coop_host PRIVATE NTDDI_VERSION=0x0A00000B) + target_link_libraries(coop_host PRIVATE coop_common imgui @@ -22,6 +29,8 @@ target_link_libraries(coop_host PRIVATE dwmapi d3dcompiler windowsapp - xinput) + xinput + mmdevapi + ole32) set_target_properties(coop_host PROPERTIES OUTPUT_NAME "coop_host") diff --git a/host/src/audio/audio_loopback.cpp b/host/src/audio/audio_loopback.cpp new file mode 100644 index 0000000..0ae8569 --- /dev/null +++ b/host/src/audio/audio_loopback.cpp @@ -0,0 +1,355 @@ +#include "audio/audio_loopback.hpp" + +#include +#include +#include +#include + +#include +#include +#include + +#include "audio/process_loopback_capture.hpp" + +namespace coop +{ +namespace +{ + +// Single-producer/single-consumer byte FIFO guarded by a mutex (the capture +// thread pushes, the render thread pops). Overflow drops the oldest samples. +struct ByteRing +{ + std::mutex mutex; + std::vector buf; + size_t head = 0; + size_t count = 0; + + void init(size_t capacity) + { + buf.assign(capacity, 0); + head = 0; + count = 0; + } + + void drop_for(size_t incoming) + { + if (count + incoming > buf.size()) + { + const size_t drop = count + incoming - buf.size(); + head = (head + drop) % buf.size(); + count -= drop; + } + } + + void push(const BYTE* data, size_t bytes, bool silent) + { + std::lock_guard lock(mutex); + if (bytes > buf.size()) + { + if (data) + { + data += bytes - buf.size(); + } + bytes = buf.size(); + } + drop_for(bytes); + const size_t tail = (head + count) % buf.size(); + const size_t first = std::min(bytes, buf.size() - tail); + if (silent || !data) + { + std::memset(&buf[tail], 0, first); + if (bytes > first) + { + std::memset(&buf[0], 0, bytes - first); + } + } + else + { + std::memcpy(&buf[tail], data, first); + if (bytes > first) + { + std::memcpy(&buf[0], data + first, bytes - first); + } + } + count += bytes; + } + + size_t available() const + { + return count; + } + + // Copy up to `bytes` into `dst`; returns how many bytes were available. + size_t pop(BYTE* dst, size_t bytes) + { + std::lock_guard lock(mutex); + bytes = std::min(bytes, count); + const size_t first = std::min(bytes, buf.size() - head); + std::memcpy(dst, &buf[head], first); + if (bytes > first) + { + std::memcpy(dst + first, &buf[0], bytes - first); + } + head = (head + bytes) % buf.size(); + count -= bytes; + return bytes; + } +}; + +} // namespace + +AudioMirror::~AudioMirror() +{ + stop(); +} + +std::string AudioMirror::status() const +{ + std::lock_guard lock(status_mutex_); + return status_; +} + +void AudioMirror::set_status(std::string s) +{ + std::lock_guard lock(status_mutex_); + status_ = std::move(s); +} + +bool AudioMirror::start(DWORD pid) +{ + stop(); + if (!pid) + { + set_status("No target process."); + return false; + } + stop_event_ = CreateEventW(nullptr, TRUE, FALSE, nullptr); + if (!stop_event_) + { + set_status("CreateEvent failed."); + return false; + } + pid_ = pid; + set_status("Starting…"); + thread_ = std::thread(&AudioMirror::thread_main, this, pid); + return true; +} + +void AudioMirror::stop() +{ + if (stop_event_) + { + SetEvent(stop_event_); + } + if (thread_.joinable()) + { + thread_.join(); + } + if (stop_event_) + { + CloseHandle(stop_event_); + stop_event_ = nullptr; + } + running_.store(false, std::memory_order_release); + pid_ = 0; +} + +void AudioMirror::thread_main(DWORD pid) +{ + const bool com_ok = SUCCEEDED(CoInitializeEx(nullptr, COINIT_MULTITHREADED)); + + IMMDeviceEnumerator* enumerator = nullptr; + IMMDevice* endpoint = nullptr; + IAudioClient* render_client = nullptr; + IAudioRenderClient* render = nullptr; + WAVEFORMATEX* fmt = nullptr; + HANDLE render_event = nullptr; + ProcessLoopbackCapture capture; + + auto fail = [&](const char* msg, HRESULT hr) { + char buf[160]; + std::snprintf(buf, sizeof(buf), "%s (0x%08lX)", msg, static_cast(hr)); + set_status(buf); + }; + + do + { + HRESULT hr = CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, + __uuidof(IMMDeviceEnumerator), reinterpret_cast(&enumerator)); + if (FAILED(hr)) + { + fail("CoCreateInstance(MMDeviceEnumerator)", hr); + break; + } + hr = enumerator->GetDefaultAudioEndpoint(eRender, eConsole, &endpoint); + if (FAILED(hr)) + { + fail("GetDefaultAudioEndpoint", hr); + break; + } + hr = endpoint->Activate(__uuidof(IAudioClient), CLSCTX_ALL, nullptr, + reinterpret_cast(&render_client)); + if (FAILED(hr)) + { + fail("Activate render client", hr); + break; + } + // Capture and render share one format (the output endpoint's mix format); + // WASAPI converts the captured process audio into it. + hr = render_client->GetMixFormat(&fmt); + if (FAILED(hr)) + { + fail("GetMixFormat", hr); + break; + } + sample_rate_.store(fmt->nSamplesPerSec, std::memory_order_relaxed); + channels_.store(fmt->nChannels, std::memory_order_relaxed); + + render_event = CreateEventW(nullptr, FALSE, FALSE, nullptr); + if (!render_event) + { + fail("CreateEvent(render)", HRESULT_FROM_WIN32(GetLastError())); + break; + } + + constexpr REFERENCE_TIME kRenderBuffer = 30 * 10000; // 30 ms, in 100-ns units + hr = render_client->Initialize(AUDCLNT_SHAREMODE_SHARED, AUDCLNT_STREAMFLAGS_EVENTCALLBACK, + kRenderBuffer, 0, fmt, nullptr); + if (FAILED(hr)) + { + fail("Render Initialize", hr); + break; + } + hr = render_client->SetEventHandle(render_event); + if (FAILED(hr)) + { + fail("Render SetEventHandle", hr); + break; + } + hr = render_client->GetService(__uuidof(IAudioRenderClient), reinterpret_cast(&render)); + if (FAILED(hr)) + { + fail("GetService(RenderClient)", hr); + break; + } + UINT32 render_frames = 0; + hr = render_client->GetBufferSize(&render_frames); + if (FAILED(hr)) + { + fail("GetBufferSize", hr); + break; + } + + const size_t frame_bytes = fmt->nBlockAlign; + ByteRing ring; + ring.init(frame_bytes * fmt->nSamplesPerSec); // ~1 s of slack + // Build ~30 ms of buffer before feeding the renderer, and rebuild it after + // an underrun, so brief capture gaps don't continuously glitch. + const size_t prime_bytes = frame_bytes * (fmt->nSamplesPerSec * 30 / 1000); + bool primed = false; + + // Capture pushes packets straight into the render ring. + if (!capture.start(pid, fmt, [&ring, frame_bytes](const BYTE* data, UINT32 frames, bool silent) { + ring.push(data, static_cast(frames) * frame_bytes, silent); + })) + { + fail("Capture start", E_FAIL); + break; + } + + if (FAILED(hr = render_client->Start())) + { + fail("Render Start", hr); + break; + } + + set_status("Mirroring."); + running_.store(true, std::memory_order_release); + + HANDLE waits[2] = {stop_event_, render_event}; + for (;;) + { + const DWORD w = WaitForMultipleObjects(2, waits, FALSE, 200); + if (w == WAIT_OBJECT_0) + { + break; + } + if (!capture.running()) + { + set_status(capture.status()); + break; + } + + UINT32 padding = 0; + if (FAILED(render_client->GetCurrentPadding(&padding))) + { + continue; + } + const UINT32 avail = render_frames - padding; + if (!primed && ring.available() >= prime_bytes) + { + primed = true; + } + if (primed && avail > 0) + { + const UINT32 have = static_cast(ring.available() / frame_bytes); + const UINT32 to_write = std::min(avail, have); + if (to_write > 0) + { + BYTE* dst = nullptr; + if (SUCCEEDED(render->GetBuffer(to_write, &dst))) + { + ring.pop(dst, static_cast(to_write) * frame_bytes); + render->ReleaseBuffer(to_write, 0); + } + } + if (to_write < avail) + { + primed = false; // ran dry; rebuffer before resuming + } + } + } + + render_client->Stop(); + } while (false); + + capture.stop(); + + if (running_.load(std::memory_order_acquire)) + { + running_.store(false, std::memory_order_release); + set_status("Stopped."); + } + + if (render) + { + render->Release(); + } + if (render_client) + { + render_client->Release(); + } + if (endpoint) + { + endpoint->Release(); + } + if (enumerator) + { + enumerator->Release(); + } + if (fmt) + { + CoTaskMemFree(fmt); + } + if (render_event) + { + CloseHandle(render_event); + } + if (com_ok) + { + CoUninitialize(); + } +} + +} // namespace coop diff --git a/host/src/audio/audio_loopback.hpp b/host/src/audio/audio_loopback.hpp new file mode 100644 index 0000000..1f60a15 --- /dev/null +++ b/host/src/audio/audio_loopback.hpp @@ -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 +#include +#include +#include + +#include + +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 running_{false}; + std::atomic sample_rate_{0}; + std::atomic channels_{0}; + + mutable std::mutex status_mutex_; + std::string status_; +}; + +} // namespace coop diff --git a/host/src/audio/process_loopback_capture.cpp b/host/src/audio/process_loopback_capture.cpp new file mode 100644 index 0000000..76d81c7 --- /dev/null +++ b/host/src/audio/process_loopback_capture.cpp @@ -0,0 +1,356 @@ +#include "audio/process_loopback_capture.hpp" + +#include + +#include +#include +#include + +namespace coop +{ +namespace +{ + +// Completion handler for ActivateAudioInterfaceAsync. The call is async even when +// used synchronously: it signals `done`, and the caller waits on it. +class ActivateHandler : public IActivateAudioInterfaceCompletionHandler +{ +public: + HANDLE done = CreateEventW(nullptr, FALSE, FALSE, nullptr); + HRESULT result = E_FAIL; + IAudioClient* client = nullptr; + + STDMETHODIMP ActivateCompleted(IActivateAudioInterfaceAsyncOperation* op) override + { + HRESULT activate_hr = E_FAIL; + IUnknown* punk = nullptr; + HRESULT hr = op->GetActivateResult(&activate_hr, &punk); + if (SUCCEEDED(hr)) + { + hr = activate_hr; + } + if (SUCCEEDED(hr) && punk) + { + hr = punk->QueryInterface(__uuidof(IAudioClient), reinterpret_cast(&client)); + } + if (punk) + { + punk->Release(); + } + result = hr; + SetEvent(done); + return S_OK; + } + + STDMETHODIMP QueryInterface(REFIID riid, void** ppv) override + { + if (riid == __uuidof(IUnknown) || riid == __uuidof(IActivateAudioInterfaceCompletionHandler)) + { + *ppv = static_cast(this); + AddRef(); + return S_OK; + } + // Mark the handler agile; ActivateAudioInterfaceAsync requires an agile + // completion handler and otherwise rejects the call (E_ILLEGAL_METHOD_CALL). + if (riid == __uuidof(IAgileObject)) + { + *ppv = static_cast(this); + AddRef(); + return S_OK; + } + *ppv = nullptr; + return E_NOINTERFACE; + } + STDMETHODIMP_(ULONG) AddRef() override + { + return ++ref_; + } + STDMETHODIMP_(ULONG) Release() override + { + const ULONG r = --ref_; + if (r == 0) + { + delete this; + } + return r; + } + +private: + ~ActivateHandler() + { + if (done) + { + CloseHandle(done); + } + } + std::atomic ref_{1}; +}; + +HRESULT activate_loopback_client(DWORD pid, IAudioClient** out) +{ + AUDIOCLIENT_ACTIVATION_PARAMS params = {}; + params.ActivationType = AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK; + params.ProcessLoopbackParams.TargetProcessId = pid; + params.ProcessLoopbackParams.ProcessLoopbackMode = PROCESS_LOOPBACK_MODE_INCLUDE_TARGET_PROCESS_TREE; + + PROPVARIANT pv = {}; + pv.vt = VT_BLOB; + pv.blob.cbSize = sizeof(params); + pv.blob.pBlobData = reinterpret_cast(¶ms); + + auto* handler = new ActivateHandler(); + HRESULT hr = E_FAIL; + if (handler->done) + { + IActivateAudioInterfaceAsyncOperation* op = nullptr; + hr = ActivateAudioInterfaceAsync(VIRTUAL_AUDIO_DEVICE_PROCESS_LOOPBACK, __uuidof(IAudioClient), + &pv, handler, &op); + if (SUCCEEDED(hr)) + { + WaitForSingleObject(handler->done, INFINITE); + hr = handler->result; + if (SUCCEEDED(hr)) + { + *out = handler->client; // transfer the QueryInterface reference + } + else if (handler->client) + { + handler->client->Release(); + } + } + if (op) + { + op->Release(); + } + } + handler->Release(); + return hr; +} + +} // namespace + +WAVEFORMATEX* default_render_format() +{ + IMMDeviceEnumerator* enumerator = nullptr; + if (FAILED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, + __uuidof(IMMDeviceEnumerator), reinterpret_cast(&enumerator)))) + { + return nullptr; + } + IMMDevice* endpoint = nullptr; + WAVEFORMATEX* fmt = nullptr; + if (SUCCEEDED(enumerator->GetDefaultAudioEndpoint(eRender, eConsole, &endpoint))) + { + IAudioClient* client = nullptr; + if (SUCCEEDED(endpoint->Activate(__uuidof(IAudioClient), CLSCTX_ALL, nullptr, + reinterpret_cast(&client)))) + { + client->GetMixFormat(&fmt); + client->Release(); + } + endpoint->Release(); + } + enumerator->Release(); + return fmt; +} + +ProcessLoopbackCapture::~ProcessLoopbackCapture() +{ + stop(); +} + +std::string ProcessLoopbackCapture::status() const +{ + std::lock_guard lock(status_mutex_); + return status_; +} + +void ProcessLoopbackCapture::set_status(std::string s) +{ + std::lock_guard lock(status_mutex_); + status_ = std::move(s); +} + +bool ProcessLoopbackCapture::start(DWORD pid, const WAVEFORMATEX* format, FrameSink sink) +{ + stop(); + if (!pid || !format) + { + set_status("No target/format."); + return false; + } + stop_event_ = CreateEventW(nullptr, TRUE, FALSE, nullptr); + if (!stop_event_) + { + set_status("CreateEvent failed."); + return false; + } + frames_captured_.store(0, std::memory_order_relaxed); + nonsilent_frames_.store(0, std::memory_order_relaxed); + + std::vector fmt_copy(sizeof(WAVEFORMATEX) + format->cbSize); + std::memcpy(fmt_copy.data(), format, fmt_copy.size()); + + set_status("Starting…"); + thread_ = std::thread(&ProcessLoopbackCapture::thread_main, this, pid, std::move(fmt_copy), + std::move(sink)); + return true; +} + +void ProcessLoopbackCapture::stop() +{ + if (stop_event_) + { + SetEvent(stop_event_); + } + if (thread_.joinable()) + { + thread_.join(); + } + if (stop_event_) + { + CloseHandle(stop_event_); + stop_event_ = nullptr; + } + running_.store(false, std::memory_order_release); +} + +void ProcessLoopbackCapture::thread_main(DWORD pid, std::vector format, FrameSink sink) +{ + const bool com_ok = SUCCEEDED(CoInitializeEx(nullptr, COINIT_MULTITHREADED)); + const auto* fmt = reinterpret_cast(format.data()); + const size_t frame_bytes = fmt->nBlockAlign; + + IAudioClient* client = nullptr; + IAudioCaptureClient* capture = nullptr; + HANDLE capture_event = nullptr; + + auto fail = [&](const char* msg, HRESULT hr) { + char buf[160]; + std::snprintf(buf, sizeof(buf), "%s (0x%08lX)", msg, static_cast(hr)); + set_status(buf); + }; + + do + { + HRESULT hr = activate_loopback_client(pid, &client); + if (FAILED(hr)) + { + fail("Process loopback activate", hr); + break; + } + + capture_event = CreateEventW(nullptr, FALSE, FALSE, nullptr); + if (!capture_event) + { + fail("CreateEvent(capture)", HRESULT_FROM_WIN32(GetLastError())); + break; + } + + // Process loopback requires shared mode, the LOOPBACK + EVENTCALLBACK flags, + // and zero buffer/periodicity (there is no device period to query). + hr = client->Initialize(AUDCLNT_SHAREMODE_SHARED, + AUDCLNT_STREAMFLAGS_LOOPBACK | AUDCLNT_STREAMFLAGS_EVENTCALLBACK, 0, 0, + fmt, nullptr); + if (FAILED(hr)) + { + fail("Capture Initialize", hr); + break; + } + hr = client->SetEventHandle(capture_event); + if (FAILED(hr)) + { + fail("Capture SetEventHandle", hr); + break; + } + hr = client->GetService(__uuidof(IAudioCaptureClient), reinterpret_cast(&capture)); + if (FAILED(hr)) + { + fail("GetService(CaptureClient)", hr); + break; + } + if (FAILED(hr = client->Start())) + { + fail("Capture Start", hr); + break; + } + + set_status("Capturing."); + running_.store(true, std::memory_order_release); + + HANDLE waits[2] = {stop_event_, capture_event}; + for (;;) + { + const DWORD w = WaitForMultipleObjects(2, waits, FALSE, 200); + if (w == WAIT_OBJECT_0) + { + break; + } + + UINT32 packet = 0; + while (SUCCEEDED(capture->GetNextPacketSize(&packet)) && packet > 0) + { + BYTE* data = nullptr; + UINT32 frames = 0; + DWORD flags = 0; + if (FAILED(capture->GetBuffer(&data, &frames, &flags, nullptr, nullptr))) + { + break; + } + const bool silent = (flags & AUDCLNT_BUFFERFLAGS_SILENT) != 0; + frames_captured_.fetch_add(frames, std::memory_order_relaxed); + if (!silent && frames > 0) + { + // Count frames that carry any non-zero sample. + const BYTE* p = data; + const BYTE* end = data + static_cast(frames) * frame_bytes; + bool any = false; + for (; p < end; ++p) + { + if (*p != 0) + { + any = true; + break; + } + } + if (any) + { + nonsilent_frames_.fetch_add(frames, std::memory_order_relaxed); + } + } + if (sink) + { + sink(data, frames, silent); + } + capture->ReleaseBuffer(frames); + } + } + + client->Stop(); + } while (false); + + if (running_.load(std::memory_order_acquire)) + { + running_.store(false, std::memory_order_release); + set_status("Stopped."); + } + + if (capture) + { + capture->Release(); + } + if (client) + { + client->Release(); + } + if (capture_event) + { + CloseHandle(capture_event); + } + if (com_ok) + { + CoUninitialize(); + } +} + +} // namespace coop diff --git a/host/src/audio/process_loopback_capture.hpp b/host/src/audio/process_loopback_capture.hpp new file mode 100644 index 0000000..9209b6c --- /dev/null +++ b/host/src/audio/process_loopback_capture.hpp @@ -0,0 +1,74 @@ +// WASAPI process-loopback capture: pulls the audio rendered by a target process +// (and its child processes) without injecting into it. Self-contained so the +// integration test can drive it directly against a known tone-generator process. +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include // WAVEFORMATEX + +namespace coop +{ + +// Default render endpoint mix format (caller owns the returned pointer; free with +// CoTaskMemFree). Returns nullptr on failure. Requires a COM-initialized thread. +WAVEFORMATEX* default_render_format(); + +class ProcessLoopbackCapture +{ +public: + // Called on the capture thread for each delivered packet. `silent` means the + // engine flagged the packet as silence (data may be undefined). + using FrameSink = std::function; + + ProcessLoopbackCapture() = default; + ~ProcessLoopbackCapture(); + + ProcessLoopbackCapture(const ProcessLoopbackCapture&) = delete; + ProcessLoopbackCapture& operator=(const ProcessLoopbackCapture&) = delete; + + // Capture `pid`'s audio as `format`, delivering packets to `sink`. Replaces any + // running capture. Returns false on synchronous setup failure (rare); WASAPI + // errors surface asynchronously via running()/status(). + bool start(DWORD pid, const WAVEFORMATEX* format, FrameSink sink); + void stop(); + + [[nodiscard]] bool running() const + { + return running_.load(std::memory_order_acquire); + } + [[nodiscard]] std::string status() const; + + [[nodiscard]] std::uint64_t frames_captured() const + { + return frames_captured_.load(std::memory_order_relaxed); + } + [[nodiscard]] std::uint64_t nonsilent_frames() const + { + return nonsilent_frames_.load(std::memory_order_relaxed); + } + +private: + void thread_main(DWORD pid, std::vector format, FrameSink sink); + void set_status(std::string s); + + std::thread thread_; + HANDLE stop_event_ = nullptr; + + std::atomic running_{false}; + std::atomic frames_captured_{0}; + std::atomic nonsilent_frames_{0}; + + mutable std::mutex status_mutex_; + std::string status_; +}; + +} // namespace coop diff --git a/host/src/audio_panel.cpp b/host/src/audio_panel.cpp new file mode 100644 index 0000000..1f7b44b --- /dev/null +++ b/host/src/audio_panel.cpp @@ -0,0 +1,60 @@ +#include "audio_panel.hpp" + +#include "imgui.h" + +namespace coop +{ + +void AudioPanel::draw_ui() +{ + const bool have_target = target_ != nullptr && IsWindow(target_); + DWORD pid = 0; + if (have_target) + { + GetWindowThreadProcessId(target_, &pid); + } + + ImGui::SetNextWindowPos(ImVec2(460, 300), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(360, 0), ImGuiCond_FirstUseEver); + ImGui::Begin("Audio mirror"); + + ImGui::BeginDisabled(!have_target); + if (ImGui::Checkbox("Mirror game audio", &enabled_)) + { + if (!enabled_) + { + mirror_.stop(); + } + } + ImGui::EndDisabled(); + if (!have_target) + { + ImGui::TextDisabled("Inject into a game first (its audio is the source)."); + } + + // Start when enabled and the target process changes; stop if it disappears. + if (enabled_ && pid != 0 && mirror_.target_pid() != pid) + { + mirror_.start(pid); + } + else if (enabled_ && pid == 0 && mirror_.target_pid() != 0) + { + mirror_.stop(); + } + + if (mirror_.running()) + { + ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "Mirroring %u Hz, %u ch", + mirror_.sample_rate(), mirror_.channels()); + } + const std::string status = mirror_.status(); + if (!status.empty()) + { + ImGui::TextWrapped("%s", status.c_str()); + } + ImGui::TextDisabled("Game audio also plays locally (double audio)."); + + ImGui::End(); +} + +} // namespace coop diff --git a/host/src/audio_panel.hpp b/host/src/audio_panel.hpp new file mode 100644 index 0000000..1e10472 --- /dev/null +++ b/host/src/audio_panel.hpp @@ -0,0 +1,30 @@ +// Owns the audio-mirror pipeline (WASAPI process loopback + re-render) and its +// UI. The target process is derived from the injected game's window. +#pragma once + +#include + +#include "audio/audio_loopback.hpp" + +namespace coop +{ + +class AudioPanel +{ +public: + // The window whose process audio to mirror (0 if none); typically the + // injected game's HWND. + void set_target(HWND target) + { + target_ = target; + } + + void draw_ui(); + +private: + HWND target_ = nullptr; + bool enabled_ = false; + AudioMirror mirror_; +}; + +} // namespace coop diff --git a/host/src/main.cpp b/host/src/main.cpp index 89aa36f..a4dc856 100644 --- a/host/src/main.cpp +++ b/host/src/main.cpp @@ -11,6 +11,7 @@ #include +#include "audio_panel.hpp" #include "capture_panel.hpp" #include "d3d11_window.hpp" #include "debug_overlay.hpp" @@ -39,6 +40,7 @@ int run() auto input = std::make_unique(); coop::InjectionPanel injection; + coop::AudioPanel audio; coop::CapturePanel capture; if (!capture.init(window.device())) { @@ -50,11 +52,14 @@ int run() { input->poll(); injection.publish(input->pads()); - capture.set_target(injection.game_hwnd()); + const HWND game = injection.game_hwnd(); + capture.set_target(game); + audio.set_target(game); imgui.begin_frame(); coop::draw_debug_overlay(*input); injection.draw(); + audio.draw_ui(); capture.draw_ui(); RECT client = {}; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b24de83..38932ba 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -12,3 +12,19 @@ target_link_libraries(hook_selftest PRIVATE xinput) add_test(NAME hook_selftest COMMAND hook_selftest) + +# Integration test for WASAPI process-loopback capture. Reuses the shipping +# capture code and captures from coop_tone (a known sine-wave render process). +add_executable(audio_loopback_test + audio_loopback_test.cpp + ${CMAKE_SOURCE_DIR}/host/src/audio/process_loopback_capture.cpp) + +target_include_directories(audio_loopback_test PRIVATE ${CMAKE_SOURCE_DIR}/host/src) + +# Process loopback needs the Windows 10 20H1 (NTDDI_WIN10_CO) headers. +target_compile_definitions(audio_loopback_test PRIVATE NTDDI_VERSION=0x0A00000B) + +target_link_libraries(audio_loopback_test PRIVATE mmdevapi ole32) + +add_dependencies(audio_loopback_test coop_tone) +add_test(NAME audio_loopback_test COMMAND audio_loopback_test) diff --git a/tests/audio_loopback_test.cpp b/tests/audio_loopback_test.cpp new file mode 100644 index 0000000..b976d3f --- /dev/null +++ b/tests/audio_loopback_test.cpp @@ -0,0 +1,157 @@ +// Integration test for WASAPI process-loopback capture. Spawns coop_tone.exe (a +// real process rendering a sine wave), captures its audio by PID, and verifies +// non-silent audio actually arrives. Exits 0 on pass, 1 on failure. +// +// Requires a working default render endpoint; on a headless machine with no audio +// device it reports SKIP and exits 0. + +#include +#include +#include + +#include + +#include +#include +#include + +#include "audio/process_loopback_capture.hpp" + +using namespace coop; + +namespace +{ + +// Directory of this test executable (coop_tone.exe is built alongside it). +std::wstring exe_dir() +{ + wchar_t path[MAX_PATH] = {}; + GetModuleFileNameW(nullptr, path, MAX_PATH); + std::wstring s(path); + const size_t slash = s.find_last_of(L"\\/"); + return slash == std::wstring::npos ? L"." : s.substr(0, slash); +} + +// Read from `pipe` until `token` appears or `timeout_ms` elapses. +bool wait_for_token(HANDLE pipe, const char* token, DWORD timeout_ms) +{ + std::string acc; + const DWORD end = GetTickCount() + timeout_ms; + while (GetTickCount() < end) + { + DWORD avail = 0; + if (PeekNamedPipe(pipe, nullptr, 0, nullptr, &avail, nullptr) && avail > 0) + { + char buf[256]; + DWORD read = 0; + if (ReadFile(pipe, buf, sizeof(buf) - 1, &read, nullptr) && read > 0) + { + acc.append(buf, read); + std::fwrite(buf, 1, read, stdout); + if (acc.find(token) != std::string::npos) + { + return true; + } + continue; + } + } + Sleep(20); + } + return false; +} + +} // namespace + +int main() +{ + if (FAILED(CoInitializeEx(nullptr, COINIT_MULTITHREADED))) + { + std::printf("FAIL: CoInitializeEx\n"); + return 1; + } + + WAVEFORMATEX* fmt = default_render_format(); + if (!fmt) + { + std::printf("SKIP: no default render endpoint (no audio device?)\n"); + CoUninitialize(); + return 0; + } + std::printf("Endpoint format: %u Hz, %u ch, %u-bit\n", fmt->nSamplesPerSec, fmt->nChannels, + fmt->wBitsPerSample); + + // --- Launch the tone generator with its stdout redirected to a pipe. --- + HANDLE read_pipe = nullptr; + HANDLE write_pipe = nullptr; + SECURITY_ATTRIBUTES sa = {sizeof(sa), nullptr, TRUE}; + if (!CreatePipe(&read_pipe, &write_pipe, &sa, 0)) + { + std::printf("FAIL: CreatePipe\n"); + return 1; + } + SetHandleInformation(read_pipe, HANDLE_FLAG_INHERIT, 0); + + std::wstring cmd = L"\"" + exe_dir() + L"\\coop_tone.exe\" 8"; // ~8 s, outlives capture + STARTUPINFOW si = {}; + si.cb = sizeof(si); + si.dwFlags = STARTF_USESTDHANDLES; + si.hStdOutput = write_pipe; + si.hStdError = write_pipe; + PROCESS_INFORMATION pi = {}; + std::vector cmd_buf(cmd.begin(), cmd.end()); + cmd_buf.push_back(L'\0'); + if (!CreateProcessW(nullptr, cmd_buf.data(), nullptr, nullptr, TRUE, 0, nullptr, nullptr, &si, &pi)) + { + std::printf("FAIL: CreateProcess(coop_tone) err=%lu\n", GetLastError()); + return 1; + } + CloseHandle(write_pipe); // keep only the read end + + int rc = 1; + if (!wait_for_token(read_pipe, "TONE_RENDERING", 5000)) + { + std::printf("FAIL: tone generator never started rendering\n"); + } + else + { + // --- Capture the tone process's audio for ~2 s. --- + ProcessLoopbackCapture capture; + const bool started = capture.start(pi.dwProcessId, fmt, nullptr); + std::printf("Capture start: %s, targeting pid %lu\n", started ? "ok" : "FAILED", + pi.dwProcessId); + Sleep(2000); + + const auto frames = capture.frames_captured(); + const auto nonsilent = capture.nonsilent_frames(); + const std::string status = capture.status(); + capture.stop(); + + std::printf("Status: %s\n", status.c_str()); + std::printf("Frames captured: %llu, non-silent: %llu\n", + static_cast(frames), + static_cast(nonsilent)); + + // Expect at least ~0.2 s of non-silent audio for a 2 s capture. + const unsigned long long need = fmt->nSamplesPerSec / 5; + if (nonsilent >= need) + { + std::printf("PASS: received %llu non-silent frames (need >= %llu)\n", + static_cast(nonsilent), need); + rc = 0; + } + else + { + std::printf("FAIL: too few non-silent frames (got %llu, need >= %llu)\n", + static_cast(nonsilent), need); + } + } + + TerminateProcess(pi.hProcess, 0); + WaitForSingleObject(pi.hProcess, 2000); + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + CloseHandle(read_pipe); + CoTaskMemFree(fmt); + CoUninitialize(); + return rc; +} diff --git a/tools/audio_tone/CMakeLists.txt b/tools/audio_tone/CMakeLists.txt new file mode 100644 index 0000000..7e56f11 --- /dev/null +++ b/tools/audio_tone/CMakeLists.txt @@ -0,0 +1,5 @@ +# Known audio source for the audio-mirror integration test: renders a sine wave +# on the default endpoint until it exits. +add_executable(coop_tone main.cpp) +target_link_libraries(coop_tone PRIVATE ole32) +set_target_properties(coop_tone PROPERTIES OUTPUT_NAME "coop_tone") diff --git a/tools/audio_tone/main.cpp b/tools/audio_tone/main.cpp new file mode 100644 index 0000000..4f17cdd --- /dev/null +++ b/tools/audio_tone/main.cpp @@ -0,0 +1,167 @@ +// coop_tone: a minimal WASAPI render process that plays a continuous sine wave on +// the default output endpoint. Used as a known audio source for the audio-mirror +// integration test (a real process actively rendering audio to capture from). +// +// coop_tone [seconds] [frequencyHz] +// +// Default: runs ~3 s at 440 Hz. Prints "TONE_RENDERING" once audio is flowing so +// a parent can synchronize before it starts capturing. + +#include +#include +#include +#include +#include + +#include + +#include +#include +#include + +namespace +{ + +constexpr double kPi = 3.14159265358979323846; + +template +void release(T*& p) +{ + if (p) + { + p->Release(); + p = nullptr; + } +} + +} // namespace + +int wmain(int argc, wchar_t** argv) +{ + const double seconds = (argc > 1) ? _wtof(argv[1]) : 3.0; + const double freq = (argc > 2) ? _wtof(argv[2]) : 440.0; + + if (FAILED(CoInitializeEx(nullptr, COINIT_MULTITHREADED))) + { + std::fprintf(stderr, "CoInitializeEx failed\n"); + return 1; + } + + IMMDeviceEnumerator* enumerator = nullptr; + IMMDevice* endpoint = nullptr; + IAudioClient* client = nullptr; + IAudioRenderClient* render = nullptr; + WAVEFORMATEX* fmt = nullptr; + int rc = 1; + + do + { + if (FAILED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, + __uuidof(IMMDeviceEnumerator), reinterpret_cast(&enumerator)))) + { + break; + } + if (FAILED(enumerator->GetDefaultAudioEndpoint(eRender, eConsole, &endpoint))) + { + break; + } + if (FAILED(endpoint->Activate(__uuidof(IAudioClient), CLSCTX_ALL, nullptr, + reinterpret_cast(&client)))) + { + break; + } + if (FAILED(client->GetMixFormat(&fmt))) + { + break; + } + + HANDLE buffer_event = CreateEventW(nullptr, FALSE, FALSE, nullptr); + constexpr REFERENCE_TIME kBuffer = 30 * 10000; // 30 ms + if (FAILED(client->Initialize(AUDCLNT_SHAREMODE_SHARED, AUDCLNT_STREAMFLAGS_EVENTCALLBACK, kBuffer, + 0, fmt, nullptr))) + { + break; + } + client->SetEventHandle(buffer_event); + if (FAILED(client->GetService(__uuidof(IAudioRenderClient), reinterpret_cast(&render)))) + { + break; + } + UINT32 buffer_frames = 0; + client->GetBufferSize(&buffer_frames); + + const bool is_float = + fmt->wFormatTag == WAVE_FORMAT_IEEE_FLOAT || + (fmt->wFormatTag == WAVE_FORMAT_EXTENSIBLE && + reinterpret_cast(fmt)->SubFormat == KSDATAFORMAT_SUBTYPE_IEEE_FLOAT); + const unsigned channels = fmt->nChannels; + const double rate = fmt->nSamplesPerSec; + const double step = 2.0 * kPi * freq / rate; + + auto write_frames = [&](UINT32 frames, double& phase) { + BYTE* data = nullptr; + if (FAILED(render->GetBuffer(frames, &data))) + { + return; + } + for (UINT32 i = 0; i < frames; ++i) + { + const double s = std::sin(phase) * 0.25; // -12 dB, gentle + phase += step; + if (phase > 2.0 * kPi) + { + phase -= 2.0 * kPi; + } + for (unsigned c = 0; c < channels; ++c) + { + if (is_float) + { + reinterpret_cast(data)[i * channels + c] = static_cast(s); + } + else + { + reinterpret_cast(data)[i * channels + c] = + static_cast(s * 32767.0); + } + } + } + render->ReleaseBuffer(frames, 0); + }; + + double phase = 0.0; + write_frames(buffer_frames, phase); // pre-roll + client->Start(); + std::printf("TONE_RENDERING pid=%lu %.0fHz %s %.0fHz %uch\n", GetCurrentProcessId(), freq, + is_float ? "float" : "pcm16", rate, channels); + std::fflush(stdout); + + const DWORD end_tick = GetTickCount() + static_cast(seconds * 1000.0); + while (GetTickCount() < end_tick) + { + if (WaitForSingleObject(buffer_event, 200) != WAIT_OBJECT_0) + { + continue; + } + UINT32 padding = 0; + if (FAILED(client->GetCurrentPadding(&padding))) + { + break; + } + write_frames(buffer_frames - padding, phase); + } + client->Stop(); + CloseHandle(buffer_event); + rc = 0; + } while (false); + + release(render); + release(client); + release(endpoint); + release(enumerator); + if (fmt) + { + CoTaskMemFree(fmt); + } + CoUninitialize(); + return rc; +}