Spaces:
Running on Zero
Running on Zero
| # ============================================================ | |
| # ControlFoley - Hugging Face ZeroGPU Gradio App | |
| # Python 3.10 | |
| # PyTorch 2.8 / ZeroGPU compatible | |
| # ============================================================ | |
| # IMPORTANT: | |
| # Hugging Face ZeroGPU requires importing spaces BEFORE torch. | |
| import spaces | |
| import os | |
| import sys | |
| import time | |
| import shutil | |
| import logging | |
| import subprocess | |
| import tempfile | |
| from pathlib import Path | |
| from contextlib import contextmanager | |
| import gradio as gr | |
| from huggingface_hub import snapshot_download | |
| import torch | |
| import torchaudio | |
| # ============================================================ | |
| # Paths / configuration | |
| # ============================================================ | |
| APP_DIR = Path(__file__).resolve().parent | |
| SOURCE_DIR = APP_DIR / "upstream_controlfoley" | |
| MODEL_DIR = APP_DIR / "model_weights" | |
| OUTPUT_DIR = APP_DIR / "outputs" | |
| ASSET_DIR = APP_DIR / "sample_assets" | |
| UPSTREAM_REPO = "https://github.com/xiaomi-research/controlfoley.git" | |
| MODEL_REPO = "YJX-Xiaomi/ControlFoley" | |
| OUTPUT_DIR.mkdir(parents=True, exist_ok=True) | |
| ASSET_DIR.mkdir(parents=True, exist_ok=True) | |
| # ControlFoley contains several relative paths internally. | |
| # Make sure its expected working directory is the Space root. | |
| os.chdir(APP_DIR) | |
| # ============================================================ | |
| # Download official ControlFoley source | |
| # ============================================================ | |
| def clone_upstream(): | |
| if (SOURCE_DIR / "controlfoley").exists(): | |
| print("ControlFoley source already available.") | |
| return | |
| print("Cloning official ControlFoley repository...") | |
| subprocess.run( | |
| [ | |
| "git", | |
| "clone", | |
| "--depth", | |
| "1", | |
| UPSTREAM_REPO, | |
| str(SOURCE_DIR), | |
| ], | |
| check=True, | |
| ) | |
| # ============================================================ | |
| # Download official ControlFoley model weights | |
| # ============================================================ | |
| def download_model_files(): | |
| required_main = MODEL_DIR / "weights" / "controlfoley.pth" | |
| required_ext = [ | |
| MODEL_DIR / "ext_weights" / "v1-44.pth", | |
| MODEL_DIR / "ext_weights" / "synchformer_state_dict.pth", | |
| MODEL_DIR / "ext_weights" / "cav_mae_st.pth", | |
| MODEL_DIR | |
| / "ext_weights" | |
| / "music_speech_audioset_epoch_15_esc_89.98.pt", | |
| ] | |
| if required_main.exists() and all(x.exists() for x in required_ext): | |
| print("ControlFoley model files already available.") | |
| return | |
| print("Downloading ControlFoley model weights...") | |
| snapshot_download( | |
| repo_id=MODEL_REPO, | |
| local_dir=str(MODEL_DIR), | |
| allow_patterns=[ | |
| "weights/*", | |
| "ext_weights/*", | |
| ], | |
| ) | |
| # ============================================================ | |
| # Startup downloads | |
| # ============================================================ | |
| clone_upstream() | |
| download_model_files() | |
| # ============================================================ | |
| # Configure Python paths | |
| # ============================================================ | |
| # ControlFoley imports modules from both repository root and lib/. | |
| if str(SOURCE_DIR) not in sys.path: | |
| sys.path.insert(0, str(SOURCE_DIR)) | |
| if str(SOURCE_DIR / "lib") not in sys.path: | |
| sys.path.insert(0, str(SOURCE_DIR / "lib")) | |
| # ============================================================ | |
| # Import ControlFoley | |
| # ============================================================ | |
| from controlfoley.inference_utils import ( | |
| all_model_cfg, | |
| generate, | |
| load_video, | |
| make_video, | |
| setup_eval_logging, | |
| ) | |
| from controlfoley.audio_model import ( | |
| create_audio_generation_model, | |
| ) | |
| from controlfoley.feature_extractor import FeaturesUtils | |
| from lib.flow_matching import FlowMatching | |
| # ============================================================ | |
| # Logging | |
| # ============================================================ | |
| setup_eval_logging() | |
| log = logging.getLogger("controlfoley-space") | |
| torch.backends.cuda.matmul.allow_tf32 = True | |
| torch.backends.cudnn.allow_tf32 = True | |
| # ============================================================ | |
| # PyTorch 2.6+ compatibility | |
| # ============================================================ | |
| # | |
| # PyTorch >=2.6 changed: | |
| # | |
| # torch.load(..., weights_only=False) | |
| # | |
| # from effectively the old default behavior to: | |
| # | |
| # weights_only=True | |
| # | |
| # Some older libraries bundled/used by ControlFoley, especially | |
| # LAION-CLAP and AudioCraft/MusicGen, call torch.load() without | |
| # specifying weights_only. | |
| # | |
| # Their official checkpoints contain more than plain tensor | |
| # state dictionaries, so weights_only=True fails. | |
| # | |
| # We DO NOT monkey-patch torch.load globally. | |
| # | |
| # Instead, this compatibility context is active ONLY while | |
| # initializing trusted upstream ControlFoley feature models. | |
| # ============================================================ | |
| def legacy_checkpoint_loading(): | |
| original_torch_load = torch.load | |
| def compatible_torch_load(*args, **kwargs): | |
| # Preserve an explicit value supplied by a library. | |
| # | |
| # Only restore the old behavior when the caller does | |
| # not specify weights_only at all. | |
| if "weights_only" not in kwargs: | |
| kwargs["weights_only"] = False | |
| return original_torch_load(*args, **kwargs) | |
| torch.load = compatible_torch_load | |
| try: | |
| yield | |
| finally: | |
| torch.load = original_torch_load | |
| # ============================================================ | |
| # ControlFoley configuration | |
| # ============================================================ | |
| MODEL_CFG = all_model_cfg["large_44k"] | |
| SEQ_CFG = MODEL_CFG.seq_cfg | |
| # ============================================================ | |
| # Main ControlFoley network | |
| # ============================================================ | |
| print("=" * 60) | |
| print("Loading ControlFoley main network...") | |
| print("=" * 60) | |
| NET = create_audio_generation_model( | |
| MODEL_CFG.model_name | |
| ).to( | |
| "cuda", | |
| torch.float32, | |
| ).eval() | |
| MAIN_CHECKPOINT = MODEL_DIR / "weights" / "controlfoley.pth" | |
| # The main ControlFoley checkpoint is a normal weight state dict, | |
| # therefore keeping weights_only=True is appropriate here. | |
| main_state = torch.load( | |
| MAIN_CHECKPOINT, | |
| map_location="cuda", | |
| weights_only=True, | |
| ) | |
| NET.load_weights(main_state) | |
| del main_state | |
| print("ControlFoley main network loaded.") | |
| # ============================================================ | |
| # Feature extractor stack | |
| # ============================================================ | |
| # | |
| # Includes: | |
| # | |
| # - DFN5B CLIP | |
| # - Synchformer | |
| # - CAV-MAE-ST | |
| # - LAION CLAP | |
| # - AudioCraft MusicGen Style | |
| # - VAE / vocoder | |
| # | |
| # LAION CLAP and some AudioCraft checkpoints require the legacy | |
| # PyTorch checkpoint-loading behavior. | |
| # ============================================================ | |
| print("=" * 60) | |
| print("Loading ControlFoley feature extractors...") | |
| print("=" * 60) | |
| with legacy_checkpoint_loading(): | |
| FEATURES = FeaturesUtils( | |
| tod_vae_ckpt=str( | |
| MODEL_DIR | |
| / "ext_weights" | |
| / "v1-44.pth" | |
| ), | |
| synchformer_ckpt=str( | |
| MODEL_DIR | |
| / "ext_weights" | |
| / "synchformer_state_dict.pth" | |
| ), | |
| cav_mae_ckpt=str( | |
| MODEL_DIR | |
| / "ext_weights" | |
| / "cav_mae_st.pth" | |
| ), | |
| clap_ckpt=str( | |
| MODEL_DIR | |
| / "ext_weights" | |
| / "music_speech_audioset_epoch_15_esc_89.98.pt" | |
| ), | |
| mode=MODEL_CFG.mode, | |
| enable_conditions=True, | |
| need_vae_encoder=False, | |
| ) | |
| FEATURES = FEATURES.to( | |
| "cuda", | |
| torch.float32, | |
| ).eval() | |
| print("ControlFoley feature extractors loaded.") | |
| # ============================================================ | |
| # Sample video | |
| # ============================================================ | |
| def prepare_sample_video(): | |
| target = ASSET_DIR / "001.mp4" | |
| if target.exists(): | |
| return target | |
| upstream_sample = SOURCE_DIR / "assets" / "001.mp4" | |
| if not upstream_sample.exists(): | |
| print("Sample video not found.") | |
| return None | |
| shutil.copy2( | |
| upstream_sample, | |
| target, | |
| ) | |
| return target | |
| SAMPLE_VIDEO = prepare_sample_video() | |
| # ============================================================ | |
| # Dynamic ZeroGPU duration | |
| # ============================================================ | |
| def gpu_budget( | |
| video_path, | |
| prompt, | |
| negative_prompt, | |
| duration, | |
| cfg_strength, | |
| steps, | |
| seed, | |
| ): | |
| """ | |
| Allocate enough ZeroGPU time depending on inference settings. | |
| """ | |
| duration = float(duration) | |
| steps = int(steps) | |
| estimated = ( | |
| 60 | |
| + int(duration * 12) | |
| + int(steps * 3) | |
| ) | |
| # ZeroGPU maximum requested allocation. | |
| return min( | |
| 300, | |
| max(120, estimated), | |
| ) | |
| # ============================================================ | |
| # Main generation function | |
| # ============================================================ | |
| def generate_foley( | |
| video_path, | |
| prompt, | |
| negative_prompt, | |
| duration, | |
| cfg_strength, | |
| steps, | |
| seed, | |
| ): | |
| if not video_path: | |
| raise gr.Error( | |
| "Please upload a video or select the sample video." | |
| ) | |
| # -------------------------------------------------------- | |
| # Parameters | |
| # -------------------------------------------------------- | |
| video_path = Path(video_path) | |
| duration = float(duration) | |
| cfg_strength = float(cfg_strength) | |
| steps = int(steps) | |
| seed = int(seed) | |
| if duration < 1 or duration > 8: | |
| raise gr.Error( | |
| "Duration must be between 1 and 8 seconds." | |
| ) | |
| if steps < 5 or steps > 30: | |
| raise gr.Error( | |
| "Inference steps must be between 5 and 30." | |
| ) | |
| if not video_path.exists(): | |
| raise gr.Error( | |
| "The uploaded video could not be found." | |
| ) | |
| # -------------------------------------------------------- | |
| # Output directory | |
| # -------------------------------------------------------- | |
| job_dir = Path( | |
| tempfile.mkdtemp( | |
| prefix="controlfoley_", | |
| dir=str(OUTPUT_DIR), | |
| ) | |
| ) | |
| audio_path = ( | |
| job_dir | |
| / "generated_foley.flac" | |
| ) | |
| video_out_path = ( | |
| job_dir | |
| / "video_with_generated_audio.mp4" | |
| ) | |
| # -------------------------------------------------------- | |
| # Load / preprocess video | |
| # -------------------------------------------------------- | |
| print(f"Loading video: {video_path}") | |
| video_info = load_video( | |
| video_path, | |
| duration, | |
| ) | |
| actual_duration = min( | |
| duration, | |
| float(video_info.total_duration), | |
| ) | |
| # -------------------------------------------------------- | |
| # Video conditioning | |
| # -------------------------------------------------------- | |
| clip_frames = ( | |
| video_info | |
| .clip_embeddings | |
| .unsqueeze(0) | |
| ) | |
| visual_frames = ( | |
| video_info | |
| .visual_features | |
| .unsqueeze(0) | |
| ) | |
| sync_frames = ( | |
| video_info | |
| .sync_embeddings | |
| .unsqueeze(0) | |
| ) | |
| # -------------------------------------------------------- | |
| # Configure temporal dimensions | |
| # -------------------------------------------------------- | |
| SEQ_CFG.total_time_seconds = actual_duration | |
| NET.update_seq_lengths( | |
| SEQ_CFG.latent_sequence_length, | |
| SEQ_CFG.clip_sequence_length, | |
| SEQ_CFG.visual_sequence_length, | |
| SEQ_CFG.sync_sequence_length, | |
| ) | |
| # -------------------------------------------------------- | |
| # Random generator | |
| # -------------------------------------------------------- | |
| rng = torch.Generator( | |
| device="cuda" | |
| ) | |
| rng.manual_seed(seed) | |
| # -------------------------------------------------------- | |
| # Flow matching sampler | |
| # -------------------------------------------------------- | |
| fm = FlowMatching( | |
| min_sigma=0, | |
| inference_mode="euler", | |
| num_steps=steps, | |
| ) | |
| # -------------------------------------------------------- | |
| # Generate | |
| # -------------------------------------------------------- | |
| print("=" * 60) | |
| print("Generating Foley audio...") | |
| print(f"Prompt: {prompt}") | |
| print(f"Duration: {actual_duration}") | |
| print(f"Steps: {steps}") | |
| print(f"CFG: {cfg_strength}") | |
| print(f"Seed: {seed}") | |
| print("=" * 60) | |
| start_time = time.time() | |
| audios = generate( | |
| # Video conditioning | |
| clip_frames, | |
| visual_frames, | |
| sync_frames, | |
| # No reference audio | |
| None, | |
| # No timbre reference | |
| None, | |
| # Reference audio duration | |
| 0.0, | |
| # Text prompt | |
| [prompt or ""], | |
| negative_text=[ | |
| negative_prompt or "" | |
| ], | |
| feature_utils=FEATURES, | |
| net=NET, | |
| fm=fm, | |
| rng=rng, | |
| cfg_strength=cfg_strength, | |
| ) | |
| # -------------------------------------------------------- | |
| # Convert generated tensor | |
| # -------------------------------------------------------- | |
| audio = ( | |
| audios | |
| .float() | |
| .cpu()[0] | |
| ) | |
| # -------------------------------------------------------- | |
| # Save generated FLAC | |
| # -------------------------------------------------------- | |
| torchaudio.save( | |
| str(audio_path), | |
| audio, | |
| SEQ_CFG.audio_sample_rate, | |
| ) | |
| # -------------------------------------------------------- | |
| # Mux generated audio with original video | |
| # -------------------------------------------------------- | |
| make_video( | |
| video_info, | |
| video_out_path, | |
| audio, | |
| sampling_rate=SEQ_CFG.audio_sample_rate, | |
| ) | |
| # -------------------------------------------------------- | |
| # Finished | |
| # -------------------------------------------------------- | |
| elapsed = time.time() - start_time | |
| status = f""" | |
| ### ✅ Generation complete | |
| **Duration:** {actual_duration:.2f}s | |
| **Generation time:** {elapsed:.1f}s | |
| **Steps:** {steps} | |
| **CFG:** {cfg_strength} | |
| **Seed:** {seed} | |
| """ | |
| # -------------------------------------------------------- | |
| # Release temporary tensors | |
| # -------------------------------------------------------- | |
| del audios | |
| del audio | |
| try: | |
| del clip_frames | |
| del visual_frames | |
| del sync_frames | |
| except Exception: | |
| pass | |
| if torch.cuda.is_available(): | |
| torch.cuda.empty_cache() | |
| return ( | |
| str(audio_path), | |
| str(video_out_path), | |
| status, | |
| ) | |
| # ============================================================ | |
| # Gradio UI | |
| # ============================================================ | |
| TITLE = """ | |
| # 🎬 ControlFoley — Video → Foley Audio | |
| Generate synchronized Foley sound effects from video using | |
| **Xiaomi Research ControlFoley**. | |
| Upload a video or select the official sample below. | |
| You can use: | |
| - **V2A** — leave the prompt empty | |
| - **TV2A** — describe the sound you want | |
| """ | |
| HELP_TEXT = """ | |
| ### Usage | |
| **Pure Video → Audio** | |
| Leave the prompt blank. | |
| **Text-guided Video → Audio** | |
| Describe the expected sound. | |
| Example: | |
| `the skateboard wheels scraping and grinding on the ground.` | |
| For the first test, use: | |
| - Duration: **8 seconds** | |
| - Steps: **25** | |
| - CFG: **4.5** | |
| - Seed: **42** | |
| If you want a quicker test, reduce inference steps to **10–15**. | |
| """ | |
| with gr.Blocks( | |
| title="ControlFoley Video to Audio" | |
| ) as demo: | |
| gr.Markdown(TITLE) | |
| with gr.Row(): | |
| # ==================================================== | |
| # INPUT | |
| # ==================================================== | |
| with gr.Column(): | |
| video = gr.Video( | |
| label="Input Video", | |
| sources=["upload"], | |
| format="mp4", | |
| ) | |
| prompt = gr.Textbox( | |
| label="Sound Prompt", | |
| placeholder=( | |
| "Describe the sound, or leave blank " | |
| "for pure Video-to-Audio" | |
| ), | |
| value=( | |
| "the skateboard wheels scraping " | |
| "and grinding on the ground." | |
| ), | |
| lines=2, | |
| ) | |
| negative_prompt = gr.Textbox( | |
| label="Negative Prompt", | |
| placeholder=( | |
| "Example: music, speech, crowd noise" | |
| ), | |
| value="", | |
| lines=1, | |
| ) | |
| with gr.Accordion( | |
| "Generation Settings", | |
| open=False, | |
| ): | |
| duration = gr.Slider( | |
| minimum=1, | |
| maximum=8, | |
| value=8, | |
| step=0.5, | |
| label="Duration (seconds)", | |
| ) | |
| cfg_strength = gr.Slider( | |
| minimum=1.0, | |
| maximum=8.0, | |
| value=4.5, | |
| step=0.5, | |
| label="CFG Strength", | |
| ) | |
| steps = gr.Slider( | |
| minimum=5, | |
| maximum=30, | |
| value=25, | |
| step=1, | |
| label="Inference Steps", | |
| ) | |
| seed = gr.Number( | |
| value=42, | |
| precision=0, | |
| label="Seed", | |
| ) | |
| generate_btn = gr.Button( | |
| "Generate Foley Sound", | |
| variant="primary", | |
| ) | |
| # ==================================================== | |
| # OUTPUT | |
| # ==================================================== | |
| with gr.Column(): | |
| audio_out = gr.Audio( | |
| label="Generated Foley Audio", | |
| type="filepath", | |
| ) | |
| video_out = gr.Video( | |
| label="Video + Generated Foley", | |
| ) | |
| status = gr.Markdown() | |
| # ======================================================== | |
| # Sample | |
| # ======================================================== | |
| if SAMPLE_VIDEO is not None: | |
| gr.Examples( | |
| examples=[ | |
| [ | |
| str(SAMPLE_VIDEO), | |
| ( | |
| "the skateboard wheels scraping " | |
| "and grinding on the ground." | |
| ), | |
| "", | |
| 8, | |
| 4.5, | |
| 25, | |
| 42, | |
| ], | |
| ], | |
| inputs=[ | |
| video, | |
| prompt, | |
| negative_prompt, | |
| duration, | |
| cfg_strength, | |
| steps, | |
| seed, | |
| ], | |
| label="Official ControlFoley Sample", | |
| ) | |
| gr.Markdown(HELP_TEXT) | |
| # ======================================================== | |
| # Generation event | |
| # ======================================================== | |
| generate_btn.click( | |
| fn=generate_foley, | |
| inputs=[ | |
| video, | |
| prompt, | |
| negative_prompt, | |
| duration, | |
| cfg_strength, | |
| steps, | |
| seed, | |
| ], | |
| outputs=[ | |
| audio_out, | |
| video_out, | |
| status, | |
| ], | |
| api_name="generate_foley", | |
| concurrency_limit=1, | |
| ) | |
| # ============================================================ | |
| # Launch | |
| # ============================================================ | |
| if __name__ == "__main__": | |
| demo.queue( | |
| max_size=20, | |
| default_concurrency_limit=1, | |
| ).launch() |