Download dub.py from khantbhko/dublab: direct link, hf CLI and curl.
- Browser
- Download file 18.6 kB
-
https://huggingface.co/khantbhko/dublab/resolve/main/dub.py
- Command line
-
hf download hf://khantbhko/dublab/dub.py
-
curl -L -o dub.py https://huggingface.co/khantbhko/dublab/resolve/main/dub.py
18.6 kB
| """Dubbing pipeline: extract audio -> transcribe -> translate -> TTS -> mux.""" | |
| import os | |
| import subprocess | |
| from pathlib import Path | |
| MAX_ATEMPO = 1.65 # never speed speech up more than this (1.5->1.8 on 2026-10-26 to fix ~1.7s drift, then 1.8->1.65 same night: 1.8 sounded too fast; 1.65 fits every ratio measured on the Oct 25 test video, 1.61-1.67, with ~0.04s total drift) | |
| MAX_VSTRETCH = 1.40 # one scene window may be slowed at most this much so its speech can stay natural (2026-11-07, his idea: stretch only the part that would drift, not the whole video) | |
| def run(cmd): | |
| p = subprocess.run(cmd, capture_output=True, text=True) | |
| if p.returncode != 0: | |
| raise RuntimeError((p.stderr or "ffmpeg failed").strip()[-400:]) | |
| return p | |
| def probe_duration(path): | |
| p = run(["ffprobe", "-v", "error", "-show_entries", "format=duration", | |
| "-of", "default=noprint_wrappers=1:nokey=1", str(path)]) | |
| return float(p.stdout.strip()) | |
| def probe_size(path): | |
| p = run(["ffprobe", "-v", "error", "-select_streams", "v:0", | |
| "-show_entries", "stream=width,height", | |
| "-of", "csv=p=0", str(path)]) | |
| w, h = p.stdout.strip().split(",")[:2] | |
| return int(w), int(h) | |
| def blur_region(src, dst, box): | |
| """Blur one rectangle of the picture for the whole video. | |
| Used to hide subtitles burned into the source video: the user marks | |
| the subtitle area once (box = x, y, w, h in pixels) and that area is | |
| gaussian-blurred on every frame. Audio is re-encoded so the output | |
| is a clean mp4 the rest of the pipeline can treat as the source. | |
| """ | |
| x, y, w, h = box | |
| fc = (f"[0:v]split[a][b];[b]crop={w}:{h}:{x}:{y},gblur=sigma=28[bb];" | |
| f"[a][bb]overlay={x}:{y}[v]") | |
| run(["ffmpeg", "-y", "-v", "error", "-i", str(src), | |
| "-filter_complex", fc, "-map", "[v]", "-map", "0:a?", | |
| "-c:v", "libx264", "-preset", "veryfast", "-crf", "18", | |
| "-c:a", "aac", "-b:a", "160k", str(dst)]) | |
| def has_video_stream(path): | |
| p = run(["ffprobe", "-v", "error", "-select_streams", "v:0", | |
| "-show_entries", "stream=codec_type", "-of", "csv=p=0", str(path)]) | |
| return "video" in p.stdout | |
| def has_audio_stream(path): | |
| p = run(["ffprobe", "-v", "error", "-select_streams", "a:0", | |
| "-show_entries", "stream=codec_type", "-of", "csv=p=0", str(path)]) | |
| return "audio" in p.stdout | |
| def extract_audio(src, dst): | |
| run(["ffmpeg", "-y", "-v", "error", "-i", str(src), | |
| "-vn", "-ac", "1", "-ar", "16000", str(dst)]) | |
| from gemini_tts import QuotaExhausted | |
| from gemini_tts import configure as _tts_configure | |
| from gemini_tts import synthesize as _gemini_tts | |
| from nara_tts import NARA_VOICES, NARA_VOICE_FOR | |
| # Edge-TTS voice name -> Gemini prebuilt voice (male/female pairs) | |
| GEMINI_VOICE_FOR = { "my-MM-NilarNeural": "Aoede", # female Burmese | |
| "my-MM-ThihaNeural": "Charon", # male Burmese | |
| "en-US-AvaNeural": "Aoede", # female English | |
| "en-US-AndrewNeural": "Charon", # male English | |
| } | |
| # When Edge-TTS recovers, the recovery watcher touches this flag file and the | |
| # TTS order flips back to Edge-first automatically (no redeploy needed). | |
| EDGE_PRIMARY_FLAG = Path(__file__).parent / ".edge_primary" | |
| def _edge_first(): | |
| try: | |
| return EDGE_PRIMARY_FLAG.exists() | |
| except OSError: | |
| return False | |
| # Per-job voice engine choice from the UI ('edge' | 'gemini' | None). | |
| # None keeps the standing flag order. app.py sets this at job start; | |
| # the worker queue is serial, so a module-level setting is safe. | |
| _ENGINE = None | |
| # Per-job Nara voice choice from the UI ('shoon' | 'thazin' | | |
| # 'ko-banyar' | 'htet-myat'). None keeps the gender map below | |
| # (female -> shoon, male -> ko-banyar). Same serial-queue rule as _ENGINE. | |
| _NARA_VOICE = None | |
| def set_nara_voice(name): | |
| global _NARA_VOICE | |
| _NARA_VOICE = name if name in NARA_VOICES else None | |
| def valid_nara_voice(name): | |
| """Return a valid Nara voice id, defaulting to shoon.""" | |
| return name if name in NARA_VOICES else "shoon" | |
| def set_engine(name): | |
| global _ENGINE | |
| _ENGINE = name if name in ("edge", "gemini", "nara") else None | |
| def configure_tts(keys): | |
| """Configure Gemini TTS key rotation. Call once at startup.""" | |
| _tts_configure(keys) | |
| _model = None | |
| def transcribe(wav, lang_hint): | |
| """Return [{start, end, text}]. lang_hint: 'en' or 'my'.""" | |
| global _model | |
| from faster_whisper import WhisperModel | |
| if _model is None: | |
| _model = WhisperModel("base", device="cpu", compute_type="int8") | |
| segments, _info = _model.transcribe( | |
| str(wav), language=lang_hint, vad_filter=True, | |
| vad_parameters=dict(min_silence_duration_ms=400)) | |
| return [{"start": float(s.start), "end": float(s.end), "text": s.text.strip()} | |
| for s in segments if s.text and s.text.strip()] | |
| def merge_segments(segs, max_gap=0.6, max_len=10.0): | |
| """Merge close whisper segments into speakable utterances.""" | |
| merged = [] | |
| for s in segs: | |
| if (merged and s["start"] - merged[-1]["end"] <= max_gap | |
| and s["end"] - merged[-1]["start"] <= max_len): | |
| merged[-1]["end"] = s["end"] | |
| merged[-1]["text"] = merged[-1]["text"] + " " + s["text"] | |
| else: | |
| merged.append(dict(s)) | |
| return merged | |
| def _synth_gemini(text, voice, lang, out_path): | |
| _gemini_tts(text, GEMINI_VOICE_FOR.get(voice, "Aoede"), lang, out_path) | |
| def _synth_edge(text, voice, out_path): | |
| import asyncio | |
| import edge_tts | |
| import edge_tts.communicate as _comm | |
| import edge_tts.constants as _C | |
| # 2026-10: Microsoft's classic speech.platform.bing.com endpoint answers | |
| # 403 in many regions. Use the newer api.msedgeservices.com endpoint for | |
| # the same voices (token as Ocp-Apim-Subscription-Key, not | |
| # TrustedClientToken). Patched at call time so library updates can't | |
| # silently revert it. | |
| _new_wss = ( | |
| "wss://api.msedgeservices.com/tts/cognitiveservices/websocket/v1" | |
| f"?Ocp-Apim-Subscription-Key={_C.TRUSTED_CLIENT_TOKEN}" | |
| ) | |
| _comm.WSS_URL = _new_wss | |
| _C.WSS_URL = _new_wss | |
| async def _go(): | |
| await edge_tts.Communicate(text, voice).save(str(out_path)) | |
| asyncio.run(_go()) | |
| def _synth_nara(text, voice, out_path): | |
| from nara_tts import synthesize as _nara_synthesize | |
| out_path = Path(out_path) | |
| wav = out_path.with_suffix(".wav") | |
| voice_id = _NARA_VOICE or NARA_VOICE_FOR.get(voice, "shoon") | |
| _nara_synthesize(text, voice_id, wav) | |
| # build_dub_track probes u{i}.mp3 with ffprobe and later runs atempo | |
| # through ffmpeg, so hand it a real mp3, not a renamed wav. | |
| run(["ffmpeg", "-y", "-v", "error", "-i", str(wav), str(out_path)]) | |
| wav.unlink(missing_ok=True) | |
| def synthesize(text, voice, out_path): | |
| """TTS one utterance. | |
| Order: the per-job engine chosen in the UI goes first (set_engine); | |
| with no choice, Nara first (the standing default since 2026-11-13), | |
| then Edge, then Gemini. The other engines are the fallback, then gTTS | |
| (last resort, robotic single voice). Nara first falls back to Edge, | |
| then Gemini, so a local-model failure never kills a job outright. | |
| """ | |
| lang = "my" if voice.startswith("my-MM") else "en" | |
| err = None | |
| if _ENGINE == "nara": | |
| order = ("nara", "edge", "gemini") | |
| elif _ENGINE in ("edge", "gemini"): | |
| order = (_ENGINE, "gemini" if _ENGINE == "edge" else "edge") | |
| else: | |
| order = ("nara", "edge", "gemini") | |
| for which in order: | |
| try: | |
| if which == "edge": | |
| _synth_edge(text, voice, out_path) | |
| elif which == "nara": | |
| _synth_nara(text, voice, out_path) | |
| else: | |
| _synth_gemini(text, voice, lang, out_path) | |
| return | |
| except QuotaExhausted: | |
| # Don't silently fall through on quota - the user should know to wait. | |
| # Edge-TTS/gTTS can't cover a sustained quota outage anyway. | |
| raise | |
| except Exception as e: # noqa: BLE001 | |
| err = e | |
| print(f"[tts] {which} failed ({type(e).__name__}), trying next") | |
| # fallback: gTTS (free, single voice per language — no male/female choice) | |
| try: | |
| from gtts import gTTS | |
| gTTS(text, lang=lang).save(str(out_path)) | |
| print(f"[tts] used gTTS fallback (previous: {type(err).__name__})") | |
| except Exception: | |
| raise err | |
| def build_dub_track(utterances, voice, tmpdir, total_dur, progress=None): | |
| """TTS each utterance, time-fit, mix into one dub track. | |
| Returns (dub_wav_path, warp, cues) where warp is a list of | |
| (start, end, factor) video segments for mux(): segments with | |
| factor > 1 are slowed down in the picture. cues is the placed | |
| utterances on the OUTPUT timeline ({start, end, text}) — the | |
| subtitle writers use it so burned-in text follows the dubbed speech | |
| exactly, stretch and cascade included. | |
| Finish-the-sentence rule (2026-10-25, user order): an utterance is | |
| NEVER trimmed; it plays to completion and the next one cascades after | |
| it, so no sentence is ever cut off mid-word. | |
| Local-stretch rule (2026-11-07, user order): speech stays at natural | |
| speed wherever it can. Each utterance gets the window up to the next | |
| utterance's start (so it can use the natural pause after it). If the | |
| natural speech is longer than its window, ONLY that window of the | |
| picture is slowed down — by the ratio it needs, capped at | |
| MAX_VSTRETCH — instead of rushing the voice. Windows that fit are | |
| untouched and play at normal speed. If one line is so long that even | |
| the stretch cap is not enough, that line alone is sped up (atempo, | |
| capped at MAX_ATEMPO) and, in the extreme, cascades like before. | |
| """ | |
| tmpdir = Path(tmpdir) | |
| # 1) synthesize everything at natural speed and measure it | |
| raw = [] | |
| for i, u in enumerate(utterances): | |
| mp3 = tmpdir / f"u{i}.mp3" | |
| synthesize(u["text"], voice, mp3) | |
| raw.append(probe_duration(mp3)) | |
| if progress: | |
| progress(i + 1, len(utterances)) | |
| # 2) per-window fit: stretch factor f and speech speed a per utterance | |
| n = len(utterances) | |
| warp = [] # (start, end, factor) covering the whole timeline | |
| starts = [] # utterance start times on the warped timeline | |
| speeds = [] # atempo per utterance (1.0 = natural) | |
| t_new = 0.0 # cursor on the warped timeline | |
| if utterances and utterances[0]["start"] > 0: | |
| warp.append((0.0, utterances[0]["start"], 1.0)) | |
| t_new = utterances[0]["start"] | |
| for i, u in enumerate(utterances): | |
| win_end = utterances[i + 1]["start"] if i + 1 < n else total_dur | |
| window = max(win_end - u["start"], 0.5) | |
| need = raw[i] / window | |
| f = min(need, MAX_VSTRETCH) if need > 1.0 else 1.0 | |
| a = need / f if need > 1.0 else 1.0 | |
| warp.append((u["start"], win_end, f)) | |
| starts.append(t_new) | |
| speeds.append(min(a, MAX_ATEMPO)) | |
| t_new += window * f | |
| out_dur = t_new | |
| # 3) render the placed segments and mix them. Placement cascades: a | |
| # line never starts before the previous one has finished. | |
| placed = [] | |
| cues = [] | |
| cursor = 0.0 | |
| for i, start in enumerate(starts): | |
| start = max(start, cursor) | |
| fitted = raw[i] / speeds[i] | |
| cursor = start + fitted | |
| cues.append({"start": start, "end": cursor, | |
| "text": utterances[i]["text"]}) | |
| out_dur = max(out_dur, cursor) | |
| mp3 = tmpdir / f"u{i}.mp3" | |
| start_ms = int(round(start * 1000)) | |
| wav_i = tmpdir / f"p{i}.wav" | |
| af = [] | |
| if speeds[i] > 1.001: | |
| af.append(f"atempo={speeds[i]:.3f}") | |
| af.append(f"adelay={start_ms}|{start_ms}") | |
| run(["ffmpeg", "-y", "-v", "error", "-i", str(mp3), | |
| "-af", ",".join(af), "-ar", "44100", "-ac", "2", str(wav_i)]) | |
| placed.append(wav_i) | |
| dub = tmpdir / "dub.wav" | |
| inputs = [] | |
| for w in placed: | |
| inputs += ["-i", str(w)] | |
| run(["ffmpeg", "-y", "-v", "error", *inputs, | |
| "-filter_complex", | |
| f"amix=inputs={len(placed)}:dropout_transition=0:normalize=0," | |
| f"apad=whole_dur={out_dur:.2f}", | |
| "-ar", "44100", "-ac", "2", str(dub)]) | |
| return dub, warp, cues | |
| # ---- subtitles (burn-in ASS + downloadable SRT) ---- | |
| def _ts_ass(t): | |
| t = max(0.0, t) | |
| cs = int(round(t * 100)) | |
| return (f"{cs // 360000}:{(cs // 6000) % 60:02d}:" | |
| f"{(cs // 100) % 60:02d}.{cs % 100:02d}") | |
| def _ts_srt(t): | |
| t = max(0.0, t) | |
| ms = int(round(t * 1000)) | |
| return (f"{ms // 3600000:02d}:{(ms // 60000) % 60:02d}:" | |
| f"{(ms // 1000) % 60:02d},{ms % 1000:03d}") | |
| def _clean_cues(cues): | |
| """Sort, drop empties, and keep cues from overlapping each other.""" | |
| out = [c for c in sorted(cues, key=lambda c: c["start"]) | |
| if c["text"] and c["text"].strip()] | |
| for i, c in enumerate(out): | |
| c["end"] = max(c["end"], c["start"] + 0.4) | |
| if i + 1 < len(out) and c["end"] > out[i + 1]["start"]: | |
| c["end"] = max(out[i + 1]["start"], c["start"] + 0.2) | |
| return out | |
| def write_ass(cues, path, w, h, bg="box"): | |
| """Write an ASS file for libass burn-in. | |
| bg='box' draws a semi-transparent black box behind the text (stays | |
| readable over any picture); bg='none' is plain text with an outline | |
| and shadow. Font size and margins scale with the frame height so | |
| portrait phone videos and landscape clips both look right. | |
| """ | |
| cues = _clean_cues(cues) | |
| fs = max(30, round(h * 0.046)) | |
| mv = round(h * 0.028) | |
| ml = round(w * 0.04) | |
| if bg == "box": | |
| style = (f"Style: Dub, Noto Sans Myanmar,{fs},&H00FFFFFF," | |
| f"&H000000FF,&H00000000,&H80141414,0,0,0,0,100,100,0,0," | |
| f"3,{max(6, round(fs * 0.22))},0,2,{ml},{ml},{mv},1") | |
| else: | |
| style = (f"Style: Dub, Noto Sans Myanmar,{fs},&H00FFFFFF," | |
| f"&H000000FF,&H00000000,&H7D000000,0,0,0,0,100,100,0,0," | |
| f"1,{max(2, round(fs * 0.09))},1,2,{ml},{ml},{mv},1") | |
| lines = ["[Script Info]", "ScriptType: v4.00+", f"PlayResX: {w}", | |
| f"PlayResY: {h}", "WrapStyle: 0", "ScaledBorderAndShadow: yes", | |
| "", "[V4+ Styles]", | |
| ("Format: Name, Fontname, Fontsize, PrimaryColour, " | |
| "SecondaryColour, OutlineColour, BackColour, Bold, Italic, " | |
| "Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, " | |
| "BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, " | |
| "MarginV, Encoding"), | |
| style, "", "[Events]", | |
| "Format: Layer, Start, End, Style, Name, MarginL, MarginR, " | |
| "MarginV, Effect, Text"] | |
| for c in cues: | |
| # ASS control chars would break the line; Burmese text rarely has | |
| # them, but strip rather than risk a corrupt subtitle file | |
| text = (c["text"].replace("\\", "").replace("{", "").replace("}", "") | |
| .replace("\r", " ").replace("\n", " ")) | |
| lines.append(f"Dialogue: 0,{_ts_ass(c['start'])},{_ts_ass(c['end'])}," | |
| f"Dub,,0,0,0,,{text}") | |
| Path(path).write_text("\n".join(lines) + "\n", encoding="utf-8") | |
| def write_srt(cues, path): | |
| """Write the same cues as a plain .srt for download.""" | |
| cues = _clean_cues(cues) | |
| lines = [] | |
| for i, c in enumerate(cues, 1): | |
| lines.append(str(i)) | |
| lines.append(f"{_ts_srt(c['start'])} --> {_ts_srt(c['end'])}") | |
| lines.append(c["text"].replace("\r", " ").strip()) | |
| lines.append("") | |
| Path(path).write_text("\n".join(lines), encoding="utf-8") | |
| def cleanup_segments(job_dir): | |
| """Drop the per-utterance TTS intermediates once the mux is done. | |
| build_dub_track leaves u*.mp3 (raw TTS) and p*.wav (time-fitted | |
| segments) in the job dir; on a 3-minute video that is ~1GB of files | |
| that nothing serves afterwards — /r/<jid> only hands out dubbed.mp4. | |
| Set DUBLAB_KEEP_SEGMENTS=1 to keep them for debugging. | |
| """ | |
| if os.environ.get("DUBLAB_KEEP_SEGMENTS"): | |
| return 0 | |
| d = Path(job_dir) | |
| removed = 0 | |
| for pat in ("u*.mp3", "p*.wav"): | |
| for f in d.glob(pat): | |
| try: | |
| f.unlink() | |
| removed += 1 | |
| except OSError: | |
| pass | |
| return removed | |
| WATERMARK_TEXT = "made with dublab.kbko.qzz.io" | |
| _WM_FONT = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" | |
| def mux(src_video, dub_wav, out_mp4, warp=None, ass_path=None): | |
| # Burned-in credit watermark, bottom-right (always on): re-encode video | |
| # with drawtext; scale guard keeps even dims for yuv420p. | |
| wm = (f"scale=trunc(iw/2)*2:trunc(ih/2)*2," | |
| f"drawtext=fontfile={_WM_FONT}:text='{WATERMARK_TEXT}':" | |
| "fontsize=h/40:fontcolor=white@0.72:" | |
| "shadowcolor=black@0.55:shadowx=1:shadowy=1:" | |
| "x=w-text_w-h/40:y=h-text_h-h/56," | |
| "format=yuv420p") | |
| if ass_path: | |
| # Speech-timed subtitles go on LAST, after the stretch join, so | |
| # the cue times (written for the output timeline) line up, and | |
| # above the watermark layer. The path is app-generated under the | |
| # job dir and contains no filter-special characters. | |
| wm += f",ass={ass_path}" | |
| tail = ["-c:v", "libx264", "-preset", "veryfast", "-crf", "20", | |
| "-map", "0:v:0", "-map", "1:a:0", | |
| "-c:a", "aac", "-b:a", "128k", | |
| # No -shortest: the finish-the-sentence rule lets the tail of | |
| # the last utterance run a little past the video end; | |
| # -shortest would cut it off again. | |
| "-movflags", "+faststart", str(out_mp4)] | |
| stretched = [w for w in (warp or []) if w[2] > 1.001] | |
| if not stretched: | |
| run(["ffmpeg", "-y", "-v", "error", "-i", str(src_video), | |
| "-i", str(dub_wav), "-vf", wm, *tail]) | |
| return | |
| # Local stretch: cut the picture at the warp boundaries, slow only | |
| # the segments whose speech needed more time, then join them back. | |
| segs = warp | |
| n = len(segs) | |
| fc = ["[0:v]split=" + str(n) + "".join(f"[s{i}]" for i in range(n))] | |
| for i, (a, b, f) in enumerate(segs): | |
| fc.append(f"[s{i}]trim=start={a:.3f}:end={b:.3f}," | |
| f"setpts=(PTS-STARTPTS)*{f:.4f},settb=AVTB[v{i}]") | |
| fc.append("".join(f"[v{i}]" for i in range(n)) | |
| + f"concat=n={n}:v=1:a=0[vcat]") | |
| fc.append(f"[vcat]{wm}[vout]") | |
| tail[tail.index("0:v:0")] = "[vout]" | |
| run(["ffmpeg", "-y", "-v", "error", "-i", str(src_video), | |
| "-i", str(dub_wav), "-filter_complex", ";".join(fc), *tail]) | |