diff --git a/AGENTS.md b/AGENTS.md
index 6fe9a6b..d1810d5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -13,21 +13,23 @@ src/SystemMedia/
PluginIntegration.cs instances (current + one per known app, per-widget cycling), variables, events,
issues, the optional settings page
SettingsConfigFlow.cs the settings page (exact YouTube info)
- ShowNextPlayingAppAction the plugin's own action
+ ShowNextPlayingAppAction switches "Any app" to the next playing app
+ SetDefaultAudioDevice... switches a default sound device (also done by writing a device variable)
Core/ platform-neutral: the player, snapshot, state mapping, leftover filter and
thumbnail history, "any app" picker, instance ids, remembered apps, settings,
YouTube correction
Events/ event definitions, change detection, the once-a-second watcher
- Variables/ deck variables for the current session
+ Variables/ deck variables for the current session and the default sound devices
Platform/
IMediaPlatform.cs the interface every OS backend implements
+ IAudioDevices.cs the default sound devices, read and switched per OS
CompositeMediaPlatform the OS backend plus every IMediaSource, routed by app id
- Windows/ GSMTC sessions, volume mixer (Audio/), app names (Shell/), icons, VLC add-on,
+ Windows/ GSMTC sessions, volume mixer and sound devices (Audio/), app names (Shell/), icons, VLC add-on,
browser tabs (Browser/)
Linux/ MPRIS over the D-Bus session bus (Tmds.DBus.Protocol), app icons from .desktop
- files and the hicolor theme
+ files and the hicolor theme, sound devices through pactl
MacOS/ Now Playing through the user's Homebrew media-control, app names/icons via
- mdfind/sips
+ mdfind/sips, sound devices through Core Audio
Players/ players the system does not see: the Player base class, MpvPlayer, SmPlayer,
the watcher that polls each, local sockets and the Qt single-app message
docs/ adding-a-player.md, the contributor guide for a new Player
@@ -49,6 +51,9 @@ Look there instead of guessing.
`thumbnail-history.json`
- the "Any app" widget option names (`cycle`, `cycle-seconds`) and settings field names
(`exact_youtube_info`), stored in users' widgets and config entry
+- action ids, parameter names and choice values (`set-default-audio-device` with `role` and `device`),
+ and the device ids it stores: the Windows endpoint id, the macOS device UID, the PulseAudio sink or
+ source name
**Instances.** The host only resolves instance ids `GetInstances()` currently lists and uses the first as
the default. `current` stays first, and an app once seen stays listed (and remembered on disk). Names carry
@@ -69,6 +74,11 @@ single-configuration integration to `default` and keeps only the first.
- Linux has no system-wide current player; the one that started playing last stands in. MPRIS volume is
the player's own, so a player without a `Volume` property gets no slider. An app id is the bus name
after `org.mpris.MediaPlayer2.` without a `.instance...` suffix, so every instance is one app.
+- Sound devices are an `IAudioDevices` behind `IMediaPlatform.AudioDevices`: Core Audio on Windows and
+ macOS, `pactl --format=json` on Linux (it ships with PulseAudio and `pipewire-pulse`, run with
+ `LC_NUMERIC=C` because some locales break its JSON). Only Windows has communication defaults; elsewhere
+ those roles are the plain defaults. Windows switches through the undocumented `IPolicyConfig`, as every
+ device switcher does; keep it confined to `WindowsAudioDevices`.
- No third-party program is bundled or run unless the user installed it (Store guideline 2); managed NuGet
libraries such as Tmds.DBus.Protocol are fine. macOS relies on the user installing `media-control`
from Homebrew; its `stream --micros` output is what `NowPlayingState` parses. Its version is not pinned,
diff --git a/README.md b/README.md
index fe50f59..7df8dee 100644
--- a/README.md
+++ b/README.md
@@ -73,6 +73,27 @@ For whatever is playing right now (the same as *Any app*):
| `system_media_volume` | The app's volume in percent; can be set |
| `system_media_shuffle_enabled`, `system_media_repeat_mode` | `repeat_mode` is `off`, `track` or `context` |
+### Sound devices
+
+Whatever is playing, these name the system's default sound devices:
+
+| Variable | Value |
+| ------------------------------------------ | ------------------------------------------------------ |
+| `system_media_output_device` | The default output, e.g. `Speakers (Realtek(R) Audio)` |
+| `system_media_input_device` | The default input, e.g. `Microphone (USB Audio)` |
+| `system_media_communication_output_device` | The output Windows uses for calls |
+| `system_media_communication_input_device` | The input Windows uses for calls |
+
+Setting one of them to a device's name makes that device the default. The **Set the default audio device**
+action does the same with a device picked from a list.
+
+Only Windows keeps separate devices for calls. On macOS and Linux the communication variables show the
+plain defaults, and setting one changes the plain default.
+
+On Linux the devices are read and switched with `pactl`, which comes with PulseAudio and with PipeWire's
+PulseAudio support (`pipewire-pulse`). Without it the variables stay empty. On Windows, switching uses the
+same undocumented interface as the Sound settings, because Windows has no public one.
+
### Events
Each event can be narrowed to one app with its **App** field and carries `app` and `appName`:
@@ -136,11 +157,13 @@ Everything runs on your computer. The plugin sends nothing about you or your med
- **SMPlayer and mpv** are read and controlled over their local IPC pipe or socket. A cover is a
screenshot mpv writes to a temporary file, which is deleted right after it is read.
- **macOS:** Now Playing is read by running your installed `media-control`; app names and icons come
- from `mdfind`, `plutil` and `sips`. Nothing third-party ships with the plugin.
+ from `mdfind`, `plutil` and `sips`, sound devices from Core Audio. Nothing third-party ships with the
+ plugin.
- **Linux:** players are read over the D-Bus session bus with the bundled
[Tmds.DBus.Protocol](https://github.com/tmds/Tmds.DBus) library (MIT). A player that reports its cover
as a web address (Spotify does) has the cover downloaded from there, as the desktop's own media controls
- do. App icons come from the apps' `.desktop` files and the icon theme.
+ do. App icons come from the apps' `.desktop` files and the icon theme. Sound devices are read and
+ switched by running `pactl`.
## Development
diff --git a/src/SystemMedia/Localization/Strings.resx b/src/SystemMedia/Localization/Strings.resx
index 85aae60..eedeeb6 100644
--- a/src/SystemMedia/Localization/Strings.resx
+++ b/src/SystemMedia/Localization/Strings.resx
@@ -97,6 +97,18 @@
Repeat mode
+
+ Default output device
+
+
+ Default input device
+
+
+ Default communication output device
+
+
+ Default communication input device
+
Track changed
@@ -199,6 +211,45 @@
No other app is playing right now.
+
+ Set the default audio device
+
+
+ Makes a speaker, headset or microphone the system's default.
+
+
+ Default for
+
+
+ Only Windows has separate defaults for calls; elsewhere they are the plain defaults.
+
+
+ Output
+
+
+ Input
+
+
+ Communication output
+
+
+ Communication input
+
+
+ Device
+
+
+ Audio devices cannot be changed on this system.
+
+
+ Choose a device first.
+
+
+ That device is not connected.
+
+
+ The default device could not be changed.
+
System Media settings
diff --git a/src/SystemMedia/Platform/CompositeMediaPlatform.cs b/src/SystemMedia/Platform/CompositeMediaPlatform.cs
index 537496a..34f0d8a 100644
--- a/src/SystemMedia/Platform/CompositeMediaPlatform.cs
+++ b/src/SystemMedia/Platform/CompositeMediaPlatform.cs
@@ -95,6 +95,8 @@ public Task SetVolumePercentAsync(AppIdentity app, int volumePercent, Canc
: _system.SetVolumePercentAsync(app, volumePercent, cancellationToken);
}
+ public IAudioDevices? AudioDevices => _system.AudioDevices;
+
public IReadOnlyList GetIssues() =>
[.. _system.GetIssues(), .. _sources.SelectMany(source => source.GetIssues())];
diff --git a/src/SystemMedia/Platform/IAudioDevices.cs b/src/SystemMedia/Platform/IAudioDevices.cs
new file mode 100644
index 0000000..57872ce
--- /dev/null
+++ b/src/SystemMedia/Platform/IAudioDevices.cs
@@ -0,0 +1,41 @@
+namespace SystemMedia.Platform;
+
+// Only Windows keeps separate defaults for calls; elsewhere the communication roles are the plain defaults.
+internal enum AudioDeviceRole
+{
+ Output,
+ Input,
+ CommunicationOutput,
+ CommunicationInput,
+}
+
+internal sealed record AudioDevice(string Id, string Name);
+
+// Reads return no device when the system has none; only a platform-level failure throws.
+internal interface IAudioDevices
+{
+ Task> GetDevicesAsync(bool input, CancellationToken cancellationToken);
+
+ Task GetDefaultAsync(AudioDeviceRole role, CancellationToken cancellationToken);
+
+ // false when no device has that id.
+ Task SetDefaultAsync(AudioDeviceRole role, string deviceId, CancellationToken cancellationToken);
+}
+
+internal static class AudioDeviceRoles
+{
+ internal static bool IsInput(this AudioDeviceRole role) =>
+ role is AudioDeviceRole.Input or AudioDeviceRole.CommunicationInput;
+
+ internal static bool IsCommunication(this AudioDeviceRole role) =>
+ role is AudioDeviceRole.CommunicationOutput or AudioDeviceRole.CommunicationInput;
+
+ // A stored id first, then a name, so a variable can be set to what it shows.
+ internal static async Task FindAsync(this IAudioDevices devices, AudioDeviceRole role, string idOrName,
+ CancellationToken cancellationToken)
+ {
+ var candidates = await devices.GetDevicesAsync(role.IsInput(), cancellationToken);
+ return candidates.FirstOrDefault(device => string.Equals(device.Id, idOrName, StringComparison.Ordinal))
+ ?? candidates.FirstOrDefault(device => string.Equals(device.Name, idOrName.Trim(), StringComparison.OrdinalIgnoreCase));
+ }
+}
diff --git a/src/SystemMedia/Platform/IMediaPlatform.cs b/src/SystemMedia/Platform/IMediaPlatform.cs
index c81b9b5..198bde7 100644
--- a/src/SystemMedia/Platform/IMediaPlatform.cs
+++ b/src/SystemMedia/Platform/IMediaPlatform.cs
@@ -56,6 +56,9 @@ internal interface IMediaPlatform : IDisposable
Task SetVolumePercentAsync(AppIdentity app, int volumePercent, CancellationToken cancellationToken);
+ // The system's default sound devices; null where the platform cannot reach them.
+ IAudioDevices? AudioDevices => null;
+
IReadOnlyList GetIssues();
Task ResolveIssueAsync(string issueId, CancellationToken cancellationToken);
diff --git a/src/SystemMedia/Platform/Linux/LinuxMediaPlatform.cs b/src/SystemMedia/Platform/Linux/LinuxMediaPlatform.cs
index 3299322..ff431e6 100644
--- a/src/SystemMedia/Platform/Linux/LinuxMediaPlatform.cs
+++ b/src/SystemMedia/Platform/Linux/LinuxMediaPlatform.cs
@@ -138,6 +138,8 @@ public async Task SetVolumePercentAsync(AppIdentity app, int volumePercent
Find(app.AppId) is { } player && player.CanControl && player.Has("Volume") &&
await TryAsync(bus => bus.SetAsync(player.BusName, "Volume", Math.Clamp(volumePercent, 0, 100) / 100d), cancellationToken);
+ public IAudioDevices AudioDevices { get; } = new PulseAudioDevices();
+
// Not "not started": the host asks for issues while the first start is still running.
public IReadOnlyList GetIssues() =>
!_startFailed ? [] :
diff --git a/src/SystemMedia/Platform/Linux/PulseAudioDevices.cs b/src/SystemMedia/Platform/Linux/PulseAudioDevices.cs
new file mode 100644
index 0000000..861aad0
--- /dev/null
+++ b/src/SystemMedia/Platform/Linux/PulseAudioDevices.cs
@@ -0,0 +1,182 @@
+using System.Diagnostics;
+using System.Text.Json;
+
+namespace SystemMedia.Platform.Linux;
+
+// PulseAudio and PipeWire (through pipewire-pulse) both answer pactl, which ships with either. Its JSON output
+// (PulseAudio 16 and later) is read rather than the text, which is translated. A device is a sink or source,
+// identified by its name.
+internal sealed class PulseAudioDevices : IAudioDevices
+{
+ private static readonly TimeSpan _commandTimeout = TimeSpan.FromSeconds(3);
+
+ // Every variable asks on its own refresh; one reading serves them all.
+ private static readonly TimeSpan _cacheDuration = TimeSpan.FromSeconds(1);
+
+ private readonly Lock _gate = new();
+ private Task? _snapshot;
+ private long _snapshotTakenAt;
+
+ public async Task> GetDevicesAsync(bool input, CancellationToken cancellationToken) =>
+ await GetSnapshotAsync() is { } snapshot ? (input ? snapshot.Sources : snapshot.Sinks) : [];
+
+ // Linux has no separate defaults for calls.
+ public async Task GetDefaultAsync(AudioDeviceRole role, CancellationToken cancellationToken)
+ {
+ if (await GetSnapshotAsync() is not { } snapshot)
+ {
+ return null;
+ }
+
+ var (devices, name) = role.IsInput() ? (snapshot.Sources, snapshot.DefaultSource) : (snapshot.Sinks, snapshot.DefaultSink);
+ return devices.FirstOrDefault(device => string.Equals(device.Id, name, StringComparison.Ordinal));
+ }
+
+ public async Task SetDefaultAsync(AudioDeviceRole role, string deviceId, CancellationToken cancellationToken)
+ {
+ if (!(await GetDevicesAsync(role.IsInput(), cancellationToken)).Any(device => string.Equals(device.Id, deviceId, StringComparison.Ordinal)))
+ {
+ return false;
+ }
+
+ var command = role.IsInput() ? "set-default-source" : "set-default-sink";
+ _ = await RunAsync(cancellationToken, command, deviceId)
+ ?? throw new InvalidOperationException($"pactl {command} failed or pactl is not installed.");
+ lock (_gate)
+ {
+ _snapshot = null;
+ }
+
+ return true;
+ }
+
+ internal static IReadOnlyList ParseDevices(string json)
+ {
+ using var document = JsonDocument.Parse(json);
+ var devices = new List();
+ foreach (var element in document.RootElement.EnumerateArray())
+ {
+ // A sink's monitor is a source too, but nobody records from it by choice.
+ if (Text(element, "name") is not { } name || Text(element, "description") is not { } description ||
+ Text(element, "monitor_of_sink") is { } monitored && monitored != "n/a" ||
+ name.EndsWith(".monitor", StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ devices.Add(new AudioDevice(name, description));
+ }
+
+ return devices;
+ }
+
+ internal static (string? Sink, string? Source) ParseDefaults(string json)
+ {
+ using var document = JsonDocument.Parse(json);
+ return (Text(document.RootElement, "default_sink_name"), Text(document.RootElement, "default_source_name"));
+ }
+
+ private Task GetSnapshotAsync()
+ {
+ lock (_gate)
+ {
+ if (_snapshot is null || Stopwatch.GetElapsedTime(_snapshotTakenAt) > _cacheDuration)
+ {
+ _snapshot = ReadSnapshotAsync();
+ _snapshotTakenAt = Stopwatch.GetTimestamp();
+ }
+
+ return _snapshot;
+ }
+ }
+
+ // Not cancelled by a caller: the reading is shared.
+ private static async Task ReadSnapshotAsync()
+ {
+ try
+ {
+ var info = RunAsync(CancellationToken.None, "info");
+ var sinks = RunAsync(CancellationToken.None, "list", "sinks");
+ var sources = RunAsync(CancellationToken.None, "list", "sources");
+ if (await info is not { } infoJson || await sinks is not { } sinksJson || await sources is not { } sourcesJson)
+ {
+ return null;
+ }
+
+ var (defaultSink, defaultSource) = ParseDefaults(infoJson);
+ return new Snapshot(ParseDevices(sinksJson), ParseDevices(sourcesJson), defaultSink, defaultSource);
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+
+ // null when pactl is missing, too old for JSON, or has no sound server to talk to.
+ private static async Task RunAsync(CancellationToken cancellationToken, params string[] arguments)
+ {
+ if (Locate() is not { } executable)
+ {
+ return null;
+ }
+
+ var start = new ProcessStartInfo(executable)
+ {
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = true,
+ };
+ start.ArgumentList.Add("--format=json");
+ foreach (var argument in arguments)
+ {
+ start.ArgumentList.Add(argument);
+ }
+
+ // Some locales put decimal commas into the JSON numbers.
+ start.Environment.Remove("LC_ALL");
+ start.Environment["LC_NUMERIC"] = "C";
+
+ using var process = Process.Start(start);
+ if (process is null)
+ {
+ return null;
+ }
+
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeout.CancelAfter(_commandTimeout);
+ try
+ {
+ var output = process.StandardOutput.ReadToEndAsync(timeout.Token);
+ _ = process.StandardError.ReadToEndAsync(timeout.Token);
+ await process.WaitForExitAsync(timeout.Token);
+ var text = await output;
+ return process.ExitCode == 0 ? text : null;
+ }
+ catch (OperationCanceledException)
+ {
+ process.Kill(entireProcessTree: true);
+ if (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+
+ return null;
+ }
+ }
+
+ // A process started outside a login shell may have a bare PATH, so the usual folder is checked too.
+ private static string? Locate() =>
+ (Environment.GetEnvironmentVariable("PATH") ?? "")
+ .Split(':', StringSplitOptions.RemoveEmptyEntries)
+ .Append("/usr/bin")
+ .Select(folder => Path.Combine(folder, "pactl"))
+ .FirstOrDefault(File.Exists);
+
+ private static string? Text(JsonElement element, string property) =>
+ element.ValueKind == JsonValueKind.Object && element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String
+ ? value.GetString()
+ : null;
+
+ private sealed record Snapshot(IReadOnlyList Sinks, IReadOnlyList Sources, string? DefaultSink, string? DefaultSource);
+}
diff --git a/src/SystemMedia/Platform/MacOS/CoreAudioDevices.cs b/src/SystemMedia/Platform/MacOS/CoreAudioDevices.cs
new file mode 100644
index 0000000..060050e
--- /dev/null
+++ b/src/SystemMedia/Platform/MacOS/CoreAudioDevices.cs
@@ -0,0 +1,163 @@
+using System.Runtime.InteropServices;
+
+namespace SystemMedia.Platform.MacOS;
+
+// The Core Audio hardware properties (AudioHardware.h). A device is identified by its UID, which survives
+// reconnects and restarts; its AudioObjectID does not.
+internal sealed unsafe partial class CoreAudioDevices : IAudioDevices
+{
+ private const string CoreAudio = "/System/Library/Frameworks/CoreAudio.framework/CoreAudio";
+ private const string CoreFoundation = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation";
+
+ private const uint SystemObject = 1;
+ private const uint ElementMain = 0;
+ private static readonly uint _devices = FourCharCode("dev#");
+ private static readonly uint _defaultOutput = FourCharCode("dOut");
+ private static readonly uint _defaultInput = FourCharCode("dIn ");
+ private static readonly uint _name = FourCharCode("lnam");
+ private static readonly uint _uid = FourCharCode("uid ");
+ private static readonly uint _streams = FourCharCode("stm#");
+ private static readonly uint _scopeGlobal = FourCharCode("glob");
+ private static readonly uint _scopeInput = FourCharCode("inpt");
+ private static readonly uint _scopeOutput = FourCharCode("outp");
+
+ public Task> GetDevicesAsync(bool input, CancellationToken cancellationToken) =>
+ Task.FromResult>([.. GetDevices(input).Select(device => device.Device)]);
+
+ // macOS has no separate defaults for calls.
+ public Task GetDefaultAsync(AudioDeviceRole role, CancellationToken cancellationToken)
+ {
+ var objectId = GetDefaultObjectId(role.IsInput());
+ return Task.FromResult(objectId == 0 ? null : Read(objectId));
+ }
+
+ public Task SetDefaultAsync(AudioDeviceRole role, string deviceId, CancellationToken cancellationToken)
+ {
+ var match = GetDevices(role.IsInput()).FirstOrDefault(device => string.Equals(device.Device.Id, deviceId, StringComparison.Ordinal));
+ if (match.Device is null)
+ {
+ return Task.FromResult(false);
+ }
+
+ var objectId = match.ObjectId;
+ var address = new PropertyAddress(role.IsInput() ? _defaultInput : _defaultOutput, _scopeGlobal);
+ var status = AudioObjectSetPropertyData(SystemObject, &address, 0, null, sizeof(uint), &objectId);
+ return status == 0
+ ? Task.FromResult(true)
+ : throw new InvalidOperationException($"Core Audio refused the default device change (status {status}).");
+ }
+
+ private static List<(uint ObjectId, AudioDevice Device)> GetDevices(bool input)
+ {
+ var address = new PropertyAddress(_devices, _scopeGlobal);
+ uint size;
+ if (AudioObjectGetPropertyDataSize(SystemObject, &address, 0, null, &size) != 0 || size == 0)
+ {
+ return [];
+ }
+
+ var objectIds = new uint[size / sizeof(uint)];
+ fixed (uint* buffer = objectIds)
+ {
+ if (AudioObjectGetPropertyData(SystemObject, &address, 0, null, &size, buffer) != 0)
+ {
+ return [];
+ }
+ }
+
+ var devices = new List<(uint, AudioDevice)>();
+ foreach (var objectId in objectIds.AsSpan(0, (int)(size / sizeof(uint))))
+ {
+ if (HasStreams(objectId, input) && Read(objectId) is { } device)
+ {
+ devices.Add((objectId, device));
+ }
+ }
+
+ return devices;
+ }
+
+ private static uint GetDefaultObjectId(bool input)
+ {
+ var address = new PropertyAddress(input ? _defaultInput : _defaultOutput, _scopeGlobal);
+ uint objectId = 0;
+ var size = (uint)sizeof(uint);
+ return AudioObjectGetPropertyData(SystemObject, &address, 0, null, &size, &objectId) == 0 ? objectId : 0;
+ }
+
+ private static bool HasStreams(uint objectId, bool input)
+ {
+ var address = new PropertyAddress(_streams, input ? _scopeInput : _scopeOutput);
+ uint size;
+ return AudioObjectGetPropertyDataSize(objectId, &address, 0, null, &size) == 0 && size > 0;
+ }
+
+ private static AudioDevice? Read(uint objectId) =>
+ ReadString(objectId, _uid) is { Length: > 0 } uid && ReadString(objectId, _name) is { Length: > 0 } name
+ ? new AudioDevice(uid, name)
+ : null;
+
+ // The caller owns the returned CFString.
+ private static string? ReadString(uint objectId, uint selector)
+ {
+ var address = new PropertyAddress(selector, _scopeGlobal);
+ IntPtr text = 0;
+ var size = (uint)sizeof(IntPtr);
+ if (AudioObjectGetPropertyData(objectId, &address, 0, null, &size, &text) != 0 || text == 0)
+ {
+ return null;
+ }
+
+ try
+ {
+ var length = CFStringGetLength(text);
+ var characters = new char[length];
+ fixed (char* buffer = characters)
+ {
+ CFStringGetCharacters(text, new CFRange(0, length), buffer);
+ }
+
+ return new string(characters);
+ }
+ finally
+ {
+ CFRelease(text);
+ }
+ }
+
+ private static uint FourCharCode(string code) =>
+ ((uint)code[0] << 24) | ((uint)code[1] << 16) | ((uint)code[2] << 8) | code[3];
+
+ [StructLayout(LayoutKind.Sequential)]
+ private readonly struct PropertyAddress(uint selector, uint scope)
+ {
+ public readonly uint Selector = selector;
+ public readonly uint Scope = scope;
+ public readonly uint Element = ElementMain;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private readonly struct CFRange(nint location, nint length)
+ {
+ public readonly nint Location = location;
+ public readonly nint Length = length;
+ }
+
+ [LibraryImport(CoreAudio)]
+ private static partial int AudioObjectGetPropertyDataSize(uint objectId, PropertyAddress* address, uint qualifierSize, void* qualifier, uint* size);
+
+ [LibraryImport(CoreAudio)]
+ private static partial int AudioObjectGetPropertyData(uint objectId, PropertyAddress* address, uint qualifierSize, void* qualifier, uint* size, void* data);
+
+ [LibraryImport(CoreAudio)]
+ private static partial int AudioObjectSetPropertyData(uint objectId, PropertyAddress* address, uint qualifierSize, void* qualifier, uint size, void* data);
+
+ [LibraryImport(CoreFoundation)]
+ private static partial nint CFStringGetLength(IntPtr text);
+
+ [LibraryImport(CoreFoundation)]
+ private static partial void CFStringGetCharacters(IntPtr text, CFRange range, char* buffer);
+
+ [LibraryImport(CoreFoundation)]
+ private static partial void CFRelease(IntPtr value);
+}
diff --git a/src/SystemMedia/Platform/MacOS/MacMediaPlatform.cs b/src/SystemMedia/Platform/MacOS/MacMediaPlatform.cs
index abda9be..bf055b5 100644
--- a/src/SystemMedia/Platform/MacOS/MacMediaPlatform.cs
+++ b/src/SystemMedia/Platform/MacOS/MacMediaPlatform.cs
@@ -123,6 +123,8 @@ public Task SetRepeatModeAsync(string appId, RepeatMode mode, Cancellation
public Task SetVolumePercentAsync(AppIdentity app, int volumePercent, CancellationToken cancellationToken) => Task.FromResult(false);
+ public IAudioDevices AudioDevices { get; } = new CoreAudioDevices();
+
// Only once a start failed or the stream broke: the host asks for issues while the first start still runs.
public IReadOnlyList GetIssues() =>
!_broken ? []
diff --git a/src/SystemMedia/Platform/Windows/Audio/CoreAudioNative.cs b/src/SystemMedia/Platform/Windows/Audio/CoreAudioNative.cs
index 7f05700..883d5a4 100644
--- a/src/SystemMedia/Platform/Windows/Audio/CoreAudioNative.cs
+++ b/src/SystemMedia/Platform/Windows/Audio/CoreAudioNative.cs
@@ -15,6 +15,7 @@ internal class MMDeviceEnumeratorComObject;
internal interface IMMDeviceEnumerator
{
[PreserveSig] int EnumAudioEndpoints(int dataFlow, int stateMask, out IMMDeviceCollection devices);
+ [PreserveSig] int GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice device);
}
[ComImport]
@@ -32,6 +33,60 @@ internal interface IMMDeviceCollection
internal interface IMMDevice
{
[PreserveSig] int Activate(ref Guid interfaceId, int classContext, IntPtr activationParams, [MarshalAs(UnmanagedType.IUnknown)] out object result);
+ [PreserveSig] int OpenPropertyStore(int access, out IPropertyStore properties);
+ [PreserveSig] int GetId([MarshalAs(UnmanagedType.LPWStr)] out string id);
+}
+
+// Undocumented, but the only way to change a default device; the Sound settings and every switcher use it.
+[ComImport]
+[Guid("870AF99C-171D-4F9E-AF0D-E63DF40C2BC9")]
+internal class PolicyConfigComObject;
+
+[ComImport]
+[Guid("F8679F50-850A-41CF-9C72-430F290290C8")]
+[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+internal interface IPolicyConfig
+{
+ [PreserveSig] int GetMixFormat(IntPtr deviceId, IntPtr format);
+ [PreserveSig] int GetDeviceFormat(IntPtr deviceId, int useDefault, IntPtr format);
+ [PreserveSig] int ResetDeviceFormat(IntPtr deviceId);
+ [PreserveSig] int SetDeviceFormat(IntPtr deviceId, IntPtr endpointFormat, IntPtr mixFormat);
+ [PreserveSig] int GetProcessingPeriod(IntPtr deviceId, int useDefault, IntPtr defaultPeriod, IntPtr minimumPeriod);
+ [PreserveSig] int SetProcessingPeriod(IntPtr deviceId, IntPtr period);
+ [PreserveSig] int GetShareMode(IntPtr deviceId, IntPtr mode);
+ [PreserveSig] int SetShareMode(IntPtr deviceId, IntPtr mode);
+ [PreserveSig] int GetPropertyValue(IntPtr deviceId, IntPtr key, IntPtr value);
+ [PreserveSig] int SetPropertyValue(IntPtr deviceId, IntPtr key, IntPtr value);
+ [PreserveSig] int SetDefaultEndpoint([MarshalAs(UnmanagedType.LPWStr)] string deviceId, int role);
+}
+
+// propsys.h
+[ComImport]
+[Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99")]
+[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+internal interface IPropertyStore
+{
+ [PreserveSig] int GetCount(out int count);
+ [PreserveSig] int GetAt(int index, out PropertyKey key);
+ [PreserveSig] int GetValue(ref PropertyKey key, out PropVariant value);
+}
+
+[StructLayout(LayoutKind.Sequential)]
+internal struct PropertyKey(Guid formatId, int propertyId)
+{
+ public Guid FormatId = formatId;
+ public int PropertyId = propertyId;
+}
+
+[StructLayout(LayoutKind.Sequential)]
+internal struct PropVariant
+{
+ public ushort VariantType;
+ public ushort Reserved1;
+ public ushort Reserved2;
+ public ushort Reserved3;
+ public IntPtr Value;
+ public IntPtr Value2;
}
[ComImport]
@@ -84,6 +139,12 @@ internal interface ISimpleAudioVolume
internal static class CoreAudioNative
{
internal const int DataFlowRender = 0;
+ internal const int DataFlowCapture = 1;
+ internal const int RoleConsole = 0;
+ internal const int RoleMultimedia = 1;
+ internal const int RoleCommunications = 2;
+ internal const int StorageRead = 0;
+ internal const ushort VariantTypeString = 31;
internal const int DeviceStateActive = 0x1;
internal const int ClassContextAll = 0x17;
internal const int SessionStateExpired = 2;
diff --git a/src/SystemMedia/Platform/Windows/Audio/WindowsAudioDevices.cs b/src/SystemMedia/Platform/Windows/Audio/WindowsAudioDevices.cs
new file mode 100644
index 0000000..a7b92c8
--- /dev/null
+++ b/src/SystemMedia/Platform/Windows/Audio/WindowsAudioDevices.cs
@@ -0,0 +1,160 @@
+using System.Runtime.InteropServices;
+
+namespace SystemMedia.Platform.Windows.Audio;
+
+internal sealed partial class WindowsAudioDevices : IAudioDevices
+{
+ // PKEY_Device_FriendlyName, the name the Sound settings show.
+ private static readonly Guid _friendlyNameFormat = new("A45C254E-DF1C-4EFD-8020-67D146A850E0");
+ private const int FriendlyNameId = 14;
+
+ public Task> GetDevicesAsync(bool input, CancellationToken cancellationToken) =>
+ Task.FromResult>(GetDevices(input));
+
+ public Task GetDefaultAsync(AudioDeviceRole role, CancellationToken cancellationToken) =>
+ Task.FromResult(GetDefault(role));
+
+ public Task SetDefaultAsync(AudioDeviceRole role, string deviceId, CancellationToken cancellationToken)
+ {
+ if (!GetDevices(role.IsInput()).Any(device => string.Equals(device.Id, deviceId, StringComparison.Ordinal)))
+ {
+ return Task.FromResult(false);
+ }
+
+ var policy = (IPolicyConfig)new PolicyConfigComObject();
+ try
+ {
+ // The Sound settings' "default device" is both the console and the multimedia role.
+ int[] roles = role.IsCommunication()
+ ? [CoreAudioNative.RoleCommunications]
+ : [CoreAudioNative.RoleConsole, CoreAudioNative.RoleMultimedia];
+ foreach (var windowsRole in roles)
+ {
+ Marshal.ThrowExceptionForHR(policy.SetDefaultEndpoint(deviceId, windowsRole));
+ }
+
+ return Task.FromResult(true);
+ }
+ finally
+ {
+ Marshal.ReleaseComObject(policy);
+ }
+ }
+
+ private static List GetDevices(bool input)
+ {
+ var devices = new List();
+ WithEnumerator(enumerator =>
+ {
+ if (enumerator.EnumAudioEndpoints(DataFlow(input), CoreAudioNative.DeviceStateActive, out var collection) != 0)
+ {
+ return;
+ }
+
+ try
+ {
+ if (collection.GetCount(out var count) != 0)
+ {
+ return;
+ }
+
+ for (var i = 0; i < count; i++)
+ {
+ if (collection.Item(i, out var device) == 0 && Read(device) is { } read)
+ {
+ devices.Add(read);
+ }
+ }
+ }
+ finally
+ {
+ Marshal.ReleaseComObject(collection);
+ }
+ });
+
+ return devices;
+ }
+
+ private static AudioDevice? GetDefault(AudioDeviceRole role)
+ {
+ AudioDevice? result = null;
+ var windowsRole = role.IsCommunication() ? CoreAudioNative.RoleCommunications : CoreAudioNative.RoleConsole;
+ WithEnumerator(enumerator =>
+ {
+ // Fails when no device of that kind is connected.
+ if (enumerator.GetDefaultAudioEndpoint(DataFlow(role.IsInput()), windowsRole, out var device) == 0)
+ {
+ result = Read(device);
+ }
+ });
+
+ return result;
+ }
+
+ private static int DataFlow(bool input) => input ? CoreAudioNative.DataFlowCapture : CoreAudioNative.DataFlowRender;
+
+ private static void WithEnumerator(Action use)
+ {
+ IMMDeviceEnumerator enumerator;
+ try
+ {
+ enumerator = (IMMDeviceEnumerator)new MMDeviceEnumeratorComObject();
+ }
+ catch (COMException)
+ {
+ // No audio service: no devices.
+ return;
+ }
+
+ try
+ {
+ use(enumerator);
+ }
+ finally
+ {
+ Marshal.ReleaseComObject(enumerator);
+ }
+ }
+
+ // Releases the device.
+ private static AudioDevice? Read(IMMDevice device)
+ {
+ IPropertyStore? properties = null;
+ try
+ {
+ if (device.GetId(out var id) != 0 || device.OpenPropertyStore(CoreAudioNative.StorageRead, out properties) != 0)
+ {
+ return null;
+ }
+
+ var key = new PropertyKey(_friendlyNameFormat, FriendlyNameId);
+ if (properties.GetValue(ref key, out var value) != 0)
+ {
+ return null;
+ }
+
+ try
+ {
+ return value.VariantType == CoreAudioNative.VariantTypeString && Marshal.PtrToStringUni(value.Value) is { Length: > 0 } name
+ ? new AudioDevice(id, name)
+ : null;
+ }
+ finally
+ {
+ _ = PropVariantClear(ref value);
+ }
+ }
+ finally
+ {
+ if (properties is not null)
+ {
+ Marshal.ReleaseComObject(properties);
+ }
+
+ Marshal.ReleaseComObject(device);
+ }
+ }
+
+ [LibraryImport("ole32.dll")]
+ private static partial int PropVariantClear(ref PropVariant value);
+}
diff --git a/src/SystemMedia/Platform/Windows/WindowsMediaPlatform.cs b/src/SystemMedia/Platform/Windows/WindowsMediaPlatform.cs
index 7f1984a..38987ff 100644
--- a/src/SystemMedia/Platform/Windows/WindowsMediaPlatform.cs
+++ b/src/SystemMedia/Platform/Windows/WindowsMediaPlatform.cs
@@ -223,6 +223,8 @@ public Task SetRepeatModeAsync(string appId, RepeatMode mode, Cancellation
public Task SetVolumePercentAsync(AppIdentity app, int volumePercent, CancellationToken cancellationToken) =>
Task.FromResult(AppVolumeMixer.SetVolumePercent(app, volumePercent));
+ public IAudioDevices AudioDevices { get; } = new WindowsAudioDevices();
+
public IReadOnlyList GetIssues()
{
var issues = new List();
diff --git a/src/SystemMedia/PluginIntegration.cs b/src/SystemMedia/PluginIntegration.cs
index 51f5a8e..4b9f191 100644
--- a/src/SystemMedia/PluginIntegration.cs
+++ b/src/SystemMedia/PluginIntegration.cs
@@ -106,6 +106,7 @@ public PluginIntegration(IPluginCatalogNotifier catalog, IOptions _platform.AudioDevices, _logger),
];
}
@@ -173,17 +174,43 @@ public void Dispose()
public async ValueTask ReadAsync(string localId, CancellationToken cancellationToken = default)
{
+ if (SystemMediaVariables.DeviceRole(localId) is { } role)
+ {
+ return _platform.AudioDevices is { } devices
+ ? VariableReading.Of((await devices.GetDefaultAsync(role, cancellationToken))?.Name)
+ : VariableReading.Unavailable;
+ }
+
var state = await _currentState.GetAsync(cancellationToken);
return SystemMediaVariables.Read(localId, state);
}
public ValueTask SetValueAsync(string localId, object? value, CancellationToken cancellationToken = default) =>
- localId switch
+ SystemMediaVariables.DeviceRole(localId) is { } role
+ ? SetDefaultAudioDeviceAsync(role, value, cancellationToken)
+ : localId switch
+ {
+ SystemMediaVariables.VolumeId => MusicPlayerVariableWrites.SetVolumeAsync(_current, value, cancellationToken),
+ SystemMediaVariables.PositionId => MusicPlayerVariableWrites.SeekAsync(_current, value, cancellationToken),
+ _ => ValueTask.FromResult(VariableWriteResult.NotWritable()),
+ };
+
+ private async ValueTask SetDefaultAudioDeviceAsync(AudioDeviceRole role, object? value, CancellationToken cancellationToken)
+ {
+ if (value?.ToString() is not { Length: > 0 } device)
{
- SystemMediaVariables.VolumeId => MusicPlayerVariableWrites.SetVolumeAsync(_current, value, cancellationToken),
- SystemMediaVariables.PositionId => MusicPlayerVariableWrites.SeekAsync(_current, value, cancellationToken),
- _ => ValueTask.FromResult(VariableWriteResult.NotWritable()),
+ return VariableWriteResult.InvalidValue();
+ }
+
+ return await SetDefaultAudioDeviceAction.SwitchAsync(_platform.AudioDevices, role, device, _logger, cancellationToken) switch
+ {
+ SetDefaultAudioDeviceAction.Outcome.Switched => VariableWriteResult.Applied(),
+ SetDefaultAudioDeviceAction.Outcome.NotSupported => VariableWriteResult.Unavailable(Strings.Actions.SetDefaultAudioDevice.NotSupported()),
+ // NotFound would claim the variable itself is missing.
+ SetDefaultAudioDeviceAction.Outcome.NotFound => VariableWriteResult.InvalidValue(Strings.Actions.SetDefaultAudioDevice.DeviceNotFound()),
+ _ => VariableWriteResult.Failed(Strings.Actions.SetDefaultAudioDevice.Failed()),
};
+ }
public Task GetEventOptionsAsync(EventOptionsContext context, CancellationToken cancellationToken)
{
diff --git a/src/SystemMedia/SetDefaultAudioDeviceAction.cs b/src/SystemMedia/SetDefaultAudioDeviceAction.cs
new file mode 100644
index 0000000..5943e65
--- /dev/null
+++ b/src/SystemMedia/SetDefaultAudioDeviceAction.cs
@@ -0,0 +1,133 @@
+using MacroDeck.Localization;
+using MacroDeck.Sdk.Actions;
+using Serilog;
+using SystemMedia.Platform;
+
+namespace SystemMedia;
+
+internal sealed class SetDefaultAudioDeviceAction(Func devices, ILogger logger) : IDynamicOptionsActionDefinition
+{
+ internal const string ActionId = "set-default-audio-device";
+
+ // Parameter names and role values are stored in users' buttons.
+ internal const string RoleParameter = "role";
+ internal const string DeviceParameter = "device";
+
+ private static readonly (string Value, AudioDeviceRole Role)[] _roles =
+ [
+ ("output", AudioDeviceRole.Output),
+ ("input", AudioDeviceRole.Input),
+ ("communication-output", AudioDeviceRole.CommunicationOutput),
+ ("communication-input", AudioDeviceRole.CommunicationInput),
+ ];
+
+ internal enum Outcome
+ {
+ Switched,
+ NotSupported,
+ NotFound,
+ Failed,
+ }
+
+ public string Id => ActionId;
+
+ public LocalizedText Name => Strings.Actions.SetDefaultAudioDevice.Name();
+
+ public LocalizedText Description => Strings.Actions.SetDefaultAudioDevice.Description();
+
+ public IReadOnlyList Parameters { get; } =
+ [
+ ActionParameter.Choice(
+ RoleParameter,
+ [
+ new ActionParameterOption { Value = "output", Label = Strings.Actions.SetDefaultAudioDevice.Role.Output() },
+ new ActionParameterOption { Value = "input", Label = Strings.Actions.SetDefaultAudioDevice.Role.Input() },
+ new ActionParameterOption { Value = "communication-output", Label = Strings.Actions.SetDefaultAudioDevice.Role.CommunicationOutput() },
+ new ActionParameterOption { Value = "communication-input", Label = Strings.Actions.SetDefaultAudioDevice.Role.CommunicationInput() },
+ ],
+ Strings.Actions.SetDefaultAudioDevice.Role.Label(),
+ Strings.Actions.SetDefaultAudioDevice.Role.Description(),
+ defaultValue: "output",
+ required: true),
+ ActionParameter.DynamicChoice(DeviceParameter, Strings.Actions.SetDefaultAudioDevice.Device.Label(), required: true),
+ ];
+
+ public IActionExecutor CreateExecutor() => new Executor(devices, logger);
+
+ public async Task GetDynamicOptionsAsync(DynamicOptionsContext context, CancellationToken cancellationToken)
+ {
+ if (!string.Equals(context.ParameterName, DeviceParameter, StringComparison.Ordinal))
+ {
+ return new DynamicOptionsResult { Options = [] };
+ }
+
+ if (devices() is not { } audio)
+ {
+ return new DynamicOptionsResult { Options = [], Error = Strings.Actions.SetDefaultAudioDevice.NotSupported() };
+ }
+
+ var role = ParseRole(context.CurrentParameters.GetValueOrDefault(RoleParameter)) ?? AudioDeviceRole.Output;
+ var available = await audio.GetDevicesAsync(role.IsInput(), cancellationToken);
+ return new DynamicOptionsResult
+ {
+ Options = [.. available.Select(device => new ActionParameterOption { Value = device.Id, Label = device.Name })],
+ CacheSeconds = 2,
+ };
+ }
+
+ internal static AudioDeviceRole? ParseRole(object? value)
+ {
+ var text = value?.ToString();
+ foreach (var (candidate, role) in _roles)
+ {
+ if (string.Equals(candidate, text, StringComparison.Ordinal))
+ {
+ return role;
+ }
+ }
+
+ return null;
+ }
+
+ internal static async Task SwitchAsync(IAudioDevices? devices, AudioDeviceRole role, string idOrName, ILogger logger,
+ CancellationToken cancellationToken)
+ {
+ if (devices is null)
+ {
+ return Outcome.NotSupported;
+ }
+
+ try
+ {
+ return await devices.FindAsync(role, idOrName, cancellationToken) is { } device &&
+ await devices.SetDefaultAsync(role, device.Id, cancellationToken)
+ ? Outcome.Switched
+ : Outcome.NotFound;
+ }
+ catch (Exception exception) when (exception is not OperationCanceledException)
+ {
+ logger.Warning(exception, "Setting the default {Role} device to {Device} failed: {Message}", role, idOrName, exception.Message);
+ return Outcome.Failed;
+ }
+ }
+
+ private sealed class Executor(Func devices, ILogger logger) : IActionExecutor
+ {
+ public async Task ExecuteAsync(ActionExecutionContext context)
+ {
+ var role = ParseRole(context.Parameters.GetValueOrDefault(RoleParameter) ?? "output");
+ if (role is null || context.Parameters.GetValueOrDefault(DeviceParameter)?.ToString() is not { Length: > 0 } device)
+ {
+ return ActionResult.Failed(ActionErrorCodes.InvalidParameter, Strings.Actions.SetDefaultAudioDevice.NoDevice());
+ }
+
+ return await SwitchAsync(devices(), role.Value, device, logger, context.CancellationToken) switch
+ {
+ Outcome.Switched => ActionResult.Success(),
+ Outcome.NotSupported => ActionResult.Failed(ActionErrorCodes.Unavailable, Strings.Actions.SetDefaultAudioDevice.NotSupported()),
+ Outcome.NotFound => ActionResult.Failed(ActionErrorCodes.NotFound, Strings.Actions.SetDefaultAudioDevice.DeviceNotFound()),
+ _ => ActionResult.Failed(ActionErrorCodes.ProviderError, Strings.Actions.SetDefaultAudioDevice.Failed()),
+ };
+ }
+ }
+}
diff --git a/src/SystemMedia/Variables/SystemMediaVariables.cs b/src/SystemMedia/Variables/SystemMediaVariables.cs
index 2300c75..b0892e4 100644
--- a/src/SystemMedia/Variables/SystemMediaVariables.cs
+++ b/src/SystemMedia/Variables/SystemMediaVariables.cs
@@ -1,6 +1,7 @@
using MacroDeck.Localization;
using MacroDeck.Sdk.MusicPlayer;
using MacroDeck.Sdk.Variables;
+using SystemMedia.Platform;
namespace SystemMedia.Variables;
@@ -53,7 +54,20 @@ internal static class SystemMediaVariables
Text("system_media_repeat_mode", Strings.Variables.RepeatMode(), state => state.RepeatMode.ToString().ToLowerInvariant(), _slow),
];
- internal static IReadOnlyList All { get; } = [.. _entries.Select(entry => entry.Definition)];
+ // The system's default devices, whatever app is playing. Setting one to a device's name switches to it.
+ private static readonly DeviceEntry[] _devices =
+ [
+ Device("system_media_output_device", Strings.Variables.OutputDevice(), AudioDeviceRole.Output),
+ Device("system_media_input_device", Strings.Variables.InputDevice(), AudioDeviceRole.Input),
+ Device("system_media_communication_output_device", Strings.Variables.CommunicationOutputDevice(), AudioDeviceRole.CommunicationOutput),
+ Device("system_media_communication_input_device", Strings.Variables.CommunicationInputDevice(), AudioDeviceRole.CommunicationInput),
+ ];
+
+ internal static IReadOnlyList All { get; } =
+ [.. _entries.Select(entry => entry.Definition), .. _devices.Select(device => device.Definition)];
+
+ internal static AudioDeviceRole? DeviceRole(string localId) =>
+ Array.Find(_devices, candidate => string.Equals(candidate.Definition.ResolvedId, localId, StringComparison.Ordinal))?.Role;
internal static VariableReading Read(string localId, MusicPlayerState state)
{
@@ -81,4 +95,11 @@ private static Entry Text(string name, LocalizedText displayName, Func Read);
+
+ private static DeviceEntry Device(string name, LocalizedText displayName, AudioDeviceRole role) =>
+ new(VariableDefinition.Eager(name, VariableType.Text, refreshInterval: _slow)
+ with { DisplayName = displayName, Write = new VariableWriteCapability() },
+ role);
+
+ private sealed record DeviceEntry(VariableDefinition Definition, AudioDeviceRole Role);
}
diff --git a/tests/SystemMedia.Tests/Linux/PulseAudioDevicesTests.cs b/tests/SystemMedia.Tests/Linux/PulseAudioDevicesTests.cs
new file mode 100644
index 0000000..f8ba67c
--- /dev/null
+++ b/tests/SystemMedia.Tests/Linux/PulseAudioDevicesTests.cs
@@ -0,0 +1,44 @@
+using NUnit.Framework;
+using SystemMedia.Platform;
+using SystemMedia.Platform.Linux;
+
+namespace SystemMedia.Tests.Linux;
+
+[TestFixture]
+public sealed class PulseAudioDevicesTests
+{
+ [Test]
+ public void Sinks_are_named_by_their_description()
+ {
+ const string json = """
+ [{"index":56,"state":"RUNNING","name":"alsa_output.pci-0000_00_1f.3.analog-stereo","description":"Built-in Audio Analog Stereo","monitor_source":"alsa_output.pci-0000_00_1f.3.analog-stereo.monitor"}]
+ """;
+
+ Assert.That(PulseAudioDevices.ParseDevices(json),
+ Is.EqualTo(new[] { new AudioDevice("alsa_output.pci-0000_00_1f.3.analog-stereo", "Built-in Audio Analog Stereo") }));
+ }
+
+ [Test]
+ public void A_monitor_of_a_sink_is_no_input()
+ {
+ const string json = """
+ [
+ {"name":"alsa_output.usb.monitor","description":"Monitor of Headset","monitor_of_sink":"alsa_output.usb"},
+ {"name":"alsa_input.usb","description":"Headset Microphone","monitor_of_sink":"n/a"},
+ {"name":"other.monitor","description":"Monitor without the field"}
+ ]
+ """;
+
+ Assert.That(PulseAudioDevices.ParseDevices(json).Single().Id, Is.EqualTo("alsa_input.usb"));
+ }
+
+ [Test]
+ public void The_defaults_come_from_the_server_info()
+ {
+ const string json = """
+ {"server_name":"PulseAudio (on PipeWire 1.2.7)","default_sink_name":"alsa_output.usb","default_source_name":"alsa_input.usb"}
+ """;
+
+ Assert.That(PulseAudioDevices.ParseDefaults(json), Is.EqualTo(("alsa_output.usb", "alsa_input.usb")));
+ }
+}
diff --git a/tests/SystemMedia.Tests/SetDefaultAudioDeviceActionTests.cs b/tests/SystemMedia.Tests/SetDefaultAudioDeviceActionTests.cs
new file mode 100644
index 0000000..423dbdd
--- /dev/null
+++ b/tests/SystemMedia.Tests/SetDefaultAudioDeviceActionTests.cs
@@ -0,0 +1,115 @@
+using MacroDeck.Sdk.Actions;
+using NUnit.Framework;
+using Serilog;
+using SystemMedia.Platform;
+
+namespace SystemMedia.Tests;
+
+[TestFixture]
+public sealed class SetDefaultAudioDeviceActionTests
+{
+ private static readonly ILogger _logger = Serilog.Core.Logger.None;
+
+ [Test]
+ public async Task A_device_is_switched_to_by_its_id()
+ {
+ var devices = new FakeAudioDevices();
+
+ var outcome = await SetDefaultAudioDeviceAction.SwitchAsync(devices, AudioDeviceRole.Output, "headset-out", _logger, CancellationToken.None);
+
+ Assert.That(outcome, Is.EqualTo(SetDefaultAudioDeviceAction.Outcome.Switched));
+ Assert.That(devices.Switched, Is.EqualTo((AudioDeviceRole.Output, "headset-out")));
+ }
+
+ [Test]
+ public async Task A_device_is_switched_to_by_the_name_a_variable_shows()
+ {
+ var devices = new FakeAudioDevices();
+
+ var outcome = await SetDefaultAudioDeviceAction.SwitchAsync(devices, AudioDeviceRole.CommunicationInput, " headset microphone ", _logger, CancellationToken.None);
+
+ Assert.That(outcome, Is.EqualTo(SetDefaultAudioDeviceAction.Outcome.Switched));
+ Assert.That(devices.Switched, Is.EqualTo((AudioDeviceRole.CommunicationInput, "headset-in")));
+ }
+
+ [Test]
+ public async Task An_output_is_not_found_among_the_inputs()
+ {
+ var devices = new FakeAudioDevices();
+
+ var outcome = await SetDefaultAudioDeviceAction.SwitchAsync(devices, AudioDeviceRole.Input, "headset-out", _logger, CancellationToken.None);
+
+ Assert.That(outcome, Is.EqualTo(SetDefaultAudioDeviceAction.Outcome.NotFound));
+ Assert.That(devices.Switched, Is.Null);
+ }
+
+ [Test]
+ public async Task A_refused_switch_is_a_failure()
+ {
+ var devices = new FakeAudioDevices { Refuses = true };
+
+ var outcome = await SetDefaultAudioDeviceAction.SwitchAsync(devices, AudioDeviceRole.Output, "speakers", _logger, CancellationToken.None);
+
+ Assert.That(outcome, Is.EqualTo(SetDefaultAudioDeviceAction.Outcome.Failed));
+ }
+
+ [Test]
+ public async Task A_platform_without_audio_devices_cannot_switch()
+ {
+ var outcome = await SetDefaultAudioDeviceAction.SwitchAsync(null, AudioDeviceRole.Output, "speakers", _logger, CancellationToken.None);
+
+ Assert.That(outcome, Is.EqualTo(SetDefaultAudioDeviceAction.Outcome.NotSupported));
+ }
+
+ [Test]
+ public async Task The_device_options_follow_the_chosen_role()
+ {
+ var action = new SetDefaultAudioDeviceAction(() => new FakeAudioDevices(), _logger);
+
+ var result = await action.GetDynamicOptionsAsync(
+ new DynamicOptionsContext
+ {
+ ParameterName = SetDefaultAudioDeviceAction.DeviceParameter,
+ CurrentParameters = new Dictionary { [SetDefaultAudioDeviceAction.RoleParameter] = "communication-input" },
+ },
+ CancellationToken.None);
+
+ Assert.That(result.Options.Single().Value, Is.EqualTo("headset-in"));
+ }
+
+ [TestCase("output", nameof(AudioDeviceRole.Output))]
+ [TestCase("input", nameof(AudioDeviceRole.Input))]
+ [TestCase("communication-output", nameof(AudioDeviceRole.CommunicationOutput))]
+ [TestCase("communication-input", nameof(AudioDeviceRole.CommunicationInput))]
+ public void The_stored_role_values_stay_the_same(string value, string role)
+ {
+ Assert.That(SetDefaultAudioDeviceAction.ParseRole(value).ToString(), Is.EqualTo(role));
+ }
+
+ private sealed class FakeAudioDevices : IAudioDevices
+ {
+ private static readonly AudioDevice[] _outputs = [new("speakers", "Speakers"), new("headset-out", "Headset")];
+ private static readonly AudioDevice[] _inputs = [new("headset-in", "Headset Microphone")];
+
+ public bool Refuses { get; init; }
+
+ public (AudioDeviceRole, string)? Switched { get; private set; }
+
+ public Task> GetDevicesAsync(bool input, CancellationToken cancellationToken) =>
+ Task.FromResult>(input ? _inputs : _outputs);
+
+ public Task GetDefaultAsync(AudioDeviceRole role, CancellationToken cancellationToken) =>
+ Task.FromResult(role.IsInput() ? _inputs[0] : _outputs[0]);
+
+ public Task SetDefaultAsync(AudioDeviceRole role, string deviceId, CancellationToken cancellationToken)
+ {
+ if (Refuses)
+ {
+ throw new InvalidOperationException("Refused.");
+ }
+
+ Switched = (role, deviceId);
+ return Task.FromResult(true);
+ }
+ }
+}
diff --git a/tests/SystemMedia.Tests/SystemMediaVariablesTests.cs b/tests/SystemMedia.Tests/SystemMediaVariablesTests.cs
index 48baf86..62cd3c5 100644
--- a/tests/SystemMedia.Tests/SystemMediaVariablesTests.cs
+++ b/tests/SystemMedia.Tests/SystemMediaVariablesTests.cs
@@ -1,5 +1,6 @@
using MacroDeck.Sdk.MusicPlayer;
using NUnit.Framework;
+using SystemMedia.Platform;
using SystemMedia.Variables;
namespace SystemMedia.Tests;
@@ -43,6 +44,24 @@ public void A_variable_reads_its_value_from_the_current_state(string id, object
Assert.That(SystemMediaVariables.Read(id, _playing).Value, Is.EqualTo(expected));
}
+ [TestCase("system-media-output-device", nameof(AudioDeviceRole.Output))]
+ [TestCase("system-media-input-device", nameof(AudioDeviceRole.Input))]
+ [TestCase("system-media-communication-output-device", nameof(AudioDeviceRole.CommunicationOutput))]
+ [TestCase("system-media-communication-input-device", nameof(AudioDeviceRole.CommunicationInput))]
+ public void A_device_variable_is_a_writable_default_device(string id, string role)
+ {
+ var variable = SystemMediaVariables.All.Single(candidate => candidate.ResolvedId == id);
+
+ Assert.That(variable.CanWrite, Is.True);
+ Assert.That(SystemMediaVariables.DeviceRole(id).ToString(), Is.EqualTo(role));
+ }
+
+ [Test]
+ public void A_state_variable_is_no_device()
+ {
+ Assert.That(SystemMediaVariables.DeviceRole(SystemMediaVariables.VolumeId), Is.Null);
+ }
+
[Test]
public void The_position_slider_ends_at_the_track_duration()
{
diff --git a/tests/SystemMedia.Tests/Windows/WindowsPluginIntegrationTests.cs b/tests/SystemMedia.Tests/Windows/WindowsPluginIntegrationTests.cs
index 714a7c2..39dcb12 100644
--- a/tests/SystemMedia.Tests/Windows/WindowsPluginIntegrationTests.cs
+++ b/tests/SystemMedia.Tests/Windows/WindowsPluginIntegrationTests.cs
@@ -2,6 +2,8 @@
using MacroDeck.Plugin.Testing;
using NUnit.Framework;
using Serilog;
+using SystemMedia.Platform;
+using SystemMedia.Platform.Windows.Audio;
using SystemMedia.Platform.Windows;
namespace SystemMedia.Tests.Windows;
@@ -22,6 +24,34 @@ public async Task The_current_instance_reports_a_connected_state_even_when_nothi
Assert.That(state!.IsConnected, Is.True);
}
+ // A machine without a microphone or speakers has no default device, so only what holds either way is checked.
+ [TestCase(nameof(AudioDeviceRole.Output))]
+ [TestCase(nameof(AudioDeviceRole.Input))]
+ [TestCase(nameof(AudioDeviceRole.CommunicationOutput))]
+ [TestCase(nameof(AudioDeviceRole.CommunicationInput))]
+ public async Task A_default_device_is_one_of_the_connected_devices(string roleName)
+ {
+ var role = Enum.Parse(roleName);
+ var devices = new WindowsAudioDevices();
+
+ var connected = await devices.GetDevicesAsync(role.IsInput(), CancellationToken.None);
+ var current = await devices.GetDefaultAsync(role, CancellationToken.None);
+
+ Assert.That(connected.Select(device => device.Name), Has.None.Empty);
+ if (current is not null)
+ {
+ Assert.That(connected, Has.Member(current));
+ }
+ }
+
+ [Test]
+ public async Task An_unknown_device_is_not_made_the_default()
+ {
+ var switched = await new WindowsAudioDevices().SetDefaultAsync(AudioDeviceRole.Output, "{0.0.0.00000000}.{unknown}", CancellationToken.None);
+
+ Assert.That(switched, Is.False);
+ }
+
// The host asks for issues while the first start is still running.
[Test]
public void A_platform_that_has_not_started_yet_is_not_reported_unavailable()