Skip to content

Latency

Score2 edited this page Aug 15, 2026 · 2 revisions

Latency

Short version: on our reference link the real end-to-end delay measured about 153 ms, and the queueing the app can account for came to 111 ms of that. The rest is a Windows sound-card buffer the platform will not describe honestly, which is why the displayed total carries a . The theoretical floor for this architecture is about 91 ms. The sub-40 ms figures you may have in mind from other tools are not reachable here, and the reasons are structural rather than unfinished work.

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


What "latency" means here

From the moment a sample enters this machine's pipeline, to the moment it leaves the peer's speaker: the sum of the time it spends queued in every buffer along the way.

The receive direction is the same definition mirrored — peer's pipeline in, this machine's speaker out. Latency is measured and reported per direction, and the two directions on one link routinely differ.

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


The measured breakdown

Reference setup: a Mac in Mode B, an app on it playing into the AudioHub virtual speaker that stands for the Windows peer's output, over a gigabit LAN on a Direct (UDP) connection, jitter buffer at a 50 ms working point. All figures in milliseconds.

Stage Runs on Today Floor Can it be reduced?
Virtual speaker buffer (hal_spk) Mac 26.7 15.0 Yes, about −8, but it needs an experiment nobody has run
Packet interval (send_pace) Mac 5.0 5.0 No: a shorter frame halves the send loop's deadline and worsens the jitter buffer by more than it saves
Network (network) both 3.3 0.3 Barely. Most of the 3.3 is scheduling tail, not network
Jitter buffer (jitter_buf) Windows 50.0 10.0 Yes — the only large block genuinely still open
Mix buffer (post_mix) Windows 0.0 0.0
Playback queue (play_ring) Windows 22.3 20.6 No: 1.5 % of the total, on the one stage where an error is instantly audible
Playback buffer (play_dev) Windows 41.9 39.9 No
Sum reported at the time 111.0 50.9
Real end-to-end ≈152.9 ≈90.8

The measured figures are medians over the same window. Medians are not additive, so the column does not add up to the reported sum exactly.

The stage names in the first column are the ones the Stats tab uses; the identifiers in brackets are what the service calls them internally.

Why the app's number is smaller than the real one

At the time of this measurement the two sound cards were not in the sum at all. They are now — both ends' device readings are added to the reported total. But that does not close the gap, because of what Windows answers with:

Asked how long a sample waits between "we hand the audio to the system" and "the speaker moves", Windows returns its engine period — 10.0 ms on this machine. The same endpoint measures 41.9 ms. The platform under-reports its own output buffer by 4.2×.

So the figure does enter the total, but as a lower bound, and the total keeps its prefix permanently on any link with a Windows output. 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. Changing the buffer size by 4.5× does not move it. Swapping the sound card for one on a completely different bus — USB against HDMI — moves it 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 — 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.

Two caveats on the 50 ms figure. It is a snapshot taken while the buffer's underrun penalty was active; on a clean link with the penalty decayed to zero, both ends measure the depth at a median of 30 ms, oscillating between three and four frames. And it is the depth for a Direct (UDP) connection only — see below.

These figures describe a Direct (UDP) connection

On a degraded connection the jitter buffer switches to a different profile whose ceiling is 400 ms, because a single TCP retransmission timeout (200 ms on Linux, 300 ms on Windows) would punch straight through the default one. Measured on a real TCP relay link, it settles at 39–41 frames ≈ 400 ms.

Against the 50 ms working point in the table that is roughly +350 ms on the total. If your reading looks nothing like this page, check the connection method first: what degradation costs.


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

The honest comparison uses today's measurement, not the floor, and compares like with like. Roc Toolkit is the closest match — a general-purpose network audio library — and its own CLI documentation example targets 40–60 ms. That figure is its jitter-buffer target and excludes its sound cards, so the quantity to put next to it is our reported 111 ms, not our real 153 ms.

The gap is real. We do not reach the published range of comparable software.

What sits behind the gap is a set of privileges we gave up on purpose:

System How it goes lower Why we cannot
AES67 / RAVENNA / Dante PTP hardware clocks; playout at an absolute timestamp, so queue depth and latency are decoupled No hardware clock. We take the next packet off a queue, so depth is latency
JackTrip, Jamulus ASIO or exclusive-mode devices; their sound-card segment is 2.67 ms The Windows endpoint's minimum period is 10 ms, and taking a device exclusively would mean seizing the output the user is listening to
Scream Kernel mode, no user-space hop Out of scope
Snapcast Nothing — its default is 1000 ms, deliberately trading latency for multi-room sample sync Not the same problem

The single most expensive line in that table is the one we chose: AudioHub never takes a device exclusively and never asks you to switch your system output to a virtual device. Going through the system's shared audio engine to make a sound costs 29.9 ms on Windows before anything else happens.

Sub-40 ms in this shape — cross-machine, general-purpose OS, virtual sound card, non-exclusive output — 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.

