DEV Community

Cover image for Clean Up Interview Transcripts Before You Quote Anyone
Lemarie Eaglen
Lemarie Eaglen

Posted on

Clean Up Interview Transcripts Before You Quote Anyone

Every interview I have recorded came back from transcription as something I could read but not yet trust. The words were there, the order was right, and I could search it. What it was not yet was quotable. Numbers disagree between two turns, a name is spelled two ways, and one sentence is missing the negation that flips its meaning.

The mistake is treating the transcript as the record. It is a draft of the record, and the distance between the two is where misquotes come from. So I split the job in two halves: get text out of the audio fast, then run a mechanical pass over that text to isolate the handful of lines that actually need my ears.

For the first half I use Interview Transcript Generator(Video Transcriber AI). It handles uploaded files and public links, labels speakers, and exports in formats I can parse. The second half is a script, and that script is why this write-up exists.

Interview Transcript Generator

What it does

You hand it a recording, either uploaded or pulled from a compatible public link. It returns text with speaker labels and timestamps in more than 200 languages, which matters once your interviews stop being monolingual.

The detail that changes your workflow is speaker separation. An interview transcript without speaker labels is a wall of prose, and you lose the ability to tell your own leading question from the answer. That distinction vanishes when you paste a quote into a document.

There are two transcription modes. The plain one is fast and fine for review. The advanced one is the one to use before publication: it accepts custom terms for names and specialist vocabulary, and removes filler words, false starts, and repeated phrases. That last behaviour is useful and also slightly dangerous, which I will get to.

Export options are TXT, DOCX, SRT, VTT, and CSV. Pick CSV when you intend to analyse anything, SRT or VTT for captions, DOCX for handing text to someone else.

Getting started

Three steps, and nearly all the value sits in step two.

1. Add the interview. Upload the audio or video, or paste a public link. Long recordings are not a practical problem, though the uploader prints its own current ceiling, so read that on the page rather than trusting a number copied into a blog post.

2. Configure before you submit. Set the interview language explicitly instead of relying on auto-detect when you already know it. Turn on speaker separation. Add every proper noun you care about as a custom term.

3. Export with intent. CSV if you will analyse it, TXT or DOCX if you will read it.

Ordering matters. Speaker labels are a processing decision, not a formatting toggle applied afterwards. Same for custom terms. Adding them later means re-running the job.

Why the cleanup deserves a second look

Removing filler words produces a cleaner read. It also removes evidence. If someone said "I mean, I guess, maybe around forty", the hesitation was part of the answer, and forty is not the same claim without it. So I run the cleanup because it saves reading time, and I keep one raw export for anything I plan to quote, cite, or base a decision on. Storage is cheap. Getting the source back on a call is not.

Cleaning the export

This is what I actually run. It reads either the CSV export or a TXT export written as Speaker: text lines, prints a speaker balance table, flags passages containing words whose meaning flips if transcribed slightly wrong, and pulls context windows around a keyword.

Save it as interview_transcript_qc.py:

"""Post-process a speaker-labelled interview transcript export.

Reads either the CSV export or a TXT export written as "Speaker: text" lines.
Prints a speaker balance table, flags passages whose meaning flips if a word
was transcribed slightly wrong, and pulls context windows around a keyword.

Usage:
    python interview_transcript_qc.py transcript.csv --quote pricing
"""

from __future__ import annotations

import argparse
import csv
import re
import sys
from collections import Counter

# Words that change meaning if transcribed slightly wrong. Replay these by ear.
RISKY = frozenset(
    """not never no cannot don't won't didn't isn't wasn't
    accept except affect effect insure ensure assure than then
    may might will would should could shall
    fifteen fifty thirteen thirty eighteen eighty
    million billion thousand percent""".split()
)

SPEAKER_COLS = ("speaker", "speakers", "label", "channel", "who")
TEXT_COLS = ("text", "content", "transcript", "utterance", "sentence", "line")
START_COLS = ("start", "start time", "begin", "from", "start_time")

