Spaces:
Opera10
/
Running on Zero

sada9 / voice_engine.py
Transfer Bot
Moved to Hugging Face automatically
c3da9f3
Raw History Blame Contribute Delete
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()