Three of the output stages — the real playback queue, the audio bridge, and the virtual microphone — run in parallel, not in series: one decoded frame can go to all three at once. Only the largest of the three is counted, on both machines, using the same rule. Adding them would report two seconds of latency for a session that is monitoring and bridging at the same time.

The two machines' contributions are summed after aligning clocks, using an offset derived from minimum round-trip samples. That window needs eight round-trip samples, roughly ten seconds, before it produces anything. Until then the total reads "Measuring…" rather than a guess.

The ≥ prefix

When a stage that is known to exist cannot be read — or can be read but is known to be wrong — its value is not filled with zero. Instead the total carries a prefix and the app 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. On the reference Windows machine the platform reports 10.0 ms for a stage measured at 41.9 ms: an understatement of 31.9 ms, on the largest single item in the chain.

The prefix clears only when both machines return a device reading that came from a platform API and is not on the known-to-under-report list. In practice:

Situation Prefix
Any Windows output permanent — Windows only ever answers with its engine period
Bluetooth, HDMI, DisplayPort, AirPlay, aggregate / multi-output devices, wireless Continuity permanent — the property these paths expose omits the buffering they actually add; Bluetooth reports 20–30 ms for 150–250 ms of real delay
A path with no real sound card on one end (Mode B's virtual speaker source, a bridge-only tail) permanent — "no device on this path" is not the same as "measured zero"
Both ends on macOS with wired devices the API describes clears — this is the only combination that does

What is not measured

The per-stage accounting can only see buffers it knows about. A buffer nobody registered is invisible: it delays every sample by its full depth and the total does not move.

The Unattributed row in the stage list exists for exactly this — it is meant to hold the difference between an independently measured sample age and the sum of the stages. The service in this build never computes it, so the row never appears. This is a known, deliberate gap rather than an oversight: a test in the repository injects a 600 ms unregistered queue and asserts that it is invisible today, so that it fails on the day that stops being true.

Treat the breakdown as an accurate account of the buffers listed, not as proof that there are no others.

Why round-trip time is not the answer

It is tempting to ping the peer and call that latency. During a 2026 investigation the perceived latency on this link was around a second while the round-trip network time measured 0.58 ms — one way, 0.29 ms. A ratio of roughly 1700×. There is no monotonic relationship between the two.

That investigation is why the per-stage accounting on this page exists. It ruled the network, the jitter buffer and the virtual speaker ring out conclusively and converged on four one-second FIFOs downstream of the jitter buffer that had no telemetry whatsoever — a second of delay that no counter in the product could see. Round-trip time would have reported the link as perfect throughout.

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 app 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.

The only thing the servo can move is the jitter buffer depth. Everything else in the table above — sound cards, rings, packetisation, network — is not under its control. Each tick it re-derives the immovable floor by subtracting the buffer from the measured total, which is why changing your default output device changes what the same setting achieves.

Set below the physical floor, it runs at the floor and the reading says Actual: N ms · Min. Set above the ceiling, it runs at the ceiling and says · Max. Lowest is the leftmost fixed stop; it does not promise 0 ms, it asks the service not to hold a single frame more than it must. AUTO, which is the default, hands the depth back to the jitter buffer's own jitter-driven loop.

Where to change it

On the peer detail page, in the Connection card: two sliders labelled Receive latency and Send latency. There is no global latency setting — the settings page has no latency control at all. The read-only overview of every peer's four transport settings is the Transport card on the Stats tab.

The stops are AUTO plus 13 fixed millisecond targets: 0 (Lowest), 10, 20, 30, 50, 75, 100, 150, 200, 300, 500, 750, 1000.

Quality is set from the same two rows and behaves differently — see Audio Quality.

Which machine executes it

The setting is per peer, per direction. Both directions are executed by the receiving side's jitter buffer, so the send target is pushed across the link and applied on the peer. You set both directions from this machine; there is no need to walk to the other computer.

The one case where the sliders genuinely do nothing is Share mode: a machine being used by others sets no profiles. The app says so rather than appearing to work — "This device is in Share mode. The peer using it (…) sets the send and receive profiles; the requested values are shown here."

When there is no number

The line under each slider is the measured value, never a copy of the target. Most of the time it is a number; when it is not, it says why:

Under the slider Meaning What to do
No stream Nothing is flowing in that direction Nothing — normal for an idle direction
Measuring… A stream exists but the round-trip window has not converged yet Wait about ten seconds
Actual: N ms The measurement, optionally with · Min or · Max when the servo has hit a limit
Unset · Auto Only in Share mode: the peer using this machine has not stated a target Nothing; it runs on AUTO

The headline latency figure on the peer card has its own set:

On the peer card Meaning
N ms / ≥N ms The total, with when a stage is a lower bound
Measuring… The round-trip window has not converged
Peer too old · No latency data The total cannot be composed from what arrived
Nothing to report for this direction

A greyed reading never means zero.

Clone this wiki locally