From 488208a1d4973062f9005d902672e1bc0af3d802 Mon Sep 17 00:00:00 2001 From: 0x0003 <0x0003@users.noreply.github.com> Date: Sun, 12 Jul 2026 11:06:07 +0900 Subject: [PATCH 1/2] wasapi/mixer: gracefully handle device disconnection Catch exceptions from GetVolume()/SetVolume() so that a disconnected device (AUDCLNT_E_DEVICE_INVALIDATED) degrades gracefully instead of crashing MPD. Closes https://github.com/MusicPlayerDaemon/MPD/issues/1880 --- src/mixer/plugins/WasapiMixerPlugin.cxx | 126 +++++++++++++----------- 1 file changed, 71 insertions(+), 55 deletions(-) diff --git a/src/mixer/plugins/WasapiMixerPlugin.cxx b/src/mixer/plugins/WasapiMixerPlugin.cxx index 5ef8d7188a..a44cfc59eb 100644 --- a/src/mixer/plugins/WasapiMixerPlugin.cxx +++ b/src/mixer/plugins/WasapiMixerPlugin.cxx @@ -19,6 +19,11 @@ #include #include +#include "Log.hxx" +#include "util/Domain.hxx" + +static constexpr Domain wasapi_mixer_domain("wasapi_mixer"); + class WasapiMixer final : public Mixer { WasapiOutput &output; @@ -35,69 +40,80 @@ class WasapiMixer final : public Mixer { if (!com_worker) return -1; - auto future = com_worker->Async([&]() -> int { - HRESULT result; - float volume_level; - - if (wasapi_is_exclusive(output)) { - auto endpoint_volume = - Activate(*wasapi_output_get_device(output)); - - result = endpoint_volume->GetMasterVolumeLevelScalar( - &volume_level); - if (FAILED(result)) { - throw MakeHResultError(result, - "Unable to get master " - "volume level"); + try { + auto future = com_worker->Async([&]() -> int { + HRESULT result; + float volume_level; + + if (wasapi_is_exclusive(output)) { + auto endpoint_volume = + Activate(*wasapi_output_get_device(output)); + + result = endpoint_volume->GetMasterVolumeLevelScalar( + &volume_level); + if (FAILED(result)) { + throw MakeHResultError(result, + "Unable to get master " + "volume level"); + } + } else { + auto session_volume = + GetService(*wasapi_output_get_client(output)); + + result = session_volume->GetMasterVolume(&volume_level); + if (FAILED(result)) { + throw MakeHResultError( + result, "Unable to get master volume"); + } } - } else { - auto session_volume = - GetService(*wasapi_output_get_client(output)); - - result = session_volume->GetMasterVolume(&volume_level); - if (FAILED(result)) { - throw MakeHResultError( - result, "Unable to get master volume"); - } - } - return std::lround(volume_level * 100.0f); - }); - return future.get(); + return std::lround(volume_level * 100.0f); + }); + return future.get(); + } catch (...) { + LogDebug(wasapi_mixer_domain, + "Failed to get volume, device may be disconnected"); + return -1; + } } void SetVolume(unsigned volume) override { auto com_worker = wasapi_output_get_com_worker(output); if (!com_worker) - throw std::runtime_error("Cannot set WASAPI volume"); - - com_worker->Async([&]() { - HRESULT result; - const float volume_level = volume / 100.0f; - - if (wasapi_is_exclusive(output)) { - auto endpoint_volume = - Activate(*wasapi_output_get_device(output)); - - result = endpoint_volume->SetMasterVolumeLevelScalar( - volume_level, nullptr); - if (FAILED(result)) { - throw MakeHResultError( - result, - "Unable to set master volume level"); - } - } else { - auto session_volume = - GetService(*wasapi_output_get_client(output)); - - result = session_volume->SetMasterVolume(volume_level, - nullptr); - if (FAILED(result)) { - throw MakeHResultError( - result, "Unable to set master volume"); + return; + + try { + com_worker->Async([&]() { + HRESULT result; + const float volume_level = volume / 100.0f; + + if (wasapi_is_exclusive(output)) { + auto endpoint_volume = + Activate(*wasapi_output_get_device(output)); + + result = endpoint_volume->SetMasterVolumeLevelScalar( + volume_level, nullptr); + if (FAILED(result)) { + throw MakeHResultError( + result, + "Unable to set master volume level"); + } + } else { + auto session_volume = + GetService(*wasapi_output_get_client(output)); + + result = session_volume->SetMasterVolume(volume_level, + nullptr); + if (FAILED(result)) { + throw MakeHResultError( + result, "Unable to set master volume"); + } } - } - }).get(); + }).get(); + } catch (...) { + LogDebug(wasapi_mixer_domain, + "Failed to set volume, device may be disconnected"); + } } }; From a578e4780c30d0ed5ea6c02d4469a4753dd68058 Mon Sep 17 00:00:00 2001 From: 0x0003 <0x0003@users.noreply.github.com> Date: Sun, 12 Jul 2026 11:06:16 +0900 Subject: [PATCH 2/2] wasapi: detect default audio device changes Register an IMMNotificationClient to receive OnDefaultDeviceChanged callbacks. On change, the output thread wakes and reopens on the new default device immediately, bypassing the 10-second fail timer via a new AudioOutput::IsDeviceChange() virtual. Closes https://github.com/MusicPlayerDaemon/MPD/issues/1408 Closes https://github.com/MusicPlayerDaemon/MPD/issues/1880 --- src/output/Control.hxx | 5 +- src/output/Interface.hxx | 10 +++ src/output/Thread.cxx | 15 ++++ .../plugins/wasapi/DeviceNotification.hxx | 85 +++++++++++++++++++ .../plugins/wasapi/WasapiOutputPlugin.cxx | 61 +++++++++++-- 5 files changed, 165 insertions(+), 11 deletions(-) create mode 100644 src/output/plugins/wasapi/DeviceNotification.hxx diff --git a/src/output/Control.hxx b/src/output/Control.hxx index df1aed3d55..fdd62ef806 100644 --- a/src/output/Control.hxx +++ b/src/output/Control.hxx @@ -586,10 +586,7 @@ private: /** * An error has occurred, and this output must be closed. */ - void InternalCloseError(std::exception_ptr e) noexcept { - Failure(e); - InternalClose(false); - } + void InternalCloseError(std::exception_ptr e) noexcept; /** * Runs inside the OutputThread. diff --git a/src/output/Interface.hxx b/src/output/Interface.hxx index ae6011c7f6..3ba5d74bc7 100644 --- a/src/output/Interface.hxx +++ b/src/output/Interface.hxx @@ -198,6 +198,16 @@ public: */ virtual void Cancel() noexcept {} + /** + * Returns true if the most recent error was caused by an + * audio device change (e.g. default device switched). The + * output framework may use this to skip the fail timer and + * reopen the output immediately. + */ + virtual bool IsDeviceChange() const noexcept { + return false; + } + /** * Pause the device. If supported, it may perform a special * action, which keeps the device open, but does not play diff --git a/src/output/Thread.cxx b/src/output/Thread.cxx index 9048a814c6..70a75462de 100644 --- a/src/output/Thread.cxx +++ b/src/output/Thread.cxx @@ -4,6 +4,7 @@ #include "Control.hxx" #include "Error.hxx" #include "Filtered.hxx" +#include "Interface.hxx" #include "Client.hxx" #include "Domain.hxx" #include "lib/fmt/AudioFormatFormatter.hxx" @@ -183,6 +184,20 @@ AudioOutputControl::InternalClose(bool drain) noexcept source.Close(); } +void +AudioOutputControl::InternalCloseError(std::exception_ptr e) noexcept +{ + Failure(e); + + /* If the plugin reports a device change (e.g. default + output switched on Windows), bypass the fail timer so + the output gets reopened immediately. */ + if (output->output->IsDeviceChange()) + fail_timer.Reset(); + + InternalClose(false); +} + inline void AudioOutputControl::InternalCheckClose(bool drain) noexcept { diff --git a/src/output/plugins/wasapi/DeviceNotification.hxx b/src/output/plugins/wasapi/DeviceNotification.hxx new file mode 100644 index 0000000000..1f761950e6 --- /dev/null +++ b/src/output/plugins/wasapi/DeviceNotification.hxx @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright The Music Player Daemon Project + +#ifndef MPD_WASAPI_DEVICE_NOTIFICATION_HXX +#define MPD_WASAPI_DEVICE_NOTIFICATION_HXX + +#include +#include + +#include +#include + +/** + * IMMNotificationClient implementation that detects default audio + * device changes on Windows. + */ +class WasapiDeviceNotification final : public IMMNotificationClient { + std::atomic_bool &device_changed; + HANDLE device_event; + LONG ref_count = 1; + +public: + WasapiDeviceNotification(std::atomic_bool &_device_changed, + HANDLE _device_event) noexcept + :device_changed(_device_changed), + device_event(_device_event) {} + + /* IUnknown */ + HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, + void **ppv) noexcept override { + if (riid == IID_IUnknown || + riid == __uuidof(IMMNotificationClient)) { + *ppv = static_cast(this); + AddRef(); + return S_OK; + } + *ppv = nullptr; + return E_NOINTERFACE; + } + + ULONG STDMETHODCALLTYPE AddRef() noexcept override { + return InterlockedIncrement(&ref_count); + } + + ULONG STDMETHODCALLTYPE Release() noexcept override { + ULONG count = InterlockedDecrement(&ref_count); + if (count == 0) + delete this; + return count; + } + + /* IMMNotificationClient */ + HRESULT STDMETHODCALLTYPE + OnDeviceStateChanged(LPCWSTR, DWORD) noexcept override { + return S_OK; + } + + HRESULT STDMETHODCALLTYPE + OnDeviceAdded(LPCWSTR) noexcept override { + return S_OK; + } + + HRESULT STDMETHODCALLTYPE + OnDeviceRemoved(LPCWSTR) noexcept override { + return S_OK; + } + + HRESULT STDMETHODCALLTYPE + OnDefaultDeviceChanged(EDataFlow flow, ERole role, + LPCWSTR) noexcept override { + if (flow == eRender && role == eMultimedia) { + device_changed.store(true, std::memory_order_release); + SetEvent(device_event); + } + return S_OK; + } + + HRESULT STDMETHODCALLTYPE + OnPropertyValueChanged(LPCWSTR, + const PROPERTYKEY) noexcept override { + return S_OK; + } +}; + +#endif diff --git a/src/output/plugins/wasapi/WasapiOutputPlugin.cxx b/src/output/plugins/wasapi/WasapiOutputPlugin.cxx index d1ff2d6117..27cc6ae714 100644 --- a/src/output/plugins/wasapi/WasapiOutputPlugin.cxx +++ b/src/output/plugins/wasapi/WasapiOutputPlugin.cxx @@ -7,6 +7,7 @@ #include "ForMixer.hxx" #include "AudioClient.hxx" #include "Device.hxx" +#include "DeviceNotification.hxx" #include "PropertyStore.hxx" #include "output/OutputAPI.hxx" #include "lib/icu/Win32.hxx" @@ -151,7 +152,9 @@ class WasapiOutputThread { ComPtr render_client; const UINT32 frame_size; const UINT32 buffer_size_in_frames; - const bool is_exclusive; + const bool is_exclusive; + std::atomic_bool &device_changed; + HANDLE device_event; /** * This flag is only used by the calling thread @@ -184,10 +187,14 @@ class WasapiOutputThread { WasapiOutputThread(IAudioClient &_client, ComPtr &&_render_client, const UINT32 _frame_size, const UINT32 _buffer_size_in_frames, - bool _is_exclusive) + bool _is_exclusive, + std::atomic_bool &_device_changed, + HANDLE _device_event) :client(_client), render_client(std::move(_render_client)), frame_size(_frame_size), buffer_size_in_frames(_buffer_size_in_frames), is_exclusive(_is_exclusive), + device_changed(_device_changed), + device_event(_device_event), ring_buffer(_buffer_size_in_frames * 4 * _frame_size) { SetEventHandle(client, event.handle()); @@ -304,6 +311,7 @@ class WasapiOutput final : public AudioOutput { const std::string device_config; std::shared_ptr com_worker; + ComPtr device_enumerator; ComPtr device; ComPtr client; WAVEFORMATEXTENSIBLE device_format; @@ -311,6 +319,11 @@ class WasapiOutput final : public AudioOutput { std::size_t watermark; std::optional pcm_export; + /* Device change notification state (see DeviceNotification.hxx) */ + std::atomic_bool device_changed{false}; + WinEvent device_event; + ComPtr notification; + public: static AudioOutput *Create(EventLoop &, const ConfigBlock &block); WasapiOutput(const ConfigBlock &block); @@ -324,14 +337,31 @@ class WasapiOutput final : public AudioOutput { com_worker = std::make_shared(); try { - com_worker->Async([&]() { ChooseDevice(); }).get(); + com_worker->Async([&]() { + ChooseDevice(); + + device_enumerator.CoCreateInstance( + __uuidof(MMDeviceEnumerator), + nullptr, CLSCTX_INPROC_SERVER); + if (device_enumerator) { + notification = ComPtr(new WasapiDeviceNotification(device_changed, device_event.handle())); + device_enumerator->RegisterEndpointNotificationCallback(notification.get()); + } + }).get(); } catch (...) { com_worker.reset(); throw; } } void Disable() noexcept override { - com_worker->Async([&]() { DoDisable(); }).get(); + com_worker->Async([&]() { + if (notification && device_enumerator) { + device_enumerator->UnregisterEndpointNotificationCallback(notification.get()); + notification.reset(); + } + device_enumerator.reset(); + DoDisable(); + }).get(); com_worker.reset(); } void Open(AudioFormat &audio_format) override { @@ -345,6 +375,9 @@ class WasapiOutput final : public AudioOutput { void Cancel() noexcept override; bool Pause() override; void Interrupt() noexcept override; + bool IsDeviceChange() const noexcept override { + return device_changed.load(std::memory_order_acquire); + } constexpr bool Exclusive() const { return is_exclusive; } constexpr size_t FrameSize() const { return device_format.Format.nBlockAlign; } @@ -419,7 +452,16 @@ try { }; while (true) { - event.Wait(); + HANDLE handles[2] = { event.handle(), device_event }; + WaitForMultipleObjects(2, handles, FALSE, INFINITE); + + if (device_changed.load(std::memory_order_acquire)) { + /* Default output device changed; the output + framework will reopen on the new device. */ + LogDebug(wasapi_output_domain, + "Audio device changed, stopping playback"); + throw std::runtime_error("Audio device changed"); + } if (cancel.load()) { ring_buffer.Discard(); @@ -531,10 +573,14 @@ WasapiOutput::DoOpen(AudioFormat &audio_format) { client.reset(); - if (GetState(*device) != DEVICE_STATE_ACTIVE) { + /* Re-enumerate if the default device changed or the current + device is no longer active (e.g. headset unplugged). */ + if (device_changed.load(std::memory_order_acquire) || + GetState(*device) != DEVICE_STATE_ACTIVE) { device.reset(); ChooseDevice(); } + device_changed.store(false, std::memory_order_release); client = Activate(*device); @@ -657,7 +703,8 @@ WasapiOutput::DoOpen(AudioFormat &audio_format) watermark = buffer_size_in_frames * 3 * FrameSize(); thread.emplace(*client, std::move(render_client), FrameSize(), - buffer_size_in_frames, is_exclusive); + buffer_size_in_frames, is_exclusive, device_changed, + device_event.handle()); paused = false; }