Spaces:
Running
Running
| """Batch dubbing queue β POST videos with settings, process sequentially. | |
| This is a lightweight batch orchestrator. Each job is a dub project that | |
| runs through the same ingestβtranscribeβtranslateβgenerate pipeline as | |
| a manual dub, but driven by the queue instead of the UI. | |
| The queue is in-memory (lives for the process lifetime). Jobs persist to | |
| the SQLite `jobs` table for history, but the queue itself restarts empty | |
| on backend restart β intentional, since GPU jobs can't be safely resumed. | |
| """ | |
| import os | |
| import uuid | |
| import time | |
| import asyncio | |
| import logging | |
| from typing import Optional, List | |
| from fastapi import APIRouter, File, UploadFile, HTTPException, Form | |
| from pydantic import BaseModel | |
| from core.config import DATA_DIR | |
| router = APIRouter() | |
| logger = logging.getLogger("omnivoice.batch") | |
| # ββ In-memory queue βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _queue: asyncio.Queue = None # Lazily initialised | |
| _worker_task: asyncio.Task = None # Background consumer | |
| _jobs: dict = {} # job_id β status dict | |
| class BatchJobStatus(BaseModel): | |
| id: str | |
| status: str # "queued" | "running" | "done" | "failed" | "cancelled" | |
| filename: str | |
| langs: List[str] | |
| voice_id: Optional[str] = None | |
| preserve_bg: bool = True | |
| created_at: float | |
| started_at: Optional[float] = None | |
| finished_at: Optional[float] = None | |
| error: Optional[str] = None | |
| progress: Optional[dict] = None | |
| def _ensure_queue(): | |
| """Lazy-init the asyncio queue + worker on first use.""" | |
| global _queue, _worker_task | |
| if _queue is None: | |
| _queue = asyncio.Queue() | |
| _worker_task = asyncio.ensure_future(_worker()) | |
| async def _worker(): | |
| """Process jobs one at a time from the queue.""" | |
| while True: | |
| job_id = await _queue.get() | |
| job = _jobs.get(job_id) | |
| if not job or job["status"] == "cancelled": | |
| _queue.task_done() | |
| continue | |
| job["status"] = "running" | |
| job["started_at"] = time.time() | |
| logger.info("Batch job %s starting: %s", job_id, job["filename"]) | |
| try: | |
| await _run_batch_pipeline(job_id, job) | |
| if job["status"] != "cancelled": | |
| job["status"] = "done" | |
| job["finished_at"] = time.time() | |
| logger.info( | |
| "Batch job %s completed in %.1fs", | |
| job_id, job["finished_at"] - job["started_at"], | |
| ) | |
| except asyncio.CancelledError: | |
| job["status"] = "cancelled" | |
| job["finished_at"] = time.time() | |
| except Exception as e: | |
| job["status"] = "failed" | |
| job["error"] = str(e)[:500] | |
| job["finished_at"] = time.time() | |
| logger.error("Batch job %s failed: %s", job_id, e, exc_info=True) | |
| finally: | |
| _queue.task_done() | |
| def _set_progress(job, stage, percent=0, **extra): | |
| """Update a job's progress dict.""" | |
| job["progress"] = {"stage": stage, "percent": percent, **extra} | |
| async def _run_batch_pipeline(job_id: str, job: dict): | |
| """Full batch dub pipeline: extract β transcribe β translate β generate β mix β export.""" | |
| import subprocess | |
| import tempfile | |
| import soundfile as sf | |
| loop = asyncio.get_running_loop() | |
| video_path = job["video_path"] | |
| langs = job["langs"] | |
| batch_dir = os.path.join(DATA_DIR, "batch", job_id) | |
| os.makedirs(batch_dir, exist_ok=True) | |
| # ββ 1. Extract audio ββββββββββββββββββββββββββββββββββββββββββββββ | |
| _set_progress(job, "extract", 0) | |
| audio_path = os.path.join(batch_dir, "audio.wav") | |
| from services.ffmpeg_utils import find_ffmpeg | |
| ffmpeg = find_ffmpeg() | |
| def _extract(): | |
| subprocess.run( | |
| [ffmpeg, "-y", "-i", video_path, | |
| "-vn", "-acodec", "pcm_s16le", "-ar", "22050", "-ac", "1", | |
| audio_path], | |
| stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, | |
| timeout=300, check=True, | |
| ) | |
| # Get duration | |
| result = subprocess.run( | |
| [ffmpeg, "-i", audio_path], | |
| stdout=subprocess.PIPE, stderr=subprocess.PIPE, | |
| timeout=30, | |
| ) | |
| import re | |
| match = re.search(r"Duration: (\d+):(\d+):(\d+)\.(\d+)", result.stderr.decode("utf-8", errors="replace")) | |
| if match: | |
| h, m, s, cs = match.groups() | |
| return int(h) * 3600 + int(m) * 60 + int(s) + int(cs) / 100 | |
| return 0.0 | |
| duration = await loop.run_in_executor(None, _extract) | |
| job["duration"] = duration | |
| _set_progress(job, "extract", 100) | |
| if job["status"] == "cancelled": | |
| return | |
| # ββ 2. Transcribe βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _set_progress(job, "transcribe", 0) | |
| from services.asr_backend import get_active_asr_backend | |
| from services.model_manager import _gpu_pool, _cpu_pool | |
| from services.segmentation import ( | |
| segment_transcript, assign_speakers_heuristic, | |
| ) | |
| def _transcribe(): | |
| backend = get_active_asr_backend() | |
| result = backend.transcribe(audio_path, word_timestamps=True) | |
| detected_lang = result.get("language", "en") | |
| segments = segment_transcript(result, duration=duration) | |
| segments = assign_speakers_heuristic(segments) | |
| for i, s in enumerate(segments): | |
| s["id"] = f"s{i:05x}" | |
| s.setdefault("text_original", s.get("text", "")) | |
| try: | |
| backend.unload() | |
| except Exception: | |
| pass | |
| return segments, detected_lang | |
| segments, source_lang = await loop.run_in_executor(_gpu_pool, _transcribe) | |
| source_lang = (source_lang or "en").split("_")[0][:2].lower() | |
| job["segments"] = segments | |
| job["source_lang"] = source_lang | |
| _set_progress(job, "transcribe", 100, segments_count=len(segments)) | |
| if job["status"] == "cancelled" or not segments: | |
| if not segments: | |
| job["error"] = "Transcription produced no segments" | |
| job["status"] = "failed" | |
| return | |
| # ββ 3. Translate + Generate per language βββββββββββββββββββββββββββ | |
| total_langs = len(langs) | |
| outputs = {} | |
| for lang_idx, target_lang in enumerate(langs): | |
| if job["status"] == "cancelled": | |
| return | |
| # ββ 3a. Translate βββββββββββββββββββββββββββββββββββββββββββββ | |
| _set_progress( | |
| job, "translate", | |
| percent=int((lang_idx / total_langs) * 100), | |
| current_lang=target_lang, | |
| ) | |
| translated_segments = list(segments) # copy | |
| if target_lang != source_lang: | |
| try: | |
| def _translate_batch(segs, src, tgt): | |
| """Translate segment texts via Google Translate.""" | |
| from deep_translator import GoogleTranslator | |
| TRANSLATE_CODES = { | |
| "en": "en", "es": "es", "fr": "fr", "de": "de", | |
| "it": "it", "pt": "pt", "ru": "ru", "ja": "ja", | |
| "ko": "ko", "zh": "zh-CN", "ar": "ar", "hi": "hi", | |
| "tr": "tr", "pl": "pl", "nl": "nl", "sv": "sv", | |
| } | |
| src_code = TRANSLATE_CODES.get(src, src) or "auto" | |
| tgt_code = TRANSLATE_CODES.get(tgt, tgt) | |
| translator = GoogleTranslator(source=src_code, target=tgt_code) | |
| out = [] | |
| for s in segs: | |
| s_copy = dict(s) | |
| text = s.get("text", "").strip() | |
| if text: | |
| try: | |
| s_copy["text"] = translator.translate(text) or text | |
| except Exception as e: | |
| logger.warning("Translate seg failed: %s", e) | |
| out.append(s_copy) | |
| return out | |
| translated_segments = await loop.run_in_executor( | |
| _cpu_pool, _translate_batch, | |
| segments, source_lang, target_lang, | |
| ) | |
| except ImportError: | |
| logger.warning("deep_translator not installed, skipping translation for %s", target_lang) | |
| except Exception as e: | |
| logger.warning("Translation failed for %s: %s, using original", target_lang, e) | |
| translated_segments = segments | |
| if job["status"] == "cancelled": | |
| return | |
| # ββ 3b. Generate TTS ββββββββββββββββββββββββββββββββββββββββββ | |
| _set_progress( | |
| job, "generate", | |
| percent=int((lang_idx / total_langs) * 100), | |
| current_lang=target_lang, | |
| current_segment=0, | |
| total_segments=len(translated_segments), | |
| ) | |
| from services.model_manager import get_model | |
| from services.audio_dsp import apply_mastering, normalize_audio | |
| from services.audio_io import atomic_save_wav | |
| import torch | |
| _model = await get_model() | |
| sr = _model.sampling_rate | |
| total_samples = int(duration * sr) | |
| full_audio = torch.zeros(1, total_samples) | |
| total_segs = len(translated_segments) | |
| for i, seg in enumerate(translated_segments): | |
| if job["status"] == "cancelled": | |
| return | |
| _set_progress( | |
| job, "generate", | |
| percent=int(((lang_idx + (i / total_segs)) / total_langs) * 100), | |
| current_lang=target_lang, | |
| current_segment=i + 1, | |
| total_segments=total_segs, | |
| ) | |
| seg_start = seg.get("start", 0) | |
| seg_end = seg.get("end", 0) | |
| seg_duration = seg_end - seg_start | |
| seg_text = seg.get("text", "").strip() | |
| if seg_duration <= 0.05 or not seg_text: | |
| continue | |
| def _gen(text=seg_text, lang=target_lang, dur=seg_duration): | |
| ref_audio = None | |
| ref_text = None | |
| # Use voice_id if provided | |
| if job.get("voice_id"): | |
| from core.db import db_conn | |
| from core.config import VOICES_DIR as _VD | |
| with db_conn() as conn: | |
| row = conn.execute( | |
| "SELECT * FROM voice_profiles WHERE id=?", | |
| (job["voice_id"],), | |
| ).fetchone() | |
| if row: | |
| if row["is_locked"] and row["locked_audio_path"]: | |
| ref_audio = os.path.join(_VD, row["locked_audio_path"]) | |
| elif row["ref_audio_path"]: | |
| ref_audio = os.path.join(_VD, row["ref_audio_path"]) | |
| ref_text = row.get("ref_text") | |
| try: | |
| audios = _model.generate( | |
| text=text, language=lang, | |
| ref_audio=ref_audio, ref_text=ref_text, | |
| duration=dur, num_step=16, | |
| guidance_scale=2.0, speed=1.0, | |
| denoise=True, postprocess_output=True, | |
| ) | |
| audio_out = audios[0] | |
| mastered = apply_mastering( | |
| audio_out, | |
| sample_rate=sr, | |
| ) | |
| return normalize_audio(mastered, target_dBFS=-2.0) | |
| except Exception as e: | |
| logger.warning("TTS failed for seg %d (lang=%s): %s", i, lang, e) | |
| return torch.zeros(1, int(dur * sr)) | |
| try: | |
| audio_tensor = await loop.run_in_executor(_gpu_pool, _gen) | |
| # Fit to slot | |
| target_samples_seg = int(seg_duration * sr) | |
| current_samples = audio_tensor.shape[-1] | |
| if target_samples_seg > current_samples: | |
| audio_tensor = torch.nn.functional.pad( | |
| audio_tensor, (0, target_samples_seg - current_samples) | |
| ) | |
| elif current_samples > target_samples_seg: | |
| audio_tensor = audio_tensor[..., :target_samples_seg] | |
| # Crossfade | |
| fade_samples = int(0.015 * sr) | |
| wl = audio_tensor.shape[-1] | |
| if wl > fade_samples * 2: | |
| ramp_up = torch.linspace(0, 1, fade_samples) | |
| ramp_down = torch.linspace(1, 0, fade_samples) | |
| audio_tensor[0, :fade_samples] *= ramp_up | |
| audio_tensor[0, -fade_samples:] *= ramp_down | |
| s_idx = int(seg_start * sr) | |
| e_idx = min(s_idx + wl, total_samples) | |
| full_audio[:, s_idx:e_idx] += audio_tensor[:, :e_idx - s_idx] | |
| except Exception as e: | |
| logger.warning("Batch TTS seg %d failed: %s", i, e) | |
| # ββ 3c. Save dubbed audio track βββββββββββββββββββββββββββββββ | |
| # Same assembly pattern as dub_generate.py:390 β `full_audio` is a | |
| # zero-init tensor that gets +='d from torch.cat-style slices, so | |
| # it can land non-contiguous + out-of-range. Go through the | |
| # audited + atomic helper to defend against #48 silent corruption | |
| # and partial-write truncation simultaneously. | |
| track_path = os.path.join(batch_dir, f"dubbed_{target_lang}.wav") | |
| atomic_save_wav(track_path, full_audio, sr) | |
| # ββ 3d. Mix with original video βββββββββββββββββββββββββββββββ | |
| _set_progress( | |
| job, "mix", | |
| percent=int(((lang_idx + 0.8) / total_langs) * 100), | |
| current_lang=target_lang, | |
| ) | |
| output_path = os.path.join(batch_dir, f"output_{target_lang}.mp4") | |
| def _mix(bg=job.get("preserve_bg", True)): | |
| if bg: | |
| # Mix dubbed audio with original background | |
| subprocess.run( | |
| [ffmpeg, "-y", | |
| "-i", video_path, | |
| "-i", track_path, | |
| "-filter_complex", | |
| "[0:a]volume=0.15[bg];[1:a]volume=1.0[dub];[bg][dub]amix=inputs=2:duration=first[out]", | |
| "-map", "0:v", "-map", "[out]", | |
| "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", | |
| "-shortest", output_path], | |
| stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, | |
| timeout=600, check=True, | |
| ) | |
| else: | |
| # Replace audio entirely | |
| subprocess.run( | |
| [ffmpeg, "-y", | |
| "-i", video_path, | |
| "-i", track_path, | |
| "-map", "0:v", "-map", "1:a", | |
| "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", | |
| "-shortest", output_path], | |
| stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, | |
| timeout=600, check=True, | |
| ) | |
| await loop.run_in_executor(None, _mix) | |
| outputs[target_lang] = output_path | |
| job["outputs"] = outputs | |
| _set_progress(job, "done", 100) | |
| # ββ Endpoints βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def enqueue_batch_job( | |
| video: UploadFile = File(...), | |
| langs: str = Form("es"), # comma-separated lang codes | |
| voice_id: Optional[str] = Form(None), | |
| preserve_bg: bool = Form(True), | |
| ): | |
| """Enqueue a video for batch dubbing. | |
| The video is saved to disk and a job is added to the queue. | |
| Returns the job ID for status polling. | |
| """ | |
| _ensure_queue() | |
| job_id = str(uuid.uuid4())[:12] | |
| lang_list = [l.strip() for l in langs.split(",") if l.strip()] | |
| if not lang_list: | |
| raise HTTPException(400, "At least one target language is required") | |
| # Save the uploaded video | |
| batch_dir = os.path.join(DATA_DIR, "batch") | |
| os.makedirs(batch_dir, exist_ok=True) | |
| ext = os.path.splitext(video.filename or "video.mp4")[1] or ".mp4" | |
| video_path = os.path.join(batch_dir, f"{job_id}{ext}") | |
| with open(video_path, "wb") as f: | |
| content = await video.read() | |
| f.write(content) | |
| job = { | |
| "id": job_id, | |
| "status": "queued", | |
| "filename": video.filename or f"{job_id}{ext}", | |
| "video_path": video_path, | |
| "langs": lang_list, | |
| "voice_id": voice_id, | |
| "preserve_bg": preserve_bg, | |
| "created_at": time.time(), | |
| "started_at": None, | |
| "finished_at": None, | |
| "error": None, | |
| "progress": None, | |
| } | |
| _jobs[job_id] = job | |
| await _queue.put(job_id) | |
| logger.info("Batch job %s enqueued: %s β %s", job_id, video.filename, lang_list) | |
| return {"job_id": job_id, "status": "queued", "queue_position": _queue.qsize()} | |
| def list_batch_jobs(status: Optional[str] = None, limit: int = 50): | |
| """List batch jobs, optionally filtered by status.""" | |
| jobs = list(_jobs.values()) | |
| if status: | |
| if status == "active": | |
| jobs = [j for j in jobs if j["status"] in ("queued", "running")] | |
| else: | |
| jobs = [j for j in jobs if j["status"] == status] | |
| jobs.sort(key=lambda j: j["created_at"], reverse=True) | |
| return jobs[:limit] | |
| def get_batch_job(job_id: str): | |
| """Get the status of a specific batch job.""" | |
| job = _jobs.get(job_id) | |
| if not job: | |
| raise HTTPException(404, "Job not found") | |
| return job | |
| def cancel_batch_job(job_id: str): | |
| """Cancel a queued or running batch job.""" | |
| job = _jobs.get(job_id) | |
| if not job: | |
| raise HTTPException(404, "Job not found") | |
| if job["status"] in ("done", "failed", "cancelled"): | |
| return {"already": job["status"]} | |
| job["status"] = "cancelled" | |
| job["finished_at"] = time.time() | |
| return {"cancelled": True} | |
| def delete_batch_job(job_id: str): | |
| """Delete a batch job record and its video file.""" | |
| job = _jobs.pop(job_id, None) | |
| if not job: | |
| raise HTTPException(404, "Job not found") | |
| if job.get("video_path") and os.path.exists(job["video_path"]): | |
| try: | |
| os.remove(job["video_path"]) | |
| except Exception: | |
| pass | |
| return {"deleted": True} | |
| def download_batch_output(job_id: str, lang: str): | |
| """Download a completed batch job's output video for a given language.""" | |
| from fastapi.responses import FileResponse | |
| job = _jobs.get(job_id) | |
| if not job: | |
| raise HTTPException(404, "Job not found") | |
| if job["status"] != "done": | |
| raise HTTPException(400, f"Job is {job['status']}, not done") | |
| outputs = job.get("outputs", {}) | |
| path = outputs.get(lang) | |
| if not path or not os.path.exists(path): | |
| raise HTTPException(404, f"No output for language '{lang}'") | |
| filename = f"{os.path.splitext(job['filename'])[0]}_{lang}.mp4" | |
| return FileResponse( | |
| path, | |
| media_type="video/mp4", | |
| filename=filename, | |
| ) | |