Audio: keep rings live, auto-promote loopback->hooked, show the reason

The host used to wait a fixed 1 s for the hook to publish a format and, on
timeout, fall to loopback permanently with no explanation -- which the now
slower (consensus) measurement made common. Restructure the audio thread to
keep the rings live the whole session and alternate: prefer hooked, and while
it isn't ready run loopback (echo) so guests still hear audio, watching the
ring to promote to the no-echo hooked path the instant the hook publishes a
format. Surface the concrete reason loopback is active (no format yet / not
renderable / no ring) in the Audio panel, in amber.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 01:16:46 +02:00
parent cb6749b511
commit c24bfdd64f
3 changed files with 115 additions and 16 deletions

View File

@@ -111,12 +111,35 @@ std::string AudioMirror::status() const
return status_; return status_;
} }
std::string AudioMirror::fallback_reason() const
{
std::lock_guard<std::mutex> lock(status_mutex_);
return fallback_reason_;
}
void AudioMirror::set_status(std::string s) void AudioMirror::set_status(std::string s)
{ {
std::lock_guard<std::mutex> lock(status_mutex_); std::lock_guard<std::mutex> lock(status_mutex_);
status_ = std::move(s); status_ = std::move(s);
} }
void AudioMirror::set_fallback_reason(std::string s)
{
std::lock_guard<std::mutex> 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) bool AudioMirror::start(DWORD pid)
{ {
stop(); stop();
@@ -159,6 +182,7 @@ void AudioMirror::stop()
running_.store(false, std::memory_order_release); running_.store(false, std::memory_order_release);
source_.store(Source::None, std::memory_order_relaxed); source_.store(Source::None, std::memory_order_relaxed);
buffered_ms_.store(0, std::memory_order_relaxed); buffered_ms_.store(0, std::memory_order_relaxed);
set_fallback_reason({});
pid_ = 0; pid_ = 0;
} }
@@ -191,10 +215,9 @@ void AudioMirror::thread_main(DWORD pid)
{ {
const bool com_ok = SUCCEEDED(CoInitializeEx(nullptr, COINIT_MULTITHREADED)); const bool com_ok = SUCCEEDED(CoInitializeEx(nullptr, COINIT_MULTITHREADED));
// Create the shared audio ring the injected hook produces into, and enable // Create the shared audio rings the injected hook produces into. The hook counts
// capture. If the hook is present it publishes a format within ~1 s and we // frames + measures the format regardless of capture_enabled, so we can keep these
// consume the ring (no echo); otherwise we fall back to process loopback. // live the whole session and promote loopback -> hooked the moment a format appears.
bool handled = false;
AudioRingHeader* rings[kMaxAudioStreams] = {}; AudioRingHeader* rings[kMaxAudioStreams] = {};
bool created_primary = false; bool created_primary = false;
for (unsigned i = 0; i < kMaxAudioStreams; ++i) for (unsigned i = 0; i < kMaxAudioStreams; ++i)
@@ -203,24 +226,69 @@ void AudioMirror::thread_main(DWORD pid)
{ {
rings[i] = audio_ring_shm_[i].as<AudioRingHeader>(); rings[i] = audio_ring_shm_[i].as<AudioRingHeader>();
audio_ring_init(*rings[i], kAudioRingCapacity); audio_ring_init(*rings[i], kAudioRingCapacity);
rings[i]->capture_enabled.store(1, std::memory_order_release);
created_primary = created_primary || (i == 0); created_primary = created_primary || (i == 0);
} }
} }
if (created_primary)
if (!created_primary)
{ {
set_status("Waiting for render-hook…"); // Couldn't create the hook's ring -> loopback only (no promote target).
if (wait_for_format(rings[0], 1000)) 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()) enable_capture(rings, false);
{
run_loopback(pid);
}
for (auto& shm : audio_ring_shm_) for (auto& shm : audio_ring_shm_)
{ {
shm.reset(); shm.reset();
@@ -245,6 +313,7 @@ void AudioMirror::thread_main(DWORD pid)
bool AudioMirror::run_hooked(AudioRingHeader* const* rings) bool AudioMirror::run_hooked(AudioRingHeader* const* rings)
{ {
AudioRingHeader* primary = rings[0]; AudioRingHeader* primary = rings[0];
enable_capture(rings, true); // hook silences the game + pushes frames into the rings
auto disable_all = [&] { auto disable_all = [&] {
for (unsigned i = 0; i < kMaxAudioStreams; ++i) for (unsigned i = 0; i < kMaxAudioStreams; ++i)
{ {
@@ -515,9 +584,10 @@ bool AudioMirror::run_hooked(AudioRingHeader* const* rings)
return true; 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); source_.store(Source::Loopback, std::memory_order_relaxed);
bool promote = false;
IMMDeviceEnumerator* enumerator = nullptr; IMMDeviceEnumerator* enumerator = nullptr;
IMMDevice* endpoint = nullptr; IMMDevice* endpoint = nullptr;
@@ -643,6 +713,14 @@ void AudioMirror::run_loopback(DWORD pid)
set_status(capture.status()); set_status(capture.status());
break; 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; UINT32 padding = 0;
if (FAILED(render_client->GetCurrentPadding(&padding))) if (FAILED(render_client->GetCurrentPadding(&padding)))
@@ -706,6 +784,7 @@ void AudioMirror::run_loopback(DWORD pid)
{ {
CloseHandle(render_event); CloseHandle(render_event);
} }
return promote; // true = hook caught up, caller should switch to hooked
} }
} // namespace coop } // namespace coop

View File

@@ -95,16 +95,25 @@ public:
[[nodiscard]] std::string status() const; [[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: private:
void thread_main(DWORD pid); void thread_main(DWORD pid);
// Returns true if it owned the session to a clean stop; false if setup failed // 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 // and the caller should fall back to the loopback path. `rings[0]` is the primary
// stream; additional non-null rings are mixed in. // stream; additional non-null rings are mixed in.
bool run_hooked(AudioRingHeader* const* rings); 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); bool wait_for_format(AudioRingHeader* ring, DWORD timeout_ms);
static void enable_capture(AudioRingHeader* const* rings, bool on);
bool stop_requested() const; bool stop_requested() const;
void set_status(std::string s); void set_status(std::string s);
void set_fallback_reason(std::string s);
std::thread thread_; std::thread thread_;
HANDLE stop_event_ = nullptr; HANDLE stop_event_ = nullptr;
@@ -120,6 +129,7 @@ private:
mutable std::mutex status_mutex_; mutable std::mutex status_mutex_;
std::string status_; std::string status_;
std::string fallback_reason_; // why loopback is active (shown in the panel); guarded by status_mutex_
}; };
} // namespace coop } // namespace coop

View File

@@ -137,6 +137,16 @@ void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details)
ImGui::TextWrapped("%s", mirror_status.c_str()); 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 // Only the loopback path leaves the game audible locally (the echo); the
// hooked path silences it, so don't warn there. // hooked path silences it, so don't warn there.
if (mirror_.source() == AudioMirror::Source::Loopback) if (mirror_.source() == AudioMirror::Source::Loopback)