Tools

Jitter and FEC simulator

/tools/jitter-fec/

A minute of voice over a network that delays and loses packets: how much audio the decoder makes up, and which buffer and protection fix it, at what cost in delay and bitrate.

How it works

Jitter and FEC simulator#

A voice stream sends a 20 ms frame every 20 ms, and the receiver plays each one at a fixed time: when it was sent, plus the trip, plus a jitter buffer. This tool runs a minute of that stream over a network that delays each packet by a different amount and loses some, and says what the listener gets for every frame: the frame itself, a copy rebuilt from a later packet, or 20 ms the decoder made up.

How to use it: an example#

Say a call sounds choppy. On its path a packet picks up about 30 ms of extra delay on average, about 1% of packets are lost, and the receiver’s buffer waits 40 ms. The question is what to change.

1. Enter what you see.

InputYou enterWhy
Jitter30The average extra delay a packet picks up
Jitter buffer40How long past the normal trip the receiver waits
Loss11% of packets never arrive
Burst length1Losses are independent of each other
ProtectionOpus in-band FECWhat the call already negotiated

The Jittery path sample is exactly this.

2. Press Run, and read the three numbers at the top.

plaintext
Concealed Longest gap Playout delay
15.1% 120 ms 70 ms
9.1 s made up in 60 s 6 frames in a row were made up. 40 ms of it is the buffer

In plain words: about one frame in seven is a guess by the decoder, which a listener hears as a call that keeps breaking up, and the worst stretch is 120 ms long.

3. Read the chart under them: the buffer’s trade. It replays the same minute at every buffer from 0 to 200 ms. The line for concealed frames crosses the dashed 1% line at the mark labelled shallowest under 1%, 90 ms, well to the right of this run at 40 ms.

4. Read what to change.

ChangeMade-up audio afterDelayWhat it costs
Raise the buffer to 80 ms and switch to RED, 2 copies0.4%40 ms more on every frameTwo full copies, about three times the audio bitrate.
Raise the buffer to 90 ms0.6%50 ms more on every frameNothing extra sent.

Each row is the same minute of network replayed with that change. Waiting 50 ms longer fixes it with nothing extra sent; three times the bitrate buys back 10 ms of it.

5. Read the protections side by side, and the counts under them.

ProtectionRecoveredConcealedLongest gapWhat it sends
None0%28.3%120 msNothing extra sent.
Opus FEC (this run)13.2%15.1%120 msA lower-bitrate copy, paid for inside the Opus bitrate.
RED, 1 copy13.2%15.1%120 msA full copy, so about twice the audio bitrate.
RED, 2 copies13.2%15.1%120 msTwo full copies, about three times the audio bitrate.

On a phone, what each sends sits under its name.

plaintext
Played on time Rebuilt from a copy Lost by the network Arrived too late
71.7% 13.2% 0.9% 27.3%
2152 of 3000 frames 395 frames 28 packets 820 packets, after 70 ms

A copy halves what no protection leaves, and a second RED copy adds nothing at this buffer. The counts say why: 27.3% of packets arrived too late against 0.9% lost by the network. The packets got there, after their slot, and so did most of the copies.

6. Try it. Set the buffer to 90 and Run. Concealed drops to 0.6%, and the playout delay goes from 70 ms to 120 ms.

What the result says, in order#

  1. The answer: the share of the audio the decoder made up, the longest run of it, and how long after sending every frame plays. Not coloured: the 1% line the advice aims at is this tool’s choice, not a standard, so it is drawn on the buffer chart instead.
  2. The buffer’s trade, the chart the decision turns on (see below).
  3. What to change, when there is something to: every buffer and protection the tool tried on the same minute that would make up less, or reach under 1% with less delay. Opus FEC and one RED copy reach back the same packet, so where they tie the table keeps the cheaper one. When nothing reaches 1%, it says how close the best setting gets.
  4. The protections, all four at this run’s buffer, with what each costs on the wire.
  5. Where the frames went: played, rebuilt, lost by the network, and arrived too late.
  6. The charts of single frames and packets.

