Files
Nat20-Notes/backend/app/pipeline/transcribe.py
KansaiGaijin bc7ac32de3 Add campaign layer, GPU model caching, file browser cleanup
- Campaigns: new table, CRUD API, React context + provider
- Sessions: scoped to campaigns, paths under campaigns/{id}/
- File browser: scoped per campaign, removed copy/paste/autoPlay
- Sidebar: campaign selector dropdown at top
- Transcribe: GPU model cached/released via job counter
- Jobs: status text updates dynamically in real-time
- Auto-redirect: blocked when summarize job is active
2026-07-10 21:41:29 +12:00

159 lines
5.7 KiB
Python

"""
Transcription + diarization using WhisperX as a Python library.
"""
import gc
import json
from pathlib import Path
import torch
import whisperx
from ..errors import TranscriptionError, DiarizationError
from ..logging_config import get_logger
log = get_logger(__name__)
# Module-level model cache — kept alive across jobs so back-to-back
# transcriptions don't reload from disk each time.
_whisper_model = None
_whisper_config = {} # {"model_size": str, "compute_type": str, "device": str}
def _get_model(model_size: str, compute_type: str, device: str):
global _whisper_model, _whisper_config
if (
_whisper_model is not None
and _whisper_config.get("model_size") == model_size
and _whisper_config.get("compute_type") == compute_type
and _whisper_config.get("device") == device
):
log.info("Reusing cached Whisper model (%s, %s, %s)", model_size, compute_type, device)
return _whisper_model
log.info("Loading Whisper model (%s, %s, %s)", model_size, compute_type, device)
_unload_cached()
_whisper_model = whisperx.load_model(model_size, device, compute_type=compute_type)
_whisper_config = {"model_size": model_size, "compute_type": compute_type, "device": device}
return _whisper_model
def _unload_cached():
global _whisper_model, _whisper_config
if _whisper_model is not None:
log.info("Unloading cached Whisper model")
del _whisper_model
_whisper_model = None
_whisper_config = {}
gc.collect()
if torch.cuda.is_available():
torch.cuda.synchronize()
torch.cuda.empty_cache()
torch.cuda.reset_peak_memory_stats()
def unload_models():
"""Release all GPU memory held by transcription models.
Called by the job runner when no more GPU jobs are queued."""
_unload_cached()
gc.collect()
if torch.cuda.is_available():
torch.cuda.synchronize()
torch.cuda.empty_cache()
torch.cuda.reset_peak_memory_stats()
def transcribe_and_diarize(
audio_path: Path,
output_json: Path,
model_size: str,
compute_type: str,
hf_token: str,
progress_cb=None,
) -> dict:
device = "cuda" if torch.cuda.is_available() else "cpu"
if device == "cpu":
log.warning("No CUDA GPU available - running on CPU, this will be much slower")
def report(msg):
log.info(msg)
if progress_cb:
progress_cb(msg)
report("Loading transcription model...")
try:
model = _get_model(model_size, compute_type, device)
except RuntimeError as e:
if "out of memory" in str(e).lower():
raise TranscriptionError(
f"Ran out of GPU memory loading the '{model_size}' model. "
f"Try a smaller model size in Settings (e.g. 'small' or 'medium'), "
f"or check nothing else is using the GPU right now.", cause=e
)
raise TranscriptionError(f"Failed to load Whisper model '{model_size}': {e}", cause=e)
except Exception as e:
raise TranscriptionError(f"Failed to load Whisper model '{model_size}': {e}", cause=e)
try:
report("Loading audio...")
audio = whisperx.load_audio(str(audio_path))
report("Transcribing...")
result = model.transcribe(audio, batch_size=16)
except RuntimeError as e:
if "out of memory" in str(e).lower():
raise TranscriptionError(
f"Ran out of GPU memory during transcription. Try a smaller Whisper model size in Settings.", cause=e
)
raise TranscriptionError(f"Transcription failed: {e}", cause=e)
except Exception as e:
raise TranscriptionError(f"Transcription failed: {e}", cause=e)
try:
report("Aligning...")
align_model, metadata = whisperx.load_align_model(language_code=result["language"], device=device)
result = whisperx.align(result["segments"], align_model, metadata, audio, device, return_char_alignments=False)
except Exception as e:
raise TranscriptionError(f"Alignment step failed: {e}", cause=e)
finally:
try:
del align_model
except NameError:
pass
gc.collect()
if device == "cuda":
torch.cuda.empty_cache()
if hf_token:
try:
report("Diarizing...")
diarize_model = whisperx.diarize.DiarizationPipeline(use_auth_token=hf_token, device=device)
diarize_segments = diarize_model(audio)
result = whisperx.assign_word_speakers(diarize_segments, result)
except Exception as e:
msg = str(e)
if "gated" in msg.lower() or "403" in msg or "access" in msg.lower():
raise DiarizationError(
"HuggingFace rejected access to the diarization model. Make sure you've accepted the "
"terms on the gated model page (linked in the error below) using the SAME account "
f"your HF token belongs to.\n{msg}", cause=e
)
raise DiarizationError(f"Diarization failed: {msg}", cause=e)
finally:
try:
del diarize_model
except NameError:
pass
gc.collect()
if device == "cuda":
torch.cuda.empty_cache()
else:
report("No HF token set - skipping speaker diarization (all speech will be unattributed)")
try:
output_json.parent.mkdir(parents=True, exist_ok=True)
output_json.write_text(json.dumps(result, ensure_ascii=False), encoding='utf-8')
except OSError as e:
raise TranscriptionError(f"Transcribed successfully, but failed to write output to {output_json}: {e}", cause=e)
report("Done")
return result