426 lines
11 KiB
C#
426 lines
11 KiB
C#
using System.Diagnostics;
|
|
using System.Text;
|
|
using HidSharp;
|
|
using NAudio.CoreAudioApi;
|
|
|
|
namespace Ausiomixer.Host;
|
|
|
|
internal static class Protocol
|
|
{
|
|
public const int Vid = 0x2E8A;
|
|
public const int Pid = 0xA541;
|
|
|
|
public const byte ReportIdInput = 1;
|
|
public const byte ReportIdOutput = 2;
|
|
|
|
public const int InputSize = 8;
|
|
public const int OutputSize = 64;
|
|
|
|
public const byte FlagE1Sw = 0x01;
|
|
public const byte FlagE2Sw = 0x02;
|
|
|
|
public const int NameChars = 12;
|
|
public const int SlotSize = 1 + 1 + NameChars; // vol + peak + name
|
|
public const int MaxVisible = 4;
|
|
public const int HdrSize = 7; // id + count + sel + master + page + nVis + masterPeak
|
|
}
|
|
|
|
internal sealed class SessionInfo
|
|
{
|
|
public required string Name { get; init; }
|
|
public required AudioSessionControl Control { get; init; }
|
|
public float Volume
|
|
{
|
|
get => Control.SimpleAudioVolume.Volume;
|
|
set => Control.SimpleAudioVolume.Volume = Math.Clamp(value, 0f, 1f);
|
|
}
|
|
|
|
public float Peak
|
|
{
|
|
get
|
|
{
|
|
try
|
|
{
|
|
return Control.AudioMeterInformation.MasterPeakValue;
|
|
}
|
|
catch
|
|
{
|
|
return 0f;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
internal static class Program
|
|
{
|
|
private static int _selected;
|
|
private static HidStream? _stream;
|
|
private static readonly object Gate = new();
|
|
|
|
public static async Task Main()
|
|
{
|
|
Console.WriteLine("Ausiomixer Host PoC");
|
|
Console.WriteLine($"Looking for HID VID=0x{Protocol.Vid:X4} PID=0x{Protocol.Pid:X4} ...");
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
Console.CancelKeyPress += (_, e) =>
|
|
{
|
|
e.Cancel = true;
|
|
cts.Cancel();
|
|
};
|
|
|
|
while (!cts.IsCancellationRequested)
|
|
{
|
|
try
|
|
{
|
|
if (_stream == null)
|
|
{
|
|
_stream = OpenDevice();
|
|
if (_stream == null)
|
|
{
|
|
Console.WriteLine("Device not found, retry in 2s...");
|
|
await Task.Delay(2000, cts.Token);
|
|
continue;
|
|
}
|
|
Console.WriteLine("Device opened.");
|
|
}
|
|
|
|
await RunLoop(cts.Token);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
break;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine($"Error: {ex.Message}");
|
|
CloseDevice();
|
|
await Task.Delay(1500, CancellationToken.None);
|
|
}
|
|
}
|
|
|
|
CloseDevice();
|
|
Console.WriteLine("Bye.");
|
|
}
|
|
|
|
private static HidStream? OpenDevice()
|
|
{
|
|
var list = DeviceList.Local;
|
|
foreach (var dev in list.GetHidDevices(Protocol.Vid, Protocol.Pid))
|
|
{
|
|
try
|
|
{
|
|
// Prefer interface that can do 64-byte output (our mixer report)
|
|
if (dev.GetMaxOutputReportLength() < Protocol.OutputSize &&
|
|
dev.GetMaxFeatureReportLength() < Protocol.OutputSize)
|
|
{
|
|
// still try open if input matches
|
|
}
|
|
|
|
if (!dev.TryOpen(out var stream) || stream == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
stream.ReadTimeout = 50;
|
|
stream.WriteTimeout = 200;
|
|
Console.WriteLine($"Opened: {dev.DevicePath}");
|
|
Console.WriteLine($" MaxIn={dev.GetMaxInputReportLength()} MaxOut={dev.GetMaxOutputReportLength()}");
|
|
return stream;
|
|
}
|
|
catch
|
|
{
|
|
// try next
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private static void CloseDevice()
|
|
{
|
|
lock (Gate)
|
|
{
|
|
_stream?.Dispose();
|
|
_stream = null;
|
|
_lastOutPayload = null;
|
|
}
|
|
}
|
|
|
|
private static byte[]? _lastOutPayload;
|
|
|
|
private static async Task RunLoop(CancellationToken ct)
|
|
{
|
|
using var enumerator = new MMDeviceEnumerator();
|
|
var lastPush = DateTime.MinValue;
|
|
|
|
while (!ct.IsCancellationRequested)
|
|
{
|
|
// Read input (non-blocking-ish)
|
|
TryReadInput(enumerator);
|
|
|
|
// ~20 Hz for live VU; skip identical payloads below
|
|
if ((DateTime.UtcNow - lastPush).TotalMilliseconds >= 50)
|
|
{
|
|
PushOutput(enumerator);
|
|
lastPush = DateTime.UtcNow;
|
|
}
|
|
|
|
await Task.Delay(15, ct);
|
|
}
|
|
}
|
|
|
|
private static List<SessionInfo> CollectSessions(MMDeviceEnumerator enumerator)
|
|
{
|
|
var result = new List<SessionInfo>();
|
|
MMDevice device;
|
|
try
|
|
{
|
|
device = enumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia);
|
|
}
|
|
catch
|
|
{
|
|
return result;
|
|
}
|
|
|
|
var sessions = device.AudioSessionManager.Sessions;
|
|
for (int i = 0; i < sessions.Count; i++)
|
|
{
|
|
var s = sessions[i];
|
|
try
|
|
{
|
|
string name = s.DisplayName;
|
|
if (string.IsNullOrWhiteSpace(name))
|
|
{
|
|
uint pid = s.GetProcessID;
|
|
if (pid != 0)
|
|
{
|
|
try
|
|
{
|
|
name = Process.GetProcessById((int)pid).ProcessName;
|
|
}
|
|
catch
|
|
{
|
|
name = $"pid{pid}";
|
|
}
|
|
}
|
|
else
|
|
{
|
|
name = "System";
|
|
}
|
|
}
|
|
|
|
// Skip pure system sounds entry sometimes named empty
|
|
result.Add(new SessionInfo { Name = TruncateName(name), Control = s });
|
|
}
|
|
catch
|
|
{
|
|
// ignore inaccessible sessions
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private static string TruncateName(string name)
|
|
{
|
|
name = name.Trim();
|
|
if (name.Length <= Protocol.NameChars)
|
|
{
|
|
return name;
|
|
}
|
|
return name[..Protocol.NameChars];
|
|
}
|
|
|
|
private static void TryReadInput(MMDeviceEnumerator enumerator)
|
|
{
|
|
HidStream? stream;
|
|
lock (Gate)
|
|
{
|
|
stream = _stream;
|
|
}
|
|
if (stream == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var buf = new byte[Math.Max(Protocol.InputSize, 64)];
|
|
int n;
|
|
try
|
|
{
|
|
n = stream.Read(buf);
|
|
}
|
|
catch (TimeoutException)
|
|
{
|
|
return;
|
|
}
|
|
catch
|
|
{
|
|
CloseDevice();
|
|
throw;
|
|
}
|
|
|
|
if (n < Protocol.InputSize)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// HidSharp usually includes report id at [0]
|
|
int off = 0;
|
|
if (buf[0] == Protocol.ReportIdInput)
|
|
{
|
|
off = 0;
|
|
}
|
|
else
|
|
{
|
|
return;
|
|
}
|
|
|
|
sbyte e1 = unchecked((sbyte)buf[off + 1]);
|
|
sbyte e2 = unchecked((sbyte)buf[off + 2]);
|
|
byte flags = buf[off + 3];
|
|
byte pot = buf[off + 4];
|
|
|
|
var sessions = CollectSessions(enumerator);
|
|
if (sessions.Count == 0)
|
|
{
|
|
_selected = 0;
|
|
}
|
|
else
|
|
{
|
|
_selected = Math.Clamp(_selected + e1, 0, sessions.Count - 1);
|
|
}
|
|
|
|
if (e1 != 0 || e2 != 0 || flags != 0)
|
|
{
|
|
Console.WriteLine($"IN e1={e1} e2={e2} flags=0x{flags:X2} pot={pot} sel={_selected}");
|
|
}
|
|
|
|
if (e2 != 0 && sessions.Count > 0 && _selected >= 0 && _selected < sessions.Count)
|
|
{
|
|
try
|
|
{
|
|
var s = sessions[_selected];
|
|
float nv = s.Volume + (e2 * 0.02f);
|
|
s.Volume = nv;
|
|
Console.WriteLine($" vol[{s.Name}]={(int)(s.Volume * 100)}%");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine($" vol fail: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
if ((flags & Protocol.FlagE1Sw) != 0)
|
|
{
|
|
// optional: mute selected — for PoC just log
|
|
Console.WriteLine(" E1 SW");
|
|
}
|
|
if ((flags & Protocol.FlagE2Sw) != 0)
|
|
{
|
|
Console.WriteLine(" E2 SW");
|
|
}
|
|
|
|
// Master from pot (absolute)
|
|
try
|
|
{
|
|
var device = enumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia);
|
|
float target = pot / 100f;
|
|
float cur = device.AudioEndpointVolume.MasterVolumeLevelScalar;
|
|
if (Math.Abs(cur - target) > 0.01f)
|
|
{
|
|
device.AudioEndpointVolume.MasterVolumeLevelScalar = target;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
private static void PushOutput(MMDeviceEnumerator enumerator)
|
|
{
|
|
HidStream? stream;
|
|
lock (Gate)
|
|
{
|
|
stream = _stream;
|
|
}
|
|
if (stream == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var sessions = CollectSessions(enumerator);
|
|
if (sessions.Count > 0)
|
|
{
|
|
_selected = Math.Clamp(_selected, 0, sessions.Count - 1);
|
|
}
|
|
else
|
|
{
|
|
_selected = 0;
|
|
}
|
|
|
|
byte master = 50;
|
|
byte masterPeak = 0;
|
|
try
|
|
{
|
|
var device = enumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia);
|
|
master = (byte)Math.Clamp((int)(device.AudioEndpointVolume.MasterVolumeLevelScalar * 100), 0, 100);
|
|
masterPeak = (byte)Math.Clamp((int)(device.AudioMeterInformation.MasterPeakValue * 100), 0, 100);
|
|
}
|
|
catch
|
|
{
|
|
// keep default
|
|
}
|
|
|
|
// Window of MaxVisible around selection
|
|
int count = sessions.Count;
|
|
int pageStart = 0;
|
|
if (count > Protocol.MaxVisible)
|
|
{
|
|
pageStart = Math.Clamp(_selected - Protocol.MaxVisible / 2, 0, count - Protocol.MaxVisible);
|
|
}
|
|
int nVis = Math.Min(Protocol.MaxVisible, count);
|
|
|
|
var report = new byte[Protocol.OutputSize];
|
|
report[0] = Protocol.ReportIdOutput;
|
|
report[1] = (byte)Math.Min(count, 255);
|
|
report[2] = (byte)_selected;
|
|
report[3] = master;
|
|
report[4] = (byte)pageStart;
|
|
report[5] = (byte)nVis;
|
|
report[6] = masterPeak;
|
|
|
|
int off = Protocol.HdrSize;
|
|
for (int i = 0; i < nVis; i++)
|
|
{
|
|
var s = sessions[pageStart + i];
|
|
byte vol = (byte)Math.Clamp((int)(s.Volume * 100), 0, 100);
|
|
byte peak = (byte)Math.Clamp((int)(s.Peak * 100), 0, 100);
|
|
report[off] = vol;
|
|
report[off + 1] = peak;
|
|
var nameBytes = Encoding.UTF8.GetBytes(s.Name);
|
|
int copy = Math.Min(Protocol.NameChars, nameBytes.Length);
|
|
Array.Copy(nameBytes, 0, report, off + 2, copy);
|
|
off += Protocol.SlotSize;
|
|
}
|
|
|
|
// Skip identical payloads
|
|
if (_lastOutPayload != null && _lastOutPayload.AsSpan().SequenceEqual(report))
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
stream.Write(report);
|
|
_lastOutPayload = (byte[])report.Clone();
|
|
}
|
|
catch
|
|
{
|
|
_lastOutPayload = null;
|
|
CloseDevice();
|
|
throw;
|
|
}
|
|
}
|
|
}
|