From 2901e5f4fb4b28c5b0af7581a95ca7a642514e21 Mon Sep 17 00:00:00 2001 From: tcpsyn Date: Fri, 14 Aug 2026 04:20:22 -0500 Subject: [PATCH] Stop Whisper misspelling the intern's name as Devin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The intern reached 21 of 58 published transcripts as "Devin". The obvious fix — seed his name in the Whisper initial prompt — only works for one of the two transcription paths: - backend/services/transcription.py (live show) uses mlx_whisper and does take an initial_prompt. Added Devon there. This also picks up the Big Bend prompt rewrite from the relocation work, which was sitting uncommitted. - publish_episode.py, which actually produces the published transcripts, uses LightningWhisperMLX, whose transcribe() signature is (audio_path, language). It accepts no initial_prompt at all, so there is nothing to seed. So the published path gets a deterministic correction pass instead: fix_proper_nouns() rewrites known mishearings after transcription, preserving casing (Devin/DEVIN/devin -> Devon/DEVON/devon) and matching whole words only, so "Devinshire" is left alone. Verified against the real episode 58 transcript: 24 occurrences to 0, output byte-identical to the manual relabel in bf1afef. Swapping the publish path to mlx_whisper would allow a real prompt, but that changes the transcription engine for every episode and is a bigger call than this warrants. Co-Authored-By: Claude Opus 5 --- backend/services/transcription.py | 2 +- publish_episode.py | 33 ++++++++++++++++++-- tests/test_proper_nouns.py | 52 +++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 tests/test_proper_nouns.py diff --git a/backend/services/transcription.py b/backend/services/transcription.py index d9d04d3..8582157 100644 --- a/backend/services/transcription.py +++ b/backend/services/transcription.py @@ -103,7 +103,7 @@ async def transcribe_audio(audio_data: bytes, source_sample_rate: int = None, audio_16k = audio # Build initial prompt — context helps Whisper with names and topic-specific words - initial_prompt = "Luke at the Roost, a late-night radio talk show in New Mexico. The host Luke talks to callers about life, relationships, sports, politics, and pop culture." + initial_prompt = "Luke at the Roost, a late-night radio talk show in Alpine, Texas, in the Big Bend region. The host Luke talks to callers about life, relationships, sports, politics, and pop culture, with his intern Devon. Callers reference Alpine, Marfa, Marathon, Terlingua, Fort Stockton, and Big Bend." if context_hint: initial_prompt += f" {context_hint}" diff --git a/publish_episode.py b/publish_episode.py index 951b17a..4308795 100755 --- a/publish_episode.py +++ b/publish_episode.py @@ -244,6 +244,34 @@ TRANSCRIPT: return result +# Recurring show names Whisper reliably mishears. LightningWhisperMLX takes no +# initial_prompt (its transcribe() signature is just audio_path + language), so +# unlike make_clips.py we cannot condition the model on these up front — they +# have to be corrected afterwards. "Devin" reached 21 of 58 published +# transcripts before this existed. +PROPER_NOUN_FIXES = { + "devin": "Devon", +} + + +def fix_proper_nouns(text: str) -> str: + """Correct known Whisper mishearings, preserving the original casing style.""" + if not text: + return "" + + def _sub(match): + word = match.group(0) + correct = PROPER_NOUN_FIXES[word.lower()] + if word.isupper(): + return correct.upper() + if word.islower(): + return correct.lower() + return correct + + pattern = r"\b(" + "|".join(PROPER_NOUN_FIXES) + r")\b" + return re.sub(pattern, _sub, text, flags=re.IGNORECASE) + + def transcribe_audio(audio_path: str) -> dict: """Transcribe audio using Lightning Whisper MLX (Apple Silicon GPU).""" print(f"[1/5] Transcribing {audio_path} (MLX GPU)...") @@ -268,12 +296,13 @@ def transcribe_audio(audio_path: str) -> dict: for segment in result.get("segments", []): start_ms, end_ms, text = segment[0], segment[1], segment[2] + text = fix_proper_nouns(text.strip()) transcript_segments.append({ "start": start_ms / 1000.0, "end": end_ms / 1000.0, - "text": text.strip() + "text": text }) - full_text.append(text.strip()) + full_text.append(text) print(f" Transcribed {duration} seconds of audio ({len(transcript_segments)} segments)") return { diff --git a/tests/test_proper_nouns.py b/tests/test_proper_nouns.py new file mode 100644 index 0000000..8dd91a8 --- /dev/null +++ b/tests/test_proper_nouns.py @@ -0,0 +1,52 @@ +"""Whisper mishears recurring show names; correct them after transcription. + +publish_episode.py transcribes with LightningWhisperMLX, whose transcribe() +signature is (audio_path, language) — it accepts no initial_prompt, so there is +no way to condition it on proper nouns the way make_clips.py does with +mlx_whisper. The intern came out as "Devin" in 21 of 58 published transcripts +because of this. + +A deterministic pass over the finished text fixes the known names without +swapping the transcription engine. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from publish_episode import fix_proper_nouns + + +def test_corrects_the_intern_name_in_all_casings(): + assert fix_proper_nouns("Devin, where's my coffee?") == "Devon, where's my coffee?" + assert fix_proper_nouns("DEVIN: Hey Luke.") == "DEVON: Hey Luke." + assert fix_proper_nouns("devin said so") == "devon said so" + + +def test_leaves_correct_spelling_alone(): + text = "Devon is the intern. DEVON: Hi." + assert fix_proper_nouns(text) == text + + +def test_only_matches_whole_words(): + """Must not corrupt a longer word that happens to contain the name.""" + assert fix_proper_nouns("Devinshire") == "Devinshire" + assert fix_proper_nouns("mcdevins") == "mcdevins" + + +def test_preserves_surrounding_text_exactly(): + src = "LUKE: Alright. Let's check in with Devin and see how he's doing.\n\nDEVIN: Hey!" + out = fix_proper_nouns(src) + assert out == "LUKE: Alright. Let's check in with Devon and see how he's doing.\n\nDEVON: Hey!" + assert len(out) == len(src) + + +def test_empty_and_none_safe(): + assert fix_proper_nouns("") == "" + assert fix_proper_nouns(None) == "" + + +def test_word_count_is_never_changed(): + src = "Devin talked to Devin about Devin. " * 20 + assert len(fix_proper_nouns(src).split()) == len(src.split())