"""LeapTalk — real-time audio-driven talking-head generation on ZeroGPU. Faithful port of the official reference implementation (https://github.com/zhangrongxiang/LeapTalk, `inference.py` streaming path): SoulX-FlashHead-1_3B (Model_Pro) + LeapTalk LoRA (merged) + LeapTalk audio projector + wav2vec2-base-960h audio encoder + Lite TAE (taew2_1) VAE + ViBT Brownian-bridge scheduler Everything (chunking, audio windowing, bridge sampling, motion-frame round-trip, colour correction) mirrors the authors' `--lite` / `--model_type pro` / `--audio_encode_mode stream` defaults from `inf.sh`. """ import os os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") import spaces # noqa: E402 — must precede any torch / CUDA-touching import import math # noqa: E402 import shutil # noqa: E402 import subprocess # noqa: E402 import sys # noqa: E402 import tempfile # noqa: E402 import time # noqa: E402 import wave # noqa: E402 from collections import deque # noqa: E402 import gradio as gr # noqa: E402 import imageio # noqa: E402 import librosa # noqa: E402 import numpy as np # noqa: E402 import torch # noqa: E402 from huggingface_hub import hf_hub_download, snapshot_download # noqa: E402 from loguru import logger # noqa: E402 from peft import PeftModel # noqa: E402 sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import flash_head.src.pipeline.flash_head_pipeline as fh_pipe_mod # noqa: E402 # torch.compile is disabled: the released LoRA was not saved from a compiled base # (`--compile off` in the reference `inf.sh`), and TAEHV uses Python-level loops. fh_pipe_mod.COMPILE_MODEL = False fh_pipe_mod.COMPILE_VAE = False from flash_head.src.pipeline.flash_head_pipeline import FlashHeadPipeline # noqa: E402 from leaptalk_inference import ( # noqa: E402 StreamParams, _audio_context_from_embeddings_range, _bridge_sample_one_chunk, _build_infer_timesteps, _decode_to_cthw, _encode_motion_prefix_from_decoded, _get_inner_flashhead_model, _maybe_apply_color_correction, ) from vibt.scheduler import ViBTScheduler # noqa: E402 # -------------------------------------------------------------------------------------- # Fixed inference configuration (reference defaults) # -------------------------------------------------------------------------------------- DEVICE = "cuda" DTYPE = torch.bfloat16 HEIGHT = WIDTH = 512 FPS = 25 SAMPLE_RATE = 16000 FRAME_NUM = 33 MOTION_FRAMES_LATENT_NUM = 2 CACHED_AUDIO_DURATION = 8 SHIFT_GAMMA = 5.0 NOISE_SCALE = 1.0 COLOR_CORRECTION_STRENGTH = 1.0 MAX_SECONDS_CAP = 20 # -------------------------------------------------------------------------------------- # Weights # -------------------------------------------------------------------------------------- logger.info("Downloading weights…") CKPT_DIR = snapshot_download( "Soul-AILab/SoulX-FlashHead-1_3B", allow_patterns=["Model_Pro/*"] ) WAV2VEC_DIR = snapshot_download( "facebook/wav2vec2-base-960h", allow_patterns=["*.json", "*.txt", "*.safetensors", "pytorch_model.bin"], ) LEAPTALK_DIR = snapshot_download("z-rx/leaptalk") LORA_DIR = os.path.join(LEAPTALK_DIR, "lora") TAE_PATH = os.path.join(LEAPTALK_DIR, "taew2_1.pth") AUDIO_PROJ_PATH = os.path.join(LEAPTALK_DIR, "audio_proj_step_10400.pt") # -------------------------------------------------------------------------------------- # Pipeline assembly (module scope, eagerly moved to CUDA) # -------------------------------------------------------------------------------------- logger.info("Building FlashHead pipeline…") # Built on CPU first so the LoRA merge / projector load happen on real tensors, # then the whole stack is moved to CUDA eagerly (ZeroGPU packs it from there). pipeline = FlashHeadPipeline( checkpoint_dir=CKPT_DIR, model_type="pro", wav2vec_dir=WAV2VEC_DIR, device="cpu", param_dtype=DTYPE, use_usp=False, use_tae=True, tae_path=TAE_PATH, tae_model_type="wan21", ) logger.info("Merging LeapTalk LoRA…") # `torch_device="cpu"` is required: PEFT otherwise infers "cuda" (ZeroGPU reports a GPU # as available at import time) and safetensors' loader bypasses the ZeroGPU patching. pipeline.model = PeftModel.from_pretrained( pipeline.model, LORA_DIR, is_trainable=False, torch_device="cpu" ) pipeline.model = pipeline.model.merge_and_unload() pipeline.model.eval().requires_grad_(False) logger.info("Loading LeapTalk audio projector…") _audio_proj_state = torch.load(AUDIO_PROJ_PATH, map_location="cpu", weights_only=True) _get_inner_flashhead_model(pipeline.model).audio_proj.load_state_dict( _audio_proj_state, strict=True ) del _audio_proj_state pipeline.device = DEVICE pipeline.model.to(DEVICE) pipeline.vae.device = DEVICE pipeline.vae.model.to(DEVICE) pipeline.audio_encoder.to(DEVICE) pipeline.audio_encoder.eval().requires_grad_(False) STREAM = StreamParams( frame_num=FRAME_NUM, motion_frames_latent_num=MOTION_FRAMES_LATENT_NUM, tgt_fps=FPS, sample_rate=SAMPLE_RATE, cached_audio_duration=CACHED_AUDIO_DURATION, ).init_with_stride(int(pipeline.config.vae_stride[0])) SLICE_SAMPLES = STREAM.slice_len * SAMPLE_RATE // FPS logger.info( f"Ready. frame_num={STREAM.frame_num} motion_frames={STREAM.motion_frames_num} " f"slice_len={STREAM.slice_len} ({SLICE_SAMPLES} samples/chunk)" ) # -------------------------------------------------------------------------------------- # Video helpers # -------------------------------------------------------------------------------------- def _ffmpeg_exe() -> str: exe = shutil.which("ffmpeg") if exe: return exe import imageio_ffmpeg return imageio_ffmpeg.get_ffmpeg_exe() def _write_wav(path: str, audio: np.ndarray, sample_rate: int = SAMPLE_RATE) -> str: pcm = (np.clip(audio, -1.0, 1.0) * 32767.0).astype(np.int16) with wave.open(path, "wb") as wf: wf.setnchannels(1) wf.setsampwidth(2) wf.setframerate(sample_rate) wf.writeframes(pcm.tobytes()) return path def _mux(video_path: str, audio_path: str, out_path: str) -> str: cmd = [ _ffmpeg_exe(), "-y", "-i", video_path, "-i", audio_path, "-c:v", "copy", "-c:a", "aac", "-b:a", "128k", "-shortest", "-movflags", "+faststart", out_path, ] proc = subprocess.run(cmd, capture_output=True) if proc.returncode != 0 or not os.path.exists(out_path): logger.warning(f"ffmpeg mux failed: {proc.stderr.decode()[-800:]}") shutil.copy(video_path, out_path) return out_path def _num_chunks_for(seconds: float) -> int: samples = max(int(seconds * SAMPLE_RATE), FRAME_NUM * SAMPLE_RATE // FPS) return max(1, math.ceil(samples / SLICE_SAMPLES)) def _estimate_duration( portrait_image=None, speech_audio=None, max_seconds: float = 9.0, num_inference_steps: int = 1, guidance_scale: float = 1.0, *args, **kwargs, ) -> int: """ZeroGPU time budget: weight streaming + per-chunk cost.""" try: chunks = _num_chunks_for(float(max_seconds)) nfe = max(1, int(num_inference_steps)) * (2 if float(guidance_scale) != 1.0 else 1) except Exception: chunks, nfe = _num_chunks_for(MAX_SECONDS_CAP), 1 # Measured on ZeroGPU (A100): ~0.54 s/chunk at 1 NFE, ~1.5 s of fixed setup + # video encode/mux, plus weight streaming on a cold worker. Kept deliberately tight # so the demo does not over-reserve visitors' quota. return int(min(90, 12 + chunks * (0.35 + 0.35 * nfe))) # -------------------------------------------------------------------------------------- # Inference # -------------------------------------------------------------------------------------- @spaces.GPU(duration=_estimate_duration) def generate( portrait_image: str, speech_audio: str, max_seconds: float = 9.0, num_inference_steps: int = 1, guidance_scale: float = 1.0, seed: int = 42, auto_crop_face: bool = True, progress=gr.Progress(track_tqdm=True), ): """Animate a portrait photo so that it speaks the given audio. Args: portrait_image: Path to a portrait photo (a single, roughly front-facing face). speech_audio: Path to a speech audio file that drives lip and head motion. max_seconds: Maximum number of seconds of the audio to animate. num_inference_steps: Bridge-sampler steps per chunk. LeapTalk is distilled for 1. guidance_scale: Audio classifier-free guidance. 1.0 disables it (2x faster). seed: Random seed for the Brownian-bridge noise. auto_crop_face: Detect and crop around the face before generating. Returns: A tuple of (path to the generated talking-head mp4, a short speed report). """ if not portrait_image: raise gr.Error("Please provide a portrait image.") if not speech_audio: raise gr.Error("Please provide a speech audio file.") num_inference_steps = max(1, int(num_inference_steps)) guidance_scale = float(guidance_scale) seed = int(seed) max_seconds = float(np.clip(max_seconds, 1.0, MAX_SECONDS_CAP)) workdir = tempfile.mkdtemp(prefix="leaptalk_") progress(0.02, desc="Preparing reference portrait…") # ---- reference image -> anchor latent X0 (same call as inference.py) -------------- pipeline.prepare_params( cond_image_path_or_dir=portrait_image, target_size=(HEIGHT, WIDTH), frame_num=STREAM.frame_num, motion_frames_num=0, sampling_steps=num_inference_steps, seed=seed, shift=SHIFT_GAMMA, color_correction_strength=COLOR_CORRECTION_STRENGTH, use_face_crop=bool(auto_crop_face), ) X0 = pipeline.ref_img_latent.to(device=DEVICE, dtype=DTYPE) # ---- scheduler ------------------------------------------------------------------- scheduler = ViBTScheduler(num_train_timesteps=1000) scheduler.timesteps = _build_infer_timesteps( step_list=None, num_inference_steps=num_inference_steps, shift_gamma=SHIFT_GAMMA, device=DEVICE, num_timesteps=1000, ) scheduler.num_inference_steps = int(scheduler.timesteps.numel()) scheduler.set_parameters(noise_scale=NOISE_SCALE, shift_gamma=SHIFT_GAMMA, seed=seed) # ---- audio (streaming ring buffer, exactly as inference.py --audio_encode_mode stream) progress(0.06, desc="Loading audio…") audio_all, _ = librosa.load(speech_audio, sr=SAMPLE_RATE, mono=True) audio_all = audio_all[: int(max_seconds * SAMPLE_RATE)] if audio_all.size == 0: raise gr.Error("The audio file appears to be empty.") frame_window_samples = STREAM.frame_num * SAMPLE_RATE // FPS remainder = len(audio_all) % SLICE_SAMPLES if remainder > 0: audio_all = np.concatenate( [audio_all, np.zeros(SLICE_SAMPLES - remainder, dtype=audio_all.dtype)] ) if len(audio_all) < frame_window_samples: audio_all = np.concatenate( [audio_all, np.zeros(frame_window_samples - len(audio_all), dtype=audio_all.dtype)] ) remainder = len(audio_all) % SLICE_SAMPLES if remainder != 0: audio_all = np.concatenate( [audio_all, np.zeros(SLICE_SAMPLES - remainder, dtype=audio_all.dtype)] ) slices = audio_all.reshape(-1, SLICE_SAMPLES) num_chunks = int(slices.shape[0]) cached_len = SAMPLE_RATE * STREAM.cached_audio_duration audio_end_idx = STREAM.cached_audio_duration * FPS audio_start_idx = audio_end_idx - STREAM.frame_num audio_dq = deque([0.0] * cached_len, maxlen=cached_len) latent_motion_frames = X0[:, :1].unsqueeze(0).clone() clamp_latent_len = int(latent_motion_frames.shape[2]) generated: list[np.ndarray] = [] gen_seconds = 0.0 gen_frames = 0 for chunk_idx in range(num_chunks): progress( 0.08 + 0.88 * chunk_idx / num_chunks, desc=f"Generating chunk {chunk_idx + 1}/{num_chunks}…", ) torch.cuda.synchronize() t0 = time.perf_counter() audio_dq.extend(slices[chunk_idx].tolist()) audio_cache = np.array(audio_dq, dtype=np.float32) audio_emb = pipeline.preprocess_audio(audio_cache, sr=SAMPLE_RATE, fps=FPS) if audio_emb is None: raise gr.Error("Failed to extract audio embeddings.") audio_emb = audio_emb.to(device=DEVICE, dtype=DTYPE) audio_ctx = _audio_context_from_embeddings_range( audio_emb, start_idx=audio_start_idx, end_idx=audio_end_idx, device=DEVICE, dtype=DTYPE, ) x_final = _bridge_sample_one_chunk( pipeline, scheduler=scheduler, ref_latent=X0, audio_context=audio_ctx, guidance_scale=guidance_scale, latent_motion_frames=latent_motion_frames, clamp_latent_len=clamp_latent_len, device=DEVICE, dtype=DTYPE, ) decoded_cthw = _decode_to_cthw(pipeline, x_final) decoded_cthw = _maybe_apply_color_correction(pipeline, decoded_cthw) # SoulX-style VAE round-trip history update (reference default) latent_motion_frames = _encode_motion_prefix_from_decoded( pipeline, decoded_video_cthw=decoded_cthw, motion_frames_num=STREAM.motion_frames_num, device=DEVICE, dtype=DTYPE, ).unsqueeze(0) clamp_latent_len = int(latent_motion_frames.shape[2]) decoded_cthw = decoded_cthw[:, STREAM.motion_frames_num:] video_thwc = ( ((decoded_cthw + 1.0) / 2.0) .permute(1, 2, 3, 0) .clamp(0.0, 1.0) .mul(255.0) .contiguous() ) torch.cuda.synchronize() chunk_seconds = time.perf_counter() - t0 frames_np = video_thwc.to(torch.float32).cpu().numpy().astype(np.uint8) generated.append(frames_np) gen_frames += int(frames_np.shape[0]) gen_seconds += chunk_seconds logger.info( f"chunk {chunk_idx + 1}/{num_chunks}: {chunk_seconds:.3f}s " f"({frames_np.shape[0] / max(chunk_seconds, 1e-6):.1f} FPS)" ) progress(0.97, desc="Encoding video…") silent_path = os.path.join(workdir, "silent.mp4") with imageio.get_writer( silent_path, format="mp4", mode="I", fps=FPS, codec="h264", pixelformat="yuv420p", ffmpeg_params=["-bf", "0"], ) as writer: for frames_np in generated: for frame in frames_np: writer.append_data(frame) wav_path = _write_wav(os.path.join(workdir, "track.wav"), audio_all) out_path = _mux(silent_path, wav_path, os.path.join(workdir, "leaptalk.mp4")) video_seconds = gen_frames / FPS report = ( f"**{gen_frames} frames** ({video_seconds:.1f}s of video) in " f"**{gen_seconds:.2f}s** of GPU time — " f"**{gen_frames / max(gen_seconds, 1e-6):.1f} FPS** generation throughput " f"({gen_frames / max(gen_seconds, 1e-6) / FPS:.2f}× real time) over " f"{num_chunks} streaming chunks at {num_inference_steps} step" f"{'s' if num_inference_steps > 1 else ''}/chunk." ) return out_path, report # -------------------------------------------------------------------------------------- # UI # -------------------------------------------------------------------------------------- CSS = """ #col-container { margin: 0 auto; max-width: 1180px; } .dark .gradio-container { color: var(--body-text-color); } """ with gr.Blocks(title="LeapTalk") as demo: with gr.Column(elem_id="col-container"): gr.Markdown( """ # 🗣️ LeapTalk — real-time talking heads Animate a **portrait photo** with a **speech clip**. LeapTalk reformulates talking-head generation as a Brownian-bridge transport (*Bridge Forcing*), which lets it synthesize each video chunk in a **single sampling step** while keeping identity stable over long rollouts. [Model](https://huggingface.co/z-rx/leaptalk) · [Paper](https://huggingface.co/papers/2608.00079) · [Project page](https://zhangrongxiang.github.io/leaptalk-page/) · [Code](https://github.com/zhangrongxiang/LeapTalk) · built on [SoulX-FlashHead-1.3B](https://huggingface.co/Soul-AILab/SoulX-FlashHead-1_3B) """ ) with gr.Row(): with gr.Column(): portrait_image = gr.Image( label="Portrait", type="filepath", height=320, sources=["upload", "webcam", "clipboard"] ) speech_audio = gr.Audio( label="Speech audio", type="filepath", sources=["upload", "microphone"] ) run_btn = gr.Button("Generate talking head", variant="primary") with gr.Column(): video_out = gr.Video( label="Result", height=460, autoplay=True ) report_out = gr.Markdown() with gr.Accordion("Advanced options", open=False): with gr.Row(): max_seconds = gr.Slider( label="Max audio length (seconds)", minimum=1, maximum=MAX_SECONDS_CAP, step=1, value=9, ) num_inference_steps = gr.Slider( label="Sampling steps per chunk", minimum=1, maximum=4, step=1, value=1, info="LeapTalk is distilled for 1-step (1 NFE) generation.", ) with gr.Row(): guidance_scale = gr.Slider( label="Audio guidance scale", minimum=1.0, maximum=3.0, step=0.1, value=1.0, info="1.0 disables audio CFG; higher strengthens lip motion but doubles compute.", ) seed = gr.Number(label="Seed", value=42, precision=0) auto_crop_face = gr.Checkbox( label="Auto-crop to face", value=True, info="Detects the face and crops around it; falls back to a centre crop.", ) gr.Examples( examples=[ ["examples/portrait.jpg", "examples/narration.wav"], ["examples/girl.png", "examples/podcast_sichuan.wav"], ], inputs=[portrait_image, speech_audio], outputs=[video_out, report_out], fn=generate, cache_examples=True, cache_mode="lazy", ) gr.Markdown( "Example assets: portrait from the " "[LeapTalk](https://github.com/zhangrongxiang/LeapTalk) repository, portrait + podcast " "clip from [SoulX-FlashHead](https://github.com/Soul-AILab/SoulX-FlashHead) " "(both Apache-2.0). The narration clip is public-domain audiobook narration from " "[LibriSpeech](https://www.openslr.org/12) (LibriVox, CC0 / public domain). " "Audio clips were trimmed to a few seconds." ) gr.on( triggers=[run_btn.click], fn=generate, inputs=[ portrait_image, speech_audio, max_seconds, num_inference_steps, guidance_scale, seed, auto_crop_face, ], outputs=[video_out, report_out], ) if __name__ == "__main__": demo.launch(mcp_server=True, theme=gr.themes.Citrus(), css=CSS)