Skip to content

Measuring USB audio output latency on Windows with a loopback cable

How long does a sound take from a game to my ears through a USB DAC? I wanted a number rather than a forum claim, so this times it with a cable loop: the DAC’s output goes into an audio interface, a script plays clicks through the DAC the way most games play audio, and it timestamps when each click comes back in. My D70 comes out at 42.9-47.9 ms from the app to the DAC’s output on Microsoft’s driver and 36.6-41.4 ms on Topping’s own driver, 12 ms of either being Windows’ own queue. The recording side is part of every loop, so on its own a loop only gives an upper bound, but reading the output driver’s clock as well lets you bound the recorder’s share, which is where the ranges come from. Short version of where I ended up: Topping’s driver is 6.4 ms faster, interrupt pins and the browser’s low-latency mode change nothing here, and the only faster route would be a non-USB output, which I can’t use.

You’ll need Windows PowerShell 5.1 (no admin), an audio interface with a line or mic input, a cable from the output under test into that input, and the script below. Everything was measured on one machine on 2026-10-26.

PieceDetail
OSWindows 10 22H2 (build 19045)
DACTopping D70 Pro Sabre into a Topping A70 Pro over XLR. Measured on Microsoft’s USB Audio 2.0 class driver (usbaudio2, 10.0.19041.7802) and on Topping’s V6.16.0 driver (ToppingUsbAudioks, 6.16.0.22102, built on Thesycon’s)
Interface (the recorder)Solid State Logic SSL 2, Thesycon driver (SSLUSBDriverks, 5.72.3.28145)
Other outputMonitor audio over the GPU (NVIDIA High Definition Audio, PCIe)
USBDAC and interface through a USB-C hub on one of the chipset’s two USB controllers (1022:43F7, PCI bus 104); the mouse has its own controller
Format48 kHz, 32-bit float, WASAPI shared mode

Read with IAudioClient::GetDevicePeriod and IAudioClient3::GetSharedModeEnginePeriod1, no cable needed:

OutputEngine period defaultEngine period minimum
D70 Pro Sabre (usbaudio2)10 ms7 ms
D70 Pro Sabre (Topping driver)10 ms10 ms
SSL 2 Output 1/2 (Thesycon)10 ms10 ms
Monitor (NVIDIA HDA)10 ms10 ms

Microsoft’s low-latency audio page2 says Windows 10 apps in shared mode get 10 ms buffers by default, smaller ones only if the app asks (AudioGraph or IAudioClient3) and the driver supports them, and that the inbox HD Audio driver goes down to 128 samples, which is 2.67 ms at 48 kHz. So an onboard HD Audio codec can run smaller buffers than either USB device here, but only for apps that ask.

The script stamps the time right after it hands a buffer with a click to Windows (ReleaseBuffer), and the capture side stamps each recorded packet with the QPC time the driver reports (IAudioCaptureClient::GetBuffer, “converted to 100-nanosecond units”3). The difference covers Windows’ queue ahead of the click, the audio engine, the output driver and device, the cable, and the recording path up to that timestamp. The recording path is the same for every output measured through the same input, so it cancels out of a comparison but not out of an absolute number.

audio-loopback.ps1(WASAPI shared, event-driven)Windows audio engine(10 ms period)click + QPC stampD70 Pro Sabre(usbaudio2 or Topping)USBSSL 2 Input 1(Thesycon)XLR cableUSB, packet QPC stamp

Text version: the script writes a click into a shared-mode buffer and notes the time, Windows mixes it and sends it over USB to the DAC, the DAC’s analog output goes down a cable into the interface, and the interface sends the recording back over USB with a timestamp per packet. The script finds the click’s first sample in the recording and subtracts.

  1. Run a cable from the output you want to measure into an interface input. For my D70 that was one of its XLR outputs (the left one that normally feeds the A70) into SSL 2 Input 1. One channel is enough, the script plays the click on both.
  2. Turn the input gain near minimum and the DAC volume low (about 20 % in Windows). A line output into a mic preamp is hot, and the script only needs a clear click.
  3. A stereo headphone jack into a balanced TRS input needs care: the input records tip minus ring, so identical left and right cancel to almost nothing. Play the click on the left channel only (-LeftOnly).
  4. Check the input is live before blaming the script: pull the cable out of the output end and touch the plug’s tip with your finger, the hum should light the input’s signal LED. On my SSL 2 the jack input took the headphone signal with LINE pressed. Earlier in the day I thought it needed LINE released, but that was with the output muted and the volume at 10 %, so don’t trust that one.
  5. Check the output isn’t muted in Windows. Mine was, which hid everything else for a while.

