second-brain/src/second_brain/transcribe.py
Travis Herbranson 33ae5a6f7d pipeline split: tower transcribe worker + claim queue + faster-whisper
Splits pull+transcribe (now tower-side, eager) from extract+embed
(stays on the dev scheduler). Three machine-coordination pieces land
together because they reference each other:

- v3 migration adds sources.claimed_by + claimed_at — observability +
  stale-claim recovery columns. The actual race-safety primitive is
  `SELECT ... FOR UPDATE SKIP LOCKED` in the new claim helper, so two
  machines can poll the queue without doubling work.

- src/second_brain/claim.py owns the claim dance (claim_next_source,
  release_claim, reap_stale_claims). Both stage gates filter by
  source_type so the tower never grabs articles and the dev side never
  grabs videos.

- src/second_brain/transcribe.py wraps faster-whisper (lazy-imported so
  it stays out of the dev install). resolve_settings() reads env >
  [whisper] block > defaults, falling back to int8 on cpu / float16 on
  cuda when compute_type is unspecified. Default model large-v3.

- src/second_brain/scheduler/transcribe_worker.py is the long-running
  poll loop. Reads pipeline_settings every iteration so the dashboard's
  enable/window/max-items/max-video-length take effect within one
  cycle. Reaps stale claims at startup. SIGTERM-clean. DB-unreachable
  backs off with a log line; never crash-loops.

- adapters/youtube.py drops the torch-whisper transcribe path; pull
  stays. Removes openai-whisper from the default deps and gates
  faster-whisper behind a new `tower` extra (uv sync --extra tower).

- main.py: new `second-brain transcribe-worker` (--once for ad-hoc).
  `process` now article-only on the pull side but still picks up
  TRANSCRIBED of any source_type for the extract step.

Live-verified: migration applies clean, transcribe-worker --once
honors transcription_enabled=false gate.
2026-05-25 10:11:37 -04:00

172 lines
5.6 KiB
Python

"""Audio/video transcription via faster-whisper (CTranslate2 backend).
Lazy-imports `faster_whisper` so the dev side — which doesn't transcribe
anything — doesn't need the heavyweight CUDA wheels installed.
Config keys (read from `[whisper]` in settings.toml, env-overrides win):
- `whisper_model` : the model name. Default "large-v3".
- `whisper_device` : "cuda" | "cpu" | "auto". Default "cuda".
- `whisper_compute_type` : "float16" | "int8" | "int8_float16" | …
Default float16 on cuda, int8 on cpu.
Outputs an SRT file next to `config.subtitles_dir/{stem}.srt` plus a
plain-text transcript string. The pipeline writes the text into
`sources.transcript_text` and the path into `sources.transcript_path`,
matching the previous torch-whisper contract so downstream extract/embed
doesn't need to change.
"""
from __future__ import annotations
import logging
import os
from pathlib import Path
from typing import Iterable, Optional
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Config resolution
# ---------------------------------------------------------------------------
def _resolve(cfg: dict | None, env_key: str, cfg_key: str, default: str) -> str:
env = os.environ.get(env_key)
if env:
return env
if cfg is not None:
v = cfg.get(cfg_key)
if v:
return v
return default
def resolve_settings(config_block: dict | None = None) -> dict[str, str]:
"""Resolve whisper runtime settings from env > config block > defaults.
Pulls model/device/compute_type and picks a sane compute_type per device
when none was specified. Returns plain strings so the caller can stash
them in logs / pass straight to faster-whisper.
"""
model = _resolve(config_block, "WHISPER_MODEL", "whisper_model", "large-v3")
device = _resolve(config_block, "WHISPER_DEVICE", "whisper_device", "cuda")
explicit_compute = None
if config_block is not None:
explicit_compute = config_block.get("whisper_compute_type")
explicit_compute = os.environ.get("WHISPER_COMPUTE_TYPE", explicit_compute)
if explicit_compute:
compute_type = explicit_compute
elif device == "cpu":
compute_type = "int8"
else:
compute_type = "float16"
return {"model": model, "device": device, "compute_type": compute_type}
# ---------------------------------------------------------------------------
# Transcriber
# ---------------------------------------------------------------------------
class Transcriber:
"""faster-whisper wrapper. Model is loaded lazily on first transcribe."""
def __init__(
self,
model: str = "large-v3",
device: str = "cuda",
compute_type: str = "float16",
) -> None:
self.model = model
self.device = device
self.compute_type = compute_type
self._impl = None
def _load(self) -> None:
if self._impl is not None:
return
try:
from faster_whisper import WhisperModel # type: ignore[import-not-found]
except ImportError as exc:
raise RuntimeError(
"faster-whisper is not installed. On the tower run: "
"uv sync --extra tower"
) from exc
logger.info(
"loading faster-whisper model=%s device=%s compute_type=%s",
self.model,
self.device,
self.compute_type,
)
self._impl = WhisperModel(
self.model, device=self.device, compute_type=self.compute_type
)
def transcribe(
self,
media_file: Path,
*,
language: Optional[str] = None,
beam_size: int = 5,
) -> tuple[str, list[dict]]:
"""Transcribe `media_file`. Returns (full_text, segments).
Each segment dict has start/end (float seconds) and text (str) —
same shape the SRT writer needs.
"""
self._load()
assert self._impl is not None # for the type-checker
segments_iter, info = self._impl.transcribe(
str(media_file),
language=language,
beam_size=beam_size,
)
logger.info(
"transcribe start: %s language=%s duration=%.1fs",
media_file.name,
getattr(info, "language", "?"),
getattr(info, "duration", 0.0) or 0.0,
)
segments: list[dict] = []
text_parts: list[str] = []
for seg in segments_iter:
t = (seg.text or "").strip()
segments.append({"start": float(seg.start), "end": float(seg.end), "text": t})
if t:
text_parts.append(t)
return " ".join(text_parts), segments
# ---------------------------------------------------------------------------
# SRT writer
# ---------------------------------------------------------------------------
def write_srt(segments: Iterable[dict], output: Path) -> None:
"""Write a list of {start,end,text} dicts as SRT into `output`."""
output.parent.mkdir(parents=True, exist_ok=True)
with open(output, "w", encoding="utf-8") as fh:
for i, seg in enumerate(segments, start=1):
start = _fmt_timestamp(seg["start"])
end = _fmt_timestamp(seg["end"])
fh.write(f"{i}\n{start} --> {end}\n{seg['text']}\n\n")
def _fmt_timestamp(seconds: float) -> str:
h = int(seconds // 3600)
m = int((seconds % 3600) // 60)
s = int(seconds % 60)
ms = int((seconds % 1) * 1000)
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
__all__ = ["Transcriber", "resolve_settings", "write_srt"]