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
16 changes: 16 additions & 0 deletions docs/architecture/system-audio-capture.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,19 @@ To measure a real recording: `dotnet run --project src/PrimeDictate.Tools.Channe
(left = microphone, right = system audio). It correlates the two channels' loudness and prints the lag;
it needs the microphone to have heard the speakers (speakers, not headphones). Below correlation 0.3 the
number is meaningless.

## Self-contained skew probe

`dotnet run --project src/PrimeDictate.Tools.ChannelSkew -- --probe [--save out.wav]` opens the same combined
lease as the app, plays a known 3.2 s noise-burst train (low level, 0.8 s fade-in) through the default output, and finds the train in each
channel with a matched filter (`ReferenceLocator`). It prints the lag of the microphone behind system audio, plus
correlation and peak/sidelobe for each side, and says RESULT: unusable when either side did not hear the train.
The matched filter finds a faint copy in room noise, so it works where envelope correlation of a recording did not.
The lag includes the speaker path (output device latency, ~3 ms/m of air). Linux check with a null sink whose
suspend-on-idle module is unloaded: +14 ms (the miniaudio mic's known ~20 ms). Not yet run on Windows.

The probe refuses to play when the default output looks like headphones or a headset (Windows endpoint form
factor, or a name such as headset/Arctis/AirPods/Bluetooth; Linux `device.form_factor` or name) and tells the
user to switch to speakers; `--allow-headphones` overrides. First Windows runs, both on a wireless headset:
loopback found the train ~75 ms after play(), system stream opened 41 to 65 ms after the mic, no padding; the
headset mic heard its own earcup leak, giving +149 ms, which is not a capture offset, so no fixed correction is applied.
146 changes: 146 additions & 0 deletions src/PrimeDictate.Core/Audio/ReferenceLocator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
using System.Numerics;

namespace PrimeDictate.Core.Audio;

/// <summary>Where a known signal was found in a recording.</summary>
/// <param name="SampleIndex">Recording sample at which the reference starts.</param>
/// <param name="Correlation">Normalized correlation at that position, 0 to 1.</param>
/// <param name="PeakToSidelobe">Peak divided by the median correlation elsewhere. Above about 6 the match is unambiguous.</param>
public sealed record ReferenceMatch(long SampleIndex, double Correlation, double PeakToSidelobe)
{
public bool IsReliable => this.Correlation >= 0.05 && this.PeakToSidelobe >= 6;
}

/// <summary>
/// Finds a known test signal in a recording with a matched filter (FFT cross-correlation). Unlike
/// <see cref="ChannelSkew"/> it needs no clean echo: a faint copy of a noise train is still found.
/// </summary>
public static class ReferenceLocator
{
public static ReferenceMatch Locate(ReadOnlySpan<float> recording, ReadOnlySpan<float> reference)
{
if (reference.Length == 0 || recording.Length < reference.Length)
{
return new ReferenceMatch(0, 0, 0);
}

var size = 1;
while (size < recording.Length + reference.Length)
{
size <<= 1;
}

var a = new Complex[size];
var b = new Complex[size];
for (var i = 0; i < recording.Length; i++)
{
a[i] = recording[i];
}

double refEnergy = 0;
for (var i = 0; i < reference.Length; i++)
{
b[i] = reference[i];
refEnergy += reference[i] * (double)reference[i];
}

Fft(a, false);
Fft(b, false);
for (var i = 0; i < size; i++)
{
a[i] *= Complex.Conjugate(b[i]);
}

Fft(a, true);

var prefix = new double[recording.Length + 1];
for (var i = 0; i < recording.Length; i++)
{
prefix[i + 1] = prefix[i] + (recording[i] * (double)recording[i]);
}

var positions = recording.Length - reference.Length + 1;
var ncc = new double[positions];
var best = 0;
for (var k = 0; k < positions; k++)
{
var energy = prefix[k + reference.Length] - prefix[k];
var norm = Math.Sqrt(energy * refEnergy);
ncc[k] = norm > 1e-12 ? Math.Abs(a[k].Real) / norm : 0;
if (ncc[k] > ncc[best])
{
best = k;
}
}

var sorted = (double[])ncc.Clone();
Array.Sort(sorted);
var median = sorted[sorted.Length / 2];
var psr = Math.Min(999, ncc[best] / Math.Max(median, 1e-3));
return new ReferenceMatch(best, ncc[best], psr);
}

/// <summary>A repeatable noise-burst train: <paramref name="bursts"/> bursts of white noise with silence between.</summary>
public static float[] NoiseBurstTrain(int sampleRate, int bursts = 8, int burstMs = 60, int gapMs = 340, int seed = 12345)
{
var burst = sampleRate * burstMs / 1000;
var gap = sampleRate * gapMs / 1000;
var rng = new Random(seed);
var x = new float[bursts * (burst + gap)];
for (var n = 0; n < bursts; n++)
{
for (var i = 0; i < burst; i++)
{
var fade = Math.Min(1f, Math.Min(i, burst - 1 - i) / (sampleRate * 0.003f)); // 3 ms edges avoid a pop
x[(n * (burst + gap)) + i] = ((float)(rng.NextDouble() * 2 - 1)) * fade;
}
}

return x;
}

private static void Fft(Complex[] x, bool inverse)
{
var n = x.Length;
for (int i = 1, j = 0; i < n; i++)
{
var bit = n >> 1;
for (; (j & bit) != 0; bit >>= 1)
{
j ^= bit;
}

j ^= bit;
if (i < j)
{
(x[i], x[j]) = (x[j], x[i]);
}
}

for (var len = 2; len <= n; len <<= 1)
{
var angle = 2 * Math.PI / len * (inverse ? 1 : -1);
var wl = new Complex(Math.Cos(angle), Math.Sin(angle));
for (var i = 0; i < n; i += len)
{
var w = Complex.One;
for (var k = 0; k < len / 2; k++)
{
var u = x[i + k];
var v = x[i + k + (len / 2)] * w;
x[i + k] = u + v;
x[i + k + (len / 2)] = u - v;
w *= wl;
}
}
}

if (inverse)
{
for (var i = 0; i < n; i++)
{
x[i] /= n;
}
}
}
}
78 changes: 78 additions & 0 deletions src/PrimeDictate.Tools.ChannelSkew/OutputGuard.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
using System.Diagnostics;
using System.Text.RegularExpressions;

/// <summary>Detects a default output that is a headset or headphones, where a test signal would go straight into someone's ears.</summary>
internal static partial class OutputGuard
{
// Windows PKEY_AudioEndpoint_FormFactor: 1 speakers, 2 line level, 3 headphones, 5 headset, 7 hands-free/handset.
private static readonly int[] HeadFormFactors = [3, 5, 6, 7];

public static bool LooksLikeHeadphones(string? name, int? windowsFormFactor, string? pulseFormFactor)
{
if (windowsFormFactor is { } f && HeadFormFactors.Contains(f))
{
return true;
}

if (pulseFormFactor is "headphone" or "headset" or "handsfree" or "hands-free" or "hifi" or "portable")
{
return true;
}

return name is not null && HeadNames().IsMatch(name);
}

/// <summary>Returns a description of the default output when it looks like headphones, otherwise null.</summary>
public static string? DetectHeadphones()
{
try
{
if (OperatingSystem.IsWindows())
{
using var enumerator = new NAudio.CoreAudioApi.MMDeviceEnumerator();
using var device = enumerator.GetDefaultAudioEndpoint(NAudio.CoreAudioApi.DataFlow.Render, NAudio.CoreAudioApi.Role.Multimedia);
int? form = null;
var key = new NAudio.CoreAudioApi.PropertyKey(new Guid("1da5d803-d492-4edd-8c23-e0c0ffee7f0e"), 0);
if (device.Properties.Contains(key) && device.Properties[key].Value is uint u)
{
form = (int)u;
}

return LooksLikeHeadphones(device.FriendlyName, form, null) ? $"{device.FriendlyName} (form factor {form?.ToString() ?? "unknown"})" : null;
}

if (OperatingSystem.IsLinux())
{
var name = Run("pactl", "get-default-sink")?.Trim();
var info = Run("pactl", "list sinks") ?? string.Empty;
var block = info.Split("Sink #", StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(b => name is not null && b.Contains($"Name: {name}"));
var form = block is null ? null : PulseForm().Match(block) is { Success: true } m ? m.Groups[1].Value : null;
var desc = block is null ? null : PulseDescription().Match(block) is { Success: true } d ? d.Groups[1].Value : null;
return LooksLikeHeadphones($"{desc} {name}", null, form) ? $"{desc ?? name} (form factor {form ?? "unknown"})" : null;
}
}
catch (Exception)
{
// Could not tell. The level is low regardless, so carry on.
}

return null;
}

private static string? Run(string file, string arguments)
{
using var p = Process.Start(new ProcessStartInfo(file, arguments) { RedirectStandardOutput = true, UseShellExecute = false, Environment = { ["LC_ALL"] = "C" } });
var text = p?.StandardOutput.ReadToEnd();
p?.WaitForExit();
return text;
}

[GeneratedRegex(@"headphone|headset|arctis|airpod|earbud|earphone|\bbuds\b|hands-?free|bluetooth|\bbt\b|wh-1000|quietcomfort|hyperx cloud|corsair void", RegexOptions.IgnoreCase)]
private static partial Regex HeadNames();

[GeneratedRegex(@"device\.form_factor = ""([^""]+)""")]
private static partial Regex PulseForm();

[GeneratedRegex(@"Description: (.+)")]
private static partial Regex PulseDescription();
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

<ItemGroup>
<ProjectReference Include="..\PrimeDictate.Core\PrimeDictate.Core.csproj" />
<ProjectReference Include="..\PrimeDictate.Platforms\PrimeDictate.Platforms.csproj" />
</ItemGroup>

</Project>
129 changes: 129 additions & 0 deletions src/PrimeDictate.Tools.ChannelSkew/Probe.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
using System.Diagnostics;
using PrimeDictate.Core.Audio;
using PrimeDictate.Core.Providers;
using PrimeDictate.Platforms.Audio;

/// <summary>
/// Self-contained skew measurement: opens the same combined lease the app uses, plays a known noise-burst
/// train through the default output, and finds that train in each channel with a matched filter.
/// </summary>
internal static class Probe
{
private const int PlayRate = 48_000;

/// <summary>Peak level of the test signal. Deliberately low: it is noise, and it may be next to someone's ears.</summary>
private const float PlayLevel = 0.15f;
private const int FadeInMs = 800;

public static async Task<int> RunAsync(string? saveWavPath, bool allowHeadphones)
{
var headphones = OutputGuard.DetectHeadphones();
if (headphones is not null && !allowHeadphones)
{
Console.Error.WriteLine($"Refusing to play: the default output looks like headphones or a headset: {headphones}.");
Console.Error.WriteLine("The mic cannot hear those, and the noise goes into your ears. Switch the Windows default output to speakers and rerun. (--allow-headphones overrides; the level is low and fades in.)");
return 3;
}

var system = SystemAudioSources.TryCreate(out var reason);
if (system is null)
{
Console.Error.WriteLine($"System audio capture unavailable: {reason}");
return 2;
}

MiniAudioCaptureSource mic;
try
{
mic = new MiniAudioCaptureSource();
}
catch (AudioSourceException ex)
{
Console.Error.WriteLine($"Microphone unavailable: {ex.Message}");
return 2;
}

var reference = ReferenceLocator.NoiseBurstTrain(16_000);
for (var i = 0; i < reference.Length; i++)
{
reference[i] *= PlayLevel * Math.Min(1f, i / (16_000f * FadeInMs / 1000)); // the exact signal that is played, so the matched filter still fits
}

var trainSeconds = reference.Length / 16_000.0;
var left = new List<float>();
var right = new List<float>();
var lease = await new CombinedAudioSource(mic, system).OpenAsync(null, default);
await using var _ = lease;
Console.WriteLine($"Microphone: {lease.DeviceName}");
Console.WriteLine($"Opened combined lease at {lease.Format.SampleRate} Hz, {lease.Format.Channels} ch. Playing a {trainSeconds:0.0} s noise train through the default output; at a low level with a fade-in. Keep the mic within about a metre of the speakers.");

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1.5 + trainSeconds + 2.5));
var clock = Stopwatch.StartNew();
long playCallSample = -1;
var playTask = Task.Run(async () =>
{
await Task.Delay(1500);
playCallSample = left.Count;
await TonePlayer.PlayAsync(reference, 16_000, PlayRate);
});

try
{
await foreach (var frame in lease.ReadFramesAsync(cts.Token))
{
var s = frame.Samples.Span;
for (var i = 0; i + 1 < s.Length; i += 2)
{
left.Add(s[i]);
right.Add(s[i + 1]);
}
}
}
catch (OperationCanceledException)
{
}

await playTask;
var lc = lease as ICombinedAudioCaptureLease;
var micArr = left.ToArray();
var sysArr = right.ToArray();
Console.WriteLine($"Captured {micArr.Length / 16_000.0:0.0} s. Padded silence: mic {lc?.MicrophonePaddedSamples ?? 0} samples, system {lc?.SystemAudioPaddedSamples ?? 0} samples. System opened {lc?.SystemOpenedAfterMicrophone.TotalMilliseconds:0} ms after mic.");
if (saveWavPath is not null)
{
using var writer = new WavFileWriter(saveWavPath, 16_000, 2);
var inter = new float[micArr.Length * 2];
for (var i = 0; i < micArr.Length; i++)
{
inter[2 * i] = micArr[i];
inter[(2 * i) + 1] = sysArr[i];
}

writer.Write(inter);
Console.WriteLine($"Saved {saveWavPath}");
}

var micMatch = ReferenceLocator.Locate(micArr, reference);
var sysMatch = ReferenceLocator.Locate(sysArr, reference);
Report("system (loopback)", sysMatch, playCallSample);
Report("microphone ", micMatch, playCallSample);
if (!sysMatch.IsReliable)
{
Console.WriteLine("RESULT: unusable. The loopback did not capture the test train; check that the default output device is the one playing sound.");
return 1;
}

if (!micMatch.IsReliable)
{
Console.WriteLine("RESULT: unusable. The microphone did not hear the test train. Turn the speakers up, move the mic closer, or use a cable from output to line-in.");
return 1;
}

var lagMs = (micMatch.SampleIndex - sysMatch.SampleIndex) * 1000.0 / 16_000;
Console.WriteLine($"RESULT: microphone is {lagMs:+0.0;-0.0;0.0} ms after system audio (positive = mic later).");
Console.WriteLine("This includes the speaker path: output device latency (Bluetooth can add 100 to 300 ms) plus ~3 ms per metre of air. Wired speakers and a mic within a metre should show under about 60 ms if capture is aligned.");
return 0;
}

private static void Report(string name, ReferenceMatch m, long playCallSample) =>
Console.WriteLine($" {name}: train found at {m.SampleIndex / 16.0:0} ms (play was called at {playCallSample / 16.0:0} ms), correlation {m.Correlation:0.00}, peak/sidelobe {m.PeakToSidelobe:0.0}{(m.IsReliable ? "" : " <- not reliable")}");
}
Loading
Loading