#include "audio_panel.hpp" #include #include "imgui.h" #include #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(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(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(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