Files
VoiceStudio/backend/api/routers/dub_core.py
T
Palash DebnathandClaude Opus 4.8 c427ffa62d feat(dub): optional speaker-count hint for diarization (#274) (#275)
When a clip has multiple speakers, pyannote's auto-detect sometimes collapses
them into a single "Speaker 1" — so the transcript merges turns and the dub
mixes voices. The diarization-consumption side is correct (overlap-weighted,
distinct Speaker N ids — pinned by a new test), so the collapse comes from
auto-detect itself.

Add an optional speaker-count hint (the reporter's own suggestion):
- backend: `/dub/transcribe-stream/{job_id}?num_speakers=N` (clamped 1–20;
  None → auto-detect) threaded to `diar_pipe(audio, num_speakers=N)`. Omitted
  entirely when unset so we don't depend on the kwarg in every pyannote build.
- frontend: `dubNumSpeakers` store field + a compact "Speakers" number input
  in the dub panel (placeholder "Auto") + i18n; `transcribeStreamUrl` appends
  the param; the SSE hook reads the hint at stream-open time.

Tests: tests/test_assign_speakers_from_diarization.py (multi-speaker split,
overlap weighting, label robustness, empty-result safety) +
dub.transcribeUrl.test.ts (param appended only for a positive int). Full
backend diarization + frontend suites pass; CJK i18n guard passes.

Does NOT close #274 — pending the reporter confirming that setting the count
resolves the collapse on their video (can't verify pyannote behaviour without
a CUDA box + the clip).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:10:08 +05:30

918 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
import uuid
import asyncio
import logging
import shutil
import subprocess
import soundfile as sf
import torch
from typing import Optional
from fastapi import APIRouter, File, Form, UploadFile, HTTPException
from fastapi.responses import FileResponse, StreamingResponse, JSONResponse
from core.db import db_conn
from core.config import PREVIEW_DIR
from core.tasks import task_manager
from core import event_bus
from schemas.requests import DubIngestUrlRequest
from services.model_manager import get_model, _gpu_pool, _cpu_pool, get_diarization_pipeline, offload_tts_for_asr, restore_tts_after_asr
from services.audio_io import _safe_soundfile_write
from services.ffmpeg_utils import find_ffmpeg
from services.segmentation import (
segment_transcript,
assign_speakers_from_diarization,
assign_speakers_from_turns,
assign_speakers_heuristic,
clean_up_segments,
)
from services import dub_pipeline
router = APIRouter()
logger = logging.getLogger("omnivoice.api")
# ── Legacy-name aliases to services/dub_pipeline.py ────────────────────────
# Phase 2.4 moved the business logic into a service. Other routers
# (dub_generate, dub_translate, dub_export) + internal call sites below still
# reference the `_get_job` / `_save_job` / `_active_procs` names; those
# aliases let the transition happen without a repo-wide rename pass.
#
# New code should import from `services.dub_pipeline` directly. Aliases can
# disappear once every caller updates.
_dub_jobs = dub_pipeline._dub_jobs
_active_procs = dub_pipeline._active_procs
_active_procs_lock = dub_pipeline._active_procs_lock
_DUB_DIR_REAL = dub_pipeline._DUB_DIR_REAL
_compute_file_hash = dub_pipeline.compute_file_hash
_find_cached_job = dub_pipeline.find_cached_job
_safe_job_dir = dub_pipeline.safe_job_dir
_register_proc = dub_pipeline.register_proc
_unregister_proc = dub_pipeline.unregister_proc
_kill_job_procs = dub_pipeline.kill_job_procs
_get_job = dub_pipeline.get_job
_save_job = dub_pipeline.save_job
@router.post("/dub/import-srt/{job_id}")
async def dub_import_srt(job_id: str, file: UploadFile = File(...)):
"""Replace `job["segments"]` with timestamps + text parsed from an SRT
file. Used as a fallback when Whisper mis-transcribes — the user can
point at their own pre-synced subtitles and skip ASR entirely.
Returns the new segment list plus counts of any cues we had to skip or
re-time (overlap shifts). The caller surfaces these so the user knows
if the import wasn't lossless.
"""
job = _get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
try:
raw_bytes = await file.read()
except Exception as e:
raise HTTPException(status_code=400, detail=f"Could not read uploaded file: {e}") from e
if not raw_bytes:
raise HTTPException(status_code=400, detail="Uploaded SRT file is empty.")
# Most SRT files are UTF-8 (with or without BOM); fall back to latin-1
# so legacy Windows-encoded subs don't blow up the import.
try:
text = raw_bytes.decode("utf-8-sig")
except UnicodeDecodeError:
text = raw_bytes.decode("latin-1", errors="replace")
from services.srt_parser import parse_srt
result = parse_srt(text)
if not result.segments:
raise HTTPException(
status_code=400,
detail=(
"No valid cues found in the uploaded file. "
f"Skipped {result.skipped_cues} malformed cue(s). "
"Expected SubRip (.srt) format: index, then 'HH:MM:SS,ms --> HH:MM:SS,ms', then text, blank line."
),
)
# Clamp cues that run past the source media's known duration. Pipeline
# downstream code assumes segment.end <= duration; without this, dub
# generation would try to time-stretch into negative slack.
duration = float(job.get("duration") or 0.0)
clamped = 0
if duration > 0:
kept = []
for seg in result.segments:
if seg["start"] >= duration:
continue
if seg["end"] > duration:
seg = {**seg, "end": round(duration, 3)}
clamped += 1
kept.append(seg)
# Re-id after clamp drops.
segments = [{**s, "id": i} for i, s in enumerate(kept)]
else:
segments = result.segments
job["segments"] = segments
# `source_lang` stays whatever the user (or the upload step) set; we
# don't try to language-detect off the cue text — that's noisy and the
# user usually knows what their .srt is.
_save_job(job_id, job)
logger.info(
"Imported %d cue(s) from .srt for job %s (skipped=%d, overlap_shifted=%d, clamped=%d)",
len(segments), job_id, result.skipped_cues, result.dropped_overlaps, clamped,
)
return {
"segments": segments,
"stats": {
"imported": len(segments),
"skipped_malformed": result.skipped_cues,
"dropped_overlap": result.dropped_overlaps,
"clamped_to_duration": clamped,
},
}
@router.post("/dub/cleanup-segments/{job_id}")
def dub_cleanup_segments(job_id: str):
"""Re-run merge/stitch passes on a job's existing segments to drop fragments."""
job = _get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
segments = job.get("segments") or []
cleaned = clean_up_segments(segments)
job["segments"] = cleaned
_save_job(job_id, job)
return {"segments": cleaned, "before": len(segments), "after": len(cleaned)}
@router.post("/dub/abort/{job_id}")
def dub_abort(job_id: str):
"""Cancel in-flight upload/transcribe subprocesses for a job."""
with _active_procs_lock:
had_procs = bool(_active_procs.get(job_id))
_kill_job_procs(job_id)
job = _dub_jobs.get(job_id)
if job is not None:
job["aborted"] = True
try:
task_manager.cancel_task(job_id)
except Exception:
pass
return {"aborted": True, "had_active_procs": had_procs}
@router.get("/dub/history")
def list_dub_history():
with db_conn() as conn:
rows = conn.execute("SELECT * FROM dub_history ORDER BY created_at DESC LIMIT 30").fetchall()
return [dict(r) for r in rows]
@router.delete("/dub/history")
def clear_dub_history():
"""Delete persisted dub rows and their on-disk dirs (scoped to known IDs)."""
with db_conn() as conn:
ids = [r["id"] for r in conn.execute("SELECT id FROM dub_history").fetchall()]
conn.execute("DELETE FROM dub_history")
for jid in ids:
safe = _safe_job_dir(jid)
if safe and os.path.isdir(safe):
shutil.rmtree(safe, ignore_errors=True)
event_bus.emit("dub_history")
return {"cleared": True, "count": len(ids)}
@router.delete("/dub/history/{history_id}")
def delete_single_dub_history(history_id: str):
with db_conn() as conn:
conn.execute("DELETE FROM dub_history WHERE id=?", (history_id,))
safe = _safe_job_dir(history_id)
if safe and os.path.isdir(safe):
shutil.rmtree(safe, ignore_errors=True)
_dub_jobs.pop(history_id, None)
event_bus.emit("dub_history", {"action": "deleted", "id": history_id})
return {"deleted": True}
@router.post("/preview/upload")
async def preview_upload(video: UploadFile = File(...)):
ext = os.path.splitext(video.filename or "video.mp4")[1].lower()
safe_name = f"{uuid.uuid4().hex[:12]}"
vid_path = os.path.join(PREVIEW_DIR, f"{safe_name}{ext}")
wav_path = os.path.join(PREVIEW_DIR, f"{safe_name}.wav")
with open(vid_path, "wb") as f:
f.write(await video.read())
has_audio = False
if ext not in [".wav", ".mp3", ".m4a", ".aac"]:
try:
ffmpeg_cmd = [
find_ffmpeg(), "-y", "-i", vid_path,
"-vn", "-acodec", "pcm_s16le", "-ar", "22050", "-ac", "1",
wav_path
]
subprocess.run(
ffmpeg_cmd, check=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
timeout=300,
)
has_audio = True
except Exception as e:
logger.warning(f"FFmpeg extraction failed: {e}")
pass
return {
"url": f"/preview/{safe_name}{ext}",
"audioUrl": f"/preview/{safe_name}.wav" if has_audio else f"/preview/{safe_name}{ext}",
"filename": video.filename,
}
@router.get("/preview/{filename}")
async def preview_serve(filename: str):
if not filename or "/" in filename or "\\" in filename or filename.startswith("."):
raise HTTPException(400, "Invalid preview filename")
preview_real = os.path.realpath(PREVIEW_DIR)
path = os.path.realpath(os.path.join(PREVIEW_DIR, filename))
if not path.startswith(preview_real + os.sep):
raise HTTPException(400, "Invalid preview filename")
if not os.path.isfile(path):
raise HTTPException(404, "Preview not found")
ext = os.path.splitext(filename)[1].lower()
media_types = {
".mp4": "video/mp4", ".mov": "video/quicktime",
".mkv": "video/x-matroska", ".webm": "video/webm",
".avi": "video/x-msvideo", ".wav": "audio/wav",
".mp3": "audio/mpeg"
}
return FileResponse(path, media_type=media_types.get(ext, "application/octet-stream"))
# ── Legacy aliases for the extracted ingest pipeline (Phase 2.4 finish) ────
_run_proc_factory = dub_pipeline.run_proc_factory
_yt_download_sync = dub_pipeline.yt_download_sync
_prep_event = dub_pipeline.prep_event
_ingest_gen = dub_pipeline.ingest_pipeline
#: Recognised audio extensions for audio-only dubbing (#119). When the client
#: declares input_type=audio we refuse anything that isn't a known audio
#: container so a mislabelled video can't slip past the video-skipping branch.
_AUDIO_EXTS = {".wav", ".mp3", ".m4a", ".aac", ".flac", ".ogg", ".opus", ".wma"}
@router.post("/dub/upload")
async def dub_upload(
video: UploadFile = File(...),
job_id: Optional[str] = Form(None),
input_type: str = Form("video"),
):
"""Accept a media upload, write to disk, queue background prep task.
`input_type` is "video" (default) or "audio". Audio-only jobs (#119) skip
scene detection, thumbnailing, and the final video mux — the transcribe →
translate → TTS core is identical.
Returns 202 with {job_id, task_id, filename}. Client should open SSE on
/tasks/stream/{task_id} to monitor extract/demucs stages and wait for the
'ready' event before starting transcription.
"""
input_type = (input_type or "video").lower()
if input_type not in ("video", "audio"):
raise HTTPException(status_code=400, detail="input_type must be 'video' or 'audio'")
job_id = job_id or str(uuid.uuid4())[:8]
job_dir = _safe_job_dir(job_id)
if job_dir is None:
raise HTTPException(
status_code=400,
detail="Invalid job_id. Must be alphanumeric + hyphens/underscores only, ≤64 chars. Generate a fresh job_id or omit it to auto-create one.",
)
ext = os.path.splitext(video.filename or "video.mp4")[1]
if input_type == "audio" and ext.lower() not in _AUDIO_EXTS:
raise HTTPException(
status_code=400,
detail=f"Audio-only dubbing needs an audio file ({', '.join(sorted(_AUDIO_EXTS))}); got '{ext or 'no extension'}'.",
)
os.makedirs(job_dir, exist_ok=True)
video_path = os.path.join(job_dir, f"original{ext}")
with open(video_path, "wb") as f:
f.write(await video.read())
filename = video.filename or f"video{ext}"
task_id = f"prep_{job_id}"
await task_manager.add_task(
task_id, "prep",
_ingest_gen, job_id, job_dir,
{"kind": "file", "path": video_path, "input_type": input_type}, filename,
)
return JSONResponse(
status_code=202,
content={"job_id": job_id, "task_id": task_id, "filename": filename},
)
@router.post("/dub/ingest-url")
async def dub_ingest_url(req: DubIngestUrlRequest):
"""Ingest a remote video URL via yt-dlp. Queues background prep task.
Returns 202 immediately with {job_id, task_id}. All work (download,
audio extract, Demucs, scene detect, thumbnail) happens in the background
task and progress is streamed via /tasks/stream/{task_id}.
"""
url = (req.url or "").strip()
if not url or not (url.startswith("http://") or url.startswith("https://")):
raise HTTPException(
status_code=400,
detail="URL must start with http:// or https://. Paste a full video link (e.g. https://youtube.com/watch?v=…) or drop a local file instead.",
)
try:
import yt_dlp # noqa: F401
except ImportError:
raise HTTPException(
status_code=500,
detail="URL ingest needs yt-dlp, but it isn't installed. Install it (`pip install yt-dlp`) and restart the server — or drop a local video file instead.",
)
job_id = req.job_id or str(uuid.uuid4())[:8]
job_dir = _safe_job_dir(job_id)
if job_dir is None:
raise HTTPException(
status_code=400,
detail="Invalid job_id. Must be alphanumeric + hyphens/underscores only, ≤64 chars. Generate a fresh job_id or omit it to auto-create one.",
)
os.makedirs(job_dir, exist_ok=True)
task_id = f"prep_{job_id}"
source = {
"kind": "url",
"url": url,
"fetch_subs": bool(req.fetch_subs),
"sub_langs": req.sub_langs or None,
}
await task_manager.add_task(
task_id, "prep",
_ingest_gen, job_id, job_dir,
source, None,
)
return JSONResponse(
status_code=202,
content={"job_id": job_id, "task_id": task_id, "filename": ""},
)
TRANSCRIBE_CHUNK_S = float(os.environ.get("OMNIVOICE_TRANSCRIBE_CHUNK_S", "30.0"))
TRANSCRIBE_CHUNK_TIMEOUT_S = float(os.environ.get("OMNIVOICE_TRANSCRIBE_CHUNK_TIMEOUT_S", "120.0"))
_sse_event = dub_pipeline.sse_event
_prep_event_helper = dub_pipeline.prep_event # alias; we keep the module-local _prep_event below for the inline one-liner shape
@router.get("/dub/transcribe-stream/{job_id}")
async def dub_transcribe_stream(job_id: str, num_speakers: Optional[int] = None):
"""Stream per-chunk segments via SSE, then emit diarized final pass.
Pre-flight checks (missing job, missing audio, ASR not loaded) are emitted
as in-stream `error` events rather than HTTP errors, because EventSource
on the client can't read non-2xx response bodies — a 503 there surfaces
as an opaque "network error" instead of the actionable message we want.
`num_speakers` is an optional hint passed straight to pyannote. Left unset,
pyannote auto-detects the count — but its auto-detect can collapse a
multi-speaker clip to a single speaker (issue #274). When the user knows
the exact count, supplying it forces pyannote to return that many speakers.
"""
# Clamp to a sane range; ignore anything non-positive / absurd so a bad
# query string can never break the diarization call. None → auto-detect.
if num_speakers is not None:
try:
num_speakers = int(num_speakers)
num_speakers = num_speakers if 1 <= num_speakers <= 20 else None
except (TypeError, ValueError):
num_speakers = None
job = _get_job(job_id)
preflight_error: Optional[str] = None
asr_audio_target: Optional[str] = None
_asr_backend = None
scene_cuts: list = []
if not job:
preflight_error = "Job not found. It may have been cleaned up or was never created."
else:
# Guard the model load: if it raises, the SSE stream would otherwise die
# before emitting any event, and the UI shows a misleading generic
# "stream dropped" message instead of the real cause (issue #255).
try:
_model = await get_model()
except Exception as e:
logger.exception("transcribe preflight: model load failed (job=%s)", job_id)
from core.failure import build_failure
f = build_failure(e, stage="transcribe-preflight", include_diagnostic=False)
preflight_error = f["reason"] + (f" — {f['hint']}" if f.get("hint") else "")
_model = None
if _model is not None:
asr_audio_target = job.get("vocals_path")
if not asr_audio_target or not os.path.exists(asr_audio_target):
asr_audio_target = job.get("audio_path")
if not asr_audio_target or not os.path.exists(asr_audio_target):
preflight_error = "No audio available for transcription."
else:
from services.asr_backend import get_active_asr_backend
try:
# The PyTorch-Whisper backend lazily builds its own pipeline
# when no preloaded `_asr_pipe` is present (issue #255), so it
# no longer needs OMNIVOICE_PRELOAD_TTS_ASR=1 — don't reject it
# here; any load failure surfaces per-chunk with a real cause.
_asr_backend = get_active_asr_backend(asr_pipe=getattr(_model, "_asr_pipe", None))
except Exception as e:
from core.failure import build_failure
f = build_failure(e, stage="transcribe-preflight", include_diagnostic=False)
preflight_error = "ASR backend initialization failed: " + f["reason"] + (
f" — {f['hint']}" if f.get("hint") else ""
)
scene_cuts = job.get("scene_cuts") or []
async def gen():
if preflight_error:
yield _sse_event("error", {"detail": preflight_error})
return
import math
import tempfile
loop = asyncio.get_running_loop()
def _load():
audio_np, sr = sf.read(asr_audio_target, dtype="float32")
if audio_np.ndim > 1:
audio_np = audio_np.mean(axis=1)
return audio_np, sr
try:
audio_np, sr = await loop.run_in_executor(_cpu_pool, _load)
except Exception as e:
yield _sse_event("error", {"detail": f"audio load failed: {e}"})
return
total = float(len(audio_np)) / float(sr) if sr else 0.0
chunks_n = max(1, int(math.ceil(total / TRANSCRIBE_CHUNK_S))) if total > 0 else 1
yield _sse_event("start", {"duration": total, "chunks": chunks_n, "chunk_s": TRANSCRIBE_CHUNK_S})
# Free VRAM: move TTS model to CPU so WhisperX + VAD can fit.
# Only offloads when free GPU memory is < 4 GB (e.g. laptop GPUs).
# Non-fatal: an offload failure must not drop the stream (#255) —
# transcription can still proceed (it just has less headroom).
try:
await loop.run_in_executor(_cpu_pool, offload_tts_for_asr)
except Exception as e:
logger.warning("offload_tts_for_asr failed (continuing): %s", e)
all_segments: list[dict] = []
detected_lang = None
next_seg_id = 0
chunk_errors: list[str] = []
# Speaker turns from an ASR backend that diarizes inline (FunASR cam++).
# When present, _diarize() uses them and skips pyannote (Phase 2, #182).
asr_speaker_turns: list[dict] = []
for i in range(chunks_n):
if job.get("aborted"):
yield _sse_event("aborted", {})
return
t0 = i * TRANSCRIBE_CHUNK_S
t1 = min(total, t0 + TRANSCRIBE_CHUNK_S)
s_from = int(t0 * sr)
s_to = int(t1 * sr)
chunk_arr = audio_np[s_from:s_to]
if len(chunk_arr) == 0:
continue
def _transcribe_chunk(arr=chunk_arr, offset=t0, local_sr=sr):
# Route through the active backend (WhisperX by default).
# Backends all take a file path, so write the chunk first.
try:
tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
tmp.close()
try:
_safe_soundfile_write(tmp.name, arr, local_sr)
r = _asr_backend.transcribe(tmp.name, word_timestamps=True)
finally:
try: os.remove(tmp.name)
except OSError: pass
shifted = []
for c in r.get("chunks", []) or []:
ts = c.get("timestamp", (0.0, 0.0)) or (0.0, 0.0)
a0 = (ts[0] if ts[0] is not None else 0.0) + offset
a1 = (ts[1] if ts[1] is not None else 0.0) + offset
shifted.append({"text": c.get("text", ""), "timestamp": (a0, a1)})
# Inline-diarization speaker turns (FunASR cam++), offset-shifted
# to the full-audio timeline so _diarize() can use them.
turns = []
for seg in r.get("segments", []) or []:
spk = seg.get("speaker")
s0, s1 = seg.get("start"), seg.get("end")
if spk is None or s0 is None or s1 is None:
continue
turns.append({"start": s0 + offset, "end": s1 + offset, "speaker": spk})
return {"chunks": shifted, "language": r.get("language"), "speaker_turns": turns}
except Exception as e:
logger.exception("chunk transcribe failed (backend=%s)", _asr_backend.id)
return {"chunks": [], "language": None, "error": str(e)}
try:
# wait_for in a loop to yield pings so the EventSource connection doesn't drop
fut = loop.run_in_executor(_gpu_pool, _transcribe_chunk)
waited = 0.0
part = None
while True:
done, pending = await asyncio.wait([fut], timeout=5.0)
if done:
part = done.pop().result()
break
yield _sse_event("ping", {})
waited += 5.0
if waited >= TRANSCRIBE_CHUNK_TIMEOUT_S:
# Re-raise TimeoutError if we exceed the overall limit
raise asyncio.TimeoutError()
except asyncio.TimeoutError:
logger.error(
"Transcribe chunk %d/%d timed out after %.0fs (job=%s)",
i + 1, chunks_n, TRANSCRIBE_CHUNK_TIMEOUT_S, job_id,
)
part = {
"chunks": [], "language": None,
"error": f"Chunk {i+1} timed out after {TRANSCRIBE_CHUNK_TIMEOUT_S:.0f}s — "
f"ASR backend may be stuck. Try restarting the server.",
}
if part.get("error"):
chunk_errors.append(part["error"])
logger.warning("Chunk %d/%d error: %s", i + 1, chunks_n, part["error"])
if detected_lang is None and part.get("language"):
detected_lang = part["language"]
asr_speaker_turns.extend(part.get("speaker_turns") or [])
chunk_segs = segment_transcript(part, duration=t1, scene_cuts=scene_cuts)
chunk_segs = assign_speakers_heuristic(chunk_segs)
for s in chunk_segs:
s["id"] = f"s{next_seg_id:05x}"
s["text_original"] = s.get("text", "")
next_seg_id += 1
all_segments.extend(chunk_segs)
yield _sse_event("segments", {
"chunk": i, "total_chunks": chunks_n,
"segments": chunk_segs,
"progress": (i + 1) / chunks_n,
"error": part.get("error"),
})
if job.get("aborted"):
yield _sse_event("aborted", {})
return
# Empty-transcription guard: if every chunk came back with zero
# segments we can't proceed to diarization/clone extraction. Emit an
# actionable error so the UI can surface a Retry instead of silently
# landing in an empty editor. Commonly caused by a first-run model
# download failure, a PyTorch 2.6 weights_only regression inside
# whisperx's VAD load, or an unsupported audio format.
if not all_segments:
# Deduplicate while preserving order so one root cause doesn't
# repeat N times in the UI toast. Sanitize each message so home
# paths / tokens from a backend traceback never leak (#255).
from core.failure import sanitize, build_failure
seen = set()
uniq: list[str] = []
for msg in chunk_errors:
s = sanitize(msg)
if s and s not in seen:
seen.add(s)
uniq.append(s)
if uniq:
detail = "Transcription produced no segments. " + " | ".join(uniq[:3])
# Add the actionable hint for a recognized failure class
# (e.g. pkg_resources missing → install setuptools).
hint = build_failure(" ".join(uniq), stage="transcribe", include_diagnostic=False).get("hint")
if hint:
detail += f" — {hint}"
else:
detail = (
"Transcription produced no segments. The audio may be silent, "
"too short, or in an unsupported format. Try re-uploading or "
"check that the source has an audible speech track."
)
logger.error("transcribe yielded 0 segments (job=%s): %s", job_id, detail)
yield _sse_event("error", {"detail": detail, "retryable": True})
yield _sse_event("done", {})
return
def _diarize():
"""Returns (segments, warning_payload_or_None).
`warning_payload` is a structured dict
`{detail, error_class, docs_url}` whenever we silently fell back
to the silence-gap heuristic (no HF_TOKEN, model unavailable,
license not accepted, or pyannote raised). The heuristic only
detects speaker turns from >1.2s silences, so a rapid-fire
man↔woman exchange will read as one speaker. Issue #78 — we
attach an `error_class` so the front-end's errorDocsMap can
render a "See docs" deeplink instead of a dead-end toast.
"""
# The active ASR backend already diarized inline (FunASR cam++):
# use its speaker turns directly and skip pyannote entirely (#182).
if asr_speaker_turns:
logger.info("Using inline ASR diarization (%d turns); skipping pyannote.", len(asr_speaker_turns))
return assign_speakers_from_turns(all_segments, asr_speaker_turns), None
from services.model_manager import (
DIARIZATION_ERR_LICENSE,
DIARIZATION_ERR_NO_TOKEN,
)
from core import error_docs_map
diar_pipe, err_sentinel = get_diarization_pipeline(return_error=True)
if not diar_pipe:
# Phase 1 AUTH-01: ask the resolver (App → Env → HF-CLI),
# not just the env var. This is the #35 fix — users who
# ran `huggingface-cli login` previously saw the "no
# HF_TOKEN" branch even though the library would have
# read the token. Now the cascade is honoured.
from services import token_resolver
resolved = token_resolver.resolve()
if err_sentinel == DIARIZATION_ERR_NO_TOKEN or not resolved:
detail = (
"Speaker diarization is disabled because no HuggingFace token "
"was found in any source (Settings → API Keys, the HF_TOKEN "
"env var, or ~/.cache/huggingface/token from `huggingface-cli "
"login`). To detect multiple speakers, set a token in one of "
"those places and accept the pyannote/speaker-diarization-3.1 "
"license at huggingface.co. Falling back to a silence-gap "
"heuristic — turns with no audible pause between them will "
"be merged into one speaker."
)
error_class = "HF_AUTH_FAILED"
elif err_sentinel == DIARIZATION_ERR_LICENSE:
who = resolved.username or "(whoami suppressed)"
detail = (
f"Speaker diarization model is gated — the "
f"pyannote/speaker-diarization-3.1 license has not been "
f"accepted on HuggingFace by this account "
f"(source={resolved.source}, user={who}). Visit "
f"huggingface.co/pyannote/speaker-diarization-3.1 AND "
f"huggingface.co/pyannote/segmentation-3.0 while signed "
f"in and click 'Agree and access repository' on both, "
f"then restart this dub job. Falling back to a "
f"silence-gap heuristic; rapid speaker turns may be "
f"merged into one speaker."
)
error_class = "PYANNOTE_LICENSE_REQUIRED"
else:
# err_sentinel == DIARIZATION_ERR_LOAD (or unexpected None
# with a resolved token — historical safety net).
who = resolved.username or "(whoami suppressed)"
detail = (
f"Speaker diarization model failed to load even though an HF "
f"token was found (source={resolved.source}, user={who}). "
f"Most common causes: the pyannote/speaker-diarization-3.1 "
f"license has not been accepted on HuggingFace, or there is "
f"a pyannote/torch version mismatch. See backend logs for "
f"the underlying error. Falling back to a silence-gap "
f"heuristic; rapid speaker turns may be merged."
)
error_class = "PYANNOTE_LICENSE_REQUIRED"
return (
assign_speakers_heuristic(all_segments),
{
"detail": detail,
"error_class": error_class,
"docs_url": error_docs_map.lookup(error_class),
},
)
try:
# Pass the user's speaker-count hint through to pyannote when
# provided (#274). pyannote's apply() accepts num_speakers;
# omit it entirely when None so we don't depend on the kwarg
# existing in every pyannote build.
if num_speakers:
logger.info("Diarizing with num_speakers=%d (user hint)", num_speakers)
diar = diar_pipe(asr_audio_target, num_speakers=num_speakers)
else:
diar = diar_pipe(asr_audio_target)
return assign_speakers_from_diarization(all_segments, diar), None
except Exception as e:
logger.error(f"Diarization failed: {e}")
# Mid-run failure — classify against the same sentinels so a
# post-load 401 (rare but possible after a token rotation)
# still gets the right docs deeplink.
from services.model_manager import _classify_diarization_error
err_class_post = _classify_diarization_error(e)
error_class = (
"PYANNOTE_LICENSE_REQUIRED"
if err_class_post == DIARIZATION_ERR_LICENSE
else "PYANNOTE_LICENSE_REQUIRED" # LOAD failures land here too
)
return (
assign_speakers_heuristic(all_segments),
{
"detail": (
f"Speaker diarization crashed mid-run "
f"({type(e).__name__}); falling back to a silence-gap "
f"heuristic. Rapid speaker turns may be merged."
),
"error_class": error_class,
"docs_url": error_docs_map.lookup(error_class),
},
)
fut_diar = loop.run_in_executor(_gpu_pool, _diarize)
final_segs = None
diar_warning = None
while True:
done, pending = await asyncio.wait([fut_diar], timeout=5.0)
if done:
final_segs, diar_warning = done.pop().result()
break
yield _sse_event("ping", {})
if diar_warning:
logger.warning("diarization fallback: %s", diar_warning.get("detail"))
yield _sse_event("warning", {
"detail": diar_warning.get("detail"),
"source": "diarization",
"error_class": diar_warning.get("error_class"),
"docs_url": diar_warning.get("docs_url"),
})
job["segments"] = final_segs
# Auto-speaker-clone: sample each detected speaker's voice from the
# Demucs-isolated vocals track and assign `auto:speaker_N` as the
# default profile for their segments. This is what lets a user add a
# new target language and have the ORIGINAL speaker speak it — the
# central pro-grade dubbing promise.
try:
from services.speaker_clone import extract_speaker_clones, auto_profile_id
vocals_for_clone = job.get("vocals_path") or asr_audio_target
fut_clones = loop.run_in_executor(
_cpu_pool, extract_speaker_clones,
vocals_for_clone, final_segs, os.path.dirname(vocals_for_clone),
)
clones = None
while True:
done, pending = await asyncio.wait([fut_clones], timeout=5.0)
if done:
clones = done.pop().result()
break
yield _sse_event("ping", {})
if clones:
job["speaker_clones"] = clones
# Default each segment's profile_id to its speaker's auto-clone,
# but only if the user hasn't already assigned something.
for s in final_segs:
if s.get("profile_id"):
continue
spk = s.get("speaker_id") or "Speaker 1"
if spk in clones:
s["profile_id"] = auto_profile_id(spk)
except Exception as e:
logger.warning("speaker_clone extraction skipped: %s", e)
job["source_lang"] = ((detected_lang or "en").split("_")[0][:2] or "en").lower()
job["full_transcript"] = " ".join(s.get("text", "") for s in final_segs)
_save_job(job_id, job)
# Restore TTS model to GPU now that ASR is done
if _asr_backend:
try:
_asr_backend.unload()
except Exception as e:
logger.warning("Failed to unload ASR backend: %s", e)
await loop.run_in_executor(_cpu_pool, restore_tts_after_asr)
if torch.backends.mps.is_available():
try: torch.mps.empty_cache()
except Exception: pass
yield _sse_event("final", {
"segments": final_segs,
"source_lang": job["source_lang"],
"full_transcript": job["full_transcript"],
"speaker_clones": job.get("speaker_clones", {}),
})
yield _sse_event("done", {})
return StreamingResponse(
gen(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
},
)
@router.post("/dub/transcribe/{job_id}")
async def dub_transcribe(job_id: str):
job = _get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
_model = await get_model()
def _transcribe():
asr_audio_target = job.get("vocals_path")
if not asr_audio_target or not os.path.exists(asr_audio_target):
asr_audio_target = job.get("audio_path")
import torch
detected_lang = None
# Route through services.asr_backend — picks WhisperX / faster-whisper
# / mlx / pytorch based on what's installed + user preference. Works
# identically on all platforms; the older mlx-vs-pytorch branching
# here duplicated the logic in asr_backend.py and skipped WhisperX.
from services.asr_backend import get_active_asr_backend
_asr = get_active_asr_backend(asr_pipe=getattr(_model, "_asr_pipe", None))
try:
try:
logger.info("Transcribing full audio via %s ...", _asr.id)
result = _asr.transcribe(asr_audio_target, word_timestamps=True)
detected_lang = result.get("language")
except Exception as e:
logger.error("ASR backend %s failed: %s", _asr.id, e)
if getattr(_model, "_asr_pipe", None) is None:
raise RuntimeError(
f"ASR backend {_asr.id} failed and PyTorch Whisper fallback is not preloaded: {e}"
) from e
# Last-resort fallback — in-memory pytorch whisper via the TTS
# model's pipeline when explicitly preloaded.
audio_np, sr = sf.read(asr_audio_target, dtype="float32")
if audio_np.ndim > 1: audio_np = audio_np.mean(axis=1)
bs = 16 if torch.cuda.is_available() else 1
result = _model._asr_pipe(
{"array": audio_np, "sampling_rate": sr},
return_timestamps=True, chunk_length_s=15, batch_size=bs,
)
detected_lang = (result.get("language") if isinstance(result, dict) else None)
finally:
try:
_asr.unload()
except Exception as e:
logger.warning("Failed to unload ASR backend: %s", e)
job["source_lang"] = (detected_lang or "en").split("_")[0][:2].lower()
scene_cuts = job.get("scene_cuts") or []
segments = segment_transcript(result, duration=job.get("duration", 0.0), scene_cuts=scene_cuts)
diar_pipe = get_diarization_pipeline()
if diar_pipe:
try:
diar_target = job.get("vocals_path") or job.get("audio_path")
diarization = diar_pipe(diar_target)
segments = assign_speakers_from_diarization(segments, diarization)
except Exception as e:
logger.error(f"Pyannote diarization failed during inference: {e}. Falling back to heuristic.")
segments = assign_speakers_heuristic(segments)
else:
segments = assign_speakers_heuristic(segments)
# Previously ran `segment_for_subtitles(segments)` here. Removed 2026-04-21 —
# that splitter enforces Netflix's 17 CPS reading-speed ceiling which
# trips on normal speech (1525 CPS) and recurses to word-level.
# For dubbing, keep the sentence-level output. Apply subtitle rules at
# SRT export time only.
for s in segments:
s.setdefault("text_original", s.get("text", ""))
job["full_transcript"] = " ".join(s["text"] for s in segments)
if torch.backends.mps.is_available():
torch.mps.empty_cache()
return segments
try:
loop = asyncio.get_running_loop()
try:
segments_result = await loop.run_in_executor(_gpu_pool, _transcribe)
except asyncio.CancelledError:
job["aborted"] = True
raise
if job.get("aborted"):
raise HTTPException(status_code=499, detail="Transcription aborted")
job["segments"] = segments_result
source_lang = job.get("source_lang")
_save_job(job_id, job)
return {
"job_id": job_id,
"segments": segments_result,
"full_transcript": job.get("full_transcript", ""),
"source_lang": source_lang,
}
except HTTPException:
raise
except asyncio.CancelledError:
raise
except Exception as e:
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))