Skip to content

Instantly share code, notes, and snippets.

@dettmering
Created September 9, 2026 10:24
Show Gist options
  • Select an option

  • Save dettmering/2193b1b77e17d1dc7e83cfd931bb0c90 to your computer and use it in GitHub Desktop.

Select an option

Save dettmering/2193b1b77e17d1dc7e83cfd931bb0c90 to your computer and use it in GitHub Desktop.
Converts an MS Teams VTT transkript into a readable format while removing artifacts and filler words.
#!/usr/bin/env python3
"""Clean a meeting transcript into readable per-speaker turns.
Handles WEBVTT (.vtt) and SubRip (.srt) subtitle files as well as plain text.
Consecutive cues from the same speaker are merged into one turn, timing and
cue-ID lines are stripped, contentless interjections ("Mhm.", "Hm.", "Ähm")
are filtered out, and whitespace is normalised.
Usage:
python3 parse_transcript.py "meeting.vtt" > clean.txt
Output format:
[Speaker Name] merged text of the turn ...
Turns that consist only of interjections are dropped entirely; if that leaves
two turns of the same speaker adjacent, they are merged.
If no speaker tags are present, lines are de-duplicated and cleaned, and the
output is the plain spoken text with blank lines between blocks.
"""
import re
import sys
# Contentless hesitation/backchannel tokens: mhm, mmh, mm, hm, hmm, mhmm ...
# plus äh/ähm/öh/öhm/ehm and uh/uhm. Deliberately does NOT include words that
# carry meaning in German ("eh", "um", "hä") or emphasis ("ah", "oh").
_FILLER_WORD = r"(?:[mh]{2,6}|ä+h+m*|ö+h+m*|e+h+m+|u+h+m*)"
# A filler plus the punctuation/whitespace clinging to it.
_FILLER_RE = re.compile(rf"\b{_FILLER_WORD}\b[\s,.;:!?…\-–—]*", re.IGNORECASE)
# Words that carry no information when a turn consists of nothing else: German
# backchannels and particles, plus the English fragments Teams' ASR hallucinates
# out of a swallowed "mhm" ("I.", "Yeah.", "Up.", "Who?", "Come.", "That's.").
# Single letters are treated as noise too ("I.", "E.", "K.", "O. K.").
_NOISE_WORDS = {
# German acknowledgment / particles
"ja", "jo", "joa", "jaja", "genau", "gut", "ok", "okay", "klar", "richtig",
"stimmt", "eben", "absolut", "sicher", "schön", "super", "prima", "gerne",
"aha", "ach", "achso", "ah", "oh", "ohh", "tja", "also", "so", "halt", "ne", "nen", "na",
"sag", "mal", "wie", "gesagt", "ha",
# bare pronouns / articles left over from a cut-off word
"ich", "du", "er", "sie", "es", "wir", "ihr", "man", "das", "die", "der",
# English ASR artefacts (harmless in German transcripts; review for English ones)
"yeah", "yes", "yep", "yup", "but", "and", "huh", "i", "a", "o", "e", "u", "it", "its", "it's", "that", "that's",
"thats", "the", "up", "who", "come", "young", "well", "right", "sure", "what",
"was",
}
# Never drop a turn containing one of these: a bare "Nein." is usually the answer
# to a question, not a backchannel.
_KEEP_WORDS = {"nein", "nee", "nö", "doch", "nicht", "kein", "keine", "nix"}
_WORD_RE = re.compile(r"[^\W\d_]+(?:'[^\W\d_]+)?")
def is_contentless(text: str) -> bool:
"""True if the whole turn is backchannel, particles or ASR noise."""
words = _WORD_RE.findall(text.lower())
if not words:
return True # punctuation only
if any(w in _KEEP_WORDS for w in words):
return False
return all(len(w) == 1 or w in _NOISE_WORDS for w in words)
_LEADING_PUNCT_RE = re.compile(r"^[\s,.;:!?…\-–—]+")
_SPACE_BEFORE_PUNCT_RE = re.compile(r"\s+([,.;:!?…])")
def strip_fillers(text: str) -> str:
"""Remove contentless interjections and tidy up the punctuation they leave."""
cleaned = _FILLER_RE.sub(" ", text)
if cleaned.strip() == text.strip():
return text.strip()
cleaned = _SPACE_BEFORE_PUNCT_RE.sub(r"\1", cleaned)
cleaned = _LEADING_PUNCT_RE.sub("", cleaned)
cleaned = " ".join(cleaned.split())
# A trailing comma is an artefact of a removed trailing filler.
cleaned = re.sub(r"[,;:]$", ".", cleaned)
# Re-capitalise if the removed filler took the sentence's capital with it.
if cleaned and text.strip()[:1].isupper() and cleaned[:1].islower():
cleaned = cleaned[0].upper() + cleaned[1:]
return cleaned
def _merge_turns(turns):
"""Join adjacent turns of the same speaker (dropped fillers can create them)."""
merged = []
for spk, txt in turns:
if merged and merged[-1][0] == spk:
merged[-1][1] = f"{merged[-1][1]} {txt}"
else:
merged.append([spk, txt])
return merged
def parse(text: str) -> str:
# Speaker-tagged VTT cues: <v Speaker>...</v> (may span multiple lines).
cues = re.findall(r"<v\s+([^>]+)>(.*?)</v>", text, re.S)
if cues:
turns = _merge_turns(
[spk.strip(), " ".join(txt.split())] for spk, txt in cues
)
kept = []
for spk, txt in turns:
txt = strip_fillers(txt)
if not txt or is_contentless(txt):
continue
kept.append([spk, txt])
return "\n\n".join(f"[{s}] {t}" for s, t in _merge_turns(kept))
# No speaker tags: strip VTT/SRT scaffolding and de-duplicate.
lines = text.splitlines()
out = []
for ln in lines:
s = ln.strip()
if not s:
continue
if s == "WEBVTT" or s.startswith("NOTE"):
continue
# timestamp lines (00:00:01.000 --> 00:00:03.000)
if "-->" in s:
continue
# bare cue numbers / ids
if re.fullmatch(r"\d+", s):
continue
if re.fullmatch(r"[0-9a-fA-F-]{20,}(/\d+-\d+)?", s):
continue
s = re.sub(r"<[^>]+>", "", s) # any stray markup
# Keep a leading "[Speaker]" tag out of the filler pass.
m = re.match(r"\[([^\]]+)\]\s*(.*)$", s)
tag, body = (f"[{m.group(1)}] ", m.group(2)) if m else ("", s)
body = strip_fillers(body)
if not body or is_contentless(body): # nothing but interjections/backchannel
continue
s = (tag + body).strip()
if out and out[-1] == s: # collapse exact repeats
continue
out.append(s)
return "\n".join(out)
def main():
if len(sys.argv) < 2:
sys.exit("Usage: python3 parse_transcript.py <transcript-file>")
path = sys.argv[1]
with open(path, encoding="utf-8", errors="replace") as f:
text = f.read()
sys.stdout.write(parse(text) + "\n")
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment