Skip to content

Latency

Score2 edited this page Aug 10, 2026 · 1 revision

Latency

Short version: end-to-end latency on our reference setup measures about 150 ms, the theoretical floor for this architecture is about 91 ms, and the sub-40 ms figures you may have in mind from other tools are not reachable here.

This page explains where the milliseconds are, why most of them cannot be removed, and how the number in the UI is produced.


What "latency" means here

From the moment a sample is captured by the peer's sound card, to the moment it leaves this machine's sound card: the sum of the time it spends queued in every buffer along the way.

That is mouth-to-ear latency minus air travel. It is the number a user actually experiences, and it is the number the UI reports.


The measured breakdown

Reference setup: a Mac sending to a Windows machine over a gigabit LAN, jitter buffer at its 50 ms working point. All figures in milliseconds.

Stage Today Floor Can it be reduced?
hal_spk — virtual speaker ring (mac) 26.7 15.0 Yes, about −8, needs work
send_pace — packetisation 5.0 2.5 No: halving it worsens jitter, likely net negative
network — one way 3.3 0.5 Barely. Most of the 3.3 is scheduling tail, not network
jitter_buf 50.0 10–15 Yes — the only large block genuinely still open
post_mix 0.0 0.0
play_ring — playback ring (Windows) 22.3 20.6 No: 1.5 % of total, on the one stage where an error is instantly audible
play_dev — sound card (Windows) 41.9 39.9 No
Reported total 111.0 50.9
Real end-to-end ≈152.9 ≈90.8

The reported total and the real total differ because play_dev — the largest single item — is not reported at all today. It is measured, it is known, and it is not yet in the number the UI shows. See the ≥ prefix.

The single most surprising line

The largest item on this link is not the network (3.3 ms), and not the jitter buffer (50 ms). It is Windows taking 41.9 ms between "we hand the audio to the system" and "the sound card starts playing" — an order of magnitude more than the entire network segment.

Of that 41.9 ms, about 29.9 ms is the Windows shared audio engine plus KS transport. Swapping the sound card for a different one moves the figure by about 1.3 ms. It is a property of the platform, not of your hardware.

The jitter buffer is not buying what its name suggests

Measured network jitter on this link is 0.18 ms at p95, with zero packet loss. Nothing like 50 ms of protection against the network is required.

What the buffer actually absorbs is the sender's own scheduling stalls — occasional pauses over 100 ms in the transmit loop, at a rate of roughly one every four minutes. That is why it is the one large block still worth opening: fixing the stalls is an architectural change, but it is our architecture.


Why below 40 ms is unreachable

An early target of "under 30 ms" was written before any of this was measured. It is formally withdrawn. Even the later, more generous 40 ms bar does not survive contact with the measurements.

The endpoint arithmetic

Take the transport entirely out of the picture — set packetisation, network, and jitter buffer all to zero. What remains:

Windows playback floor   play_ring 20.6 + play_dev 39.9  =  60.5 ms
macOS virtual speaker ring, best engineered value        =  15.0 ms
                                                   total =  75.5 ms

75.5 ms with an infinitely fast network — 1.9× the 40 ms bar.

And not one millisecond of that 75.5 is something we failed to do. The 39.9 is the hard limit of Microsoft's shared engine plus an in-box class driver; the 20.6 is a structural block-and-margin figure with 2× oversubscription; the 15.0 is the minimum target of the virtual device ring itself.

The theoretical floor

Idealising every remaining stage gives 90.8 ms end-to-end. That figure still rests on four assumptions, none of them verified, including a scheduling-tail improvement whose feasibility is unknown.

It is not a promise. It is a line that cannot be crossed.

Summary

The two biggest blocks on this link are someone else's driver hard limit and our own process's scheduling jitter. Neither is obtainable by making a constant smaller, and neither is on the network.

For comparison

Within the same class of system — cross-machine, general-purpose OS, virtual sound card — 91 ms sits in the upper-middle of the field. It is well ahead of Snapcast (1000 ms default) and comparable to commercial tools in the same shape. It is slower than systems that avoid the class entirely: bare-metal microcontroller receivers, or professional low-latency tools that require dedicated audio interfaces and have no virtual sound card in the path.

Sub-40 ms in this shape is not a target anyone has publicly hit.


How the number is produced

Buffer-depth accounting, not a stopwatch

If a buffer drains at a known rate and currently holds N samples, then a sample entering now waits exactly N ÷ rate before it leaves. That is the stage's exact residence time, not an estimate — when callbacks fire and how much each one moves both cancel out.

AudioHub reads this at every stage it can see: capture ring, capture device, send queue, virtual speaker ring, packetisation, network, jitter buffer, mix alignment, playback ring, playback device. Most of these are integer sample counts read within one machine against one clock, with no estimation component whatsoever.

The two machines' contributions are summed after aligning clocks, using an offset derived from minimum round-trip samples.

The completeness check

A second, independent path computes the true age of a sample by comparing two wall-clock anchors. The difference between that and the stage-by-stage sum is reported as unattributed.

This is not circular: one side is a difference of timestamps and the other is a model. A buffer nobody knew about shows up in the first and not the second, and lands in the unattributed figure. A persistently large unattributed value means the link has a buffer we are not modelling.

The ≥ prefix

When a stage that is known to exist cannot be read, its value is not filled with zero. Instead the total carries a prefix and the UI says what is missing.

Filling with zero would make a Bluetooth headset — genuinely adding 150–250 ms — look exactly as good as an analogue output. An unreadable sound-card buffer typically costs 10–20 ms of understatement.

The prefix is removed once every declared stage reports. For output paths where the platform is known to under-report — Bluetooth, HDMI, AirPlay — it stays permanently, because those APIs report 20–30 ms for devices actually adding 150–250 ms.

Why round-trip time is not the answer

It is tempting to ping the peer and call that latency. On the same link where perceived latency is around a second, measured one-way network time is 0.58 ms — a ratio of roughly 1700×. There is no monotonic relationship between the two.

Round-trip time is displayed separately, clearly labelled as one segment, and is never used to stand in for or backfill the total. When no audio is flowing, the UI shows the network segment alone and says in as many words that this is not the total, because the buffers and sound cards that dominate can only be measured while audio is actually moving.


The latency setting is a target, not a limit

The latency control sets a goal for total end-to-end latency, and the service actively fills buffers up to it.

Setting 300 ms does not mean "this link can only manage 300 ms". It means the service will deliberately hold 300 ms of buffer. Measured: a 300 ms target produced 304.2 ms actual — a servo loop converging on the value you asked for.

Set below the physical floor, it runs at the floor. Set above the ceiling, it runs at the ceiling. "As low as possible" sets no target and presses to the minimum; AUTO adapts to measured network quality.

The setting is per peer, per direction, and it acts on the receiving end — so on a send-only path, this machine's slider has nothing to act on, and the UI says so rather than appearing to do nothing.

Clone this wiki locally