Skip to content

Commit 23ecf7d

Browse files
committed
feat(windows): add externally managed audio capture
1 parent c53f97e commit 23ecf7d

8 files changed

Lines changed: 278 additions & 18 deletions

File tree

docs/configuration.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -829,6 +829,26 @@ editing the `conf` file in a text editor. Use the examples as reference.
829829
</tr>
830830
</table>
831831

832+
### external_audio
833+
834+
Windows only. Enable capture-only operation when an external audio router manages
835+
application playback. An explicit [audio_sink](#audio_sink) is required; use an
836+
endpoint ID when names are ambiguous. Sunshine captures that endpoint directly
837+
without changing or restoring Windows default devices or endpoint formats.
838+
839+
In this mode, [virtual_sink](#virtual_sink), automatic Steam audio driver installation,
840+
and Moonlight's host-playback toggle do not affect routing. Configure local playback
841+
and the audio sent to the selected endpoint in your external mixer. If the endpoint
842+
is unavailable, audio capture fails rather than falling back to another endpoint.
843+
Video streaming can continue without audio.
844+
845+
Disabled by default. Other platforms retain their existing behavior.
846+
847+
@code{}
848+
external_audio = enabled
849+
audio_sink = My Streaming Mix
850+
@endcode
851+
832852
### stream_audio
833853

834854
<table>

src/config.cpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -802,6 +802,7 @@ namespace config {
802802
{}, // virtual_sink
803803
true, // stream audio
804804
true, // install_steam_drivers
805+
false, // external_audio
805806
};
806807

807808
/**
@@ -1705,6 +1706,7 @@ namespace config {
17051706
string_f(vars, "virtual_sink", audio.virtual_sink);
17061707
bool_f(vars, "stream_audio", audio.stream);
17071708
bool_f(vars, "install_steam_audio_drivers", audio.install_steam_drivers);
1709+
bool_f(vars, "external_audio", audio.external_audio);
17081710

17091711
string_restricted_f(vars, "origin_web_ui_allowed", nvhttp.origin_web_ui_allowed, {"pc"sv, "lan"sv, "wan"sv});
17101712

src/config.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ namespace config {
214214
std::string virtual_sink; ///< Virtual audio sink for audio routing
215215
bool stream; ///< Enable audio streaming to clients
216216
bool install_steam_drivers; ///< Install Steam audio drivers for enhanced compatibility
217+
bool external_audio; ///< Windows capture-only mode; an external router owns endpoint defaults and formats.
217218
};
218219

219220
/**

src/platform/windows/audio.cpp

Lines changed: 74 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
// standard includes
88
#include <format>
9+
#include <functional>
10+
#include <type_traits>
911
#include <utility>
1012

1113
// platform includes
@@ -871,6 +873,15 @@ namespace platf::audio {
871873
std::optional<sink_t> sink_info() override {
872874
sink_t sink;
873875

876+
if (config::audio.external_audio) {
877+
if (config::audio.sink.empty()) {
878+
BOOST_LOG(error) << "external_audio requires an explicit audio_sink";
879+
return std::nullopt;
880+
}
881+
sink.host = config::audio.sink;
882+
return sink;
883+
}
884+
874885
// Fill host sink name with the device_id of the current default audio device.
875886
{
876887
auto device = default_device(device_enum);
@@ -987,9 +998,13 @@ namespace platf::audio {
987998
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 {
988999
auto mic = std::make_unique<mic_wasapi_t>();
9891000

990-
// Prefer the sink that was assigned to this capture session since it accounts
991-
// for the priority between virtual and configured sinks.
992-
const auto &requested_sink = assigned_sink.empty() ? config::audio.sink : assigned_sink;
1001+
// External routing pins capture to the configured sink; otherwise prefer the session's assigned sink.
1002+
const auto &requested_sink = config::audio.external_audio || assigned_sink.empty() ? config::audio.sink : assigned_sink;
1003+
1004+
if (config::audio.external_audio && requested_sink.empty()) {
1005+
BOOST_LOG(error) << "external_audio requires an explicit audio_sink";
1006+
return nullptr;
1007+
}
9931008

9941009
// Capture the requested sink directly instead of relying on it being the default
9951010
// render device, so that capture keeps working when the default device differs
@@ -1011,7 +1026,7 @@ namespace platf::audio {
10111026

10121027
// If this is a virtual sink, set a callback that will change the sink back if it's changed
10131028
auto virtual_sink_info = extract_virtual_sink_info(assigned_sink);
1014-
if (virtual_sink_info) {
1029+
if (virtual_sink_info && !config::audio.external_audio) {
10151030
mic->default_endpt_changed_cb = [this] {
10161031
BOOST_LOG(info) << "Resetting sink to ["sv << assigned_sink << "] after default changed";
10171032
set_sink(assigned_sink);
@@ -1098,6 +1113,10 @@ namespace platf::audio {
10981113
* @return Status from updating sink.
10991114
*/
11001115
int set_sink(const std::string &sink) override {
1116+
if (config::audio.external_audio) {
1117+
return 0;
1118+
}
1119+
11011120
auto device_id = set_format(sink);
11021121
if (!device_id) {
11031122
return -1;
@@ -1252,6 +1271,10 @@ namespace platf::audio {
12521271
* @brief Resets the default audio device from Steam Streaming Speakers.
12531272
*/
12541273
void reset_default_device() {
1274+
if (config::audio.external_audio) {
1275+
return;
1276+
}
1277+
12551278
auto matched_steam = find_device_id(match_steam_speakers());
12561279
if (!matched_steam) {
12571280
return;
@@ -1380,26 +1403,29 @@ namespace platf::audio {
13801403
}
13811404

13821405
/**
1383-
* @brief Initialize Windows audio policy interfaces.
1406+
* @brief Initialize endpoint enumeration and policy control when routing is managed by Sunshine.
13841407
*
1408+
* @param create_instance COM factory used to initialize the required interfaces.
13851409
* @return 0 on success; nonzero or negative platform status on failure.
13861410
*/
1387-
int init() {
1388-
auto status = CoCreateInstance(
1389-
CLSID_CPolicyConfigClient,
1390-
nullptr,
1391-
CLSCTX_ALL,
1392-
IID_IPolicyConfig,
1393-
(void **) &policy
1394-
);
1411+
int init(const std::function<std::remove_pointer_t<decltype(&CoCreateInstance)>> &create_instance = CoCreateInstance) {
1412+
if (!config::audio.external_audio) {
1413+
auto status = create_instance(
1414+
CLSID_CPolicyConfigClient,
1415+
nullptr,
1416+
CLSCTX_ALL,
1417+
IID_IPolicyConfig,
1418+
(void **) &policy
1419+
);
13951420

1396-
if (FAILED(status)) {
1397-
BOOST_LOG(error) << "Couldn't create audio policy config: [0x"sv << util::hex(status).to_string_view() << ']';
1421+
if (FAILED(status)) {
1422+
BOOST_LOG(error) << "Couldn't create audio policy config: [0x"sv << util::hex(status).to_string_view() << ']';
13981423

1399-
return -1;
1424+
return -1;
1425+
}
14001426
}
14011427

1402-
status = CoCreateInstance(
1428+
auto status = create_instance(
14031429
CLSID_MMDeviceEnumerator,
14041430
nullptr,
14051431
CLSCTX_ALL,
@@ -1428,6 +1454,36 @@ namespace platf::audio {
14281454

14291455
#ifdef SUNSHINE_TESTS
14301456
namespace tests {
1457+
/**
1458+
* @brief Exercise controller initialization with a supplied COM factory.
1459+
* @param create_instance Factory providing or rejecting the requested interfaces.
1460+
* @return Status from controller initialization.
1461+
*/
1462+
int initialize_audio_control(const std::function<std::remove_pointer_t<decltype(&CoCreateInstance)>> &create_instance) {
1463+
audio_control_t control;
1464+
return control.init(create_instance);
1465+
}
1466+
1467+
/**
1468+
* @brief Exercise sink discovery without initializing Windows policy interfaces.
1469+
* @return Sinks reported by the production controller for the current configuration.
1470+
*/
1471+
std::optional<sink_t> configured_sink_info() {
1472+
audio_control_t control;
1473+
return control.sink_info();
1474+
}
1475+
1476+
/**
1477+
* @brief Exercise the external-routing sink-change guard without policy interfaces.
1478+
* @param sink Sink a caller attempts to select.
1479+
* @return Status from the production sink setter.
1480+
*/
1481+
int set_external_sink(const std::string &sink) {
1482+
audio_control_t control;
1483+
control.reset_default_device();
1484+
return control.set_sink(sink);
1485+
}
1486+
14311487
/**
14321488
* @brief Resolve a sink through the production Windows endpoint lookup.
14331489
*
@@ -1542,7 +1598,7 @@ namespace platf {
15421598

15431599
// Install Steam Streaming Speakers if needed. We do this during audio_control() to ensure
15441600
// the sink information returned includes the new Steam Streaming Speakers device.
1545-
if (config::audio.install_steam_drivers && !control->find_device_id(control->match_steam_speakers())) {
1601+
if (!config::audio.external_audio && config::audio.install_steam_drivers && !control->find_device_id(control->match_steam_speakers())) {
15461602
// This is best effort. Don't fail if it doesn't work.
15471603
control->install_steam_audio_drivers();
15481604
}

src_assets/common/assets/web/config.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ <h1>{{ $t('config.configuration') }}</h1>
246246
options: {
247247
"audio_sink": "",
248248
"virtual_sink": "",
249+
"external_audio": "disabled",
249250
"stream_audio": "enabled",
250251
"install_steam_audio_drivers": "enabled",
251252
"adapter_name": "",

src_assets/common/assets/web/configs/tabs/AudioVideo.vue

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,13 @@ const config = ref(props.config)
4949

5050
<PlatformLayout :platform="platform">
5151
<template #windows>
52+
<Checkbox class="mb-3"
53+
id="external_audio"
54+
locale-prefix="config"
55+
v-model="config.external_audio"
56+
default="false"
57+
></Checkbox>
58+
5259
<!-- Virtual Sink -->
5360
<div class="mb-3">
5461
<label for="virtual_sink" class="form-label">{{ $t('config.virtual_sink') }}</label>

src_assets/common/assets/web/public/assets/locale/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,8 @@
233233
"encoder_desc": "Force a specific encoder, otherwise Sunshine will select the best available option. Note: If you specify a hardware encoder on Windows, it must match the GPU where the display is connected.",
234234
"encoder_software": "Software",
235235
"encoders": "Encoders",
236+
"external_audio": "Externally Managed Audio",
237+
"external_audio_desc": "Capture only the configured Audio Sink without changing Windows defaults or device formats. Requires an explicit Audio Sink. Virtual Sink selection, automatic Steam audio driver installation, and Moonlight's host-playback toggle are ignored; manage playback and application routing in your audio mixer.",
236238
"external_ip": "External IP",
237239
"external_ip_desc": "If no external IP address is given, Sunshine will automatically detect external IP",
238240
"fec_percentage": "FEC Percentage",

0 commit comments

Comments
 (0)