Created
September 9, 2026 10:24
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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