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 @@
+