Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -938,6 +938,40 @@ consent prompt for directory ACLs, so the Web UI shows setup steps for correctin
</tr>
</table>

### external_audio

<table>
<tr>
<td>Description</td>
<td colspan="2">
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.
<br>
<br>
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.}
</td>
</tr>
<tr>
<td>Default</td>
<td colspan="2">@code{}
disabled
@endcode</td>
</tr>
<tr>
<td>Example</td>
<td colspan="2">@code{}
external_audio = enabled
audio_sink = My Streaming Mix
@endcode</td>
</tr>
</table>

### adapter_name

<table>
Expand Down
2 changes: 2 additions & 0 deletions src/config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -808,6 +808,7 @@ namespace config {
{}, // virtual_sink
true, // stream audio
true, // install_steam_drivers
false, // external_audio
};

/**
Expand Down Expand Up @@ -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});

Expand Down
1 change: 1 addition & 0 deletions src/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
};

/**
Expand Down
94 changes: 76 additions & 18 deletions src/platform/windows/audio.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

// standard includes
#include <format>
#include <functional>
#include <type_traits>
#include <utility>

// platform includes
Expand Down Expand Up @@ -871,6 +873,15 @@ namespace platf::audio {
std::optional<sink_t> 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);
Expand Down Expand Up @@ -987,9 +998,13 @@ namespace platf::audio {
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;
// 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
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<typename CreateInstance = decltype(&CoCreateInstance)>
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,
Expand Down Expand Up @@ -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<std::remove_pointer_t<decltype(&CoCreateInstance)>> &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<sink_t> 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.
*
Expand Down Expand Up @@ -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())) {
Comment thread
AlexAllocated marked this conversation as resolved.
// This is best effort. Don't fail if it doesn't work.
control->install_steam_audio_drivers();
}
Expand Down
1 change: 1 addition & 0 deletions src_assets/common/assets/web/configs/config_tabs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions src_assets/common/assets/web/configs/tabs/AudioVideo.vue
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,18 @@ const config = ref(props.config)
default="true"
></Checkbox>

<PlatformLayout :platform="platform">
<template #windows>
<!-- Externally Managed Audio -->
<Checkbox class="mb-3"
id="external_audio"
locale-prefix="config"
v-model="config.external_audio"
default="false"
></Checkbox>
</template>
</PlatformLayout>

<AdapterNameSelector
:platform="platform"
:config="config"
Expand Down
2 changes: 2 additions & 0 deletions src_assets/common/assets/web/public/assets/locale/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,8 @@
"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.",
"encoder_software": "Software",
"encoders": "Encoders",
"external_audio": "Externally Managed Audio",
"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.",
"external_ip": "External IP",
"external_ip_desc": "If no external IP address is given, Sunshine will automatically detect external IP",
"fec_percentage": "FEC Percentage",
Expand Down
Loading
Loading