dashhdata commited on
Commit
4ec3855
·
verified ·
1 Parent(s): 080b4bd

Upload folder using huggingface_hub

Browse files
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: pink
5
- colorTo: indigo
6
  sdk: docker
7
- pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 &bull; FREE GPU &bull; 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 &mdash; HuggingFace GPU + Edge TTS + FFmpeg &bull; Male Voice Only &bull; 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)