Spaces:
Running on Zero
Running on Zero
| """ | |
| Hyper-RVC Main Processing Module | |
| Enhanced with Advanced Progress Tracking System | |
| """ | |
| import spaces | |
| import torch | |
| import argparse | |
| import gc | |
| import hashlib | |
| import json | |
| import os | |
| import shlex | |
| import subprocess | |
| import time | |
| from contextlib import suppress | |
| from dataclasses import dataclass, field | |
| from datetime import timedelta | |
| from typing import Optional, Callable, List, Tuple, Any | |
| from urllib.parse import urlparse, parse_qs | |
| import shutil | |
| import gradio as gr | |
| import librosa | |
| import numpy as np | |
| import soundfile as sf | |
| import sox | |
| import yt_dlp | |
| from pedalboard import Pedalboard, Reverb, Compressor, HighpassFilter | |
| from pedalboard.io import AudioFile | |
| from pydub import AudioSegment | |
| import noisereduce as nr | |
| from mdx import run_mdx | |
| from rvc import Config, load_hubert, get_vc, rvc_infer | |
| import logging | |
| logging.getLogger("httpx").setLevel(logging.WARNING) | |
| BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) | |
| IS_ZERO_GPU = os.getenv("SPACES_ZERO_GPU") | |
| mdxnet_models_dir = os.path.join(BASE_DIR, 'mdxnet_models') | |
| rvc_models_dir = os.path.join(BASE_DIR, 'rvc_models') | |
| output_dir = os.path.join(BASE_DIR, 'song_output') | |
| # ============================================================================= | |
| # ENHANCED PROGRESS TRACKING SYSTEM | |
| # ============================================================================= | |
| class ProgressStep: | |
| """Represents a single step in the processing pipeline.""" | |
| name: str | |
| weight: float = 1.0 # Relative weight for progress calculation | |
| status: str = "pending" # pending, in_progress, completed, error | |
| start_time: Optional[float] = None | |
| end_time: Optional[float] = None | |
| detail: str = "" | |
| class EnhancedProgressTracker: | |
| """ | |
| Advanced progress tracker with: | |
| - Step-by-step progress visualization | |
| - Time estimation (ETA) | |
| - Weighted progress calculation | |
| - Detailed status messages | |
| - Smooth progress updates | |
| """ | |
| def __init__(self, progress: gr.Progress = None, is_webui: bool = True): | |
| self.progress = progress | |
| self.is_webui = is_webui | |
| self.steps: List[ProgressStep] = [] | |
| self.current_step_index: int = 0 | |
| self.total_weight: float = 0.0 | |
| self.completed_weight: float = 0.0 | |
| self.start_time: float = time.time() | |
| self.last_update_time: float = 0.0 | |
| self.min_update_interval: float = 0.1 # Minimum seconds between UI updates | |
| def add_step(self, name: str, weight: float = 1.0) -> 'EnhancedProgressTracker': | |
| """Add a processing step.""" | |
| step = ProgressStep(name=name, weight=weight) | |
| self.steps.append(step) | |
| self.total_weight += weight | |
| return self | |
| def set_steps(self, steps: List[Tuple[str, float]]) -> 'EnhancedProgressTracker': | |
| """Set multiple steps at once. Format: [(name, weight), ...]""" | |
| self.steps = [] | |
| self.total_weight = 0.0 | |
| for name, weight in steps: | |
| self.add_step(name, weight) | |
| return self | |
| def start(self, message: str = None): | |
| """Initialize and start tracking.""" | |
| self.start_time = time.time() | |
| self.current_step_index = 0 | |
| self.completed_weight = 0.0 | |
| if message: | |
| self._update_progress(0, message) | |
| if self.steps: | |
| self._start_step(0) | |
| def _start_step(self, index: int): | |
| """Mark a step as started.""" | |
| if index < len(self.steps): | |
| self.steps[index].status = "in_progress" | |
| self.steps[index].start_time = time.time() | |
| self.current_step_index = index | |
| def _complete_step(self, index: int): | |
| """Mark a step as completed.""" | |
| if index < len(self.steps): | |
| self.steps[index].status = "completed" | |
| self.steps[index].end_time = time.time() | |
| self.completed_weight += self.steps[index].weight | |
| def _calculate_progress(self) -> float: | |
| """Calculate overall progress (0-100).""" | |
| if self.total_weight == 0: | |
| return 0.0 | |
| # Completed weight contributes fully | |
| progress = (self.completed_weight / self.total_weight) * 100 | |
| # Current step contributes partially based on sub-progress | |
| if self.current_step_index < len(self.steps): | |
| current_step = self.steps[self.current_step_index] | |
| if current_step.status == "in_progress": | |
| # Assume current step is 50% done for smooth progression | |
| step_contribution = (current_step.weight * 0.5 / self.total_weight) * 100 | |
| progress += step_contribution | |
| return min(progress, 99.9) # Cap at 99.9 until fully complete | |
| def _estimate_remaining_time(self) -> str: | |
| """Estimate time remaining based on progress.""" | |
| elapsed = time.time() - self.start_time | |
| current_progress = self._calculate_progress() | |
| if current_progress > 0.1: | |
| total_estimated = (elapsed / current_progress) * 100 | |
| remaining = max(0, total_estimated - elapsed) | |
| return str(timedelta(seconds=int(remaining))) | |
| return "Calculating..." | |
| def _get_status_message(self, custom_message: str = None) -> str: | |
| """Generate detailed status message.""" | |
| if custom_message: | |
| return custom_message | |
| if self.current_step_index < len(self.steps): | |
| step = self.steps[self.current_step_index] | |
| eta = self._estimate_remaining_time() | |
| return f"[{self.current_step_index + 1}/{len(self.steps)}] {step.name} | ETA: {eta}" | |
| return "Processing..." | |
| def _update_progress(self, percent: float, message: str = None): | |
| """Update the Gradio progress bar.""" | |
| current_time = time.time() | |
| # Throttle updates to prevent UI flooding | |
| if current_time - self.last_update_time < self.min_update_interval and percent < 100: | |
| return | |
| self.last_update_time = current_time | |
| if self.is_webui and self.progress: | |
| status_msg = self._get_status_message(message) | |
| self.progress(percent / 100.0, desc=status_msg) | |
| elif not self.is_webui: | |
| print(f"[{percent:.1f}%] {self._get_status_message(message)}") | |
| def update_step(self, step_name: str = None, detail: str = "", sub_progress: float = None): | |
| """ | |
| Update progress within current or specified step. | |
| Args: | |
| step_name: Name of step to update (uses current if None) | |
| detail: Additional detail about current operation | |
| sub_progress: Sub-progress within current step (0-1) | |
| """ | |
| # Find step index | |
| if step_name: | |
| for i, step in enumerate(self.steps): | |
| if step.name == step_name: | |
| if step.status == "pending": | |
| self._complete_step(self.current_step_index) | |
| self._start_step(i) | |
| break | |
| # Update detail | |
| if self.current_step_index < len(self.steps): | |
| self.steps[self.current_step_index].detail = detail | |
| # Calculate and display progress | |
| overall_progress = self._calculate_progress() | |
| # Adjust for sub-progress if provided | |
| if sub_progress is not None and self.current_step_index < len(self.steps): | |
| step = self.steps[self.current_step_index] | |
| step_progress = (step.weight * sub_progress / self.total_weight) * 100 | |
| overall_progress = ((self.completed_weight + step.weight * sub_progress) / self.total_weight) * 100 | |
| message = f"{detail}" if detail else None | |
| self._update_progress(min(overall_progress, 99.9), message) | |
| def next_step(self, detail: str = ""): | |
| """Complete current step and move to next.""" | |
| if self.current_step_index < len(self.steps): | |
| self._complete_step(self.current_step_index) | |
| next_idx = self.current_step_index + 1 | |
| if next_idx < len(self.steps): | |
| self._start_step(next_idx) | |
| # Update progress for new step | |
| overall_progress = self._calculate_progress() | |
| message = f"{detail}" if detail else None | |
| self._update_progress(overall_progress, message) | |
| def complete(self, final_message: str = "Complete!"): | |
| """Mark all processing as complete.""" | |
| # Complete any remaining in-progress step | |
| if self.current_step_index < len(self.steps): | |
| self._complete_step(self.current_step_index) | |
| elapsed = time.time() - self.start_time | |
| completion_msg = f"{final_message} | Total time: {timedelta(seconds=int(elapsed))}" | |
| self._update_progress(100, completion_msg) | |
| def error(self, error_message: str): | |
| """Mark current step as errored.""" | |
| if self.current_step_index < len(self.steps): | |
| self.steps[self.current_step_index].status = "error" | |
| self.steps[self.current_step_index].detail = error_message | |
| self._update_progress(self._calculate_progress(), f"ERROR: {error_message}") | |
| def get_progress_info(self) -> dict: | |
| """Get current progress information as dictionary.""" | |
| return { | |
| "current_step": self.current_step_index, | |
| "total_steps": len(self.steps), | |
| "progress_percent": self._calculate_progress(), | |
| "eta": self._estimate_remaining_time(), | |
| "elapsed": str(timedelta(seconds=int(time.time() - self.start_time))), | |
| "steps": [ | |
| {"name": s.name, "status": s.status, "detail": s.detail} | |
| for s in self.steps | |
| ] | |
| } | |
| def create_progress_tracker(progress: gr.Progress = None, is_webui: bool = True) -> EnhancedProgressTracker: | |
| """ | |
| Factory function to create a pre-configured progress tracker | |
| for the song cover pipeline. | |
| """ | |
| tracker = EnhancedProgressTracker(progress=progress, is_webui=is_webui) | |
| # Define pipeline steps with weights reflecting relative processing time | |
| tracker.set_steps([ | |
| ("Initializing Pipeline", 1), | |
| ("Downloading/Loading Audio", 3), | |
| ("Converting to Stereo", 1), | |
| ("Separating Vocals from Instrumental", 15), | |
| ("Separating Main Vocals from Backup", 12), | |
| ("Applying DeReverb to Vocals", 10), | |
| ("Extracting Voiceless Track", 8), | |
| ("Converting Voice with RVC", 25), | |
| ("Applying Audio Effects", 8), | |
| ("Applying Pitch Shift", 5), | |
| ("Combining Audio Tracks", 7), | |
| ("Finalizing Output", 5), | |
| ]) | |
| return tracker | |
| # ============================================================================= | |
| # UTILITY FUNCTIONS | |
| # ============================================================================= | |
| def clean_old_folders(base_path: str, max_age_seconds: int = 10800): | |
| """Clean up old output folders to save disk space.""" | |
| if not os.path.isdir(base_path): | |
| logging.warning(f"Error: {base_path} is not a valid directory.") | |
| return | |
| now = time.time() | |
| cleaned_count = 0 | |
| for folder_name in os.listdir(base_path): | |
| folder_path = os.path.join(base_path, folder_name) | |
| if os.path.isdir(folder_path): | |
| last_modified = os.path.getmtime(folder_path) | |
| if now - last_modified > max_age_seconds: | |
| try: | |
| shutil.rmtree(folder_path) | |
| cleaned_count += 1 | |
| except Exception as e: | |
| logging.warning(f"Failed to delete {folder_path}: {e}") | |
| if cleaned_count > 0: | |
| logging.info(f"Cleaned up {cleaned_count} old folders from {base_path}") | |
| def get_youtube_video_id(url, ignore_playlist=True): | |
| """ | |
| Extract video ID from various YouTube URL formats. | |
| Examples: | |
| - http://youtu.be/SA2iWivDJiE | |
| - http://www.youtube.com/watch?v=_oPAwA_Udwc&feature=feedu | |
| - http://www.youtube.com/embed/SA2iWivDJiE | |
| """ | |
| if "m.youtube.com" in url: | |
| url = url.replace("m.youtube.com", "www.youtube.com") | |
| query = urlparse(url) | |
| if query.hostname == 'youtu.be': | |
| if query.path[1:] == 'watch': | |
| return query.query[2:] | |
| return query.path[1:] | |
| if query.hostname in {'www.youtube.com', 'youtube.com', 'music.youtube.com'}: | |
| if not ignore_playlist: | |
| with suppress(KeyError): | |
| return parse_qs(query.query)['list'][0] | |
| if query.path == '/watch': | |
| return parse_qs(query.query)['v'][0] | |
| if query.path[:7] == '/watch/': | |
| return query.path.split('/')[1] | |
| if query.path[:7] == '/embed/': | |
| return query.path.split('/')[2] | |
| if query.path[:3] == '/v/': | |
| return query.path.split('/')[1] | |
| return None | |
| def yt_download(link, progress_callback=None): | |
| """ | |
| Download audio from YouTube URL with progress callback support. | |
| Args: | |
| link: YouTube video URL | |
| progress_callback: Optional function(status, percent) for progress updates | |
| Returns: | |
| Path to downloaded audio file | |
| """ | |
| if not link.strip(): | |
| raise ValueError("You need to provide a download link.") | |
| def progress_hook(d): | |
| if progress_callback and d['status'] == 'downloading': | |
| if 'downloaded_bytes' in d and 'total_bytes' in d and d['total_bytes'] > 0: | |
| percent = (d['downloaded_bytes'] / d['total_bytes']) * 100 | |
| progress_callback(f"Downloading: {percent:.1f}%", min(percent, 90)) | |
| ydl_opts = { | |
| 'format': 'bestaudio/best', | |
| 'outtmpl': '%(title)s', | |
| 'nocheckcertificate': True, | |
| 'ignoreerrors': True, | |
| 'no_warnings': True, | |
| 'quiet': True, | |
| 'extractaudio': True, | |
| 'postprocessors': [{ | |
| 'key': 'FFmpegExtractAudio', | |
| 'preferredcodec': 'mp3', | |
| 'preferredquality': '192', | |
| }], | |
| 'progress_hooks': [progress_hook] if progress_callback else [], | |
| } | |
| with yt_dlp.YoutubeDL(ydl_opts) as ydl: | |
| result = ydl.extract_info(link, download=True) | |
| if result is None: | |
| raise Exception("Failed to extract video information") | |
| download_path = ydl.prepare_filename(result, outtmpl='%(title)s.mp3') | |
| return download_path | |
| def raise_exception(error_msg, is_webui): | |
| """Raise appropriate exception based on context.""" | |
| if is_webui: | |
| raise gr.Error(error_msg) | |
| else: | |
| raise Exception(error_msg) | |
| def get_rvc_model(voice_model, is_webui): | |
| """ | |
| Locate RVC model files (.pth and .index) in model directory. | |
| Returns: | |
| Tuple of (model_path, index_path) | |
| """ | |
| rvc_model_filename, rvc_index_filename = None, None | |
| model_dir = os.path.join(rvc_models_dir, voice_model) | |
| if not os.path.isdir(model_dir): | |
| error_msg = f'Model directory does not exist: {model_dir}' | |
| raise_exception(error_msg, is_webui) | |
| for file in os.listdir(model_dir): | |
| file_path = os.path.join(model_dir, file) | |
| # Handle nested directories | |
| if os.path.isdir(file_path): | |
| for ff in os.listdir(file_path): | |
| ext = os.path.splitext(ff)[1] | |
| if ext == '.pth': | |
| rvc_model_filename = ff | |
| elif ext == '.index': | |
| rvc_index_filename = ff | |
| else: | |
| ext = os.path.splitext(file)[1] | |
| if ext == '.pth': | |
| rvc_model_filename = file | |
| elif ext == '.index': | |
| rvc_index_filename = file | |
| if rvc_model_filename is None: | |
| error_msg = f'No model file (.pth) found in {model_dir}.' | |
| raise_exception(error_msg, is_webui) | |
| model_path = os.path.join(model_dir, rvc_model_filename) | |
| index_path = os.path.join(model_dir, rvc_index_filename) if rvc_index_filename else '' | |
| return model_path, index_path | |
| def get_audio_paths(song_dir): | |
| """Extract various audio paths from processed song directory.""" | |
| orig_song_path = None | |
| instrumentals_path = None | |
| main_vocals_dereverb_path = None | |
| backup_vocals_path = None | |
| for file in os.listdir(song_dir): | |
| if file.endswith('_Instrumental.wav'): | |
| instrumentals_path = os.path.join(song_dir, file) | |
| orig_song_path = instrumentals_path.replace('_Instrumental', '') | |
| elif file.endswith('_Vocals_Main_DeReVerb.wav'): | |
| main_vocals_dereverb_path = os.path.join(song_dir, file) | |
| elif file.endswith('_Vocals_Backup.wav'): | |
| backup_vocals_path = os.path.join(song_dir, file) | |
| return orig_song_path, instrumentals_path, main_vocals_dereverb_path, backup_vocals_path | |
| def get_audio_with_suffix(song_dir, suffix="_mysuffix.wav"): | |
| """Find audio file with specific suffix in directory.""" | |
| for file in os.listdir(song_dir): | |
| if file.endswith(suffix): | |
| return os.path.join(song_dir, file) | |
| return None | |
| def convert_to_stereo(audio_path): | |
| """ | |
| Convert mono audio to stereo if needed. | |
| FIXED: Added file existence validation and better error handling | |
| """ | |
| # Validate file exists first | |
| if not audio_path or not os.path.exists(audio_path): | |
| raise FileNotFoundError(f"Audio file not found for stereo conversion: {audio_path}") | |
| try: | |
| # Try loading with soundfile first (more reliable) | |
| try: | |
| data, sr = sf.read(audio_path) | |
| if len(data.shape) == 1 or data.shape[1] == 1: | |
| # Mono file - need conversion | |
| stereo_path = f'{os.path.splitext(audio_path)[0]}_stereo.wav' | |
| command = shlex.split(f'ffmpeg -y -loglevel error -i "{audio_path}" -ac 2 -f wav "{stereo_path}"') | |
| result = subprocess.run(command, capture_output=True, text=True, timeout=60) | |
| if result.returncode != 0: | |
| logging.error(f"FFmpeg stereo conversion failed: {result.stderr}") | |
| return audio_path # Return original if conversion fails | |
| return stereo_path | |
| else: | |
| return audio_path # Already stereo | |
| except Exception as sf_error: | |
| logging.debug(f"soundfile failed, trying librosa: {sf_error}") | |
| # Fallback to librosa | |
| wave, sr_librosa = librosa.load(audio_path, mono=False, sr=44100) | |
| # Check if mono | |
| if type(wave[0]) != np.ndarray: | |
| stereo_path = f'{os.path.splitext(audio_path)[0]}_stereo.wav' | |
| command = shlex.split(f'ffmpeg -y -loglevel error -i "{audio_path}" -ac 2 -f wav "{stereo_path}"') | |
| result = subprocess.run(command, capture_output=True, text=True, timeout=60) | |
| if result.returncode != 0: | |
| logging.error(f"FFmpeg stereo conversion failed: {result.stderr}") | |
| return audio_path | |
| return stereo_path | |
| else: | |
| return audio_path | |
| except FileNotFoundError: | |
| raise # Re-raise FileNotFoundError with original message | |
| except Exception as e: | |
| logging.warning(f"Stereo conversion warning for {audio_path}: {e}") | |
| # Return original path if conversion fails completely | |
| return audio_path | |
| def pitch_shift(audio_path, pitch_change): | |
| """Apply pitch shift to audio file.""" | |
| output_path = f'{os.path.splitext(audio_path)[0]}_p{pitch_change}.wav' | |
| if not os.path.exists(output_path): | |
| y, sr = sf.read(audio_path) | |
| tfm = sox.Transformer() | |
| tfm.pitch(pitch_change) | |
| y_shifted = tfm.build_array(input_array=y, sample_rate_in=sr) | |
| sf.write(output_path, y_shifted, sr) | |
| return output_path | |
| def get_hash(filepath): | |
| """Generate short hash of file for identification.""" | |
| with open(filepath, 'rb') as f: | |
| file_hash = hashlib.blake2b() | |
| while chunk := f.read(8192): | |
| file_hash.update(chunk) | |
| return file_hash.hexdigest()[:11] | |
| # ============================================================================= | |
| # AUDIO PROCESSING FUNCTIONS | |
| # ============================================================================= | |
| def add_audio_effects(audio_path, reverb_rm_size, reverb_wet, reverb_dry, reverb_damping): | |
| """Apply professional audio effects (HPF, Compressor, Reverb).""" | |
| output_path = f'{os.path.splitext(audio_path)[0]}_mixed.wav' | |
| board = Pedalboard( | |
| [ | |
| HighpassFilter(cutoff_frequency=80), | |
| Compressor(ratio=4, threshold_db=-15), | |
| Reverb(room_size=reverb_rm_size, dry_level=reverb_dry, wet_level=reverb_wet, damping=reverb_damping) | |
| ] | |
| ) | |
| with AudioFile(audio_path) as f: | |
| with AudioFile(output_path, 'w', f.samplerate, f.num_channels) as o: | |
| while f.tell() < f.frames: | |
| chunk = f.read(int(f.samplerate)) | |
| effected = board(chunk, f.samplerate, reset=False) | |
| o.write(effected) | |
| return output_path | |
| def combine_audio(audio_paths, output_path, main_gain, backup_gain, inst_gain, output_format): | |
| """Combine multiple audio tracks with gain adjustments.""" | |
| try: | |
| main_vocal_audio = AudioSegment.from_wav(audio_paths[0]) - 4 + main_gain | |
| backup_vocal_audio = AudioSegment.from_wav(audio_paths[1]) - 6 + backup_gain | |
| instrumental_audio = AudioSegment.from_wav(audio_paths[2]) - 7 + inst_gain | |
| combined = main_vocal_audio.overlay(backup_vocal_audio).overlay(instrumental_audio) | |
| combined.export(output_path, format=output_format) | |
| except Exception as e: | |
| logging.error(f"Audio combining error: {e}") | |
| raise | |
| def apply_noisereduce(audio_list, type_output="wav"): | |
| """Apply noise reduction to audio files.""" | |
| result = [] | |
| for audio_path in audio_list: | |
| out_path = f"{os.path.splitext(audio_path)[0]}_nr.{type_output}" | |
| try: | |
| audio = AudioSegment.from_file(audio_path) | |
| samples = np.array(audio.get_array_of_samples()) | |
| reduced_noise = nr.reduce_noise( | |
| samples, | |
| sr=audio.frame_rate, | |
| prop_decrease=0.6, | |
| n_std_thresh_stationary=1.5 | |
| ) | |
| reduced_audio = AudioSegment( | |
| reduced_noise.tobytes(), | |
| frame_rate=audio.frame_rate, | |
| sample_width=audio.sample_width, | |
| channels=audio.channels | |
| ) | |
| reduced_audio.export(out_path, format=type_output) | |
| result.append(out_path) | |
| except Exception as e: | |
| logging.warning(f"Noise reduction error for {audio_path}: {e}") | |
| result.append(audio_path) | |
| return result | |
| # ============================================================================= | |
| # MAIN PROCESSING PIPELINE | |
| # ============================================================================= | |
| def preprocess_song(mdx_model_params, song_output_dir, orig_song_path, keep_orig=False, | |
| tracker: EnhancedProgressTracker = None): | |
| """ | |
| Preprocess song: separate vocals/instrumentals, apply dereverb. | |
| Returns: | |
| Tuple of all processed audio paths | |
| """ | |
| # Step: Vocal Separation | |
| if tracker: | |
| tracker.update_step(detail="Running MDX vocal separation...") | |
| vocals_path, instrumentals_path = run_mdx( | |
| mdx_model_params, | |
| song_output_dir, | |
| os.path.join(mdxnet_models_dir, 'UVR-MDX-NET-Voc_FT.onnx'), | |
| orig_song_path, | |
| denoise=True, | |
| keep_orig=keep_orig | |
| ) | |
| if tracker: | |
| tracker.next_step(detail="Separating main from backup vocals...") | |
| # Step: Main/Backup Vocal Separation | |
| backup_vocals_path, main_vocals_path = run_mdx( | |
| mdx_model_params, | |
| song_output_dir, | |
| os.path.join(mdxnet_models_dir, 'UVR_MDXNET_KARA_2.onnx'), | |
| vocals_path, | |
| suffix='Backup', | |
| invert_suffix='Main', | |
| denoise=True | |
| ) | |
| if tracker: | |
| tracker.next_step(detail="Applying dereverb processing...") | |
| # Step: Dereverb | |
| _, main_vocals_dereverb_path = run_mdx( | |
| mdx_model_params, | |
| song_output_dir, | |
| os.path.join(mdxnet_models_dir, 'Reverb_HQ_By_FoxJoy.onnx'), | |
| main_vocals_path, | |
| invert_suffix='DeReverb', | |
| exclude_main=True, | |
| denoise=True | |
| ) | |
| return orig_song_path, vocals_path, instrumentals_path, main_vocals_path, backup_vocals_path, main_vocals_dereverb_path | |
| def voice_change(voice_model, vocals_path, output_path, pitch_change, f0_method, | |
| index_rate, filter_radius, rms_mix_rate, protect, crepe_hop_length, | |
| is_webui, steps, tracker: EnhancedProgressTracker = None): | |
| """ | |
| Perform RVC voice conversion on vocals. | |
| """ | |
| if tracker: | |
| tracker.update_step(detail="Loading RVC model...") | |
| rvc_model_path, rvc_index_path = get_rvc_model(voice_model, is_webui) | |
| cpt, version, net_g, tgt_sr, vc = get_vc(device, config.is_half, config, rvc_model_path) | |
| if tracker: | |
| tracker.update_step(detail="Running voice conversion (this may take a while)...") | |
| global hubert_model | |
| rvc_infer( | |
| rvc_index_path, index_rate, vocals_path, output_path, pitch_change, | |
| f0_method, cpt, version, net_g, filter_radius, tgt_sr, rms_mix_rate, | |
| protect, crepe_hop_length, vc, hubert_model, steps | |
| ) | |
| del cpt | |
| gc.collect() | |
| torch.cuda.empty_cache() if torch.cuda.is_available() else None | |
| def process_song( | |
| song_dir, mdx_model_params, song_id, is_webui, input_type, | |
| keep_files, pitch_change, pitch_change_all, voice_model, index_rate, | |
| filter_radius, rms_mix_rate, protect, f0_method, crepe_hop_length, | |
| output_format, keep_orig, orig_song_path, steps, | |
| tracker: EnhancedProgressTracker = None | |
| ): | |
| """ | |
| Process song through the full pipeline. | |
| """ | |
| if not os.path.exists(song_dir): | |
| os.makedirs(song_dir) | |
| orig_song_path, vocals_path, instrumentals_path, main_vocals_path, backup_vocals_path, main_vocals_dereverb_path = preprocess_song( | |
| mdx_model_params, song_dir, orig_song_path, keep_orig, tracker | |
| ) | |
| else: | |
| vocals_path, main_vocals_path = None, None | |
| paths = get_audio_paths(song_dir) | |
| if any(path is None for path in paths): | |
| orig_song_path, vocals_path, instrumentals_path, main_vocals_path, backup_vocals_path, main_vocals_dereverb_path = preprocess_song( | |
| mdx_model_params, song_dir, orig_song_path, keep_orig, tracker | |
| ) | |
| else: | |
| orig_song_path, instrumentals_path, main_vocals_dereverb_path, backup_vocals_path = paths | |
| # Voiceless track extraction | |
| ins_path = get_audio_with_suffix(song_dir, "_Voiceless.wav") | |
| if not ins_path: | |
| if tracker: | |
| tracker.next_step(detail="Extracting voiceless instrumental track...") | |
| instrumentals_path, _ = run_mdx( | |
| mdx_model_params, | |
| song_dir, | |
| os.path.join(mdxnet_models_dir, "UVR-MDX-NET-Inst_HQ_4.onnx"), | |
| instrumentals_path, | |
| exclude_inversion=True, | |
| suffix="Voiceless", | |
| denoise=False, | |
| keep_orig=True, | |
| base_device=("cuda" if IS_ZERO_GPU else "") | |
| ) | |
| ins_path = get_audio_with_suffix(song_dir, "_Voiceless.wav") | |
| else: | |
| instrumentals_path = ins_path | |
| # Prepare output paths | |
| pitch_change_total = pitch_change * 12 + pitch_change_all | |
| ai_vocals_path = os.path.join( | |
| song_dir, | |
| f'{os.path.splitext(os.path.basename(orig_song_path))[0]}_{voice_model}_p{pitch_change_total}_i{index_rate}_fr{filter_radius}_rms{rms_mix_rate}_pro{protect}_{f0_method}{"" if f0_method != "mangio-crepe" else f"_{crepe_hop_length}"}_s{steps}.wav' | |
| ) | |
| ai_cover_path = os.path.join( | |
| song_dir, | |
| f'{os.path.splitext(os.path.basename(orig_song_path))[0]} ({voice_model} Ver).{output_format}' | |
| ) | |
| # RVC Voice Conversion | |
| if not os.path.exists(ai_vocals_path): | |
| if tracker: | |
| tracker.next_step() | |
| voice_change( | |
| voice_model, main_vocals_dereverb_path, ai_vocals_path, pitch_change_total, | |
| f0_method, index_rate, filter_radius, rms_mix_rate, protect, | |
| crepe_hop_length, is_webui, steps, tracker | |
| ) | |
| return ai_vocals_path, ai_cover_path, instrumentals_path, backup_vocals_path, vocals_path, main_vocals_path, ins_path | |
| def song_cover_pipeline( | |
| song_input, voice_model, pitch_change, keep_files, | |
| is_webui=0, main_gain=0, backup_gain=0, inst_gain=0, index_rate=0.5, | |
| filter_radius=3, rms_mix_rate=0.25, f0_method='rmvpe', crepe_hop_length=128, | |
| protect=0.33, pitch_change_all=0, reverb_rm_size=0.15, reverb_wet=0.2, | |
| reverb_dry=0.8, reverb_damping=0.7, output_format='mp3', extra_denoise=False, | |
| steps=1, progress=gr.Progress() | |
| ): | |
| """ | |
| Main pipeline for generating AI song covers with enhanced progress tracking. | |
| """ | |
| # Initialize enhanced progress tracker | |
| tracker = create_progress_tracker(progress=progress, is_webui=bool(is_webui)) | |
| # Cleanup old folders | |
| if not keep_files or IS_ZERO_GPU: | |
| clean_old_folders("./song_output", 14400) | |
| if IS_ZERO_GPU: | |
| clean_old_folders("./rvc_models", 10800) | |
| try: | |
| # Input validation - with detailed error messages | |
| if not voice_model: | |
| raise_exception('Please select a voice model first!', is_webui) | |
| # Validate voice model exists | |
| model_dir = os.path.join(rvc_models_dir, voice_model) | |
| if not os.path.isdir(model_dir): | |
| raise_exception(f'Voice model not found: "{voice_model}". Please download or select a valid model.', is_webui) | |
| if song_input is None or (isinstance(song_input, str) and len(song_input.strip()) == 0): | |
| raise_exception('Please provide an audio file (select from folder, upload, or paste URL).', is_webui) | |
| # Clean up input path | |
| if isinstance(song_input, str): | |
| song_input = song_input.strip().strip('\"').strip("'") | |
| # CRITICAL: Validate file exists BEFORE processing | |
| if not urlparse(song_input).scheme: # Not a URL | |
| if not os.path.exists(song_input): | |
| raise_exception( | |
| f'Audio file not found: "{song_input}"\n\n' | |
| 'The file may have been deleted or moved. Please:\n' | |
| '1. Re-select the audio from the folder dropdown\n' | |
| '2. Re-upload the file\n' | |
| '3. Check that the file path is correct', | |
| is_webui | |
| ) | |
| # Verify file is readable and has content | |
| try: | |
| file_size = os.path.getsize(song_input) | |
| if file_size == 0: | |
| raise_exception(f'Audio file is empty (0 bytes): {song_input}', is_webui) | |
| logging.info(f"Audio file validated: {song_input} ({file_size/1024/1024:.2f} MB)") | |
| except OSError as e: | |
| raise_exception(f'Cannot read audio file: {song_input}\nError: {e}', is_webui) | |
| logging.info(f"Starting pipeline - Input: {song_input}, Model: {voice_model}") | |
| # Start progress tracking | |
| tracker.start("Initializing Hyper-RVC pipeline...") | |
| tracker.next_step(detail="Validating inputs...") | |
| # Load MDX model parameters | |
| with open(os.path.join(mdxnet_models_dir, 'model_data.json')) as infile: | |
| mdx_model_params = json.load(infile) | |
| # Determine input type | |
| if urlparse(song_input).scheme == 'https': | |
| input_type = 'yt' | |
| song_id = get_youtube_video_id(song_input) | |
| if song_id is None: | |
| raise_exception('Invalid YouTube URL.', is_webui) | |
| else: | |
| input_type = 'local' | |
| song_input = song_input.strip('"') | |
| if os.path.exists(song_input): | |
| song_id = get_hash(song_input) | |
| else: | |
| error_msg = f'File not found: {song_input}' | |
| raise_exception(error_msg, is_webui) | |
| song_id = None | |
| song_dir = os.path.join(output_dir, song_id) | |
| # Download/Load audio | |
| if input_type == 'yt': | |
| def yt_progress(status, percent): | |
| tracker.update_step(detail=status) | |
| tracker.next_step(detail="Downloading from YouTube...") | |
| orig_song_path = yt_download(song_input, progress_callback=yt_progress) | |
| keep_orig = False | |
| else: | |
| orig_song_path = song_input | |
| keep_orig = True | |
| tracker.next_step(detail="Loading local audio file...") | |
| # Convert to stereo | |
| tracker.next_step(detail="Converting to stereo if needed...") | |
| orig_song_path = convert_to_stereo(orig_song_path) | |
| start_time = time.time() | |
| # Run main processing pipeline | |
| ( | |
| ai_vocals_path, | |
| ai_cover_path, | |
| instrumentals_path, | |
| backup_vocals_path, | |
| vocals_path, | |
| main_vocals_path, | |
| ins_path | |
| ) = process_song( | |
| song_dir, | |
| mdx_model_params, | |
| song_id, | |
| is_webui, | |
| input_type, | |
| keep_files, | |
| pitch_change, | |
| pitch_change_all, | |
| voice_model, | |
| index_rate, | |
| filter_radius, | |
| rms_mix_rate, | |
| protect, | |
| f0_method, | |
| crepe_hop_length, | |
| output_format, | |
| keep_orig, | |
| orig_song_path, | |
| steps, | |
| tracker | |
| ) | |
| end_time = time.time() | |
| logging.info(f"Pipeline execution time: {end_time - start_time:.2f}s") | |
| # Apply audio effects | |
| tracker.next_step(detail="Applying reverb and effects...") | |
| if extra_denoise: | |
| tracker.update_step(detail="Applying noise reduction...") | |
| ai_vocals_path = apply_noisereduce([ai_vocals_path])[0] | |
| ai_vocals_mixed_path = add_audio_effects( | |
| ai_vocals_path, reverb_rm_size, reverb_wet, reverb_dry, reverb_damping | |
| ) | |
| # Pitch shift for instrumentals if needed | |
| if pitch_change_all != 0: | |
| tracker.next_step(detail="Applying overall pitch change...") | |
| instrumentals_path = pitch_shift(instrumentals_path, pitch_change_all) | |
| backup_vocals_path = pitch_shift(backup_vocals_path, pitch_change_all) | |
| # Combine tracks | |
| tracker.next_step(detail="Mixing final audio tracks...") | |
| combine_audio( | |
| [ai_vocals_mixed_path, backup_vocals_path, instrumentals_path], | |
| ai_cover_path, main_gain, backup_gain, inst_gain, output_format | |
| ) | |
| # Cleanup intermediate files | |
| if not keep_files: | |
| tracker.next_step(detail="Cleaning up temporary files...") | |
| intermediate_files = [vocals_path, main_vocals_path, ai_vocals_mixed_path] | |
| if extra_denoise and ai_vocals_path.endswith('_nr.wav'): | |
| intermediate_files.append(ai_vocals_path) | |
| if pitch_change_all != 0: | |
| intermediate_files += [instrumentals_path, backup_vocals_path] | |
| for file in intermediate_files: | |
| if file and os.path.exists(file) and file != ins_path: | |
| try: | |
| os.remove(file) | |
| except Exception as e: | |
| logging.debug(f"Cleanup: Could not remove {file}") | |
| # Complete! | |
| tracker.complete("AI Cover generated successfully!") | |
| return ai_cover_path | |
| except Exception as e: | |
| tracker.error(str(e)) | |
| raise_exception(str(e), is_webui) | |
| # ============================================================================= | |
| # BATCH PROCESSING | |
| # ============================================================================= | |
| def batch_process_files( | |
| input_files, voice_model, pitch_change, f0_method, index_rate, protect, | |
| output_format, progress=gr.Progress() | |
| ): | |
| """ | |
| Process multiple audio files with the same RVC model settings. | |
| Features enhanced progress tracking with per-file progress. | |
| """ | |
| if not input_files: | |
| raise gr.Error("No files provided for batch processing!") | |
| if not voice_model: | |
| raise gr.Error("Please select a voice model!") | |
| # Initialize batch progress tracker | |
| tracker = EnhancedProgressTracker(progress=progress, is_webui=True) | |
| tracker.start("Starting batch processing...") | |
| results = [] | |
| total_files = len(input_files) | |
| batch_output_dir = os.path.join(output_dir, 'batch_output') | |
| os.makedirs(batch_output_dir, exist_ok=True) | |
| # Load RVC model once for efficiency | |
| tracker.update_step(detail="Loading RVC model (shared across all files)...") | |
| rvc_model_path, rvc_index_path = get_rvc_model(voice_model, True) | |
| cpt, version, net_g, tgt_sr, vc = get_vc(device, config.is_half, config, rvc_model_path) | |
| global hubert_model | |
| for i, file_info in enumerate(input_files): | |
| try: | |
| # Get file path | |
| if hasattr(file_info, 'name'): | |
| input_path = file_info.name | |
| elif isinstance(file_info, str): | |
| input_path = file_info | |
| else: | |
| continue | |
| filename = os.path.basename(input_path) | |
| base_name = os.path.splitext(filename)[0] | |
| # Update progress with file info | |
| file_progress = ((i) / total_files) * 100 | |
| tracker.update_step( | |
| detail=f"Processing file {i+1}/{total_files}: {filename}", | |
| sub_progress=0 | |
| ) | |
| # Load and convert audio | |
| audio, sr = librosa.load(input_path, sr=16000, mono=True) | |
| temp_input = os.path.join(batch_output_dir, f'temp_{base_name}.wav') | |
| sf.write(temp_input, audio, 16000) | |
| output_path = os.path.join(batch_output_dir, f'{base_name}_{voice_model}_converted.{output_format}') | |
| # Update progress for voice conversion | |
| tracker.update_step( | |
| detail=f"[{i+1}/{total_files}] Converting voice: {filename}", | |
| sub_progress=0.5 | |
| ) | |
| # Run voice conversion | |
| rvc_infer( | |
| rvc_index_path, index_rate, temp_input, | |
| output_path.replace(f'.{output_format}', '.wav'), | |
| pitch_change * 12, f0_method, cpt, version, net_g, | |
| 3, tgt_sr, 0.25, protect, 128, vc, hubert_model, 1 | |
| ) | |
| # Convert to desired format if needed | |
| if output_format != 'wav': | |
| wav_path = output_path.replace(f'.{output_format}', '.wav') | |
| y, sr_out = sf.read(wav_path) | |
| sf.write(output_path, y, sr_out) | |
| os.remove(wav_path) | |
| else: | |
| output_path = output_path.replace(f'.{output_format}', '.wav') | |
| results.append(output_path) | |
| logging.info(f"[+] Batch processed: {filename}") | |
| # Mark file complete | |
| tracker.update_step( | |
| detail=f"[✓] Completed: {filename}", | |
| sub_progress=1.0 | |
| ) | |
| except Exception as e: | |
| logging.error(f"[-] Error processing {getattr(file_info, 'name', 'unknown')}: {str(e)}") | |
| continue | |
| # Cleanup GPU resources | |
| del cpt | |
| gc.collect() | |
| if torch.cuda.is_available(): | |
| torch.cuda.empty_cache() | |
| tracker.complete(f"Batch complete! {len(results)}/{total_files} files processed.") | |
| return results | |
| # ============================================================================= | |
| # PREVIEW GENERATION | |
| # ============================================================================= | |
| def generate_preview( | |
| audio_path, duration=10, voice_model=None, pitch_change=0, f0_method='rmvpe+', | |
| index_rate=0.5, protect=0.33, progress=gr.Progress() | |
| ): | |
| """ | |
| Generate a short preview clip of the voice conversion. | |
| Useful for testing settings before full conversion. | |
| """ | |
| tracker = EnhancedProgressTracker(progress=progress, is_webui=True) | |
| tracker.set_steps([ | |
| ("Loading Audio", 2), | |
| ("Extracting Preview Segment", 1), | |
| ("Loading RVC Model", 3), | |
| ("Converting Preview", 4), | |
| ("Finalizing", 1) | |
| ]) | |
| tracker.start() | |
| if not audio_path: | |
| raise gr.Error("Please provide an audio file for preview!") | |
| tracker.next_step(detail="Reading audio file...") | |
| # Load audio | |
| audio, sr = librosa.load(audio_path, sr=16000, mono=True) | |
| # Extract preview segment (from middle of audio for best representation) | |
| max_samples = int(duration * sr) | |
| start_sample = min(len(audio) // 2 - max_samples // 2, len(audio) - max_samples) | |
| start_sample = max(0, start_sample) | |
| preview_audio = audio[start_sample:start_sample + max_samples] | |
| # Save preview input | |
| preview_dir = os.path.join(output_dir, 'preview') | |
| os.makedirs(preview_dir, exist_ok=True) | |
| preview_input_path = os.path.join(preview_dir, 'preview_input.wav') | |
| sf.write(preview_input_path, preview_audio, 16000) | |
| tracker.next_step(detail="Preview extracted, loading model...") | |
| # If no model specified, return original audio | |
| if not voice_model: | |
| tracker.complete("Returning original audio preview.") | |
| return preview_input_path | |
| # Load model | |
| rvc_model_path, rvc_index_path = get_rvc_model(voice_model, True) | |
| cpt, version, net_g, tgt_sr, vc = get_vc(device, config.is_half, config, rvc_model_path) | |
| global hubert_model | |
| tracker.next_step(detail="Generating voice conversion preview...") | |
| # Convert preview | |
| preview_output_path = os.path.join(preview_dir, 'preview_output.wav') | |
| rvc_infer( | |
| rvc_index_path, index_rate, preview_input_path, | |
| preview_output_path, pitch_change * 12, f0_method, cpt, version, net_g, | |
| 3, tgt_sr, 0.25, protect, 128, vc, hubert_model, 1 | |
| ) | |
| del cpt | |
| gc.collect() | |
| tracker.complete("Preview generated successfully!") | |
| return preview_output_path | |
| # ============================================================================= | |
| # GLOBAL INITIALIZATION & CLI | |
| # ============================================================================= | |
| device = "cuda:0" if torch.cuda.is_available() else "cpu" | |
| compute_half = True if torch.cuda.is_available() else False | |
| config = Config(device, compute_half) | |
| hubert_model = load_hubert("cuda", config.is_half, None) | |
| print(f"Device: {device}, Half precision: {config.is_half}") | |
| print("RVC models and Hubert loaded successfully.") | |
| if __name__ == '__main__': | |
| parser = argparse.ArgumentParser( | |
| description='Generate an AI cover song in the song_output/id directory.', | |
| add_help=True | |
| ) | |
| parser.add_argument('-i', '--song-input', type=str, required=True, help='Link to a YouTube video or filepath to local mp3/wav') | |
| parser.add_argument('-dir', '--rvc-dirname', type=str, required=True, help='Name of folder in rvc_models containing RVC model') | |
| parser.add_argument('-p', '--pitch-change', type=int, required=True, help='Pitch change in octaves (+1 M→F, -1 F→M)') | |
| parser.add_argument('-k', '--keep-files', action=argparse.BooleanOptionalAction, help='Keep intermediate audio files') | |
| parser.add_argument('-ir', '--index-rate', type=float, default=0.5, help='Index rate for timbre control (0-1)') | |
| parser.add_argument('-fr', '--filter-radius', type=int, default=3, help='Filter radius for median filtering (0-7)') | |
| parser.add_argument('-rms', '--rms-mix-rate', type=float, default=0.25, help='RMS mix rate (0=original loudness, 1=fixed)') | |
| parser.add_argument('-palgo', '--pitch-detection-algo', type=str, default='rmvpe', help='Pitch detection algorithm') | |
| parser.add_argument('-hop', '--crepe-hop-length', type=int, default=128, help='Crepe hop length for mangio-crepe') | |
| parser.add_argument('-pro', '--protect', type=float, default=0.33, help='Protect voiceless consonants (0-0.5)') | |
| parser.add_argument('-mv', '--main-vol', type=int, default=0, help='Main vocals volume (dB)') | |
| parser.add_argument('-bv', '--backup-vol', type=int, default=0, help='Backup vocals volume (dB)') | |
| parser.add_argument('-iv', '--inst-vol', type=int, default=0, help='Instrumentals volume (dB)') | |
| parser.add_argument('-pall', '--pitch-change-all', type=int, default=0, help='Overall pitch change (semitones)') | |
| parser.add_argument('-rsize', '--reverb-size', type=float, default=0.15, help='Reverb room size (0-1)') | |
| parser.add_argument('-rwet', '--reverb-wetness', type=float, default=0.2, help='Reverb wet level (0-1)') | |
| parser.add_argument('-rdry', '--reverb-dryness', type=float, default=0.8, help='Reverb dry level (0-1)') | |
| parser.add_argument('-rdamp', '--reverb-damping', type=float, default=0.7, help='Reverb damping (0-1)') | |
| parser.add_argument('-oformat', '--output-format', type=str, default='mp3', help='Output format (mp3/wav)') | |
| args = parser.parse_args() | |
| rvc_dirname = args.rvc_dirname | |
| if not os.path.exists(os.path.join(rvc_models_dir, rvc_dirname)): | |
| raise Exception(f'The folder {os.path.join(rvc_models_dir, rvc_dirname)} does not exist.') | |
| cover_path = song_cover_pipeline( | |
| args.song_input, rvc_dirname, args.pitch_change, args.keep_files, | |
| main_gain=args.main_vol, backup_gain=args.backup_vol, inst_gain=args.inst_vol, | |
| index_rate=args.index_rate, filter_radius=args.filter_radius, | |
| rms_mix_rate=args.rms_mix_rate, f0_method=args.pitch_detection_algo, | |
| crepe_hop_length=args.crepe_hop_length, protect=args.protect, | |
| pitch_change_all=args.pitch_change_all, | |
| reverb_rm_size=args.reverb_size, reverb_wet=args.reverb_wetness, | |
| reverb_dry=args.reverb_dryness, reverb_damping=args.reverb_damping, | |
| output_format=args.output_format | |
| ) | |
| print(f'[+] Cover generated at {cover_path}') | |