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>
263 lines
7.3 KiB
C++
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
|