""" ๐ŸŽฌ Movie Dubber โ€” Any Language โ†’ Hindi Single-file Streamlit app for Hugging Face Spaces (CPU, free tier) ALL code is in this one file โ€” no utils/ folder needed. This is the most reliable way to avoid import errors on HF Spaces. Pipeline: Upload MP4 โ†’ Extract Audio โ†’ Transcribe โ†’ Translate โ†’ TTS โ†’ Merge โ†’ Download """ import os import gc import math import tempfile import subprocess import streamlit as st # โ”€โ”€โ”€ Page config (MUST be first Streamlit call) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ st.set_page_config( page_title="๐ŸŽฌ Movie Dubber โ€” Any Language โ†’ Hindi", page_icon="๐ŸŽฌ", layout="wide", initial_sidebar_state="expanded", ) st.markdown(""" """, unsafe_allow_html=True) # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• # SECTION 1 โ€” AUDIO UTILITIES # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• def extract_audio(video_path: str) -> str: """ Pull audio from video using ffmpeg. Returns path to a 16 kHz mono WAV โ€” ideal for Whisper. """ out = os.path.join(tempfile.gettempdir(), "extracted_audio.wav") subprocess.run([ "ffmpeg", "-y", "-i", video_path, "-vn", # no video "-acodec", "pcm_s16le", # uncompressed WAV "-ar", "16000", # 16 kHz "-ac", "1", # mono out, ], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) return out def detect_silence_segments(audio_path: str): """Return list of (start_sec, end_sec) for silent / music regions.""" from pydub import AudioSegment from pydub.silence import detect_nonsilent audio = AudioSegment.from_wav(audio_path) speech = detect_nonsilent(audio, min_silence_len=700, silence_thresh=-40) total_ms = len(audio) silence, prev = [], 0 for s, e in speech: if s > prev + 200: silence.append((prev / 1000, s / 1000)) prev = e if prev < total_ms - 200: silence.append((prev / 1000, total_ms / 1000)) return silence # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• # SECTION 2 โ€” TRANSCRIPTION # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• def transcribe_audio(audio_path: str, model_size: str = "base", progress_cb=None): """ Transcribe audio with faster-whisper (INT8 quantised, CPU-optimised). Returns list of { start, end, text, speaker, lang }. """ from faster_whisper import WhisperModel if progress_cb: progress_cb(5, f"Loading Whisper '{model_size}'โ€ฆ") model = WhisperModel(model_size, device="cpu", compute_type="int8") if progress_cb: progress_cb(20, "Transcribingโ€ฆ") raw_segs, info = model.transcribe( audio_path, beam_size=3, vad_filter=True, vad_parameters={"min_silence_duration_ms": 500}, word_timestamps=True, ) if progress_cb: progress_cb(50, f"Language detected: {info.language}") segments = [] for i, seg in enumerate(raw_segs): text = seg.text.strip() if not text: continue speaker = _heuristic_speaker(segments, seg.start) segments.append({ "start": round(seg.start, 2), "end": round(seg.end, 2), "text": text, "speaker": speaker, "lang": info.language, }) if progress_cb and i % 5 == 0: progress_cb(min(90, 50 + i * 2), f"Segment {i+1} doneโ€ฆ") del model gc.collect() return segments def _heuristic_speaker(existing, current_start): """Flip speaker ID when gap > 1.5 s (lightweight alternative to pyannote).""" if not existing: return "1" if current_start - existing[-1]["end"] > 1.5: return "2" if existing[-1]["speaker"] == "1" else "1" return existing[-1]["speaker"] # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• # SECTION 3 โ€” TRANSLATION (Helsinki-NLP MarianMT) # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• _DIRECT_HI = {"en-hi": "Helsinki-NLP/opus-mt-en-hi"} _TO_EN = { "de": "Helsinki-NLP/opus-mt-de-en", "fr": "Helsinki-NLP/opus-mt-fr-en", "es": "Helsinki-NLP/opus-mt-es-en", "it": "Helsinki-NLP/opus-mt-it-en", "pt": "Helsinki-NLP/opus-mt-pt-en", "ja": "Helsinki-NLP/opus-mt-ja-en", "zh": "Helsinki-NLP/opus-mt-zh-en", "ko": "Helsinki-NLP/opus-mt-ko-en", "ar": "Helsinki-NLP/opus-mt-ar-en", "ru": "Helsinki-NLP/opus-mt-ru-en", "tr": "Helsinki-NLP/opus-mt-tr-en", "nl": "Helsinki-NLP/opus-mt-nl-en", "pl": "Helsinki-NLP/opus-mt-pl-en", } _mt_cache = {} def _load_mt(name): from transformers import MarianMTModel, MarianTokenizer if name not in _mt_cache: tok = MarianTokenizer.from_pretrained(name) mdl = MarianMTModel.from_pretrained(name) mdl.eval() _mt_cache[name] = (tok, mdl) return _mt_cache[name] def _mt(text, model_name): tok, mdl = _load_mt(model_name) inp = tok([text], return_tensors="pt", padding=True, truncation=True, max_length=512) out = mdl.generate(**inp, num_beams=2, max_length=512) return tok.decode(out[0], skip_special_tokens=True).strip() def translate_to_hindi(text: str, source_lang: str = "en") -> str: """Translate text โ†’ Hindi. Pivots through English if no direct model.""" if not text.strip() or source_lang == "hi": return text src = source_lang.lower() # Direct path if f"{src}-hi" in _DIRECT_HI: try: return _mt(text, _DIRECT_HI[f"{src}-hi"]) except Exception: pass # Pivot: src โ†’ English if src != "en" and src in _TO_EN: try: text = _mt(text, _TO_EN[src]) except Exception: pass # English โ†’ Hindi try: return _mt(text, _DIRECT_HI["en-hi"]) except Exception: return f"[Translation failed: {text}]" # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• # SECTION 4 โ€” HINDI TTS (gTTS โ€” free, instant, no GPU) # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• # โ”€โ”€ Voice options โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ # gTTS uses Google's TTS via HTTP. Different TLDs give subtly different voices. # We label them Male/Female as a UX convenience; the underlying difference is # accent/server โ€” gTTS doesn't expose true gender control, but the variation # is clearly audible and useful for multi-speaker dubbing. VOICE_OPTIONS = { "Male 1 (Standard)": "com", # US/international server "Male 2 (Indian)": "co.in", # Indian English server "Female 1 (UK)": "co.uk", # UK server โ€” higher pitch "Female 2 (Australia)": "com.au", # Australian server "Default": "com", # fallback } # Legacy alias kept so any existing code using SPEAKER_OPTIONS still works SPEAKER_OPTIONS = VOICE_OPTIONS def _text_to_wav(text: str, tld: str, out_path: str): """Hindi text โ†’ WAV via gTTS (MP3 intermediate).""" from gtts import gTTS from pydub import AudioSegment if not text.strip(): AudioSegment.silent(500).export(out_path, format="wav") return mp3 = out_path.replace(".wav", ".mp3") gTTS(text=text, lang="hi", tld=tld, slow=False).save(mp3) AudioSegment.from_mp3(mp3).export(out_path, format="wav") try: os.remove(mp3) except Exception: pass def _fit_to_duration(speech, target_ms): """Speed-fit TTS audio to match original segment duration.""" from pydub import AudioSegment dur = len(speech) if not dur or not target_ms: return speech ratio = dur / target_ms if ratio > 1.3: mult = min(ratio, 1.8) speech = speech._spawn( speech.raw_data, overrides={"frame_rate": int(speech.frame_rate * mult)}, ).set_frame_rate(speech.frame_rate) elif dur < target_ms: speech = speech + AudioSegment.silent(target_ms - dur) return speech def generate_hindi_speech( segments, speaker="com", speaker_voice_map=None, speech_volume=1.2, progress_cb=None, ): """ Generate full dubbed audio track. Supports per-speaker voices via speaker_voice_map: { "1": "com", "2": "co.uk", ... } If speaker_voice_map is None, all segments use `speaker` (tld string). Each segment โ†’ TTS with its speaker's voice โ†’ speed-fit โ†’ overlaid at correct timestamp on a silent base track. """ from pydub import AudioSegment total_ms = int(max(s["end"] for s in segments) * 1000) + 2000 track = AudioSegment.silent(duration=total_ms) tmp, n = tempfile.gettempdir(), len(segments) for i, seg in enumerate(segments): if progress_cb: progress_cb(int((i + 1) / n * 90), f"Synthesising {i+1}/{n}โ€ฆ") hindi = seg.get("hindi_text", "").strip() if not hindi: continue # Pick voice: per-speaker map โ†’ fallback to global speaker tld spk_id = str(seg.get("speaker", "1")) if speaker_voice_map and spk_id in speaker_voice_map: tld = speaker_voice_map[spk_id] else: tld = speaker start_ms = int(seg["start"] * 1000) target_ms = int(seg["end"] * 1000) - start_ms wav = os.path.join(tmp, f"seg_{i:04d}.wav") try: _text_to_wav(hindi, tld, wav) except Exception: continue speech = AudioSegment.from_wav(wav) if speech_volume != 1.0: speech = speech + (20 * math.log10(max(speech_volume, 0.01))) speech = _fit_to_duration(speech, target_ms) track = track.overlay(speech, position=start_ms) try: os.remove(wav) except Exception: pass gc.collect() out = os.path.join(tmp, "hindi_dubbed_audio.wav") track.export(out, format="wav") return out # โ”€โ”€ Transcription download helper โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def build_transcription_txt(segments: list) -> str: """ Format transcribed segments into a readable .txt file. Output format per segment: [MM:SS - MM:SS] Speaker N: Original text Easy to read, copy, and share. """ lines = [] for seg in segments: start_m, start_s = divmod(int(seg["start"]), 60) end_m, end_s = divmod(int(seg["end"]), 60) ts = f"[{start_m:02d}:{start_s:02d} - {end_m:02d}:{end_s:02d}]" lines.append(f"{ts} Speaker {seg['speaker']}:") lines.append(seg["text"]) lines.append("") # blank line between segments return "\n".join(lines) # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• # SECTION 5 โ€” VIDEO MERGE (ffmpeg, no video re-encoding) # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• def merge_audio_video(video_path, dubbed_audio_path, bgm_volume=0.15): """ Mix Hindi speech with faint original BGM, then mux into video. Video stream is COPIED (no re-encode) โ†’ fast even on CPU. """ out = os.path.join(tempfile.gettempdir(), "hindi_dubbed_output.mp4") bgm = str(round(bgm_volume, 3)) result = subprocess.run([ "ffmpeg", "-y", "-i", video_path, "-i", dubbed_audio_path, "-filter_complex", f"[0:a]volume={bgm}[b];[b][1:a]amix=inputs=2:duration=first[mix]", "-map", "0:v", "-map", "[mix]", "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-shortest", out, ], stdout=subprocess.PIPE, stderr=subprocess.PIPE) if result.returncode != 0: # Fallback: replace audio entirely (skip BGM blend) fallback = os.path.join(tempfile.gettempdir(), "dubbed_fallback.mp4") subprocess.run([ "ffmpeg", "-y", "-i", video_path, "-i", dubbed_audio_path, "-map", "0:v", "-map", "1:a", "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-shortest", fallback, ], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) return fallback return out # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• # SECTION 6 โ€” SMART AUTO-ASSIGN # Splits a Hindi dubbing text block and maps lines โ†’ segments # Rule-based only โ€” no ML models, fully CPU-safe # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• import re # Emotion keyword lists for simple rule-based detection _EMOTION_RULES = { "๐Ÿ˜  Angry": ["เค—เฅเคธเฅเคธเคพ", "เค•เฅเคฐเฅ‹เคง", "เคšเคฟเคฒเฅเคฒเคพเค“", "เคฎเคค เค•เคฐเฅ‹", "เคฌเค‚เคฆ เค•เคฐเฅ‹", "เคจเคนเฅ€เค‚", "เค•เคญเฅ€ เคจเคนเฅ€เค‚"], "๐Ÿ’• Soft": ["เคชเฅเคฏเคพเคฐ", "เคถเคพเค‚เคค", "เคงเฅ€เคฐเฅ‡", "เคฎเคพเคซ", "เคฆเฅเค–", "เคฐเฅ‹", "เค†เคเคธเฅ‚"], "๐Ÿ˜„ Happy": ["เค–เฅเคถเฅ€", "เคนเคพเคนเคพเคนเคพ", "เคฌเคขเคผเคฟเคฏเคพ", "เคถเคพเคฌเคพเคถ", "เคตเคพเคน", "เคฎเคœเคผเคพ", "เคœเฅ€เคค"], "๐Ÿ˜ Neutral": [], # default } def _detect_emotion(text: str) -> str: """Return emotion label for a Hindi line โ€” pure keyword matching, no model.""" for label, keywords in _EMOTION_RULES.items(): if any(kw in text for kw in keywords): return label return "๐Ÿ˜ Neutral" def _split_hindi_text(raw: str) -> list: """ Split Hindi paragraph into sentences. Splits on: newline, Hindi danda (เฅค), ? ! Strips whitespace and drops empty lines. """ normalized = re.sub(r"[เฅค?!\n]+", "\n", raw) return [ln.strip() for ln in normalized.splitlines() if ln.strip()] def _merge_short_lines(lines: list, target: int) -> list: """If we have MORE lines than segments, merge the shortest adjacent pairs.""" lines = list(lines) while len(lines) > target: # Find index of shortest line that has a neighbour idx = min(range(len(lines) - 1), key=lambda i: len(lines[i])) merged = lines[idx] + " " + lines[idx + 1] lines = lines[:idx] + [merged] + lines[idx + 2:] return lines def _split_long_lines(lines: list, target: int) -> list: """If we have FEWER lines than segments, split the longest line at midpoint.""" lines = list(lines) while len(lines) < target: idx = max(range(len(lines)), key=lambda i: len(lines[i])) line = lines[idx] mid = len(line) // 2 # Find a natural break near the middle (comma or space) split_at = -1 for offset in range(mid): for pos in [mid - offset, mid + offset]: if 0 < pos < len(line) and line[pos] in "ุŒ, ": split_at = pos break if split_at != -1: break if split_at == -1: split_at = mid # fallback: hard split at middle p1 = line[:split_at].strip() p2 = line[split_at:].strip().lstrip("ุŒ, ") if not p1 or not p2: break # can't split further lines = lines[:idx] + [p1, p2] + lines[idx + 1:] return lines def auto_assign_hindi_text(segments: list, hindi_text: str) -> list: """ Split a block of Hindi dubbing text and assign each line to the matching segment, preserving speaker + timestamps. Steps: 1. Split text into lines on newline / danda / ? / ! 2. Adjust line count to match segment count: - Too many lines โ†’ merge shortest adjacent pairs - Too few lines โ†’ split longest lines at midpoint 3. Assign one line per segment 4. Detect emotion per line (keyword-based, no model) """ lines = _split_hindi_text(hindi_text) n = len(segments) if not lines: return segments if len(lines) > n: lines = _merge_short_lines(lines, n) elif len(lines) < n: lines = _split_long_lines(lines, n) # Safety pad while len(lines) < n: lines.append("") return [ {**seg, "hindi_text": lines[i] if i < len(lines) else "", "emotion": _detect_emotion(lines[i] if i < len(lines) else "")} for i, seg in enumerate(segments) ] # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• # STREAMLIT UI # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• # Session state for k in ["audio_path", "segments", "translated_segments", "dubbed_audio_path", "output_video_path", "speaker_voice_map"]: if k not in st.session_state: st.session_state[k] = None # โ”€โ”€ Sidebar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ with st.sidebar: st.title("โš™๏ธ Settings") st.subheader("๐ŸŽ™๏ธ Transcription") whisper_model = st.selectbox( "Whisper model", ["tiny", "base", "small"], index=1, help="tiny=fastest | base=balanced โœ… | small=most accurate", ) st.subheader("๐ŸŽš๏ธ Audio Mix") bgm_volume = st.slider("Background music volume", 0.0, 1.0, 0.15, 0.05) speech_volume = st.slider("Hindi speech volume", 0.5, 2.0, 1.2, 0.1) st.subheader("โ„น๏ธ Stack") st.caption("faster-whisper ยท Helsinki-NLP ยท gTTS ยท ffmpeg\n100% free ยท CPU ยท Open-source") # โ”€โ”€ Main โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ st.title("๐ŸŽฌ Movie Dubber") st.markdown("**Upload any video โ†’ Get Hindi dubbed output โ€” 100% free**") # STEP 1 โ€” Upload st.header("Step 1 โ€” Upload Video") uploaded = st.file_uploader( "MP4 / AVI / MKV / MOV (keep under ~200 MB for CPU tier)", type=["mp4", "avi", "mkv", "mov"], ) if uploaded: video_path = os.path.join(tempfile.gettempdir(), uploaded.name) with open(video_path, "wb") as f: f.write(uploaded.read()) st.video(video_path) st.success(f"โœ… {uploaded.name} ({os.path.getsize(video_path)/1e6:.1f} MB)") # STEP 2 โ€” Extract Audio st.header("Step 2 โ€” Extract Audio") if st.button("๐Ÿ”Š Extract Audio", use_container_width=True): with st.spinner("Running ffmpegโ€ฆ"): bar = st.progress(0, "Extractingโ€ฆ") apth = extract_audio(video_path) bar.progress(70, "Detecting silenceโ€ฆ") sil = detect_silence_segments(apth) bar.progress(100, "Done!") st.session_state.audio_path = apth st.audio(apth) st.success("โœ… Audio extracted!") if sil: with st.expander(f"๐Ÿ”‡ {len(sil)} silence segment(s)"): for s, e in sil[:10]: st.caption(f" {s:.1f}s โ†’ {e:.1f}s") # STEP 3 โ€” Transcribe if st.session_state.audio_path: st.header("Step 3 โ€” Transcribe Speech") st.info(f"Whisper **{whisper_model}** on CPU โ€” ~1โ€“3 min/min of audio. โ˜•") if st.button("๐Ÿ“ Transcribe", use_container_width=True): bar = st.progress(0, "Loadingโ€ฆ") segs = transcribe_audio( st.session_state.audio_path, model_size=whisper_model, progress_cb=lambda p, t: bar.progress(p, t), ) bar.progress(100, "Done!") st.session_state.segments = segs st.session_state.translated_segments = [{**s, "hindi_text": ""} for s in segs] st.session_state.speaker_voice_map = None # reset on new transcription st.success(f"โœ… {len(segs)} segments found!") # โ”€โ”€ Download transcription as TXT โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ if st.session_state.segments: txt_content = build_transcription_txt(st.session_state.segments) st.download_button( label="โฌ‡๏ธ Download Transcription (.txt)", data=txt_content.encode("utf-8"), file_name="transcription.txt", mime="text/plain", use_container_width=True, ) # โ”€โ”€ Speaker voice assignment โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ if st.session_state.segments: st.subheader("๐ŸŽ™๏ธ Assign Voices to Speakers") st.caption( "Each detected speaker can have a different Hindi voice. " "Changes take effect when you generate Hindi speech." ) # Find unique speaker IDs in order of first appearance seen, unique_speakers = set(), [] for seg in st.session_state.segments: spk = str(seg.get("speaker", "1")) if spk not in seen: seen.add(spk) unique_speakers.append(spk) voice_names = list(VOICE_OPTIONS.keys()) # Default assignments: alternate Male 1 / Female 1 for first two, # then Default for any extras defaults = ["Male 1 (Standard)", "Female 1 (UK)", "Male 2 (Indian)", "Female 2 (Australia)", "Default"] voice_map = {} # spk_id โ†’ tld string cols = st.columns(min(len(unique_speakers), 3)) for idx, spk_id in enumerate(unique_speakers): col = cols[idx % len(cols)] with col: default_voice = defaults[idx] if idx < len(defaults) else "Default" chosen = st.selectbox( f"Speaker {spk_id} voice", voice_names, index=voice_names.index(default_voice), key=f"voice_spk_{spk_id}", ) voice_map[spk_id] = VOICE_OPTIONS[chosen] if st.button("๐Ÿ’พ Save Voice Assignments", use_container_width=True): st.session_state.speaker_voice_map = voice_map st.success( "โœ… Voice assignments saved! " + " | ".join( f"Speaker {k} โ†’ {[n for n,v in VOICE_OPTIONS.items() if v==tld][0]}" for k, tld in voice_map.items() ) ) # STEP 4 โ€” Translate if st.session_state.segments: st.header("Step 4 โ€” Translate to Hindi") tab_auto, tab_machine = st.tabs([ "โœ๏ธ Write Hindi Yourself (Smart Assign)", "๐Ÿค– Auto Machine-Translate", ]) # โ”€โ”€ Tab A: Smart assign from user's own Hindi block โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ with tab_auto: st.markdown( "Write your full Hindi dubbing script in **one box** โ€” " "the app will split it and assign each line to the right speaker automatically." ) st.markdown( "**Supported separators:** newline ยท Hindi danda `เฅค` ยท `?` ยท `!`" ) # Show original lines for reference with st.expander("๐Ÿ“„ Show original transcribed lines for reference"): for seg in st.session_state.segments: st.caption( f"[{seg['start']:.1f}s โ†’ {seg['end']:.1f}s] " f"Speaker {seg['speaker']}: {seg['text']}" ) hindi_block = st.text_area( "Write full Hindi dubbing text here", height=250, placeholder=( "เค…เคฐเฅ‡ เคธเฅเคจเฅ‹, เคคเฅเคฎ เค•เคนเคพเค เคœเคพ เคฐเคนเฅ‡ เคนเฅ‹?\n" "เคฎเฅˆเค‚ เคฌเคพเคœเคพเคฐ เคœเคพ เคฐเคนเคพ เคนเฅ‚เคเฅค\n" "เค เฅ€เค• เคนเฅˆ, เคœเคฒเฅเคฆเฅ€ เค†เคจเคพเฅค" ), key="hindi_block_input", ) n_segs = len(st.session_state.segments) n_lines = len(_split_hindi_text(hindi_block)) if hindi_block.strip() else 0 # Live line-count feedback if hindi_block.strip(): if n_lines == n_segs: st.success(f"โœ… {n_lines} lines detected โ€” perfect match with {n_segs} segments!") elif n_lines > n_segs: st.warning( f"โš ๏ธ {n_lines} lines, {n_segs} segments โ€” " f"{n_lines - n_segs} extra line(s) will be merged automatically." ) else: st.warning( f"โš ๏ธ {n_lines} lines, {n_segs} segments โ€” " f"{n_segs - n_lines} missing line(s) will be split automatically." ) if st.button("๐ŸŽฏ Auto Assign to Speakers", use_container_width=True, disabled=not hindi_block.strip()): assigned = auto_assign_hindi_text( st.session_state.segments, hindi_block ) st.session_state.translated_segments = assigned st.success("โœ… Hindi text assigned to all speakers!") # Preview result st.markdown("#### Assignment preview") for seg in assigned: emotion = seg.get("emotion", "") st.markdown( f'
' f'Speaker {seg["speaker"]} ' f'{seg["start"]:.1f}s โ†’ {seg["end"]:.1f}s ' f'{emotion}
' f'{seg["hindi_text"]}' f'
', unsafe_allow_html=True, ) # โ”€โ”€ Tab B: Machine translation (original behaviour) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ with tab_machine: st.info( "Automatically translates each segment using Helsinki-NLP MarianMT. " "Free, offline, CPU-only." ) if st.button("๐ŸŒ Auto Translate All Segments", use_container_width=True): segs = st.session_state.segments bar = st.progress(0, "Loading translation modelโ€ฆ") out = [] for i, seg in enumerate(segs): bar.progress(int((i+1)/len(segs)*100), f"Translating {i+1}/{len(segs)}โ€ฆ") out.append({**seg, "hindi_text": translate_to_hindi( seg["text"], source_lang=seg.get("lang", "en") )}) gc.collect() st.session_state.translated_segments = out st.success("โœ… Translation done!") # STEP 5 โ€” Edit if st.session_state.translated_segments and any( s.get("hindi_text") for s in st.session_state.translated_segments ): st.header("Step 5 โ€” Review & Edit Translations") st.caption("Fix Hindi text before generating speech.") edited = [] for i, seg in enumerate(st.session_state.translated_segments): c1, c2 = st.columns(2) with c1: st.markdown( f'
' f'Speaker {seg.get("speaker","?")} ' f'{seg["start"]:.1f}sโ†’{seg["end"]:.1f}s
' f'Original: {seg["text"]}
', unsafe_allow_html=True, ) with c2: new = st.text_area( f"Hindi #{i+1}", value=seg.get("hindi_text",""), key=f"hi_{i}", height=100, label_visibility="collapsed", ) edited.append({**seg, "hindi_text": new}) if st.button("๐Ÿ’พ Save Edits", use_container_width=True): st.session_state.translated_segments = edited st.success("โœ… Saved!") # STEP 6 โ€” TTS st.header("Step 6 โ€” Generate Hindi Speech") if st.button("๐ŸŽค Generate Hindi Audio", use_container_width=True): bar = st.progress(0, "Startingโ€ฆ") dpth = generate_hindi_speech( st.session_state.translated_segments, speaker="com", # global fallback tld speaker_voice_map=st.session_state.speaker_voice_map, speech_volume=speech_volume, progress_cb=lambda p, t: bar.progress(p, t), ) bar.progress(100, "Done!") st.session_state.dubbed_audio_path = dpth st.audio(dpth) st.success("โœ… Hindi audio ready!") # STEP 7 โ€” Merge if st.session_state.dubbed_audio_path: st.header("Step 7 โ€” Merge & Download") if st.button("๐ŸŽฌ Create Dubbed Video", use_container_width=True): with st.spinner("Mergingโ€ฆ"): out_path = merge_audio_video( video_path=video_path, dubbed_audio_path=st.session_state.dubbed_audio_path, bgm_volume=bgm_volume, ) st.session_state.output_video_path = out_path st.video(out_path) st.success("๐ŸŽ‰ Done!") with open(out_path, "rb") as f: st.download_button( "โฌ‡๏ธ Download Hindi Dubbed Video", data=f, file_name="hindi_dubbed.mp4", mime="video/mp4", use_container_width=True, ) else: st.markdown(""" --- ### How it works | Step | What happens | |------|-------------| | 1๏ธโƒฃ Upload | Any MP4 / AVI / MKV video | | 2๏ธโƒฃ Extract | ffmpeg pulls the audio track | | 3๏ธโƒฃ Transcribe | Whisper converts speech โ†’ text with timestamps | | 4๏ธโƒฃ Translate | MarianMT translates โ†’ natural Hindi | | 5๏ธโƒฃ Edit | Fix any translation manually | | 6๏ธโƒฃ Speak | gTTS generates Hindi audio per segment | | 7๏ธโƒฃ Merge | ffmpeg mixes Hindi audio back into video | > โš ๏ธ On CPU: transcription takes ~1โ€“3 min per minute of video. Grab a chai โ˜• """)