TURN_RE = re.compile(r"^\s*[\[\(]?(?P<sp>[A-Z][A-Za-z0-9 _\-]{0,24})[\]\)]?\s*[:\-]\s*(?P<tx>.+)$")


def sniff_delimiter(sample):
    header = sample.splitlines()[0] if sample else ""
    for cand in (",", "\t", ";"):
        if cand in header:
            return cand
    return ","


def pick(row, candidates):
    for key in candidates:
        if key in row and row[key] not in (None, ""):
            return str(row[key])
    return None


def load_csv(path):
    with open(path, encoding="utf-8-sig", newline="") as fh:
        sample = fh.read(4096)
        fh.seek(0)
        delim = sniff_delimiter(sample)
        rows = [{(k or "").strip().lower(): v for k, v in r.items()} for r in csv.DictReader(fh, delimiter=delim)]

    turns = []
    for row in rows:
        text = pick(row, TEXT_COLS)
        if text is None:
            continue
        turns.append(
            {
                "speaker": (pick(row, SPEAKER_COLS) or "UNKNOWN").strip(),
                "text": re.sub(r"\s+", " ", text).strip(),
                "start": pick(row, START_COLS) or "",
            }
        )
    return [t for t in turns if t["text"]]


def load_txt(path):
    turns, current = [], None
    with open(path, encoding="utf-8") as fh:
        for raw in fh:
            line = raw.strip()
            if not line:
                continue
            match = TURN_RE.match(line)
            if match:
                if current:
                    turns.append(current)
                current = {"speaker": match.group("sp").strip(), "text": match.group("tx").strip(), "start": ""}
            elif current:
                current["text"] += " " + line
    if current:
        turns.append(current)
    return turns


def analyse(turns):
    stats, order, total = {}, [], 0
    for turn in turns:
        speaker = turn["speaker"]
        if speaker not in stats:
            stats[speaker] = Counter()
            order.append(speaker)
        words = turn["text"].split()
        stats[speaker]["words"] += len(words)
        stats[speaker]["turns"] += 1
        stats[speaker]["max_turn"] = max(stats[speaker]["max_turn"], len(words))
        total += len(words)
    return {"stats": stats, "order": order, "total_words": total, "turns": len(turns)}


def find_risky_passages(turns):
    hits = []
    for index, turn in enumerate(turns):
        words = [w.strip(",.").lower() for w in turn["text"].split()]
        found = sorted({w for w in words if w in RISKY})
        if found:
            hits.append((index, turn, found))
    return hits


def quote_context(turns, keyword, pad=1):
    needle = keyword.strip().lower()
    windows = []
    for index, turn in enumerate(turns):
        if needle in turn["text"].lower():
            lo, hi = max(0, index - pad), min(len(turns), index + pad + 1)
            windows.append([(t["speaker"], t["text"]) for t in turns[lo:hi]])
    return windows


def main(argv=None):
    parser = argparse.ArgumentParser()
    parser.add_argument("path", help="CSV or TXT transcript export")
    parser.add_argument("--quote", default=None, help="keyword to pull context around")
    parser.add_argument("--pad", type=int, default=1, help="turns of context around a match")
    parser.add_argument("--top", type=int, default=5, help="how many flagged passages to print")
    args = parser.parse_args(argv)

    turns = load_csv(args.path) if args.path.lower().endswith(".csv") else load_txt(args.path)
    if not turns:
        print("no turns parsed; check the export format", file=sys.stderr)
        return 1

    report = analyse(turns)
    print("turns parsed      : %d" % report["turns"])
    print("words total       : %d" % report["total_words"])
    print("distinct speakers : %d (%s)" % (len(report["order"]), ", ".join(report["order"])))
    print()
    print("%-14s %7s %7s %7s %8s" % ("speaker", "turns", "words", "longest", "share"))
    for speaker in report["order"]:
        stat = report["stats"][speaker]
        share = (stat["words"] / report["total_words"] * 100) if report["total_words"] else 0
        print("%-14s %7d %7d %7d %7.1f%%" % (speaker, stat["turns"], stat["words"], stat["max_turn"], share))
    print()

    risky = find_risky_passages(turns)
    print("passages needing an audio check: %d (%.1f%% of turns)" % (len(risky), len(risky) / len(turns) * 100))
    for index, turn, found in risky[: args.top]:
        print("  [%s] %s: %s... (%s)" % (index, turn["speaker"], turn["text"][:70], ", ".join(found[:4])))
    print()

    if args.quote:
        windows = quote_context(turns, args.quote, args.pad)
        print('context for "%s": %d window(s)' % (args.quote, len(windows)))
        for window in windows[: args.top]:
            for speaker, text in window:
                print("  %s: %s" % (speaker, text[:120] + ("..." if len(text) > 120 else "")))
            print("  --")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
