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_;
}
std::string AudioMirror::fallback_reason() const
{
std::lock_guard<std::mutex> lock(status_mutex_);
return fallback_reason_;
}
void AudioMirror::set_status(std::string s)
{
std::lock_guard<std::mutex> lock(status_mutex_);
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)
{
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<AudioRingHeader>();
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

View File

@@ -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

View File

@@ -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)