The model#

  • Delay. Every packet takes the one-way delay plus an extra delay drawn from an exponential distribution with the jitter as its mean. A packet that arrives after its slot is as good as lost (RFC 7005 section 3).
  • Loss. A two-state Gilbert model. With a burst length of 1 each loss is independent; with 3, a loss takes three packets on average, at the same overall rate.
  • Copies. Opus in-band FEC carries a lower-bitrate copy of frame n-1 inside packet n (RFC 7587 section 3.3). RED carries whole earlier frames (RFC 2198), here one or two. A copy only helps if the packet carrying it arrives before the missing frame’s slot, so reaching back k packets needs at least 20k ms of buffer.

Reading the charts#

  1. The buffer’s trade. The same minute replayed at every buffer from 0 to 200 ms, or further when the buffer is deeper: how many frames arrive too late, and how many are still concealed after the copies. The marks show this run’s buffer and the shallowest one that gets under 1% made up. Every millisecond of buffer is added to every frame.
  2. What Alice hears. The first five seconds, a row per second and a column per 20 ms frame, with only the incidents drawn: frames rebuilt from a copy and frames made up by the decoder. An empty stretch is audio that played as sent. When nothing went wrong in those five seconds, the chart is left out.
  3. Arrivals. Each packet’s trip, for the first five seconds, against the time it had. Points above the line arrived too late to play.

What it deliberately does not model#

  • FEC on every packet. Real Opus re-encodes only frames it judges important, so this is the most FEC can do.
  • Correlated delay. Real queues delay neighbouring packets together, which bunches late packets like a burst.
  • An adaptive buffer. libwebrtc’s moves its target with the delay it measures; this one is fixed.
  • Quality. A rebuilt frame counts as rebuilt, whether it came from a lower-bitrate FEC copy or a full RED copy.

References#

Source and references

