517 lines
18 KiB
C#
517 lines
18 KiB
C#
using System.Runtime.InteropServices;
|
|
|
|
namespace Mimir.WasapiLoopback;
|
|
|
|
internal static class Program
|
|
{
|
|
private const int ERender = 0;
|
|
private const int EConsole = 0;
|
|
private const int EMultimedia = 1;
|
|
private const int ECommunications = 2;
|
|
private const int ClsctxAll = 23;
|
|
private const uint AudclntSharemodeShared = 0;
|
|
private const uint AudclntStreamflagsLoopback = 0x00020000;
|
|
private const int AudclntBufferflagsSilent = 0x2;
|
|
private static readonly Guid IidAudioClient = new("1CB9AD4C-DBFA-4c32-B178-C2F568A703B2");
|
|
private static readonly Guid IidAudioCaptureClient = new("C8ADBD64-E71E-48a0-A4DE-185C395CD317");
|
|
private static readonly Guid PcmSubformat = new("00000001-0000-0010-8000-00aa00389b71");
|
|
private static readonly Guid FloatSubformat = new("00000003-0000-0010-8000-00aa00389b71");
|
|
|
|
public static int Main(string[] args)
|
|
{
|
|
try
|
|
{
|
|
var options = Options.Parse(args);
|
|
Capture(options);
|
|
return 0;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.Error.WriteLine(ex);
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
private static void Capture(Options options)
|
|
{
|
|
var outputIsStdout = options.OutputPath is "-" or "stdout";
|
|
Stream output = outputIsStdout
|
|
? Console.OpenStandardOutput()
|
|
: new FileStream(options.OutputPath, FileMode.Create, FileAccess.Write, FileShare.Read);
|
|
|
|
using var outputLifetime = outputIsStdout ? null : output;
|
|
IMMDeviceEnumerator enumerator = (IMMDeviceEnumerator)(object)new MMDeviceEnumerator();
|
|
Check(enumerator.GetDefaultAudioEndpoint(ERender, RoleFromName(options.Role), out var device), "GetDefaultAudioEndpoint");
|
|
|
|
var audioClientId = IidAudioClient;
|
|
Check(device.Activate(ref audioClientId, ClsctxAll, IntPtr.Zero, out var audioClient), "Activate IAudioClient");
|
|
Check(audioClient.GetMixFormat(out var mixFormatPtr), "GetMixFormat");
|
|
|
|
var activeFormatPtr = mixFormatPtr;
|
|
var fmt = Marshal.PtrToStructure<WaveFormatEx>(activeFormatPtr);
|
|
var sampleFormat = SampleFormat.From(activeFormatPtr);
|
|
LogFormat("MixFormat", fmt, sampleFormat);
|
|
|
|
var init = audioClient.Initialize(AudclntSharemodeShared, AudclntStreamflagsLoopback, 0, 0, activeFormatPtr, IntPtr.Zero);
|
|
Check(init, "Initialize loopback");
|
|
|
|
var captureClientId = IidAudioCaptureClient;
|
|
Check(audioClient.GetService(ref captureClientId, out var captureClient), "GetService IAudioCaptureClient");
|
|
Check(audioClient.Start(), "Start");
|
|
|
|
try
|
|
{
|
|
Pump(options, output, captureClient, fmt, sampleFormat);
|
|
}
|
|
finally
|
|
{
|
|
audioClient.Stop();
|
|
Marshal.FreeCoTaskMem(mixFormatPtr);
|
|
Marshal.ReleaseComObject(captureClient);
|
|
Marshal.ReleaseComObject(audioClient);
|
|
Marshal.ReleaseComObject(device);
|
|
Marshal.ReleaseComObject(enumerator);
|
|
}
|
|
}
|
|
|
|
private static void Pump(
|
|
Options options,
|
|
Stream output,
|
|
IAudioCaptureClient captureClient,
|
|
WaveFormatEx fmt,
|
|
SampleFormat sampleFormat)
|
|
{
|
|
var targetChannels = Math.Max(1, options.Channels);
|
|
var sourceChannels = Math.Max(1, (int)fmt.Channels);
|
|
var sourceRate = Math.Max(1, (int)fmt.SamplesPerSec);
|
|
var sourceFramesPerTargetFrame = sourceRate / (double)Math.Max(1, options.SampleRate);
|
|
var sourceBytesPerFrame = fmt.BlockAlign;
|
|
var sourceBytesPerSample = Math.Max(1, sampleFormat.ContainerBits / 8);
|
|
var frameBytes = targetChannels * sizeof(float);
|
|
var end = options.Seconds > 0 ? DateTime.UtcNow.AddSeconds(options.Seconds) : DateTime.MaxValue;
|
|
var pcm = new byte[8192];
|
|
var previousFrame = new float[targetChannels];
|
|
var left = new float[targetChannels];
|
|
var right = new float[targetChannels];
|
|
var hasPreviousFrame = false;
|
|
var sourceFramesSeen = 0L;
|
|
var nextOutputSourceFrame = 0.0;
|
|
var nextMeter = DateTime.UtcNow.AddSeconds(1);
|
|
var samplesSeen = 0L;
|
|
var peak = 0f;
|
|
var clipped = 0L;
|
|
|
|
while (DateTime.UtcNow < end)
|
|
{
|
|
Check(captureClient.GetNextPacketSize(out var packetFrames), "GetNextPacketSize");
|
|
if (packetFrames == 0)
|
|
{
|
|
Thread.Sleep(2);
|
|
continue;
|
|
}
|
|
|
|
Check(captureClient.GetBuffer(out var data, out var frames, out var flags, out _, out _), "GetBuffer");
|
|
try
|
|
{
|
|
var packetStart = sourceFramesSeen;
|
|
var packetEnd = packetStart + frames;
|
|
var outputFrames = 0;
|
|
var cursor = nextOutputSourceFrame;
|
|
while (cursor < packetEnd)
|
|
{
|
|
outputFrames++;
|
|
cursor += sourceFramesPerTargetFrame;
|
|
}
|
|
|
|
var needed = checked(outputFrames * frameBytes);
|
|
if (pcm.Length < needed)
|
|
{
|
|
pcm = new byte[needed];
|
|
}
|
|
|
|
var dst = 0;
|
|
while (nextOutputSourceFrame < packetEnd)
|
|
{
|
|
var leftAbsolute = (long)Math.Floor(nextOutputSourceFrame);
|
|
var rightAbsolute = Math.Min(packetEnd - 1, leftAbsolute + 1);
|
|
var fraction = nextOutputSourceFrame - leftAbsolute;
|
|
ReadFrame(leftAbsolute, packetStart, data, flags, sourceChannels, sourceBytesPerFrame, sourceBytesPerSample, sampleFormat, previousFrame, hasPreviousFrame, left);
|
|
ReadFrame(rightAbsolute, packetStart, data, flags, sourceChannels, sourceBytesPerFrame, sourceBytesPerSample, sampleFormat, previousFrame, hasPreviousFrame, right);
|
|
|
|
for (var ch = 0; ch < targetChannels; ch++)
|
|
{
|
|
var sample = (float)(left[ch] + (right[ch] - left[ch]) * fraction);
|
|
var abs = Math.Abs(sample);
|
|
peak = Math.Max(peak, abs);
|
|
if (abs > 1f)
|
|
{
|
|
clipped++;
|
|
}
|
|
|
|
sample = Math.Clamp(sample, -1f, 1f);
|
|
BitConverter.TryWriteBytes(pcm.AsSpan(dst, sizeof(float)), sample);
|
|
dst += sizeof(float);
|
|
samplesSeen++;
|
|
}
|
|
|
|
nextOutputSourceFrame += sourceFramesPerTargetFrame;
|
|
}
|
|
|
|
if (frames > 0)
|
|
{
|
|
ReadFrame(packetEnd - 1, packetStart, data, flags, sourceChannels, sourceBytesPerFrame, sourceBytesPerSample, sampleFormat, previousFrame, false, previousFrame);
|
|
hasPreviousFrame = true;
|
|
}
|
|
|
|
output.Write(pcm, 0, needed);
|
|
output.Flush();
|
|
sourceFramesSeen = packetEnd;
|
|
|
|
if (DateTime.UtcNow >= nextMeter)
|
|
{
|
|
Console.Error.WriteLine("LoopbackMeter samples={0} peak={1:0.000000} clipCandidates={2}", samplesSeen, peak, clipped);
|
|
nextMeter = DateTime.UtcNow.AddSeconds(1);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
Check(captureClient.ReleaseBuffer(frames), "ReleaseBuffer");
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void ReadFrame(
|
|
long absoluteFrame,
|
|
long packetStart,
|
|
IntPtr data,
|
|
uint flags,
|
|
int sourceChannels,
|
|
int sourceBytesPerFrame,
|
|
int sourceBytesPerSample,
|
|
SampleFormat format,
|
|
float[] previousFrame,
|
|
bool hasPreviousFrame,
|
|
float[] target)
|
|
{
|
|
if (absoluteFrame < packetStart)
|
|
{
|
|
for (var ch = 0; ch < target.Length; ch++)
|
|
{
|
|
target[ch] = hasPreviousFrame ? previousFrame[ch] : 0f;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
var packetFrame = checked((int)(absoluteFrame - packetStart));
|
|
for (var ch = 0; ch < target.Length; ch++)
|
|
{
|
|
var sample = 0f;
|
|
if ((flags & AudclntBufferflagsSilent) == 0)
|
|
{
|
|
var srcCh = Math.Min(ch, sourceChannels - 1);
|
|
var src = IntPtr.Add(data, packetFrame * sourceBytesPerFrame + srcCh * sourceBytesPerSample);
|
|
sample = ReadSample(src, format);
|
|
}
|
|
|
|
target[ch] = sample;
|
|
}
|
|
}
|
|
|
|
private static float ReadSample(IntPtr source, SampleFormat format)
|
|
{
|
|
if (format.Kind == SampleKind.IeeeFloat && format.ContainerBits == 32)
|
|
{
|
|
return Marshal.PtrToStructure<float>(source);
|
|
}
|
|
|
|
if (format.Kind != SampleKind.Pcm)
|
|
{
|
|
return 0f;
|
|
}
|
|
|
|
return format.ContainerBits switch
|
|
{
|
|
16 => Marshal.ReadInt16(source) / 32768f,
|
|
24 => ReadPacked24(source) / 8388608f,
|
|
32 => ReadPcm32(source, format.ValidBits),
|
|
_ => 0f
|
|
};
|
|
}
|
|
|
|
private static float ReadPcm32(IntPtr source, int validBits)
|
|
{
|
|
var value = Marshal.ReadInt32(source);
|
|
if (validBits is > 0 and < 32)
|
|
{
|
|
var shifted = value >> (32 - validBits);
|
|
return Math.Clamp(shifted / (float)(1 << (validBits - 1)), -1f, 1f);
|
|
}
|
|
|
|
return Math.Clamp(value / 2147483648f, -1f, 1f);
|
|
}
|
|
|
|
private static int ReadPacked24(IntPtr source)
|
|
{
|
|
var b0 = Marshal.ReadByte(source, 0);
|
|
var b1 = Marshal.ReadByte(source, 1);
|
|
var b2 = Marshal.ReadByte(source, 2);
|
|
var value = b0 | (b1 << 8) | (b2 << 16);
|
|
if ((value & 0x800000) != 0)
|
|
{
|
|
value |= unchecked((int)0xff000000);
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
private static void LogFormat(string label, WaveFormatEx fmt, SampleFormat sampleFormat)
|
|
{
|
|
Console.Error.WriteLine(
|
|
"{0} tag={1} channels={2} rate={3} bits={4} blockAlign={5} cbSize={6} kind={7} validBits={8} subFormat={9}",
|
|
label,
|
|
fmt.FormatTag,
|
|
fmt.Channels,
|
|
fmt.SamplesPerSec,
|
|
fmt.BitsPerSample,
|
|
fmt.BlockAlign,
|
|
fmt.Size,
|
|
sampleFormat.Kind,
|
|
sampleFormat.ValidBits,
|
|
sampleFormat.SubFormat);
|
|
}
|
|
|
|
private static void Check(int hr, string stage)
|
|
{
|
|
if (hr >= 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var inner = Marshal.GetExceptionForHR(hr);
|
|
throw new InvalidOperationException(stage + " failed: 0x" + hr.ToString("X8") + " " + inner?.Message, inner);
|
|
}
|
|
|
|
private static int RoleFromName(string roleName)
|
|
{
|
|
return roleName.ToLowerInvariant() switch
|
|
{
|
|
"multimedia" => EMultimedia,
|
|
"communications" => ECommunications,
|
|
_ => EConsole
|
|
};
|
|
}
|
|
|
|
private sealed record Options(string OutputPath, double Seconds, int SampleRate, int Channels, string Role)
|
|
{
|
|
public static Options Parse(string[] args)
|
|
{
|
|
var output = "stdout";
|
|
var seconds = 0.0;
|
|
var sampleRate = 48000;
|
|
var channels = 2;
|
|
var role = "Console";
|
|
|
|
for (var i = 0; i < args.Length; i++)
|
|
{
|
|
switch (args[i])
|
|
{
|
|
case "--output":
|
|
output = ReadValue(args, ref i);
|
|
break;
|
|
case "--seconds":
|
|
seconds = double.Parse(ReadValue(args, ref i), System.Globalization.CultureInfo.InvariantCulture);
|
|
break;
|
|
case "--sample-rate":
|
|
sampleRate = int.Parse(ReadValue(args, ref i), System.Globalization.CultureInfo.InvariantCulture);
|
|
break;
|
|
case "--channels":
|
|
channels = int.Parse(ReadValue(args, ref i), System.Globalization.CultureInfo.InvariantCulture);
|
|
break;
|
|
case "--role":
|
|
role = ReadValue(args, ref i);
|
|
break;
|
|
case "--help":
|
|
case "-h":
|
|
Console.Error.WriteLine("Usage: Mimir.WasapiLoopback --output stdout --sample-rate 48000 --channels 2 [--seconds 10] [--role Console]");
|
|
Environment.Exit(0);
|
|
break;
|
|
default:
|
|
throw new ArgumentException("Unknown argument: " + args[i]);
|
|
}
|
|
}
|
|
|
|
return new Options(output, seconds, sampleRate, channels, role);
|
|
}
|
|
|
|
private static string ReadValue(string[] args, ref int index)
|
|
{
|
|
if (index + 1 >= args.Length)
|
|
{
|
|
throw new ArgumentException("Missing value for " + args[index]);
|
|
}
|
|
|
|
index++;
|
|
return args[index];
|
|
}
|
|
}
|
|
|
|
private readonly struct SampleFormat
|
|
{
|
|
public SampleFormat(SampleKind kind, int containerBits, int validBits, Guid subFormat)
|
|
{
|
|
Kind = kind;
|
|
ContainerBits = containerBits;
|
|
ValidBits = validBits;
|
|
SubFormat = subFormat;
|
|
}
|
|
|
|
public SampleKind Kind { get; }
|
|
public int ContainerBits { get; }
|
|
public int ValidBits { get; }
|
|
public Guid SubFormat { get; }
|
|
|
|
public static SampleFormat From(IntPtr formatPtr)
|
|
{
|
|
var fmt = Marshal.PtrToStructure<WaveFormatEx>(formatPtr);
|
|
var kind = fmt.FormatTag switch
|
|
{
|
|
1 => SampleKind.Pcm,
|
|
3 => SampleKind.IeeeFloat,
|
|
_ => SampleKind.Unknown
|
|
};
|
|
var validBits = fmt.BitsPerSample;
|
|
var subFormat = Guid.Empty;
|
|
|
|
if (fmt.FormatTag == 65534 && fmt.Size >= 22)
|
|
{
|
|
var ext = Marshal.PtrToStructure<WaveFormatExtensible>(formatPtr);
|
|
subFormat = ext.SubFormat;
|
|
kind = subFormat == FloatSubformat
|
|
? SampleKind.IeeeFloat
|
|
: subFormat == PcmSubformat
|
|
? SampleKind.Pcm
|
|
: SampleKind.Unknown;
|
|
|
|
if (ext.ValidBitsPerSample > 0 && ext.ValidBitsPerSample <= fmt.BitsPerSample)
|
|
{
|
|
validBits = ext.ValidBitsPerSample;
|
|
}
|
|
}
|
|
|
|
return new SampleFormat(kind, fmt.BitsPerSample, validBits, subFormat);
|
|
}
|
|
}
|
|
|
|
private enum SampleKind
|
|
{
|
|
Unknown,
|
|
Pcm,
|
|
IeeeFloat
|
|
}
|
|
|
|
[StructLayout(LayoutKind.Sequential, Pack = 2)]
|
|
private struct WaveFormatEx
|
|
{
|
|
public ushort FormatTag;
|
|
public ushort Channels;
|
|
public uint SamplesPerSec;
|
|
public uint AvgBytesPerSec;
|
|
public ushort BlockAlign;
|
|
public ushort BitsPerSample;
|
|
public ushort Size;
|
|
}
|
|
|
|
[StructLayout(LayoutKind.Sequential, Pack = 2)]
|
|
private struct WaveFormatExtensible
|
|
{
|
|
public WaveFormatEx Format;
|
|
public ushort ValidBitsPerSample;
|
|
public uint ChannelMask;
|
|
public Guid SubFormat;
|
|
}
|
|
|
|
[ComImport]
|
|
[Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")]
|
|
private sealed class MMDeviceEnumerator
|
|
{
|
|
}
|
|
|
|
[ComImport]
|
|
[Guid("A95664D2-9614-4F35-A746-DE8DB63617E6")]
|
|
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
private interface IMMDeviceEnumerator
|
|
{
|
|
[PreserveSig]
|
|
int EnumAudioEndpoints(int dataFlow, uint stateMask, out IntPtr devices);
|
|
|
|
[PreserveSig]
|
|
int GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice endpoint);
|
|
}
|
|
|
|
[ComImport]
|
|
[Guid("D666063F-1587-4E43-81F1-B948E807363F")]
|
|
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
private interface IMMDevice
|
|
{
|
|
[PreserveSig]
|
|
int Activate(ref Guid iid, int dwClsCtx, IntPtr pActivationParams, out IAudioClient audioClient);
|
|
}
|
|
|
|
[ComImport]
|
|
[Guid("1CB9AD4C-DBFA-4c32-B178-C2F568A703B2")]
|
|
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
private interface IAudioClient
|
|
{
|
|
[PreserveSig]
|
|
int Initialize(uint shareMode, uint streamFlags, long hnsBufferDuration, long hnsPeriodicity, IntPtr pFormat, IntPtr audioSessionGuid);
|
|
|
|
[PreserveSig]
|
|
int GetBufferSize(out uint bufferSize);
|
|
|
|
[PreserveSig]
|
|
int GetStreamLatency(out long latency);
|
|
|
|
[PreserveSig]
|
|
int GetCurrentPadding(out uint currentPadding);
|
|
|
|
[PreserveSig]
|
|
int IsFormatSupported(uint shareMode, IntPtr pFormat, out IntPtr closestMatch);
|
|
|
|
[PreserveSig]
|
|
int GetMixFormat(out IntPtr deviceFormat);
|
|
|
|
[PreserveSig]
|
|
int GetDevicePeriod(out long defaultDevicePeriod, out long minimumDevicePeriod);
|
|
|
|
[PreserveSig]
|
|
int Start();
|
|
|
|
[PreserveSig]
|
|
int Stop();
|
|
|
|
[PreserveSig]
|
|
int Reset();
|
|
|
|
[PreserveSig]
|
|
int SetEventHandle(IntPtr eventHandle);
|
|
|
|
[PreserveSig]
|
|
int GetService(ref Guid iid, out IAudioCaptureClient captureClient);
|
|
}
|
|
|
|
[ComImport]
|
|
[Guid("C8ADBD64-E71E-48a0-A4DE-185C395CD317")]
|
|
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
private interface IAudioCaptureClient
|
|
{
|
|
[PreserveSig]
|
|
int GetBuffer(out IntPtr data, out uint frames, out uint flags, out ulong devicePosition, out ulong qpcPosition);
|
|
|
|
[PreserveSig]
|
|
int ReleaseBuffer(uint frames);
|
|
|
|
[PreserveSig]
|
|
int GetNextPacketSize(out uint frames);
|
|
}
|
|
}
|