Add compiled Raven loopback capture helper
This commit is contained in:
parent
6658acaee3
commit
7f0e2de8be
6 changed files with 556 additions and 7 deletions
517
src/Mimir.WasapiLoopback/Program.cs
Normal file
517
src/Mimir.WasapiLoopback/Program.cs
Normal file
|
|
@ -0,0 +1,517 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue