Download voice_engine.py from Opera10/sada9: direct link, hf CLI and curl.
- Browser
- Download file 40.9 kB
-
https://huggingface.co/spaces/Opera10/sada9/resolve/main/voice_engine.py
- Command line
-
hf download hf://spaces/Opera10/sada9/voice_engine.py
-
curl -L -o voice_engine.py https://huggingface.co/spaces/Opera10/sada9/resolve/main/voice_engine.py
40.9 kB
| # ========================================================================== | |
| # FILE: voice_engine.py — موتورِ «کلون کردن صدا» (Vevo)، کاملاً روی CPU | |
| # -------------------------------------------------------------------------- | |
| # این فایل همان کدِ اسپیسِ صدای قبلی (Sada) است، بدونِ رابطِ Gradio: | |
| # • همان پچها (pkg_resources، LangSegment، Amphion و transformers) | |
| # • همان مدل: amphion/Vevo — تبدیلِ timbre با flow matching در ۳۲ گام | |
| # • همان برشِ هوشمند از روی سکوت (smart_split_audio_vevo) و همان ادغام | |
| # • همان تعدادِ هسته: همهی هستههای واقعیِ کانتینر (cgroup)، بدونِ GPU | |
| # هیچ تنظیمی از مدل عوض نشده؛ کیفیتِ خروجی دقیقاً مثلِ قبل است. | |
| # | |
| # چرا یک پروسهی جدا (و نه داخلِ همان پروسهی Gradio)؟ | |
| # در این اسپیس «ساخت آهنگ» روی ZeroGPU است. ZeroGPU برای هر کارِ GPU از | |
| # پروسهی اصلی fork میگیرد. اگر تبدیلِ صدا با ۱۶ رشتهی CPU داخلِ همان پروسه | |
| # اجرا شود، fork وسطِ کارِ چندرشتهایِ OpenMP/MKL میتواند پروسهی GPU را قفل | |
| # کند. پچهای سراسریِ این موتور (LlamaConfig، pkg_resources و ...) هم نباید | |
| # روی مدلهای آهنگ اثر بگذارند. برای همین این موتور در پروسهی خودش اجرا | |
| # میشود و spaces/ZeroGPU را اصلاً ایمپورت نمیکند؛ یعنی دقیقاً مثلِ اسپیسِ | |
| # صدای قبلی، فقط CPU و RAM مصرف میکند و هیچ سهمیهی GPU نمیگیرد. | |
| # | |
| # اجرا: voice_clone.py این فایل را با python -u voice_engine.py --serve <socket> | |
| # بالا میآورد و کارها را از راهِ یک سوکتِ محلی (فقط داخلِ همین کانتینر) میفرستد. | |
| # | |
| # 🆕 تغییرهای این نسخه (طبقِ درخواست و از روی لاگهای اسپیسِ قبلی): | |
| # ۱) صدای مرجع حداکثر ۱۲ ثانیه (VOICE_REF_MAX_SECONDS): مدلِ Vevo صدای مرجع را جلوی «هر» تکه | |
| # میگذارد؛ مرجعِ ۳ دقیقهای یعنی هر تکهی ۶ ثانیهای مثلِ یک صدای ۳ دقیقهای حساب میشد | |
| # (همان ۲۰۰ ثانیهایهای لاگ). مدل با مرجعِ حداکثر ~۱۲ ثانیه آموزش دیده؛ پس از مرجعِ بلندتر | |
| # «پُرصداترین» ۱۲ ثانیه برداشته میشود (از روی سکوت بریده، نه وسطِ کلمه). مرجعِ کوتاهتر | |
| # دست نمیخورد. | |
| # ۲) دو کارِ همزمان (VOICE_CONCURRENT_JOBS=2، فقط وقتی اکشن پرو جای خالیِ دیگری ندارد): | |
| # کارِ تنها همهی هستهها را میگیرد (مثلِ قبل)؛ وقتی دو کار با هماند هر کدام نصف. تقسیمِ | |
| # هستهها در «هر گامِ» مدل دوباره حساب میشود (نه فقط اولِ کار). | |
| # ۳) کارِ بیصاحب لغو میشود: اگر درخواستدهنده دیگر منتظر نبود (اتصال قطع شد / زمانش تمام شد)، | |
| # همان لحظه (در گامِ بعدیِ مدل) متوقف میشود و CPU را برای کارهای واقعی آزاد میکند. قبلاً | |
| # کارِ بیصاحب تا آخر ادامه میداد و کارهای بعدی پشتش میماندند (BrokenPipe و ۶۴۵/۶۴۷ ثانیهایهای | |
| # لاگ برای فایلهای دوتکهای). | |
| # هر تکه دقیقاً مثلِ قبل ساخته میشود (همان نرمالسازی، همان ۳۲ گام، همان مدل) و تکههای یک | |
| # فایل مثلِ قبل یکییکی و به ترتیب ساخته و به هم وصل میشوند. | |
| # ========================================================================== | |
| import os | |
| import sys | |
| import json | |
| import time | |
| import signal | |
| import threading | |
| import importlib.util | |
| import torch | |
| import torchaudio # noqa: F401 (مثلِ قبل: Amphion به آن نیاز دارد) | |
| import numpy as np | |
| from huggingface_hub import snapshot_download, hf_hub_download | |
| import subprocess | |
| import uuid | |
| import soundfile as sf | |
| import librosa | |
| import shutil | |
| import re | |
| import warnings | |
| # مرجعی که PySoundFile نمیخواند (بعضی mp3ها) خودکار با audioread خوانده میشود؛ فقط هشدارِ | |
| # «منسوخ شدن» در لاگ میماند که هیچ خطایی نیست — بیصدا شد تا لاگ تمیز بماند (رفتار همان است). | |
| warnings.filterwarnings("ignore", message="PySoundFile failed") | |
| warnings.filterwarnings("ignore", category=FutureWarning, module="librosa") | |
| # --- رفع خطای pkg_resources (نسخههای جدید setuptools آن را حذف کردهاند و pyworld به آن نیاز دارد) --- | |
| try: | |
| import pkg_resources # noqa: F401 | |
| except ImportError: | |
| import types | |
| from importlib import metadata as _md | |
| class _DistributionNotFound(Exception): | |
| pass | |
| class _Dist: | |
| def __init__(self, name): | |
| try: | |
| self.version = _md.version(name) | |
| except _md.PackageNotFoundError: | |
| raise _DistributionNotFound(name) | |
| self.project_name = name | |
| self.key = name.lower() | |
| _pr = types.ModuleType("pkg_resources") | |
| _pr.DistributionNotFound = _DistributionNotFound | |
| _pr.get_distribution = lambda name: _Dist(name) | |
| _pr.require = lambda *a, **k: [] | |
| sys.modules["pkg_resources"] = _pr | |
| print("[FIX] pkg_resources shim installed") | |
| # --- 1. نصب و تنظیمات اولیه (بدون تغییر) --- | |
| def install_espeak(): | |
| try: | |
| result = subprocess.run(["which", "espeak-ng"], capture_output=True, text=True) | |
| if result.returncode != 0: | |
| print("Installing espeak-ng...") | |
| subprocess.run(["apt-get", "update"], check=True) | |
| subprocess.run(["apt-get", "install", "-y", "espeak-ng", "espeak-ng-data"], check=True) | |
| except Exception as e: | |
| print(f"Error installing espeak-ng: {e}") | |
| install_espeak() | |
| def patch_langsegment_init(): | |
| try: | |
| spec = importlib.util.find_spec("LangSegment") | |
| if spec is None or spec.origin is None: return | |
| init_path = os.path.join(os.path.dirname(spec.origin), '__init__.py') | |
| with open(init_path, 'r') as f: lines = f.readlines() | |
| modified = False | |
| new_lines =[] | |
| target_line_prefix = "from .LangSegment import" | |
| for line in lines: | |
| if line.strip().startswith(target_line_prefix) and ('setLangfilters' in line or 'getLangfilters' in line): | |
| mod_line = line.replace(',setLangfilters', '').replace(',getLangfilters', '') | |
| mod_line = mod_line.replace('setLangfilters,', '').replace('getLangfilters,', '').rstrip(',') | |
| new_lines.append(mod_line + '\n') | |
| modified = True | |
| else: | |
| new_lines.append(line) | |
| if modified: | |
| with open(init_path, 'w') as f: f.writelines(new_lines) | |
| try: | |
| import LangSegment | |
| importlib.reload(LangSegment) | |
| except: pass | |
| except: pass | |
| patch_langsegment_init() | |
| if not os.path.exists("Amphion"): | |
| print("Cloning Amphion repository...") | |
| subprocess.run(["git", "clone", "https://github.com/open-mmlab/Amphion.git"]) | |
| amphion_path = os.path.abspath("Amphion") | |
| if amphion_path not in sys.path: | |
| sys.path.append(amphion_path) | |
| # --- پچ کردن تمام کدهای Amphion برای سازگاری با نسخه جدید transformers --- | |
| def patch_amphion_llama_config(): | |
| try: | |
| for root, dirs, files in os.walk(amphion_path): | |
| for file in files: | |
| if file.endswith(".py"): | |
| file_path = os.path.join(root, file) | |
| try: | |
| with open(file_path, "r", encoding="utf-8") as f: | |
| content = f.read() | |
| new_content = re.sub( | |
| r'LlamaConfig\(\s*0\s*,\s*256\s*,\s*1024\s*,\s*1\s*,\s*1\s*\)', | |
| r'LlamaConfig(vocab_size=0, hidden_size=256, intermediate_size=1024, num_hidden_layers=1, num_attention_heads=1)', | |
| content | |
| ) | |
| if new_content != content: | |
| with open(file_path, "w", encoding="utf-8") as f: | |
| f.write(new_content) | |
| print(f"Patched {file} successfully.") | |
| except Exception: | |
| pass | |
| except Exception as e: | |
| print(f"Failed to patch Amphion: {e}") | |
| patch_amphion_llama_config() | |
| # --- رفع مشکل rope_theta با تزریق (Monkey Patch) در LlamaConfig --- | |
| # (این پچ فقط داخلِ همین پروسه است و به مدلهای ساخت آهنگ نمیرسد.) | |
| import transformers | |
| from transformers.models.llama.configuration_llama import LlamaConfig | |
| _original_llama_init = LlamaConfig.__init__ | |
| def _patched_llama_init(self, *args, **kwargs): | |
| kwargs.setdefault('rope_theta', 10000.0) | |
| kwargs.setdefault('attention_bias', False) | |
| kwargs.setdefault('rope_scaling', None) | |
| kwargs.setdefault('max_position_embeddings', 4096) | |
| _original_llama_init(self, *args, **kwargs) | |
| if not hasattr(self, 'rope_theta'): | |
| self.rope_theta = 10000.0 | |
| if not hasattr(self, 'attention_bias'): | |
| self.attention_bias = False | |
| LlamaConfig.__init__ = _patched_llama_init | |
| os.makedirs("wav", exist_ok=True) | |
| os.makedirs("ckpts/Vevo", exist_ok=True) | |
| try: | |
| from models.vc.vevo.vevo_utils import VevoInferencePipeline | |
| except ImportError: | |
| sys.path.append(os.path.join(amphion_path)) | |
| from models.vc.vevo.vevo_utils import VevoInferencePipeline | |
| # --- توابع ذخیره و تنظیمات مدل --- | |
| def save_audio_pcm16(waveform, output_path, sample_rate=24000): | |
| try: | |
| if isinstance(waveform, torch.Tensor): | |
| waveform = waveform.detach().cpu() | |
| if waveform.dim() == 2 and waveform.shape[0] == 1: | |
| waveform = waveform.squeeze(0) | |
| waveform = waveform.numpy() | |
| sf.write(output_path, waveform, sample_rate, subtype='PCM_16') | |
| except Exception as e: | |
| print(f"Save error: {e}") | |
| downloaded_resources = { | |
| "configs": False, | |
| "tokenizer_vq8192": False, | |
| "fmt_Vq8192ToMels": False, | |
| "vocoder": False | |
| } | |
| def setup_configs(): | |
| if downloaded_resources["configs"]: return | |
| target_config_path = "models/vc/vevo/config" | |
| os.makedirs(target_config_path, exist_ok=True) | |
| config_files =["Vq8192ToMels.json", "Vocoder.json", "hubert_large_l18_mean_std.npz"] | |
| source_config_path = os.path.join(amphion_path, "models/vc/vevo/config") | |
| for file in config_files: | |
| target_file_path = f"{target_config_path}/{file}" | |
| source_file_path = os.path.join(source_config_path, file) | |
| if not os.path.exists(target_file_path): | |
| if os.path.exists(source_file_path): | |
| shutil.copy(source_file_path, target_file_path) | |
| else: | |
| try: | |
| file_data = hf_hub_download(repo_id="amphion/Vevo", filename=f"config/{file}", repo_type="model") | |
| if os.path.exists(target_file_path): os.remove(target_file_path) | |
| subprocess.run(["cp", file_data, target_file_path]) | |
| except Exception as e: | |
| print(f"Error downloading config {file}: {e}") | |
| downloaded_resources["configs"] = True | |
| setup_configs() | |
| # ========================================================================= | |
| # اجرای کامل روی CPU (بدون مصرف اعتبار ZeroGPU) | |
| # نکته: این پروسه spaces را ایمپورت نمیکند، پس GPUی در کار نیست؛ با این حال | |
| # دستگاه مثلِ قبل صریحاً CPU گذاشته میشود. کیفیت و تنظیمات مدل دست نخورده است. | |
| # ========================================================================= | |
| def get_real_cpu_count(): | |
| n = os.cpu_count() or 1 | |
| try: | |
| n = min(n, len(os.sched_getaffinity(0))) | |
| except Exception: | |
| pass | |
| try: | |
| # سهمیه واقعی کانتینر (cgroup v2) | |
| with open("/sys/fs/cgroup/cpu.max") as f: | |
| quota, period = f.read().split() | |
| if quota != "max": | |
| n = min(n, max(1, int(int(quota) / int(period)))) | |
| except Exception: | |
| pass | |
| return max(1, n) | |
| CPU_THREADS = get_real_cpu_count() | |
| # حداکثر چند درخواست همزمان (۲ = «کارِ دوم همزمان»؛ ۱ = رفتارِ قبلی). اکشن پرو کارِ دوم را فقط | |
| # وقتی میفرستد که هیچ اسپیسِ صدای خالیِ دیگری نمانده باشد. | |
| VOICE_CONCURRENT_JOBS = max(1, min(4, int(os.environ.get("VOICE_CONCURRENT_JOBS", "2") or 2))) | |
| if CPU_THREADS < 2 * VOICE_CONCURRENT_JOBS: | |
| VOICE_CONCURRENT_JOBS = max(1, CPU_THREADS // 2) | |
| torch.set_num_threads(CPU_THREADS) | |
| try: | |
| torch.set_num_interop_threads(max(1, min(4, CPU_THREADS // 4))) | |
| except Exception: | |
| pass | |
| os.environ["OMP_NUM_THREADS"] = str(CPU_THREADS) | |
| os.environ["MKL_NUM_THREADS"] = str(CPU_THREADS) | |
| print(f"[CPU MODE] Using {CPU_THREADS} CPU threads (os.cpu_count={os.cpu_count()}); " | |
| f"up to {VOICE_CONCURRENT_JOBS} job(s) at once (a lone job gets all threads)") | |
| device = torch.device("cpu") | |
| inference_pipelines = {} | |
| downloaded_content_style_tokenizer_path = None | |
| downloaded_fmt_path = None | |
| downloaded_vocoder_path = None | |
| def preload_all_resources(): | |
| setup_configs() | |
| global downloaded_content_style_tokenizer_path, downloaded_fmt_path, downloaded_vocoder_path | |
| if not downloaded_resources["tokenizer_vq8192"]: | |
| downloaded_content_style_tokenizer_path = snapshot_download(repo_id="amphion/Vevo", repo_type="model", cache_dir="./ckpts/Vevo", allow_patterns=["tokenizer/vq8192/"]) | |
| downloaded_resources["tokenizer_vq8192"] = True | |
| if not downloaded_resources["fmt_Vq8192ToMels"]: | |
| downloaded_fmt_path = snapshot_download(repo_id="amphion/Vevo", repo_type="model", cache_dir="./ckpts/Vevo", allow_patterns=["acoustic_modeling/Vq8192ToMels/"]) | |
| downloaded_resources["fmt_Vq8192ToMels"] = True | |
| if not downloaded_resources["vocoder"]: | |
| downloaded_vocoder_path = snapshot_download(repo_id="amphion/Vevo", repo_type="model", cache_dir="./ckpts/Vevo", allow_patterns=["acoustic_modeling/Vocoder/*"]) | |
| downloaded_resources["vocoder"] = True | |
| preload_all_resources() | |
| _pipeline_lock = threading.Lock() | |
| def get_pipeline(): | |
| with _pipeline_lock: | |
| if "timbre" in inference_pipelines: return inference_pipelines["timbre"] | |
| tokenizer_path = os.path.join(downloaded_content_style_tokenizer_path, "tokenizer/vq8192") | |
| fmt_ckpt = os.path.join(downloaded_fmt_path, "acoustic_modeling/Vq8192ToMels") | |
| vocoder_ckpt = os.path.join(downloaded_vocoder_path, "acoustic_modeling/Vocoder") | |
| pipeline = VevoInferencePipeline( | |
| content_style_tokenizer_ckpt_path=tokenizer_path, | |
| fmt_cfg_path="./models/vc/vevo/config/Vq8192ToMels.json", | |
| fmt_ckpt_path=fmt_ckpt, | |
| vocoder_cfg_path="./models/vc/vevo/config/Vocoder.json", | |
| vocoder_ckpt_path=vocoder_ckpt, | |
| device=device, | |
| ) | |
| _install_step_hooks(pipeline) | |
| inference_pipelines["timbre"] = pipeline | |
| return pipeline | |
| # ========================================================================= | |
| # 🆕 تقسیمِ هستهها بینِ کارهای همزمان + لغوِ کارِ بیصاحب، در «هر گامِ» مدل | |
| # ------------------------------------------------------------------------- | |
| # روی ماژولهای سنگینِ مدل (هر گامِ flow matching، لایههای HuBERT، vocoder ...) یک | |
| # forward-pre-hook گذاشته میشود که در همان رشتهی کار اجرا میشود و دو کار میکند: | |
| # • اگر کار لغو شده (درخواستدهنده دیگر منتظر نیست) همان لحظه متوقفش میکند؛ | |
| # • تعدادِ رشتههای همین کار را با «همهی هستهها ÷ تعدادِ کارهای در جریان» یکی میکند | |
| # (torch.set_num_threads برای هر رشته جداست). کارِ تنها = همهی هستهها، مثلِ قبل. | |
| # هیچ عددی از خودِ مدل عوض نمیشود؛ فقط اینکه هر لحظه چند هسته کار کنند. | |
| # ========================================================================= | |
| _tls = threading.local() | |
| _jobs_lock = threading.Lock() | |
| _active_jobs = 0 | |
| class _JobCancelled(Exception): | |
| pass | |
| class _Job: | |
| def __init__(self): | |
| self.cancelled = threading.Event() | |
| self.done = threading.Event() | |
| self.reason = "" | |
| self.threads = 0 | |
| self.max_parallel = 1 | |
| def cancel(self, reason): | |
| if self.done.is_set() or self.cancelled.is_set(): | |
| return | |
| self.reason = reason | |
| self.cancelled.set() | |
| def check(self): | |
| if self.cancelled.is_set(): | |
| raise _JobCancelled(self.reason or "cancelled") | |
| def _sync_threads(job): | |
| """رشتههای کارِ همین رشته را با تعدادِ کارهای در جریان هماهنگ میکند.""" | |
| active = max(1, _active_jobs) | |
| if active > job.max_parallel: | |
| job.max_parallel = active | |
| want = max(1, CPU_THREADS // active) | |
| if job.threads != want: | |
| torch.set_num_threads(want) | |
| job.threads = want | |
| def _step_hook(module, args): | |
| job = getattr(_tls, "job", None) | |
| if job is not None: | |
| job.check() | |
| _sync_threads(job) | |
| return None | |
| def _install_step_hooks(pipeline): | |
| """هوک روی ماژولهایی که در هر تکه بارها صدا زده میشوند (و لایههای داخلِ ModuleListهایشان).""" | |
| names = ("fmt_model", "hubert_model", "vocoder_model", "mel_model", "content_style_tokenizer") | |
| hooked, roots = 0, [] | |
| for name in names: | |
| root = getattr(pipeline, name, None) | |
| if not isinstance(root, torch.nn.Module): | |
| continue | |
| roots.append(name) | |
| targets = {id(root): root} | |
| estimator = getattr(root, "diff_estimator", None) | |
| if isinstance(estimator, torch.nn.Module): | |
| targets[id(estimator)] = estimator # هر گامِ flow matching | |
| for sub in root.modules(): | |
| if isinstance(sub, torch.nn.ModuleList): | |
| for layer in sub: | |
| if isinstance(layer, torch.nn.Module): | |
| targets[id(layer)] = layer # لایهها / بلوکها | |
| for mod in targets.values(): | |
| mod.register_forward_pre_hook(_step_hook) | |
| hooked += 1 | |
| print(f"[CPU MODE] per-step thread/cancel hooks on {hooked} modules ({', '.join(roots) or 'none'})", flush=True) | |
| # --- تابع جدید برش هوشمند با قابلیت افزایش داینامیک بازه جستجو --- | |
| # ⚠️ اکشن پرو هم دقیقاً همین الگوریتم را (با همین عددها) پیش از ارسال اجرا | |
| # میکند تا تکهها را همزمان به چند اسپیس بفرستد؛ اگر اینجا عوض شد، آنجا | |
| # (proxy_worker.py → _voice_split_points) هم باید عوض شود. | |
| def smart_split_audio_vevo(y, sr, min_segment=4): | |
| total_samples = len(y) | |
| current_sample = 0 | |
| chunks = [] | |
| # اگر طول کل فایل کمتر از حد مجاز اولیه (8 ثانیه) باشد، نیازی به برش نیست | |
| if total_samples <= 8 * sr: | |
| return [y] | |
| while current_sample < total_samples: | |
| # اگر حجم نمونههای باقیمانده کمتر از حداقل زمان قطعه (4 ثانیه) باشد، بقیه فایل را یکجا بردار | |
| if total_samples - current_sample <= min_segment * sr: | |
| chunks.append(y[current_sample:]) | |
| break | |
| # بازههای گامبهگام برای جستجوی سکوت: ابتدا ۸، سپس ۱۲، ۱۵ و نهایتاً ۲۰ ثانیه | |
| steps = [8, 12, 15, 20] | |
| silence_threshold = 0.015 # آستانه شدت انرژی سیگنال برای پذیرش به عنوان سکوت واقعی | |
| best_split_point = None | |
| best_rms_val = float('inf') | |
| for max_sec in steps: | |
| start_search = current_sample + int(min_segment * sr) | |
| end_search = current_sample + int(max_sec * sr) | |
| if start_search >= total_samples: | |
| best_split_point = total_samples | |
| break | |
| if end_search > total_samples: | |
| end_search = total_samples | |
| search_region = y[start_search:end_search] | |
| if len(search_region) == 0: | |
| best_split_point = total_samples | |
| break | |
| # محاسبه میزان انرژی (RMS) قطعه | |
| rms = librosa.feature.rms(y=search_region, frame_length=2048, hop_length=512)[0] | |
| if len(rms) == 0: | |
| continue | |
| min_rms_idx = np.argmin(rms) | |
| min_rms_val = rms[min_rms_idx] | |
| candidate_point = start_search + (min_rms_idx * 512) | |
| # ذخیره بهترین نقطه کمصدا (در صورتی که سکوت مطلق پیدا نشود، کمصداترین نقطه انتخاب میشود) | |
| if min_rms_val < best_rms_val: | |
| best_rms_val = min_rms_val | |
| best_split_point = candidate_point | |
| # اگر شدت صدای نقطه پیدا شده پایینتر از آستانه سکوت بود، فرآیند جستجو را متوقف کرده و برش بزن | |
| if min_rms_val < silence_threshold: | |
| break | |
| if best_split_point is None: | |
| best_split_point = min(current_sample + int(8 * sr), total_samples) | |
| chunk_audio = y[current_sample:best_split_point] | |
| chunks.append(chunk_audio) | |
| current_sample = best_split_point | |
| # بررسی قطعه آخر: اگر بیش از حد کوتاه باشد، آن را با بخش قبلی ادغام میکنیم | |
| if len(chunks) > 1 and len(chunks[-1]) < int(1.5 * sr): | |
| last_chunk = chunks.pop() | |
| combined_len = len(chunks[-1]) + len(last_chunk) | |
| # تا سقف حداکثر ۲۰ ثانیه ادغام قطعه آخر مجاز است | |
| if combined_len <= int(20 * sr): | |
| chunks[-1] = np.concatenate([chunks[-1], last_chunk]) | |
| else: | |
| chunks.append(last_chunk) | |
| return chunks | |
| # ========================================================================= | |
| # همان تابعِ اصلیِ قبلی (predict_voice_conversion)، فقط بهجای gr.Progress | |
| # یک تابعِ گزارشِ پیشرفت میگیرد. خطاها مثلِ قبل بالا میروند و پروسهی | |
| # Gradio همان پیامِ قبلی را («خطایی در حین پردازش رخ داد: ...») نشان میدهد. | |
| # تکهها مثلِ قبل یکییکی و به ترتیب ساخته و به هم وصل میشوند. | |
| # ========================================================================= | |
| SR = 24000 | |
| # 🆕 سقفِ طولِ صدای مرجع (صدای مدل). توضیح: بالای فایل، بندِ ۱. ۰ = بدونِ سقف (رفتارِ قبلی). | |
| REF_MAX_SECONDS = float(os.environ.get("VOICE_REF_MAX_SECONDS", "12")) | |
| _REF_TOLERANCE_SECONDS = 0.5 # تا ۱۲٫۵ ثانیه دست نمیخورد (اکشن پرو خودش ۱۲ ثانیه میفرستد) | |
| _REF_FRAME = 480 # ۲۰ میلیثانیه در ۲۴ کیلوهرتز (همان گامِ فریمهای مدل) | |
| def pick_reference_segment(y, sr=SR, max_seconds=REF_MAX_SECONDS): | |
| """صدای مرجعِ بلند ← «پُرصداترین» تکهی حداکثر max_seconds ثانیهای (بیشترین گفتار)، که درست | |
| پیش از شروعِ گفتار شروع و روی یک مکث تمام میشود (نه وسطِ کلمه). مرجعِ کوتاهتر از سقف بدونِ | |
| هیچ تغییری برمیگردد. (اکشن پرو همین را پیش از ارسال اجرا میکند: _voice_pick_reference.)""" | |
| y = np.asarray(y, dtype=np.float32) | |
| n = len(y) | |
| if max_seconds <= 0 or n <= int((max_seconds + _REF_TOLERANCE_SECONDS) * sr): | |
| return y | |
| frame = max(1, int(_REF_FRAME * sr / 24000)) | |
| nf = n // frame | |
| win = max(1, int(max_seconds * sr / frame)) | |
| if nf <= win: | |
| return y | |
| rms = np.sqrt(np.mean(np.square(y[: nf * frame].reshape(nf, frame), dtype=np.float64), axis=1)) | |
| thr = max(0.008, 0.12 * float(np.percentile(rms, 95))) # زیرِ این = مکث/سکوت | |
| active = (rms > thr).astype(np.int64) | |
| csum = np.concatenate([[0], np.cumsum(active)]) | |
| step = max(1, int(0.5 * sr / frame)) | |
| starts = np.arange(0, nf - win + 1, step) | |
| counts = csum[starts + win] - csum[starts] | |
| best = int(starts[int(np.argmax(counts))]) # بیشترین گفتار؛ در تساوی، زودترین | |
| sec = max(1, int(sr / frame)) | |
| # شروع: آخرین فریمِ ساکت در ±۱ ثانیهی دورِ بهترین شروع (یعنی درست پیش از گفتار) | |
| h_lo, h_hi = max(0, best - sec), min(nf - win, best + sec) + 1 | |
| s0 = h_lo + _last_quiet(rms[h_lo:h_hi], thr) | |
| # پایان: آخرین فریمِ ساکت در ۳ ثانیهی آخرِ پنجره (تا جای ممکن بلند، روی مکث) | |
| t_lo, t_hi = max(s0 + 1, s0 + win - 3 * sec), min(nf, s0 + win) | |
| e0 = (t_lo + _last_quiet(rms[t_lo:t_hi], thr)) if t_hi > t_lo else t_hi | |
| e0 = max(e0, s0 + 1) | |
| return y[s0 * frame: e0 * frame] | |
| def _last_quiet(values, thr): | |
| """اندیسِ آخرین فریمِ ساکت (زیرِ thr)؛ اگر نبود، آخرین فریمی که هماندازهی کمصداترین است.""" | |
| values = np.asarray(values, dtype=np.float64) | |
| if len(values) == 0: | |
| return 0 | |
| quiet = np.nonzero(values <= thr)[0] | |
| if len(quiet): | |
| return int(quiet[-1]) | |
| low = float(values.min()) | |
| return int(np.nonzero(values <= low * 1.05 + 1e-6)[0][-1]) | |
| def run_voice_conversion(source_audio_path, ref_audio_path, progress=None, job=None): | |
| if progress is None: | |
| progress = lambda value, desc="": None # noqa: E731 | |
| if job is None: | |
| job = _Job() | |
| task_id = str(uuid.uuid4()) | |
| out_path = f"wav/{task_id}_out.wav" | |
| # پردازش صوت اصلی | |
| content_data, _ = librosa.load(source_audio_path, sr=SR) | |
| # پردازش صوت مرجع (🆕 مرجعِ بلندتر از سقف ← پُرصداترین تکهاش؛ کوتاهتر دست نمیخورد) | |
| ref_data, _ = librosa.load(ref_audio_path, sr=SR) | |
| ref_full = len(ref_data) / SR | |
| ref_data = pick_reference_segment(ref_data, SR) | |
| if len(ref_data) < int(ref_full * SR): | |
| print(f"[CPU MODE] reference {ref_full:.1f}s -> using its best {len(ref_data) / SR:.1f}s " | |
| f"(VOICE_REF_MAX_SECONDS={REF_MAX_SECONDS:g})", flush=True) | |
| ref_tensor = torch.FloatTensor(ref_data).unsqueeze(0) | |
| ref_tensor = ref_tensor / (torch.max(torch.abs(ref_tensor)) + 1e-6) * 0.95 | |
| temp_r = f"wav/{task_id}_temp_r.wav" | |
| save_audio_pcm16(ref_tensor, temp_r, SR) | |
| try: | |
| # اعمال تقسیمکننده هوشمند چندمرحلهای | |
| chunks = smart_split_audio_vevo(content_data, SR, min_segment=4) | |
| generated_chunks = [] | |
| pipeline = get_pipeline() | |
| # شبیهسازی قطعه به قطعه (مثلِ قبل، یکییکی و به ترتیب) | |
| total_chunks = len(chunks) | |
| print(f"[CPU MODE] {total_chunks} chunks, {len(content_data)/SR:.1f}s audio " | |
| f"(ref {len(ref_data)/SR:.1f}s)", flush=True) | |
| for idx, chunk in enumerate(chunks): | |
| job.check() | |
| progress(idx / total_chunks, f"در حال پردازش روی CPU: قطعه {idx+1} از {total_chunks}") | |
| chunk_started = time.time() | |
| chunk_tensor = torch.FloatTensor(chunk).unsqueeze(0) | |
| chunk_tensor = chunk_tensor / (torch.max(torch.abs(chunk_tensor)) + 1e-6) * 0.95 | |
| temp_c_chunk = f"wav/{task_id}_temp_c_{idx}.wav" | |
| save_audio_pcm16(chunk_tensor, temp_c_chunk, SR) | |
| try: | |
| _sync_threads(job) | |
| with torch.inference_mode(): | |
| gen = pipeline.inference_fm( | |
| src_wav_path=temp_c_chunk, | |
| timbre_ref_wav_path=temp_r, | |
| flow_matching_steps=32, | |
| ) | |
| finally: | |
| if os.path.exists(temp_c_chunk): | |
| os.remove(temp_c_chunk) | |
| if torch.isnan(gen).any(): | |
| gen = torch.nan_to_num(gen, nan=0.0) | |
| gen_np = gen.detach().cpu().squeeze().numpy() | |
| generated_chunks.append(gen_np) | |
| if total_chunks > 1: | |
| print(f" [CPU MODE] chunk {idx+1}/{total_chunks}: {len(chunk)/SR:.1f}s in " | |
| f"{time.time() - chunk_started:.1f}s ({job.threads} threads)", flush=True) | |
| job.check() | |
| progress(1.0, "در حال ادغام و ذخیره خروجی") | |
| # ادغام قطعات خروجی (به ترتیب) | |
| final_gen = np.concatenate(generated_chunks, axis=0) | |
| # ذخیره نهایی | |
| save_audio_pcm16(final_gen, out_path, SR) | |
| finally: | |
| if os.path.exists(temp_r): | |
| os.remove(temp_r) | |
| return out_path | |
| # ========================================================================= | |
| # سرویسدهی به پروسهی Gradio (فقط داخلِ همین کانتینر، با کلیدِ مخفی) | |
| # ========================================================================= | |
| _job_slots = threading.BoundedSemaphore(VOICE_CONCURRENT_JOBS) | |
| _state = {"busy": False, "warm": False, "jobs": 0, "cancelled": 0, "started_at": time.time()} | |
| def _write_state(path, **extra): | |
| if not path: | |
| return | |
| data = {"pid": os.getpid(), "ready": True, "busy": _state["busy"], "warm": _state["warm"], | |
| "jobs": _state["jobs"], "cancelled": _state["cancelled"], "threads": CPU_THREADS, | |
| "concurrency": VOICE_CONCURRENT_JOBS, "active": _active_jobs, "ts": time.time()} | |
| data.update(extra) | |
| try: | |
| tmp = f"{path}.tmp" | |
| with open(tmp, "w", encoding="utf-8") as f: | |
| json.dump(data, f) | |
| os.replace(tmp, path) | |
| except Exception: | |
| pass | |
| def _cleanup_old_outputs(max_age_seconds=2 * 3600): | |
| """خروجیهای قدیمیِ پوشهی wav پاک میشوند (Gradio هر خروجی را لحظهی تحویل در | |
| کشِ خودش کپی میکند، پس بعد از چند ساعت اینجا دیگر لازم نیستند).""" | |
| try: | |
| cutoff = time.time() - max_age_seconds | |
| for name in os.listdir("wav"): | |
| path = os.path.join("wav", name) | |
| try: | |
| if os.path.isfile(path) and os.path.getmtime(path) < cutoff: | |
| os.remove(path) | |
| except Exception: | |
| pass | |
| except Exception: | |
| pass | |
| def _watch_client(conn, job): | |
| """اگر درخواستدهنده (پروسهی Gradio) اتصال را بست -- یعنی دیگر کسی منتظرِ این نتیجه نیست | |
| (کاربر/اکشن پرو رفت یا زمانش تمام شد) -- کار همان لحظه لغو میشود.""" | |
| while not job.done.is_set() and not job.cancelled.is_set(): | |
| try: | |
| ready = conn.poll(0.5) | |
| except (OSError, EOFError, ValueError): | |
| job.cancel("the requester closed the connection") | |
| return | |
| if not ready: | |
| continue | |
| try: | |
| msg = conn.recv() | |
| except (OSError, EOFError, ValueError): | |
| job.cancel("the requester is no longer waiting") | |
| return | |
| if (msg or {}).get("op") == "cancel": | |
| job.cancel("cancel requested") | |
| return | |
| def _serve(sock_path, state_path): | |
| from multiprocessing.connection import Listener | |
| authkey = bytes.fromhex(os.environ.get("VOICE_ENGINE_AUTHKEY", "")) | |
| if not authkey: | |
| raise SystemExit("VOICE_ENGINE_AUTHKEY is missing") | |
| try: | |
| os.remove(sock_path) | |
| except FileNotFoundError: | |
| pass | |
| listener = Listener(sock_path, family="AF_UNIX", authkey=authkey) | |
| _write_state(state_path) | |
| print(f"🎙️ [Voice Engine] آماده است (pid={os.getpid()}, CPU threads={CPU_THREADS}, " | |
| f"تا {VOICE_CONCURRENT_JOBS} کارِ همزمان).", flush=True) | |
| def handle(conn): | |
| global _active_jobs | |
| try: | |
| msg = conn.recv() | |
| op = (msg or {}).get("op") | |
| if op == "ping": | |
| conn.send({"op": "pong", "busy": _state["busy"], "warm": _state["warm"], "jobs": _state["jobs"], | |
| "active": _active_jobs, "concurrency": VOICE_CONCURRENT_JOBS}) | |
| return | |
| if op != "convert": | |
| conn.send({"op": "error", "message": f"unknown op: {op}"}) | |
| return | |
| job = _Job() | |
| with _job_slots: | |
| with _jobs_lock: | |
| _active_jobs += 1 | |
| _state["busy"] = True | |
| _write_state(state_path) | |
| threading.Thread(target=_watch_client, args=(conn, job), daemon=True, | |
| name="voice-client-watch").start() | |
| _tls.job = job | |
| started = time.time() | |
| send_lock = threading.Lock() | |
| def report(value, desc=""): | |
| if job.cancelled.is_set(): | |
| return | |
| try: | |
| with send_lock: | |
| conn.send({"op": "progress", "value": float(value), "desc": str(desc)}) | |
| except Exception: | |
| job.cancel("the requester is no longer waiting") | |
| try: | |
| out_path = run_voice_conversion(msg.get("source"), msg.get("ref"), progress=report, job=job) | |
| _state["warm"] = True | |
| job.check() | |
| try: | |
| with send_lock: | |
| conn.send({"op": "done", "path": os.path.abspath(out_path)}) | |
| except Exception: | |
| job.cancel("the requester left right before the result") | |
| raise _JobCancelled(job.reason) | |
| job.done.set() | |
| together = " (همزمان با کارِ دیگر)" if job.max_parallel > 1 else "" | |
| print(f"✅ [Voice Engine] تبدیل در {time.time() - started:.1f} ثانیه کامل شد{together}.", flush=True) | |
| except _JobCancelled: | |
| _state["cancelled"] += 1 | |
| print(f"⏹️ [Voice Engine] کار بعد از {time.time() - started:.1f} ثانیه لغو شد " | |
| f"({job.reason}); CPU آزاد شد.", flush=True) | |
| except Exception as e: | |
| if job.cancelled.is_set(): | |
| _state["cancelled"] += 1 | |
| print(f"⏹️ [Voice Engine] کار بعد از {time.time() - started:.1f} ثانیه لغو شد " | |
| f"({job.reason}); CPU آزاد شد.", flush=True) | |
| else: | |
| print(f"❌ [Voice Engine] خطا: {type(e).__name__}: {e}", flush=True) | |
| try: | |
| with send_lock: | |
| conn.send({"op": "error", "message": str(e)}) | |
| except Exception: | |
| pass | |
| finally: | |
| job.done.set() | |
| _tls.job = None | |
| with _jobs_lock: | |
| _active_jobs = max(0, _active_jobs - 1) | |
| _state["busy"] = _active_jobs > 0 | |
| _state["jobs"] += 1 | |
| jobs_done = _state["jobs"] | |
| _write_state(state_path) | |
| if jobs_done % 25 == 0: | |
| _cleanup_old_outputs() | |
| except EOFError: | |
| pass | |
| except Exception as e: | |
| print(f"⚠️ [Voice Engine] اتصال: {type(e).__name__}: {e}", flush=True) | |
| finally: | |
| try: | |
| conn.close() | |
| except Exception: | |
| pass | |
| while True: | |
| try: | |
| conn = listener.accept() | |
| except Exception as e: | |
| print(f"⚠️ [Voice Engine] accept: {type(e).__name__}: {e}", flush=True) | |
| time.sleep(0.5) | |
| continue | |
| threading.Thread(target=handle, args=(conn,), daemon=True).start() | |
| def _exit_with_parent(parent_pid): | |
| """اگر پروسهی اصلیِ اسپیس بسته شد، این موتور هم بسته شود (پروسهی یتیم نماند).""" | |
| while True: | |
| time.sleep(5) | |
| if os.getppid() != parent_pid: | |
| print("🛑 [Voice Engine] پروسهی اصلی بسته شد؛ موتور صدا هم بسته میشود.", flush=True) | |
| os._exit(0) | |
| def main(): | |
| sock_path = sys.argv[sys.argv.index("--serve") + 1] | |
| state_path = sys.argv[sys.argv.index("--state") + 1] if "--state" in sys.argv else "" | |
| threading.Thread(target=_exit_with_parent, args=(os.getppid(),), daemon=True).start() | |
| signal.signal(signal.SIGTERM, lambda *_: os._exit(0)) | |
| # گرمکردنِ مدل همان اول (قبلاً اولین درخواست این زمان را میداد). اگر نشد، | |
| # مثلِ قبل در اولین درخواست دوباره ساخته میشود. | |
| try: | |
| t0 = time.time() | |
| get_pipeline() | |
| _state["warm"] = True | |
| print(f"🔥 [Voice Engine] مدل Vevo در {time.time() - t0:.1f} ثانیه روی CPU آماده شد.", flush=True) | |
| _warm_up() | |
| except Exception as e: | |
| print(f"⚠️ [Voice Engine] گرمکردنِ مدل ناموفق بود ({type(e).__name__}: {e})؛ " | |
| f"در اولین درخواست دوباره امتحان میشود.", flush=True) | |
| _serve(sock_path, state_path) | |
| def _warm_up(): | |
| """🆕 یک تبدیلِ آزمایشیِ خیلی کوتاه پیش از اولین درخواست (صدای ساختگیِ ۲ ثانیهای): اولین | |
| اجرای مدل و librosa کارهای یکبارهای دارد (کامپایل/آمادهسازی) که قبلاً به اولین کاربر میافتاد | |
| (همان ۴۰ ثانیهایِ اولِ لاگ برای یک تکهی ۶ ثانیهای). خروجی بلافاصله پاک میشود.""" | |
| t0 = time.time() | |
| tag = uuid.uuid4().hex | |
| src, ref, out = f"wav/{tag}_warm_src.wav", f"wav/{tag}_warm_ref.wav", None | |
| try: | |
| t = np.arange(int(2.0 * SR)) / SR | |
| tone = (0.2 * np.sin(2 * np.pi * 180 * t) * np.hanning(len(t))).astype(np.float32) | |
| sf.write(src, tone, SR, subtype="PCM_16") | |
| sf.write(ref, tone[::-1].copy(), SR, subtype="PCM_16") | |
| out = run_voice_conversion(src, ref) | |
| print(f"🔥 [Voice Engine] گرمکردنِ اولیه در {time.time() - t0:.1f} ثانیه انجام شد.", flush=True) | |
| except Exception as e: | |
| print(f"⚠️ [Voice Engine] گرمکردنِ اولیه انجام نشد ({type(e).__name__}: {e}); مشکلی نیست.", flush=True) | |
| finally: | |
| for path in (src, ref, out): | |
| try: | |
| if path and os.path.exists(path): | |
| os.remove(path) | |
| except Exception: | |
| pass | |
| if __name__ == "__main__": | |
| main() | |