"""Whisper transcription with segment timestamps. Reads audio/*.mp3, writes transcripts/.json: { "guid": ..., "ep_num": ..., "title": ..., "pub_date": ..., "audio_url": ..., "model": ..., "segments": [ {"start": 12.34, "end": 18.90, "text": "..."}, ... ] } Fully resumable: finished transcripts are skipped unless --force. Use --minutes N to transcribe only the first N minutes (testing). """ import argparse import json import os import sys import time from config import (TRANSCRIPT_DIR, WHISPER_MODEL, DEVICE, COMPUTE_TYPE, LANGUAGE, VAD_FILTER, BEAM_SIZE, WORD_TIMESTAMPS, CONDITION_ON_PREVIOUS_TEXT, KEEP_AUDIO) from feed import load_episodes from download import audio_path, slug as ep_slug from preprocess import prepare_audio, prepared_path, load_pcm from lexicon import build_lexicon, tags_for_text # faster-whisper runs one giant FFT over the whole input; multi-hour episodes # blow up RAM (~2.5 GB arrays). Transcribe in fixed windows instead. CHUNK_SECONDS = 600 def transcript_path(ep: dict) -> str: return os.path.join(TRANSCRIPT_DIR, ep_slug(ep) + ".json") def transcribe_episode(model, ep: dict, minutes: float | None) -> dict: src = audio_path(ep) prep = prepared_path(ep) t0 = time.time() prepare_audio(src, prep, max_seconds=minutes * 60 + 5 if minutes else None) print(f" prepped audio in {time.time() - t0:.0f}s -> {os.path.basename(prep)}", flush=True) pcm = load_pcm(prep) total_samples = len(pcm) if minutes: total_samples = min(total_samples, int(minutes * 60 * 16000)) duration = total_samples / 16000.0 segments = [] n_chunks = (total_samples + CHUNK_SECONDS * 16000 - 1) // (CHUNK_SECONDS * 16000) for ci in range(n_chunks): a = ci * CHUNK_SECONDS * 16000 b = min(a + CHUNK_SECONDS * 16000, total_samples) offset = a / 16000.0 chunk = pcm[a:b] segs_iter, info = model.transcribe( chunk, language=LANGUAGE, vad_filter=VAD_FILTER, beam_size=BEAM_SIZE, word_timestamps=WORD_TIMESTAMPS, condition_on_previous_text=CONDITION_ON_PREVIOUS_TEXT, ) for seg in segs_iter: text = seg.text.strip() if not text: continue segments.append({ "start": round(seg.start + offset, 2), "end": round(seg.end + offset, 2), "text": text, }) print(f" chunk {ci + 1}/{n_chunks} done " f"(audio {(b - a) / 16000 / 60:.0f}m, " f"{len(segments)} segs so far)", flush=True) return { "guid": ep["guid"], "ep_num": ep["ep_num"], "title": ep["title"], "pub_date": ep["pub_date"], "audio_url": ep["audio_url"], "page_link": ep.get("page_link", ""), "duration_sec": duration, "model": WHISPER_MODEL, "language": LANGUAGE, "partial_minutes": minutes, "segments": segments, } def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("eps", nargs="*", type=int, help="episode numbers; default: all") ap.add_argument("--minutes", type=float, default=None, help="only transcribe first N minutes (testing)") ap.add_argument("--force", action="store_true", help="re-transcribe even if transcript exists") args = ap.parse_args() from faster_whisper import WhisperModel eps = load_episodes() todo = [] for ep in eps: if ep["ep_num"] is not None and args.eps and ep["ep_num"] not in args.eps: continue tp = transcript_path(ep) if os.path.exists(tp) and not args.force and not args.minutes: continue if not os.path.exists(audio_path(ep)): print(f"skip (no audio): {ep['title']}", file=sys.stderr) continue todo.append(ep) model = None # lazy-load after we know there's work done = 0 for i, ep in enumerate(todo, 1): label = f"Ep {ep['ep_num']}" if ep["ep_num"] else ep["title"][:40] if model is None: print(f"loading whisper model '{WHISPER_MODEL}' " f"(device={DEVICE}, compute={COMPUTE_TYPE}) ...") model = WhisperModel(WHISPER_MODEL, device=DEVICE, compute_type=COMPUTE_TYPE) t0 = time.time() src = audio_path(ep) prep = prepared_path(ep) ok = False try: result = transcribe_episode(model, ep, args.minutes) outp = transcript_path(ep) with open(outp + ".tmp", "w", encoding="utf-8") as f: json.dump(result, f, ensure_ascii=False) os.replace(outp + ".tmp", outp) dur = result["duration_sec"] or 1 rate = dur / max(time.time() - t0, 0.001) n_seg = len(result["segments"]) lexicon = build_lexicon() ep_tag_set: set[str] = set() for seg in result["segments"]: ep_tag_set.update(tags_for_text(seg["text"], lexicon)) print(f"[{i}/{len(todo)}] {label}: {n_seg} segments, " f"{dur/60:.0f} min audio, {rate:.1f}x realtime") print(f" TAGS: {' '.join('#' + t for t in sorted(ep_tag_set)) or '(none)'}") done += 1 ok = True except Exception as e: # noqa: BLE001 print(f"{label}: TRANSCRIBE FAILED - {e}", file=sys.stderr) continue finally: # reclaim disk: prepped wav always goes; mp3 unless KEEP_AUDIO if os.path.exists(prep): try: os.remove(prep) except OSError: pass if not KEEP_AUDIO and ok and not args.minutes: try: os.remove(src) except OSError: pass print(f"transcribed={done} skipped_existing=" f"{len([e for e in eps if os.path.exists(transcript_path(e))])}") if __name__ == "__main__": main()