Enter fullscreen mode Exit fullscreen mode

On a short six-turn extract it prints this:

turns parsed      : 6
words total       : 83
distinct speakers : 2 (Interviewer, Respondent)

speaker          turns   words longest    share
Interviewer          3      36      15    43.4%
Respondent           3      47      17    56.6%

passages needing an audio check: 4 (66.7% of turns)
  [0] Interviewer: So um before we start could you walk me through how pricing actually w... (could)
  [1] Respondent: Sure. The list price is fifteen dollars per seat but most teams end up... (fifteen, fifty)
  [3] Respondent: Forty. And I should add we don't insure the annual plan at that tier... (don't, insure, should)
  [5] Respondent: Support tickets dropped by thirty percent. Honestly the biggest change... (percent, thirty)
Enter fullscreen mode Exit fullscreen mode

Two things come out of it that make the pass worth running.

The speaker share tells me immediately that this extract is mostly one person answering. That is normal for an interview and alarming for a focus group. A focus group where one voice holds 80 percent of the words is a moderation failure, and this table is where that shows up cheaply.

The flagged list is short and specific. Four lines out of six carry a number, or a word like "insure" that sits one syllable from "ensure". Those are the lines I replay before anything goes into print. The other two I trust without listening again.

I use --quote heavily too. Every quote about money needs the question that produced it, because a number separated from its question is how a source ends up feeling misrepresented.

Interview Transcript Generator

Three settings worth knowing

The AI summary is underrated. On a 45 minute interview it gives you a map of themes before you read a line, and you can ask it questions to find where something was discussed. Use it to navigate. Do not use it as a source.

Keep your custom term list in a file. If you interview inside a domain with recurring names, paste the same list every time. This one habit removes most manual corrections.

There is also an online editor. Correct names and punctuation in the browser rather than in a text editor, because edits made there carry into every export format. Fixing a name once instead of five times across five downloads is the whole argument.

What still breaks

Crosstalk is the honest failure. When two people talk over each other, the exchange gets apportioned to whichever speaker the model is more confident about, and no amount of post-processing recovers who said which half. You listen. Same for a cafe recording: the model is not better at separating speech from an espresso machine than you are.

The other thing worth knowing is that published figures for maximum file size disagree between the FAQ and the upload box itself. I stopped quoting either number. Read whatever the screen says at the moment you are uploading.

Why I stopped doing this by hand

This is what I settled on after trying to run the same pipeline on a local model and a pile of glue scripts. Interview Transcript Generator makes the first half quick enough that what remains is deciding which lines deserve your ears, and that is the right split. Export CSV, run the pass, replay the flagged handful.

If you want the same thing running unattended, it also exists as a REST endpoint. Transcript API takes a public URL rather than an upload, covers 200+ languages with speaker diarization, and you poll a returned job id for the result.

The part I have not solved is crosstalk detection after the fact. Listening does not scale, and I have not found a signal in the transcript alone that I would trust. If someone has, that is the next version of this script.

Top comments (0)