ref:f111d6a4af0c9535f46cf710a6dbd198616cc830

fix(windows): capture the configured audio sink instead of the default device (#5408)

Co-authored-by: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com>
SHA: f111d6a4af0c9535f46cf710a6dbd198616cc830
Author: tonyctalope <95322229+tonyctalope@users.noreply.github.com>
Date: 2026-09-01 19:27
Parents: c59469b
2 files changed +532 -6
Type
src/platform/windows/audio.cpp +167 −6
@@ -6,6 +6,7 @@
// standard includes
#include <format>
#include <utility>
// platform includes
#include <Audioclient.h>
@@ -599,9 +600,10 @@
* @param frame_size Number of samples captured per audio frame.
* @param channels_out Channels out.
* @param continuous Whether silent audio should continue to be emitted.
* @param capture_device Endpoint device to capture from; the default render device is used when empty.
* @return 0 on success; nonzero or negative platform status on failure.
*/
int init(std::uint32_t sample_rate, std::uint32_t frame_size, std::uint32_t channels_out, bool continuous) {
int init(std::uint32_t sample_rate, std::uint32_t frame_size, std::uint32_t channels_out, bool continuous, device_t capture_device) {
audio_event.reset(CreateEventA(nullptr, FALSE, FALSE, nullptr));
if (!audio_event) {
BOOST_LOG(error) << "Couldn't create Event handle"sv;
@@ -631,8 +633,9 @@
return -1;
}
select_capture_device(std::move(capture_device));
auto device = default_device(device_enum);
if (!device) {
return -1;
}
@@ -708,6 +711,20 @@
return 0;
}
/**
* @brief Select the endpoint used by this capture stream.
*
* @param capture_device Explicit endpoint to capture, or an empty pointer to follow the default endpoint.
*/
void select_capture_device(device_t capture_device) {
follows_default_device = !capture_device;
if (follows_default_device) {
device = default_device(device_enum);
} else {
device = std::move(capture_device);
}
}
~mic_wasapi_t() override {
if (device_enum) {
device_enum->UnregisterEndpointNotificationCallback(&endpt_notification);
@@ -744,8 +761,11 @@
(*default_endpt_changed_cb)();
}
// Reinitialize to pick up the new default device
return capture_e::reinit;
// Reinitialize to pick up the new default device, unless capture is
// pinned to an explicitly requested sink
if (follows_default_device) {
return capture_e::reinit;
}
}
status = WaitForSingleObjectEx(audio_event.get(), default_latency_ms, FALSE);
@@ -833,6 +853,7 @@
float *sample_buf_pos; ///< Current write position in `sample_buf`.
int channels; ///< Number of channels in the capture format.
bool continuous_audio; ///< Whether audio packets continue during silence.
bool follows_default_device; ///< Whether capture follows the default render device rather than an explicit sink.
HANDLE mmcss_task_handle = nullptr; ///< MMCSS task handle for the audio capture thread.
};
@@ -925,6 +946,34 @@
}
/**
* @brief Resolve a sink name to the audio endpoint device it refers to.
*
* @param sink Sink name, virtual sink descriptor, or device identifier.
* @return Endpoint device to capture from, or an empty pointer if the sink couldn't be resolved.
*/
device_t get_sink_device(const std::string &sink) {
std::wstring device_id;
if (auto virtual_sink_info = extract_virtual_sink_info(sink)) {
device_id = virtual_sink_info->first;
} else if (auto matched = find_device_id(match_all_fields(utf_utils::from_utf8(sink)))) {
device_id = matched->second;
} else {
return nullptr;
}
device_t device;
if (FAILED(device_enum->GetDevice(device_id.c_str(), &device))) {
return nullptr;
}
if (DWORD device_state {}; FAILED(device->GetState(&device_state)) || device_state != DEVICE_STATE_ACTIVE) {
return nullptr;
}
return device;
}
/**
* @brief Create a microphone capture stream for the requested layout.
*
* @param mapping Opus channel mapping table for the requested layout.
@@ -937,7 +986,25 @@
*/
std::unique_ptr<mic_t> microphone(const std::uint8_t *mapping, int channels, std::uint32_t sample_rate, std::uint32_t frame_size, bool continuous_audio, [[maybe_unused]] bool host_audio_enabled) override {
auto mic = std::make_unique<mic_wasapi_t>();
// Prefer the sink that was assigned to this capture session since it accounts
// for the priority between virtual and configured sinks.
const auto &requested_sink = assigned_sink.empty() ? config::audio.sink : assigned_sink;
// Capture the requested sink directly instead of relying on it being the default
// render device, so that capture keeps working when the default device differs
// from the sink or changes during the session.
device_t capture_device;
if (!requested_sink.empty()) {
if (mic->init(sample_rate, frame_size, channels, continuous_audio)) {
capture_device = get_sink_device(requested_sink);
if (!capture_device) {
BOOST_LOG(error) << "Couldn't resolve audio sink ["sv << requested_sink << "] to a capture device"sv;
return nullptr;
}
BOOST_LOG(info) << "Capturing audio from sink ["sv << requested_sink << ']';
}
if (mic->init(sample_rate, frame_size, channels, continuous_audio, std::move(capture_device))) {
return nullptr;
}
@@ -1356,8 +1423,102 @@
policy_t policy; ///< Windows policy configuration interface used to switch default audio devices.
audio::device_enum_t device_enum; ///< Device enumerator used to query and watch audio endpoints.
std::string assigned_sink; ///< Virtual sink assigned while Sunshine captures host audio.
std::string assigned_sink; ///< Sink assigned while Sunshine captures host audio, captured directly by the microphone.
};
#ifdef SUNSHINE_TESTS
namespace tests {
/**
* @brief Resolve a sink through the production Windows endpoint lookup.
*
* @param sink Sink name, virtual sink descriptor, or device identifier.
* @param device_enum Device enumerator supplied by the test.
* @return `true` when the sink resolves to an active endpoint.
*/
bool sink_device_available(const std::string &sink, IMMDeviceEnumerator *device_enum) {
audio_control_t control;
device_enum->AddRef();
control.device_enum.reset(device_enum);
return static_cast<bool>(control.get_sink_device(sink));
}
/**
* @brief Exercise microphone creation with controlled assigned and configured sinks.
*
* @param assigned_sink Sink selected by the shared audio context.
* @param configured_sink Sink configured by the user.
* @param device_enum Device enumerator supplied by the test.
* @return `true` when microphone initialization succeeds.
*/
bool microphone_available(const std::string &assigned_sink, const std::string &configured_sink, IMMDeviceEnumerator *device_enum) {
audio_control_t control;
device_enum->AddRef();
control.device_enum.reset(device_enum);
control.assigned_sink = assigned_sink;
auto previous_configured_sink = std::exchange(config::audio.sink, configured_sink);
auto microphone = control.microphone(nullptr, 2, 48000, 240, false, false);
config::audio.sink = std::move(previous_configured_sink);
return static_cast<bool>(microphone);
}
/**
* @brief Select a default or explicit capture endpoint through the production selection path.
*
* @param device_enum Device enumerator supplied by the test.
* @param capture_device Explicit endpoint, or `nullptr` to select the default endpoint.
* @return `true` when capture follows the default endpoint.
*/
bool capture_follows_default_device(IMMDeviceEnumerator *device_enum, IMMDevice *capture_device) {
mic_wasapi_t microphone;
device_enum->AddRef();
microphone.device_enum.reset(device_enum);
device_t selected_device;
if (capture_device) {
capture_device->AddRef();
selected_device.reset(capture_device);
}
microphone.select_capture_device(std::move(selected_device));
return microphone.follows_default_device;
}
/**
* @brief Exercise the production default-device-change path without live audio hardware.
*
* @param follows_default_device Whether the capture follows the default render endpoint.
* @param install_callback Whether to install a default-device-change callback.
* @param render_device_changed Whether to signal a render rather than capture endpoint change.
* @param callback_count Receives the number of callback invocations.
* @return Capture result produced after processing the notification.
*/
capture_e simulate_default_device_change(bool follows_default_device, bool install_callback, bool render_device_changed, int &callback_count) {
mic_wasapi_t mic;
mic.audio_event.reset(CreateEventA(nullptr, FALSE, FALSE, nullptr));
mic.default_latency_ms = 0;
mic.sample_buf = util::buffer_t<float> {1};
mic.sample_buf_pos = std::begin(mic.sample_buf);
mic.continuous_audio = false;
mic.follows_default_device = follows_default_device;
if (install_callback) {
mic.default_endpt_changed_cb = [&callback_count] {
++callback_count;
};
}
mic.endpt_notification.OnDefaultDeviceChanged(
render_device_changed ? eRender : eCapture,
eConsole,
nullptr
);
std::vector<float> sample(1);
return mic.sample(sample);
}
} // namespace tests
#endif
} // namespace platf::audio
namespace platf {
tests/unit/platform/windows/test_audio.cpp +365 −0
@@ -1,0 +1,365 @@
/**
* @file tests/unit/platform/windows/test_audio.cpp
* @brief Tests for Windows audio sink selection and endpoint-change handling.
*/
#include "../../../tests_common.h"
#ifdef _WIN32
#include "src/platform/common.h"
#include <cstring>
#include <mmdeviceapi.h>
#include <propsys.h>
namespace platf::audio::tests {
bool sink_device_available(const std::string &sink, IMMDeviceEnumerator *device_enum);
bool microphone_available(const std::string &assigned_sink, const std::string &configured_sink, IMMDeviceEnumerator *device_enum);
bool capture_follows_default_device(IMMDeviceEnumerator *device_enum, IMMDevice *capture_device);
capture_e simulate_default_device_change(bool follows_default_device, bool install_callback, bool render_device_changed, int &callback_count);
} // namespace platf::audio::tests
namespace {
class fake_property_store_t final: public IPropertyStore {
public:
explicit fake_property_store_t(std::wstring friendly_name):
friendly_name {std::move(friendly_name)} {
}
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID, void **object) override { // NOSONAR(cpp:S5008): required by the Windows COM interface
*object = nullptr;
return E_NOINTERFACE;
}
ULONG STDMETHODCALLTYPE AddRef() override {
return 1;
}
ULONG STDMETHODCALLTYPE Release() override {
return 1;
}
HRESULT STDMETHODCALLTYPE GetCount(DWORD *property_count) override {
*property_count = friendly_name.empty() ? 0 : 1;
return S_OK;
}
HRESULT STDMETHODCALLTYPE GetAt(DWORD, PROPERTYKEY *) override {
return E_NOTIMPL;
}
HRESULT STDMETHODCALLTYPE GetValue(REFPROPERTYKEY, PROPVARIANT *value) override {
if (friendly_name.empty()) {
return E_NOTIMPL;
}
const auto byte_count = (friendly_name.size() + 1) * sizeof(wchar_t);
value->pwszVal = static_cast<wchar_t *>(CoTaskMemAlloc(byte_count));
if (!value->pwszVal) {
return E_OUTOFMEMORY;
}
std::memcpy(value->pwszVal, friendly_name.c_str(), byte_count);
value->vt = VT_LPWSTR;
return S_OK;
}
HRESULT STDMETHODCALLTYPE SetValue(REFPROPERTYKEY, REFPROPVARIANT) override {
return E_NOTIMPL;
}
HRESULT STDMETHODCALLTYPE Commit() override {
return E_NOTIMPL;
}
std::wstring friendly_name;
};
class fake_device_t final: public IMMDevice {
public:
fake_device_t(std::wstring id, std::wstring friendly_name):
id {std::move(id)},
properties {std::move(friendly_name)} {
}
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID, void **object) override { // NOSONAR(cpp:S5008): required by the Windows COM interface
*object = nullptr;
return E_NOINTERFACE;
}
ULONG STDMETHODCALLTYPE AddRef() override {
return 1;
}
ULONG STDMETHODCALLTYPE Release() override {
return 1;
}
HRESULT STDMETHODCALLTYPE Activate(REFIID, DWORD, PROPVARIANT *, void **) override { // NOSONAR(cpp:S5008): required by the Windows COM interface
return E_NOTIMPL;
}
HRESULT STDMETHODCALLTYPE OpenPropertyStore(DWORD, IPropertyStore **property_store) override {
properties.AddRef();
*property_store = &properties;
return S_OK;
}
HRESULT STDMETHODCALLTYPE GetId(LPWSTR *device_id) override {
const auto byte_count = (id.size() + 1) * sizeof(wchar_t);
*device_id = static_cast<wchar_t *>(CoTaskMemAlloc(byte_count));
if (!*device_id) {
return E_OUTOFMEMORY;
}
std::memcpy(*device_id, id.c_str(), byte_count);
return S_OK;
}
HRESULT STDMETHODCALLTYPE GetState(DWORD *device_state) override {
if (FAILED(state_status)) {
return state_status;
}
*device_state = state;
return S_OK;
}
std::wstring id;
fake_property_store_t properties;
HRESULT state_status = S_OK;
DWORD state = DEVICE_STATE_ACTIVE;
};
class fake_device_collection_t final: public IMMDeviceCollection {
public:
explicit fake_device_collection_t(fake_device_t &device):
device {device} {
}
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID, void **object) override { // NOSONAR(cpp:S5008): required by the Windows COM interface
*object = nullptr;
return E_NOINTERFACE;
}
ULONG STDMETHODCALLTYPE AddRef() override {
return 1;
}
ULONG STDMETHODCALLTYPE Release() override {
return 1;
}
HRESULT STDMETHODCALLTYPE GetCount(UINT *device_count) override {
*device_count = 1;
return S_OK;
}
HRESULT STDMETHODCALLTYPE Item(UINT index, IMMDevice **item) override {
if (index != 0) {
return E_INVALIDARG;
}
device.AddRef();
*item = &device;
return S_OK;
}
fake_device_t &device;
};
class fake_device_enumerator_t final: public IMMDeviceEnumerator {
public:
explicit fake_device_enumerator_t(std::wstring id, std::wstring friendly_name = {}):
device {std::move(id), std::move(friendly_name)},
collection {device} {
}
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID, void **object) override { // NOSONAR(cpp:S5008): required by the Windows COM interface
*object = nullptr;
return E_NOINTERFACE;
}
ULONG STDMETHODCALLTYPE AddRef() override {
return 1;
}
ULONG STDMETHODCALLTYPE Release() override {
return 1;
}
HRESULT STDMETHODCALLTYPE EnumAudioEndpoints(EDataFlow, DWORD, IMMDeviceCollection **devices) override {
if (FAILED(enumeration_status)) {
return enumeration_status;
}
collection.AddRef();
*devices = &collection;
return S_OK;
}
HRESULT STDMETHODCALLTYPE GetDefaultAudioEndpoint(EDataFlow, ERole, IMMDevice **resolved_device) override {
++get_default_device_calls;
device.AddRef();
*resolved_device = &device;
return S_OK;
}
HRESULT STDMETHODCALLTYPE GetDevice(LPCWSTR device_id, IMMDevice **resolved_device) override {
++get_device_calls;
last_requested_id = device_id;
if (FAILED(get_device_status)) {
return get_device_status;
}
if (last_requested_id != device.id) {
return HRESULT_FROM_WIN32(ERROR_NOT_FOUND);
}
device.AddRef();
*resolved_device = &device;
return S_OK;
}
HRESULT STDMETHODCALLTYPE RegisterEndpointNotificationCallback(IMMNotificationClient *) override {
return S_OK;
}
HRESULT STDMETHODCALLTYPE UnregisterEndpointNotificationCallback(IMMNotificationClient *) override {
return S_OK;
}
fake_device_t device;
fake_device_collection_t collection;
HRESULT enumeration_status = S_OK;
HRESULT get_device_status = S_OK;
int get_device_calls = 0;
int get_default_device_calls = 0;
std::wstring last_requested_id;
};
} // namespace
TEST(WindowsAudioTest, AssignedSinkTakesPriorityOverConfiguredSink) {
fake_device_enumerator_t enumerator {L"assigned-id"};
EXPECT_FALSE(platf::audio::tests::microphone_available("assigned-id", "configured-id", &enumerator));
EXPECT_EQ(enumerator.get_device_calls, 1);
EXPECT_EQ(enumerator.last_requested_id, L"assigned-id");
}
TEST(WindowsAudioTest, ConfiguredSinkIsUsedWhenNoSinkWasAssigned) {
fake_device_enumerator_t enumerator {L"configured-id"};
EXPECT_FALSE(platf::audio::tests::microphone_available({}, "configured-id", &enumerator));
EXPECT_EQ(enumerator.get_device_calls, 1);
EXPECT_EQ(enumerator.last_requested_id, L"configured-id");
}
TEST(WindowsAudioTest, DefaultDeviceIsUsedWhenNoSinkWasRequested) {
fake_device_enumerator_t enumerator {L"endpoint-id"};
platf::audio::tests::microphone_available({}, {}, &enumerator);
EXPECT_EQ(enumerator.get_device_calls, 0);
}
TEST(WindowsAudioTest, DefaultCaptureSelectsDefaultEndpoint) {
fake_device_enumerator_t enumerator {L"endpoint-id"};
EXPECT_TRUE(platf::audio::tests::capture_follows_default_device(&enumerator, nullptr));
EXPECT_EQ(enumerator.get_default_device_calls, 1);
}
TEST(WindowsAudioTest, PinnedCaptureKeepsExplicitEndpoint) {
fake_device_enumerator_t enumerator {L"endpoint-id"};
EXPECT_FALSE(platf::audio::tests::capture_follows_default_device(&enumerator, &enumerator.device));
EXPECT_EQ(enumerator.get_default_device_calls, 0);
}
TEST(WindowsAudioTest, MicrophoneRejectsUnresolvedSink) {
fake_device_enumerator_t enumerator {L"endpoint-id"};
EXPECT_FALSE(platf::audio::tests::microphone_available("unknown", {}, &enumerator));
EXPECT_EQ(enumerator.get_device_calls, 0);
}
TEST(WindowsAudioTest, ResolvesVirtualSinkDescriptorToActiveEndpoint) {
fake_device_enumerator_t enumerator {L"endpoint-id"};
EXPECT_TRUE(platf::audio::tests::sink_device_available("virtual-Stereoendpoint-id", &enumerator));
EXPECT_EQ(enumerator.get_device_calls, 1);
EXPECT_EQ(enumerator.last_requested_id, L"endpoint-id");
}
TEST(WindowsAudioTest, ResolvesDeviceIdentifiersAndFriendlyNames) {
fake_device_enumerator_t id_enumerator {L"endpoint-id"};
EXPECT_TRUE(platf::audio::tests::sink_device_available("endpoint-id", &id_enumerator));
fake_device_enumerator_t name_enumerator {L"endpoint-id", L"Friendly Endpoint"};
EXPECT_TRUE(platf::audio::tests::sink_device_available("Friendly Endpoint", &name_enumerator));
}
TEST(WindowsAudioTest, RejectsUnknownOrUnenumerableSinks) {
fake_device_enumerator_t unknown_enumerator {L"endpoint-id"};
EXPECT_FALSE(platf::audio::tests::sink_device_available("unknown", &unknown_enumerator));
EXPECT_EQ(unknown_enumerator.get_device_calls, 0);
fake_device_enumerator_t failed_enumerator {L"endpoint-id"};
failed_enumerator.enumeration_status = E_FAIL;
EXPECT_FALSE(platf::audio::tests::sink_device_available("endpoint-id", &failed_enumerator));
EXPECT_EQ(failed_enumerator.get_device_calls, 0);
}
TEST(WindowsAudioTest, RejectsUnavailableResolvedEndpoints) {
fake_device_enumerator_t missing_enumerator {L"endpoint-id"};
missing_enumerator.get_device_status = E_FAIL;
EXPECT_FALSE(platf::audio::tests::sink_device_available("virtual-Stereoendpoint-id", &missing_enumerator));
fake_device_enumerator_t state_failure_enumerator {L"endpoint-id"};
state_failure_enumerator.device.state_status = E_FAIL;
EXPECT_FALSE(platf::audio::tests::sink_device_available("virtual-Stereoendpoint-id", &state_failure_enumerator));
fake_device_enumerator_t inactive_enumerator {L"endpoint-id"};
inactive_enumerator.device.state = DEVICE_STATE_DISABLED;
EXPECT_FALSE(platf::audio::tests::sink_device_available("virtual-Stereoendpoint-id", &inactive_enumerator));
}
TEST(WindowsAudioTest, DefaultFollowingCaptureReinitializesAfterRenderEndpointChange) {
int callback_count = 0;
EXPECT_EQ(
platf::audio::tests::simulate_default_device_change(true, true, true, callback_count),
platf::capture_e::reinit
);
EXPECT_EQ(callback_count, 1);
}
TEST(WindowsAudioTest, PinnedCaptureContinuesAfterRenderEndpointChange) {
int callback_count = 0;
EXPECT_EQ(
platf::audio::tests::simulate_default_device_change(false, true, true, callback_count),
platf::capture_e::timeout
);
EXPECT_EQ(callback_count, 1);
}
TEST(WindowsAudioTest, CaptureEndpointChangeDoesNotTriggerRenderCallback) {
int callback_count = 0;
EXPECT_EQ(
platf::audio::tests::simulate_default_device_change(false, true, false, callback_count),
platf::capture_e::timeout
);
EXPECT_EQ(callback_count, 0);
}
TEST(WindowsAudioTest, DefaultChangeWithoutCallbackStillReinitializes) {
int callback_count = 0;
EXPECT_EQ(
platf::audio::tests::simulate_default_device_change(true, false, true, callback_count),
platf::capture_e::reinit
);
EXPECT_EQ(callback_count, 0);
}
#endif