From e58f7f32ebeb2622630e65c1421866abac6f8e72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Sat, 3 Oct 2026 21:31:45 +0200 Subject: [PATCH] feat: add default sound device variables and switching Four variables name the system's default output and input devices and the ones Windows uses for calls; writing a device name to one switches to it. The "Set the default audio device" action does the same with a device picked from a list. Windows reads devices through Core Audio and switches them through the undocumented IPolicyConfig, macOS uses Core Audio, Linux runs pactl with JSON output. macOS and Linux have no separate call devices, so the communication roles are the plain defaults there. --- AGENTS.md | 20 +- README.md | 27 ++- src/SystemMedia/Localization/Strings.resx | 51 +++++ .../Platform/CompositeMediaPlatform.cs | 2 + src/SystemMedia/Platform/IAudioDevices.cs | 41 ++++ src/SystemMedia/Platform/IMediaPlatform.cs | 3 + .../Platform/Linux/LinuxMediaPlatform.cs | 2 + .../Platform/Linux/PulseAudioDevices.cs | 182 ++++++++++++++++++ .../Platform/MacOS/CoreAudioDevices.cs | 163 ++++++++++++++++ .../Platform/MacOS/MacMediaPlatform.cs | 2 + .../Platform/Windows/Audio/CoreAudioNative.cs | 61 ++++++ .../Windows/Audio/WindowsAudioDevices.cs | 160 +++++++++++++++ .../Platform/Windows/WindowsMediaPlatform.cs | 2 + src/SystemMedia/PluginIntegration.cs | 35 +++- .../SetDefaultAudioDeviceAction.cs | 133 +++++++++++++ .../Variables/SystemMediaVariables.cs | 23 ++- .../Linux/PulseAudioDevicesTests.cs | 44 +++++ .../SetDefaultAudioDeviceActionTests.cs | 115 +++++++++++ .../SystemMediaVariablesTests.cs | 19 ++ .../Windows/WindowsPluginIntegrationTests.cs | 30 +++ 20 files changed, 1103 insertions(+), 12 deletions(-) create mode 100644 src/SystemMedia/Platform/IAudioDevices.cs create mode 100644 src/SystemMedia/Platform/Linux/PulseAudioDevices.cs create mode 100644 src/SystemMedia/Platform/MacOS/CoreAudioDevices.cs create mode 100644 src/SystemMedia/Platform/Windows/Audio/WindowsAudioDevices.cs create mode 100644 src/SystemMedia/SetDefaultAudioDeviceAction.cs create mode 100644 tests/SystemMedia.Tests/Linux/PulseAudioDevicesTests.cs create mode 100644 tests/SystemMedia.Tests/SetDefaultAudioDeviceActionTests.cs 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()