Files
AudioMixer/host/Ausiomixer.Host/Program.cs
T
2026-09-07 16:53:18 +03:00

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;
}
}
}