From ba6ab9def5c42ba36308b862175da09c849ea56f Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 7 Sep 2026 21:20:46 -0600 Subject: [PATCH] feat(windows): add externally managed audio capture --- docs/configuration.md | 34 ++++ src/config.cpp | 2 + src/config.h | 1 + src/platform/windows/audio.cpp | 94 ++++++++-- .../assets/web/configs/config_tabs.json | 1 + .../assets/web/configs/tabs/AudioVideo.vue | 12 ++ .../assets/web/public/assets/locale/en.json | 2 + tests/unit/platform/windows/test_audio.cpp | 171 ++++++++++++++++++ tests/web/ExternalAudio.test.js | 81 +++++++++ 9 files changed, 380 insertions(+), 18 deletions(-) create mode 100644 tests/web/ExternalAudio.test.js diff --git a/docs/configuration.md b/docs/configuration.md index 3a90dc3f286..2a20a2aec40 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -938,6 +938,40 @@ consent prompt for directory ACLs, so the Web UI shows setup steps for correctin +### external_audio + + + + + + + + + + + + + + +
Description + Enables capture-only operation when an external audio router manages application playback. + An explicit [audio_sink](#audio_sink) is required; use an endpoint ID when names are ambiguous. + Sunshine captures that endpoint directly without changing or restoring Windows default devices + or endpoint formats. +
+
+ In this mode, [virtual_sink](#virtual_sink), automatic Steam audio driver installation, + and Moonlight's host-playback toggle do not affect routing. Configure local playback and the audio + sent to the selected endpoint in your external mixer. If the endpoint is unavailable, audio capture + fails rather than falling back to another endpoint. Video streaming can continue without audio. + @note{This option is only supported on Windows. Other platforms retain their existing behavior.} +
Default@code{} + disabled + @endcode
Example@code{} + external_audio = enabled + audio_sink = My Streaming Mix + @endcode
+ ### adapter_name diff --git a/src/config.cpp b/src/config.cpp index 708ccc02394..580670401c2 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -808,6 +808,7 @@ namespace config { {}, // virtual_sink true, // stream audio true, // install_steam_drivers + false, // external_audio }; /** @@ -1753,6 +1754,7 @@ namespace config { string_f(vars, "virtual_sink", audio.virtual_sink); bool_f(vars, "stream_audio", audio.stream); bool_f(vars, "install_steam_audio_drivers", audio.install_steam_drivers); + bool_f(vars, "external_audio", audio.external_audio); string_restricted_f(vars, "origin_web_ui_allowed", nvhttp.origin_web_ui_allowed, {"pc"sv, "lan"sv, "wan"sv}); diff --git a/src/config.h b/src/config.h index a381c229a47..976b97d1b1f 100644 --- a/src/config.h +++ b/src/config.h @@ -232,6 +232,7 @@ namespace config { std::string virtual_sink; ///< Virtual audio sink for audio routing bool stream; ///< Enable audio streaming to clients bool install_steam_drivers; ///< Install Steam audio drivers for enhanced compatibility + bool external_audio; ///< Windows capture-only mode; an external router owns endpoint defaults and formats. }; /** diff --git a/src/platform/windows/audio.cpp b/src/platform/windows/audio.cpp index 9ace2a6b338..a7e656f6212 100644 --- a/src/platform/windows/audio.cpp +++ b/src/platform/windows/audio.cpp @@ -6,6 +6,8 @@ // standard includes #include +#include +#include #include // platform includes @@ -871,6 +873,15 @@ namespace platf::audio { std::optional sink_info() override { sink_t sink; + if (config::audio.external_audio) { + if (config::audio.sink.empty()) { + BOOST_LOG(error) << "external_audio requires an explicit audio_sink"; + return std::nullopt; + } + sink.host = config::audio.sink; + return sink; + } + // Fill host sink name with the device_id of the current default audio device. { auto device = default_device(device_enum); @@ -987,9 +998,13 @@ 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(); - // 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; + // External routing pins capture to the configured sink; otherwise prefer the session's assigned sink. + const auto &requested_sink = config::audio.external_audio || assigned_sink.empty() ? config::audio.sink : assigned_sink; + + if (config::audio.external_audio && requested_sink.empty()) { + BOOST_LOG(error) << "external_audio requires an explicit audio_sink"; + return nullptr; + } // 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 @@ -1011,7 +1026,7 @@ namespace platf::audio { // If this is a virtual sink, set a callback that will change the sink back if it's changed auto virtual_sink_info = extract_virtual_sink_info(assigned_sink); - if (virtual_sink_info) { + if (virtual_sink_info && !config::audio.external_audio) { mic->default_endpt_changed_cb = [this] { BOOST_LOG(info) << "Resetting sink to ["sv << assigned_sink << "] after default changed"; set_sink(assigned_sink); @@ -1098,6 +1113,10 @@ namespace platf::audio { * @return Status from updating sink. */ int set_sink(const std::string &sink) override { + if (config::audio.external_audio) { + return 0; + } + auto device_id = set_format(sink); if (!device_id) { return -1; @@ -1252,6 +1271,10 @@ namespace platf::audio { * @brief Resets the default audio device from Steam Streaming Speakers. */ void reset_default_device() { + if (config::audio.external_audio) { + return; + } + auto matched_steam = find_device_id(match_steam_speakers()); if (!matched_steam) { return; @@ -1380,26 +1403,31 @@ namespace platf::audio { } /** - * @brief Initialize Windows audio policy interfaces. + * @brief Initialize endpoint enumeration and policy control when routing is managed by Sunshine. * + * @tparam CreateInstance Callable type used for COM activation. + * @param create_instance COM factory used to initialize the required interfaces. * @return 0 on success; nonzero or negative platform status on failure. */ - int init() { - auto status = CoCreateInstance( - CLSID_CPolicyConfigClient, - nullptr, - CLSCTX_ALL, - IID_IPolicyConfig, - (void **) &policy - ); + template + int init(const CreateInstance &create_instance = &CoCreateInstance) { + if (!config::audio.external_audio) { + auto status = create_instance( + CLSID_CPolicyConfigClient, + nullptr, + CLSCTX_ALL, + IID_IPolicyConfig, + (void **) &policy + ); - if (FAILED(status)) { - BOOST_LOG(error) << "Couldn't create audio policy config: [0x"sv << util::hex(status).to_string_view() << ']'; + if (FAILED(status)) { + BOOST_LOG(error) << "Couldn't create audio policy config: [0x"sv << util::hex(status).to_string_view() << ']'; - return -1; + return -1; + } } - status = CoCreateInstance( + auto status = create_instance( CLSID_MMDeviceEnumerator, nullptr, CLSCTX_ALL, @@ -1428,6 +1456,36 @@ namespace platf::audio { #ifdef SUNSHINE_TESTS namespace tests { + /** + * @brief Exercise controller initialization with a supplied COM factory. + * @param create_instance Factory providing or rejecting the requested interfaces. + * @return Status from controller initialization. + */ + int initialize_audio_control(const std::function> &create_instance) { + audio_control_t control; + return control.init(create_instance); + } + + /** + * @brief Exercise sink discovery without initializing Windows policy interfaces. + * @return Sinks reported by the production controller for the current configuration. + */ + std::optional configured_sink_info() { + audio_control_t control; + return control.sink_info(); + } + + /** + * @brief Exercise the external-routing sink-change guard without policy interfaces. + * @param sink Sink a caller attempts to select. + * @return Status from the production sink setter. + */ + int set_external_sink(const std::string &sink) { + audio_control_t control; + control.reset_default_device(); + return control.set_sink(sink); + } + /** * @brief Resolve a sink through the production Windows endpoint lookup. * @@ -1542,7 +1600,7 @@ namespace platf { // Install Steam Streaming Speakers if needed. We do this during audio_control() to ensure // the sink information returned includes the new Steam Streaming Speakers device. - if (config::audio.install_steam_drivers && !control->find_device_id(control->match_steam_speakers())) { + if (!config::audio.external_audio && config::audio.install_steam_drivers && !control->find_device_id(control->match_steam_speakers())) { // This is best effort. Don't fail if it doesn't work. control->install_steam_audio_drivers(); } diff --git a/src_assets/common/assets/web/configs/config_tabs.json b/src_assets/common/assets/web/configs/config_tabs.json index 5158dbfdef8..71ab0211041 100644 --- a/src_assets/common/assets/web/configs/config_tabs.json +++ b/src_assets/common/assets/web/configs/config_tabs.json @@ -42,6 +42,7 @@ "virtual_sink": "", "stream_audio": "enabled", "install_steam_audio_drivers": "enabled", + "external_audio": "disabled", "adapter_name": "", "output_name": "", "dd_configuration_option": "disabled", diff --git a/src_assets/common/assets/web/configs/tabs/AudioVideo.vue b/src_assets/common/assets/web/configs/tabs/AudioVideo.vue index 7fbf241db70..61144cb39c1 100644 --- a/src_assets/common/assets/web/configs/tabs/AudioVideo.vue +++ b/src_assets/common/assets/web/configs/tabs/AudioVideo.vue @@ -75,6 +75,18 @@ const config = ref(props.config) default="true" > + + + + #include + #include + #include // platform includes #include #include // local includes + #include "src/config.h" #include "src/platform/common.h" namespace platf::audio::tests { + int initialize_audio_control(const std::function> &create_instance); + std::optional configured_sink_info(); + int set_external_sink(const std::string &sink); 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); @@ -266,6 +273,170 @@ TEST(WindowsAudioTest, DefaultDeviceIsUsedWhenNoSinkWasRequested) { EXPECT_EQ(enumerator.get_device_calls, 0); } +/** + * @brief Preserve global audio configuration while exercising capture-only mode. + */ +class ExternalAudioTest: public testing::Test { +protected: + void SetUp() override { + previous = config::audio; + config::audio.external_audio = true; + config::audio.sink = "configured-id"; + config::audio.virtual_sink = "ignored-virtual-sink"; + policy_requests = 0; + enumerator_requests = 0; + enumerator_status = S_OK; + } + + void TearDown() override { + config::audio = previous; + } + + /** + * @brief Get the number of policy activation requests. + * @return Requests observed by the test factory. + */ + static int policy_request_count() { + return policy_requests; + } + + /** + * @brief Get the number of endpoint enumeration activation requests. + * @return Requests observed by the test factory. + */ + static int enumerator_request_count() { + return enumerator_requests; + } + + /** + * @brief Simulate failure to initialize endpoint enumeration. + */ + static void fail_enumerator_initialization() { + enumerator_status = E_FAIL; + } + + /** + * @brief Supply endpoint enumeration while simulating an unavailable policy interface. + * @param class_id Requested COM class. + * @param outer Unused aggregation pointer. + * @param context Unused activation context. + * @param interface_id Unused interface identifier. + * @param object Receives the enumerator on success. + * @return Simulated COM activation status. + */ + static HRESULT WINAPI create_capture_interface(REFCLSID class_id, [[maybe_unused]] LPUNKNOWN outer, [[maybe_unused]] DWORD context, [[maybe_unused]] REFIID interface_id, LPVOID *object) { + *object = nullptr; + if (class_id != CLSID_MMDeviceEnumerator) { + ++policy_requests; + return REGDB_E_CLASSNOTREG; + } + ++enumerator_requests; + if (FAILED(enumerator_status)) { + return enumerator_status; + } + static fake_device_enumerator_t enumerator {L"configured-id"}; + enumerator.AddRef(); + *object = static_cast(&enumerator); + return S_OK; + } + +private: + config::audio_t previous; ///< Configuration restored after each test. + inline static int policy_requests = 0; ///< Requests for the unavailable policy interface. + inline static int enumerator_requests = 0; ///< Requests for endpoint enumeration. + inline static HRESULT enumerator_status = S_OK; ///< Simulated enumeration initialization result. +}; + +TEST_F(ExternalAudioTest, InitializesWithoutPolicyInterface) { + EXPECT_EQ(platf::audio::tests::initialize_audio_control(&create_capture_interface), 0); + EXPECT_EQ(policy_request_count(), 0); + EXPECT_EQ(enumerator_request_count(), 1); +} + +TEST_F(ExternalAudioTest, InitializationRequiresEndpointEnumerator) { + fail_enumerator_initialization(); + EXPECT_NE(platf::audio::tests::initialize_audio_control(&create_capture_interface), 0); + EXPECT_EQ(policy_request_count(), 0); + EXPECT_EQ(enumerator_request_count(), 1); +} + +TEST_F(ExternalAudioTest, DisabledModeStillRequiresPolicyInterface) { + config::audio.external_audio = false; + EXPECT_NE(platf::audio::tests::initialize_audio_control(&create_capture_interface), 0); + EXPECT_EQ(policy_request_count(), 1); + EXPECT_EQ(enumerator_request_count(), 0); +} + +TEST_F(ExternalAudioTest, RequiresExplicitSink) { + config::audio.sink.clear(); + EXPECT_EXIT(std::exit(platf::audio::tests::configured_sink_info() ? 1 : 0), testing::ExitedWithCode(0), ""); + fake_device_enumerator_t enumerator {L"endpoint-id"}; + EXPECT_FALSE(platf::audio::tests::microphone_available({}, {}, &enumerator)); + EXPECT_EQ(enumerator.get_default_device_calls, 0); + EXPECT_EQ(enumerator.get_device_calls, 0); +} + +TEST_F(ExternalAudioTest, SkipsDefaultAndVirtualDeviceDiscovery) { + EXPECT_EXIT( + { + const auto sinks = platf::audio::tests::configured_sink_info(); + std::exit(sinks && sinks->host == "configured-id" && !sinks->null ? 0 : 1); + }, + testing::ExitedWithCode(0), + "" + ); +} + +TEST_F(ExternalAudioTest, SinkChangesDoNotAccessPolicyOrFormatInterfaces) { + EXPECT_EXIT(std::exit(platf::audio::tests::set_external_sink("virtual-Stereoendpoint-id")), testing::ExitedWithCode(0), ""); + EXPECT_EXIT(std::exit(platf::audio::tests::set_external_sink("other-endpoint")), testing::ExitedWithCode(0), ""); +} + +TEST_F(ExternalAudioTest, ConfiguredSinkWinsOverAssignedSink) { + fake_device_enumerator_t enumerator {L"configured-id"}; + EXPECT_FALSE(platf::audio::tests::microphone_available("virtual-Stereoother-id", "configured-id", &enumerator)); + EXPECT_EQ(enumerator.last_requested_id, L"configured-id"); + EXPECT_EQ(enumerator.get_default_device_calls, 0); +} + +TEST_F(ExternalAudioTest, MissingSinkDoesNotFallBackToDefault) { + fake_device_enumerator_t enumerator {L"endpoint-id"}; + EXPECT_FALSE(platf::audio::tests::microphone_available({}, "missing-id", &enumerator)); + EXPECT_EQ(enumerator.get_default_device_calls, 0); +} + +TEST_F(ExternalAudioTest, DisconnectedSinkDoesNotFallBackToDefault) { + fake_device_enumerator_t enumerator {L"configured-id"}; + enumerator.device.state = DEVICE_STATE_UNPLUGGED; + EXPECT_FALSE(platf::audio::tests::microphone_available({}, "configured-id", &enumerator)); + EXPECT_EQ(enumerator.get_default_device_calls, 0); +} + +TEST_F(ExternalAudioTest, DisabledModePreservesAssignedSinkPriority) { + config::audio.external_audio = false; + fake_device_enumerator_t enumerator {L"assigned-id"}; + EXPECT_FALSE(platf::audio::tests::microphone_available("assigned-id", "configured-id", &enumerator)); + EXPECT_EQ(enumerator.last_requested_id, L"assigned-id"); + EXPECT_EQ(enumerator.get_default_device_calls, 0); +} + +TEST_F(ExternalAudioTest, DisabledModePreservesDefaultFallback) { + config::audio.external_audio = false; + 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); + EXPECT_EQ(enumerator.get_device_calls, 0); +} + +TEST_F(ExternalAudioTest, ReactivatedSinkCanBeResolvedAgain) { + fake_device_enumerator_t enumerator {L"configured-id"}; + enumerator.device.state = DEVICE_STATE_UNPLUGGED; + EXPECT_FALSE(platf::audio::tests::sink_device_available("configured-id", &enumerator)); + enumerator.device.state = DEVICE_STATE_ACTIVE; + EXPECT_TRUE(platf::audio::tests::sink_device_available("configured-id", &enumerator)); + EXPECT_EQ(enumerator.get_default_device_calls, 0); +} + TEST(WindowsAudioTest, DefaultCaptureSelectsDefaultEndpoint) { fake_device_enumerator_t enumerator {L"endpoint-id"}; diff --git a/tests/web/ExternalAudio.test.js b/tests/web/ExternalAudio.test.js new file mode 100644 index 00000000000..b921818dce9 --- /dev/null +++ b/tests/web/ExternalAudio.test.js @@ -0,0 +1,81 @@ +import { mount } from '@vue/test-utils' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' + +import AudioVideo from '../../src_assets/common/assets/web/configs/tabs/AudioVideo.vue' +import configTabs from '../../src_assets/common/assets/web/configs/config_tabs.json' +import configDocumentation from '../../docs/configuration.md?raw' +import configurationShortcuts from '../../docs/configuration.js?raw' + +function mountAudioVideo(platform) { + const config = structuredClone(configTabs.find(tab => tab.id === 'av').options) + const wrapper = mount(AudioVideo, { + props: { platform, config }, + global: { + mocks: { $t: key => key }, + provide: { + platform: ref(platform), + i18n: { t: key => key }, + }, + stubs: { + AdapterNameSelector: true, + DisplayOutputSelector: true, + DisplayDeviceOptions: true, + DisplayModesSettings: true, + }, + }, + }) + return { wrapper, config } +} + +afterEach(() => { + vi.restoreAllMocks() + document.body.innerHTML = '' +}) + +describe('externally managed audio configuration', () => { + it('renders after the existing Windows audio controls and preserves its saved value', async () => { + const { wrapper, config } = mountAudioVideo('windows') + expect(wrapper.findAll('input').map(input => input.attributes('id'))).toEqual([ + 'audio_sink', + 'virtual_sink', + 'install_steam_audio_drivers', + 'stream_audio', + 'external_audio', + ]) + expect(config.external_audio).toBe('disabled') + expect(wrapper.get('#external_audio').element.checked).toBe(false) + await wrapper.get('#external_audio').setValue(true) + expect(config.external_audio).toBe('enabled') + await wrapper.get('#external_audio').setValue(false) + expect(config.external_audio).toBe('disabled') + wrapper.unmount() + }) + + it.each(['linux', 'freebsd', 'macos'])('keeps the Windows-only option hidden on %s', platform => { + const { wrapper } = mountAudioVideo(platform) + expect(wrapper.find('#external_audio').exists()).toBe(false) + expect(wrapper.find('#stream_audio').exists()).toBe(true) + wrapper.unmount() + }) + + it('provides the standard documentation table and a working configuration shortcut', () => { + const section = configDocumentation.split('### external_audio\n')[1].split('\n### ')[0] + // Doxygen emits configuration headings as h2 and gives tables this class. + document.body.innerHTML = '' + + '

external_audio

' + section.replace('
', '
') + const rows = [...document.querySelectorAll('tr')] + expect(rows.map(row => row.cells[0].textContent.trim())).toEqual(['Description', 'Default', 'Example']) + expect(rows[0].textContent).toContain('This option is only supported on Windows.') + expect(rows[1].textContent).toContain('disabled') + expect(rows[2].textContent).toContain('external_audio = enabled') + + new Function('document', configurationShortcuts)(document) + document.dispatchEvent(new Event('DOMContentLoaded')) + const button = document.querySelector('.open-button') + expect(button.textContent).toBe('Open configuration') + const open = vi.spyOn(window, 'open').mockImplementation(() => null) + button.click() + expect(open).toHaveBeenCalledWith('https://localhost:47990/config/#external_audio', '_blank', 'noopener') + }) +})