Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- Dockerfile +15 -0
- README.md +20 -5
- config.py +78 -0
- jobs/__init__.py +0 -0
- jobs/pipeline.py +344 -0
- jobs/progress_tracker.py +154 -0
- main.py +74 -0
- requirements.txt +10 -0
- routes/__init__.py +0 -0
- routes/dub.py +181 -0
- routes/info.py +36 -0
- services/__init__.py +0 -0
- services/audio_assembler.py +165 -0
- services/audio_extractor.py +82 -0
- services/audio_mixer.py +96 -0
- services/downloader.py +114 -0
- services/merger.py +84 -0
- services/speaker_profiler.py +37 -0
- services/subtitle_generator.py +62 -0
- services/transcriber.py +243 -0
- services/translator.py +139 -0
- services/tts_generator.py +134 -0
- services/vocal_separator.py +89 -0
- templates/index.html +251 -0
- utils/__init__.py +0 -0
- utils/file_manager.py +76 -0
- utils/gpu_detector.py +87 -0
Dockerfile
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
# Install FFmpeg
|
| 4 |
+
RUN apt-get update && apt-get install -y ffmpeg && rm -rf /var/lib/apt/lists/*
|
| 5 |
+
|
| 6 |
+
WORKDIR /app
|
| 7 |
+
COPY requirements.txt .
|
| 8 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 9 |
+
|
| 10 |
+
COPY . .
|
| 11 |
+
RUN mkdir -p temp_jobs static
|
| 12 |
+
|
| 13 |
+
EXPOSE 7860
|
| 14 |
+
|
| 15 |
+
CMD ["python", "main.py"]
|
README.md
CHANGED
|
@@ -1,10 +1,25 @@
|
|
| 1 |
---
|
| 2 |
title: Video Dubbing Agent
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
-
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: Video Dubbing Agent
|
| 3 |
+
emoji: 🎬
|
| 4 |
+
colorFrom: purple
|
| 5 |
+
colorTo: green
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: true
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# Video Dubbing Agent
|
| 12 |
+
|
| 13 |
+
AI-powered video dubbing system. Paste a YouTube URL, pick a language, and get the video dubbed with a male voice.
|
| 14 |
+
|
| 15 |
+
- **Free GPU transcription** via HuggingFace Inference API (Whisper Large V3)
|
| 16 |
+
- **20+ languages** including Hindi, Marathi, Spanish, French, Japanese, etc.
|
| 17 |
+
- **Male voice only** (Edge TTS Neural voices)
|
| 18 |
+
- **Subtitles** generated in both original and target language
|
| 19 |
+
|
| 20 |
+
## How to use
|
| 21 |
+
|
| 22 |
+
1. Paste a YouTube URL
|
| 23 |
+
2. Select target language
|
| 24 |
+
3. Click "Start Dubbing"
|
| 25 |
+
4. Download the dubbed video when done
|
config.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Video Dubbing Agent — Configuration
|
| 3 |
+
Deployed version with HuggingFace GPU transcription + male-only voices.
|
| 4 |
+
"""
|
| 5 |
+
import os
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
BASE_DIR = Path(__file__).parent
|
| 9 |
+
TEMP_DIR = BASE_DIR / "temp_jobs"
|
| 10 |
+
STATIC_DIR = BASE_DIR / "static"
|
| 11 |
+
TEMPLATES_DIR = BASE_DIR / "templates"
|
| 12 |
+
TEMP_DIR.mkdir(exist_ok=True)
|
| 13 |
+
|
| 14 |
+
# Server
|
| 15 |
+
HOST = "0.0.0.0"
|
| 16 |
+
PORT = int(os.getenv("PORT", 7860)) # HF Spaces uses 7860
|
| 17 |
+
DEBUG = os.getenv("DEBUG", "false").lower() == "true"
|
| 18 |
+
|
| 19 |
+
# YouTube
|
| 20 |
+
YT_FORMAT = "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best"
|
| 21 |
+
YT_MAX_DURATION = 4 * 3600
|
| 22 |
+
|
| 23 |
+
# Audio
|
| 24 |
+
AUDIO_SAMPLE_RATE = 16000
|
| 25 |
+
AUDIO_CHANNELS = 1
|
| 26 |
+
|
| 27 |
+
# === TRANSCRIPTION: HuggingFace Free GPU API ===
|
| 28 |
+
# This uses HF's free Inference API with GPU — no local model needed!
|
| 29 |
+
HF_API_URL = "https://api-inference.huggingface.co/models/openai/whisper-large-v3"
|
| 30 |
+
HF_TOKEN = os.getenv("HF_TOKEN", "") # Optional: set for higher rate limits
|
| 31 |
+
# Fallback to local faster-whisper if HF API fails
|
| 32 |
+
WHISPER_MODEL_SIZE = os.getenv("WHISPER_MODEL", "tiny")
|
| 33 |
+
WHISPER_COMPUTE_TYPE = "int8"
|
| 34 |
+
|
| 35 |
+
# === VOICE: MALE ONLY ===
|
| 36 |
+
# Force all speakers to use MALE voice regardless of detected gender
|
| 37 |
+
FORCE_MALE_VOICE = True
|
| 38 |
+
|
| 39 |
+
# Edge TTS Voice Map — MALE voices only
|
| 40 |
+
EDGE_TTS_VOICES = {
|
| 41 |
+
"mr": {"male": "mr-IN-ManoharNeural", "female": "mr-IN-ManoharNeural"},
|
| 42 |
+
"hi": {"male": "hi-IN-MadhurNeural", "female": "hi-IN-MadhurNeural"},
|
| 43 |
+
"en": {"male": "en-US-GuyNeural", "female": "en-US-GuyNeural"},
|
| 44 |
+
"es": {"male": "es-ES-AlvaroNeural", "female": "es-ES-AlvaroNeural"},
|
| 45 |
+
"fr": {"male": "fr-FR-HenriNeural", "female": "fr-FR-HenriNeural"},
|
| 46 |
+
"de": {"male": "de-DE-ConradNeural", "female": "de-DE-ConradNeural"},
|
| 47 |
+
"ja": {"male": "ja-JP-KeitaNeural", "female": "ja-JP-KeitaNeural"},
|
| 48 |
+
"zh": {"male": "zh-CN-YunxiNeural", "female": "zh-CN-YunxiNeural"},
|
| 49 |
+
"pt": {"male": "pt-BR-AntonioNeural", "female": "pt-BR-AntonioNeural"},
|
| 50 |
+
"ar": {"male": "ar-SA-HamedNeural", "female": "ar-SA-HamedNeural"},
|
| 51 |
+
"ko": {"male": "ko-KR-InJoonNeural", "female": "ko-KR-InJoonNeural"},
|
| 52 |
+
"ru": {"male": "ru-RU-DmitryNeural", "female": "ru-RU-DmitryNeural"},
|
| 53 |
+
"it": {"male": "it-IT-DiegoNeural", "female": "it-IT-DiegoNeural"},
|
| 54 |
+
"ta": {"male": "ta-IN-ValluvarNeural", "female": "ta-IN-ValluvarNeural"},
|
| 55 |
+
"te": {"male": "te-IN-MohanNeural", "female": "te-IN-MohanNeural"},
|
| 56 |
+
"bn": {"male": "bn-IN-BashkarNeural", "female": "bn-IN-BashkarNeural"},
|
| 57 |
+
"gu": {"male": "gu-IN-NiranjanNeural", "female": "gu-IN-NiranjanNeural"},
|
| 58 |
+
"kn": {"male": "kn-IN-GaganNeural", "female": "kn-IN-GaganNeural"},
|
| 59 |
+
"ml": {"male": "ml-IN-MidhunNeural", "female": "ml-IN-MidhunNeural"},
|
| 60 |
+
"ur": {"male": "ur-PK-AsadNeural", "female": "ur-PK-AsadNeural"},
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
TTS_MAX_SPEED_FACTOR = 1.3
|
| 64 |
+
TTS_CROSSFADE_MS = 50
|
| 65 |
+
BACKGROUND_VOLUME = 0.15
|
| 66 |
+
JOB_EXPIRY_HOURS = 12
|
| 67 |
+
MAX_CONCURRENT_JOBS = 2
|
| 68 |
+
|
| 69 |
+
# Audio chunk size for HF API (30 seconds per chunk)
|
| 70 |
+
HF_CHUNK_DURATION_SEC = 30
|
| 71 |
+
|
| 72 |
+
SUPPORTED_LANGUAGES = {
|
| 73 |
+
"mr": "Marathi", "hi": "Hindi", "en": "English", "es": "Spanish",
|
| 74 |
+
"fr": "French", "de": "German", "ja": "Japanese", "zh": "Chinese",
|
| 75 |
+
"pt": "Portuguese", "ar": "Arabic", "ko": "Korean", "ru": "Russian",
|
| 76 |
+
"it": "Italian", "ta": "Tamil", "te": "Telugu", "bn": "Bengali",
|
| 77 |
+
"gu": "Gujarati", "kn": "Kannada", "ml": "Malayalam", "ur": "Urdu",
|
| 78 |
+
}
|
jobs/__init__.py
ADDED
|
File without changes
|
jobs/pipeline.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pipeline Orchestrator
|
| 3 |
+
Runs all 9+ stages sequentially for a dubbing job.
|
| 4 |
+
Supports stage-level resumability and detailed progress tracking.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import logging
|
| 8 |
+
import json
|
| 9 |
+
import traceback
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Optional
|
| 12 |
+
|
| 13 |
+
from jobs.progress_tracker import tracker
|
| 14 |
+
from services.downloader import download_video, get_video_info, use_local_file
|
| 15 |
+
from services.audio_extractor import extract_audio_for_stt, extract_audio_stereo, get_video_duration
|
| 16 |
+
from services.vocal_separator import separate_vocals
|
| 17 |
+
from services.transcriber import transcribe_audio
|
| 18 |
+
from services.speaker_profiler import profile_speakers
|
| 19 |
+
from services.translator import translate_segments
|
| 20 |
+
from services.tts_generator import generate_dubbed_audio
|
| 21 |
+
from services.audio_assembler import assemble_dubbed_audio
|
| 22 |
+
from services.audio_mixer import mix_audio
|
| 23 |
+
from services.merger import merge_video_audio
|
| 24 |
+
from services.subtitle_generator import generate_subtitles
|
| 25 |
+
|
| 26 |
+
logger = logging.getLogger(__name__)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def run_dubbing_pipeline(
|
| 30 |
+
job_id: str,
|
| 31 |
+
job_dir: Path,
|
| 32 |
+
youtube_url: Optional[str] = None,
|
| 33 |
+
local_file: Optional[str] = None,
|
| 34 |
+
target_language: str = "mr",
|
| 35 |
+
source_language: Optional[str] = None,
|
| 36 |
+
preserve_background: bool = True,
|
| 37 |
+
generate_subs: bool = True,
|
| 38 |
+
):
|
| 39 |
+
"""
|
| 40 |
+
Run the complete dubbing pipeline.
|
| 41 |
+
This runs as a background task.
|
| 42 |
+
"""
|
| 43 |
+
try:
|
| 44 |
+
logger.info(f"=== Pipeline started: job={job_id}, target={target_language} ===")
|
| 45 |
+
|
| 46 |
+
# Checkpoint file for resumability
|
| 47 |
+
checkpoint_path = job_dir / "checkpoint.json"
|
| 48 |
+
checkpoint = _load_checkpoint(checkpoint_path)
|
| 49 |
+
|
| 50 |
+
# ============================================================
|
| 51 |
+
# STAGE 1: Download video
|
| 52 |
+
# ============================================================
|
| 53 |
+
if checkpoint.get("stage", 0) < 1:
|
| 54 |
+
tracker.update_stage(job_id, "downloading")
|
| 55 |
+
logger.info("STAGE 1: Downloading video...")
|
| 56 |
+
|
| 57 |
+
if youtube_url:
|
| 58 |
+
video_info = get_video_info(youtube_url)
|
| 59 |
+
tracker.get_job(job_id).video_title = video_info["title"]
|
| 60 |
+
|
| 61 |
+
def dl_progress(pct):
|
| 62 |
+
tracker.update_progress(job_id, int(pct))
|
| 63 |
+
|
| 64 |
+
video_path = download_video(youtube_url, job_dir, progress_callback=dl_progress)
|
| 65 |
+
elif local_file:
|
| 66 |
+
video_path = use_local_file(local_file, job_dir)
|
| 67 |
+
video_info = {"title": Path(local_file).stem, "duration": 0}
|
| 68 |
+
tracker.get_job(job_id).video_title = video_info["title"]
|
| 69 |
+
else:
|
| 70 |
+
raise ValueError("No youtube_url or local_file provided.")
|
| 71 |
+
|
| 72 |
+
_save_checkpoint(checkpoint_path, 1, {"video_path": str(video_path), "video_info": video_info})
|
| 73 |
+
else:
|
| 74 |
+
cp = checkpoint["data"]
|
| 75 |
+
video_path = Path(cp["video_path"])
|
| 76 |
+
video_info = cp.get("video_info", {})
|
| 77 |
+
logger.info("STAGE 1: Skipped (checkpoint)")
|
| 78 |
+
|
| 79 |
+
# ============================================================
|
| 80 |
+
# STAGE 2: Extract audio
|
| 81 |
+
# ============================================================
|
| 82 |
+
if checkpoint.get("stage", 0) < 2:
|
| 83 |
+
tracker.update_stage(job_id, "extracting_audio")
|
| 84 |
+
logger.info("STAGE 2: Extracting audio...")
|
| 85 |
+
|
| 86 |
+
stt_audio = extract_audio_for_stt(video_path, job_dir)
|
| 87 |
+
stereo_audio = extract_audio_stereo(video_path, job_dir)
|
| 88 |
+
total_duration = get_video_duration(video_path)
|
| 89 |
+
|
| 90 |
+
_save_checkpoint(checkpoint_path, 2, {
|
| 91 |
+
"stt_audio": str(stt_audio),
|
| 92 |
+
"stereo_audio": str(stereo_audio),
|
| 93 |
+
"total_duration": total_duration,
|
| 94 |
+
})
|
| 95 |
+
else:
|
| 96 |
+
cp = checkpoint["data"]
|
| 97 |
+
stt_audio = Path(cp["stt_audio"])
|
| 98 |
+
stereo_audio = Path(cp["stereo_audio"])
|
| 99 |
+
total_duration = cp["total_duration"]
|
| 100 |
+
logger.info("STAGE 2: Skipped (checkpoint)")
|
| 101 |
+
|
| 102 |
+
# ============================================================
|
| 103 |
+
# STAGE 3: Vocal separation
|
| 104 |
+
# ============================================================
|
| 105 |
+
if checkpoint.get("stage", 0) < 3:
|
| 106 |
+
tracker.update_stage(job_id, "separating_vocals")
|
| 107 |
+
logger.info("STAGE 3: Separating vocals from background...")
|
| 108 |
+
|
| 109 |
+
if preserve_background:
|
| 110 |
+
separation = separate_vocals(stt_audio, job_dir)
|
| 111 |
+
else:
|
| 112 |
+
separation = {"vocals": stt_audio, "background": None, "separated": False}
|
| 113 |
+
|
| 114 |
+
_save_checkpoint(checkpoint_path, 3, {
|
| 115 |
+
"vocals_path": str(separation["vocals"]),
|
| 116 |
+
"background_path": str(separation["background"]) if separation["background"] else None,
|
| 117 |
+
"separated": separation["separated"],
|
| 118 |
+
})
|
| 119 |
+
else:
|
| 120 |
+
cp = checkpoint["data"]
|
| 121 |
+
separation = {
|
| 122 |
+
"vocals": Path(cp["vocals_path"]),
|
| 123 |
+
"background": Path(cp["background_path"]) if cp["background_path"] else None,
|
| 124 |
+
"separated": cp["separated"],
|
| 125 |
+
}
|
| 126 |
+
logger.info("STAGE 3: Skipped (checkpoint)")
|
| 127 |
+
|
| 128 |
+
# ============================================================
|
| 129 |
+
# STAGE 4: Transcribe + Diarize
|
| 130 |
+
# ============================================================
|
| 131 |
+
if checkpoint.get("stage", 0) < 4:
|
| 132 |
+
tracker.update_stage(job_id, "transcribing")
|
| 133 |
+
logger.info("STAGE 4: Transcribing with WhisperX...")
|
| 134 |
+
|
| 135 |
+
def transcribe_progress(pct):
|
| 136 |
+
tracker.update_progress(job_id, pct)
|
| 137 |
+
|
| 138 |
+
segments = transcribe_audio(
|
| 139 |
+
audio_path=separation["vocals"],
|
| 140 |
+
output_dir=job_dir,
|
| 141 |
+
source_language=source_language,
|
| 142 |
+
progress_callback=transcribe_progress,
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
# Load detected language from transcript
|
| 146 |
+
transcript_data = json.loads((job_dir / "transcript.json").read_text())
|
| 147 |
+
detected_language = transcript_data.get("language", source_language or "en")
|
| 148 |
+
|
| 149 |
+
_save_checkpoint(checkpoint_path, 4, {
|
| 150 |
+
"detected_language": detected_language,
|
| 151 |
+
})
|
| 152 |
+
else:
|
| 153 |
+
transcript_data = json.loads((job_dir / "transcript.json").read_text())
|
| 154 |
+
segments = transcript_data["segments"]
|
| 155 |
+
detected_language = checkpoint["data"].get("detected_language", "en")
|
| 156 |
+
logger.info("STAGE 4: Skipped (checkpoint)")
|
| 157 |
+
|
| 158 |
+
# ============================================================
|
| 159 |
+
# STAGE 5: Speaker profiling + gender detection
|
| 160 |
+
# ============================================================
|
| 161 |
+
if checkpoint.get("stage", 0) < 5:
|
| 162 |
+
tracker.update_stage(job_id, "profiling_speakers")
|
| 163 |
+
logger.info("STAGE 5: Profiling speakers & detecting gender...")
|
| 164 |
+
|
| 165 |
+
speaker_profiles = profile_speakers(
|
| 166 |
+
segments=segments,
|
| 167 |
+
audio_path=separation["vocals"],
|
| 168 |
+
output_dir=job_dir,
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
# Save profiles (convert Path to str for JSON)
|
| 172 |
+
profiles_serializable = {}
|
| 173 |
+
for spk, prof in speaker_profiles.items():
|
| 174 |
+
ref = prof.get("reference_audio")
|
| 175 |
+
profiles_serializable[spk] = {
|
| 176 |
+
**prof,
|
| 177 |
+
"reference_audio": str(ref) if ref else None,
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
_save_checkpoint(checkpoint_path, 5, {
|
| 181 |
+
"speaker_profiles": profiles_serializable,
|
| 182 |
+
})
|
| 183 |
+
else:
|
| 184 |
+
profiles_serializable = checkpoint["data"]["speaker_profiles"]
|
| 185 |
+
speaker_profiles = {}
|
| 186 |
+
for spk, prof in profiles_serializable.items():
|
| 187 |
+
ref = prof.get("reference_audio")
|
| 188 |
+
speaker_profiles[spk] = {
|
| 189 |
+
**prof,
|
| 190 |
+
"reference_audio": Path(ref) if ref else None,
|
| 191 |
+
}
|
| 192 |
+
logger.info("STAGE 5: Skipped (checkpoint)")
|
| 193 |
+
|
| 194 |
+
# ============================================================
|
| 195 |
+
# STAGE 6: Translation
|
| 196 |
+
# ============================================================
|
| 197 |
+
if checkpoint.get("stage", 0) < 6:
|
| 198 |
+
tracker.update_stage(job_id, "translating")
|
| 199 |
+
logger.info(f"STAGE 6: Translating {detected_language} → {target_language}...")
|
| 200 |
+
|
| 201 |
+
def translate_progress(pct):
|
| 202 |
+
tracker.update_progress(job_id, pct)
|
| 203 |
+
|
| 204 |
+
translated = translate_segments(
|
| 205 |
+
segments=segments,
|
| 206 |
+
source_language=detected_language,
|
| 207 |
+
target_language=target_language,
|
| 208 |
+
output_dir=job_dir,
|
| 209 |
+
progress_callback=translate_progress,
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
_save_checkpoint(checkpoint_path, 6, {})
|
| 213 |
+
else:
|
| 214 |
+
translated_data = json.loads((job_dir / "translated_segments.json").read_text())
|
| 215 |
+
translated = translated_data["segments"]
|
| 216 |
+
logger.info("STAGE 6: Skipped (checkpoint)")
|
| 217 |
+
|
| 218 |
+
# ============================================================
|
| 219 |
+
# STAGE 7: TTS Generation (gender-matched)
|
| 220 |
+
# ============================================================
|
| 221 |
+
if checkpoint.get("stage", 0) < 7:
|
| 222 |
+
tracker.update_stage(job_id, "generating_tts")
|
| 223 |
+
logger.info("STAGE 7: Generating gender-matched TTS audio...")
|
| 224 |
+
|
| 225 |
+
def tts_progress(pct):
|
| 226 |
+
tracker.update_progress(job_id, pct)
|
| 227 |
+
|
| 228 |
+
tts_segments = generate_dubbed_audio(
|
| 229 |
+
translated_segments=translated,
|
| 230 |
+
speaker_profiles=speaker_profiles,
|
| 231 |
+
target_language=target_language,
|
| 232 |
+
output_dir=job_dir,
|
| 233 |
+
progress_callback=tts_progress,
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
_save_checkpoint(checkpoint_path, 7, {})
|
| 237 |
+
else:
|
| 238 |
+
tts_segments = translated # Will have tts_audio_path from previous run
|
| 239 |
+
logger.info("STAGE 7: Skipped (checkpoint)")
|
| 240 |
+
|
| 241 |
+
# ============================================================
|
| 242 |
+
# STAGE 8: Audio Assembly
|
| 243 |
+
# ============================================================
|
| 244 |
+
if checkpoint.get("stage", 0) < 8:
|
| 245 |
+
tracker.update_stage(job_id, "assembling_audio")
|
| 246 |
+
logger.info("STAGE 8: Assembling dubbed audio track...")
|
| 247 |
+
|
| 248 |
+
def assemble_progress(pct):
|
| 249 |
+
tracker.update_progress(job_id, pct)
|
| 250 |
+
|
| 251 |
+
dubbed_audio = assemble_dubbed_audio(
|
| 252 |
+
segments=tts_segments,
|
| 253 |
+
total_duration=total_duration,
|
| 254 |
+
output_dir=job_dir,
|
| 255 |
+
progress_callback=assemble_progress,
|
| 256 |
+
)
|
| 257 |
+
|
| 258 |
+
_save_checkpoint(checkpoint_path, 8, {"dubbed_audio": str(dubbed_audio)})
|
| 259 |
+
else:
|
| 260 |
+
dubbed_audio = Path(checkpoint["data"]["dubbed_audio"])
|
| 261 |
+
logger.info("STAGE 8: Skipped (checkpoint)")
|
| 262 |
+
|
| 263 |
+
# ============================================================
|
| 264 |
+
# STAGE 9: Background Audio Mixing
|
| 265 |
+
# ============================================================
|
| 266 |
+
if checkpoint.get("stage", 0) < 9:
|
| 267 |
+
tracker.update_stage(job_id, "mixing_audio")
|
| 268 |
+
logger.info("STAGE 9: Mixing dubbed audio with background...")
|
| 269 |
+
|
| 270 |
+
final_audio = mix_audio(
|
| 271 |
+
dubbed_audio_path=dubbed_audio,
|
| 272 |
+
background_audio_path=separation.get("background"),
|
| 273 |
+
output_dir=job_dir,
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
_save_checkpoint(checkpoint_path, 9, {"final_audio": str(final_audio)})
|
| 277 |
+
else:
|
| 278 |
+
final_audio = Path(checkpoint["data"]["final_audio"])
|
| 279 |
+
logger.info("STAGE 9: Skipped (checkpoint)")
|
| 280 |
+
|
| 281 |
+
# ============================================================
|
| 282 |
+
# STAGE 10: Final Video Merge
|
| 283 |
+
# ============================================================
|
| 284 |
+
if checkpoint.get("stage", 0) < 10:
|
| 285 |
+
tracker.update_stage(job_id, "merging_video")
|
| 286 |
+
logger.info("STAGE 10: Merging dubbed audio into video...")
|
| 287 |
+
|
| 288 |
+
final_video = merge_video_audio(
|
| 289 |
+
video_path=video_path,
|
| 290 |
+
audio_path=final_audio,
|
| 291 |
+
output_dir=job_dir,
|
| 292 |
+
video_title=video_info.get("title", "dubbed_video"),
|
| 293 |
+
)
|
| 294 |
+
|
| 295 |
+
_save_checkpoint(checkpoint_path, 10, {"final_video": str(final_video)})
|
| 296 |
+
else:
|
| 297 |
+
final_video = Path(checkpoint["data"]["final_video"])
|
| 298 |
+
logger.info("STAGE 10: Skipped (checkpoint)")
|
| 299 |
+
|
| 300 |
+
# ============================================================
|
| 301 |
+
# STAGE 11: Subtitles (bonus)
|
| 302 |
+
# ============================================================
|
| 303 |
+
subtitle_paths = {}
|
| 304 |
+
if generate_subs:
|
| 305 |
+
tracker.update_stage(job_id, "generating_subtitles")
|
| 306 |
+
logger.info("STAGE 11: Generating subtitles...")
|
| 307 |
+
|
| 308 |
+
srt_paths = generate_subtitles(
|
| 309 |
+
segments=tts_segments,
|
| 310 |
+
output_dir=job_dir,
|
| 311 |
+
source_language=detected_language,
|
| 312 |
+
target_language=target_language,
|
| 313 |
+
)
|
| 314 |
+
subtitle_paths = {k: str(v) for k, v in srt_paths.items()}
|
| 315 |
+
|
| 316 |
+
# ============================================================
|
| 317 |
+
# DONE
|
| 318 |
+
# ============================================================
|
| 319 |
+
tracker.complete_job(job_id, str(final_video), subtitle_paths)
|
| 320 |
+
logger.info(f"=== Pipeline complete: {final_video} ===")
|
| 321 |
+
|
| 322 |
+
except Exception as e:
|
| 323 |
+
error_msg = f"{type(e).__name__}: {str(e)}"
|
| 324 |
+
logger.error(f"Pipeline failed for job {job_id}: {error_msg}")
|
| 325 |
+
logger.error(traceback.format_exc())
|
| 326 |
+
tracker.fail_job(job_id, error_msg)
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
def _load_checkpoint(path: Path) -> dict:
|
| 330 |
+
"""Load checkpoint for resumability."""
|
| 331 |
+
if path.exists():
|
| 332 |
+
try:
|
| 333 |
+
return json.loads(path.read_text())
|
| 334 |
+
except json.JSONDecodeError:
|
| 335 |
+
return {"stage": 0, "data": {}}
|
| 336 |
+
return {"stage": 0, "data": {}}
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
def _save_checkpoint(path: Path, stage: int, data: dict):
|
| 340 |
+
"""Save checkpoint after completing a stage."""
|
| 341 |
+
existing = _load_checkpoint(path)
|
| 342 |
+
existing["stage"] = stage
|
| 343 |
+
existing["data"].update(data)
|
| 344 |
+
path.write_text(json.dumps(existing, indent=2))
|
jobs/progress_tracker.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Job Progress Tracker
|
| 3 |
+
Manages per-job state, progress, and stage tracking.
|
| 4 |
+
Thread-safe for background task usage.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import time
|
| 8 |
+
import threading
|
| 9 |
+
import json
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Optional, Dict
|
| 12 |
+
from dataclasses import dataclass, field, asdict
|
| 13 |
+
from enum import Enum
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class JobStatus(str, Enum):
|
| 17 |
+
QUEUED = "queued"
|
| 18 |
+
PROCESSING = "processing"
|
| 19 |
+
COMPLETED = "completed"
|
| 20 |
+
FAILED = "failed"
|
| 21 |
+
CANCELLED = "cancelled"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
STAGE_NAMES = [
|
| 25 |
+
"downloading",
|
| 26 |
+
"extracting_audio",
|
| 27 |
+
"separating_vocals",
|
| 28 |
+
"transcribing",
|
| 29 |
+
"profiling_speakers",
|
| 30 |
+
"translating",
|
| 31 |
+
"generating_tts",
|
| 32 |
+
"assembling_audio",
|
| 33 |
+
"mixing_audio",
|
| 34 |
+
"merging_video",
|
| 35 |
+
"generating_subtitles",
|
| 36 |
+
]
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@dataclass
|
| 40 |
+
class JobState:
|
| 41 |
+
job_id: str
|
| 42 |
+
status: JobStatus = JobStatus.QUEUED
|
| 43 |
+
current_stage: str = ""
|
| 44 |
+
stage_number: int = 0
|
| 45 |
+
total_stages: int = len(STAGE_NAMES)
|
| 46 |
+
progress_percent: int = 0
|
| 47 |
+
elapsed_time: float = 0
|
| 48 |
+
estimated_remaining: str = "calculating..."
|
| 49 |
+
start_time: float = 0
|
| 50 |
+
error_message: str = ""
|
| 51 |
+
video_title: str = ""
|
| 52 |
+
target_language: str = ""
|
| 53 |
+
output_path: str = ""
|
| 54 |
+
subtitle_paths: Dict = field(default_factory=dict)
|
| 55 |
+
|
| 56 |
+
def to_dict(self):
|
| 57 |
+
d = asdict(self)
|
| 58 |
+
d["status"] = self.status.value
|
| 59 |
+
d["elapsed_time_str"] = _format_time(self.elapsed_time)
|
| 60 |
+
return d
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _format_time(seconds: float) -> str:
|
| 64 |
+
if seconds < 60:
|
| 65 |
+
return f"{int(seconds)}s"
|
| 66 |
+
elif seconds < 3600:
|
| 67 |
+
return f"{int(seconds // 60)}m {int(seconds % 60)}s"
|
| 68 |
+
else:
|
| 69 |
+
h = int(seconds // 3600)
|
| 70 |
+
m = int((seconds % 3600) // 60)
|
| 71 |
+
return f"{h}h {m}m"
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class ProgressTracker:
|
| 75 |
+
"""Thread-safe job progress tracker."""
|
| 76 |
+
|
| 77 |
+
def __init__(self):
|
| 78 |
+
self._jobs: Dict[str, JobState] = {}
|
| 79 |
+
self._lock = threading.Lock()
|
| 80 |
+
|
| 81 |
+
def create_job(self, job_id: str, target_language: str = "") -> JobState:
|
| 82 |
+
with self._lock:
|
| 83 |
+
state = JobState(
|
| 84 |
+
job_id=job_id,
|
| 85 |
+
target_language=target_language,
|
| 86 |
+
start_time=time.time()
|
| 87 |
+
)
|
| 88 |
+
self._jobs[job_id] = state
|
| 89 |
+
return state
|
| 90 |
+
|
| 91 |
+
def get_job(self, job_id: str) -> Optional[JobState]:
|
| 92 |
+
with self._lock:
|
| 93 |
+
return self._jobs.get(job_id)
|
| 94 |
+
|
| 95 |
+
def update_stage(self, job_id: str, stage_name: str):
|
| 96 |
+
with self._lock:
|
| 97 |
+
job = self._jobs.get(job_id)
|
| 98 |
+
if not job:
|
| 99 |
+
return
|
| 100 |
+
job.status = JobStatus.PROCESSING
|
| 101 |
+
job.current_stage = stage_name
|
| 102 |
+
if stage_name in STAGE_NAMES:
|
| 103 |
+
job.stage_number = STAGE_NAMES.index(stage_name) + 1
|
| 104 |
+
job.elapsed_time = time.time() - job.start_time
|
| 105 |
+
# Estimate remaining
|
| 106 |
+
if job.stage_number > 0:
|
| 107 |
+
per_stage = job.elapsed_time / job.stage_number
|
| 108 |
+
remaining_stages = job.total_stages - job.stage_number
|
| 109 |
+
est = per_stage * remaining_stages
|
| 110 |
+
job.estimated_remaining = f"~{_format_time(est)}"
|
| 111 |
+
job.progress_percent = int(job.stage_number / job.total_stages * 100)
|
| 112 |
+
|
| 113 |
+
def update_progress(self, job_id: str, percent: int):
|
| 114 |
+
with self._lock:
|
| 115 |
+
job = self._jobs.get(job_id)
|
| 116 |
+
if job:
|
| 117 |
+
# Blend stage progress into overall progress
|
| 118 |
+
base = (job.stage_number - 1) / job.total_stages * 100
|
| 119 |
+
stage_contrib = percent / job.total_stages
|
| 120 |
+
job.progress_percent = min(int(base + stage_contrib), 99)
|
| 121 |
+
job.elapsed_time = time.time() - job.start_time
|
| 122 |
+
|
| 123 |
+
def complete_job(self, job_id: str, output_path: str, subtitle_paths: dict = None):
|
| 124 |
+
with self._lock:
|
| 125 |
+
job = self._jobs.get(job_id)
|
| 126 |
+
if job:
|
| 127 |
+
job.status = JobStatus.COMPLETED
|
| 128 |
+
job.progress_percent = 100
|
| 129 |
+
job.current_stage = "done"
|
| 130 |
+
job.output_path = output_path
|
| 131 |
+
job.subtitle_paths = subtitle_paths or {}
|
| 132 |
+
job.elapsed_time = time.time() - job.start_time
|
| 133 |
+
job.estimated_remaining = "0s"
|
| 134 |
+
|
| 135 |
+
def fail_job(self, job_id: str, error: str):
|
| 136 |
+
with self._lock:
|
| 137 |
+
job = self._jobs.get(job_id)
|
| 138 |
+
if job:
|
| 139 |
+
job.status = JobStatus.FAILED
|
| 140 |
+
job.error_message = error
|
| 141 |
+
job.elapsed_time = time.time() - job.start_time
|
| 142 |
+
job.estimated_remaining = ""
|
| 143 |
+
|
| 144 |
+
def list_jobs(self) -> list:
|
| 145 |
+
with self._lock:
|
| 146 |
+
return [j.to_dict() for j in self._jobs.values()]
|
| 147 |
+
|
| 148 |
+
def remove_job(self, job_id: str):
|
| 149 |
+
with self._lock:
|
| 150 |
+
self._jobs.pop(job_id, None)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
# Global singleton
|
| 154 |
+
tracker = ProgressTracker()
|
main.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Video Dubbing Agent — Deployed Version
|
| 3 |
+
Run: python main.py
|
| 4 |
+
Access: http://localhost:7860
|
| 5 |
+
"""
|
| 6 |
+
import logging
|
| 7 |
+
import sys
|
| 8 |
+
from contextlib import asynccontextmanager
|
| 9 |
+
|
| 10 |
+
from fastapi import FastAPI
|
| 11 |
+
from fastapi.staticfiles import StaticFiles
|
| 12 |
+
from fastapi.responses import FileResponse
|
| 13 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 14 |
+
|
| 15 |
+
from config import HOST, PORT, DEBUG, STATIC_DIR, TEMPLATES_DIR
|
| 16 |
+
from routes.dub import router as dub_router
|
| 17 |
+
from routes.info import router as info_router
|
| 18 |
+
from utils.file_manager import cleanup_expired_jobs
|
| 19 |
+
|
| 20 |
+
logging.basicConfig(
|
| 21 |
+
level=logging.INFO,
|
| 22 |
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
| 23 |
+
handlers=[logging.StreamHandler(sys.stdout)],
|
| 24 |
+
)
|
| 25 |
+
logger = logging.getLogger(__name__)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@asynccontextmanager
|
| 29 |
+
async def lifespan(app: FastAPI):
|
| 30 |
+
logger.info("Video Dubbing Agent starting...")
|
| 31 |
+
cleanup_expired_jobs()
|
| 32 |
+
logger.info(f"Ready at http://localhost:{PORT}")
|
| 33 |
+
yield
|
| 34 |
+
logger.info("Shutting down.")
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
app = FastAPI(
|
| 38 |
+
title="Video Dubbing Agent",
|
| 39 |
+
description="AI video dubbing: YouTube → any language. Male voice. Free GPU transcription.",
|
| 40 |
+
version="2.0.0",
|
| 41 |
+
lifespan=lifespan,
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
app.add_middleware(
|
| 45 |
+
CORSMiddleware,
|
| 46 |
+
allow_origins=["*"],
|
| 47 |
+
allow_credentials=True,
|
| 48 |
+
allow_methods=["*"],
|
| 49 |
+
allow_headers=["*"],
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
STATIC_DIR.mkdir(exist_ok=True)
|
| 53 |
+
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
| 54 |
+
|
| 55 |
+
app.include_router(dub_router, prefix="/api", tags=["Dubbing"])
|
| 56 |
+
app.include_router(info_router, prefix="/api", tags=["Info"])
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@app.get("/")
|
| 60 |
+
async def serve_frontend():
|
| 61 |
+
index_path = TEMPLATES_DIR / "index.html"
|
| 62 |
+
if index_path.exists():
|
| 63 |
+
return FileResponse(str(index_path))
|
| 64 |
+
return {"message": "Video Dubbing Agent API. Visit /docs for API docs."}
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@app.get("/health")
|
| 68 |
+
async def health():
|
| 69 |
+
return {"status": "healthy", "service": "video-dubbing-agent", "version": "2.0"}
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
if __name__ == "__main__":
|
| 73 |
+
import uvicorn
|
| 74 |
+
uvicorn.run("main:app", host=HOST, port=PORT, reload=False)
|
requirements.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.104.1
|
| 2 |
+
uvicorn[standard]==0.24.0
|
| 3 |
+
python-multipart==0.0.6
|
| 4 |
+
pydantic==2.5.0
|
| 5 |
+
yt-dlp>=2024.1.5
|
| 6 |
+
deep-translator==1.11.4
|
| 7 |
+
edge-tts>=6.1.9
|
| 8 |
+
pydub==0.25.1
|
| 9 |
+
faster-whisper==0.10.0
|
| 10 |
+
requests>=2.31.0
|
routes/__init__.py
ADDED
|
File without changes
|
routes/dub.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
API Routes — Dubbing Endpoints
|
| 3 |
+
POST /dub, GET /status, GET /download, DELETE /job
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
import threading
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Optional
|
| 10 |
+
|
| 11 |
+
from fastapi import APIRouter, HTTPException, BackgroundTasks
|
| 12 |
+
from fastapi.responses import FileResponse
|
| 13 |
+
from pydantic import BaseModel, HttpUrl
|
| 14 |
+
|
| 15 |
+
from jobs.progress_tracker import tracker
|
| 16 |
+
from jobs.pipeline import run_dubbing_pipeline
|
| 17 |
+
from utils.file_manager import create_job_directory, cleanup_job, get_job_disk_usage
|
| 18 |
+
from config import SUPPORTED_LANGUAGES
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger(__name__)
|
| 21 |
+
router = APIRouter()
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# === Request/Response Models ===
|
| 25 |
+
|
| 26 |
+
class DubRequest(BaseModel):
|
| 27 |
+
youtube_url: Optional[str] = None
|
| 28 |
+
local_file: Optional[str] = None
|
| 29 |
+
target_language: str = "mr"
|
| 30 |
+
source_language: Optional[str] = None # Auto-detect if None
|
| 31 |
+
preserve_background: bool = True
|
| 32 |
+
generate_subtitles: bool = True
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class DubResponse(BaseModel):
|
| 36 |
+
job_id: str
|
| 37 |
+
status: str
|
| 38 |
+
message: str
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class StatusResponse(BaseModel):
|
| 42 |
+
job_id: str
|
| 43 |
+
status: str
|
| 44 |
+
current_stage: str
|
| 45 |
+
stage_number: int
|
| 46 |
+
total_stages: int
|
| 47 |
+
progress_percent: int
|
| 48 |
+
elapsed_time: str
|
| 49 |
+
estimated_remaining: str
|
| 50 |
+
video_title: str
|
| 51 |
+
target_language: str
|
| 52 |
+
error_message: str = ""
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# === Endpoints ===
|
| 56 |
+
|
| 57 |
+
@router.post("/dub", response_model=DubResponse)
|
| 58 |
+
async def start_dubbing(request: DubRequest):
|
| 59 |
+
"""
|
| 60 |
+
Start a new dubbing job.
|
| 61 |
+
Provide either youtube_url or local_file path.
|
| 62 |
+
"""
|
| 63 |
+
# Validate input
|
| 64 |
+
if not request.youtube_url and not request.local_file:
|
| 65 |
+
raise HTTPException(400, "Provide either 'youtube_url' or 'local_file'.")
|
| 66 |
+
|
| 67 |
+
if request.target_language not in SUPPORTED_LANGUAGES:
|
| 68 |
+
raise HTTPException(
|
| 69 |
+
400,
|
| 70 |
+
f"Unsupported language: '{request.target_language}'. "
|
| 71 |
+
f"Supported: {list(SUPPORTED_LANGUAGES.keys())}"
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
# Create job
|
| 75 |
+
job_id, job_dir = create_job_directory()
|
| 76 |
+
tracker.create_job(job_id, target_language=request.target_language)
|
| 77 |
+
|
| 78 |
+
# Run pipeline in background thread
|
| 79 |
+
thread = threading.Thread(
|
| 80 |
+
target=run_dubbing_pipeline,
|
| 81 |
+
kwargs={
|
| 82 |
+
"job_id": job_id,
|
| 83 |
+
"job_dir": job_dir,
|
| 84 |
+
"youtube_url": request.youtube_url,
|
| 85 |
+
"local_file": request.local_file,
|
| 86 |
+
"target_language": request.target_language,
|
| 87 |
+
"source_language": request.source_language,
|
| 88 |
+
"preserve_background": request.preserve_background,
|
| 89 |
+
"generate_subs": request.generate_subtitles,
|
| 90 |
+
},
|
| 91 |
+
daemon=True,
|
| 92 |
+
)
|
| 93 |
+
thread.start()
|
| 94 |
+
|
| 95 |
+
lang_name = SUPPORTED_LANGUAGES.get(request.target_language, request.target_language)
|
| 96 |
+
|
| 97 |
+
return DubResponse(
|
| 98 |
+
job_id=job_id,
|
| 99 |
+
status="queued",
|
| 100 |
+
message=f"Dubbing job started → {lang_name}. Track progress at /status/{job_id}"
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@router.get("/status/{job_id}")
|
| 105 |
+
async def get_status(job_id: str):
|
| 106 |
+
"""Get the current status and progress of a dubbing job."""
|
| 107 |
+
job = tracker.get_job(job_id)
|
| 108 |
+
if not job:
|
| 109 |
+
raise HTTPException(404, f"Job not found: {job_id}")
|
| 110 |
+
|
| 111 |
+
data = job.to_dict()
|
| 112 |
+
data["disk_usage"] = get_job_disk_usage(job_id)
|
| 113 |
+
return data
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
@router.get("/download/{job_id}")
|
| 117 |
+
async def download_video(job_id: str):
|
| 118 |
+
"""Download the final dubbed video."""
|
| 119 |
+
job = tracker.get_job(job_id)
|
| 120 |
+
if not job:
|
| 121 |
+
raise HTTPException(404, f"Job not found: {job_id}")
|
| 122 |
+
|
| 123 |
+
if job.status.value != "completed":
|
| 124 |
+
raise HTTPException(400, f"Job is not complete. Status: {job.status.value}")
|
| 125 |
+
|
| 126 |
+
video_path = Path(job.output_path)
|
| 127 |
+
if not video_path.exists():
|
| 128 |
+
raise HTTPException(404, "Output video file not found.")
|
| 129 |
+
|
| 130 |
+
return FileResponse(
|
| 131 |
+
path=str(video_path),
|
| 132 |
+
media_type="video/mp4",
|
| 133 |
+
filename=video_path.name,
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
@router.get("/download/{job_id}/subtitles/{lang_type}")
|
| 138 |
+
async def download_subtitles(job_id: str, lang_type: str):
|
| 139 |
+
"""
|
| 140 |
+
Download subtitle file.
|
| 141 |
+
lang_type: 'original' or 'translated'
|
| 142 |
+
"""
|
| 143 |
+
job = tracker.get_job(job_id)
|
| 144 |
+
if not job:
|
| 145 |
+
raise HTTPException(404, f"Job not found: {job_id}")
|
| 146 |
+
|
| 147 |
+
if job.status.value != "completed":
|
| 148 |
+
raise HTTPException(400, "Job not complete.")
|
| 149 |
+
|
| 150 |
+
srt_path_str = job.subtitle_paths.get(lang_type)
|
| 151 |
+
if not srt_path_str:
|
| 152 |
+
raise HTTPException(404, f"No '{lang_type}' subtitle file available.")
|
| 153 |
+
|
| 154 |
+
srt_path = Path(srt_path_str)
|
| 155 |
+
if not srt_path.exists():
|
| 156 |
+
raise HTTPException(404, "Subtitle file not found on disk.")
|
| 157 |
+
|
| 158 |
+
return FileResponse(
|
| 159 |
+
path=str(srt_path),
|
| 160 |
+
media_type="application/x-subrip",
|
| 161 |
+
filename=srt_path.name,
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
@router.delete("/job/{job_id}")
|
| 166 |
+
async def delete_job(job_id: str):
|
| 167 |
+
"""Delete a job and clean up all its temp files."""
|
| 168 |
+
job = tracker.get_job(job_id)
|
| 169 |
+
if not job:
|
| 170 |
+
raise HTTPException(404, f"Job not found: {job_id}")
|
| 171 |
+
|
| 172 |
+
cleanup_job(job_id)
|
| 173 |
+
tracker.remove_job(job_id)
|
| 174 |
+
|
| 175 |
+
return {"message": f"Job {job_id} deleted.", "status": "deleted"}
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
@router.get("/jobs")
|
| 179 |
+
async def list_jobs():
|
| 180 |
+
"""List all jobs with their current status."""
|
| 181 |
+
return {"jobs": tracker.list_jobs()}
|
routes/info.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
API Routes — Info Endpoints
|
| 3 |
+
GET /languages, GET /system-info
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter
|
| 7 |
+
from config import SUPPORTED_LANGUAGES, EDGE_TTS_VOICES
|
| 8 |
+
from utils.gpu_detector import detect_gpu, get_system_report
|
| 9 |
+
|
| 10 |
+
router = APIRouter()
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@router.get("/languages")
|
| 14 |
+
async def get_languages():
|
| 15 |
+
"""List all supported target languages with their voice details."""
|
| 16 |
+
languages = []
|
| 17 |
+
for code, name in SUPPORTED_LANGUAGES.items():
|
| 18 |
+
voices = EDGE_TTS_VOICES.get(code, {})
|
| 19 |
+
languages.append({
|
| 20 |
+
"code": code,
|
| 21 |
+
"name": name,
|
| 22 |
+
"male_voice": voices.get("male", "N/A"),
|
| 23 |
+
"female_voice": voices.get("female", "N/A"),
|
| 24 |
+
})
|
| 25 |
+
return {"languages": languages, "total": len(languages)}
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@router.get("/system-info")
|
| 29 |
+
async def get_system_info():
|
| 30 |
+
"""Get system readiness report (GPU, estimated times)."""
|
| 31 |
+
gpu = detect_gpu()
|
| 32 |
+
report = get_system_report()
|
| 33 |
+
return {
|
| 34 |
+
"gpu": gpu,
|
| 35 |
+
"report": report,
|
| 36 |
+
}
|
services/__init__.py
ADDED
|
File without changes
|
services/audio_assembler.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Stage 7 — Audio Assembly
|
| 3 |
+
Stitches all TTS segments into a single continuous dubbed audio track.
|
| 4 |
+
Places each segment at the correct timestamp with silence padding.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import logging
|
| 8 |
+
import subprocess
|
| 9 |
+
import json
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import List, Dict
|
| 12 |
+
|
| 13 |
+
from config import TTS_CROSSFADE_MS
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def assemble_dubbed_audio(
|
| 19 |
+
segments: List[Dict],
|
| 20 |
+
total_duration: float,
|
| 21 |
+
output_dir: Path,
|
| 22 |
+
progress_callback=None
|
| 23 |
+
) -> Path:
|
| 24 |
+
"""
|
| 25 |
+
Create a full-length audio track by placing each TTS segment
|
| 26 |
+
at its original timestamp position.
|
| 27 |
+
|
| 28 |
+
Returns path to the assembled dubbed audio file.
|
| 29 |
+
"""
|
| 30 |
+
output_path = output_dir / "dubbed_audio_full.wav"
|
| 31 |
+
|
| 32 |
+
# Filter segments that have TTS audio
|
| 33 |
+
valid_segments = [
|
| 34 |
+
s for s in segments
|
| 35 |
+
if s.get("tts_audio_path") and Path(s["tts_audio_path"]).exists()
|
| 36 |
+
]
|
| 37 |
+
|
| 38 |
+
if not valid_segments:
|
| 39 |
+
raise RuntimeError("No valid TTS segments to assemble.")
|
| 40 |
+
|
| 41 |
+
logger.info(f"Assembling {len(valid_segments)} segments into {total_duration:.1f}s track")
|
| 42 |
+
|
| 43 |
+
# Use ffmpeg filter_complex to place segments at correct timestamps
|
| 44 |
+
# This is more reliable than pydub for long audio
|
| 45 |
+
filter_parts = []
|
| 46 |
+
inputs = []
|
| 47 |
+
|
| 48 |
+
# First input: silence for the full duration
|
| 49 |
+
inputs.extend([
|
| 50 |
+
"-f", "lavfi",
|
| 51 |
+
"-t", str(total_duration),
|
| 52 |
+
"-i", f"anullsrc=r=24000:cl=mono"
|
| 53 |
+
])
|
| 54 |
+
|
| 55 |
+
# Add each TTS segment as an input
|
| 56 |
+
for idx, seg in enumerate(valid_segments):
|
| 57 |
+
inputs.extend(["-i", seg["tts_audio_path"]])
|
| 58 |
+
|
| 59 |
+
# Build filter: overlay each segment at its start time
|
| 60 |
+
# [0] is the silence base, [1], [2], etc. are the TTS segments
|
| 61 |
+
n_segs = len(valid_segments)
|
| 62 |
+
|
| 63 |
+
if n_segs == 0:
|
| 64 |
+
raise RuntimeError("No segments to assemble.")
|
| 65 |
+
|
| 66 |
+
# For large numbers of segments, build the filter in stages
|
| 67 |
+
# to avoid ffmpeg filter complexity limits
|
| 68 |
+
if n_segs > 100:
|
| 69 |
+
return _assemble_chunked(valid_segments, total_duration, output_dir, output_path, progress_callback)
|
| 70 |
+
|
| 71 |
+
# Build adelay filter chain
|
| 72 |
+
filter_parts = []
|
| 73 |
+
mix_inputs = ["[base]"]
|
| 74 |
+
|
| 75 |
+
# The silence base
|
| 76 |
+
filter_parts.append("[0]aresample=24000[base]")
|
| 77 |
+
|
| 78 |
+
for idx, seg in enumerate(valid_segments):
|
| 79 |
+
input_idx = idx + 1 # +1 because [0] is silence
|
| 80 |
+
delay_ms = int(seg["start"] * 1000)
|
| 81 |
+
# Resample to 24kHz, apply delay
|
| 82 |
+
filter_parts.append(
|
| 83 |
+
f"[{input_idx}]aresample=24000,adelay={delay_ms}|{delay_ms}[s{idx}]"
|
| 84 |
+
)
|
| 85 |
+
mix_inputs.append(f"[s{idx}]")
|
| 86 |
+
|
| 87 |
+
# Mix all together
|
| 88 |
+
all_inputs = "".join(mix_inputs)
|
| 89 |
+
filter_parts.append(
|
| 90 |
+
f"{all_inputs}amix=inputs={n_segs + 1}:duration=longest:dropout_transition=0[out]"
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
filter_complex = ";".join(filter_parts)
|
| 94 |
+
|
| 95 |
+
cmd = [
|
| 96 |
+
"ffmpeg", "-y",
|
| 97 |
+
*inputs,
|
| 98 |
+
"-filter_complex", filter_complex,
|
| 99 |
+
"-map", "[out]",
|
| 100 |
+
"-acodec", "pcm_s16le",
|
| 101 |
+
"-ar", "24000",
|
| 102 |
+
"-ac", "1",
|
| 103 |
+
str(output_path)
|
| 104 |
+
]
|
| 105 |
+
|
| 106 |
+
logger.info("Running ffmpeg audio assembly...")
|
| 107 |
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
|
| 108 |
+
|
| 109 |
+
if result.returncode != 0:
|
| 110 |
+
logger.warning(f"FFmpeg filter_complex failed. Trying chunked assembly. Error: {result.stderr[:500]}")
|
| 111 |
+
return _assemble_chunked(valid_segments, total_duration, output_dir, output_path, progress_callback)
|
| 112 |
+
|
| 113 |
+
logger.info(f"Audio assembly complete: {output_path}")
|
| 114 |
+
|
| 115 |
+
if progress_callback:
|
| 116 |
+
progress_callback(100)
|
| 117 |
+
|
| 118 |
+
return output_path
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _assemble_chunked(
|
| 122 |
+
segments: List[Dict],
|
| 123 |
+
total_duration: float,
|
| 124 |
+
output_dir: Path,
|
| 125 |
+
output_path: Path,
|
| 126 |
+
progress_callback=None
|
| 127 |
+
) -> Path:
|
| 128 |
+
"""
|
| 129 |
+
Fallback: assemble audio using pydub for better memory management.
|
| 130 |
+
Processes in chunks for very long videos.
|
| 131 |
+
"""
|
| 132 |
+
try:
|
| 133 |
+
from pydub import AudioSegment
|
| 134 |
+
except ImportError:
|
| 135 |
+
raise RuntimeError("pydub not installed. Run: pip install pydub")
|
| 136 |
+
|
| 137 |
+
logger.info("Using pydub chunked assembly (fallback)...")
|
| 138 |
+
|
| 139 |
+
# Create silent base track
|
| 140 |
+
total_ms = int(total_duration * 1000)
|
| 141 |
+
base = AudioSegment.silent(duration=total_ms, frame_rate=24000)
|
| 142 |
+
|
| 143 |
+
total_segs = len(segments)
|
| 144 |
+
for idx, seg in enumerate(segments):
|
| 145 |
+
try:
|
| 146 |
+
tts_audio = AudioSegment.from_file(seg["tts_audio_path"])
|
| 147 |
+
# Resample to 24kHz mono
|
| 148 |
+
tts_audio = tts_audio.set_frame_rate(24000).set_channels(1)
|
| 149 |
+
|
| 150 |
+
position_ms = int(seg["start"] * 1000)
|
| 151 |
+
|
| 152 |
+
# Overlay at the correct position
|
| 153 |
+
base = base.overlay(tts_audio, position=position_ms)
|
| 154 |
+
|
| 155 |
+
except Exception as e:
|
| 156 |
+
logger.warning(f"Failed to overlay segment {idx}: {e}")
|
| 157 |
+
continue
|
| 158 |
+
|
| 159 |
+
if progress_callback and idx % 50 == 0:
|
| 160 |
+
progress_callback(int((idx + 1) / total_segs * 100))
|
| 161 |
+
|
| 162 |
+
# Export
|
| 163 |
+
base.export(str(output_path), format="wav")
|
| 164 |
+
logger.info(f"Chunked assembly complete: {output_path}")
|
| 165 |
+
return output_path
|
services/audio_extractor.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Stage 2 — Audio Extraction & Preprocessing
|
| 3 |
+
Extracts audio from video, creates STT-ready WAV and stereo backup.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
import subprocess
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from config import AUDIO_SAMPLE_RATE, AUDIO_CHANNELS
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def extract_audio_for_stt(video_path: Path, output_dir: Path) -> Path:
|
| 15 |
+
"""
|
| 16 |
+
Extract audio from video as 16kHz mono WAV for speech-to-text.
|
| 17 |
+
"""
|
| 18 |
+
output_path = output_dir / "audio_stt.wav"
|
| 19 |
+
|
| 20 |
+
cmd = [
|
| 21 |
+
"ffmpeg", "-y",
|
| 22 |
+
"-i", str(video_path),
|
| 23 |
+
"-vn", # No video
|
| 24 |
+
"-acodec", "pcm_s16le", # 16-bit PCM
|
| 25 |
+
"-ar", str(AUDIO_SAMPLE_RATE), # 16kHz
|
| 26 |
+
"-ac", str(AUDIO_CHANNELS), # Mono
|
| 27 |
+
str(output_path)
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
logger.info("Extracting audio for STT (16kHz mono WAV)...")
|
| 31 |
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
|
| 32 |
+
|
| 33 |
+
if result.returncode != 0:
|
| 34 |
+
raise RuntimeError(f"FFmpeg audio extraction failed: {result.stderr}")
|
| 35 |
+
|
| 36 |
+
logger.info(f"STT audio: {output_path} ({output_path.stat().st_size / 1e6:.1f} MB)")
|
| 37 |
+
return output_path
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def extract_audio_stereo(video_path: Path, output_dir: Path) -> Path:
|
| 41 |
+
"""
|
| 42 |
+
Extract original stereo audio (for background mixing later).
|
| 43 |
+
"""
|
| 44 |
+
output_path = output_dir / "audio_original_stereo.wav"
|
| 45 |
+
|
| 46 |
+
cmd = [
|
| 47 |
+
"ffmpeg", "-y",
|
| 48 |
+
"-i", str(video_path),
|
| 49 |
+
"-vn",
|
| 50 |
+
"-acodec", "pcm_s16le",
|
| 51 |
+
"-ar", "44100",
|
| 52 |
+
str(output_path)
|
| 53 |
+
]
|
| 54 |
+
|
| 55 |
+
logger.info("Extracting stereo audio for background mixing...")
|
| 56 |
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
|
| 57 |
+
|
| 58 |
+
if result.returncode != 0:
|
| 59 |
+
raise RuntimeError(f"FFmpeg stereo extraction failed: {result.stderr}")
|
| 60 |
+
|
| 61 |
+
logger.info(f"Stereo audio: {output_path}")
|
| 62 |
+
return output_path
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def get_audio_duration(audio_path: Path) -> float:
|
| 66 |
+
"""Get audio duration in seconds using ffprobe."""
|
| 67 |
+
cmd = [
|
| 68 |
+
"ffprobe",
|
| 69 |
+
"-v", "quiet",
|
| 70 |
+
"-show_entries", "format=duration",
|
| 71 |
+
"-of", "csv=p=0",
|
| 72 |
+
str(audio_path)
|
| 73 |
+
]
|
| 74 |
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
| 75 |
+
if result.returncode != 0:
|
| 76 |
+
raise RuntimeError(f"ffprobe failed: {result.stderr}")
|
| 77 |
+
return float(result.stdout.strip())
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def get_video_duration(video_path: Path) -> float:
|
| 81 |
+
"""Get video duration in seconds."""
|
| 82 |
+
return get_audio_duration(video_path) # ffprobe works on video too
|
services/audio_mixer.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Stage 8 — Background Audio Mixing
|
| 3 |
+
Mixes the dubbed vocal track with the original background music/ambience.
|
| 4 |
+
Creates a natural-sounding output that preserves the video's atmosphere.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import logging
|
| 8 |
+
import subprocess
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Optional
|
| 11 |
+
|
| 12 |
+
from config import BACKGROUND_VOLUME
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def mix_audio(
|
| 18 |
+
dubbed_audio_path: Path,
|
| 19 |
+
background_audio_path: Optional[Path],
|
| 20 |
+
output_dir: Path,
|
| 21 |
+
background_volume: float = BACKGROUND_VOLUME,
|
| 22 |
+
progress_callback=None
|
| 23 |
+
) -> Path:
|
| 24 |
+
"""
|
| 25 |
+
Mix dubbed vocal track with original background audio.
|
| 26 |
+
If no background audio, just return the dubbed audio.
|
| 27 |
+
|
| 28 |
+
Args:
|
| 29 |
+
dubbed_audio_path: Full dubbed vocal track from assembly
|
| 30 |
+
background_audio_path: Background track from demucs (or None)
|
| 31 |
+
output_dir: Working directory
|
| 32 |
+
background_volume: Volume level for background (0.0 - 1.0)
|
| 33 |
+
|
| 34 |
+
Returns: Path to mixed audio file
|
| 35 |
+
"""
|
| 36 |
+
output_path = output_dir / "final_audio_mixed.wav"
|
| 37 |
+
|
| 38 |
+
if background_audio_path is None or not background_audio_path.exists():
|
| 39 |
+
logger.info("No background audio available. Using dubbed audio as-is.")
|
| 40 |
+
# Just copy/convert the dubbed audio
|
| 41 |
+
cmd = [
|
| 42 |
+
"ffmpeg", "-y",
|
| 43 |
+
"-i", str(dubbed_audio_path),
|
| 44 |
+
"-acodec", "pcm_s16le",
|
| 45 |
+
"-ar", "44100",
|
| 46 |
+
"-ac", "2", # Stereo output
|
| 47 |
+
str(output_path)
|
| 48 |
+
]
|
| 49 |
+
subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
| 50 |
+
return output_path
|
| 51 |
+
|
| 52 |
+
logger.info(f"Mixing dubbed audio with background at {background_volume * 100:.0f}% volume")
|
| 53 |
+
|
| 54 |
+
if progress_callback:
|
| 55 |
+
progress_callback(20)
|
| 56 |
+
|
| 57 |
+
# Use ffmpeg to mix:
|
| 58 |
+
# - Dubbed vocals at full volume
|
| 59 |
+
# - Background at reduced volume
|
| 60 |
+
cmd = [
|
| 61 |
+
"ffmpeg", "-y",
|
| 62 |
+
"-i", str(dubbed_audio_path), # Input 0: dubbed vocals
|
| 63 |
+
"-i", str(background_audio_path), # Input 1: background
|
| 64 |
+
"-filter_complex",
|
| 65 |
+
f"[0]aresample=44100,aformat=sample_fmts=fltp:channel_layouts=stereo[vocals];"
|
| 66 |
+
f"[1]aresample=44100,aformat=sample_fmts=fltp:channel_layouts=stereo,"
|
| 67 |
+
f"volume={background_volume}[bg];"
|
| 68 |
+
f"[vocals][bg]amix=inputs=2:duration=first:dropout_transition=2[out]",
|
| 69 |
+
"-map", "[out]",
|
| 70 |
+
"-acodec", "pcm_s16le",
|
| 71 |
+
"-ar", "44100",
|
| 72 |
+
"-ac", "2",
|
| 73 |
+
str(output_path)
|
| 74 |
+
]
|
| 75 |
+
|
| 76 |
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
|
| 77 |
+
|
| 78 |
+
if result.returncode != 0:
|
| 79 |
+
logger.warning(f"Audio mixing failed: {result.stderr[:500]}")
|
| 80 |
+
logger.info("Falling back to dubbed audio without background.")
|
| 81 |
+
# Fallback: just use dubbed audio
|
| 82 |
+
cmd_fallback = [
|
| 83 |
+
"ffmpeg", "-y",
|
| 84 |
+
"-i", str(dubbed_audio_path),
|
| 85 |
+
"-acodec", "pcm_s16le",
|
| 86 |
+
"-ar", "44100", "-ac", "2",
|
| 87 |
+
str(output_path)
|
| 88 |
+
]
|
| 89 |
+
subprocess.run(cmd_fallback, capture_output=True, text=True, timeout=300)
|
| 90 |
+
else:
|
| 91 |
+
logger.info(f"Audio mixing complete: {output_path}")
|
| 92 |
+
|
| 93 |
+
if progress_callback:
|
| 94 |
+
progress_callback(100)
|
| 95 |
+
|
| 96 |
+
return output_path
|
services/downloader.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Stage 1 — YouTube Download Service
|
| 3 |
+
Downloads video from YouTube URL using yt-dlp.
|
| 4 |
+
Handles long videos (2.5+ hrs), age-restricted content, and error recovery.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import logging
|
| 8 |
+
import subprocess
|
| 9 |
+
import json
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from config import YT_FORMAT, YT_MAX_DURATION
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def get_video_info(url: str) -> dict:
|
| 17 |
+
"""Fetch video metadata without downloading."""
|
| 18 |
+
cmd = [
|
| 19 |
+
"yt-dlp",
|
| 20 |
+
"--dump-json",
|
| 21 |
+
"--no-download",
|
| 22 |
+
"--no-playlist",
|
| 23 |
+
url
|
| 24 |
+
]
|
| 25 |
+
logger.info(f"Fetching video info: {url}")
|
| 26 |
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
| 27 |
+
|
| 28 |
+
if result.returncode != 0:
|
| 29 |
+
raise RuntimeError(f"yt-dlp info failed: {result.stderr}")
|
| 30 |
+
|
| 31 |
+
info = json.loads(result.stdout)
|
| 32 |
+
duration = info.get("duration", 0)
|
| 33 |
+
|
| 34 |
+
if duration > YT_MAX_DURATION:
|
| 35 |
+
raise ValueError(
|
| 36 |
+
f"Video is {duration // 3600}h {(duration % 3600) // 60}m long. "
|
| 37 |
+
f"Max allowed: {YT_MAX_DURATION // 3600}h."
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
return {
|
| 41 |
+
"title": info.get("title", "unknown"),
|
| 42 |
+
"duration": duration,
|
| 43 |
+
"uploader": info.get("uploader", "unknown"),
|
| 44 |
+
"description": info.get("description", ""),
|
| 45 |
+
"thumbnail": info.get("thumbnail", ""),
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def download_video(url: str, output_dir: Path, progress_callback=None) -> Path:
|
| 50 |
+
"""
|
| 51 |
+
Download video from YouTube.
|
| 52 |
+
Returns path to downloaded video file.
|
| 53 |
+
"""
|
| 54 |
+
output_template = str(output_dir / "source_video.%(ext)s")
|
| 55 |
+
|
| 56 |
+
cmd = [
|
| 57 |
+
"yt-dlp",
|
| 58 |
+
"-f", YT_FORMAT,
|
| 59 |
+
"--no-playlist",
|
| 60 |
+
"--merge-output-format", "mp4",
|
| 61 |
+
"-o", output_template,
|
| 62 |
+
"--newline", # Progress on new lines for parsing
|
| 63 |
+
url
|
| 64 |
+
]
|
| 65 |
+
|
| 66 |
+
logger.info(f"Starting download: {url}")
|
| 67 |
+
|
| 68 |
+
process = subprocess.Popen(
|
| 69 |
+
cmd,
|
| 70 |
+
stdout=subprocess.PIPE,
|
| 71 |
+
stderr=subprocess.STDOUT,
|
| 72 |
+
text=True,
|
| 73 |
+
bufsize=1
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
for line in process.stdout:
|
| 77 |
+
line = line.strip()
|
| 78 |
+
if line:
|
| 79 |
+
logger.debug(f"yt-dlp: {line}")
|
| 80 |
+
# Parse progress percentage
|
| 81 |
+
if "[download]" in line and "%" in line:
|
| 82 |
+
try:
|
| 83 |
+
pct = float(line.split("%")[0].split()[-1])
|
| 84 |
+
if progress_callback:
|
| 85 |
+
progress_callback(pct)
|
| 86 |
+
except (ValueError, IndexError):
|
| 87 |
+
pass
|
| 88 |
+
|
| 89 |
+
process.wait()
|
| 90 |
+
|
| 91 |
+
if process.returncode != 0:
|
| 92 |
+
raise RuntimeError("yt-dlp download failed. Check URL and network.")
|
| 93 |
+
|
| 94 |
+
# Find the downloaded file
|
| 95 |
+
video_files = list(output_dir.glob("source_video.*"))
|
| 96 |
+
if not video_files:
|
| 97 |
+
raise FileNotFoundError("Download completed but no video file found.")
|
| 98 |
+
|
| 99 |
+
video_path = video_files[0]
|
| 100 |
+
logger.info(f"Downloaded: {video_path} ({video_path.stat().st_size / 1e6:.1f} MB)")
|
| 101 |
+
return video_path
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def use_local_file(file_path: str, output_dir: Path) -> Path:
|
| 105 |
+
"""Copy or symlink a local video file into the job directory."""
|
| 106 |
+
import shutil
|
| 107 |
+
src = Path(file_path)
|
| 108 |
+
if not src.exists():
|
| 109 |
+
raise FileNotFoundError(f"Local file not found: {file_path}")
|
| 110 |
+
|
| 111 |
+
dest = output_dir / f"source_video{src.suffix}"
|
| 112 |
+
shutil.copy2(src, dest)
|
| 113 |
+
logger.info(f"Using local file: {dest}")
|
| 114 |
+
return dest
|
services/merger.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Stage 9 — Final Video Merge
|
| 3 |
+
Replaces original audio with dubbed audio in the video.
|
| 4 |
+
Video stream is copied (no re-encoding) for speed.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import logging
|
| 8 |
+
import subprocess
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def merge_video_audio(
|
| 15 |
+
video_path: Path,
|
| 16 |
+
audio_path: Path,
|
| 17 |
+
output_dir: Path,
|
| 18 |
+
video_title: str = "dubbed_video",
|
| 19 |
+
progress_callback=None
|
| 20 |
+
) -> Path:
|
| 21 |
+
"""
|
| 22 |
+
Replace the original audio track in the video with the dubbed audio.
|
| 23 |
+
Video is NOT re-encoded (-c:v copy) for maximum speed.
|
| 24 |
+
|
| 25 |
+
Returns: Path to final dubbed video file.
|
| 26 |
+
"""
|
| 27 |
+
# Clean filename
|
| 28 |
+
safe_title = "".join(c if c.isalnum() or c in " -_" else "_" for c in video_title)
|
| 29 |
+
safe_title = safe_title[:100].strip() or "dubbed_video"
|
| 30 |
+
output_path = output_dir / f"{safe_title}_dubbed.mp4"
|
| 31 |
+
|
| 32 |
+
logger.info(f"Merging video + dubbed audio → {output_path.name}")
|
| 33 |
+
|
| 34 |
+
if progress_callback:
|
| 35 |
+
progress_callback(10)
|
| 36 |
+
|
| 37 |
+
cmd = [
|
| 38 |
+
"ffmpeg", "-y",
|
| 39 |
+
"-i", str(video_path), # Original video
|
| 40 |
+
"-i", str(audio_path), # New dubbed audio
|
| 41 |
+
"-c:v", "copy", # Don't re-encode video (fast!)
|
| 42 |
+
"-c:a", "aac", # Encode audio as AAC
|
| 43 |
+
"-b:a", "192k", # Audio bitrate
|
| 44 |
+
"-map", "0:v:0", # Take video from first input
|
| 45 |
+
"-map", "1:a:0", # Take audio from second input
|
| 46 |
+
"-shortest", # Match shortest stream
|
| 47 |
+
"-movflags", "+faststart", # Web-optimized MP4
|
| 48 |
+
str(output_path)
|
| 49 |
+
]
|
| 50 |
+
|
| 51 |
+
result = subprocess.run(
|
| 52 |
+
cmd,
|
| 53 |
+
capture_output=True,
|
| 54 |
+
text=True,
|
| 55 |
+
timeout=600 # 10 min timeout
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
if result.returncode != 0:
|
| 59 |
+
# Try with audio re-encoding fallback
|
| 60 |
+
logger.warning(f"Fast merge failed. Trying with full audio encode. Error: {result.stderr[:300]}")
|
| 61 |
+
cmd_fallback = [
|
| 62 |
+
"ffmpeg", "-y",
|
| 63 |
+
"-i", str(video_path),
|
| 64 |
+
"-i", str(audio_path),
|
| 65 |
+
"-c:v", "copy",
|
| 66 |
+
"-c:a", "aac",
|
| 67 |
+
"-b:a", "128k",
|
| 68 |
+
"-map", "0:v:0",
|
| 69 |
+
"-map", "1:a:0",
|
| 70 |
+
"-shortest",
|
| 71 |
+
str(output_path)
|
| 72 |
+
]
|
| 73 |
+
result = subprocess.run(cmd_fallback, capture_output=True, text=True, timeout=600)
|
| 74 |
+
|
| 75 |
+
if result.returncode != 0:
|
| 76 |
+
raise RuntimeError(f"Video merge failed: {result.stderr}")
|
| 77 |
+
|
| 78 |
+
file_size_mb = output_path.stat().st_size / 1e6
|
| 79 |
+
logger.info(f"Final video: {output_path} ({file_size_mb:.1f} MB)")
|
| 80 |
+
|
| 81 |
+
if progress_callback:
|
| 82 |
+
progress_callback(100)
|
| 83 |
+
|
| 84 |
+
return output_path
|
services/speaker_profiler.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Stage 5 — Speaker Profiler (Simplified — Male Voice Forced)
|
| 3 |
+
Since we force male voice, this just groups speakers.
|
| 4 |
+
"""
|
| 5 |
+
import logging
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Dict, List
|
| 8 |
+
from collections import defaultdict
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def profile_speakers(
|
| 14 |
+
segments: List[Dict],
|
| 15 |
+
audio_path: Path,
|
| 16 |
+
output_dir: Path
|
| 17 |
+
) -> Dict[str, Dict]:
|
| 18 |
+
"""Profile speakers — all forced to male."""
|
| 19 |
+
speaker_segments = defaultdict(list)
|
| 20 |
+
for seg in segments:
|
| 21 |
+
speaker_segments[seg.get("speaker", "SPEAKER_00")].append(seg)
|
| 22 |
+
|
| 23 |
+
logger.info(f"Found {len(speaker_segments)} speakers — ALL forced to MALE voice")
|
| 24 |
+
|
| 25 |
+
profiles = {}
|
| 26 |
+
for speaker_id, segs in speaker_segments.items():
|
| 27 |
+
total_time = sum(s["end"] - s["start"] for s in segs)
|
| 28 |
+
profiles[speaker_id] = {
|
| 29 |
+
"gender": "male", # FORCED
|
| 30 |
+
"reference_audio": None,
|
| 31 |
+
"total_speaking_time": round(total_time, 2),
|
| 32 |
+
"avg_pitch": 0,
|
| 33 |
+
"segment_count": len(segs),
|
| 34 |
+
}
|
| 35 |
+
logger.info(f" {speaker_id}: MALE (forced), {len(segs)} segments, {total_time:.1f}s")
|
| 36 |
+
|
| 37 |
+
return profiles
|
services/subtitle_generator.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Bonus — Subtitle Generator
|
| 3 |
+
Creates .srt subtitle files in both original and target languages.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import List, Dict
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def generate_subtitles(
|
| 14 |
+
segments: List[Dict],
|
| 15 |
+
output_dir: Path,
|
| 16 |
+
source_language: str,
|
| 17 |
+
target_language: str
|
| 18 |
+
) -> Dict[str, Path]:
|
| 19 |
+
"""
|
| 20 |
+
Generate .srt subtitle files.
|
| 21 |
+
Returns dict with paths to original and translated subtitle files.
|
| 22 |
+
"""
|
| 23 |
+
original_srt = output_dir / f"subtitles_{source_language}.srt"
|
| 24 |
+
translated_srt = output_dir / f"subtitles_{target_language}.srt"
|
| 25 |
+
|
| 26 |
+
# Original language subtitles
|
| 27 |
+
_write_srt(original_srt, segments, text_key="text")
|
| 28 |
+
logger.info(f"Original subtitles: {original_srt}")
|
| 29 |
+
|
| 30 |
+
# Translated language subtitles
|
| 31 |
+
_write_srt(translated_srt, segments, text_key="translated_text")
|
| 32 |
+
logger.info(f"Translated subtitles: {translated_srt}")
|
| 33 |
+
|
| 34 |
+
return {
|
| 35 |
+
"original": original_srt,
|
| 36 |
+
"translated": translated_srt,
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _write_srt(output_path: Path, segments: List[Dict], text_key: str):
|
| 41 |
+
"""Write segments to .srt format."""
|
| 42 |
+
with open(output_path, "w", encoding="utf-8") as f:
|
| 43 |
+
for idx, seg in enumerate(segments, 1):
|
| 44 |
+
text = seg.get(text_key, seg.get("text", "")).strip()
|
| 45 |
+
if not text:
|
| 46 |
+
continue
|
| 47 |
+
|
| 48 |
+
start = _format_srt_time(seg["start"])
|
| 49 |
+
end = _format_srt_time(seg["end"])
|
| 50 |
+
|
| 51 |
+
f.write(f"{idx}\n")
|
| 52 |
+
f.write(f"{start} --> {end}\n")
|
| 53 |
+
f.write(f"{text}\n\n")
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _format_srt_time(seconds: float) -> str:
|
| 57 |
+
"""Convert seconds to SRT time format: HH:MM:SS,mmm"""
|
| 58 |
+
hours = int(seconds // 3600)
|
| 59 |
+
minutes = int((seconds % 3600) // 60)
|
| 60 |
+
secs = int(seconds % 60)
|
| 61 |
+
millis = int((seconds % 1) * 1000)
|
| 62 |
+
return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}"
|
services/transcriber.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Stage 4 — Transcription Service
|
| 3 |
+
PRIMARY: HuggingFace Inference API (free GPU — whisper-large-v3)
|
| 4 |
+
FALLBACK: Local faster-whisper (CPU)
|
| 5 |
+
|
| 6 |
+
For long videos: splits audio into 30s chunks and sends to HF API.
|
| 7 |
+
This gives us GPU-quality transcription for FREE.
|
| 8 |
+
"""
|
| 9 |
+
import logging
|
| 10 |
+
import subprocess
|
| 11 |
+
import json
|
| 12 |
+
import time
|
| 13 |
+
import requests
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
from typing import List, Dict, Optional
|
| 16 |
+
|
| 17 |
+
from config import HF_API_URL, HF_TOKEN, HF_CHUNK_DURATION_SEC, WHISPER_MODEL_SIZE
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def transcribe_audio(
|
| 23 |
+
audio_path: Path,
|
| 24 |
+
output_dir: Path,
|
| 25 |
+
source_language: Optional[str] = None,
|
| 26 |
+
device: str = "cpu",
|
| 27 |
+
progress_callback=None,
|
| 28 |
+
) -> List[Dict]:
|
| 29 |
+
"""Transcribe audio — tries HF GPU API first, falls back to local."""
|
| 30 |
+
|
| 31 |
+
# Try HuggingFace API first (free GPU!)
|
| 32 |
+
try:
|
| 33 |
+
logger.info("Attempting HuggingFace GPU transcription (whisper-large-v3)...")
|
| 34 |
+
segments = _transcribe_hf_api(audio_path, output_dir, source_language, progress_callback)
|
| 35 |
+
if segments and len(segments) > 0:
|
| 36 |
+
logger.info(f"HF API transcription success: {len(segments)} segments")
|
| 37 |
+
return segments
|
| 38 |
+
except Exception as e:
|
| 39 |
+
logger.warning(f"HF API failed: {e}. Falling back to local.")
|
| 40 |
+
|
| 41 |
+
# Fallback: local faster-whisper
|
| 42 |
+
logger.info("Using local faster-whisper fallback...")
|
| 43 |
+
return _transcribe_local(audio_path, output_dir, source_language, progress_callback)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _transcribe_hf_api(
|
| 47 |
+
audio_path: Path,
|
| 48 |
+
output_dir: Path,
|
| 49 |
+
source_language: Optional[str],
|
| 50 |
+
progress_callback=None,
|
| 51 |
+
) -> List[Dict]:
|
| 52 |
+
"""
|
| 53 |
+
Transcribe using HuggingFace Inference API with GPU.
|
| 54 |
+
Splits long audio into chunks, sends each to the API.
|
| 55 |
+
"""
|
| 56 |
+
# Get audio duration
|
| 57 |
+
duration = _get_duration(audio_path)
|
| 58 |
+
logger.info(f"Audio duration: {duration:.1f}s ({duration/60:.1f} min)")
|
| 59 |
+
|
| 60 |
+
# Split into chunks
|
| 61 |
+
chunk_dir = output_dir / "audio_chunks"
|
| 62 |
+
chunk_dir.mkdir(exist_ok=True)
|
| 63 |
+
|
| 64 |
+
chunk_duration = HF_CHUNK_DURATION_SEC
|
| 65 |
+
chunks = _split_audio(audio_path, chunk_dir, chunk_duration)
|
| 66 |
+
logger.info(f"Split into {len(chunks)} chunks ({chunk_duration}s each)")
|
| 67 |
+
|
| 68 |
+
headers = {}
|
| 69 |
+
if HF_TOKEN:
|
| 70 |
+
headers["Authorization"] = f"Bearer {HF_TOKEN}"
|
| 71 |
+
|
| 72 |
+
all_segments = []
|
| 73 |
+
time_offset = 0.0
|
| 74 |
+
|
| 75 |
+
for idx, chunk_path in enumerate(chunks):
|
| 76 |
+
if progress_callback:
|
| 77 |
+
progress_callback(int((idx / len(chunks)) * 100))
|
| 78 |
+
|
| 79 |
+
# Read chunk bytes
|
| 80 |
+
with open(chunk_path, "rb") as f:
|
| 81 |
+
audio_bytes = f.read()
|
| 82 |
+
|
| 83 |
+
# Send to HF API
|
| 84 |
+
retries = 3
|
| 85 |
+
for attempt in range(retries):
|
| 86 |
+
try:
|
| 87 |
+
resp = requests.post(
|
| 88 |
+
HF_API_URL,
|
| 89 |
+
headers=headers,
|
| 90 |
+
data=audio_bytes,
|
| 91 |
+
timeout=120,
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
if resp.status_code == 503:
|
| 95 |
+
# Model is loading
|
| 96 |
+
wait_time = resp.json().get("estimated_time", 30)
|
| 97 |
+
logger.info(f"Model loading, waiting {wait_time:.0f}s...")
|
| 98 |
+
time.sleep(min(wait_time, 60))
|
| 99 |
+
continue
|
| 100 |
+
|
| 101 |
+
if resp.status_code == 429:
|
| 102 |
+
# Rate limited
|
| 103 |
+
logger.info("Rate limited, waiting 10s...")
|
| 104 |
+
time.sleep(10)
|
| 105 |
+
continue
|
| 106 |
+
|
| 107 |
+
resp.raise_for_status()
|
| 108 |
+
result = resp.json()
|
| 109 |
+
|
| 110 |
+
# Extract text and create segment
|
| 111 |
+
text = result.get("text", "").strip()
|
| 112 |
+
if text:
|
| 113 |
+
chunk_start = idx * chunk_duration
|
| 114 |
+
all_segments.append({
|
| 115 |
+
"start": round(chunk_start, 3),
|
| 116 |
+
"end": round(chunk_start + chunk_duration, 3),
|
| 117 |
+
"text": text,
|
| 118 |
+
"speaker": "SPEAKER_00",
|
| 119 |
+
"words": [],
|
| 120 |
+
})
|
| 121 |
+
|
| 122 |
+
# Handle chunked results if API returns them
|
| 123 |
+
if "chunks" in result:
|
| 124 |
+
for chunk_seg in result["chunks"]:
|
| 125 |
+
ts = chunk_seg.get("timestamp", [0, chunk_duration])
|
| 126 |
+
all_segments.append({
|
| 127 |
+
"start": round((ts[0] or 0) + idx * chunk_duration, 3),
|
| 128 |
+
"end": round((ts[1] or chunk_duration) + idx * chunk_duration, 3),
|
| 129 |
+
"text": chunk_seg.get("text", "").strip(),
|
| 130 |
+
"speaker": "SPEAKER_00",
|
| 131 |
+
"words": [],
|
| 132 |
+
})
|
| 133 |
+
# Remove the full-chunk segment we added above
|
| 134 |
+
if text and "chunks" in result:
|
| 135 |
+
all_segments = [s for s in all_segments if not (
|
| 136 |
+
s["start"] == round(idx * chunk_duration, 3) and
|
| 137 |
+
s["text"] == text
|
| 138 |
+
)]
|
| 139 |
+
|
| 140 |
+
break # Success
|
| 141 |
+
|
| 142 |
+
except requests.exceptions.Timeout:
|
| 143 |
+
logger.warning(f"Chunk {idx} timed out (attempt {attempt+1})")
|
| 144 |
+
time.sleep(5)
|
| 145 |
+
except Exception as e:
|
| 146 |
+
logger.warning(f"Chunk {idx} error: {e} (attempt {attempt+1})")
|
| 147 |
+
time.sleep(5)
|
| 148 |
+
|
| 149 |
+
if idx % 10 == 0:
|
| 150 |
+
logger.info(f"Transcribed chunk {idx+1}/{len(chunks)}")
|
| 151 |
+
|
| 152 |
+
if not all_segments:
|
| 153 |
+
raise RuntimeError("HF API returned no transcription results")
|
| 154 |
+
|
| 155 |
+
# Detect language from first few segments
|
| 156 |
+
detected_lang = source_language or "hi"
|
| 157 |
+
|
| 158 |
+
# Save transcript
|
| 159 |
+
transcript_path = output_dir / "transcript.json"
|
| 160 |
+
with open(transcript_path, "w", encoding="utf-8") as f:
|
| 161 |
+
json.dump({
|
| 162 |
+
"language": detected_lang,
|
| 163 |
+
"segments": all_segments,
|
| 164 |
+
"total_segments": len(all_segments),
|
| 165 |
+
"method": "huggingface_gpu_api",
|
| 166 |
+
}, f, ensure_ascii=False, indent=2)
|
| 167 |
+
|
| 168 |
+
if progress_callback:
|
| 169 |
+
progress_callback(100)
|
| 170 |
+
|
| 171 |
+
return all_segments
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def _transcribe_local(
|
| 175 |
+
audio_path: Path,
|
| 176 |
+
output_dir: Path,
|
| 177 |
+
source_language: Optional[str],
|
| 178 |
+
progress_callback=None,
|
| 179 |
+
) -> List[Dict]:
|
| 180 |
+
"""Fallback: local faster-whisper on CPU."""
|
| 181 |
+
try:
|
| 182 |
+
from faster_whisper import WhisperModel
|
| 183 |
+
except ImportError:
|
| 184 |
+
raise RuntimeError("faster-whisper not installed. Run: pip install faster-whisper")
|
| 185 |
+
|
| 186 |
+
model = WhisperModel(WHISPER_MODEL_SIZE, device="cpu", compute_type="int8")
|
| 187 |
+
|
| 188 |
+
raw_segments, info = model.transcribe(
|
| 189 |
+
str(audio_path),
|
| 190 |
+
language=source_language,
|
| 191 |
+
beam_size=5,
|
| 192 |
+
vad_filter=True,
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
segments = []
|
| 196 |
+
for seg in raw_segments:
|
| 197 |
+
segments.append({
|
| 198 |
+
"start": round(seg.start, 3),
|
| 199 |
+
"end": round(seg.end, 3),
|
| 200 |
+
"text": seg.text.strip(),
|
| 201 |
+
"speaker": "SPEAKER_00",
|
| 202 |
+
"words": [],
|
| 203 |
+
})
|
| 204 |
+
|
| 205 |
+
transcript_path = output_dir / "transcript.json"
|
| 206 |
+
with open(transcript_path, "w", encoding="utf-8") as f:
|
| 207 |
+
json.dump({
|
| 208 |
+
"language": info.language,
|
| 209 |
+
"segments": segments,
|
| 210 |
+
"total_segments": len(segments),
|
| 211 |
+
"method": "local_faster_whisper",
|
| 212 |
+
}, f, ensure_ascii=False, indent=2)
|
| 213 |
+
|
| 214 |
+
if progress_callback:
|
| 215 |
+
progress_callback(100)
|
| 216 |
+
|
| 217 |
+
return segments
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def _get_duration(audio_path: Path) -> float:
|
| 221 |
+
cmd = ["ffprobe", "-v", "quiet", "-show_entries", "format=duration", "-of", "csv=p=0", str(audio_path)]
|
| 222 |
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
| 223 |
+
return float(result.stdout.strip())
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def _split_audio(audio_path: Path, output_dir: Path, chunk_sec: int) -> List[Path]:
|
| 227 |
+
"""Split audio into fixed-duration chunks."""
|
| 228 |
+
duration = _get_duration(audio_path)
|
| 229 |
+
chunks = []
|
| 230 |
+
|
| 231 |
+
for start in range(0, int(duration) + 1, chunk_sec):
|
| 232 |
+
chunk_path = output_dir / f"chunk_{start:06d}.wav"
|
| 233 |
+
cmd = [
|
| 234 |
+
"ffmpeg", "-y", "-i", str(audio_path),
|
| 235 |
+
"-ss", str(start), "-t", str(chunk_sec),
|
| 236 |
+
"-acodec", "pcm_s16le", "-ar", "16000", "-ac", "1",
|
| 237 |
+
str(chunk_path)
|
| 238 |
+
]
|
| 239 |
+
subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
| 240 |
+
if chunk_path.exists() and chunk_path.stat().st_size > 1000:
|
| 241 |
+
chunks.append(chunk_path)
|
| 242 |
+
|
| 243 |
+
return chunks
|
services/translator.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Stage 5 — Translation Service
|
| 3 |
+
Translates transcribed segments into target language.
|
| 4 |
+
Uses deep-translator (Google Translate free tier) — no API key needed.
|
| 5 |
+
Batches nearby segments for better translation quality.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import logging
|
| 9 |
+
import json
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import List, Dict, Optional
|
| 12 |
+
|
| 13 |
+
from config import TRANSLATION_BATCH_SIZE
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def translate_segments(
|
| 19 |
+
segments: List[Dict],
|
| 20 |
+
source_language: str,
|
| 21 |
+
target_language: str,
|
| 22 |
+
output_dir: Path,
|
| 23 |
+
progress_callback=None
|
| 24 |
+
) -> List[Dict]:
|
| 25 |
+
"""
|
| 26 |
+
Translate all segments into the target language.
|
| 27 |
+
Groups short consecutive segments from same speaker for better context.
|
| 28 |
+
|
| 29 |
+
Returns segments with added 'translated_text' field.
|
| 30 |
+
"""
|
| 31 |
+
try:
|
| 32 |
+
from deep_translator import GoogleTranslator
|
| 33 |
+
except ImportError:
|
| 34 |
+
raise RuntimeError("deep-translator not installed. Run: pip install deep-translator")
|
| 35 |
+
|
| 36 |
+
translator = GoogleTranslator(source=source_language, target=target_language)
|
| 37 |
+
|
| 38 |
+
total = len(segments)
|
| 39 |
+
translated_segments = []
|
| 40 |
+
|
| 41 |
+
# Batch translate for efficiency and context
|
| 42 |
+
batches = _create_translation_batches(segments)
|
| 43 |
+
logger.info(f"Translating {total} segments in {len(batches)} batches → {target_language}")
|
| 44 |
+
|
| 45 |
+
done_count = 0
|
| 46 |
+
for batch_idx, batch in enumerate(batches):
|
| 47 |
+
# Combine batch texts with separator
|
| 48 |
+
combined_text = " ||| ".join(seg["text"] for seg in batch)
|
| 49 |
+
|
| 50 |
+
try:
|
| 51 |
+
translated_combined = translator.translate(combined_text)
|
| 52 |
+
# Split back
|
| 53 |
+
translated_parts = translated_combined.split(" ||| ")
|
| 54 |
+
|
| 55 |
+
# If split count doesn't match, translate individually
|
| 56 |
+
if len(translated_parts) != len(batch):
|
| 57 |
+
translated_parts = _translate_individually(translator, batch)
|
| 58 |
+
|
| 59 |
+
except Exception as e:
|
| 60 |
+
logger.warning(f"Batch translation failed: {e}. Translating individually.")
|
| 61 |
+
translated_parts = _translate_individually(translator, batch)
|
| 62 |
+
|
| 63 |
+
# Assign translations back to segments
|
| 64 |
+
for seg, translated_text in zip(batch, translated_parts):
|
| 65 |
+
seg_copy = seg.copy()
|
| 66 |
+
seg_copy["translated_text"] = translated_text.strip()
|
| 67 |
+
translated_segments.append(seg_copy)
|
| 68 |
+
done_count += 1
|
| 69 |
+
|
| 70 |
+
if progress_callback:
|
| 71 |
+
progress_callback(int(done_count / total * 100))
|
| 72 |
+
|
| 73 |
+
# Handle empty/non-speech segments
|
| 74 |
+
for seg in translated_segments:
|
| 75 |
+
if not seg.get("translated_text") or seg["translated_text"].strip() == "":
|
| 76 |
+
seg["translated_text"] = seg["text"] # Keep original if translation empty
|
| 77 |
+
|
| 78 |
+
# Save translated transcript
|
| 79 |
+
output_path = output_dir / "translated_segments.json"
|
| 80 |
+
with open(output_path, "w", encoding="utf-8") as f:
|
| 81 |
+
json.dump({
|
| 82 |
+
"source_language": source_language,
|
| 83 |
+
"target_language": target_language,
|
| 84 |
+
"segments": translated_segments,
|
| 85 |
+
"total": len(translated_segments),
|
| 86 |
+
}, f, ensure_ascii=False, indent=2)
|
| 87 |
+
|
| 88 |
+
logger.info(f"Translation complete: {len(translated_segments)} segments → {output_path}")
|
| 89 |
+
return translated_segments
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _create_translation_batches(
|
| 93 |
+
segments: List[Dict],
|
| 94 |
+
max_batch_chars: int = 4000
|
| 95 |
+
) -> List[List[Dict]]:
|
| 96 |
+
"""
|
| 97 |
+
Group consecutive segments from same speaker into batches.
|
| 98 |
+
Respects Google Translate's character limits (~5000 chars).
|
| 99 |
+
"""
|
| 100 |
+
batches = []
|
| 101 |
+
current_batch = []
|
| 102 |
+
current_chars = 0
|
| 103 |
+
current_speaker = None
|
| 104 |
+
|
| 105 |
+
for seg in segments:
|
| 106 |
+
text = seg.get("text", "").strip()
|
| 107 |
+
if not text:
|
| 108 |
+
continue
|
| 109 |
+
|
| 110 |
+
# Start new batch if speaker changes or char limit reached
|
| 111 |
+
if (current_speaker and seg["speaker"] != current_speaker) or \
|
| 112 |
+
(current_chars + len(text) > max_batch_chars) or \
|
| 113 |
+
len(current_batch) >= TRANSLATION_BATCH_SIZE:
|
| 114 |
+
if current_batch:
|
| 115 |
+
batches.append(current_batch)
|
| 116 |
+
current_batch = []
|
| 117 |
+
current_chars = 0
|
| 118 |
+
|
| 119 |
+
current_batch.append(seg)
|
| 120 |
+
current_chars += len(text) + 5 # +5 for separator
|
| 121 |
+
current_speaker = seg["speaker"]
|
| 122 |
+
|
| 123 |
+
if current_batch:
|
| 124 |
+
batches.append(current_batch)
|
| 125 |
+
|
| 126 |
+
return batches
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _translate_individually(translator, batch: List[Dict]) -> List[str]:
|
| 130 |
+
"""Fallback: translate each segment one by one."""
|
| 131 |
+
results = []
|
| 132 |
+
for seg in batch:
|
| 133 |
+
try:
|
| 134 |
+
translated = translator.translate(seg["text"])
|
| 135 |
+
results.append(translated or seg["text"])
|
| 136 |
+
except Exception as e:
|
| 137 |
+
logger.warning(f"Individual translation failed for '{seg['text'][:50]}...': {e}")
|
| 138 |
+
results.append(seg["text"]) # Keep original on failure
|
| 139 |
+
return results
|
services/tts_generator.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Stage 7 — TTS Generation (MALE VOICE ONLY)
|
| 3 |
+
Uses Edge TTS (free, no API key). Forces male voice for ALL speakers.
|
| 4 |
+
"""
|
| 5 |
+
import logging
|
| 6 |
+
import asyncio
|
| 7 |
+
import subprocess
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import List, Dict
|
| 10 |
+
|
| 11 |
+
from config import EDGE_TTS_VOICES, TTS_MAX_SPEED_FACTOR, FORCE_MALE_VOICE
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def generate_dubbed_audio(
|
| 17 |
+
translated_segments: List[Dict],
|
| 18 |
+
speaker_profiles: Dict,
|
| 19 |
+
target_language: str,
|
| 20 |
+
output_dir: Path,
|
| 21 |
+
progress_callback=None
|
| 22 |
+
) -> List[Dict]:
|
| 23 |
+
"""Generate TTS audio. ALWAYS uses male voice."""
|
| 24 |
+
tts_dir = output_dir / "tts_segments"
|
| 25 |
+
tts_dir.mkdir(exist_ok=True)
|
| 26 |
+
|
| 27 |
+
voices = EDGE_TTS_VOICES.get(target_language)
|
| 28 |
+
if not voices:
|
| 29 |
+
raise ValueError(f"No voices for '{target_language}'. Supported: {list(EDGE_TTS_VOICES.keys())}")
|
| 30 |
+
|
| 31 |
+
# FORCE MALE VOICE
|
| 32 |
+
voice = voices["male"]
|
| 33 |
+
logger.info(f"Using MALE voice: {voice} (forced)")
|
| 34 |
+
|
| 35 |
+
total = len(translated_segments)
|
| 36 |
+
results = []
|
| 37 |
+
|
| 38 |
+
# Create or get event loop
|
| 39 |
+
try:
|
| 40 |
+
loop = asyncio.get_event_loop()
|
| 41 |
+
if loop.is_closed():
|
| 42 |
+
loop = asyncio.new_event_loop()
|
| 43 |
+
asyncio.set_event_loop(loop)
|
| 44 |
+
except RuntimeError:
|
| 45 |
+
loop = asyncio.new_event_loop()
|
| 46 |
+
asyncio.set_event_loop(loop)
|
| 47 |
+
|
| 48 |
+
for idx, seg in enumerate(translated_segments):
|
| 49 |
+
text = seg.get("translated_text", "").strip()
|
| 50 |
+
if not text:
|
| 51 |
+
seg_result = seg.copy()
|
| 52 |
+
seg_result["tts_audio_path"] = None
|
| 53 |
+
seg_result["tts_duration"] = 0
|
| 54 |
+
results.append(seg_result)
|
| 55 |
+
continue
|
| 56 |
+
|
| 57 |
+
output_path = tts_dir / f"seg_{idx:06d}.mp3"
|
| 58 |
+
try:
|
| 59 |
+
loop.run_until_complete(_generate_tts_segment(text, voice, output_path))
|
| 60 |
+
except Exception as e:
|
| 61 |
+
logger.warning(f"TTS failed for segment {idx}: {e}")
|
| 62 |
+
seg_result = seg.copy()
|
| 63 |
+
seg_result["tts_audio_path"] = None
|
| 64 |
+
seg_result["tts_duration"] = 0
|
| 65 |
+
results.append(seg_result)
|
| 66 |
+
continue
|
| 67 |
+
|
| 68 |
+
if not output_path.exists() or output_path.stat().st_size == 0:
|
| 69 |
+
seg_result = seg.copy()
|
| 70 |
+
seg_result["tts_audio_path"] = None
|
| 71 |
+
seg_result["tts_duration"] = 0
|
| 72 |
+
results.append(seg_result)
|
| 73 |
+
continue
|
| 74 |
+
|
| 75 |
+
tts_duration = _get_audio_duration(output_path)
|
| 76 |
+
original_duration = seg["end"] - seg["start"]
|
| 77 |
+
|
| 78 |
+
# Speed-match
|
| 79 |
+
if original_duration > 0 and tts_duration > 0:
|
| 80 |
+
speed_ratio = tts_duration / original_duration
|
| 81 |
+
if speed_ratio > 1.05:
|
| 82 |
+
target_speed = min(speed_ratio, TTS_MAX_SPEED_FACTOR)
|
| 83 |
+
adjusted_path = tts_dir / f"seg_{idx:06d}_adj.mp3"
|
| 84 |
+
_adjust_speed(output_path, adjusted_path, target_speed)
|
| 85 |
+
if adjusted_path.exists():
|
| 86 |
+
output_path = adjusted_path
|
| 87 |
+
tts_duration = _get_audio_duration(output_path)
|
| 88 |
+
|
| 89 |
+
seg_result = seg.copy()
|
| 90 |
+
seg_result["tts_audio_path"] = str(output_path)
|
| 91 |
+
seg_result["tts_duration"] = round(tts_duration, 3)
|
| 92 |
+
seg_result["voice_used"] = voice
|
| 93 |
+
seg_result["gender_matched"] = "male"
|
| 94 |
+
results.append(seg_result)
|
| 95 |
+
|
| 96 |
+
if progress_callback and idx % 10 == 0:
|
| 97 |
+
progress_callback(int((idx + 1) / total * 100))
|
| 98 |
+
|
| 99 |
+
if progress_callback:
|
| 100 |
+
progress_callback(100)
|
| 101 |
+
|
| 102 |
+
valid = len([r for r in results if r.get("tts_audio_path")])
|
| 103 |
+
logger.info(f"TTS complete: {valid}/{total} segments (MALE voice: {voice})")
|
| 104 |
+
return results
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
async def _generate_tts_segment(text: str, voice: str, output_path: Path):
|
| 108 |
+
import edge_tts
|
| 109 |
+
communicate = edge_tts.Communicate(text, voice)
|
| 110 |
+
await communicate.save(str(output_path))
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def _get_audio_duration(audio_path: Path) -> float:
|
| 114 |
+
cmd = ["ffprobe", "-v", "quiet", "-show_entries", "format=duration", "-of", "csv=p=0", str(audio_path)]
|
| 115 |
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
| 116 |
+
try:
|
| 117 |
+
return float(result.stdout.strip())
|
| 118 |
+
except (ValueError, AttributeError):
|
| 119 |
+
return 0.0
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def _adjust_speed(input_path: Path, output_path: Path, speed_factor: float):
|
| 123 |
+
filters = []
|
| 124 |
+
remaining = speed_factor
|
| 125 |
+
while remaining > 2.0:
|
| 126 |
+
filters.append("atempo=2.0")
|
| 127 |
+
remaining /= 2.0
|
| 128 |
+
while remaining < 0.5:
|
| 129 |
+
filters.append("atempo=0.5")
|
| 130 |
+
remaining /= 0.5
|
| 131 |
+
filters.append(f"atempo={remaining:.4f}")
|
| 132 |
+
filter_chain = ",".join(filters)
|
| 133 |
+
cmd = ["ffmpeg", "-y", "-i", str(input_path), "-filter:a", filter_chain, str(output_path)]
|
| 134 |
+
subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
services/vocal_separator.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Stage 2B — Vocal/Background Separation using Demucs
|
| 3 |
+
Separates vocals from background music/noise for cleaner dubbing.
|
| 4 |
+
Falls back to using raw audio if demucs is not available.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import logging
|
| 8 |
+
import subprocess
|
| 9 |
+
import shutil
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def separate_vocals(audio_path: Path, output_dir: Path) -> dict:
|
| 16 |
+
"""
|
| 17 |
+
Use demucs to separate vocals from background audio.
|
| 18 |
+
Returns dict with 'vocals' and 'background' paths.
|
| 19 |
+
Falls back to raw audio if demucs is unavailable.
|
| 20 |
+
"""
|
| 21 |
+
vocals_dir = output_dir / "separated"
|
| 22 |
+
vocals_dir.mkdir(exist_ok=True)
|
| 23 |
+
|
| 24 |
+
# Check if demucs is available
|
| 25 |
+
if not shutil.which("python") and not shutil.which("demucs"):
|
| 26 |
+
logger.warning("Demucs not found. Using raw audio without separation.")
|
| 27 |
+
return _fallback_no_separation(audio_path, output_dir)
|
| 28 |
+
|
| 29 |
+
try:
|
| 30 |
+
cmd = [
|
| 31 |
+
"python", "-m", "demucs",
|
| 32 |
+
"--two-stems", "vocals", # Only separate vocals vs rest
|
| 33 |
+
"-n", "htdemucs", # Best free model
|
| 34 |
+
"-o", str(vocals_dir),
|
| 35 |
+
"--mp3", # Smaller output
|
| 36 |
+
str(audio_path)
|
| 37 |
+
]
|
| 38 |
+
|
| 39 |
+
logger.info("Running demucs vocal separation (this takes a while for long audio)...")
|
| 40 |
+
result = subprocess.run(
|
| 41 |
+
cmd,
|
| 42 |
+
capture_output=True,
|
| 43 |
+
text=True,
|
| 44 |
+
timeout=3600 # 1 hour timeout for long videos
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
if result.returncode != 0:
|
| 48 |
+
logger.warning(f"Demucs failed: {result.stderr}. Falling back to raw audio.")
|
| 49 |
+
return _fallback_no_separation(audio_path, output_dir)
|
| 50 |
+
|
| 51 |
+
# Demucs outputs to: separated/htdemucs/<filename>/vocals.mp3 and no_vocals.mp3
|
| 52 |
+
stem_name = audio_path.stem
|
| 53 |
+
demucs_out = vocals_dir / "htdemucs" / stem_name
|
| 54 |
+
|
| 55 |
+
vocals_path = demucs_out / "vocals.mp3"
|
| 56 |
+
background_path = demucs_out / "no_vocals.mp3"
|
| 57 |
+
|
| 58 |
+
if not vocals_path.exists():
|
| 59 |
+
# Try wav extension
|
| 60 |
+
vocals_path = demucs_out / "vocals.wav"
|
| 61 |
+
background_path = demucs_out / "no_vocals.wav"
|
| 62 |
+
|
| 63 |
+
if not vocals_path.exists():
|
| 64 |
+
logger.warning("Demucs output not found. Falling back.")
|
| 65 |
+
return _fallback_no_separation(audio_path, output_dir)
|
| 66 |
+
|
| 67 |
+
logger.info(f"Vocal separation complete: vocals={vocals_path}, bg={background_path}")
|
| 68 |
+
return {
|
| 69 |
+
"vocals": vocals_path,
|
| 70 |
+
"background": background_path,
|
| 71 |
+
"separated": True
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
except subprocess.TimeoutExpired:
|
| 75 |
+
logger.warning("Demucs timed out. Falling back to raw audio.")
|
| 76 |
+
return _fallback_no_separation(audio_path, output_dir)
|
| 77 |
+
except Exception as e:
|
| 78 |
+
logger.warning(f"Demucs error: {e}. Falling back to raw audio.")
|
| 79 |
+
return _fallback_no_separation(audio_path, output_dir)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _fallback_no_separation(audio_path: Path, output_dir: Path) -> dict:
|
| 83 |
+
"""Fallback: use raw audio as vocals, no background track."""
|
| 84 |
+
logger.info("Using raw audio without vocal separation.")
|
| 85 |
+
return {
|
| 86 |
+
"vocals": audio_path,
|
| 87 |
+
"background": None,
|
| 88 |
+
"separated": False
|
| 89 |
+
}
|
templates/index.html
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Video Dubbing Agent</title>
|
| 7 |
+
<style>
|
| 8 |
+
:root {
|
| 9 |
+
--bg: #0a0a12; --s1: #13132a; --s2: #1c1c3a; --border: #2a2a4a;
|
| 10 |
+
--accent: #7c5cfc; --accent2: #a78bfa; --green: #10b981;
|
| 11 |
+
--yellow: #f59e0b; --red: #ef4444; --text: #e8e8f4; --text2: #8888a8;
|
| 12 |
+
}
|
| 13 |
+
* { margin:0; padding:0; box-sizing:border-box; }
|
| 14 |
+
body { font-family:'Inter','Segoe UI',system-ui,sans-serif; background:var(--bg); color:var(--text); min-height:100vh; }
|
| 15 |
+
.wrap { max-width:860px; margin:0 auto; padding:1.5rem; }
|
| 16 |
+
|
| 17 |
+
/* Header */
|
| 18 |
+
.hdr { text-align:center; padding:2rem 0 1.5rem; }
|
| 19 |
+
.hdr h1 { font-size:2rem; background:linear-gradient(135deg,var(--accent),var(--green)); -webkit-background-clip:text; -webkit-text-fill-color:transparent; }
|
| 20 |
+
.hdr p { color:var(--text2); font-size:0.9rem; margin-top:0.3rem; }
|
| 21 |
+
.badge { display:inline-block; padding:0.2rem 0.6rem; background:rgba(124,92,252,0.15); color:var(--accent2); border-radius:20px; font-size:0.7rem; font-weight:600; margin-top:0.5rem; border:1px solid rgba(124,92,252,0.3); }
|
| 22 |
+
|
| 23 |
+
/* Card */
|
| 24 |
+
.card { background:var(--s1); border:1px solid var(--border); border-radius:14px; padding:1.5rem; margin-bottom:1.2rem; }
|
| 25 |
+
.card h2 { font-size:1rem; color:var(--accent2); margin-bottom:1rem; }
|
| 26 |
+
|
| 27 |
+
/* Form */
|
| 28 |
+
.fg { margin-bottom:0.9rem; }
|
| 29 |
+
.fg label { display:block; font-size:0.78rem; color:var(--text2); margin-bottom:0.3rem; font-weight:500; text-transform:uppercase; letter-spacing:0.5px; }
|
| 30 |
+
input, select { width:100%; padding:0.7rem 0.9rem; background:var(--s2); border:1px solid var(--border); border-radius:8px; color:var(--text); font-size:0.9rem; outline:none; transition:border 0.2s; }
|
| 31 |
+
input:focus, select:focus { border-color:var(--accent); }
|
| 32 |
+
select option { background:var(--s2); }
|
| 33 |
+
|
| 34 |
+
/* Button */
|
| 35 |
+
.btn { display:flex; align-items:center; justify-content:center; gap:0.5rem; padding:0.8rem; background:linear-gradient(135deg,var(--accent),#6d28d9); color:#fff; border:none; border-radius:10px; font-size:0.95rem; font-weight:600; cursor:pointer; width:100%; transition:all 0.2s; }
|
| 36 |
+
.btn:hover { opacity:0.9; transform:translateY(-1px); }
|
| 37 |
+
.btn:disabled { opacity:0.4; cursor:not-allowed; transform:none; }
|
| 38 |
+
.btn-green { background:linear-gradient(135deg,var(--green),#059669); }
|
| 39 |
+
.btn-sm { padding:0.5rem 1rem; font-size:0.8rem; width:auto; border-radius:8px; }
|
| 40 |
+
.btn-ghost { background:var(--s2); border:1px solid var(--border); }
|
| 41 |
+
|
| 42 |
+
/* Progress */
|
| 43 |
+
.hidden { display:none !important; }
|
| 44 |
+
.prog-bar-wrap { background:var(--s2); border-radius:20px; height:32px; position:relative; overflow:hidden; margin:0.8rem 0; }
|
| 45 |
+
.prog-bar { height:100%; background:linear-gradient(90deg,var(--accent),var(--green)); border-radius:20px; transition:width 0.6s ease; }
|
| 46 |
+
.prog-text { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); font-size:0.8rem; font-weight:700; color:#fff; text-shadow:0 1px 3px rgba(0,0,0,0.5); }
|
| 47 |
+
|
| 48 |
+
/* Stats grid */
|
| 49 |
+
.stats { display:grid; grid-template-columns:1fr 1fr 1fr; gap:0.6rem; margin:0.8rem 0; }
|
| 50 |
+
.stat { background:var(--s2); padding:0.6rem 0.8rem; border-radius:8px; }
|
| 51 |
+
.stat .lbl { font-size:0.65rem; color:var(--text2); text-transform:uppercase; letter-spacing:0.5px; }
|
| 52 |
+
.stat .val { font-size:1rem; font-weight:700; margin-top:0.15rem; }
|
| 53 |
+
|
| 54 |
+
/* Pipeline visual */
|
| 55 |
+
.pipe { display:flex; flex-wrap:wrap; gap:0.3rem; margin:0.8rem 0; }
|
| 56 |
+
.dot { padding:0.25rem 0.55rem; border-radius:6px; font-size:0.65rem; font-weight:500; background:var(--s2); color:var(--text2); border:1px solid var(--border); transition:all 0.3s; }
|
| 57 |
+
.dot.done { background:rgba(16,185,129,0.12); color:var(--green); border-color:var(--green); }
|
| 58 |
+
.dot.now { background:rgba(124,92,252,0.15); color:var(--accent2); border-color:var(--accent); animation:blink 1.2s ease-in-out infinite; }
|
| 59 |
+
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.5} }
|
| 60 |
+
|
| 61 |
+
/* Log */
|
| 62 |
+
.log { background:#06060e; border:1px solid var(--border); border-radius:8px; padding:0.8rem; font-family:'Fira Code',monospace; font-size:0.72rem; color:var(--text2); max-height:180px; overflow-y:auto; margin-top:0.8rem; }
|
| 63 |
+
.log .l { padding:0.1rem 0; }
|
| 64 |
+
.log .ok { color:var(--green); }
|
| 65 |
+
.log .wr { color:var(--yellow); }
|
| 66 |
+
.log .er { color:var(--red); }
|
| 67 |
+
|
| 68 |
+
/* Result */
|
| 69 |
+
.res-ok { border-color:var(--green) !important; }
|
| 70 |
+
.res-err { border-color:var(--red) !important; }
|
| 71 |
+
|
| 72 |
+
/* Jobs */
|
| 73 |
+
.job { display:flex; justify-content:space-between; align-items:center; padding:0.6rem 0.8rem; background:var(--s2); border-radius:8px; margin-bottom:0.4rem; }
|
| 74 |
+
.job-id { font-family:monospace; color:var(--accent2); font-size:0.85rem; }
|
| 75 |
+
.job-st { padding:0.15rem 0.5rem; border-radius:4px; font-size:0.7rem; font-weight:600; }
|
| 76 |
+
.st-completed { background:rgba(16,185,129,0.12); color:var(--green); }
|
| 77 |
+
.st-processing { background:rgba(124,92,252,0.12); color:var(--accent2); }
|
| 78 |
+
.st-failed { background:rgba(239,68,68,0.12); color:var(--red); }
|
| 79 |
+
.st-queued { background:rgba(245,158,11,0.12); color:var(--yellow); }
|
| 80 |
+
|
| 81 |
+
.dl-row { display:flex; gap:0.5rem; margin-top:0.6rem; flex-wrap:wrap; }
|
| 82 |
+
.footer { text-align:center; padding:1.5rem 0; color:var(--text2); font-size:0.72rem; }
|
| 83 |
+
|
| 84 |
+
@media(max-width:600px) { .stats{grid-template-columns:1fr 1fr} .wrap{padding:1rem} .hdr h1{font-size:1.5rem} }
|
| 85 |
+
</style>
|
| 86 |
+
</head>
|
| 87 |
+
<body>
|
| 88 |
+
<div class="wrap">
|
| 89 |
+
<div class="hdr">
|
| 90 |
+
<h1>Video Dubbing Agent</h1>
|
| 91 |
+
<p>YouTube video → dubbed in any language with AI</p>
|
| 92 |
+
<span class="badge">MALE VOICE • FREE GPU • 20+ LANGUAGES</span>
|
| 93 |
+
</div>
|
| 94 |
+
|
| 95 |
+
<!-- INPUT -->
|
| 96 |
+
<div class="card" id="inputCard">
|
| 97 |
+
<h2>New Dubbing Job</h2>
|
| 98 |
+
<div class="fg">
|
| 99 |
+
<label>YouTube URL</label>
|
| 100 |
+
<input type="text" id="ytUrl" placeholder="https://www.youtube.com/watch?v=..." value="https://www.youtube.com/watch?v=LiQ91h3yFeI" />
|
| 101 |
+
</div>
|
| 102 |
+
<div class="fg">
|
| 103 |
+
<label>Target Language</label>
|
| 104 |
+
<select id="lang">
|
| 105 |
+
<option value="mr" selected>Marathi</option>
|
| 106 |
+
<option value="hi">Hindi</option>
|
| 107 |
+
<option value="en">English</option>
|
| 108 |
+
<option value="es">Spanish</option>
|
| 109 |
+
<option value="fr">French</option>
|
| 110 |
+
<option value="de">German</option>
|
| 111 |
+
<option value="ja">Japanese</option>
|
| 112 |
+
<option value="zh">Chinese</option>
|
| 113 |
+
<option value="pt">Portuguese</option>
|
| 114 |
+
<option value="ar">Arabic</option>
|
| 115 |
+
<option value="ko">Korean</option>
|
| 116 |
+
<option value="ru">Russian</option>
|
| 117 |
+
<option value="it">Italian</option>
|
| 118 |
+
<option value="ta">Tamil</option>
|
| 119 |
+
<option value="te">Telugu</option>
|
| 120 |
+
<option value="bn">Bengali</option>
|
| 121 |
+
<option value="gu">Gujarati</option>
|
| 122 |
+
<option value="kn">Kannada</option>
|
| 123 |
+
<option value="ml">Malayalam</option>
|
| 124 |
+
<option value="ur">Urdu</option>
|
| 125 |
+
</select>
|
| 126 |
+
</div>
|
| 127 |
+
<button class="btn" id="startBtn" onclick="startJob()">Start Dubbing</button>
|
| 128 |
+
</div>
|
| 129 |
+
|
| 130 |
+
<!-- PROGRESS -->
|
| 131 |
+
<div class="card hidden" id="progCard">
|
| 132 |
+
<h2 id="progTitle">Processing...</h2>
|
| 133 |
+
<div id="vidTitle" style="color:var(--text2);font-size:0.85rem;margin-bottom:0.4rem"></div>
|
| 134 |
+
<div class="prog-bar-wrap">
|
| 135 |
+
<div class="prog-bar" id="pBar" style="width:0%"></div>
|
| 136 |
+
<div class="prog-text" id="pText">0%</div>
|
| 137 |
+
</div>
|
| 138 |
+
<div class="stats">
|
| 139 |
+
<div class="stat"><div class="lbl">Stage</div><div class="val" id="sStage">Queued</div></div>
|
| 140 |
+
<div class="stat"><div class="lbl">Progress</div><div class="val" id="sNum">0 / 11</div></div>
|
| 141 |
+
<div class="stat"><div class="lbl">Elapsed</div><div class="val" id="sTime">0s</div></div>
|
| 142 |
+
</div>
|
| 143 |
+
<div class="pipe" id="pipeDots"></div>
|
| 144 |
+
<div class="log" id="logBox"></div>
|
| 145 |
+
</div>
|
| 146 |
+
|
| 147 |
+
<!-- RESULT -->
|
| 148 |
+
<div class="card hidden" id="resCard">
|
| 149 |
+
<h2 id="resTitle">Done!</h2>
|
| 150 |
+
<p id="resMsg" style="margin-bottom:0.8rem"></p>
|
| 151 |
+
<button class="btn btn-green" id="dlBtn" onclick="dlVideo()">Download Dubbed Video (.mp4)</button>
|
| 152 |
+
<div class="dl-row">
|
| 153 |
+
<button class="btn btn-sm btn-ghost" onclick="dlSub('original')">Original Subtitles</button>
|
| 154 |
+
<button class="btn btn-sm btn-ghost" onclick="dlSub('translated')">Translated Subtitles</button>
|
| 155 |
+
<button class="btn btn-sm btn-ghost" onclick="resetUI()">New Job</button>
|
| 156 |
+
</div>
|
| 157 |
+
</div>
|
| 158 |
+
|
| 159 |
+
<!-- JOBS -->
|
| 160 |
+
<div class="card">
|
| 161 |
+
<h2>Recent Jobs</h2>
|
| 162 |
+
<div id="jobsList"><span style="color:var(--text2);font-size:0.85rem">No jobs yet</span></div>
|
| 163 |
+
</div>
|
| 164 |
+
|
| 165 |
+
<div class="footer">Video Dubbing Agent v2.0 — HuggingFace GPU + Edge TTS + FFmpeg • Male Voice Only • 100% Free</div>
|
| 166 |
+
</div>
|
| 167 |
+
|
| 168 |
+
<script>
|
| 169 |
+
const API='/api';
|
| 170 |
+
let jobId=null, poll=null;
|
| 171 |
+
const SL={downloading:'Download',extracting_audio:'Extract Audio',separating_vocals:'Vocal Split',transcribing:'Transcribe (GPU)',profiling_speakers:'Speakers',translating:'Translate',generating_tts:'TTS (Male)',assembling_audio:'Assembly',mixing_audio:'Mixing',merging_video:'Merge Video',generating_subtitles:'Subtitles'};
|
| 172 |
+
|
| 173 |
+
function dots(cur){
|
| 174 |
+
const c=document.getElementById('pipeDots');
|
| 175 |
+
const st=Object.keys(SL), ci=st.indexOf(cur);
|
| 176 |
+
c.innerHTML=st.map((s,i)=>`<span class="dot${i<ci?' done':i===ci?' now':''}">${SL[s]}</span>`).join('');
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
function log(m,t=''){
|
| 180 |
+
const b=document.getElementById('logBox'),d=document.createElement('div');
|
| 181 |
+
d.className=`l ${t}`;d.textContent=`[${new Date().toLocaleTimeString()}] ${m}`;
|
| 182 |
+
b.appendChild(d);b.scrollTop=b.scrollHeight;
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
async function startJob(){
|
| 186 |
+
const url=document.getElementById('ytUrl').value.trim();
|
| 187 |
+
if(!url){alert('Enter a YouTube URL');return}
|
| 188 |
+
const btn=document.getElementById('startBtn');
|
| 189 |
+
btn.disabled=true;btn.textContent='Starting...';
|
| 190 |
+
try{
|
| 191 |
+
const r=await fetch(`${API}/dub`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({youtube_url:url,target_language:document.getElementById('lang').value,preserve_background:true,generate_subtitles:true})});
|
| 192 |
+
const d=await r.json();
|
| 193 |
+
if(!r.ok)throw new Error(d.detail||'Failed');
|
| 194 |
+
jobId=d.job_id;log(`Job ${jobId} started`,'ok');
|
| 195 |
+
document.getElementById('progCard').classList.remove('hidden');
|
| 196 |
+
document.getElementById('resCard').classList.add('hidden');
|
| 197 |
+
poll=setInterval(pollJob,2500);
|
| 198 |
+
}catch(e){alert(e.message);log(e.message,'er')}
|
| 199 |
+
finally{btn.disabled=false;btn.textContent='Start Dubbing'}
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
async function pollJob(){
|
| 203 |
+
if(!jobId)return;
|
| 204 |
+
try{
|
| 205 |
+
const r=await fetch(`${API}/status/${jobId}`);
|
| 206 |
+
const d=await r.json();
|
| 207 |
+
document.getElementById('sStage').textContent=SL[d.current_stage]||d.current_stage||'Queued';
|
| 208 |
+
document.getElementById('sNum').textContent=`${d.stage_number} / ${d.total_stages}`;
|
| 209 |
+
document.getElementById('sTime').textContent=d.elapsed_time_str||'0s';
|
| 210 |
+
document.getElementById('pBar').style.width=`${d.progress_percent}%`;
|
| 211 |
+
document.getElementById('pText').textContent=`${d.progress_percent}%`;
|
| 212 |
+
if(d.video_title)document.getElementById('vidTitle').textContent=d.video_title;
|
| 213 |
+
dots(d.current_stage);
|
| 214 |
+
if(d.status==='completed'){clearInterval(poll);log('DONE!','ok');showRes(true,'Your dubbed video is ready!')}
|
| 215 |
+
else if(d.status==='failed'){clearInterval(poll);log(`FAILED: ${d.error_message}`,'er');showRes(false,d.error_message)}
|
| 216 |
+
else log(`${SL[d.current_stage]||d.current_stage} — ${d.progress_percent}%`);
|
| 217 |
+
refreshJobs();
|
| 218 |
+
}catch(e){log(`Poll err: ${e.message}`,'wr')}
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
function showRes(ok,msg){
|
| 222 |
+
const c=document.getElementById('resCard');
|
| 223 |
+
c.classList.remove('hidden','res-ok','res-err');
|
| 224 |
+
c.classList.add(ok?'res-ok':'res-err');
|
| 225 |
+
document.getElementById('resTitle').textContent=ok?'Dubbing Complete!':'Failed';
|
| 226 |
+
document.getElementById('resMsg').textContent=msg;
|
| 227 |
+
document.getElementById('dlBtn').style.display=ok?'flex':'none';
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
function dlVideo(){if(jobId)window.open(`${API}/download/${jobId}`,'_blank')}
|
| 231 |
+
function dlSub(t){if(jobId)window.open(`${API}/download/${jobId}/subtitles/${t}`,'_blank')}
|
| 232 |
+
function resetUI(){
|
| 233 |
+
jobId=null;
|
| 234 |
+
document.getElementById('progCard').classList.add('hidden');
|
| 235 |
+
document.getElementById('resCard').classList.add('hidden');
|
| 236 |
+
document.getElementById('logBox').innerHTML='';
|
| 237 |
+
document.getElementById('pBar').style.width='0%';
|
| 238 |
+
document.getElementById('pText').textContent='0%';
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
async function refreshJobs(){
|
| 242 |
+
try{
|
| 243 |
+
const r=await fetch(`${API}/jobs`),d=await r.json(),c=document.getElementById('jobsList');
|
| 244 |
+
if(!d.jobs||!d.jobs.length){c.innerHTML='<span style="color:var(--text2);font-size:0.85rem">No jobs yet</span>';return}
|
| 245 |
+
c.innerHTML=d.jobs.slice(-5).reverse().map(j=>`<div class="job"><div><span class="job-id">${j.job_id}</span> <span style="color:var(--text2);font-size:0.75rem">${j.video_title||''}</span></div><span class="job-st st-${j.status}">${j.status}</span></div>`).join('');
|
| 246 |
+
}catch(e){}
|
| 247 |
+
}
|
| 248 |
+
refreshJobs();
|
| 249 |
+
</script>
|
| 250 |
+
</body>
|
| 251 |
+
</html>
|
utils/__init__.py
ADDED
|
File without changes
|
utils/file_manager.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Utility — File & Job Directory Manager
|
| 3 |
+
Creates/cleans temp directories, tracks disk usage, handles expiry.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
import shutil
|
| 8 |
+
import uuid
|
| 9 |
+
import time
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from config import TEMP_DIR, JOB_EXPIRY_HOURS
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def create_job_directory() -> tuple:
|
| 17 |
+
"""Create a unique job directory. Returns (job_id, job_dir)."""
|
| 18 |
+
job_id = str(uuid.uuid4())[:8]
|
| 19 |
+
job_dir = TEMP_DIR / job_id
|
| 20 |
+
job_dir.mkdir(parents=True, exist_ok=True)
|
| 21 |
+
logger.info(f"Created job directory: {job_dir}")
|
| 22 |
+
return job_id, job_dir
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def get_job_directory(job_id: str) -> Path:
|
| 26 |
+
"""Get the directory for a given job ID."""
|
| 27 |
+
job_dir = TEMP_DIR / job_id
|
| 28 |
+
if not job_dir.exists():
|
| 29 |
+
raise FileNotFoundError(f"Job directory not found: {job_id}")
|
| 30 |
+
return job_dir
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def cleanup_job(job_id: str):
|
| 34 |
+
"""Remove all temp files for a job."""
|
| 35 |
+
job_dir = TEMP_DIR / job_id
|
| 36 |
+
if job_dir.exists():
|
| 37 |
+
shutil.rmtree(job_dir)
|
| 38 |
+
logger.info(f"Cleaned up job: {job_id}")
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def cleanup_expired_jobs():
|
| 42 |
+
"""Remove job directories older than JOB_EXPIRY_HOURS."""
|
| 43 |
+
if not TEMP_DIR.exists():
|
| 44 |
+
return
|
| 45 |
+
|
| 46 |
+
now = time.time()
|
| 47 |
+
expiry_seconds = JOB_EXPIRY_HOURS * 3600
|
| 48 |
+
cleaned = 0
|
| 49 |
+
|
| 50 |
+
for job_dir in TEMP_DIR.iterdir():
|
| 51 |
+
if job_dir.is_dir():
|
| 52 |
+
age = now - job_dir.stat().st_mtime
|
| 53 |
+
if age > expiry_seconds:
|
| 54 |
+
shutil.rmtree(job_dir)
|
| 55 |
+
logger.info(f"Expired job cleaned: {job_dir.name}")
|
| 56 |
+
cleaned += 1
|
| 57 |
+
|
| 58 |
+
if cleaned:
|
| 59 |
+
logger.info(f"Cleaned {cleaned} expired jobs")
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def get_job_disk_usage(job_id: str) -> str:
|
| 63 |
+
"""Get human-readable disk usage for a job."""
|
| 64 |
+
job_dir = TEMP_DIR / job_id
|
| 65 |
+
if not job_dir.exists():
|
| 66 |
+
return "0 B"
|
| 67 |
+
|
| 68 |
+
total = sum(f.stat().st_size for f in job_dir.rglob("*") if f.is_file())
|
| 69 |
+
|
| 70 |
+
if total > 1e9:
|
| 71 |
+
return f"{total / 1e9:.1f} GB"
|
| 72 |
+
elif total > 1e6:
|
| 73 |
+
return f"{total / 1e6:.1f} MB"
|
| 74 |
+
elif total > 1e3:
|
| 75 |
+
return f"{total / 1e3:.1f} KB"
|
| 76 |
+
return f"{total} B"
|
utils/gpu_detector.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Utility — GPU Detection
|
| 3 |
+
Auto-detect CUDA availability and recommend settings.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
import subprocess
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def detect_gpu() -> dict:
|
| 13 |
+
"""
|
| 14 |
+
Detect GPU availability and return system info.
|
| 15 |
+
"""
|
| 16 |
+
info = {
|
| 17 |
+
"cuda_available": False,
|
| 18 |
+
"gpu_name": None,
|
| 19 |
+
"gpu_memory_mb": 0,
|
| 20 |
+
"recommended_device": "cpu",
|
| 21 |
+
"recommended_compute": "int8",
|
| 22 |
+
"recommended_batch_size": 4,
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
try:
|
| 26 |
+
import torch
|
| 27 |
+
if torch.cuda.is_available():
|
| 28 |
+
info["cuda_available"] = True
|
| 29 |
+
info["gpu_name"] = torch.cuda.get_device_name(0)
|
| 30 |
+
info["gpu_memory_mb"] = torch.cuda.get_device_properties(0).total_mem // (1024 * 1024)
|
| 31 |
+
info["recommended_device"] = "cuda"
|
| 32 |
+
info["recommended_compute"] = "float16"
|
| 33 |
+
info["recommended_batch_size"] = 16 if info["gpu_memory_mb"] > 8000 else 8
|
| 34 |
+
|
| 35 |
+
logger.info(f"GPU detected: {info['gpu_name']} ({info['gpu_memory_mb']} MB)")
|
| 36 |
+
else:
|
| 37 |
+
logger.info("PyTorch installed but no CUDA GPU detected.")
|
| 38 |
+
except ImportError:
|
| 39 |
+
logger.info("PyTorch not installed. Using CPU mode.")
|
| 40 |
+
|
| 41 |
+
# Fallback: check nvidia-smi
|
| 42 |
+
if not info["cuda_available"]:
|
| 43 |
+
try:
|
| 44 |
+
result = subprocess.run(
|
| 45 |
+
["nvidia-smi", "--query-gpu=name,memory.total", "--format=csv,noheader"],
|
| 46 |
+
capture_output=True, text=True, timeout=5
|
| 47 |
+
)
|
| 48 |
+
if result.returncode == 0 and result.stdout.strip():
|
| 49 |
+
parts = result.stdout.strip().split(",")
|
| 50 |
+
info["gpu_name"] = parts[0].strip()
|
| 51 |
+
logger.info(f"nvidia-smi found GPU: {info['gpu_name']} (but PyTorch CUDA not available)")
|
| 52 |
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
| 53 |
+
pass
|
| 54 |
+
|
| 55 |
+
return info
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def get_system_report() -> str:
|
| 59 |
+
"""Generate a human-readable system readiness report."""
|
| 60 |
+
gpu = detect_gpu()
|
| 61 |
+
lines = [
|
| 62 |
+
"=== System Report ===",
|
| 63 |
+
f"GPU: {gpu['gpu_name'] or 'None detected'}",
|
| 64 |
+
f"CUDA: {'Yes' if gpu['cuda_available'] else 'No'}",
|
| 65 |
+
f"VRAM: {gpu['gpu_memory_mb']} MB" if gpu['gpu_memory_mb'] else "VRAM: N/A",
|
| 66 |
+
f"Mode: {gpu['recommended_device'].upper()}",
|
| 67 |
+
f"Compute: {gpu['recommended_compute']}",
|
| 68 |
+
f"Batch size: {gpu['recommended_batch_size']}",
|
| 69 |
+
"",
|
| 70 |
+
"Estimated processing times for 2.5hr video:",
|
| 71 |
+
]
|
| 72 |
+
|
| 73 |
+
if gpu["cuda_available"] and gpu["gpu_memory_mb"] > 8000:
|
| 74 |
+
lines.append(" Transcription: ~15-25 min")
|
| 75 |
+
lines.append(" TTS Generation: ~20-30 min")
|
| 76 |
+
lines.append(" Total: ~45-75 min")
|
| 77 |
+
elif gpu["cuda_available"]:
|
| 78 |
+
lines.append(" Transcription: ~25-40 min")
|
| 79 |
+
lines.append(" TTS Generation: ~30-45 min")
|
| 80 |
+
lines.append(" Total: ~75-120 min")
|
| 81 |
+
else:
|
| 82 |
+
lines.append(" Transcription: ~2-4 hours (CPU)")
|
| 83 |
+
lines.append(" TTS Generation: ~1-2 hours (CPU)")
|
| 84 |
+
lines.append(" Total: ~3-6 hours")
|
| 85 |
+
lines.append(" ⚠ GPU strongly recommended for videos > 30 min")
|
| 86 |
+
|
| 87 |
+
return "\n".join(lines)
|