""" ๐ฌ 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'