diff --git a/host/src/audio/audio_loopback.cpp b/host/src/audio/audio_loopback.cpp index 914d652..0e7c117 100644 --- a/host/src/audio/audio_loopback.cpp +++ b/host/src/audio/audio_loopback.cpp @@ -111,12 +111,35 @@ std::string AudioMirror::status() const return status_; } +std::string AudioMirror::fallback_reason() const +{ + std::lock_guard lock(status_mutex_); + return fallback_reason_; +} + void AudioMirror::set_status(std::string s) { std::lock_guard lock(status_mutex_); status_ = std::move(s); } +void AudioMirror::set_fallback_reason(std::string s) +{ + std::lock_guard lock(status_mutex_); + fallback_reason_ = std::move(s); +} + +void AudioMirror::enable_capture(AudioRingHeader* const* rings, bool on) +{ + for (unsigned i = 0; i < kMaxAudioStreams; ++i) + { + if (rings[i] != nullptr) + { + rings[i]->capture_enabled.store(on ? 1u : 0u, std::memory_order_release); + } + } +} + bool AudioMirror::start(DWORD pid) { stop(); @@ -159,6 +182,7 @@ void AudioMirror::stop() running_.store(false, std::memory_order_release); source_.store(Source::None, std::memory_order_relaxed); buffered_ms_.store(0, std::memory_order_relaxed); + set_fallback_reason({}); pid_ = 0; } @@ -191,10 +215,9 @@ void AudioMirror::thread_main(DWORD pid) { const bool com_ok = SUCCEEDED(CoInitializeEx(nullptr, COINIT_MULTITHREADED)); - // Create the shared audio ring the injected hook produces into, and enable - // capture. If the hook is present it publishes a format within ~1 s and we - // consume the ring (no echo); otherwise we fall back to process loopback. - bool handled = false; + // Create the shared audio rings the injected hook produces into. The hook counts + // frames + measures the format regardless of capture_enabled, so we can keep these + // live the whole session and promote loopback -> hooked the moment a format appears. AudioRingHeader* rings[kMaxAudioStreams] = {}; bool created_primary = false; for (unsigned i = 0; i < kMaxAudioStreams; ++i) @@ -203,24 +226,69 @@ void AudioMirror::thread_main(DWORD pid) { rings[i] = audio_ring_shm_[i].as(); audio_ring_init(*rings[i], kAudioRingCapacity); - rings[i]->capture_enabled.store(1, std::memory_order_release); created_primary = created_primary || (i == 0); } } - if (created_primary) + + if (!created_primary) { - set_status("Waiting for render-hook…"); - if (wait_for_format(rings[0], 1000)) + // Couldn't create the hook's ring -> loopback only (no promote target). + set_fallback_reason("Couldn't create the audio ring; using loopback (echo)."); + if (!stop_requested()) { - handled = run_hooked(rings); + run_loopback(pid, nullptr); + } + } + else + { + // Prefer the hooked (no-echo) path. While it isn't ready, run loopback (echo) so + // guests still hear audio, but watch the ring and promote to hooked the instant the + // hook publishes a format. A short wait first catches the fast cases (exact format / + // already-measured) without ever starting the echo. + // The guessed-rate path takes a few seconds to reach consensus; loopback covers + // that gap and the promote hands off seamlessly. + constexpr DWORD kHookWaitMs = 1200; + for (;;) + { + if (stop_requested()) + { + break; + } + set_status("Waiting for render-hook…"); + bool watch_for_promote = true; + if (wait_for_format(rings[0], kHookWaitMs)) + { + set_fallback_reason({}); // hooked path is taking over + if (run_hooked(rings)) + { + break; // ran to a clean stop + } + if (stop_requested()) + { + break; + } + // run_hooked failed to initialize (the game's format isn't renderable here). + // That won't fix itself, so don't bounce back to it -- stay on loopback. + set_fallback_reason("Render-hook format isn't renderable on this endpoint; using loopback (echo)."); + watch_for_promote = false; + } + else if (!stop_requested()) + { + set_fallback_reason( + "Render-hook hasn't published a format yet; using loopback (echo) -- will switch to " + "hooked automatically once it does."); + } + + enable_capture(rings, false); // game audible locally so loopback can capture it + if (!run_loopback(pid, watch_for_promote ? rings[0] : nullptr)) + { + break; // stopped (not a promote) + } + // Promoted: a format appeared -> loop and try the hooked path again. } } - if (!handled && !stop_requested()) - { - run_loopback(pid); - } - + enable_capture(rings, false); for (auto& shm : audio_ring_shm_) { shm.reset(); @@ -245,6 +313,7 @@ void AudioMirror::thread_main(DWORD pid) bool AudioMirror::run_hooked(AudioRingHeader* const* rings) { AudioRingHeader* primary = rings[0]; + enable_capture(rings, true); // hook silences the game + pushes frames into the rings auto disable_all = [&] { for (unsigned i = 0; i < kMaxAudioStreams; ++i) { @@ -515,9 +584,10 @@ bool AudioMirror::run_hooked(AudioRingHeader* const* rings) return true; } -void AudioMirror::run_loopback(DWORD pid) +bool AudioMirror::run_loopback(DWORD pid, AudioRingHeader* promote_ring) { source_.store(Source::Loopback, std::memory_order_relaxed); + bool promote = false; IMMDeviceEnumerator* enumerator = nullptr; IMMDevice* endpoint = nullptr; @@ -643,6 +713,14 @@ void AudioMirror::run_loopback(DWORD pid) set_status(capture.status()); break; } + // Auto-promote: the hook published a format -> hand back so the caller switches + // to the no-echo hooked path (the rings stayed live the whole time). + if (promote_ring != nullptr && audio_ring_format_ready(*promote_ring)) + { + set_status("Render-hook ready -- switching to hooked (no echo)…"); + promote = true; + break; + } UINT32 padding = 0; if (FAILED(render_client->GetCurrentPadding(&padding))) @@ -706,6 +784,7 @@ void AudioMirror::run_loopback(DWORD pid) { CloseHandle(render_event); } + return promote; // true = hook caught up, caller should switch to hooked } } // namespace coop diff --git a/host/src/audio/audio_loopback.hpp b/host/src/audio/audio_loopback.hpp index c65ad9a..ba2d873 100644 --- a/host/src/audio/audio_loopback.hpp +++ b/host/src/audio/audio_loopback.hpp @@ -95,16 +95,25 @@ public: [[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; + 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); + // 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); bool stop_requested() const; void set_status(std::string s); + void set_fallback_reason(std::string s); std::thread thread_; HANDLE stop_event_ = nullptr; @@ -120,6 +129,7 @@ private: 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 diff --git a/host/src/audio_panel.cpp b/host/src/audio_panel.cpp index 1f512d3..78c3591 100644 --- a/host/src/audio_panel.cpp +++ b/host/src/audio_panel.cpp @@ -137,6 +137,16 @@ void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details) ImGui::TextWrapped("%s", mirror_status.c_str()); } + // Why we're on loopback instead of the no-echo hooked path (empty when hooked). Amber + // because it's a degraded-but-working state that auto-resolves when the hook catches up. + const std::string reason = mirror_.fallback_reason(); + if (!reason.empty()) + { + ImGui::PushStyleColor(ImGuiCol_Text, kAmber); + ImGui::TextWrapped("Why loopback: %s", reason.c_str()); + ImGui::PopStyleColor(); + } + // Only the loopback path leaves the game audible locally (the echo); the // hooked path silences it, so don't warn there. if (mirror_.source() == AudioMirror::Source::Loopback)