index.ts456 lines, TypeScript
TypeScript
/**
* Jitter, loss and the ways a receiver covers for them, over a few seconds of one voice stream.
*
* A frame leaves every 20 ms and must play at a fixed time: when it was sent, plus the one-way delay, plus the
* jitter buffer. Each packet's trip gets a random extra delay on top of the fixed one, so some arrive after
* their time and are as good as lost. Some are lost outright, at random or in bursts. What the listener hears
* for each frame is one of three things:
*
* - **played**: the packet itself arrived in time;
* - **recovered**: it did not, but a later packet carrying a copy of it did, in time for this frame's slot;
* - **concealed**: neither, so the decoder makes up 20 ms from the audio before.
*
* The copy comes from one of two schemes. **Opus in-band FEC** (RFC 7587 section 3.3) puts a lower-bitrate
* re-encoding of frame n-1 inside packet n, so it reaches back one packet. **RED** (RFC 2198) carries whole
* earlier frames as redundant blocks, here one or two of them, so it can bridge a burst of two.
*
* ⚠️ What it assumes, so the numbers are read as a model and not a measurement:
*
* - every packet carries an FEC copy. Real Opus only re-encodes frames it judges important, so this is the
* best FEC can do, not what it does;
* - the extra delay per packet is exponential with the mean given, independent from packet to packet. Real
* queues correlate, which makes late packets bunch together, much like bursts of loss;
* - bursts follow a two-state Gilbert model: once in the lossy state, each next packet stays lost with
* probability 1 - 1/burst, so bursts average the length given;
* - the buffer is fixed. An adaptive one, as libwebrtc's is, would move it with the jitter it measures.
*
* Seeded, so the same inputs always give the same run, and a different seed is a different few seconds. That is
* also what makes the advice honest: every change it suggests is the same minute of network replayed with that change.
*/
import type { Field, Output, Series, Tool } from "@toolbench/sdk";
type Input = { delay: number; jitter: number; buffer: number; loss: number; burst: number; protection: string; packets: number; seed: number };
class InputError extends Error {
readonly input: string;
constructor(message: string, input: string) {
super(message);
this.name = "InputError";
this.input = input;
}
}
const FRAME_MS = 20;
/**
* The arrivals chart shows the first five seconds; the numbers come from the whole run. A minute of packets is
* what makes a percentage of a few percent settle: over five seconds one burst more or less moves it by half.
*/
const PLOTTED = 250;
/**
* The line the advice aims at, as a share of frames the decoder makes up. The tool's own choice, not a standard,
* which is why it is drawn on the buffer chart and never used to colour a result.
*/
const CLEAN = 1;
/** How far back each scheme's copies reach: Opus FEC carries one earlier frame, RED as many as it is set to. */
const SCHEMES: { id: string; label: string; reach: number; cost: string }[] = [
{ id: "none", label: "None", reach: 0, cost: "nothing extra sent" },
/*
* Opus FEC and one RED copy reach back the same one packet, so they recover the same frames here. They
* differ in what the copy is and what it costs, which the protection table says in words.
*/
{ id: "fec", label: "Opus FEC", reach: 1, cost: "a lower-bitrate copy, paid for inside the Opus bitrate" },
{ id: "red1", label: "RED, 1 copy", reach: 1, cost: "a full copy, so about twice the audio bitrate" },
{ id: "red2", label: "RED, 2 copies", reach: 2, cost: "two full copies, about three times the audio bitrate" },
];
/** Mulberry32: small, fast, and the same sequence in every JavaScript engine for the same seed. */
function random(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
type Packet = { sent: number; arrives: number | null };
/**
* One run's packets: their send times, and when each arrives or that it never does. Delays and losses are drawn
* once, so changing the buffer or the protection replays the same network rather than a new one.
*/
function network(input: Input): Packet[] {
const next = random(input.seed);
const loss = input.loss / 100;
// Gilbert: leave the lossy state with probability 1/burst, enter it at the rate that gives the loss rate asked.
const leave = 1 / input.burst;
const enter = loss >= 1 ? 1 : (leave * loss) / (1 - loss);
let lossy = next() < loss;
const out: Packet[] = [];
for (let n = 1; n <= input.packets; n++) {
if (n > 1) lossy = lossy ? next() >= leave : next() < enter;
const extra = -input.jitter * Math.log(1 - next());
const sent = n * FRAME_MS;
out.push({ sent, arrives: lossy ? null : Math.round((sent + input.delay + extra) * 10) / 10 });
}
return out;
}
type Outcome = "played" | "recovered" | "concealed";
/** What the listener gets for each frame, given the buffer and how far back the copies reach. */
function outcomes(packets: Packet[], delay: number, buffer: number, reach: number): Outcome[] {
return packets.map((p, i) => {
const deadline = p.sent + delay + buffer;
if (p.arrives !== null && p.arrives <= deadline) return "played";
for (let k = 1; k <= reach; k++) {
const later = packets[i + k];
if (later?.arrives !== null && later?.arrives !== undefined && later.arrives <= deadline) return "recovered";
}
return "concealed";
});
}
/** A stretch of audio: "400 ms", "9.1 s". */
const span = (ms: number) => (ms < 1000 ? `${ms} ms` : `${Math.round(ms / 100) / 10} s`);
const pct = (count: number, of: number) => Math.round((count / of) * 1000) / 10;
const count = (list: Outcome[], what: Outcome) => list.filter((o) => o === what).length;
/**
* Shares to one decimal that always add up, so every scheme's bar on one network has the same height, which is
* the point of drawing them side by side. Concealed is rounded from its own count and recovered takes the
* remainder, not the other way round: concealment is what the buffer chart plots, and rounding it second let
* a deeper buffer appear to conceal 0.1% more.
*/
function shares(list: Outcome[]): { played: number; recovered: number; concealed: number } {
const missed = pct(list.length - count(list, "played"), list.length);
const concealed = pct(count(list, "concealed"), list.length);
return { played: Math.round((100 - missed) * 10) / 10, recovered: Math.round((missed - concealed) * 10) / 10, concealed };
}
/** The longest run of concealed frames, which is what a listener notices: one 20 ms gap is a click, ten are a dropout. */
function longestGap(list: Outcome[]): number {
let longest = 0;
let run = 0;
for (const o of list) {
run = o === "concealed" ? run + 1 : 0;
longest = Math.max(longest, run);
}
return longest;
}
export default {
run(input: Input) {
try {
const { delay, jitter, buffer, loss, burst, packets: total, seed } = input;
if (!(delay >= 0)) throw new InputError("One-way delay cannot be negative.", "delay");
if (!(jitter >= 0)) throw new InputError("Jitter cannot be negative.", "jitter");
if (!(buffer >= 0)) throw new InputError("The jitter buffer cannot be negative.", "buffer");
if (!(loss >= 0 && loss < 100)) throw new InputError("Loss must be at least 0% and below 100%.", "loss");
if (!(burst >= 1)) throw new InputError("Bursts average at least one packet: 1 means losses are independent.", "burst");
if (!(Number.isInteger(total) && total >= 10 && total <= 6000)) throw new InputError("Packets must be a whole number from 10 to 6,000.", "packets");
if (!Number.isInteger(seed)) throw new InputError("The seed must be a whole number.", "seed");
const scheme = SCHEMES.find((s) => s.id === input.protection);
if (!scheme) throw new InputError(`Protection must be one of ${SCHEMES.map((s) => s.id).join(", ")}.`, "protection");
const packets = network(input);
const here = outcomes(packets, delay, buffer, scheme.reach);
const lost = packets.filter((p) => p.arrives === null).length;
const late = packets.filter((p) => p.arrives !== null && p.arrives > p.sent + delay + buffer).length;
const played = count(here, "played");
const recovered = count(here, "recovered");
const concealed = count(here, "concealed");
const gap = longestGap(here);
const share = shares(here);
const playsAt = delay + buffer;
/*
* The buffer sweep, done once and read three times: by the advice, by the chart, and by the annotation. In
* steps of 10 ms from 0 to 200, or further when the run's own buffer is deeper, with the run's own depth
* added when it is not a multiple of 10, so "this run" is always a point on the line.
*/
const top = Math.max(200, Math.ceil(buffer / 10) * 10);
const depths = [...new Set([...Array.from({ length: top / 10 + 1 }, (_, i) => i * 10), buffer])].sort((x, y) => x - y);
const concealedAt = depths.map((b) => shares(outcomes(packets, delay, b, scheme.reach)).concealed);
const enough = depths.find((_, i) => (concealedAt[i] ?? 100) < CLEAN);
/*
* The answer: how much was made up, the longest stretch of it, and what the buffer costs every frame. No tone,
* because the only line it could be judged against is one this tool chose, and the buffer chart draws that.
*/
const answer: Output = {
kind: "fields",
role: "answer",
fields: [
{
label: "Concealed",
value: `${share.concealed}%`,
note: concealed === 0 ? `Nothing made up in ${span(total * FRAME_MS)}` : `${span(concealed * FRAME_MS)} made up in ${span(total * FRAME_MS)}`,
},
{
label: "Longest gap",
value: `${gap * FRAME_MS} ms`,
note: gap > 1 ? `${gap} frames in a row were made up.` : gap === 1 ? "Only single frames were made up." : "No frame was made up.",
},
{
label: "Playout delay",
value: `${playsAt} ms`,
note: buffer === 0 ? "No buffer: all of it is the trip" : `${buffer} ms of it is the buffer`,
},
],
};
/*
* What to change, from the same network replayed: the shallowest buffer that brings made-up audio under 1%
* with this protection, and every other protection that would make up less at this buffer. Each row is a
* replay, not a rule of thumb, and they are sorted by what they leave made up, least first.
*/
type Advice = { change: string; after: number; delay: string; cost: string };
const advice: Advice[] = [];
const costOf = (s: (typeof SCHEMES)[number]) => `${s.cost.charAt(0).toUpperCase()}${s.cost.slice(1)}.`;
const shift = (to: number) =>
to > buffer ? `${to - buffer} ms more on every frame` : to < buffer ? `${buffer - to} ms less on every frame` : "No change";
for (const other of SCHEMES) {
const same = other.id === scheme.id;
const sweep = same ? concealedAt : depths.map((b) => shares(outcomes(packets, delay, b, other.reach)).concealed);
// The shallowest buffer that gets this protection under 1%, and what it does at this run's buffer.
const at = depths.findIndex((_, i) => (sweep[i] ?? 100) < CLEAN);
const here = sweep[depths.indexOf(buffer)] ?? 100;
const to = at === -1 ? undefined : depths[at];
if (to !== undefined && to !== buffer && (to > buffer ? share.concealed >= CLEAN : true)) {
const move = to > buffer ? `Raise the buffer to ${to} ms` : `Lower the buffer to ${to} ms`;
advice.push({
change: same ? move : `${move} and switch to ${other.label}`,
after: sweep[at] ?? 0,
delay: shift(to),
cost: same ? (to < buffer ? "Nothing extra sent, and the conversation is quicker." : "Nothing extra sent.") : costOf(other),
});
}
if (!same && here < share.concealed && (to === undefined || to >= buffer)) {
advice.push({ change: `Switch to ${other.label}`, after: here, delay: "No change", cost: costOf(other) });
}
}
/*
* Opus FEC and one RED copy reach back the same packet, so at the same buffer they rebuild the same frames.
* SCHEMES is in order of cost, so keeping the first of two rows with the same delay and the same result keeps
* the cheaper one, and the table never recommends paying twice the bitrate for nothing.
*/
const seen = new Set<string>();
const distinct = advice.filter((x) => {
const key = `${x.delay}|${x.after}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
distinct.sort((x, y) => x.after - y.after);
/*
* When nothing reaches 1%, the reader still needs to know how far the settings can go: the least made-up audio
* any protection reaches at any buffer swept, cheapest and shallowest first among equals.
*/
if (distinct.length === 0 && share.concealed >= CLEAN) {
let best = { after: share.concealed, depth: buffer, at: scheme };
for (const other of SCHEMES) {
for (const b of depths) {
const after = shares(outcomes(packets, delay, b, other.reach)).concealed;
if (after < best.after) best = { after, depth: b, at: other };
}
}
distinct.push(
best.after < share.concealed
? {
change: `The most these settings reach: a ${best.depth} ms buffer with ${best.at.label}`,
after: best.after,
delay: shift(best.depth),
cost: best.at.id === scheme.id ? "Nothing extra sent." : costOf(best.at),
}
: { change: "Nothing tried here makes up less", after: share.concealed, delay: "No change", cost: "The network has to improve." },
);
}
const actions: Output = {
kind: "table",
role: "actions",
caption:
distinct.every((x) => x.after >= CLEAN)
? `No buffer up to ${top} ms gets made-up audio under 1% with any protection on this network. It is ${share.concealed}% now.`
: `What to change, each tried on this same minute of network. Made-up audio is ${share.concealed}% now.`,
columns: [
{ label: "Change", wrap: "prose" },
{ label: "Made-up audio after", align: "end", mono: true, wrap: "none" },
{ label: "Delay", wrap: "prose" },
{ label: "What it costs", wrap: "prose" },
],
rows: distinct.map((a) => [a.change, { text: `${a.after}%`, mono: true }, a.delay, a.cost]),
};
/*
* Where the frames went, quietly, under the comparison. The longest gap and the buffer's delay are in the answer,
* so they are not said again here.
*/
const metrics: Field[] = [
{ label: "Played on time", value: `${share.played}%`, note: `${played} of ${total} frames` },
{ label: "Rebuilt from a copy", value: `${share.recovered}%`, note: scheme.id === "none" ? "No copies are sent" : `${recovered} frames` },
{ label: "Lost by the network", value: `${pct(lost, total)}%`, note: `${lost} packets` },
{ label: "Arrived too late", value: `${pct(late, total)}%`, note: late === 0 ? `None after ${playsAt} ms` : `${late} packets, after ${playsAt} ms` },
];
/*
* What Alice hears: the first five seconds, a row per second and a column per 20 ms frame, with only the
* incidents drawn. Played frames are left out: at 95% of the minute they gave the ordinary case far more ink
* than the frames that went wrong, so a run of made-up frames is now the only thing in its row.
*
* Two categories, two point series, each with its own colour and marker, rather than a heatmap whose key would
* show them as amounts. A series with no points in the window is left out, so the key names only what is drawn.
*/
const perSecond = 1000 / FRAME_MS;
const window = here.slice(0, PLOTTED);
const label: Record<Exclude<Outcome, "played">, string> = { concealed: "Made up by the decoder", recovered: "Rebuilt from a copy" };
// Made-up frames first, so the problem takes the strongest colour.
const incidents = (["concealed", "recovered"] as const).flatMap((kind): Series[] => {
const at = window.flatMap((o, i) => (o === kind ? [i] : []));
if (at.length === 0) return [];
return [
{
label: label[kind],
shape: "points",
x: at.map((i) => (i % perSecond) + 1),
points: at.map((i) => Math.floor(i / perSecond) + 1),
notes: at.map((i) => `frame ${i + 1}, at ${((i * FRAME_MS) / 1000).toFixed(2)} s: ${label[kind].toLowerCase()}`),
},
];
});
const seconds = Math.ceil(window.length / perSecond);
const strip: Output | null =
incidents.length === 0
? null
: {
kind: "series",
chart: {
xLabel: "Frame within the second",
yLabel: seconds > 1 ? `Second, the first ${seconds}` : "Second",
/*
* The series carry their own x. These two hold the x axis to the whole second, frame 1 to 50, which
* the incidents alone would not: without them a second whose only gap is at frame 12 draws frame 12
* at the edge.
*/
x: [1, Math.min(perSecond, window.length)],
series: incidents,
readout: { mode: "point" },
},
};
/*
* C: every packet's trip against the time it had. Two point series, on time and too late, so the colour
* and the legend both say which side of the rule a packet fell; lost packets have no trip to plot.
*/
const shown = packets.slice(0, PLOTTED);
const numbers = shown.map((_, i) => i + 1);
const trip = (p: Packet) => (p.arrives === null ? null : Math.round((p.arrives - p.sent) * 10) / 10);
const onTime = shown.map((p) => (p.arrives !== null && p.arrives <= p.sent + delay + buffer ? trip(p) : null));
const tooLate = shown.map((p) => (p.arrives !== null && p.arrives > p.sent + delay + buffer ? trip(p) : null));
const arrivals: Output = {
kind: "series",
chart: {
xLabel: total > PLOTTED ? `Packet, the first ${PLOTTED} of ${total}` : "Packet",
yLabel: "Trip",
yUnit: "ms",
x: numbers,
series: [
{ label: "On time", unit: "ms", shape: "points", points: onTime },
{ label: "Too late", unit: "ms", shape: "points", points: tooLate },
],
thresholds: [{ y: delay + buffer, label: `plays at ${delay + buffer} ms`, tone: "warn" }],
readout: { mode: "point" },
},
};
/*
* D: the buffer's trade, over the same packets. More buffer catches more late packets and adds that much
* delay to every frame. Two lines: what arrives too late, and what is still concealed after the copies.
*/
const lateAt = depths.map((b) => pct(packets.filter((p) => p.arrives !== null && p.arrives > p.sent + delay + b).length, total));
const trade: Output = {
kind: "series",
chart: {
xLabel: "Jitter buffer",
xUnit: "ms",
yLabel: "Frames",
yUnit: "%",
x: depths,
/*
* Log, because the trade spans orders of magnitude: with no buffer nearly every packet is late,
* and the decisions are made down where it is a few percent or less. A share of zero cannot sit on
* a log axis; the runtime leaves those points out and the data table says so.
*/
yScale: "log",
series: [
{ label: "Arrive too late", unit: "%", shape: "line", markers: true, points: lateAt },
{ label: scheme.id === "none" ? "Concealed" : `Concealed, with ${scheme.label}`, unit: "%", shape: "line", markers: true, points: concealedAt },
],
/*
* Both marks the decision needs: where this run sits, and the shallowest buffer that gets under the
* line. When they are the same depth, one mark says both rather than two labels colliding.
*/
annotations:
enough === buffer
? [{ x: buffer, label: "this run, the shallowest under 1%" }]
: [{ x: buffer, label: "this run" }, ...(enough !== undefined ? [{ x: enough, label: "shallowest under 1%" }] : [])],
thresholds: [{ y: CLEAN, label: "1% made up", tone: "warn" }],
},
};
/*
* E: what each scheme buys on this run's network, at this buffer. A table rather than bars, because it is what a
* reader compares digit by digit, and bars of the same two columns only repeated it: what each recovers, what it
* still leaves made up, its longest gap, and what it costs on the wire. The run's own scheme is named in words
* rather than coloured, since being the current choice is not a judgement on it.
*/
const byScheme = SCHEMES.map((s) => outcomes(packets, delay, buffer, s.reach));
const protection: Output = {
kind: "table",
role: "comparison",
caption: `Protection, at a ${buffer} ms buffer`,
columns: [
{ label: "Protection", wrap: "prose", rowHeader: true },
{ label: "Recovered", align: "end", mono: true, wrap: "none" },
{ label: "Concealed", align: "end", mono: true, wrap: "none" },
{ label: "Longest gap", align: "end", mono: true, wrap: "none" },
// Words, not a measurement: start-aligned, so on a phone it falls under the row's name as a note.
{ label: "What it sends", wrap: "prose" },
],
rows: SCHEMES.map((s, i) => {
const o = byScheme[i] ?? [];
return [s.id === scheme.id ? `${s.label} (this run)` : s.label, `${shares(o).recovered}%`, `${shares(o).concealed}%`, `${longestGap(o) * FRAME_MS} ms`, costOf(s)];
}),
};
return {
kind: "group",
/*
* The answer, then the buffer trade it turns on, then what to change when there is something to change,
* then the protections side by side and the counts behind them. The pictures of individual frames and
* packets come after, as the evidence: they show the first five seconds, and the numbers are the minute.
*/
parts: [
answer,
trade,
...(distinct.length > 0 || share.concealed >= CLEAN ? [actions] : []),
protection,
{ kind: "fields", role: "metrics", fields: metrics },
...(strip ? [strip] : []),
arrivals,
],
};
} catch (error) {
if (error instanceof InputError) return { kind: "error", message: error.message, input: error.input };
throw error;
}
},
} satisfies Tool<Input>;

Related posts

2 items
  • Sep 17, 2026 22 min read Networking & media

    Late is lost

    Packets arrive unevenly, and a packet that misses its slot is as good as lost. How a jitter buffer trades delay for dropouts, what concealment can and can't hide, and what Opus FEC and RED buy against random loss and against bursts.

    Real-time voice, from the packet upaudionetworkingwebrtc
  • Networking & media

    Late is lost

    Packets arrive unevenly, and a packet that misses its slot is as good as lost. How a jitter buffer trades delay for dropouts, what concealment can and can't hide, and what Opus FEC and RED buy against random loss and against bursts.

    audionetworking
    Sep 2026
  • Series

    Real-time voice, from the packet up

    How a voice call actually works, one layer at a time.

    6 posts