Files
CoopAllTheThings/host/src/audio_panel.cpp
BlackMark c24bfdd64f 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>
2026-06-22 01:16:46 +02:00

263 lines
7.3 KiB
C++

#include "audio_panel.hpp"
#include <algorithm>
#include "imgui.h"
#include <mmreg.h>
#include "ui/app_chrome.hpp"
namespace coop
{
namespace
{
const ImVec4 kGreen(0.4f, 1.0f, 0.4f, 1.0f);
const ImVec4 kAmber(1.0f, 0.8f, 0.3f, 1.0f);
const ImVec4 kRed(1.0f, 0.45f, 0.4f, 1.0f);
const char* format_tag_name(std::uint32_t tag)
{
switch (tag)
{
case WAVE_FORMAT_PCM:
return "PCM";
case WAVE_FORMAT_IEEE_FLOAT:
return "float";
case WAVE_FORMAT_EXTENSIBLE:
return "ext";
default:
return "?";
}
}
// How the hooked backend learned a stream's format (drives the pitch correctness).
const char* audio_format_state_name(std::uint32_t state)
{
switch (state)
{
case AudioFormat_Exact:
return "known (from game)";
case AudioFormat_Measuring:
return "measuring rate...";
case AudioFormat_Measured:
return "measured rate (ch/bits assumed)";
case AudioFormat_LowConfidence:
return "LOW-CONFIDENCE rate (verify / override)";
case AudioFormat_Override:
return "manual override";
default:
return "unknown";
}
}
ImVec4 audio_format_state_color(std::uint32_t state)
{
switch (state)
{
case AudioFormat_Exact:
case AudioFormat_Measured:
case AudioFormat_Override:
return kGreen; // format trustworthy -> correct pitch
case AudioFormat_Measuring:
return kAmber; // still verifying the rate
default:
return kRed; // unknown or low-confidence -> needs attention
}
}
} // namespace
void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details)
{
const bool have_target = target_ != nullptr && IsWindow(target_);
DWORD pid = 0;
if (have_target)
{
GetWindowThreadProcessId(target_, &pid);
}
apply_panel_layout(Panel::Audio);
ImGui::Begin("Audio mirror");
ImGui::BeginDisabled(!have_target);
if (ImGui::Checkbox("Mirror game audio", &enabled_))
{
if (!enabled_)
{
mirror_.stop();
}
}
ImGui::EndDisabled();
if (!have_target)
{
ImGui::TextDisabled("Inject into a game first (its audio is the source).");
}
// Start when enabled and the target process changes; stop if it disappears.
if (enabled_ && pid != 0 && mirror_.target_pid() != pid)
{
mirror_.start(pid);
}
else if (enabled_ && pid == 0 && mirror_.target_pid() != 0)
{
mirror_.stop();
}
if (mirror_.running())
{
const AudioMirror::Source src = mirror_.source();
const bool hooked = src == AudioMirror::Source::Hooked;
ImGui::TextColored(kGreen, "Mirroring %u Hz, %u ch", mirror_.sample_rate(), mirror_.channels());
ImGui::Text("Source:");
ImGui::SameLine();
ImGui::TextColored(hooked ? kGreen : kAmber, "%s", mirror_.source_name());
// Where the rendered format came from -- so it's clear the playback pitch is right.
// Hooked: the primary stream's provenance (exact / measured). Loopback: the audio is
// captured post-mix at the device endpoint format, so it's always known-correct.
ImGui::Text("Format:");
ImGui::SameLine();
if (hooked)
{
const std::uint32_t st = status.audio_streams[0].format_state; // [0] is the primary
ImGui::TextColored(audio_format_state_color(st), "%s", audio_format_state_name(st));
}
else
{
ImGui::TextColored(kGreen, "device endpoint (known, post-mix)");
}
ImGui::Text("Buffered: %4u ms", mirror_.buffered_ms());
}
const std::string mirror_status = mirror_.status();
if (!mirror_status.empty())
{
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)
{
bool audio_hook_on = false;
const std::uint32_t hn =
status.hook_entry_count < kMaxHookEntries ? status.hook_entry_count : kMaxHookEntries;
for (std::uint32_t i = 0; i < hn; ++i)
{
if (status.hook_entries[i].subsystem == HookSubsys_Audio && status.hook_entries[i].installed)
{
audio_hook_on = true;
break;
}
}
if (audio_hook_on)
{
ImGui::TextDisabled("Game audio also plays locally (echo).");
}
else
{
ImGui::TextDisabled("Audio render-hook is off -> loopback (echo). Enable it in the Injection panel.");
}
}
// --- Render-stream view -----------------------------------------------
// The hook counts every render stream the game creates, even with mirroring
// off. v1 captures only the first ("primary"); the count makes a multi-stream
// game obvious, and the per-stream table (debug details) shows why.
ImGui::Separator();
ImGui::Text("Render streams: %u", status.audio_streams_seen);
if (status.audio_streams_seen > kMaxAudioStreams)
{
ImGui::SameLine();
ImGui::TextDisabled("(showing first %u)", kMaxAudioStreams);
}
if (!debug_details)
{
ImGui::End();
return; // the per-stream table below is diagnostic detail
}
const std::uint32_t rows = std::min<std::uint32_t>(status.audio_streams_seen, kMaxAudioStreams);
const double now = ImGui::GetTime();
const bool resample = (now - rate_base_time_) >= 0.5; // recompute frames/s ~2x a second
if (rows > 0 &&
ImGui::BeginTable("audio_streams", 6, ImGuiTableFlags_Borders | ImGuiTableFlags_SizingFixedFit))
{
ImGui::TableSetupColumn("#");
ImGui::TableSetupColumn("role");
ImGui::TableSetupColumn("format");
ImGui::TableSetupColumn("source");
ImGui::TableSetupColumn("frames");
ImGui::TableSetupColumn("live");
ImGui::TableHeadersRow();
for (std::uint32_t i = 0; i < rows; ++i)
{
const AudioStreamInfo& s = status.audio_streams[i];
// Debounced activity: remember when this stream last advanced, and call it
// live for a short window afterwards so bursty releases don't flicker.
if (s.frames_rendered > prev_frames_[i])
{
last_active_[i] = now;
}
prev_frames_[i] = s.frames_rendered;
const bool live = last_active_[i] > 0.0 && (now - last_active_[i]) < 0.4;
if (resample)
{
const double dt = now - rate_base_time_;
frames_per_s_[i] =
dt > 0.0 ? static_cast<double>(s.frames_rendered - rate_base_frames_[i]) / dt : 0.0;
rate_base_frames_[i] = s.frames_rendered;
}
ImGui::TableNextRow();
ImGui::TableNextColumn();
ImGui::Text("%u", i);
ImGui::TableNextColumn();
ImGui::TextColored(s.is_primary ? ImVec4(0.4f, 1.0f, 0.4f, 1.0f) : ImVec4(0.7f, 0.7f, 0.7f, 1.0f),
"%s", s.is_primary ? "primary" : "extra");
ImGui::TableNextColumn();
ImGui::Text("%u Hz %uch %u-bit %s", s.sample_rate, s.channels, s.bits,
format_tag_name(s.format_tag));
ImGui::TableNextColumn();
ImGui::TextColored(audio_format_state_color(s.format_state), "%s",
audio_format_state_name(s.format_state));
ImGui::TableNextColumn();
ImGui::Text("%llu", static_cast<unsigned long long>(s.frames_rendered));
ImGui::TableNextColumn();
if (live)
{
ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "live");
ImGui::SameLine();
ImGui::TextDisabled("%6.0f/s", frames_per_s_[i]);
}
else
{
ImGui::TextDisabled("idle");
}
}
ImGui::EndTable();
}
if (resample)
{
rate_base_time_ = now;
}
ImGui::End();
}
} // namespace coop