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:
@@ -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)
|
||||
{
|
||||
// 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())
|
||||
{
|
||||
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…");
|
||||
if (wait_for_format(rings[0], 1000))
|
||||
bool watch_for_promote = true;
|
||||
if (wait_for_format(rings[0], kHookWaitMs))
|
||||
{
|
||||
handled = run_hooked(rings);
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user