diff --git a/docs/TESTING.md b/docs/TESTING.md index 0488907..7363cfb 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -113,3 +113,16 @@ Pré-condições: Ajustes → Interface = **Automático** (padrão). - [ ] Fechar a janela durante a sessão vai para a bandeja; fora da sessão fecha o app. Menu da bandeja: Mostrar, Entrar, Restaurar, Sair. Resultado: ______ - [ ] Atualização: com uma release mais nova no GitHub, o aviso aparece nas duas interfaces (na Console, como banner no topo). Resultado: ______ - [ ] Atalho "Console Mode 1 Click" e `--tray` no início do Windows continuam funcionando. Resultado: ______ + +## Áudio nativo, sem SoundVolumeView (issue #91) + +Pré-condições: app **fechado**; em `config.json` (na pasta de dados) acrescente `"NativeAudio": true` e abra o app. O log (`consolemode.log`) deve mostrar `audio=native` na linha `Startup:`. Para voltar ao SoundVolumeView, troque para `false`. + +- [ ] Ajustes → Saída de áudio lista as mesmas saídas que com o SoundVolumeView, com os mesmos nomes, e as desativadas aparecem com o sufixo "[Desabilitado]". Resultado: ______ +- [ ] A saída que já estava escolhida com o SoundVolumeView continua selecionada (o ID salvo é o mesmo). Resultado: ______ +- [ ] Entrar no modo console com uma saída fixa (ex.: HDMI da TV): o som passa para ela, inclusive em apps de chamada (papel "comunicações"). O log mostra `Áudio: saída padrão = …`. Resultado: ______ +- [ ] "A que aparecer ao conectar (TV)" com a TV começando **desligada**: quando a TV liga, o som vai para o HDMI dela. Resultado: ______ +- [ ] Uma saída **desabilitada** em Configurações → Som: escolhê-la liga a saída e o som vai para ela. Resultado: ______ +- [ ] Menu da sessão (Select + Y): o volume inicial é o do Windows; ◀/▶ muda de 5 em 5 e tira o mudo; A alterna o mudo. Resultado: ______ +- [ ] Ao restaurar a mesa, o som volta para a saída de antes do modo console. Resultado: ______ +- [ ] Com `"NativeAudio": true` e **sem** o `SoundVolumeView.exe` na pasta `tools`, tudo acima continua funcionando. Resultado: ______ diff --git a/src/ConsoleMode/App.xaml.cs b/src/ConsoleMode/App.xaml.cs index 62953b3..fb29674 100644 --- a/src/ConsoleMode/App.xaml.cs +++ b/src/ConsoleMode/App.xaml.cs @@ -77,7 +77,7 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) AppLog.Write("Controles: " + ControllerInput.DescribeDevices().ReplaceLineEndings(" | ")); ProtocolService.EnsureRegistered(); AppLog.Write($"Startup: exe={Environment.ProcessPath}, args={string.Join(' ', Environment.GetCommandLineArgs().Skip(1))}, " + - $"mmt={AppPaths.HasMmt}, svv={AppPaths.HasSvv}, rtss-cli={AppPaths.HasRtssCli}"); + $"mmt={AppPaths.HasMmt}, svv={AppPaths.HasSvv}, audio={(AudioService.UseNative ? "native" : "svv")}, rtss-cli={AppPaths.HasRtssCli}"); ViewModel = new MainViewModel(); _window = new MainWindow(ViewModel); diff --git a/src/ConsoleMode/Models/Models.cs b/src/ConsoleMode/Models/Models.cs index ab0cb09..3e84d23 100644 --- a/src/ConsoleMode/Models/Models.cs +++ b/src/ConsoleMode/Models/Models.cs @@ -19,6 +19,8 @@ public sealed class AppConfig public string AudioDeviceId { get; set; } = ""; public string AudioDeviceName { get; set; } = ""; public bool AudioAutoSwitch { get; set; } + /// Use Core Audio instead of SoundVolumeView (issue #91); off until verified on hardware. + public bool NativeAudio { get; set; } public int FpsLimit { get; set; } public Dictionary MonitorModes { get; set; } = new(StringComparer.OrdinalIgnoreCase); public bool HdrEnable { get; set; } diff --git a/src/ConsoleMode/Native/CoreAudio.cs b/src/ConsoleMode/Native/CoreAudio.cs new file mode 100644 index 0000000..61cf9f6 --- /dev/null +++ b/src/ConsoleMode/Native/CoreAudio.cs @@ -0,0 +1,260 @@ +using System.Runtime.InteropServices; + +namespace ConsoleMode.Native; + +/// +/// Windows Core Audio (MMDevice API) for the output devices: list, switch the default, enable a +/// disabled one, volume and mute. Replaces SoundVolumeView (issue #91). +/// Switching the default output uses IPolicyConfig, which Windows doesn't document but has kept +/// stable since Windows 7; the Control Panel, SoundVolumeView, EarTrumpet and AudioSwitcher use it. +/// +internal static class CoreAudio +{ + public sealed record Endpoint(string Id, string Name, string AdapterName, bool IsActive, bool IsDefault); + + private const int ERender = 0; + private const int EConsole = 0, EMultimedia = 1, ECommunications = 2; + private const int DeviceStateActive = 0x1, DeviceStateDisabled = 0x2, DeviceStateUnplugged = 0x8; + private const int StgmRead = 0; + private const int ClsctxAll = 0x17; + private const ushort VtLpwstr = 31; + + // PKEY_Device_DeviceDesc: the endpoint's own name ("Speakers", "LG TV"). + private static readonly PropertyKey DeviceDescKey = new(new Guid("a45c254e-df1c-4efd-8020-67d146a850e0"), 2); + // PKEY_DeviceInterface_FriendlyName: the adapter ("Realtek(R) Audio", "NVIDIA High Definition Audio"). + private static readonly PropertyKey InterfaceFriendlyNameKey = new(new Guid("026e516e-b814-414b-83cd-856d6fef4822"), 2); + + /// Active, disabled and unplugged outputs (the same set SoundVolumeView listed). + public static List ListRenderEndpoints() + { + var result = new List(); + var enumerator = CreateEnumerator(); + try + { + var defaultId = TryGetDefaultId(enumerator); + var collection = enumerator.EnumAudioEndpoints(ERender, DeviceStateActive | DeviceStateDisabled | DeviceStateUnplugged); + try + { + var count = collection.GetCount(); + for (var i = 0; i < count; i++) + { + var device = collection.Item(i); + try + { + var id = device.GetId(); + var state = device.GetState(); + var store = device.OpenPropertyStore(StgmRead); + try + { + result.Add(new Endpoint( + id, + GetString(store, DeviceDescKey), + GetString(store, InterfaceFriendlyNameKey), + state == DeviceStateActive, + string.Equals(id, defaultId, StringComparison.OrdinalIgnoreCase))); + } + finally { Release(store); } + } + finally { Release(device); } + } + } + finally { Release(collection); } + } + finally { Release(enumerator); } + return result; + } + + /// Makes the output the default for every role (console, multimedia, communications). + public static void SetDefault(string endpointId) + { + var policy = CreatePolicyConfig(); + try + { + foreach (var role in new[] { EConsole, EMultimedia, ECommunications }) + Marshal.ThrowExceptionForHR(policy.SetDefaultEndpoint(endpointId, role)); + } + finally { Release(policy); } + } + + /// Enables an output disabled in Sound settings. + public static void Enable(string endpointId) + { + var policy = CreatePolicyConfig(); + try { Marshal.ThrowExceptionForHR(policy.SetEndpointVisibility(endpointId, 1)); } + finally { Release(policy); } + } + + /// Master volume from 0 to 100. + public static int GetVolumePercent(string endpointId) => + WithVolume(endpointId, v => (int)Math.Round(v.GetMasterVolumeLevelScalar() * 100)); + + public static void SetVolumePercent(string endpointId, int percent) => + WithVolume(endpointId, v => + { + var context = Guid.Empty; + v.SetMasterVolumeLevelScalar(Math.Clamp(percent, 0, 100) / 100f, ref context); + return 0; + }); + + public static void SetMute(string endpointId, bool mute) => + WithVolume(endpointId, v => + { + var context = Guid.Empty; + v.SetMute(mute, ref context); + return 0; + }); + + private static T WithVolume(string endpointId, Func action) + { + var enumerator = CreateEnumerator(); + try + { + var device = enumerator.GetDevice(endpointId); + try + { + var iid = typeof(IAudioEndpointVolume).GUID; + var volume = (IAudioEndpointVolume)device.Activate(ref iid, ClsctxAll, IntPtr.Zero); + try { return action(volume); } + finally { Release(volume); } + } + finally { Release(device); } + } + finally { Release(enumerator); } + } + + private static string? TryGetDefaultId(IMMDeviceEnumerator enumerator) + { + try + { + var device = enumerator.GetDefaultAudioEndpoint(ERender, EConsole); + try { return device.GetId(); } + finally { Release(device); } + } + catch (COMException) + { + return null; // no output at all + } + } + + private static string GetString(IPropertyStore store, PropertyKey key) + { + var value = default(PropVariant); + try + { + store.GetValue(ref key, out value); + return value.Type == VtLpwstr && value.Pointer != IntPtr.Zero ? Marshal.PtrToStringUni(value.Pointer) ?? "" : ""; + } + finally { PropVariantClear(ref value); } + } + + private static IMMDeviceEnumerator CreateEnumerator() => (IMMDeviceEnumerator)new MMDeviceEnumeratorCom(); + + private static IPolicyConfig CreatePolicyConfig() => (IPolicyConfig)new PolicyConfigClientCom(); + + private static void Release(object? comObject) + { + if (comObject is not null && Marshal.IsComObject(comObject)) Marshal.ReleaseComObject(comObject); + } + + [DllImport("ole32.dll")] + private static extern int PropVariantClear(ref PropVariant value); + + [StructLayout(LayoutKind.Sequential)] + private struct PropertyKey(Guid formatId, int propertyId) + { + public Guid FormatId = formatId; + public int PropertyId = propertyId; + } + + /// Just enough of PROPVARIANT to read strings (24 bytes on 64-bit, 16 on 32-bit). + [StructLayout(LayoutKind.Explicit, Size = 24)] + private struct PropVariant + { + [FieldOffset(0)] public ushort Type; + [FieldOffset(8)] public IntPtr Pointer; + } + + [ComImport, Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")] + private class MMDeviceEnumeratorCom { } + + [ComImport, Guid("870AF99C-171D-4F9E-AF0D-E63DF40C2BC9")] + private class PolicyConfigClientCom { } + + [ComImport, Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IMMDeviceEnumerator + { + IMMDeviceCollection EnumAudioEndpoints(int dataFlow, int stateMask); + IMMDevice GetDefaultAudioEndpoint(int dataFlow, int role); + IMMDevice GetDevice([MarshalAs(UnmanagedType.LPWStr)] string id); + void RegisterEndpointNotificationCallback(IntPtr client); + void UnregisterEndpointNotificationCallback(IntPtr client); + } + + [ComImport, Guid("0BD7A1BE-7A1A-44DB-8397-CC5392387B5E"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IMMDeviceCollection + { + int GetCount(); + IMMDevice Item(int index); + } + + [ComImport, Guid("D666063F-1587-4E43-81F1-B948E807363F"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IMMDevice + { + [return: MarshalAs(UnmanagedType.IUnknown)] + object Activate(ref Guid iid, int clsCtx, IntPtr activationParams); + IPropertyStore OpenPropertyStore(int access); + [return: MarshalAs(UnmanagedType.LPWStr)] + string GetId(); + int GetState(); + } + + [ComImport, Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IPropertyStore + { + int GetCount(); + PropertyKey GetAt(int index); + void GetValue(ref PropertyKey key, out PropVariant value); + void SetValue(ref PropertyKey key, ref PropVariant value); + void Commit(); + } + + [ComImport, Guid("5CDF2C82-841E-4546-9722-0CF74078229A"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IAudioEndpointVolume + { + void RegisterControlChangeNotify(IntPtr notify); + void UnregisterControlChangeNotify(IntPtr notify); + uint GetChannelCount(); + void SetMasterVolumeLevel(float levelDb, ref Guid eventContext); + void SetMasterVolumeLevelScalar(float level, ref Guid eventContext); + float GetMasterVolumeLevel(); + float GetMasterVolumeLevelScalar(); + void SetChannelVolumeLevel(uint channel, float levelDb, ref Guid eventContext); + void SetChannelVolumeLevelScalar(uint channel, float level, ref Guid eventContext); + float GetChannelVolumeLevel(uint channel); + float GetChannelVolumeLevelScalar(uint channel); + void SetMute([MarshalAs(UnmanagedType.Bool)] bool mute, ref Guid eventContext); + [return: MarshalAs(UnmanagedType.Bool)] + bool GetMute(); + } + + /// + /// Only the last two methods are called; the ones before them hold their place in the vtable, + /// so their parameters don't matter. + /// + [ComImport, Guid("F8679F50-850A-41CF-9C72-430F290290C8"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IPolicyConfig + { + [PreserveSig] int GetMixFormat(IntPtr a, IntPtr b); + [PreserveSig] int GetDeviceFormat(IntPtr a, int b, IntPtr c); + [PreserveSig] int ResetDeviceFormat(IntPtr a); + [PreserveSig] int SetDeviceFormat(IntPtr a, IntPtr b, IntPtr c); + [PreserveSig] int GetProcessingPeriod(IntPtr a, int b, IntPtr c, IntPtr d); + [PreserveSig] int SetProcessingPeriod(IntPtr a, IntPtr b); + [PreserveSig] int GetShareMode(IntPtr a, IntPtr b); + [PreserveSig] int SetShareMode(IntPtr a, IntPtr b); + [PreserveSig] int GetPropertyValue(IntPtr a, IntPtr b, IntPtr c); + [PreserveSig] int SetPropertyValue(IntPtr a, IntPtr b, IntPtr c); + [PreserveSig] int SetDefaultEndpoint([MarshalAs(UnmanagedType.LPWStr)] string deviceId, int role); + [PreserveSig] int SetEndpointVisibility([MarshalAs(UnmanagedType.LPWStr)] string deviceId, int visible); + } +} diff --git a/src/ConsoleMode/Services/AudioNaming.cs b/src/ConsoleMode/Services/AudioNaming.cs new file mode 100644 index 0000000..5853aee --- /dev/null +++ b/src/ConsoleMode/Services/AudioNaming.cs @@ -0,0 +1,24 @@ +namespace ConsoleMode.Services; + +/// +/// Names and IDs of audio outputs, shared by the SoundVolumeView and the native (Core Audio) +/// backends so the saved setting (AudioDeviceId) and the restore backup work with either. +/// Pure, so it's tested. +/// +public static class AudioNaming +{ + /// + /// SoundVolumeView's "Command-Line Friendly ID" for an output: + /// {adapter}\Device\{endpoint}\Render, e.g. Realtek(R) Audio\Device\Speakers\Render. + /// + public static string FriendlyId(string adapterName, string endpointName) => + $"{adapterName}\\Device\\{endpointName}\\Render"; + + /// "Speakers (Realtek(R) Audio)"; just one of them when the other is empty or the same. + public static string DisplayName(string endpointName, string adapterName) + { + if (string.IsNullOrWhiteSpace(endpointName)) return adapterName; + if (!string.IsNullOrWhiteSpace(adapterName) && adapterName != endpointName) return $"{endpointName} ({adapterName})"; + return endpointName; + } +} diff --git a/src/ConsoleMode/Services/AudioService.cs b/src/ConsoleMode/Services/AudioService.cs index 01f4ae0..b8c5920 100644 --- a/src/ConsoleMode/Services/AudioService.cs +++ b/src/ConsoleMode/Services/AudioService.cs @@ -1,11 +1,30 @@ using ConsoleMode.Models; +using ConsoleMode.Native; namespace ConsoleMode.Services; public sealed class AudioService { + private static bool? _useNative; private List? _cache; private string? _cachedDefaultId; + // Native backend: friendly ID (what config and backups store) -> Core Audio endpoint ID. + private Dictionary _endpointIds = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Core Audio instead of SoundVolumeView (issue #91). Off by default while it's verified on real + /// hardware: set "NativeAudio": true in config.json and restart the app. + /// + public static bool UseNative => _useNative ??= LoadNativeFlag(); + + /// Audio switching works: the native backend is on, or SoundVolumeView is present. + public static bool IsAvailable => UseNative || AppPaths.HasSvv; + + private static bool LoadNativeFlag() + { + try { return ConfigService.Load().NativeAudio; } + catch { return false; } + } public void ClearCache() { @@ -21,18 +40,53 @@ public int InvokeSvv(params string[] args) public IReadOnlyList GetDevices(bool forceRefresh = false) { - if (!AppPaths.HasSvv) return []; + if (!IsAvailable) return []; if (!forceRefresh && _cache is not null) return _cache; + try + { + var devices = UseNative ? ReadNativeDevices() : ReadSvvDevices(); + _cache = [.. devices.OrderBy(d => d.IsActive ? 0 : 1).ThenBy(d => d.Name)]; + } + catch (Exception ex) + { + AppLog.Write($"Áudio: não foi possível listar as saídas: {ex.Message}"); + _cache = []; + } + _cachedDefaultId = _cache.FirstOrDefault(d => d.IsDefault)?.FriendlyId; + return _cache; + } + + private List ReadNativeDevices() + { + var devices = new List(); + var ids = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var endpoint in CoreAudio.ListRenderEndpoints()) + { + var friendlyId = AudioNaming.FriendlyId(endpoint.AdapterName, endpoint.Name); + // Two outputs with the same names: keep the active / default one, like the ID can only name one. + if (ids.ContainsKey(friendlyId) && !endpoint.IsActive && !endpoint.IsDefault) continue; + ids[friendlyId] = endpoint.Id; + devices.RemoveAll(d => string.Equals(d.FriendlyId, friendlyId, StringComparison.OrdinalIgnoreCase)); + devices.Add(new AudioDevice + { + Name = AudioNaming.DisplayName(endpoint.Name, endpoint.AdapterName), + FriendlyId = friendlyId, + IsDefault = endpoint.IsDefault, + IsActive = endpoint.IsActive + }); + } + _endpointIds = ids; + return devices; + } + + private List ReadSvvDevices() + { var csvPath = Path.Combine(Path.GetTempPath(), $"consolemode_audio_{Guid.NewGuid():N}.csv"); try { InvokeSvv("/ShowDisabledDevices", "1", "/ShowUnpluggedDevices", "1", "/scomma", csvPath); - if (!File.Exists(csvPath)) - { - _cache = []; - return _cache; - } + if (!File.Exists(csvPath)) return []; var devices = new List(); foreach (var row in CsvReader.Read(csvPath)) @@ -43,32 +97,15 @@ public IReadOnlyList GetDevices(bool forceRefresh = false) if (!string.Equals(row.Get("Direction"), "Render", StringComparison.OrdinalIgnoreCase)) continue; var deviceState = row.Get("Device State"); - var isActive = deviceState.Contains("Active", StringComparison.OrdinalIgnoreCase) || string.IsNullOrWhiteSpace(deviceState); - var friendlyName = row.Get("Name"); - var driverName = row.Get("Device Name"); - string displayName; - if (string.IsNullOrWhiteSpace(friendlyName)) displayName = driverName; - else if (!string.IsNullOrWhiteSpace(driverName) && driverName != friendlyName) displayName = $"{friendlyName} ({driverName})"; - else displayName = friendlyName; - - devices.Add(new AudioDevice { - Name = displayName, + Name = AudioNaming.DisplayName(row.Get("Name"), row.Get("Device Name")), FriendlyId = friendlyId, IsDefault = row.Get("Default").Contains("Render", StringComparison.OrdinalIgnoreCase), - IsActive = isActive + IsActive = deviceState.Contains("Active", StringComparison.OrdinalIgnoreCase) || string.IsNullOrWhiteSpace(deviceState) }); } - - _cache = [.. devices.OrderBy(d => d.IsActive ? 0 : 1).ThenBy(d => d.Name)]; - _cachedDefaultId = _cache.FirstOrDefault(d => d.IsDefault)?.FriendlyId; - return _cache; - } - catch - { - _cache = []; - return _cache; + return devices; } finally { @@ -84,19 +121,70 @@ public IReadOnlyList GetDevices(bool forceRefresh = false) public void SetOutput(string friendlyId) { - InvokeSvv("/Enable", friendlyId); - InvokeSvv("/SetDefault", friendlyId, "all"); + if (UseNative) + { + var endpointId = ResolveEndpoint(friendlyId); + if (GetDevices().FirstOrDefault(d => d.FriendlyId == friendlyId) is { IsActive: false }) + CoreAudio.Enable(endpointId); + CoreAudio.SetDefault(endpointId); + } + else + { + InvokeSvv("/Enable", friendlyId); + InvokeSvv("/SetDefault", friendlyId, "all"); + } + AppLog.Write($"Áudio: saída padrão = {friendlyId}"); _cachedDefaultId = friendlyId; _cache = null; } public void Restore(string? backupId) { - if (string.IsNullOrWhiteSpace(backupId) || !AppPaths.HasSvv) return; - try { InvokeSvv("/SetDefault", backupId, "all"); } + if (string.IsNullOrWhiteSpace(backupId) || !IsAvailable) return; + try + { + if (UseNative) CoreAudio.SetDefault(ResolveEndpoint(backupId)); + else InvokeSvv("/SetDefault", backupId, "all"); + } catch (Exception ex) { AppLog.Write($"Não foi possível restaurar o áudio: {ex.Message}"); } } + /// Output volume from 0 to 100, or null when it can't be read. + public int? GetVolumePercent(string friendlyId) => UseNative + ? CoreAudio.GetVolumePercent(ResolveEndpoint(friendlyId)) + : SessionMenuMath.ParseVolumeExitCode(InvokeSvv("/GetPercent", friendlyId)); + + /// Sets the volume and unmutes, like turning the knob. + public void SetVolumePercent(string friendlyId, int percent) + { + if (UseNative) + { + var endpointId = ResolveEndpoint(friendlyId); + CoreAudio.SetVolumePercent(endpointId, percent); + CoreAudio.SetMute(endpointId, false); + } + else + { + InvokeSvv("/SetVolume", friendlyId, percent.ToString()); + InvokeSvv("/Unmute", friendlyId); + } + } + + public void SetMute(string friendlyId, bool mute) + { + if (UseNative) CoreAudio.SetMute(ResolveEndpoint(friendlyId), mute); + else InvokeSvv(mute ? "/Mute" : "/Unmute", friendlyId); + } + + private string ResolveEndpoint(string friendlyId) + { + if (_endpointIds.TryGetValue(friendlyId, out var id)) return id; + GetDevices(forceRefresh: true); + return _endpointIds.TryGetValue(friendlyId, out id) + ? id + : throw new InvalidOperationException($"Saída de áudio não encontrada: {friendlyId}"); + } + public AudioDevice? PickNewDevice(IReadOnlyList candidates, string? hint, MonitorInfo? focus) { if (candidates.Count == 0) return null; diff --git a/src/ConsoleMode/Services/ConsoleEngine.cs b/src/ConsoleMode/Services/ConsoleEngine.cs index e247252..9a11005 100644 --- a/src/ConsoleMode/Services/ConsoleEngine.cs +++ b/src/ConsoleMode/Services/ConsoleEngine.cs @@ -81,7 +81,7 @@ public void Start(AppConfig config, MonitorInfo? focusInfo, Func d.FriendlyId == config.AudioDeviceId); @@ -150,7 +150,7 @@ public void Start(AppConfig config, MonitorInfo? focusInfo, Func d.FriendlyId == id)?.Name ?? LocalizationService.Get("AudioNoChange"); int? vol = null; - try { if (id is not null) vol = SessionMenuMath.ParseVolumeExitCode(Engine.Audio.InvokeSvv("/GetPercent", id)); } catch { /* svv optional */ } + try { if (id is not null) vol = Engine.Audio.GetVolumePercent(id); } catch { /* audio optional */ } return (modeText, hdrOn, name, vol); }); SessionModeText = mode; @@ -162,7 +162,7 @@ public void ChangeVolume(int direction) IsMuted = false; _ = Task.Run(() => { - try { Engine.Audio.InvokeSvv("/SetVolume", id, next.ToString()); Engine.Audio.InvokeSvv("/Unmute", id); } + try { Engine.Audio.SetVolumePercent(id, next); } catch (Exception ex) { AppLog.Write($"Volume: {ex.Message}"); } }); } @@ -175,7 +175,7 @@ private void ToggleMute() var muted = IsMuted; _ = Task.Run(() => { - try { Engine.Audio.InvokeSvv(muted ? "/Mute" : "/Unmute", id); } + try { Engine.Audio.SetMute(id, muted); } catch (Exception ex) { AppLog.Write($"Mudo: {ex.Message}"); } }); } diff --git a/src/ConsoleMode/ViewModels/MainViewModel.cs b/src/ConsoleMode/ViewModels/MainViewModel.cs index f27ed4e..57bbf0b 100644 --- a/src/ConsoleMode/ViewModels/MainViewModel.cs +++ b/src/ConsoleMode/ViewModels/MainViewModel.cs @@ -204,7 +204,7 @@ private void BuildAudioOptions(string selectedValue) AudioOptions.Clear(); AudioOptions.Add(new ComboOption { Text = LocalizationService.Get("AudioNoChange"), Value = "" }); AudioOptions.Add(new ComboOption { Text = LocalizationService.Get("AudioOnConnect"), Value = ConsoleEngine.AudioOnConnectId }); - if (AppPaths.HasSvv) + if (AudioService.IsAvailable) { foreach (var device in _availableAudioDevices) AudioOptions.Add(new ComboOption @@ -264,7 +264,7 @@ private static LoadResult LoadData(ConsoleEngine engine) List audio = []; try { - if (AppPaths.HasSvv) audio = [.. engine.Audio.GetDevices(true)]; + if (AudioService.IsAvailable) audio = [.. engine.Audio.GetDevices(true)]; } catch (Exception ex) { @@ -890,6 +890,7 @@ private AppConfig BuildConfig() AudioDeviceId = auto ? "" : audioId, AudioDeviceName = auto ? "" : SelectedAudio?.Text ?? "", AudioAutoSwitch = auto, + NativeAudio = _loadedConfig.NativeAudio, FpsLimit = ReadFpsLimit(), HdrEnable = HdrEnable, VrrEnable = VrrEnable, diff --git a/tests/ConsoleMode.Tests/AudioNamingTests.cs b/tests/ConsoleMode.Tests/AudioNamingTests.cs new file mode 100644 index 0000000..fbda216 --- /dev/null +++ b/tests/ConsoleMode.Tests/AudioNamingTests.cs @@ -0,0 +1,20 @@ +using ConsoleMode.Services; + +namespace ConsoleMode.Tests; + +public sealed class AudioNamingTests +{ + [Theory] + [InlineData("Realtek(R) Audio", "Speakers", @"Realtek(R) Audio\Device\Speakers\Render")] + [InlineData("NVIDIA High Definition Audio", "LG TV SSCR2", @"NVIDIA High Definition Audio\Device\LG TV SSCR2\Render")] + public void Friendly_id_matches_the_one_saved_by_SoundVolumeView(string adapter, string endpoint, string expected) => + Assert.Equal(expected, AudioNaming.FriendlyId(adapter, endpoint)); + + [Theory] + [InlineData("Speakers", "Realtek(R) Audio", "Speakers (Realtek(R) Audio)")] + [InlineData("Speakers", "", "Speakers")] + [InlineData("", "Realtek(R) Audio", "Realtek(R) Audio")] + [InlineData("LG TV", "LG TV", "LG TV")] + public void Display_name_shows_the_endpoint_and_its_adapter(string endpoint, string adapter, string expected) => + Assert.Equal(expected, AudioNaming.DisplayName(endpoint, adapter)); +} diff --git a/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj b/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj index 14e33dd..1a07711 100644 --- a/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj +++ b/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj @@ -28,6 +28,7 @@ +