fix: multi-trigger audio recovery after Windows sleep/wake
The two prior attempts at #783 failed for lack of a reliable trigger: re-setting audio-device to its own value is a no-op, and gating recovery on audio-device-list changes while current-ao is null never fires in the real flows (a paused player only discovers the dead WASAPI session at unpause, when the device list is already stable; a zombie session that consumes samples silently never goes null). Replace the single gate with a recovery scheduler on the mpv event thread, ticked every wait-event timeout: - WM_POWERBROADCAST resume (both PBT variants, via the plugin's existing window-proc delegate) requests unconditional ao-reloads at +1.5s and +6s after wake — the only trigger that reaches zombie sessions; the second shot covers a first reload landing while the audio stack is still restoring. The handoff is a single atomic flag: no mpv calls or timers from the platform thread, so there is nothing to clean up on dispose. - current-ao falling to null arms clock-driven retries (budget 5, x2 backoff capped at 8s); retries must be clock-driven because a failed ao-reload falls back to null without a property change event. A device-list change while null refreshes the budget, and audio-device-list is now observed natively so recovery does not depend on the Dart side. Every trigger, attempt, and outcome logs as [mpv:audio-recovery] at warn level so exported logs from the reporter are actionable if this still misses. Verified with synthetic PBT_APMRESUMEAUTOMATIC while playing and while paused: full WASAPI reinit both times, audio restarts on unpause. Ref #783
This commit is contained in:
@@ -2,12 +2,29 @@
|
||||
|
||||
#include <windowsx.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "sanitize_utf8.h"
|
||||
|
||||
namespace mpv {
|
||||
|
||||
namespace {
|
||||
|
||||
// Audio recovery schedule (issue #783: silent WASAPI after wake from sleep).
|
||||
// Resume reloads fire unconditionally — a post-wake WASAPI session can stay
|
||||
// "healthy" from mpv's point of view while producing no sound, so there is no
|
||||
// property to gate on; the second shot covers a first reload that lands while
|
||||
// the audio stack is still restoring and creates another silent session.
|
||||
// Null-fallback retries are clock-driven because a failed ao-reload falls
|
||||
// back to null again without emitting a current-ao change event.
|
||||
constexpr int kResumeReloadAttempts = 2;
|
||||
constexpr std::chrono::milliseconds kResumeFirstDelay{1500};
|
||||
constexpr std::chrono::milliseconds kResumeRetryDelay{4500};
|
||||
constexpr int kNullRetryBudget = 5;
|
||||
constexpr std::chrono::milliseconds kNullFirstDelay{500};
|
||||
constexpr std::chrono::milliseconds kNullBackoffCap{8000};
|
||||
constexpr std::chrono::milliseconds kDeviceListDebounce{250};
|
||||
|
||||
// DComp-mode input forwarding. mpv's inner window lives on mpv's own thread
|
||||
// and consumes the mouse input over the video (WS_EX_TRANSPARENT hit-test
|
||||
// skipping is same-thread-only, and disabling the subtree makes the system
|
||||
@@ -114,7 +131,7 @@ bool MpvPlayer::Initialize(HWND view) {
|
||||
|
||||
// When WASAPI becomes unavailable (sleep, device unplug), fall back to null
|
||||
// audio output instead of permanently dropping the audio track. Recovery is
|
||||
// handled in the event loop when audio-device-list changes.
|
||||
// handled by MaybeRunAudioRecovery in the event loop.
|
||||
mpv_set_option_string(mpv_, "audio-fallback-to-null", "yes");
|
||||
|
||||
// Default to warn-level logging; Dart side can raise to "v" if debug logging is enabled.
|
||||
@@ -133,6 +150,9 @@ bool MpvPlayer::Initialize(HWND view) {
|
||||
// Observe video-params/sig-peak for HDR detection
|
||||
mpv_observe_property(mpv_, 0, "video-params/sig-peak", MPV_FORMAT_DOUBLE);
|
||||
mpv_observe_property(mpv_, 0, "current-ao", MPV_FORMAT_STRING);
|
||||
// Native observation so audio recovery doesn't depend on the Dart side
|
||||
// choosing to observe the device list.
|
||||
mpv_observe_property(mpv_, 0, "audio-device-list", MPV_FORMAT_NONE);
|
||||
|
||||
// Start event loop.
|
||||
StartEventLoop();
|
||||
@@ -340,10 +360,72 @@ void MpvPlayer::SetEventCallback(EventCallback callback) {
|
||||
event_callback_ = std::move(callback);
|
||||
}
|
||||
|
||||
void MpvPlayer::ReloadAudioOutput() {
|
||||
void MpvPlayer::NotifyPowerSuspend() { LogRecovery("system suspending"); }
|
||||
|
||||
void MpvPlayer::NotifyPowerResume() { resume_reload_requested_.store(true); }
|
||||
|
||||
void MpvPlayer::LogRecovery(const std::string& text) {
|
||||
char log_msg[512];
|
||||
snprintf(log_msg, sizeof(log_msg), "MPV [warn] audio-recovery: %s", text.c_str());
|
||||
OutputDebugStringA(log_msg);
|
||||
|
||||
// Emitted as a synthetic log-message event so it reaches the app logs
|
||||
// regardless of the mpv log level.
|
||||
flutter::EncodableMap data;
|
||||
data[flutter::EncodableValue("prefix")] = flutter::EncodableValue("audio-recovery");
|
||||
data[flutter::EncodableValue("level")] = flutter::EncodableValue("warn");
|
||||
data[flutter::EncodableValue("text")] = flutter::EncodableValue(text);
|
||||
SendEvent("log-message", data);
|
||||
}
|
||||
|
||||
void MpvPlayer::TryAudioReload(const char* reason, int attempt) {
|
||||
if (audio_reload_pending_) return;
|
||||
audio_reload_pending_ = true;
|
||||
CommandAsync({"ao-reload"}, [this](int) { audio_reload_pending_ = false; });
|
||||
LogRecovery("issuing ao-reload (reason=" + std::string(reason) + ", attempt " + std::to_string(attempt) + ")");
|
||||
std::string reason_str = reason;
|
||||
CommandAsync({"ao-reload"}, [this, reason_str, attempt](int error) {
|
||||
audio_reload_pending_ = false;
|
||||
LogRecovery("ao-reload completed (reason=" + reason_str + ", attempt " + std::to_string(attempt) +
|
||||
", error=" + std::to_string(error) + ")");
|
||||
});
|
||||
}
|
||||
|
||||
void MpvPlayer::MaybeRunAudioRecovery() {
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
|
||||
if (resume_reload_requested_.exchange(false)) {
|
||||
if (file_loaded_) {
|
||||
resume_attempts_left_ = kResumeReloadAttempts;
|
||||
resume_next_attempt_ = now + kResumeFirstDelay;
|
||||
LogRecovery("power resume detected; scheduling ao-reload in " + std::to_string(kResumeFirstDelay.count()) +
|
||||
"ms");
|
||||
} else {
|
||||
LogRecovery("power resume detected; no file loaded, nothing to recover");
|
||||
}
|
||||
}
|
||||
|
||||
if (resume_attempts_left_ > 0 && now >= resume_next_attempt_) {
|
||||
int attempt = kResumeReloadAttempts - resume_attempts_left_ + 1;
|
||||
resume_attempts_left_--;
|
||||
resume_next_attempt_ = now + kResumeRetryDelay;
|
||||
TryAudioReload("resume", attempt);
|
||||
}
|
||||
|
||||
if (null_attempts_left_ > 0 && now >= null_next_attempt_) {
|
||||
if (!current_ao_is_null_) {
|
||||
null_attempts_left_ = 0;
|
||||
LogRecovery("audio recovered (current-ao no longer null)");
|
||||
} else {
|
||||
int attempt = kNullRetryBudget - null_attempts_left_ + 1;
|
||||
null_attempts_left_--;
|
||||
null_next_attempt_ = now + null_backoff_;
|
||||
null_backoff_ = std::min(null_backoff_ * 2, kNullBackoffCap);
|
||||
TryAudioReload("null-fallback", attempt);
|
||||
if (null_attempts_left_ == 0) {
|
||||
LogRecovery("audio recovery budget exhausted; waiting for device list change or power resume");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void MpvPlayer::StartEventLoop() {
|
||||
@@ -365,13 +447,15 @@ void MpvPlayer::StopEventLoop() {
|
||||
void MpvPlayer::EventLoop() {
|
||||
while (running_) {
|
||||
mpv_event* event = mpv_wait_event(mpv_, 0.1);
|
||||
if (event->event_id == MPV_EVENT_NONE) {
|
||||
continue;
|
||||
}
|
||||
if (event->event_id == MPV_EVENT_SHUTDOWN) {
|
||||
break;
|
||||
}
|
||||
HandleMpvEvent(event);
|
||||
if (event->event_id != MPV_EVENT_NONE) {
|
||||
HandleMpvEvent(event);
|
||||
}
|
||||
// Runs on every iteration including wait timeouts: this ~100ms tick is
|
||||
// the clock that drives scheduled audio reload attempts.
|
||||
MaybeRunAudioRecovery();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -455,19 +539,42 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) {
|
||||
if (prop->format == MPV_FORMAT_STRING && prop->data) {
|
||||
current_ao = *static_cast<char**>(prop->data);
|
||||
}
|
||||
current_ao_is_null_ = current_ao && strcmp(current_ao, "null") == 0;
|
||||
bool is_null = current_ao && strcmp(current_ao, "null") == 0;
|
||||
if (is_null && !current_ao_is_null_) {
|
||||
// AO fell back to null (audio-fallback-to-null); start recovery.
|
||||
null_attempts_left_ = kNullRetryBudget;
|
||||
null_backoff_ = kNullFirstDelay;
|
||||
null_next_attempt_ = std::chrono::steady_clock::now() + kNullFirstDelay;
|
||||
LogRecovery("current-ao fell back to null; starting recovery (budget " + std::to_string(kNullRetryBudget) +
|
||||
")");
|
||||
} else if (!is_null && current_ao_is_null_) {
|
||||
null_attempts_left_ = 0;
|
||||
LogRecovery(std::string("current-ao is now '") + (current_ao ? current_ao : "") + "'");
|
||||
}
|
||||
current_ao_is_null_ = is_null;
|
||||
}
|
||||
|
||||
// Audio recovery for sleep/wake or unplugged devices.
|
||||
// Mirrors mpv's TOOLS/lua/ao-null-reload.lua for embedded libmpv.
|
||||
if (strcmp(prop->name, "audio-device-list") == 0 && current_ao_is_null_) {
|
||||
ReloadAudioOutput();
|
||||
// A device (re)appearing while the AO sits on the null fallback is a
|
||||
// fresh recovery opportunity: refresh the retry budget and pull the next
|
||||
// attempt close. Gated on the native observation (userdata 0) so the
|
||||
// Dart-side observation of the same property doesn't double-trigger.
|
||||
if (strcmp(prop->name, "audio-device-list") == 0 && event->reply_userdata == 0 && current_ao_is_null_) {
|
||||
auto candidate = std::chrono::steady_clock::now() + kDeviceListDebounce;
|
||||
if (null_attempts_left_ <= 0 || candidate < null_next_attempt_) {
|
||||
null_next_attempt_ = candidate;
|
||||
}
|
||||
null_attempts_left_ = kNullRetryBudget;
|
||||
null_backoff_ = kNullFirstDelay;
|
||||
LogRecovery("audio-device-list changed while ao=null; rescheduling ao-reload");
|
||||
}
|
||||
|
||||
SendPropertyChange(prop->name, &node);
|
||||
break;
|
||||
}
|
||||
case MPV_EVENT_END_FILE: {
|
||||
file_loaded_ = false;
|
||||
resume_attempts_left_ = 0;
|
||||
null_attempts_left_ = 0;
|
||||
auto* end = static_cast<mpv_event_end_file*>(event->data);
|
||||
flutter::EncodableMap data;
|
||||
data[flutter::EncodableValue("reason")] = flutter::EncodableValue(static_cast<int>(end->reason));
|
||||
@@ -479,6 +586,7 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) {
|
||||
break;
|
||||
}
|
||||
case MPV_EVENT_FILE_LOADED: {
|
||||
file_loaded_ = true;
|
||||
SendEvent("file-loaded");
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <mpv/client.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
@@ -75,6 +76,12 @@ class MpvPlayer {
|
||||
// Sets the event callback for property changes and events.
|
||||
void SetEventCallback(EventCallback callback);
|
||||
|
||||
// Power notifications, called from the platform thread (window proc).
|
||||
// NotifyPowerResume only sets an atomic flag consumed by the event thread —
|
||||
// no mpv calls, no timers — so it cannot race Dispose and needs no cleanup.
|
||||
void NotifyPowerSuspend();
|
||||
void NotifyPowerResume();
|
||||
|
||||
private:
|
||||
void StartEventLoop();
|
||||
void StopEventLoop();
|
||||
@@ -82,7 +89,9 @@ class MpvPlayer {
|
||||
void HandleMpvEvent(mpv_event* event);
|
||||
void SendPropertyChange(const char* name, mpv_node* data);
|
||||
void SendEvent(const std::string& name, const flutter::EncodableMap& data = {});
|
||||
void ReloadAudioOutput();
|
||||
void MaybeRunAudioRecovery();
|
||||
void TryAudioReload(const char* reason, int attempt);
|
||||
void LogRecovery(const std::string& text);
|
||||
uint64_t RegisterStatusRequest(StatusCallback callback);
|
||||
StatusCallback TakeStatusRequest(uint64_t request_id);
|
||||
uint64_t RegisterGetPropertyRequest(GetPropertyCallback callback);
|
||||
@@ -98,6 +107,16 @@ class MpvPlayer {
|
||||
bool current_ao_is_null_ = false;
|
||||
bool audio_reload_pending_ = false;
|
||||
|
||||
// Audio recovery state. Event thread only, except |resume_reload_requested_|
|
||||
// which the platform thread sets on WM_POWERBROADCAST resume.
|
||||
std::atomic<bool> resume_reload_requested_{false};
|
||||
bool file_loaded_ = false;
|
||||
int resume_attempts_left_ = 0;
|
||||
std::chrono::steady_clock::time_point resume_next_attempt_{};
|
||||
int null_attempts_left_ = 0;
|
||||
std::chrono::steady_clock::time_point null_next_attempt_{};
|
||||
std::chrono::milliseconds null_backoff_{};
|
||||
|
||||
uint64_t next_reply_userdata_ = 1;
|
||||
std::map<std::string, uint64_t> observed_properties_;
|
||||
std::map<std::string, int> name_to_id_;
|
||||
|
||||
@@ -137,6 +137,17 @@ void MpvPlayerPlugin::HandleMethodCall(
|
||||
DrainPlatformTasks();
|
||||
return std::optional<HRESULT>(0);
|
||||
}
|
||||
// Both resume variants are handled: which of the two arrives (or
|
||||
// both) depends on S3 vs Modern Standby; re-arming is idempotent.
|
||||
// player_ create/reset also happens on this thread, so the
|
||||
// null-check suffices. Never consume the message.
|
||||
if (message == WM_POWERBROADCAST && player_ && player_->IsInitialized()) {
|
||||
if (wparam == PBT_APMSUSPEND) {
|
||||
player_->NotifyPowerSuspend();
|
||||
} else if (wparam == PBT_APMRESUMEAUTOMATIC || wparam == PBT_APMRESUMESUSPEND) {
|
||||
player_->NotifyPowerResume();
|
||||
}
|
||||
}
|
||||
return std::optional<HRESULT>(std::nullopt);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user