Latency Budget
Where the delay in a real-time loop comes from, decomposed into filter, buffer and processing — and what a zero-phase filter costs when you insist on running one live.
2 claims on this page are unverified. TODO(confirm) marks a specific statement the author has not yet checked against a
primary source. Everything else on this page has been reviewed. Treat a marked claim as
provisional and go to the cited source rather than quoting the sentence.
Modes: default — the widget below runs in default. Use Share state to put the exact view in the URL.
What it does
A real-time pipeline is replayed over a ten-second trace, block by block, and the delay between an event in the signal and the output that reflects it is decomposed into three rows rather than printed as one number:
worst case = filter group delay + buffer + processing
mean = filter group delay + buffer/2 + processing
Each row has a different cause and a different remedy, which is the reason the read-out refuses to collapse them. Change the filter order and one row moves; change the block length and a different one does; the processing row never moves on its own, because it is a number you measured and told the widget, and no tool can know what your compute, transport and display cost.
At the widget’s defaults — 160 Hz, an 8–12 Hz linear-phase FIR of 129 taps, blocks of 32 samples, 10 ms of stated processing, run causally — the budget is 400.00 + 200.00 + 10.00 = 610.00 ms worst case and 510.00 ms averaged over where in the block a sample lands. That is L7.3’s pipeline, and it is the default state, so an empty share-link reproduces it.
Only the filter row is measured. A loopback probe — one Hann-windowed packet at the centre of the pass band — goes through the same pipeline and the shift of its energy centroid is read off, which is how latency is measured on a real rig: send a known event and time its arrival. A cross-correlation was tried first and was quietly wrong, because two narrow-band signals correlate just as well at every multiple of the carrier period.
The buffer term has two defensible answers, one sample apart
This is a named control, not a hidden default, because quoting one while meaning the other is the commonest error in a latency budget:
| convention | at B = 32, fs = 160 Hz | what it counts |
|---|---|---|
block period, B/fs (the default) | 200.00 ms | a sample is not available until its acquisition period completes, so the block is handed over at the end of the last sample’s period and the oldest sample in it has waited a full block |
first-to-last, (B − 1)/fs | 193.75 ms | the difference between the arrival instants of the first and last samples of the block |
Whichever is selected, the table prints it and says what the other would read on the same pipeline. A third convention — adding a further B/fs for the hold until the next update — answers “how stale is the number on screen right now” rather than “how late does an event appear”, and is named but not budgeted.
Why a zero-phase filter cannot run causally
Forward–backward filtering has no group delay because, in the interior of a record, it is one convolution with a kernel symmetric about the sample it is correcting. Symmetric means it reaches to both sides, so the output at time t uses signal from after t. Offline that is free; live it does not exist yet. The widget offers the three things a person can actually do about it:
| option | filter row | what it really is |
|---|---|---|
| Causal | the group delay | one forward pass with state carried across blocks — what a live loop can run |
| Zero-phase, live | the look-ahead | forward–backward over a window that reaches past the block: N − 1 samples for an N-tap FIR, exactly twice the causal group delay of the same filter. The delay did not go away; it changed its name from lag to wait |
| Zero-phase per block | zero, and misleading | filtfilt on each buffer as if the buffer were the whole recording. The probe returns a negative delay — an output ahead of its input — and every block edge grows its own transient (pf-filter-across-boundaries) |
For an IIR the two-sided kernel never ends, so the look-ahead is a truncation criterion, not a bound: how far before an impulse the forward–backward response still exceeds 1 % of its peak. On the shipped 8–12 Hz Butterworths that is 83 / 145 / 186 / 226 samples at orders 2 / 4 / 6 / 8, and every one of them exceeds the causal group delay of the same order.
Controls
| Control | What it sets |
|---|---|
| Trace | Which replayed trace: eyes-closed alpha, eyes-open, or the synthetic eyes-open → eyes-closed splice |
| Filter type | FIR (windowed sinc, Hamming) or IIR (Butterworth, as second-order sections) |
| Order | FIR: the tap count, forced odd so (N − 1)/2 is a whole sample. IIR: the Butterworth order |
| Band | The band-pass edges, clamped inside (0, Nyquist), opening on the band the asset declares (8–12 Hz) |
| Quote the group delay at | IIR only: the frequency the table’s number is read at, because an IIR has no single group delay |
| Buffer | Block length in samples, shown in milliseconds and as a loop update rate |
| Processing delay | A stated constant in milliseconds: your compute, transport and display. Not derived, and the row says so |
| Buffer convention | Block period or first-to-last, as above |
| How the filter is run | causal · zero-phase, live · zero-phase per block |
| Draw raw / reference, window, start | View only; they change no number |
What to look for
- Read the three rows before the total, and decide which one you would attack first. The total alone does not tell you.
- Double the tap count. The filter row doubles with it — a linear-phase FIR delays every frequency by exactly
(N − 1)/2samples, so length and latency are the same dial — and the other two rows do not move. What the length buys is the transition band:3.3·fs/N, which is 4.09 Hz at 129 taps and 2.05 Hz at 257, for another 400 ms. - Change the block length instead, and watch only the buffer row and the loop update rate move.
- Switch to Butterworth. The group delay stops being one number: the read-out quotes it at a frequency you choose and prints its range across the pass band beside it. Order 4 costs 204.54 ms at 10 Hz where a 129-tap FIR costs 400 — and order 2 does not meet a −20 dB stop-band target on this band at all.
- Compare the two ways of asking for zero phase, then look at what “zero-phase per block” does to the waveform at each block edge. The filter row says 0 ms and the trace says otherwise.
Used in
- L7.3 Real-time processing and neurofeedback — the lesson’s exercise asks for the end-to-end latency of the pipeline this widget opens on.
w-filter-sandbox’s causal mode shows the same delay as a shift on the waveform. The two agree by construction for a linear-phase FIR — both take (N − 1)/2 — but they are controlled from opposite ends: the sandbox’s control is the transition band, from which it derives the tap count, while this widget’s control is the tap count, from which it derives the transition band as 3.3·fs/N. Quote one or the other; a mixture will not reconcile.
Data provenance
ds-eegbci — EEG Motor Movement/Imagery Database (EEGMMIDB), PhysioNet v1.0.0, ODC-By 1.0, open access, dataset DOI 10.13026/C28G6P. Cite Schalk et al. (2004) and Goldberger et al. (2000) as the dataset asks. The shipped traces are derivatives: cropped to ten seconds, reduced to one channel, and demeaned.
Three traces, each 10 s of subject S001, channel O1, at the recording’s native 160 Hz:
| id | source | selection |
|---|---|---|
alpha-ec | R02, eyes closed, t0 = 50.0 s | the 10-s window with the highest median 8–12 Hz Hilbert envelope on O1 (97.8 µV), on a 0.5-s grid with 1 s dropped at each end |
alpha-eo | R01, eyes open, t0 = 1.0 s | the same criterion, minimised (10.6 µV) |
splice-eo-ec | R01 + R02 | synthetic: five seconds of alpha-eo followed by five of alpha-ec, joined at t = 5 s with no cross-fade |
160 Hz and unfiltered is the point. ds-eegbci is recorded with no hardware filters, so the delay a filter adds is the only delay in the trace, and nothing was resampled — resampling would add a filter, and therefore a delay, to an asset whose entire subject is delay.
The splice is synthetic and labelled so, with derived_from and modifications in its sidecar and in data/manifest.json: no subject produced that ten seconds of EEG. It does two jobs — the alpha envelope steps up at t = 5 s, so you can see how late a causal pipeline notices a change, and the join is a real discontinuity, which is what pf-filter-across-boundaries is about.
traces.json also ships a reference block: SciPy’s own group delays, zero-phase look-aheads and stop-band attenuation for a fixed menu of twelve designs (scipy 1.15.3), so the widget’s TypeScript can be checked against SciPy rather than trusted. The widget’s test suite does exactly that, and the two agree — every FIR group delay exactly, every Butterworth group delay to six decimals, every zero-phase look-ahead to the sample. Every causal design additionally carries an empirically measured lag on the shipped trace; a predicted delay that does not show up as a real lag stops the asset script.
Open the code
site/src/components/widgets/w-latency-budget/ — Widget.svelte (presentation only), budget.ts (all of the mathematics: design, group delay, look-ahead, the budget, the block-wise replay and the loopback probe), state.ts, data.ts, synthetic.ts (a seeded fallback so everything works before the asset ships, labelled as a simulation when it is showing), README.md, and 66 tests of which nine run against the shipped asset and its SciPy reference. Repository link: TODO(confirm) (GitHub org/repo).
TODO(confirm): screenshot.png in that folder is a labelled placeholder drawn with matplotlib, not a capture of the widget, and at the time this page was written the widget’s charts, theme switching and keyboard paths had been unit-tested and server-rendered but not yet looked at in a browser (site/notes/widget-latency.md §7). This page is where someone should open it and check — in particular whether the three budget rows and the two conventions read clearly side by side at narrow widths.