Loss and playout simulator
/tools/loss-playout/One voice frame is lost. UDP conceals that one frame; TCP recovers it and holds every frame behind it, and each one whose playout time passes while it waits is lost to the listener anyway.
One voice frame is lost. UDP conceals that one frame; TCP recovers it and holds every frame behind it, and each one whose playout time passes while it waits is lost to the listener anyway.
A voice call sends a small frame of audio every 20 ms, and the receiver plays each one at a fixed time. One frame is lost. This tool answers what that costs the listener over UDP and over TCP: which frames still arrive before they have to play, and how much audio has to be concealed.
The short answer is that UDP loses the one frame, and TCP loses the one frame plus every frame queued behind it while it waits for the resend. The numbers show how many that is.
Say you’re choosing a transport for a voice feature. Frames are 20 ms of Opus, the path is 30 ms each way, and the receiver holds 40 ms of buffer. One packet in the middle of a sentence, frame 5, is dropped.
1. Enter the call.
| Input | You enter | Why |
|---|---|---|
| Frame size | 20 ms | The usual Opus packet |
| One-way delay | 30 | Half the round trip |
| Jitter buffer | 40 | What the receiver waits before playing |
| Lost frame | 5 | Any frame with at least three after it |
| Retransmission timeout | 200 | Linux’s default minimum |
| Frames | 20 | Enough to see the queue drain |
2. Press Run, and read the table at the top.
Frame 5 lost, at a 40 ms buffer: the audio each loses, the frames, and the buffer that would hide it
| Transport | Audio | Frames | Buffer |
|---|---|---|---|
| UDP | 20 ms | 5 (lost) | never |
| TCP, fast retransmit | 80 ms | 5 to 8 | 120 ms |
| TCP, 200 ms timeout | 160 ms | 5 to 12 | 200 ms |
In plain words: over UDP the listener hears one 20 ms gap, which the decoder papers over. Over TCP the same loss costs four frames at best and eight at worst, because TCP won’t hand over frame 6 until frame 5 has been resent. Frame 9 is held too, and still plays on time: it is released at 250 ms and plays at 250 ms.
3. Read the buffer column. That’s the trade in one column: TCP can hide the loss only with a buffer of 120 ms or 200 ms, 80 or 160 ms more than the 40 in use, and that delay is paid on every word of the call, not just the lost one. UDP never resends, so no buffer brings frame 5 back.
4. Check it. Press the Bigger buffer sample (120 ms) and Run. The fast retransmit row reads 0 ms and “none” frames: the resend lands at 250 ms, exactly when frame 5 has to play. The timeout still misses frames 5 to 8.
5. Read the chart under it. Each line is when the application gets a frame. Points on or under the deadline line play. The TCP lines go flat at the lost frame: that flat stretch is the queue being held, and every point on it above the deadline is audio nobody hears. Under the chart, the three timings it turns on: when frame 5 has to play, and when each kind of resend leaves.
Every number follows from a handful of rules, so every result can be checked by hand.
frame ms: frame n leaves at n x frame.delay ms to arrive. Every frame takes exactly the same time: there is no jitter.n x frame + delay + buffer. Arriving at or before that time is on time.lost, is dropped once.resend + delay).
That is the whole mechanism: a frame can arrive in time and still be held behind the gap.lost + 3 arrives,
and its ACK takes another delay to return. That needs three frames after the loss; with fewer, it cannot
fire and the timer resends instead.rto ms after the lost frame was sent. 200 ms is Linux’s default minimum; RFC 6298
recommends 1 s. The timer is always running, so when it would fire before the third duplicate ACK (60 ms
frames with a 200 ms timer, for instance) the fast-retransmit row resends on the timer too.resend - lost x frame: the repair arrives at resend + delay and the
lost frame plays at lost x frame + delay + buffer, so the delay cancels. That’s the “Buffer”
column, and everything above the buffer you have is added to every frame of the call.At the defaults (20 ms frames, 30 ms each way, 40 ms of buffer, frame 5 lost):
| Transport | Missed | Concealed |
|---|---|---|
| UDP | frame 5 | 20 ms |
| TCP, fast retransmit | frames 5 to 8, all released at 250 ms | 80 ms |
| TCP, 200 ms timeout | frames 5 to 12, released at 330 ms | 160 ms |
A bigger buffer changes the TCP rows and not the UDP one. At 120 ms, fast retransmit’s repair arrives exactly on frame 5’s deadline and nothing is missed, while the timeout still costs frames 5 to 8. That buffer is also 80 ms of extra delay on every word of the call, which is the trade.
Each line is when the application gets a frame. A frame plays if its point is on or under the deadline line. UDP runs parallel to the deadline with one gap. The TCP lines go flat from the lost frame to where they rejoin their own arrival times: that flat stretch is the queue being held, and every frame on it above the deadline is audio nobody hears.
The second chart, after the timings, is why 20 ms is the usual choice. Every packet carries 40 bytes of headers, IPv4’s 20 without options, UDP’s 8 and RTP’s 12, whatever the frame size, so a smaller frame sends more packets and more header bits: at 10 ms the headers cost as much as the sound itself. A bigger frame saves them and loses more audio per lost packet. The sound is fixed at Opus’s 32 kb/s, 80 bytes per 20 ms frame, so the chart compares sizes rather than codecs. SRTP’s authentication tag and any header extensions would add to every packet, and are left out.
index.ts/** * One lost voice frame, over UDP and over TCP. * * A voice call sends a small 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. A frame that is not there by then is useless, however correct it * is when it finally turns up. So the question for a lost frame is not whether it is recovered but which * frames still make their playout deadline, and that is where the two transports differ. * * **UDP** loses the frame and carries on. The decoder conceals one frame's worth of audio and every later * frame plays on time. The cost of one loss is one frame. * * **TCP** recovers the frame, and delivers in order: nothing after the gap reaches the application until the * resent copy does. Every frame queued behind it is held and then released at once, and each one whose * deadline passed while it waited is as lost to the listener as if the network had dropped it. The cost of * one loss is every frame whose deadline falls before the repair, which is the point of the tool. * * Two ways the resend happens: * * - **Fast retransmit** (RFC 5681 section 3.2): each frame after the gap draws a duplicate ACK, and the third * one triggers the resend. That needs three frames to follow the loss, so it cannot fire near the end of a * talk spurt. * - **The retransmission timer** (RFC 6298): with nothing to draw duplicate ACKs, the sender waits out the * timer. 200 ms is Linux's default minimum; the RFC recommends 1 s. * * ⚠️ Where this stops being true: no jitter, one loss, no congestion window effects, no RACK (which current * Linux uses and which can resend sooner than three duplicate ACKs), and the timer is started from the lost * frame's own send time rather than restarted by later ACKs. Each of those would make the TCP numbers worse * or better by a few frames, not change which transport a live voice stream wants. */import type { Cell, Field, Output, Series, Tool } from "@toolbench/sdk";
type Input = { frame: string; delay: number; buffer: number; lost: number; rto: number; frames: number };
/** Named so the error path can say which input was wrong rather than "invalid input". */class InputError extends Error { readonly input: string;
constructor(message: string, input: string) { super(message); this.name = "InputError"; this.input = input; }}
/** Codec frame sizes in common use: Opus runs 10 to 60, G.711 over RTP is usually 20. */const FRAME_SIZES = [10, 20, 40, 60];
/** * For the frame-size chart: Opus at 32 kb/s, which is 80 bytes per 20 ms frame, and 40 bytes of headers per * packet, IPv4's 20 without options, UDP's 8 and RTP's 12 without CSRCs or extensions. Fixed sizes from the * RFCs and one stated bitrate, so the chart computes what a frame size costs rather than quoting anyone. */const OPUS_KBPS = 32;const HEADER_BYTES = 20 + 8 + 12;
/** Duplicate ACKs that trigger a resend without waiting for the timer, per RFC 5681. */const DUP_ACKS = 3;
/** Frame number to the time the application gets it, or null when it never does. */type Delivery = (number | null)[];
type Transport = { label: string; delivered: Delivery; missed: number[]; /** When the resent copy reaches the receiver and releases the queue. Absent for UDP, which never resends. */ repaired?: number;};
/** "5", "19 and 20", "5 to 12", joined when a run breaks, so the note reads the way a person would say it. */function frameList(frames: number[]): string { const runs: [number, number][] = []; for (const n of frames) { const last = runs.at(-1); if (last && n === last[1] + 1) last[1] = n; else runs.push([n, n]); } return runs .map(([a, b]) => (a === b ? `${a}` : b === a + 1 ? `${a} and ${b}` : `${a} to ${b}`)) .join(", ");}
const plural = (count: number, word: string): string => `${count} ${word}${count === 1 ? "" : "s"}`;
/** * Why 20 ms: what each frame size costs on the wire, beside how much audio one lost packet takes with it. * The sound costs the same at every size; the headers are paid per packet, so a smaller frame sends more * packets and more header bits. A bigger frame saves those and loses more audio per loss. Bars, stacked, * one row per size, because the question is a total and the share of it that is overhead. */function frameCost(chosen: number): Output { const kbps = (n: number) => Math.round(n * 10) / 10; const headers = FRAME_SIZES.map((ms) => kbps((HEADER_BYTES * 8) / ms)); const rows = FRAME_SIZES.map((ms) => `${ms} ms${ms === chosen ? " (this run)" : ""}`); return { kind: "series", chart: { xLabel: "Frame size", yLabel: "Bits on the wire", yUnit: "kb/s", x: rows, orientation: "horizontal", series: [ // Named plainly: the key adds the unit, and the bitrate and header size are in each readout note. { label: "Opus", unit: "kb/s", shape: "bar", stack: "wire", points: FRAME_SIZES.map(() => OPUS_KBPS), notes: FRAME_SIZES.map((ms) => `${OPUS_KBPS} kb/s, ${(OPUS_KBPS * ms) / 8} bytes a frame`) }, { label: "Headers", unit: "kb/s", shape: "bar", stack: "wire", points: headers, notes: headers.map((h) => `${h} kb/s, ${HEADER_BYTES} bytes a packet, ${Math.round((h / (h + OPUS_KBPS)) * 100)}% of the total`), }, ], readout: { titles: FRAME_SIZES.map((ms) => `${ms} ms: ${kbps(1000 / ms)} packets a second, one loss erases ${ms} ms`), }, }, };}
export default { run(input: Input) { try { const frameMs = Number(input.frame); const { delay: oneWay, buffer, lost, rto, frames } = input;
if (!FRAME_SIZES.includes(frameMs)) throw new InputError(`Frame size must be one of ${FRAME_SIZES.join(", ")} ms.`, "frame"); if (!(oneWay >= 0)) throw new InputError("One-way delay cannot be negative.", "delay"); if (!(buffer >= 0)) throw new InputError("Jitter buffer cannot be negative.", "buffer"); if (!(Number.isInteger(frames) && frames >= 1)) throw new InputError("Frames must be a whole number, at least 1.", "frames"); if (!(Number.isInteger(lost) && lost >= 1)) throw new InputError("The lost frame must be a whole frame number, at least 1.", "lost"); if (lost > frames) throw new InputError(`Frame ${lost} is never sent: the run has ${frames} frames.`, "lost"); /* * A timer shorter than the round trip fires before any acknowledgement could have come back, so it * would resend frames that were never lost. Real TCP cannot get there: RFC 6298 builds the timer from * the measured round trip, so it is always at least that long. */ if (!(rto >= 2 * oneWay)) { throw new InputError( `A retransmission timer shorter than the round trip fires before any ACK could return: at ${oneWay} ms each way it must be at least ${2 * oneWay} ms.`, "rto", ); }
const numbers = Array.from({ length: frames }, (_, i) => i + 1); const sent = (n: number) => n * frameMs; const arrives = (n: number) => sent(n) + oneWay; const deadline = (n: number) => sent(n) + oneWay + buffer; const missedIn = (delivered: Delivery) => numbers.filter((n) => { const t = delivered[n - 1]; return t === null || t === undefined || t > deadline(n); });
/* * In-order delivery is the whole mechanism: a frame after the gap can arrive in time and still be held, * because the application only ever sees the stream up to the first hole. So each later frame goes up * at whichever is later, its own arrival or the repair. */ const tcp = (label: string, resendAt: number): Transport => { const repaired = resendAt + oneWay; const delivered = numbers.map((n) => (n < lost ? arrives(n) : Math.max(arrives(n), repaired))); return { label, delivered, missed: missedIn(delivered), repaired }; };
const udpDelivered = numbers.map((n) => (n === lost ? null : arrives(n))); const udp: Transport = { label: "UDP", delivered: udpDelivered, missed: missedIn(udpDelivered) };
const timerAt = sent(lost) + rto; const canFastRetransmit = lost + DUP_ACKS <= frames; // The third frame after the gap arrives, and its duplicate ACK spends one more trip getting back. const thirdDupAck = canFastRetransmit ? sent(lost + DUP_ACKS) + 2 * oneWay : null; /* * The timer is running whether or not duplicate ACKs come, so the resend is whichever fires first. With * 60 ms frames the third duplicate ACK can be slower than a 200 ms timer, and showing it as the later * of the two would describe a sender that does not exist. */ const fastAt = thirdDupAck === null ? timerAt : Math.min(thirdDupAck, timerAt); const fast = tcp("TCP, fast retransmit", fastAt); const timeout = tcp(`TCP, ${rto} ms timeout`, timerAt); const transports = [udp, fast, timeout];
let fastNote: string; if (thirdDupAck === null) { const after = frames - lost; const follow = after === 0 ? "no frame follows" : `only ${plural(after, "frame")} ${after === 1 ? "follows" : "follow"}`; fastNote = `It cannot fire: ${follow} the loss and ${DUP_ACKS} are needed, so the timer resends.`; } else if (thirdDupAck > timerAt) { fastNote = `The timer fires first: the third duplicate ACK would reach the sender at ${thirdDupAck} ms.`; } else { fastNote = `The third duplicate ACK, drawn by frame ${lost + DUP_ACKS}, reaches the sender.`; }
/* * The answer is a comparison, so it is a table: the three transports in one frame, what each loses, which * frames, and the buffer that would hide the loss. No tone and no "nothing to change" row: the reader is * choosing between the three, and none of them is the tool's to approve. * * The buffer that hides a resend: the repair reaches the receiver at resend + delay and the lost frame plays * at sent + delay + buffer, so the buffer that just covers it is resend - sent, and the delay cancels. Every * millisecond of it is also added to every frame of the call, which is why the answer for live voice is * usually UDP. UDP never resends, so no buffer hides its loss. */ const ms = (t: Transport) => t.missed.length * frameMs; const comparison: Output = { kind: "table", role: "comparison", // Short headers, because a comparison keeps its columns on a phone; the caption says what each one is. caption: `Frame ${lost} lost, at a ${buffer} ms buffer: the audio each loses, the frames, and the buffer that would hide it`, columns: [ { label: "Transport", wrap: "prose", rowHeader: true }, { label: "Audio", align: "end", mono: true, wrap: "none" }, { label: "Frames", align: "end", mono: true, wrap: "none" }, { label: "Buffer", align: "end", mono: true, wrap: "none" }, ], rows: transports.map((t): Cell[] => [ t.label, `${ms(t)} ms`, t.missed.length === 0 ? "none" : t.repaired === undefined ? `${frameList(t.missed)} (lost)` : frameList(t.missed), t.repaired === undefined ? "never" : `${t.repaired - oneWay - sent(lost)} ms`, ]), };
const fields: Field[] = [ { label: `Frame ${lost} plays at`, value: `${deadline(lost)} ms`, note: `It is sent at ${sent(lost)} ms, arrives at ${arrives(lost)} ms, then waits ${buffer} ms in the buffer.`, }, { label: "Fast retransmit resends at", value: `${fastAt} ms`, note: fastNote }, { label: "Timeout resends at", value: `${timerAt} ms`, note: `That is ${rto} ms after frame ${lost} was sent.` }, ];
/* * Delivery time against frame number, beside the deadline. A frame plays when its point is on or below * the deadline line. The TCP lines run flat from the loss to where they meet their own arrival times * again: that flat stretch is the queue being held, and everything on it above the deadline is audio * the listener never hears. */ const line = (label: string, points: (number | null)[]): Series => ({ label, unit: "ms", shape: "line", points }); const chart = { /* * Linked to the per-frame table below, both ways: pointing at a frame lights its row, and pointing * at a row moves the readout to that frame. The chart's own data table lights too, once opened. */ id: "playout", readout: { highlightTable: true }, xLabel: "Frame", yLabel: "Time", yUnit: "ms", x: numbers, /* * Markers on the transports, not on the deadline: frames are separate events, so each delivery is * a point and the line through them only guides the eye. The markers also keep UDP findable on * frames 1 to 4, where all three transports deliver at the same moment and their lines coincide. * The deadline is a rule, a line is exactly what it is. */ series: [line("Playout deadline", numbers.map(deadline)), ...transports.map((t) => ({ ...line(t.label, t.delivered), markers: true }))], annotations: [{ x: lost, label: `frame ${lost} lost` }], };
const cell = (n: number, t: number | null | undefined): Cell => { if (t === null || t === undefined) return { text: "lost", mono: true, tone: "bad" }; return t > deadline(n) ? { text: `${t} ms, late`, mono: true, tone: "warn" } : { text: `${t} ms, on time`, mono: true }; }; const rows: Cell[][] = numbers.map((n) => [n, sent(n), deadline(n), ...transports.map((t) => cell(n, t.delivered[n - 1]))]);
/* * The comparison, the timeline that explains it, the timings the timeline turns on, then the frame-size * chart, which answers a different question, and last the per-frame audit trail. A page showing the first * four parts shows both charts and leaves the per-frame table behind "show more". */ const parts: Output[] = [ comparison, { kind: "series", chart }, { kind: "fields", fields }, frameCost(frameMs), { kind: "table", caption: "Every frame: when the application gets it, against when it has to play", columns: [ { label: "Frame", align: "end", mono: true, wrap: "none", rowHeader: true }, { label: "Sent (ms)", align: "end", mono: true, wrap: "none" }, { label: "Play by (ms)", align: "end", mono: true, wrap: "none" }, ...transports.map((t) => ({ label: t.label, align: "end" as const, mono: true, wrap: "none" as const })), ], rows, link: { chart: "playout", keys: numbers }, }, ]; return { kind: "group", parts }; } catch (error) { if (error instanceof InputError) return { kind: "error", message: error.message, input: error.input }; throw error; } },} satisfies Tool<Input>;
Twenty milliseconds of speech becomes an 80-byte Opus frame inside a 120-byte packet. Why each header is there, and why one lost packet costs 20 ms over UDP and 80 or more over TCP.
How a voice call actually works, one layer at a time.

Twenty milliseconds of speech becomes an 80-byte Opus frame inside a 120-byte packet. Why each header is there, and why one lost packet costs 20 ms over UDP and 80 or more over TCP.
How a voice call actually works, one layer at a time.