How to Remove Duplicate Lines From YouTube Auto Captions
YouTube auto captions roll, so each cue repeats the lines already on screen and appends the one being spoken, and a naive extraction reads every sentence two or three times. The fix is one rule: drop any line identical to the line emitted immediately before it, after stripping inline tags and collapsing whitespace.
TL;DR
YouTube auto caption cues roll, so each cue repeats the lines already on screen and appends the new one. Drop any line identical to the line emitted immediately before it, after stripping inline tags and collapsing whitespace. A real 18-minute auto-caption file drops from 1,484 lines and 10,052 words to 498 lines and 3,366 words.
Try it on your own script
Paste your draft below. You get your hook, structure, and pacing scores, a script-level attention-risk map, and the single biggest issue quoted from your own lines. Free, no login.
Free · No login · See a sample audit first if you prefer.
Key Takeaways
- YouTube auto captions roll: each cue repeats the lines already on screen and appends the one being spoken, so reading every line writes each sentence two or three times
- Remove them with one rule: drop any line identical to the line you emitted immediately before it
- On a real 18-minute auto-caption file pulled with yt-dlp 2026.08.19 on 19 September 2026, that rule cut 1,484 VTT text lines to 498, and 10,052 words to 3,366
- Without the rule, the same file reports 538 words per minute for a speaker who talks at 180
- The same 18-minute track produced byte-identical cleaned prose from its VTT rendition and its JSON3 rendition
- Of 162 automatic caption keys yt-dlp listed for the measured video, 131 carried a tlang= parameter and were machine translations; one key, en-orig, was the original speech recognition track
Key Statistics
- •A real 18-minute auto-caption file pulled with yt-dlp 2026.08.19 on 19 September 2026 carried 1,484 non-empty VTT text lines and 10,052 words, and consecutive-duplicate removal left 498 lines and 3,366 words, a reduction of 2.99x.
- •The published transcript for the same video reports 3,368 words over 18 minutes 40 seconds, or 180 words per minute, measured 19 September 2026; the two-word gap is exactly the two [Music] segments a sound-cue filter removes.
- •Across 349 YouTube videos measured in July 2026, speaking rate had a median of 181 words per minute, a 25th percentile of 160 and a 75th percentile of 201, from prepublish-fe/lib/seo/wpm-data.ts.
- •Of 162 automatic caption keys yt-dlp listed for the measured video on 19 September 2026, 131 carried a tlang= parameter and were machine translations; one key, en-orig, was the original speech recognition track.
- •The same 18-minute track produced byte-identical cleaned prose from its VTT rendition (171,557 bytes) and its JSON3 rendition (361,161 bytes), measured 19 September 2026.
In This Guide
- How to Remove Duplicate Lines From YouTube Auto Captions
- What rolling cues are in YouTube auto captions
- YouTube VTT vs JSON3: where the duplicate text lives
- Deduplicating YouTube auto captions: the six steps
- Python: clean an ASR transcript from VTT or JSON3
- JavaScript: the same parser in Node
- How to check the cleaned transcript is correct
- What removing duplicate caption lines does not fix
How to Remove Duplicate Lines From YouTube Auto Captions
YouTube auto captions roll. Each cue repeats the lines already on screen and appends the one being spoken, so reading every line writes each sentence two or three times. Remove them with one rule: drop any line identical to the line you emitted immediately before it.
On a real 18-minute auto-caption file pulled with yt-dlp 2026.08.19 on 19 September 2026, that rule cut 1,484 VTT text lines to 498, and 10,052 words to 3,366. Without it, the same file reports 538 words per minute for a speaker who talks at 180.
What rolling cues are in YouTube auto captions
The caption renderer keeps a small window of text on screen, two lines in the file measured here, and scrolls that window as speech continues. The VTT file records what the renderer displays at each moment rather than what was said. Each cue therefore carries every visible line, which puts each line in three consecutive cues: the cue that introduces it with per-word timings inline, a ten-millisecond cue that repeats it alone to flush the window, and the cue where it sits at the top while the next line arrives below.
Keeping the first occurrence of each line and dropping the rest recovers the spoken words exactly. The cue model that produces the repeats is defined in the WebVTT specification: a cue carries a start time, an end time and any number of text lines, and nothing in the format forbids a cue from repeating the text of the cue before it. The table counts what each extraction approach produces on the same file.
| Extraction | Lines kept | Words | Words per minute |
|---|---|---|---|
| Every VTT text line, tags stripped, nothing dropped | 1,484 | 10,052 | 538 |
| The same lines with consecutive-duplicate removal | 498 | 3,366 | 180 |
The file is the auto-generated English track of aircAruvnKk, a 3Blue1Brown video titled "But what is a neural network? | Deep learning chapter 1", runtime 18 minutes 40 seconds, pulled on 19 September 2026. The 986 dropped lines are 66.4 percent of the text lines in the file, and the word count is 2.99 times the real one.
A file without rolling cues needs no such treatment. The manual captions of "Me at the zoo", 19 seconds and six cues, contain no repeated line at all, and the same parser passes it through unchanged at 38 words.
YouTube VTT vs JSON3: where the duplicate text lives
Both formats describe the same speech from the same track. They differ in what one unit means, and that difference decides where the repeats sit.
WebVTT (--sub-format vtt) | JSON3 (--sub-format json3) | |
|---|---|---|
| One unit | a cue block: a timing line plus every visible line | an event: a time plus the words that entered the window |
| Timing | 00:00:04.400 --> 00:00:06.869 align:start position:0% | tStartMs, dDurationMs |
| Text | plain lines, with <00:00:04.799><c> word tags inline | segs[].utf8, with tOffsetMs per word |
| Word timing | inline tags inside the cue text | tOffsetMs inside each segment |
| Line breaks | a newline inside the cue | a separate event holding only "\n" |
| Where the repeat lives | in the file: each line is written two or three times | not in the event text on this track |
| Size of the measured track | 171,557 bytes | 361,161 bytes |
| Non-empty text units | 1,484 | 500 |
| Naive word count | 10,052 | 3,368 |
The VTT excerpt below is the head of that file, verbatim, starting at 4.4 seconds. It covers the stretch to 8.6 seconds, where the speaker begins an explanation of why a 28 by 28 pixel image is still readable.
00:00:04.400 --> 00:00:06.869 align:start position:0%
This<00:00:04.799><c> is</c><00:00:04.960><c> a</c><00:00:05.200><c> three.</c><00:00:05.920><c> It's</c><00:00:06.080><c> sloppily</c><00:00:06.640><c> written</c>
00:00:06.869 --> 00:00:06.879 align:start position:0%
This is a three. It's sloppily written
00:00:06.879 --> 00:00:08.549 align:start position:0%
This is a three. It's sloppily written
and<00:00:07.120><c> rendered</c><00:00:07.440><c> at</c><00:00:07.680><c> an</c><00:00:07.839><c> extremely</c><00:00:08.320><c> low</c>
00:00:08.549 --> 00:00:08.559 align:start position:0%
and rendered at an extremely low
Four things in that block matter to a parser.
This is a three. It's sloppily writtenappears in three cues. Only the first occurrence is new text.- The cue at 6.869 lasts ten milliseconds and carries no new words. It exists to flush the window, and it is the cue a cue-level dedup misses.
- The
<00:00:04.799><c>sequences are word-level timings, not content. The tagged line is 164 characters long and 38 characters once the tags come out; the whole file carries 8,604 tags and 60,228 characters of markup. - A line holding a single space is a blank display line. It is whitespace, and trimming turns it into nothing.
The JSON3 excerpt is the same four seconds in the other container. Whitespace inside each object is compacted here for readability; the field names and values are as returned.
{ "tStartMs": 0, "dDurationMs": 1119069, "id": 1, "wpWinPosId": 1, "wsWinStyleId": 1 },
{ "tStartMs": 0, "dDurationMs": 2510, "wWinId": 1, "segs": [ { "utf8": "[Music]" } ] },
{ "tStartMs": 4390, "wWinId": 1, "aAppend": 1, "segs": [ { "utf8": "\n" } ] },
{ "tStartMs": 4400, "dDurationMs": 4159, "wWinId": 1, "segs": [
{ "utf8": "This", "acAsrConf": 0 },
{ "utf8": " is", "tOffsetMs": 399, "acAsrConf": 0 },
{ "utf8": " a", "tOffsetMs": 560, "acAsrConf": 0 },
{ "utf8": " three.", "tOffsetMs": 800, "acAsrConf": 0 },
{ "utf8": " It's", "tOffsetMs": 1520, "acAsrConf": 0 },
{ "utf8": " sloppily", "tOffsetMs": 1680, "acAsrConf": 0 },
{ "utf8": " written", "tOffsetMs": 2240, "acAsrConf": 0 } ] },
{ "tStartMs": 6869, "dDurationMs": 1690, "wWinId": 1, "aAppend": 1, "segs": [ { "utf8": "\n" } ] },
{ "tStartMs": 6879, "dDurationMs": 4561, "wWinId": 1, "segs": [
{ "utf8": "and", "acAsrConf": 0 },
{ "utf8": " rendered", "tOffsetMs": 241, "acAsrConf": 0 },
{ "utf8": " at", "tOffsetMs": 561, "acAsrConf": 0 },
{ "utf8": " an", "tOffsetMs": 801, "acAsrConf": 0 },
{ "utf8": " extremely", "tOffsetMs": 960, "acAsrConf": 0 },
{ "utf8": " low", "tOffsetMs": 1441, "acAsrConf": 0 } ] },
- The first object has no
segs. ItsdDurationMsof 1,119,069 ms is the video duration, 18 minutes 39 seconds, and it is the only object that carrieswpWinPosIdandwsWinStyleId. - Each
segsarray holds only the words that entered the window at that moment, andtOffsetMsgives the offset of that word inside the event. Word timing survives without any inline markup. - The
"\n"events markedaAppend: 1are the line breaks the player inserts. They hold no words, so a parser that joinssegs[].utf8and drops empty results removes them without a special case. - No two consecutive events in this file carried the same text. The repeat is a property of the VTT rendering of the same track, not of the JSON3 body, which is why a parser can read both shapes with one dedup rule at the end.
How to pull both renditions
yt-dlp --skip-download --write-auto-subs --sub-langs "en-orig" --sub-format vtt \
-o "%(id)s" "https://www.youtube.com/watch?v=aircAruvnKk"
yt-dlp --skip-download --write-auto-subs --sub-langs "en-orig" --sub-format json3 \
-o "json3_%(id)s" "https://www.youtube.com/watch?v=aircAruvnKk"
en-orig is the original speech recognition track. In yt-dlp's JSON for that video, automatic_captions held 162 keys and 131 of them carried a tlang= parameter, which marks a translation the player generates on request rather than a track the creator published. One key ended in -orig, and that was the original. Prefer a creator-uploaded track when one exists, then the -orig track, then the ASR key for the video's spoken language. That order is implemented in pickOriginalTrack in prepublish-be/internal/infrastructure/youtube/channel_dlp.go and pickTrack in prepublish-extension/src/content/youtube-main.js.
The official route returns a track only for a video you have permission to edit: captions.download accepts the youtube.force-ssl or youtubepartner scope and costs 200 quota units per call, with a 403 when the permission is missing. For your own uploads that is the stable path and it returns the same VTT and JSON3 shapes these parsers consume.
Both pulls above ran from a residential connection. On a datacenter IP the same command can return metadata and zero caption characters unless curl_cffi is installed, measured 19 September 2026, and a residential proxy alone did not fix that. If your caption body arrives empty or short, the problem is upstream of the parser and no dedup rule will repair it.
Deduplicating YouTube auto captions: the six steps
Six steps, in this order. Every one of them exists for a reason visible in the excerpt above.
- Skip structural lines:
WEBVTT,Kind:,Language:,NOTE,STYLE,REGION,X-TIMESTAMP-MAP, anything containing-->, and anything that is only digits. - Strip inline tags with
<[^>]+>, which removes<c>,</c>and the<00:00:04.799>word markers in one pass. - Collapse runs of whitespace to a single space and trim. A newline inside a cue is a display wrap, not a sentence break.
- Drop a line that is nothing but a bracketed sound cue, matching the pattern
^[\[(][^\])]*[\])]$, so[Music]and(laughs)stay out of the prose. - Drop a line whose text equals the last line you kept. This is the rule that removes rolling repeats, and comparing against the last kept line rather than the previous file line is what preserves genuine repetition later in the video.
- Join the survivors with single spaces, then start a new paragraph when the silence before a cue reaches 2,500 ms or the current paragraph reaches 90 words, whichever comes first. Capitalize the first letter of each paragraph and nothing else, so the parser adds no punctuation of its own.
Steps 1 through 5 are parseVTTToText in prepublish-be/internal/infrastructure/youtube/channel_dlp.go, which calls stripVTTTags and isNumericLine from extractor.go so the two readers cannot drift apart. Step 4, the duration fallback and the paragraph rule come from parseJson3 and segmentsToText in prepublish-extension/src/lib/transcript.js, where SOUND_CUE_ONLY, PARAGRAPH_GAP_MS = 2500, PARAGRAPH_WORDS = 90 and DEFAULT_DURATION_MS = 2000 are defined. Neither parser invents punctuation, and the extension file says so in its own comment.
Python: clean an ASR transcript from VTT or JSON3
The script below reads either container and prints prose. Save it as clean_transcript.py.
#!/usr/bin/env python3
"""Turn a YouTube caption file into clean prose.
Both caption shapes YouTube serves carry the same words in different
containers:
VTT a rolling window. Every cue holds all the visible lines, so the line
being spoken appears in two or three consecutive cues.
JSON3 windowed events. Each event holds the words that entered the window
at that moment, and a separate event carries the line break.
Usage:
python3 clean_transcript.py captions.en.vtt
python3 clean_transcript.py captions.en.json3
python3 clean_transcript.py captions.en.json3 18:39
"""
import json
import re
import sys
TAGS = re.compile(r"<[^>]+>")
# [Music] and (laughs) carry no script content.
SOUND_CUE = re.compile(r"^[\[(][^\])]*[\])]$")
TIME_LINE = re.compile(
r"^(?:(\d{1,2}):)?(\d{2}):(\d{2})[.,](\d{3})\s*-->\s*"
r"(?:(\d{1,2}):)?(\d{2}):(\d{2})[.,](\d{3})"
)
VTT_HEADERS = ("WEBVTT", "Kind:", "Language:", "NOTE", "STYLE", "REGION", "X-TIMESTAMP-MAP")
PARAGRAPH_GAP_MS = 2500 # a silence this long starts a new paragraph
PARAGRAPH_WORDS = 90 # longest paragraph, in words
FALLBACK_DURATION_MS = 2000
def collapse(text):
"""Cue text wraps on hard newlines that are not sentence breaks."""
return re.sub(r"\s+", " ", text).strip()
def strip_tags(text):
return collapse(TAGS.sub("", text))
def to_ms(hours, minutes, seconds, millis):
return ((int(hours or 0) * 60 + int(minutes)) * 60 + int(seconds)) * 1000 + int(millis)
def iter_vtt_cues(body):
"""Yield (start_ms, end_ms, [line, ...]) for each cue block."""
start = end = None
lines = []
for raw in body.splitlines():
line = raw.strip()
match = TIME_LINE.match(line)
if match:
if start is not None:
yield start, end, lines
groups = match.groups()
start = to_ms(*groups[:4])
end = to_ms(*groups[4:])
lines = []
continue
if start is None or not line or line.startswith(VTT_HEADERS) or line.isdigit():
continue
lines.append(line)
if start is not None:
yield start, end, lines
def vtt_cues(body):
"""Drop the part of a rolling cue that the previous cue already said."""
last_kept = ""
for start, end, lines in iter_vtt_cues(body):
for raw in lines:
text = strip_tags(raw)
if not text or SOUND_CUE.match(text) or text == last_kept:
continue
last_kept = text
yield start, max(0, end - start), text
def next_start(events, index, start):
"""A cue with no duration ends where the next one begins."""
for event in events[index + 1 :]:
if isinstance(event, dict) and isinstance(event.get("tStartMs"), (int, float)):
candidate = int(event["tStartMs"])
if candidate > start:
return candidate - start
return FALLBACK_DURATION_MS
def json3_cues(body):
events = json.loads(body).get("events") or []
last_kept = ""
for index, event in enumerate(events):
if not isinstance(event, dict):
continue
segs = event.get("segs")
if not isinstance(segs, list):
continue
text = strip_tags("".join(s.get("utf8", "") for s in segs if isinstance(s, dict)))
if not text or SOUND_CUE.match(text) or text == last_kept:
continue
start = int(event.get("tStartMs") or 0)
duration = int(event.get("dDurationMs") or 0)
if duration <= 0:
duration = next_start(events, index, start)
last_kept = text
yield start, max(0, duration), text
def capitalize_first(text):
return text[:1].upper() + text[1:] if text else ""
def to_prose(cues):
paragraphs = []
current = []
words = 0
previous_end = None
for start, duration, text in cues:
gap = 0 if previous_end is None else start - previous_end
if current and (gap >= PARAGRAPH_GAP_MS or words >= PARAGRAPH_WORDS):
paragraphs.append(" ".join(current))
current = []
words = 0
current.append(text)
words += len(text.split(" "))
previous_end = start + duration
if current:
paragraphs.append(" ".join(current))
return "\n\n".join(capitalize_first(p) for p in paragraphs)
def cues_for(body):
return json3_cues(body) if body.lstrip().startswith("{") else vtt_cues(body)
def runtime_ms(value):
parts = [float(p) for p in value.split(":")]
seconds = 0.0
for part in parts:
seconds = seconds * 60 + part
return seconds * 1000
def main(argv):
body = open(argv[1], encoding="utf-8").read()
prose = to_prose(cues_for(body))
print(prose)
words = len(prose.split())
if len(argv) > 2:
minutes = runtime_ms(argv[2]) / 60000
print(f"\n---\n{words} words over {minutes:.2f} min = {words / minutes:.0f} wpm", file=sys.stderr)
else:
print(f"\n---\n{words} words", file=sys.stderr)
if __name__ == "__main__":
main(sys.argv)
Run it with the file path, and add the runtime as a second argument to get the words-per-minute check described below.
$ python3 clean_transcript.py aircAruvnKk.en-orig.vtt 18:40
The verification section pastes what that run printed.
JavaScript: the same parser in Node
The same rules in JavaScript, with the constants copied from the extension file so the two stay in step. Save it as clean-transcript.mjs and run it with Node 18 or newer.
#!/usr/bin/env node
// Turn a YouTube caption file into clean prose.
// Same constants and the same two rules as prepublish-extension/src/lib/transcript.js:
// consecutive duplicate cue text is dropped, and paragraphs break on a 2.5 s
// silence or at 90 words. The VTT reader is new; JSON3 matches parseJson3.
//
// Usage: node clean-transcript.mjs captions.en.vtt [HH:MM:SS]
import { readFileSync } from 'node:fs';
const TAGS = /<[^>]+>/g;
// [Music] and (laughs) carry no script content.
const SOUND_CUE_ONLY = /^[[(][^\])]*[\])]$/;
const TIME_LINE =
/^(?:(\d{1,2}):)?(\d{2}):(\d{2})[.,](\d{3})\s*-->\s*(?:(\d{1,2}):)?(\d{2}):(\d{2})[.,](\d{3})/;
const VTT_HEADERS = ['WEBVTT', 'Kind:', 'Language:', 'NOTE', 'STYLE', 'REGION', 'X-TIMESTAMP-MAP'];
const PARAGRAPH_GAP_MS = 2500;
const PARAGRAPH_WORDS = 90;
const DEFAULT_DURATION_MS = 2000;
/** Hard line wraps inside a cue are not sentence breaks. */
function cleanText(value) {
if (typeof value !== 'string') return '';
return value.replace(/\s+/g, ' ').trim();
}
function stripTags(value) {
return cleanText(value.replace(TAGS, ''));
}
function toMs(hours, minutes, seconds, millis) {
return ((Number(hours || 0) * 60 + Number(minutes)) * 60 + Number(seconds)) * 1000 + Number(millis);
}
function* iterVttCues(body) {
let start = null;
let end = null;
let lines = [];
for (const raw of body.split(/\r?\n/)) {
const line = raw.trim();
const match = TIME_LINE.exec(line);
if (match) {
if (start !== null) yield { start, end, lines };
const g = match.slice(1);
start = toMs(g[0], g[1], g[2], g[3]);
end = toMs(g[4], g[5], g[6], g[7]);
lines = [];
continue;
}
if (start === null || !line || VTT_HEADERS.some((h) => line.startsWith(h))) continue;
if (/^\d+$/.test(line)) continue;
lines.push(line);
}
if (start !== null) yield { start, end, lines };
}
function* vttCues(body) {
let lastKept = '';
for (const cue of iterVttCues(body)) {
for (const raw of cue.lines) {
const text = stripTags(raw);
if (!text || SOUND_CUE_ONLY.test(text) || text === lastKept) continue;
lastKept = text;
yield { startMs: cue.start, durationMs: Math.max(0, cue.end - cue.start), text };
}
}
}
function nextStart(events, index, start) {
for (let i = index + 1; i < events.length; i += 1) {
const event = events[i];
if (event && typeof event.tStartMs === 'number' && event.tStartMs > start) {
return event.tStartMs - start;
}
}
return DEFAULT_DURATION_MS;
}
function* json3Cues(body) {
const events = (JSON.parse(body).events || []);
let lastKept = '';
for (let i = 0; i < events.length; i += 1) {
const event = events[i];
if (!event || typeof event !== 'object') continue;
if (!Array.isArray(event.segs)) continue;
const text = stripTags(
event.segs.map((s) => (s && typeof s.utf8 === 'string' ? s.utf8 : '')).join(''),
);
if (!text || SOUND_CUE_ONLY.test(text) || text === lastKept) continue;
const startMs = Number(event.tStartMs) || 0;
let durationMs = Number(event.dDurationMs) || 0;
if (durationMs <= 0) durationMs = nextStart(events, i, startMs);
lastKept = text;
yield { startMs, durationMs: Math.max(0, durationMs), text };
}
}
function capitalizeFirst(text) {
return text ? text.charAt(0).toUpperCase() + text.slice(1) : '';
}
function toProse(cues) {
const paragraphs = [];
let current = [];
let words = 0;
let previousEnd = null;
for (const cue of cues) {
const gap = previousEnd === null ? 0 : cue.startMs - previousEnd;
if (current.length && (gap >= PARAGRAPH_GAP_MS || words >= PARAGRAPH_WORDS)) {
paragraphs.push(current.join(' '));
current = [];
words = 0;
}
current.push(cue.text);
words += cue.text.split(' ').filter(Boolean).length;
previousEnd = cue.startMs + cue.durationMs;
}
if (current.length) paragraphs.push(current.join(' '));
return paragraphs.map(capitalizeFirst).join('\n\n');
}
function runtimeMs(value) {
return value.split(':').reduce((total, part) => total * 60 + Number(part), 0) * 1000;
}
const [path, runtime] = process.argv.slice(2);
const body = readFileSync(path, 'utf8');
const prose = toProse(body.trimStart().startsWith('{') ? json3Cues(body) : vttCues(body));
console.log(prose);
const words = prose.split(/\s+/).filter(Boolean).length;
if (runtime) {
const minutes = runtimeMs(runtime) / 60000;
console.error(`\n---\n${words} words over ${minutes.toFixed(2)} min = ${Math.round(words / minutes)} wpm`);
} else {
console.error(`\n---\n${words} words`);
}
How to check the cleaned transcript is correct
Four checks, in the order that catches the most errors for the least reading.
Cross-format agreement. Feed the VTT and the JSON3 of the same track to the same parser. Both containers describe the same speech, so the prose must come out identical. On the measured file it did, in both languages, with no character difference between the four runs. If the two disagree, one reader has a bug and the word counts against each other will say which.
Word count against runtime and speaking rate. Divide the cleaned word count by the runtime in minutes. Across 349 YouTube videos measured in July 2026, speaking rate had a median of 181 words per minute, with 160 at the 25th percentile and 201 at the 75th, from prepublish-fe/lib/seo/wpm-data.ts. A talking-head video that lands far above 201 words per minute has duplication or repeated phrases still in the text. One far below 160 has lost cues, usually to a truncated download or a network block, and the fix is upstream. The same arithmetic through Prepublish's pipeline gives 3,368 words at 180 words per minute for the video measured here, and the transcript tool prints the word count and the runtime for any public link.
| Check | Measured on aircAruvnKk (18:40) | Reading |
|---|---|---|
| Words after dedup | 3,366 | inside the band |
| Words per minute | 180 | 1 below the 181 median, inside 160 to 201 |
| Published transcript count for the same video | 3,368 words, 180 wpm | 2 words apart, and the gap is the two [Music] markers |
| Naive word count | 10,052 | 538 wpm, 2.99x the cleaned count |
| Paragraphs | 37, longest 97 words, shortest 11 | consistent with the 90-word cap plus the cue that crossed it |
| Last cue end | 18:39.069 | 931 ms before the 18:40 runtime |
| Consecutive events with identical text in the JSON3 | 0 | the dedup rule had nothing to remove there |
Spot-check the proper nouns and the numbers. Dedup repairs repetition and nothing else, so the words still need reading. In the 3,366-word transcript, relu appears three times in lower case and ReLU once, sigmoid appears nine times, and 28x 28 reads as two tokens where a person would write one. Reading the first minute and the last minute catches the cases that matter, because names and figures cluster at the start and the end of an explanation.
Recount the words with a second tool. A word counter that reads the cleaned text directly, such as the script word counter, should agree with the parser to within the sound cues you chose to drop. Converting the runtime into minutes at words to minutes gives the same band as a table rather than a command line.
Test output from the runs behind this article, on the pulled file. The first run is cut after its opening paragraph; the whole run printed 37 paragraphs and 3,366 words.
$ python3 clean_transcript.py aircAruvnKk.en-orig.vtt 18:40
This is a three. It's sloppily written and rendered at an extremely low resolution of 28x 28 pixels. But your brain has no trouble recognizing it as a three.
...
Incredibly um deep uh neural networks. All right, thank you Alicia.
---
3366 words over 18.67 min = 180 wpm
$ python3 clean_transcript.py json3_aircAruvnKk.en-orig.json3 18:40
3366 words over 18.67 min = 180 wpm
$ node clean-transcript.mjs aircAruvnKk.en-orig.vtt 18:40
3366 words over 18.67 min = 180 wpm
$ node clean-transcript.mjs json3_aircAruvnKk.en-orig.json3 18:40
3366 words over 18.67 min = 180 wpm
$ diff py_vtt.txt py_json.txt && diff py_vtt.txt js_vtt.txt && diff py_json.txt js_json.txt
$ python3 clean_transcript.py zoo_jNQXAC9IVRw.en.vtt 0:19
38 words over 0.32 min = 120 wpm
The two lines around the ellipsis are the first and last paragraph of the same run. Both parsers agree, both containers agree, and the manual caption file reports the same word count from the Python and the JavaScript reader.
Both parsers were tested on macOS with Python 3.14.6 and Node v24.19.0. The VTT file measured 171,557 bytes and the JSON3 file 361,161 bytes, both pulled from YouTube with yt-dlp 2026.08.19 on 19 September 2026, and a 440-byte manual caption file came from a second video. Four runs produced identical prose, and the manual file produced no duplicates to remove.
What removing duplicate caption lines does not fix
Each row below was observed in the cleaned 3,366-word transcript, or in the second file used for testing.
| Artifact | What dedup does | What would fix it |
|---|---|---|
A word repeated inside a line: at at some point, many many variants, relu relu stands for | Nothing. The line is not identical to the previous line | Reading, or a language model pass with the sentence in front of it |
| A mis-heard proper noun or number | Nothing | The video itself, or the creator's own description and chapter titles |
Filler words that are in the audio: the last paragraph of the measured file reads Incredibly um deep uh neural networks. All right, thank you Alicia. | Nothing. um and uh are separate words, not repeats | A stop word list, or reading the text before publishing it |
One term cased two ways: relu three times and ReLU once | Nothing | A term list applied after parsing |
A bracketed line that carries content: (baaaaaaaaaaahhh!!) | Dropped whenever the bracket covers the whole line | Nothing, unless the pattern is narrowed to known cue words |
| Sentence boundaries and punctuation | Nothing. Neither parser invents punctuation | Human editing |
| Two speakers in one track | Nothing. Neither format carries speaker labels | Manual attribution, or diarization run on the audio |
| A translated track used as the spoken one | Nothing | Selecting the -orig track, or the spoken language |
| A caption body truncated by the network | Nothing | A canary that checks caption character counts, not exit status |
The casing row is the one that costs time at scale. In this single file, relu and ReLU refer to the same function, and a search for either spelling finds only part of the mentions. The same holds for product names, acronyms and spelled-out numbers, which is why the dedup rule belongs before any search, count or summary that treats the words as data.
Deduplication is the step that makes the rest visible. A 10,052-word wall of repeated text hides three stutters, a casing split and at least one number written as two tokens. A 3,366-word paragraph structure puts them where a reader can see them.
Frequently asked questions
Why do YouTube auto captions repeat the same line?
The caption renderer keeps a small window of text on screen and scrolls it as the speaker continues, and the VTT file records what the renderer displays rather than what was said. Each cue therefore carries every visible line, so a line appears in three consecutive cues: the one that introduces it, a ten-millisecond cue that repeats it alone, and the one where it sits at the top while the next line arrives.
What is the quickest way to remove duplicate lines from a YouTube VTT file?
Strip the inline tags, collapse whitespace, then walk the lines and drop any line whose text equals the last line you kept. Comparing against the last kept line rather than the previous file line preserves genuine repetition later in the video. On the 18-minute file measured on 19 September 2026 that one rule removed 986 of 1,484 text lines and 6,686 of 10,052 words.
Do YouTube JSON3 captions contain duplicate lines?
The JSON3 body can carry duplicated cue text, and the parser in Prepublish's extension drops consecutive duplicates because the player has served that shape. In the JSON3 file measured on 19 September 2026, no two consecutive events carried the same text: the events hold only the words that entered the window, and line breaks arrive as their own events. The dedup rule costs nothing when there is nothing to drop, so keep it in both readers.
How do I clean an ASR transcript in Python?
Skip the header, timing and cue-number lines, remove everything between angle brackets with the pattern <[^>]+>, collapse runs of whitespace, drop lines that are only a bracketed sound cue, and drop any line identical to the previous line you kept. Join the survivors with single spaces, and start a new paragraph at a silence of 2,500 ms or at 90 words. The Python sample in this article does all of it for both VTT and JSON3 input.
Why does my cleaned transcript still show 500 words per minute?
Duplication survived the pass. The usual cause is comparing a cue against the previous cue rather than against the last line kept, which misses the rolling flush cues, and the second cause is running the dedup before stripping inline tags so that the tagged and untagged versions of the same line look different. A talking-head video measuring near 500 words per minute against the runtime is roughly three times the expected rate.
Should I delete [Music] and [Applause] lines from a transcript?
Delete them when the bracket is the entire line and the text is for reading, which is what the shipped parser does. The cost is visible: the cleaned prose for the measured video is 3,366 words against the published transcript count of 3,368, and the difference is one [Music] marker at the start and another later. Keep them when the transcript is an archive of the caption file rather than a script.
Can I get YouTube captions from the YouTube Data API instead of yt-dlp?
Only for videos you own or have edit permission on. The captions.download method requires that permission and accepts the youtube.force-ssl or youtubepartner scopes at a quota cost of 200 units per call, so it does not replace yt-dlp for other people's videos. For your own uploads it is the stable route, and it returns the same VTT or JSON3 shapes that the parsers here consume.
Does removing duplicate lines break the timestamps?
No, provided the reader records a time when it keeps a line and ignores the repeat cues afterwards. The kept line takes the start time of the cue that introduced it, and its duration comes from the end of that cue. On a rolling file the flush cue is only ten milliseconds long, so consecutive lines still produce a near-zero gap, which keeps the 2,500 ms paragraph rule from firing inside a sentence.
Related Guides
Free tools to put this into practice
Hook Analyzer
Score your first 1-3 sentences
Title Analyzer
Writing rubric + 5 rewrites
Words to Minutes
Script length calculator
Word Counter
Count, reading time, duration
Want to see how this reads on real channels? Browse the channel breakdowns. Each one compares script patterns across a channel's own higher-viewed and lower-viewed uploads, quoted from the transcripts.
See where your next script leaks viewers
Paste your script, get your scores and the biggest leak for free. No login.