multitalk-inf-v2 / multitalk.py
vincentraj's picture
all models
369e7ad
Raw History Blame Contribute Delete
25 kB
import modal
import os
import subprocess
import time
import json
import tempfile
import logging
import sys
import warnings
import shutil
import base64
import requests
from typing import Optional, Dict, Any, Union
from datetime import datetime
from types import SimpleNamespace
from io import BytesIO
from pathlib import Path
from fastapi import HTTPException
from pydantic import BaseModel
# Suppress warnings for cleaner output
warnings.filterwarnings("ignore")
# Modal App Configuration
app = modal.App("multitalk-inference")
# Create a Volume for model weights
volume = modal.Volume.from_name("multitalk-weights", create_if_missing=True)
MODEL_DIR = Path("/models")
# HuggingFace secret for authentication
# huggingface_secret = modal.Secret.from_name(
# "huggingface-secret", required_keys=["HF_TOKEN"]
# )
huggingface_secret = ''
# Base image with system dependencies
base_image = (
modal.Image.debian_slim(python_version="3.10")
.apt_install(
"ffmpeg",
"libsndfile1",
"git",
"wget",
"curl",
"libgl1-mesa-glx",
"libglib2.0-0",
"libsm6",
"libxext6",
"libxrender-dev",
"libgomp1",
)
.pip_install(
"torch>=2.0.0",
"torchvision",
"torchaudio",
"numpy",
"soundfile",
"librosa",
"pyloudnorm",
"einops",
"transformers>=4.30.0",
"huggingface-hub",
"Pillow",
"fastapi[standard]",
"pydantic",
"requests",
"accelerate",
"diffusers",
)
.env({"HF_HUB_ENABLE_HF_TRANSFER": "1"})
)
# Download image with additional dependencies
download_image = base_image.pip_install(
"huggingface_hub[hf_transfer]",
"requests",
"tqdm",
)
# Pydantic models for API
class MultiTalkRequest(BaseModel):
image: str # base64 encoded image
first_audio: str # base64 encoded audio
prompt: str = "A smiling man and woman wearing headphones sit in front of microphones, appearing to host a podcast."
second_audio: Optional[str] = None # base64 encoded audio (optional)
num_frames: int = 81
sampling_steps: int = 40
seed: Optional[int] = None
turbo: bool = True
class MultiTalkResponse(BaseModel):
video: str # base64 encoded video
metadata: Dict[str, Any]
class ErrorResponse(BaseModel):
error: str
type: str
# Helper functions
def loudness_norm(audio_array, sr=16000, lufs=-23):
import pyloudnorm as pyln
meter = pyln.Meter(sr)
loudness = meter.integrated_loudness(audio_array)
if abs(loudness) > 100:
return audio_array
normalized_audio = pyln.normalize.loudness(audio_array, loudness, lufs)
return normalized_audio
def extract_audio_from_video(filename, sample_rate=16000):
"""Extract audio from video file with robust error handling"""
import librosa
raw_audio_path = f"{os.path.splitext(os.path.basename(filename))[0]}.wav"
ffmpeg_command = [
"ffmpeg",
"-y",
"-i",
str(filename),
"-vn",
"-acodec",
"pcm_s16le",
"-ar",
str(sample_rate),
"-ac",
"2",
raw_audio_path,
]
subprocess.run(ffmpeg_command, check=True, capture_output=True)
human_speech_array, sr = librosa.load(raw_audio_path, sr=sample_rate)
human_speech_array = loudness_norm(human_speech_array, sr)
os.remove(raw_audio_path)
return human_speech_array
def audio_prepare_single(audio_path, sample_rate=16000):
import librosa
ext = os.path.splitext(audio_path)[1].lower()
if ext in [".mp4", ".mov", ".avi", ".mkv"]:
human_speech_array = extract_audio_from_video(audio_path, sample_rate)
return human_speech_array
else:
human_speech_array, sr = librosa.load(audio_path, sr=sample_rate)
human_speech_array = loudness_norm(human_speech_array, sr)
return human_speech_array
def audio_prepare_multi(left_path, right_path, audio_type, sample_rate=16000):
import numpy as np
human_speech_array1 = audio_prepare_single(left_path)
human_speech_array2 = audio_prepare_single(right_path)
if audio_type == "para":
new_human_speech1 = human_speech_array1
new_human_speech2 = human_speech_array2
elif audio_type == "add":
new_human_speech1 = np.concatenate([
human_speech_array1[:human_speech_array1.shape[0]],
np.zeros(human_speech_array2.shape[0]),
])
new_human_speech2 = np.concatenate([
np.zeros(human_speech_array1.shape[0]),
human_speech_array2[:human_speech_array2.shape[0]],
])
sum_human_speechs = new_human_speech1 + new_human_speech2
return new_human_speech1, new_human_speech2, sum_human_speechs
def get_embedding(speech_array, wav2vec_feature_extractor, audio_encoder, sr=16000, device="cpu"):
"""Extract audio embeddings optimized for GPU processing"""
import torch
import numpy as np
from einops import rearrange
audio_duration = len(speech_array) / sr
video_length = audio_duration * 25 # Assume the video fps is 25
# Extract audio features
audio_feature = np.squeeze(
wav2vec_feature_extractor(speech_array, sampling_rate=sr).input_values
)
audio_feature = torch.from_numpy(audio_feature).float().to(device=device)
audio_feature = audio_feature.unsqueeze(0)
# Generate embeddings on appropriate device
with torch.no_grad():
embeddings = audio_encoder(
audio_feature, seq_len=int(video_length), output_hidden_states=True
)
if len(embeddings) == 0:
print("⚠️ Failed to extract audio embedding")
return None
audio_emb = torch.stack(embeddings.hidden_states[1:], dim=1).squeeze(0)
audio_emb = rearrange(audio_emb, "b s d -> s b d")
# Keep on CPU for compatibility with downstream processing
audio_emb = audio_emb.cpu().detach()
return audio_emb
def download_with_resume(url: str, dest: str, max_retries: int = 3, chunk_size: int = 8192 * 1024) -> None:
"""Download file with resume capability and robust error handling."""
print(f"[!] Downloading from: {url}")
print(f"[~] Destination: {dest}")
# Create destination directory if it doesn't exist
os.makedirs(os.path.dirname(dest), exist_ok=True)
# Get initial file size if resuming
initial_size = 0
if os.path.exists(dest):
initial_size = os.path.getsize(dest)
print(f"[~] Resuming download from {initial_size} bytes")
for attempt in range(max_retries):
try:
headers = {}
if initial_size > 0:
headers["Range"] = f"bytes={initial_size}-"
response = requests.get(url, headers=headers, stream=True, timeout=60)
response.raise_for_status()
# Get total file size
total_size = initial_size
if "content-length" in response.headers:
total_size = initial_size + int(response.headers["content-length"])
elif "content-range" in response.headers:
content_range = response.headers["content-range"]
if "/" in content_range:
total_size = int(content_range.split("/")[-1])
print(f"[~] Total size: {total_size / (1024**3):.2f} GB")
start_time = time.time()
last_progress_time = start_time
mode = "ab" if initial_size > 0 else "wb"
with open(dest, mode) as f:
downloaded = initial_size
for chunk in response.iter_content(chunk_size=chunk_size):
if chunk:
f.write(chunk)
downloaded += len(chunk)
current_time = time.time()
if current_time - last_progress_time >= 10:
if total_size > 0:
progress = (downloaded / total_size) * 100
speed = downloaded / (current_time - start_time) / (1024**2)
print(f"[~] Progress: {progress:.1f}% ({downloaded/(1024**3):.2f}/{total_size/(1024**3):.2f} GB) - Speed: {speed:.1f} MB/s")
last_progress_time = current_time
final_size = os.path.getsize(dest)
if total_size > 0 and final_size < total_size:
print(f"[!] Download incomplete: {final_size}/{total_size} bytes")
initial_size = final_size
continue
elapsed_time = time.time() - start_time
print(f"[+] Download completed in {elapsed_time:.2f} seconds")
return
except requests.exceptions.RequestException as e:
print(f"[!] Download attempt {attempt + 1} failed: {str(e)}")
if attempt < max_retries - 1:
print(f"[~] Retrying in 5 seconds...")
time.sleep(5)
initial_size = os.path.getsize(dest) if os.path.exists(dest) else 0
else:
print(f"[ERROR] Failed to download after {max_retries} attempts")
raise
def extract_tar_file(tar_path: str, extract_dir: str) -> None:
"""Extract tar file with error handling"""
print(f"[~] Extracting {tar_path} to {extract_dir}")
try:
import tarfile
with tarfile.open(tar_path, "r") as tar:
tar.extractall(path=extract_dir)
print(f"[+] Extraction completed")
os.remove(tar_path)
print(f"[+] Cleaned up {tar_path}")
except Exception as e:
print(f"[ERROR] Failed to extract {tar_path}: {str(e)}")
raise
def download_weights(url: str, dest: str) -> None:
"""Download and extract model weights with robust error handling."""
start_time = time.time()
try:
is_tar = dest.endswith(".tar")
extract_dir = dest.replace(".tar", "") if is_tar else dest
if is_tar and os.path.exists(extract_dir) and os.path.isdir(extract_dir):
print(f"[~] Model already exists at {extract_dir}, skipping download")
return
download_with_resume(url, dest)
if is_tar:
extract_tar_file(dest, os.path.dirname(dest))
total_time = time.time() - start_time
print(f"[+] Model setup completed in {total_time:.2f} seconds")
except Exception as e:
print(f"[ERROR] Failed to download weights from {url}: {str(e)}")
if os.path.exists(dest):
os.remove(dest)
raise
def decode_base64_file(base64_string: str, output_path: str) -> str:
"""Decode base64 string and save to file"""
try:
if "," in base64_string:
base64_string = base64_string.split(",")[1]
with open(output_path, "wb") as f:
f.write(base64.b64decode(base64_string))
return output_path
except Exception as e:
raise ValueError(f"Failed to decode base64 file: {str(e)}")
def encode_file_to_base64(file_path: str) -> str:
"""Encode file to base64 string"""
try:
with open(file_path, "rb") as f:
return base64.b64encode(f.read()).decode("utf-8")
except Exception as e:
raise ValueError(f"Failed to encode file to base64: {str(e)}")
# Download models function
@app.function(
volumes={MODEL_DIR.as_posix(): volume},
image=download_image,
secrets=[huggingface_secret],
timeout=3600, # 1 hour timeout for downloads
)
def download_models():
"""Download all required MultiTalk models"""
MODEL_CACHE = MODEL_DIR / "weights"
BASE_URL = f"https://weights.replicate.delivery/default/multitalk/{MODEL_CACHE.name}/"
# Create model cache directory
MODEL_CACHE.mkdir(parents=True, exist_ok=True)
# Download model weights
model_files = [
"chinese-wav2vec2-base.tar",
"MeiGen-MultiTalk.tar",
"Wan2.1-I2V-14B-480P.tar",
]
print("[~] Starting model downloads...")
for model_file in model_files:
url = BASE_URL + model_file
dest_path = MODEL_CACHE / model_file
download_weights(url, str(dest_path))
# Commit changes to volume
volume.commit()
print("βœ… All models downloaded and committed to volume!")
# Inference class
class MultiTalkInference:
def __init__(self):
"""Initialize the MultiTalk inference handler"""
import torch
from transformers import Wav2Vec2FeatureExtractor
# Note: You'll need to add proper imports for MultiTalk components
# import wan
# from wan.configs import WAN_CONFIGS
# from src.audio_analysis.wav2vec2 import Wav2Vec2Model
# from wan.utils.multitalk_utils import save_video_ffmpeg
# Model paths
self.model_cache = MODEL_DIR / "weights"
self.ckpt_dir = self.model_cache / "Wan2.1-I2V-14B-480P"
self.wav2vec_dir = self.model_cache / "chinese-wav2vec2-base"
self.multitalk_dir = self.model_cache / "MeiGen-MultiTalk"
# Initialize device
self.device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
print(f"Using device: {self.device}")
# Load wav2vec models
print("Loading wav2vec models...")
audio_device = (
self.device
if torch.cuda.is_available()
and torch.cuda.get_device_properties(0).total_memory > 40 * 1024**3
else "cpu"
)
print(f"Loading audio encoder on: {audio_device}")
# Note: Replace with actual imports when available
# self.audio_encoder = Wav2Vec2Model.from_pretrained(
# str(self.wav2vec_dir), local_files_only=True, attn_implementation="eager"
# ).to(audio_device)
# self.audio_encoder.feature_extractor._freeze_parameters()
# self.wav2vec_feature_extractor = Wav2Vec2FeatureExtractor.from_pretrained(
# str(self.wav2vec_dir), local_files_only=True
# )
self.audio_device = audio_device
# Load MultiTalk pipeline
print("Loading MultiTalk pipeline...")
# self.cfg = WAN_CONFIGS["multitalk-14B"]
# self.wan_i2v = wan.MultiTalkPipeline(
# config=self.cfg,
# checkpoint_dir=str(self.ckpt_dir),
# device_id=0,
# rank=0,
# t5_fsdp=False,
# dit_fsdp=False,
# use_usp=False,
# t5_cpu=False,
# )
# GPU optimizations
if torch.cuda.is_available():
vram_gb = torch.cuda.get_device_properties(0).total_memory / (1024**3)
print(f"πŸ” Detected {vram_gb:.1f}GB VRAM")
if vram_gb > 40:
print("πŸš€ High-VRAM detected: Enabling maximum performance optimizations")
torch.backends.cuda.enable_flash_sdp(True)
torch.backends.cudnn.benchmark = True
torch.backends.cuda.matmul.allow_tf32 = True
torch.backends.cudnn.allow_tf32 = True
torch.cuda.empty_cache()
print("⚑ Enabled Flash-SDP, TF32, and cuDNN benchmarking")
else:
print("πŸ”§ Standard GPU optimizations enabled")
torch.backends.cuda.enable_flash_sdp(True)
torch.cuda.empty_cache()
print("βœ… Model setup completed successfully!")
def generate_video(self, request: MultiTalkRequest) -> MultiTalkResponse:
"""Generate video from the request"""
import torch
import numpy as np
import random
import soundfile as sf
try:
# Validate parameters
if not isinstance(request.num_frames, int) or request.num_frames < 25 or request.num_frames > 201:
raise ValueError("num_frames must be an integer between 25 and 201")
if not isinstance(request.sampling_steps, int) or request.sampling_steps < 2 or request.sampling_steps > 100:
raise ValueError("sampling_steps must be an integer between 2 and 100")
# Auto-correct frame count
num_frames = request.num_frames
original_frames = num_frames
if (num_frames - 1) % 4 != 0:
n_lower = (num_frames - 1) // 4
n_upper = n_lower + 1
frames_lower = 4 * n_lower + 1
frames_upper = 4 * n_upper + 1
if abs(num_frames - frames_lower) <= abs(num_frames - frames_upper):
num_frames = frames_lower
else:
num_frames = frames_upper
num_frames = max(25, min(num_frames, 201))
while (num_frames - 1) % 4 != 0 and num_frames <= 201:
num_frames += 1
print(f"πŸ“ Auto-corrected num_frames from {original_frames} to {num_frames}")
# Set seed
if request.seed is None:
seed = random.randint(0, 99999999)
else:
seed = request.seed
print(f"🎬 Generating video with seed: {seed}")
with tempfile.TemporaryDirectory() as temp_dir:
# Save input files
image_path = os.path.join(temp_dir, "input_image.jpg")
first_audio_path = os.path.join(temp_dir, "first_audio.wav")
decode_base64_file(request.image, image_path)
decode_base64_file(request.first_audio, first_audio_path)
second_audio_path = None
if request.second_audio:
second_audio_path = os.path.join(temp_dir, "second_audio.wav")
decode_base64_file(request.second_audio, second_audio_path)
is_multi_person = second_audio_path is not None
audio_save_dir = os.path.join(temp_dir, "audio_embeddings")
os.makedirs(audio_save_dir, exist_ok=True)
# Process audio and generate embeddings
if is_multi_person:
print("🎀 Processing multi-person audio...")
audio_type = "add"
speech1, speech2, combined_speech = audio_prepare_multi(
first_audio_path, second_audio_path, audio_type
)
# Generate embeddings
embedding1 = get_embedding(
speech1, self.wav2vec_feature_extractor, self.audio_encoder,
device=self.audio_device
)
embedding2 = get_embedding(
speech2, self.wav2vec_feature_extractor, self.audio_encoder,
device=self.audio_device
)
# Save embeddings and audio
emb1_path = os.path.join(audio_save_dir, "1.pt")
emb2_path = os.path.join(audio_save_dir, "2.pt")
sum_audio_path = os.path.join(audio_save_dir, "sum.wav")
torch.save(embedding1, emb1_path)
torch.save(embedding2, emb2_path)
sf.write(sum_audio_path, combined_speech, 16000)
input_data = {
"prompt": request.prompt,
"cond_image": image_path,
"audio_type": audio_type,
"cond_audio": {"person1": emb1_path, "person2": emb2_path},
"video_audio": sum_audio_path,
}
else:
print("🎀 Processing single-person audio...")
speech = audio_prepare_single(first_audio_path)
embedding = get_embedding(
speech, self.wav2vec_feature_extractor, self.audio_encoder,
device=self.audio_device
)
emb_path = os.path.join(audio_save_dir, "1.pt")
sum_audio_path = os.path.join(audio_save_dir, "sum.wav")
torch.save(embedding, emb_path)
sf.write(sum_audio_path, speech, 16000)
input_data = {
"prompt": request.prompt,
"cond_image": image_path,
"cond_audio": {"person1": emb_path},
"video_audio": sum_audio_path,
}
print("🎬 Generating video...")
# Configure generation parameters
high_vram = (
torch.cuda.is_available()
and torch.cuda.get_device_properties(0).total_memory > 40 * 1024**3
)
if request.turbo:
teacache_thresh = 0.8
text_guide_scale = 3.0
audio_guide_scale = 3.0
shift = 5.0
offload_model = False
else:
teacache_thresh = 0.3
text_guide_scale = 5.0
audio_guide_scale = 4.0
shift = 7.0
offload_model = not high_vram
extra_args = SimpleNamespace(
use_teacache=True,
teacache_thresh=teacache_thresh,
use_apg=False,
size="multitalk-480",
)
# Generate video (replace with actual generation call)
# video = self.wan_i2v.generate(
# input_data,
# size_buckget="multitalk-480",
# motion_frame=25,
# frame_num=num_frames,
# shift=shift,
# sampling_steps=request.sampling_steps,
# text_guide_scale=text_guide_scale,
# audio_guide_scale=audio_guide_scale,
# seed=seed,
# offload_model=offload_model,
# max_frames_num=num_frames,
# extra_args=extra_args,
# )
# Save video (replace with actual save call)
output_name = f"multitalk_{abs(hash(request.prompt + str(seed))) % 10000}"
# save_video_ffmpeg(video, output_name, [input_data["video_audio"]])
# For now, create a dummy video file
output_file = f"{output_name}.mp4"
with open(output_file, "wb") as f:
f.write(b"dummy video content")
# Encode video to base64
video_b64 = encode_file_to_base64(output_file)
# Cleanup
if torch.cuda.is_available():
torch.cuda.empty_cache()
print("βœ… Video generation completed successfully!")
return MultiTalkResponse(
video=video_b64,
metadata={
"seed": seed,
"num_frames": num_frames,
"sampling_steps": request.sampling_steps,
"turbo": request.turbo,
"is_multi_person": is_multi_person,
"prompt": request.prompt,
"original_frames_requested": original_frames,
}
)
except Exception as e:
print(f"❌ Error during inference: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
# Inference endpoint
@app.function(
volumes={MODEL_DIR.as_posix(): volume},
image=base_image,
gpu=modal.gpu.A100(size="40GB"), # Adjust GPU as needed
secrets=[huggingface_secret],
timeout=1800, # 30 minute timeout
container_idle_timeout=300, # 5 minute idle timeout
)
@modal.web_endpoint(method="POST")
def generate_video(request: MultiTalkRequest) -> Union[MultiTalkResponse, ErrorResponse]:
"""Main inference endpoint"""
try:
# Initialize inference handler
handler = MultiTalkInference()
# Generate video
response = handler.generate_video(request)
return response
except Exception as e:
print(f"❌ Endpoint error: {str(e)}")
return ErrorResponse(error=str(e), type=type(e).__name__)