Save as audio-loopback.ps1. It opens the output in shared mode, event-driven, at the default engine period, plays bursts of 2 kHz for 2 ms each, a quarter second apart, and prints each click’s latency and the median. It also reads the render stream’s IAudioClock, whose device position is “the sample that is currently playing through the speakers” with the QPC time it was read4, and interpolates when each click was reported playing. That splits every click into the part up to where the output driver says it’s playing and the part after.

Terminal window
# audio-loopback.ps1 - output latency as a game sees it, measured through a cable loop.
#
# .\audio-loopback.ps1 -Render 'D70' -Capture 'Input 1 (SSL 2' [-Clicks 25] [-Amplitude 0.25]
# .\audio-loopback.ps1 -List
# -MinPeriod: open the output with IAudioClient3::InitializeSharedAudioStream at the endpoint's
# minimum shared-mode period (what a browser asks for), instead of the default period games get.
# (https://learn.microsoft.com/en-us/windows/win32/api/audioclient/nf-audioclient-iaudioclient3-initializesharedaudiostream)
# -LeftOnly: burst on the left channel only. Needed when a stereo headphone jack feeds a balanced
# TRS input: identical L and R on tip and ring cancel to almost nothing (tip minus ring).
#
# Cable the output under test into the capture input. The script opens the output in WASAPI
# shared mode, event-driven, at the engine's default period (how most games play audio), plays
# short 2 kHz bursts, and timestamps when each comes back on the capture input.
#
# Latency = capture time of the burst's first sample (IAudioCaptureClient::GetBuffer QPC position,
# "converted to 100-nanosecond units", https://learn.microsoft.com/en-us/windows/win32/api/audioclient/nf-audioclient-iaudiocaptureclient-getbuffer)
# - QPC time right after the app released the buffer holding the burst.
# It includes the capture side's own delay (if its driver timestamps late), which is the same
# for every output measured through the same input: compare outputs, do not read one number as
# the output's absolute latency. Interface IDs from Wine's audioclient.idl, flags from
# https://learn.microsoft.com/en-us/windows/win32/coreaudio/audclnt-streamflags-xxx-constants
param(
[string] $Render,
[string] $Capture = 'Input 1 (SSL 2',
[int] $Clicks = 25,
[double] $Amplitude = 0.25,
[switch] $LeftOnly,
[switch] $MinPeriod,
[switch] $List
)
$ErrorActionPreference = 'Stop'
Add-Type -TypeDefinition @'
using System; using System.Collections.Generic; using System.Diagnostics; using System.Runtime.InteropServices; using System.Threading;
[ComImport, Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")] class MMDeviceEnumeratorCo {}
[ComImport, Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
interface IMMDeviceEnumerator { int EnumAudioEndpoints(int flow, int mask, out IMMDeviceCollection c); }
[ComImport, Guid("0BD7A1BE-7A1A-44DB-8397-CC5392387B5E"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
interface IMMDeviceCollection { int GetCount(out uint n); int Item(uint i, out IMMDevice d); }
[ComImport, Guid("D666063F-1587-4E43-81F1-B948E807363F"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
interface IMMDevice {
int Activate(ref Guid iid, int ctx, IntPtr p, [MarshalAs(UnmanagedType.IUnknown)] out object o);
int OpenPropertyStore(int a, out IntPtr s); int GetId([MarshalAs(UnmanagedType.LPWStr)] out string id); }
[ComImport, Guid("1CB9AD4C-DBFA-4c32-B178-C2F568A703B2"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
interface IAudioClient {
int Initialize(int mode, uint flags, long dur, long period, IntPtr fmt, IntPtr session);
int GetBufferSize(out uint frames); int GetStreamLatency(out long hns); int GetCurrentPadding(out uint frames);
int IsFormatSupported(int mode, IntPtr fmt, out IntPtr closest); int GetMixFormat(out IntPtr fmt);
int GetDevicePeriod(out long def, out long min); int Start(); int Stop(); int Reset();
int SetEventHandle(IntPtr h); int GetService(ref Guid iid, [MarshalAs(UnmanagedType.IUnknown)] out object o); }
// IAudioClient3 vtable after IUnknown: IAudioClient's 12, IAudioClient2's 3, then its own 3 (Wine audioclient.idl)
[ComImport, Guid("7ED4EE07-8E67-4CD4-8C1A-2B7A5987AD42"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
interface IAudioClient3 {
int a1(); int a2(); int a3(); int a4(); int a5(); int a6(); int a7(); int a8(); int a9(); int a10(); int a11(); int a12();
int b1(); int b2(); int b3();
int GetSharedModeEnginePeriod(IntPtr fmt, out uint def, out uint fund, out uint min, out uint max);
int GetCurrentSharedModeEnginePeriod(out IntPtr fmt, out uint cur);
int InitializeSharedAudioStream(uint flags, uint periodFrames, IntPtr fmt, IntPtr session);
}
[ComImport, Guid("F294ACFC-3146-4483-A7BF-ADDCA7C260E2"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
interface IAudioRenderClient { int GetBuffer(uint frames, out IntPtr data); int ReleaseBuffer(uint frames, uint flags); }
[ComImport, Guid("CD63314F-3FBA-4A1B-812C-EF96358728E7"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
interface IAudioClock { int GetFrequency(out ulong f); int GetPosition(out ulong pos, out ulong qpc); int GetCharacteristics(out uint c); }
[ComImport, Guid("C8ADBD64-E71E-48A0-A4DE-185C395CD317"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
interface IAudioCaptureClient {
int GetBuffer(out IntPtr data, out uint frames, out uint flags, out ulong devPos, out ulong qpcPos);
int ReleaseBuffer(uint frames); int GetNextPacketSize(out uint frames); }
public class Endpoint { public string Id; public int Flow; }
public static class Loop {
static void Ck(int hr, string what) { if (hr != 0) throw new Exception(what + " failed: 0x" + hr.ToString("X8")); }
public static List<Endpoint> Endpoints() {
var r = new List<Endpoint>(); var e = (IMMDeviceEnumerator)new MMDeviceEnumeratorCo();
for (int flow = 0; flow < 2; flow++) {
IMMDeviceCollection c; Ck(e.EnumAudioEndpoints(flow, 1, out c), "EnumAudioEndpoints"); uint n; c.GetCount(out n);
for (uint i = 0; i < n; i++) { IMMDevice d; c.Item(i, out d); string id; d.GetId(out id); r.Add(new Endpoint { Id = id, Flow = flow }); }
}
return r;
}
static IAudioClient Open(string id, int flow) { return (IAudioClient)OpenAs(id, flow, typeof(IAudioClient).GUID); }
static object OpenAs(string id, int flow, Guid iid) {
var e = (IMMDeviceEnumerator)new MMDeviceEnumeratorCo(); IMMDeviceCollection c; e.EnumAudioEndpoints(flow, 1, out c);
uint n; c.GetCount(out n);
for (uint i = 0; i < n; i++) {
IMMDevice d; c.Item(i, out d); string did; d.GetId(out did);
if (did == id) { object o; Ck(d.Activate(ref iid, 23, IntPtr.Zero, out o), "Activate"); return o; }
}
throw new Exception("endpoint not found: " + id);
}
static long Now() { return (long)(Stopwatch.GetTimestamp() * (10000000.0 / Stopwatch.Frequency)); } // 100 ns units, same base as the capture QPC position
static void CheckFloat(IntPtr fmt, out int ch, out int rate) {
ch = Marshal.ReadInt16(fmt, 2); rate = Marshal.ReadInt32(fmt, 4); int bits = Marshal.ReadInt16(fmt, 14);
if (bits != 32) throw new Exception("mix format is " + bits + "-bit, expected 32-bit float");
}
public static string[] Run(string renderId, string captureId, int clicks, double amp, bool leftOnly, bool minPeriod) {
var log = new List<string>();
// capture: shared mode, polled, 200 ms buffer
var cap = Open(captureId, 1); IntPtr cfmt; cap.GetMixFormat(out cfmt); int cch, crate; CheckFloat(cfmt, out cch, out crate);
Ck(cap.Initialize(0, 0, 2000000, 0, cfmt, IntPtr.Zero), "capture Initialize");
Guid ccid = typeof(IAudioCaptureClient).GUID; object co; Ck(cap.GetService(ref ccid, out co), "capture GetService"); var cc = (IAudioCaptureClient)co;
var samples = new List<float>(); var times = new List<long>(); // per frame: max |sample| across channels, and its time
bool stop = false; Exception capErr = null; int pkts = 0, silentPkts = 0;
var th = new Thread(() => {
try {
while (!stop) {
uint next; cc.GetNextPacketSize(out next);
while (next > 0) {
IntPtr data; uint frames, flags; ulong dpos, qpc; Ck(cc.GetBuffer(out data, out frames, out flags, out dpos, out qpc), "capture GetBuffer");
var buf = new float[frames * cch]; if ((flags & 2) == 0) Marshal.Copy(data, buf, 0, buf.Length); else silentPkts++; // 2 = AUDCLNT_BUFFERFLAGS_SILENT
pkts++;
for (int f = 0; f < frames; f++) {
float m = 0; for (int k = 0; k < cch; k++) m = Math.Max(m, Math.Abs(buf[f * cch + k]));
samples.Add(m); times.Add((long)qpc + (long)(f * 10000000.0 / crate));
}
cc.ReleaseBuffer(frames); cc.GetNextPacketSize(out next);
}
Thread.Sleep(1);
}
} catch (Exception ex) { capErr = ex; }
});
// render: shared mode, event-driven, default engine period (buffer duration 0)
IAudioClient ren; IntPtr rfmt; int rch, rrate;
if (minPeriod) {
object o3 = OpenAs(renderId, 0, typeof(IAudioClient3).GUID); var c3 = (IAudioClient3)o3; ren = (IAudioClient)o3;
ren.GetMixFormat(out rfmt); CheckFloat(rfmt, out rch, out rrate);
uint pd, pf, pmin, pmax; Ck(c3.GetSharedModeEnginePeriod(rfmt, out pd, out pf, out pmin, out pmax), "GetSharedModeEnginePeriod");
Ck(c3.InitializeSharedAudioStream(0x00040000, pmin, rfmt, IntPtr.Zero), "render InitializeSharedAudioStream");
IntPtr cf; uint cur; c3.GetCurrentSharedModeEnginePeriod(out cf, out cur);
log.Add(string.Format("MIN PERIOD: requested {0} frames ({1:F2} ms; default {2}, fundamental {3}, max {4}); engine now {5} frames ({6:F2} ms)",
pmin, pmin * 1000.0 / rrate, pd, pf, pmax, cur, cur * 1000.0 / rrate));
} else {
ren = Open(renderId, 0); ren.GetMixFormat(out rfmt); CheckFloat(rfmt, out rch, out rrate);
Ck(ren.Initialize(0, 0x00040000, 0, 0, rfmt, IntPtr.Zero), "render Initialize");
}
var ev = new AutoResetEvent(false); Ck(ren.SetEventHandle(ev.SafeWaitHandle.DangerousGetHandle()), "SetEventHandle");
uint bufFrames; ren.GetBufferSize(out bufFrames); long lat; ren.GetStreamLatency(out lat); long dp, mp; ren.GetDevicePeriod(out dp, out mp);
log.Add(string.Format("render buffer {0} frames ({1:F2} ms) at {2} Hz, {3} ch; GetStreamLatency {4:F2} ms; device period {5:F2} ms",
bufFrames, bufFrames * 1000.0 / rrate, rrate, rch, lat / 1e4, dp / 1e4));
Guid rcid = typeof(IAudioRenderClient).GUID; object ro; Ck(ren.GetService(ref rcid, out ro), "render GetService"); var rc = (IAudioRenderClient)ro;
// IAudioClock: device position = "the sample that is currently playing through the speakers", with its QPC time
// (https://learn.microsoft.com/en-us/windows/win32/api/audioclient/nf-audioclient-iaudioclock-getposition)
Guid ckid = typeof(IAudioClock).GUID; object cko; Ck(ren.GetService(ref ckid, out cko), "render clock"); var clk = (IAudioClock)cko;
ulong cfreq; clk.GetFrequency(out cfreq);
var clkFrames = new List<double>(); var clkQpc = new List<long>(); var clickFrames = new List<long>(); long written = bufFrames;
int burst = rrate / 500; // 2 ms
var writes = new List<long>(); var pads = new List<uint>();
IntPtr p0; Ck(rc.GetBuffer(bufFrames, out p0), "prefill"); rc.ReleaseBuffer(bufFrames, 2); // 2 = AUDCLNT_BUFFERFLAGS_SILENT
th.Start(); Thread.Sleep(300);
Ck(cap.Start(), "capture Start"); Ck(ren.Start(), "render Start");
long t0 = Now(), every = 2500000, warm = 10000000; int sent = 0; long nextAt = t0 + warm;
while (sent < clicks) {
ev.WaitOne(200);
{ ulong cp, cq; if (clk.GetPosition(out cp, out cq) == 0) { clkFrames.Add(cp * (double)rrate / cfreq); clkQpc.Add((long)cq); } }
uint pad; ren.GetCurrentPadding(out pad); uint avail = bufFrames - pad; if (avail == 0) continue;
IntPtr data; Ck(rc.GetBuffer(avail, out data), "render GetBuffer");
var buf = new float[avail * rch];
bool click = Now() >= nextAt;
if (click) for (int f = 0; f < Math.Min(burst, (int)avail); f++) {
float v = (float)(amp * Math.Sin(2 * Math.PI * 2000 * f / rrate)); for (int k = 0; k < (leftOnly ? 1 : rch); k++) buf[f * rch + k] = v; }
Marshal.Copy(buf, 0, data, buf.Length); rc.ReleaseBuffer(avail, 0);
if (click) { writes.Add(Now()); pads.Add(pad); clickFrames.Add(written); sent++; nextAt += every; }
written += avail;
}
var tail = Stopwatch.StartNew();
while (tail.ElapsedMilliseconds < 500) { ulong cp, cq; if (clk.GetPosition(out cp, out cq) == 0) { clkFrames.Add(cp * (double)rrate / cfreq); clkQpc.Add((long)cq); } Thread.Sleep(2); }
ren.Stop(); cap.Stop(); stop = true; th.Join();
if (capErr != null) throw capErr;
// onsets: first frame above 25 % of the run's peak, 150 ms refractory
float peak = 0; foreach (var s in samples) peak = Math.Max(peak, s);
log.Add(string.Format("capture {0} frames at {1} Hz, {2} ch, peak {3:F6}, {4} packets ({5} flagged silent)", samples.Count, crate, cch, peak, pkts, silentPkts));
if (peak < 0.002) { log.Add("no signal on the capture input: check the cable, the input gain and the output volume"); return log.ToArray(); }
var onsets = new List<long>(); float thr = peak * 0.25f; long last = 0; bool any = false;
for (int i = 0; i < samples.Count; i++) if (samples[i] >= thr && (!any || times[i] - last > 1500000)) { onsets.Add(times[i]); last = times[i]; any = true; }
var lats = new List<double>(); var winPart = new List<double>(); var hwPart = new List<double>();
for (int i = 0; i < writes.Count; i++) {
long w = writes[i]; long hit = -1; foreach (var o in onsets) if (o > w - 50000 && o < w + 2000000) { hit = o; break; } // -5 ms .. +200 ms
if (hit < 0) { log.Add(string.Format("click {0,2}: not found", i + 1)); continue; }
double ms = (hit - w) / 1e4; lats.Add(ms);
// when did the render clock reach the click's first frame? interpolate between clock readings
double dev = double.NaN; long cf = clickFrames[i];
for (int k = 1; k < clkFrames.Count; k++) if (clkFrames[k - 1] < cf && clkFrames[k] >= cf && clkFrames[k] > clkFrames[k - 1]) {
dev = clkQpc[k - 1] + (clkQpc[k] - clkQpc[k - 1]) * (cf - clkFrames[k - 1]) / (clkFrames[k] - clkFrames[k - 1]); break; }
string split = "";
if (!double.IsNaN(dev)) { double a = (dev - w) / 1e4, b = (hit - dev) / 1e4; winPart.Add(a); hwPart.Add(b); split = string.Format(" = {0,6:F2} until the driver reports it playing + {1,6:F2} after", a, b); }
log.Add(string.Format("click {0,2}: {1,7:F2} ms (queued behind {2} frames = {3:F2} ms){4}", i + 1, ms, pads[i], pads[i] * 1000.0 / rrate, split));
}
if (lats.Count > 0) {
lats.Sort(); double sum = 0; foreach (var x in lats) sum += x;
log.Add(string.Format("RESULT {0}/{1} clicks: median {2:F2} ms, min {3:F2}, max {4:F2}, mean {5:F2}",
lats.Count, writes.Count, lats[lats.Count / 2], lats[0], lats[lats.Count - 1], sum / lats.Count));
log.Add(string.Format(" p25 {0:F2} ms, p75 {1:F2} ms", lats[lats.Count / 4], lats[lats.Count * 3 / 4]));
}
if (winPart.Count > 0) {
winPart.Sort(); hwPart.Sort();
log.Add(string.Format("SPLIT app -> render driver reports it playing: median {0:F2} ms; that -> captured: median {1:F2} ms ({2} clicks, render clock {3} Hz)",
winPart[winPart.Count / 2], hwPart[hwPart.Count / 2], winPart.Count, cfreq));
} else log.Add("SPLIT render clock gave no usable readings");
log.Add(string.Format("onsets detected: {0}", onsets.Count));
return log.ToArray();
}
}
'@
function Get-Name($id) { (Get-PnpDevice -InstanceId "SWD\MMDEVAPI\$id" -ErrorAction SilentlyContinue).FriendlyName }
$eps = [Loop]::Endpoints() | ForEach-Object { [pscustomobject]@{ Flow = @('render', 'capture')[$_.Flow]; Name = (Get-Name $_.Id); Id = $_.Id } }
if ($List -or -not $Render) { $eps | Format-Table Flow, Name -AutoSize | Out-String -Width 200; return }
$r = @($eps | Where-Object { $_.Flow -eq 'render' -and $_.Name -like "*$Render*" })
$c = @($eps | Where-Object { $_.Flow -eq 'capture' -and $_.Name -like "*$Capture*" })
if ($r.Count -ne 1) { throw "render '$Render' matches $($r.Count) endpoints; use -List" }
if ($c.Count -ne 1) { throw "capture '$Capture' matches $($c.Count) endpoints; use -List" }
"render: $($r[0].Name)"
"capture: $($c[0].Name)"
[Loop]::Run($r[0].Id, $c[0].Id, $Clicks, $Amplitude, [bool]$LeftOnly, [bool]$MinPeriod)

Run it from a normal PowerShell window:

Terminal window
.\audio-loopback.ps1 -List # endpoint names
.\audio-loopback.ps1 -Render 'D70' -Capture 'Input 2 (SSL 2' -Clicks 50
.\audio-loopback.ps1 -Render 'SSL 2' -Capture 'Input 1 (SSL 2' -LeftOnly -Clicks 50
.\audio-loopback.ps1 -Render 'SSL 2' -Capture 'Loopback 1' -Clicks 50 # the driver's own loopback, no analog
.\audio-loopback.ps1 -Render 'D70' -Capture 'Input 2 (SSL 2' -MinPeriod # the period a browser asks for

-Render and -Capture match on the endpoint name and have to match exactly one endpoint each. If it says there’s no signal, check the cable, the gain and the Windows volume, the script prints the capture peak so you can see how far off it is (below 0.002 counts as no signal; the noise floor here was about 0.0001).

Run the outputs back to back so the comparison holds. My runs, 50 clicks each, 48 kHz, default shared-mode period, one session (2026-10-26, about 17:00):

LoopTotal (median, p25-p75)App -> driver says playing-> captured
D70 XLR -> SSL 2 Input 247.88 ms (47.57-48.10)42.97 ms4.83 ms
SSL 2 headphone -> SSL 2 Input 1 (-LeftOnly)51.32 ms (51.30-51.33)31.97 ms19.35 ms
SSL 2 driver loopback (no analog)50.26 ms (50.23-50.27)31.97 ms18.29 ms

The first runs, earlier the same day and hours apart, 25 clicks each: D70 XLR -> SSL 2 Input 1 47.33 ms (47.16-47.54, one at 38.56), SSL 2 headphone -> SSL 2 Input 1 51.51 ms (50.09-51.61, one at 42.22). Those put the D70 about 4.2 ms ahead of the SSL 2’s output, with the D70 path at 47.3 ms and 12 ms of it the queue; the single low readings were most likely the script stamping ReleaseBuffer late. Within that D70 run the readings crept up about 0.4 ms, the clock drift again.

In every run Windows gave the stream a 1056-frame buffer (22 ms) and each click sat behind 576 frames (12 ms) already queued. GetStreamLatency came back 0, so no driver here tells Windows about its own delay. The D70’s wider p25-p75 is the DAC’s and the interface’s clocks drifting against each other; the SSL 2 loops share one clock.

All of the above was with the SSL 2 control panel at 512 samples. At 64 samples, 50 clicks each:

Loop512 samples64 samples-> captured at 64
SSL 2 driver loopback50.26 ms39.35 ms7.38 ms
SSL 2 headphone -> Input 151.32 ms40.70 ms8.73 ms
D70 XLR -> SSL 2 Input 247.88 ms47.47 ms4.57 ms

So the setting isn’t ASIO-only, it applies to the shared-mode path too, but only on the SSL 2’s output side: its loops dropped 10.6-10.9 ms, all of it after the driver says playing. The D70 loop goes through the same SSL 2 input and didn’t move, so the capture side didn’t change.

The D70 also has a vendor driver (Topping V6.16.0, built on Thesycon’s), which I’d removed in August because of intermittent crackle. Reinstalled for this, Windows restarted with the DAC left plugged in, both buffers at 128 samples, same cables, 50 clicks each:

LoopD70 on Microsoft’s usbaudio2D70 on the Topping driver
D70 XLR -> SSL 2 Input 247.73 ms (47.47-47.93)41.35 ms (41.06-41.63)
SSL 2 headphone -> Input 1 (control)38.96 ms39.58 ms
SSL 2 driver loopback (control)38.09 ms38.11 ms

The SSL 2 loops didn’t move, so the 6.4 ms is the D70’s driver, even though it offers a 10 ms minimum period against usbaudio2’s 7 ms. The Topping run had a wider spread, one click at 51.45 ms. Whether the crackle comes back is the real question for that driver, and that’s a listening test, not this one.

I also moved the GPU’s interrupts to CPU 2 and the audio controller’s (the one the DAC and interface are on) to CPU 4, with the policy from the 8 kHz mouse guide, and restarted:

LoopTopping driver+ GPU and audio controller pinned
D70 XLR -> SSL 2 Input 241.35 ms (41.06-41.63)41.34 ms (41.04-41.60)
SSL 2 headphone -> Input 139.58 ms39.61 ms
SSL 2 driver loopback38.11 ms38.74 ms

No change, which is what you’d expect: latency is buffering, a pin only moves where the interrupt work runs. The 51 ms outlier didn’t come back in 50 clicks, which is the part a pin could plausibly help with (stalls), along with crackle.

Chromium opens audio with IAudioClient3::InitializeSharedAudioStream at the smallest period the endpoint offers5; games mostly take the default. The script does the same with -MinPeriod. Here it made no difference, because with the drivers I ended up on every output offers exactly one period:

Output (driver)DefaultFundamentalMinimumMaximum
D70 (Topping)480 frames (10 ms)480480480
SSL 2 (Thesycon)480480480480
Monitor (NVIDIA HD Audio)480480480480

So a browser gets the same 10 ms as a game on this machine, and the default-mode numbers above are what it would see too.

The last column is “output delay the driver doesn’t report” plus “the recorder’s capture delay”. Both real loops went through the same SSL 2, so the capture delay is the same in both. For the D70 the sum is 4.83 ms. A DAC can only add delay after its driver says a sample is playing, never take it away, so the SSL 2’s capture side is at most 4.83 ms. That bounds both outputs:

OutputApp to analog outOf which Windows’ queue
D70 Pro Sabre (usbaudio2)43.0-47.9 ms12 ms
SSL 2 (Thesycon), 512-sample buffer46.5-51.3 ms12 ms
SSL 2 (Thesycon), 64-sample buffer36.1-40.7 ms12 ms
D70 again, SSL 2 at 6442.9-47.5 ms12 ms
D70 on the Topping driver, 128 samples36.6-41.4 ms12 ms

Two more things fall out:

  • The SSL 2’s analog loop is only 1.06 ms longer than its driver-internal loopback, so its converters and the cable are about 1 ms. Its driver reports samples playing at least 14.5 ms before they leave the jack (19.35 minus at most 4.83).
  • The “driver says playing” column means different things per driver (43 ms for usbaudio2, 32 ms for the Thesycon driver), so don’t compare that column across drivers, only the totals and the bounds.

There’s a claim going round that onboard (PCH) HD Audio is about 35 ms faster than any USB DAC. The community results behind it come from a browser round-trip test (https://superpowered.com/webbrowserlatency: speaker out, microphone in, through Chromium), where each person keeps one mic, so their own outputs compare: onboard 3.5 mm, PCIe sound cards and GPU audio land around 31-50 ms, USB DACs and USB codecs around 70-160 ms, 35-45 ms apart for one person. The GPU results that match onboard audio were on Microsoft’s inbox “High Definition Audio Device” driver for the GPU’s audio, which takes small periods; NVIDIA’s own driver offers only 10 ms here. Their explanation is the driver honouring the small buffer the browser asks for, which fits Microsoft’s page (inbox HD Audio down to 128 samples, only for apps that ask). My numbers don’t confirm or rule out the size of the gap. The HD Audio driver’s smaller minimum buffer (2.67 ms against 7-10 ms here) explains a few ms for apps that ask for small buffers. Of the D70’s 43-48 ms, 12 ms is the shared-mode queue every output pays, which leaves 31-36 ms for the engine, usbaudio2 and the DAC, and only a non-USB output measured the same way can say how much of that onboard audio would save. A 35 ms gap would need the USB driver and DAC to account for around 30 ms of those 31-36 ms on their own. A browser round-trip test isn’t comparable with these numbers directly: it includes the input device and the browser’s own buffering, and the browser asks for a smaller period than a game does.

Where that leaves me: Topping’s driver at 36.6-41.4 ms is the fastest output I can use. Every output here runs a 10 ms period whoever asks, the pins don’t change latency, and a non-USB path (onboard HD Audio optical, or GPU audio through an HDMI audio extractor into the DAC’s optical input) isn’t available to me: GPU audio is out for my use, and no AM5 Mini-ITX board I checked has an HD Audio codec with optical out together with USB4 and an Intel NIC.

CheckHowExpected
Input is livetap the cable tip, watch the input LEDit flickers
Output not mutedWindows sound settings, or the script’s peakpeak well above 0.002
Clicks foundRESULT n/25 line (25 is the default -Clicks)every click found, n = 25
Stable pathspread of the per-click lineswithin about 0.5 ms apart from single outliers
  • The recorder is part of every number. Compare outputs through the same input, don’t quote one loop as an output’s latency.
  • A driver’s software loopback endpoint isn’t a hardware loop. The SSL 2’s Loopback 1 L/R measured about 50 ms, but that signal never leaves the driver, so it says nothing about the analog path.
  • Stereo into balanced cancels. Identical left and right into a TRS balanced input record as near silence; play one channel.
  • Check Windows mute, the output volume and the interface’s input mode before anything else. I lost most of an afternoon to a muted output at 10 % and the wrong LINE setting; the tip-touch hum test in Part 1 tells you whether the input is live at all.
  • Run the outputs you compare back to back. My first D70 and SSL 2 runs were hours apart; the table above is from one session.
  • Check the interface’s own buffer setting. The SSL 2 at 512 samples cost about 11 ms on its output against 64, in shared mode, not just ASIO.
  • Don’t clip the input. My D70 run hit full scale (peak 1.0) at 100 % Windows volume into the mic preamp. The onset is the first sample over 25 % of the peak, so it moves by at most part of a 2 kHz cycle, but turn it down.
  • A browser doesn’t necessarily get a smaller buffer than a game. Check what your drivers offer with GetSharedModeEnginePeriod (or -MinPeriod) before comparing against browser round-trip tests; here every output offered only 10 ms.
  • Exclusive mode isn’t measured here. Few games use it, but it would show how much of the 47 ms on Microsoft’s driver is Windows rather than the USB driver and DAC.
Path or objectWhat it is
audio-loopback.ps1the script above: WASAPI shared render + capture, QPC-timed clicks
IAudioClient3::GetSharedModeEnginePerioddefault and minimum shared-mode engine period per endpoint
IAudioCaptureClient::GetBuffer (pu64QPCPosition)per-packet capture timestamp in 100 ns units
IAudioClock::GetPositionrender device position (the sample playing now) with its QPC time
IAudioClient3::InitializeSharedAudioStreamshared-mode stream at a chosen period (-MinPeriod)
Get-PnpDevice -Class MEDIA + DEVPKEY_Device_Servicewhich driver each audio device runs on

The same machine’s 8 kHz mouse work, including keeping the mouse alone on a CPU-attached USB controller away from the audio devices on the chipset one, is in Fixing merged reports from an 8 kHz mouse on Windows.

  1. Microsoft, “IAudioClient3::GetSharedModeEnginePeriod method (audioclient.h),” Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/audioclient/nf-audioclient-iaudioclient3-getsharedmodeengineperiod ↩

  2. Microsoft, “Low latency audio,” Microsoft Learn. https://learn.microsoft.com/en-us/windows-hardware/drivers/audio/low-latency-audio ↩

  3. Microsoft, “IAudioCaptureClient::GetBuffer method (audioclient.h),” Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/audioclient/nf-audioclient-iaudiocaptureclient-getbuffer ↩

  4. Microsoft, “IAudioClock::GetPosition method (audioclient.h),” Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/audioclient/nf-audioclient-iaudioclock-getposition ↩

  5. Microsoft, “IAudioClient3::InitializeSharedAudioStream method (audioclient.h),” Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/audioclient/nf-audioclient-iaudioclient3-initializesharedaudiostream ↩