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_;
|
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)
|
||||||
{
|
{
|
||||||
|
// 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…");
|
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())
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user