diff --git a/src/platform/windows/audio.cpp b/src/platform/windows/audio.cpp index 38bce01ca86..9ace2a6b338 100644 --- a/src/platform/windows/audio.cpp +++ b/src/platform/windows/audio.cpp @@ -6,6 +6,7 @@ // standard includes #include +#include // platform includes #include @@ -599,9 +600,10 @@ namespace platf::audio { * @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; @@ -632,7 +634,8 @@ namespace platf::audio { return -1; } - auto device = default_device(device_enum); + select_capture_device(std::move(capture_device)); + if (!device) { return -1; } @@ -708,6 +711,20 @@ namespace platf::audio { 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 @@ namespace platf::audio { (*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 @@ namespace platf::audio { 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. }; @@ -924,6 +945,34 @@ namespace platf::audio { return std::nullopt; } + /** + * @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. * @@ -938,7 +987,25 @@ namespace platf::audio { std::unique_ptr 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(); - if (mic->init(sample_rate, frame_size, channels, continuous_audio)) { + // 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()) { + 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 @@ namespace platf::audio { 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(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(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 {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 sample(1); + return mic.sample(sample); + } + } // namespace tests +#endif } // namespace platf::audio namespace platf { diff --git a/tests/unit/platform/windows/test_audio.cpp b/tests/unit/platform/windows/test_audio.cpp new file mode 100644 index 00000000000..081b5b0d571 --- /dev/null +++ b/tests/unit/platform/windows/test_audio.cpp @@ -0,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 + #include + #include + +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(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(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