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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: ______
2 changes: 1 addition & 1 deletion src/ConsoleMode/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions src/ConsoleMode/Models/Models.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ public sealed class AppConfig
public string AudioDeviceId { get; set; } = "";
public string AudioDeviceName { get; set; } = "";
public bool AudioAutoSwitch { get; set; }
/// <summary>Use Core Audio instead of SoundVolumeView (issue #91); off until verified on hardware.</summary>
public bool NativeAudio { get; set; }
public int FpsLimit { get; set; }
public Dictionary<string, SavedDisplayMode> MonitorModes { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public bool HdrEnable { get; set; }
Expand Down
260 changes: 260 additions & 0 deletions src/ConsoleMode/Native/CoreAudio.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
using System.Runtime.InteropServices;

namespace ConsoleMode.Native;

/// <summary>
/// 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.
/// </summary>
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);

/// <summary>Active, disabled and unplugged outputs (the same set SoundVolumeView listed).</summary>
public static List<Endpoint> ListRenderEndpoints()
{
var result = new List<Endpoint>();
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;
}

/// <summary>Makes the output the default for every role (console, multimedia, communications).</summary>
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); }
}

/// <summary>Enables an output disabled in Sound settings.</summary>
public static void Enable(string endpointId)
{
var policy = CreatePolicyConfig();
try { Marshal.ThrowExceptionForHR(policy.SetEndpointVisibility(endpointId, 1)); }
finally { Release(policy); }
}

/// <summary>Master volume from 0 to 100.</summary>
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<T>(string endpointId, Func<IAudioEndpointVolume, T> 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;
}

/// <summary>Just enough of PROPVARIANT to read strings (24 bytes on 64-bit, 16 on 32-bit).</summary>
[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();
}

/// <summary>
/// Only the last two methods are called; the ones before them hold their place in the vtable,
/// so their parameters don't matter.
/// </summary>
[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);
}
}
24 changes: 24 additions & 0 deletions src/ConsoleMode/Services/AudioNaming.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
namespace ConsoleMode.Services;

/// <summary>
/// Names and IDs of audio outputs, shared by the SoundVolumeView and the native (Core Audio)
/// backends so the saved setting (<c>AudioDeviceId</c>) and the restore backup work with either.
/// Pure, so it's tested.
/// </summary>
public static class AudioNaming
{
/// <summary>
/// SoundVolumeView's "Command-Line Friendly ID" for an output:
/// <c>{adapter}\Device\{endpoint}\Render</c>, e.g. <c>Realtek(R) Audio\Device\Speakers\Render</c>.
/// </summary>
public static string FriendlyId(string adapterName, string endpointName) =>
$"{adapterName}\\Device\\{endpointName}\\Render";

/// <summary>"Speakers (Realtek(R) Audio)"; just one of them when the other is empty or the same.</summary>
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;
}
}
Loading
Loading