Files
ai-podcast/backend/services/transcription.py
T
lukeandClaude Opus 5 2901e5f4fb Stop Whisper misspelling the intern's name as Devin
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 <noreply@anthropic.com>
2026-08-14 04:20:22 -05:00

124 lines
4.4 KiB
Python

"""Whisper transcription service"""
import tempfile
import numpy as np
from faster_whisper import WhisperModel
import librosa
WHISPER_MODEL = "distil-large-v3"
# Global model instance (loaded once)
_whisper_model = None
def get_whisper_model() -> WhisperModel:
"""Get or create Whisper model instance"""
global _whisper_model
if _whisper_model is None:
print(f"Loading Whisper {WHISPER_MODEL} model...")
_whisper_model = WhisperModel(WHISPER_MODEL, device="cpu", compute_type="int8")
print("Whisper model loaded")
return _whisper_model
def decode_audio(audio_data: bytes, source_sample_rate: int = None) -> tuple[np.ndarray, int]:
"""
Decode audio from various formats to numpy array.
Args:
audio_data: Raw audio bytes
source_sample_rate: If provided, treat as raw PCM at this sample rate
Returns:
Tuple of (audio array as float32, sample rate)
"""
# If sample rate is provided, assume raw PCM (from server-side recording)
if source_sample_rate is not None:
print(f"Decoding raw PCM at {source_sample_rate}Hz, {len(audio_data)} bytes")
if len(audio_data) % 2 != 0:
audio_data = audio_data + b'\x00'
audio = np.frombuffer(audio_data, dtype=np.int16).astype(np.float32) / 32768.0
return audio, source_sample_rate
print(f"First 20 bytes: {audio_data[:20].hex()}")
# Try to decode with librosa first (handles webm, ogg, wav, mp3, etc via ffmpeg)
try:
with tempfile.NamedTemporaryFile(suffix='.webm', delete=False) as f:
f.write(audio_data)
temp_path = f.name
audio, sample_rate = librosa.load(temp_path, sr=None, mono=True)
print(f"Decoded with librosa: {len(audio)} samples at {sample_rate}Hz")
import os
os.unlink(temp_path)
return audio.astype(np.float32), sample_rate
except Exception as e:
print(f"librosa decode failed: {e}, trying raw PCM at 16kHz...")
# Fall back to raw PCM (16-bit signed int, 16kHz mono - Whisper's rate)
if len(audio_data) % 2 != 0:
audio_data = audio_data + b'\x00'
audio = np.frombuffer(audio_data, dtype=np.int16).astype(np.float32) / 32768.0
return audio, 16000
async def transcribe_audio(audio_data: bytes, source_sample_rate: int = None,
context_hint: str = "") -> str:
"""
Transcribe audio data to text using Whisper.
Args:
audio_data: Audio bytes (webm, ogg, wav, or raw PCM)
source_sample_rate: If provided, treat audio_data as raw PCM at this rate
context_hint: Optional extra context for the initial prompt (e.g. caller name/topic)
Returns:
Transcribed text
"""
model = get_whisper_model()
print(f"Transcribing audio: {len(audio_data)} bytes")
# Decode audio from whatever format
audio, detected_sample_rate = decode_audio(audio_data, source_sample_rate)
print(f"Audio samples: {len(audio)}, duration: {len(audio)/detected_sample_rate:.2f}s")
print(f"Audio range: min={audio.min():.4f}, max={audio.max():.4f}")
# Check if audio is too quiet
if np.abs(audio).max() < 0.01:
print("Warning: Audio appears to be silent or very quiet")
return ""
# Resample to 16kHz for Whisper
if detected_sample_rate != 16000:
audio_16k = librosa.resample(audio, orig_sr=detected_sample_rate, target_sr=16000)
print(f"Resampled to {len(audio_16k)} samples at 16kHz")
else:
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 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}"
# Transcribe
segments, info = model.transcribe(
audio_16k,
beam_size=5,
language="en",
vad_filter=True,
initial_prompt=initial_prompt,
)
segments_list = list(segments)
text = " ".join([s.text for s in segments_list]).strip()
print(f"Transcription result: '{text}' (language: {info.language}, prob: {info.language_probability:.2f})")
return text