diff --git "a/app.py" "b/app.py" new file mode 100644--- /dev/null +++ "b/app.py" @@ -0,0 +1,3442 @@ +# ╔══════════════════════════════════════════════════════════════════════╗ +# ║ XTTS v2 Voice Studio — Professional Edition v11 ║ +# ║ SINGLE-FILE BUILD (app.py + requirements.txt + Dockerfile) ║ +# ║ • CPU-only (16 vCPU / 104 GB RAM) • @GPU defined, never used ║ +# ║ • Faster-Whisper • Soft Clipping • Real Progress • Cancel Jobs ║ +# ║ • Audio Post-Editor • Full Mobile Responsive UI ║ +# ╚══════════════════════════════════════════════════════════════════════╝ +# +# Run: python app.py +# This file now contains everything the old Dockerfile / requirements.txt +# did. On start-up it (1) sets the environment, (2) installs system +# packages when running as root, (3) installs the pinned Python packages +# once, (4) creates the runtime folders, then (5) starts the server on +# port 7860. Set SKIP_BOOTSTRAP=1 to skip steps 2-3. +# +# Minimal Dockerfile that is still needed (a script cannot create its own +# container): +# FROM python:3.10-slim +# RUN useradd -m -u 1000 user && mkdir -p /data && chown 1000:1000 /data +# USER user +# WORKDIR /home/user/app +# COPY --chown=user app.py . +# EXPOSE 7860 +# CMD ["python", "app.py"] + +import os, sys, re, time, json, uuid, shutil, asyncio, threading +import subprocess, hashlib, tempfile, ctypes.util, traceback + +# ══════════════════════════════════════════════════════════════════════ +# ZeroGPU COMPATIBILITY +# Hugging Face ZeroGPU Spaces kill the app at start-up with +# "No @spaces.GPU function detected" unless at least one function is +# decorated with @spaces.GPU. `import spaces` must happen BEFORE torch. +# The stub below is never called — the app itself still runs on CPU only. +# (If you switch the Space hardware to a normal CPU tier this is harmless.) +# ══════════════════════════════════════════════════════════════════════ +try: + import spaces + @spaces.GPU + def _zerogpu_stub(): + return True + ZEROGPU = True +except Exception as _e: # not on Spaces / package missing + spaces, ZEROGPU = None, False + print(f"[i] `spaces` not used: {_e}", flush=True) + +# ══════════════════════════════════════════════════════════════════════ +# HARDWARE PROFILE — 16 CPU cores · 104 GB RAM · NO GPU +# ══════════════════════════════════════════════════════════════════════ +CPU_THREADS = int(os.environ.get("CPU_THREADS", "16")) + +# ══════════════════════════════════════════════════════════════════════ +# ENVIRONMENT (from Dockerfile ENV block) +# ══════════════════════════════════════════════════════════════════════ +os.environ["PATH"] = os.path.expanduser("~/.local/bin") + os.pathsep + os.environ.get("PATH", "") +os.environ["COQUI_TOS_AGREED"] = "1" # accept Coqui TOS automatically +os.environ["TOKENIZERS_PARALLELISM"] = "false" # avoid tokenizer warnings +if not ZEROGPU: + os.environ["CUDA_VISIBLE_DEVICES"] = "" # GPU is never used +for _v in ("OMP_NUM_THREADS", "MKL_NUM_THREADS", "OPENBLAS_NUM_THREADS", + "NUMEXPR_NUM_THREADS", "VECLIB_MAXIMUM_THREADS"): + os.environ[_v] = str(CPU_THREADS) + +# HF model cache lives on the persistent /data mount when it is writable +if os.path.isdir("/data") and os.access("/data", os.W_OK): + os.environ.setdefault("HF_HOME", "/data/huggingface") +else: + os.environ.setdefault("HF_HOME", os.path.expanduser("~/.cache/huggingface")) + +# ══════════════════════════════════════════════════════════════════════ +# @GPU — placeholder decorator (like `spaces.GPU`). +# It is defined only for compatibility and is INTENTIONALLY NEVER APPLIED +# anywhere in this file. Everything runs on CPU. +# ═══════════════��══════════════════════════════════════════════════════ +def GPU(func=None, **_kwargs): + if func is None: + return lambda f: f + return func + +# ══════════════════════════════════════════════════════════════════════ +# REQUIREMENTS (from requirements.txt) +# ══════════════════════════════════════════════════════════════════════ +REQUIREMENTS = """ +coqui-tts>=0.22.0 +transformers>=4.33.0,<4.38.0 +fastapi>=0.111.0 +uvicorn[standard]>=0.29.0 +python-multipart>=0.0.9 +torch>=2.2.0,<2.6.0 +torchaudio>=2.2.0,<2.6.0 +numpy>=1.24.0 +soundfile>=0.12.0 +scipy>=1.10.0 +faster-whisper>=0.10.0 +deep-translator>=1.11.0 +huggingface-hub>=0.20.0 +""" + +SYSTEM_PACKAGES = { # apt package -> "is it present?" check + "build-essential": lambda: shutil.which("gcc"), + "libsndfile1": lambda: ctypes.util.find_library("sndfile"), + "ffmpeg": lambda: shutil.which("ffmpeg"), + "git": lambda: shutil.which("git"), + "libgomp1": lambda: ctypes.util.find_library("gomp"), +} + +def _sh(cmd): + print("[bootstrap] $", " ".join(cmd), flush=True) + return subprocess.run(cmd).returncode + +def _bootstrap(): + """Everything the Dockerfile used to do at build time.""" + if os.environ.get("SKIP_BOOTSTRAP") == "1": + return + if os.environ.get("SPACE_ID") and os.environ.get("FORCE_BOOTSTRAP") != "1": + print("[bootstrap] Running on Hugging Face Spaces — dependencies come " + "from requirements.txt, skipping runtime pip.", flush=True) + return + + # ── System deps (only possible as root with apt) ────────────────── + missing = [p for p, ok in SYSTEM_PACKAGES.items() if not ok()] + if missing: + if hasattr(os, "geteuid") and os.geteuid() == 0 and shutil.which("apt-get"): + _sh(["apt-get", "update"]) + _sh(["apt-get", "install", "-y", "--no-install-recommends"] + missing) + else: + print(f"[bootstrap] Not root — cannot apt-install: {', '.join(missing)} " + "(the pip wheels bundle what the app needs).", flush=True) + + # ── Python deps (once per requirements version) ─────────────────── + tag = hashlib.md5(REQUIREMENTS.encode()).hexdigest()[:10] + marker = os.path.join(os.path.dirname(os.path.abspath(__file__)), f".deps_ok_{tag}") + if os.path.exists(marker): + return + with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as f: + f.write(REQUIREMENTS) + req_path = f.name + try: + _sh([sys.executable, "-m", "pip", "install", "--no-cache-dir", "--upgrade", "pip"]) + rc = _sh([sys.executable, "-m", "pip", "install", "--no-cache-dir", "-r", req_path]) + if rc == 0: + open(marker, "w").close() + else: + print("[bootstrap] pip install failed — continuing anyway.", flush=True) + finally: + try: os.remove(req_path) + except OSError: pass + +def _make_dirs(): + """Runtime folders (from Dockerfile mkdir lines) — best effort.""" + here = os.path.dirname(os.path.abspath(__file__)) + for d in ("/data/huggingface", "/data/voice_library", "/data/outputs", + os.path.join(here, "voice_library"), os.path.join(here, "outputs")): + try: os.makedirs(d, exist_ok=True) + except OSError: pass + +_bootstrap() +_make_dirs() + +# ══════════════════════════════════════════════════════════════════════ +# APPLICATION +# ══════════════════════════════════════════════════════════════════════ +import numpy as np +import soundfile as sf +import uvicorn +from fastapi import FastAPI, Form, File, UploadFile, HTTPException, BackgroundTasks +from fastapi.responses import HTMLResponse, FileResponse, JSONResponse +from fastapi.middleware.cors import CORSMiddleware +import torch + +torch.set_num_threads(CPU_THREADS) # use all 16 cores for inference +try: + torch.set_num_interop_threads(2) +except RuntimeError: + pass + +# ── Persistent Storage ──────────────────────────────────────────────── +PERSISTENT_DIR = "/data" if os.path.exists("/data") else os.path.join(os.getcwd(), "app_data") +VOICE_LIB = os.path.join(PERSISTENT_DIR, "voice_library") +OUTPUT_DIR = os.path.join(PERSISTENT_DIR, "outputs") +HISTORY_FILE = os.path.join(PERSISTENT_DIR, "history.json") +MAX_CHARS = 5000 + +for d in [VOICE_LIB, OUTPUT_DIR]: + os.makedirs(d, exist_ok=True) + +HF_TOKEN = os.environ.get("HF_TOKEN") +DATASET_REPO_ID = os.environ.get("DATASET_REPO_ID") + +try: + from huggingface_hub import HfApi, snapshot_download + HUB_AVAILABLE = True + if HF_TOKEN and DATASET_REPO_ID: + print("[*] Restoring data from Hugging Face Hub …") + try: + snapshot_download(repo_id=DATASET_REPO_ID, repo_type="dataset", + local_dir=PERSISTENT_DIR, token=HF_TOKEN) + print("[✓] Data restoration complete.") + except Exception as e: + print(f"[!] Warning: Could not download backup ({e})") +except ImportError: + HUB_AVAILABLE = False + +def trigger_cloud_backup(): + if not (HUB_AVAILABLE and HF_TOKEN and DATASET_REPO_ID): return + def _run(): + try: + HfApi(token=HF_TOKEN).upload_folder( + folder_path=PERSISTENT_DIR, repo_id=DATASET_REPO_ID, + repo_type="dataset", commit_message=f"Auto-backup: {int(time.time())}") + except Exception as e: + print(f"[!] Cloud backup failed: {e}") + threading.Thread(target=_run).start() + +# ── XTTS Model ──────────────────────────────────────────────────────── +from TTS.api import TTS +device = "cpu" # GPU intentionally disabled — CPU only + +# ── Background model loading (server starts immediately) ────────────── +xtts = None +xtts_ready = False +xtts_loading = True +xtts_error = None + +def _load_model(): + global xtts, xtts_ready, xtts_loading, xtts_error + try: + print(f"[*] Loading XTTS v2 on {device.upper()} …") + xtts = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to(device) + xtts_ready = True + print("[✓] XTTS ready.") + except Exception as e: + xtts_error = f"{type(e).__name__}: {e}" + print(f"[!] XTTS loading failed: {xtts_error}", flush=True) + traceback.print_exc() + finally: + xtts_loading = False + +threading.Thread(target=_load_model, daemon=True).start() + +# tts_lock is created lazily inside the event loop to avoid asyncio issues +_tts_lock = None +def get_tts_lock(): + global _tts_lock + if _tts_lock is None: + _tts_lock = asyncio.Lock() + return _tts_lock + +def _startup_cleanup(): + for f in os.listdir("/tmp"): + if any(f.startswith(p) for p in ("ch_","ref_","sts_","dub_")): + try: os.remove(os.path.join("/tmp",f)) + except: pass +_startup_cleanup() + +# ── Job Management: Cancellation & Progress ─────────────────────────── +job_progress: dict = {} # job_id -> {current, total, status, ts} +cancel_events: dict = {} # job_id -> threading.Event +JOB_TTL_SEC = 3600 + +def _gc_jobs(): + cutoff = time.time() - JOB_TTL_SEC + for k in [k for k,v in list(job_progress.items()) + if v.get("status") != "running" and v.get("ts",0) < cutoff]: + job_progress.pop(k, None) + +# ── Faster-Whisper (replaces openai-whisper) ────────────────────────── +whisper_mdl = None +WHISPER_OK = False + +def _load_whisper(): + """Load Faster-Whisper in the background. If the default HF cache + (e.g. the /data volume) gives an I/O error, retry from local disk.""" + global whisper_mdl, WHISPER_OK + try: + from faster_whisper import WhisperModel + except Exception as e: + print(f"[!] Faster-Whisper not installed: {e}", flush=True) + return + for root in (None, "/tmp/whisper_models"): + try: + if root: os.makedirs(root, exist_ok=True) + whisper_mdl = WhisperModel("small", device="cpu", compute_type="int8", + cpu_threads=CPU_THREADS, download_root=root) + WHISPER_OK = True + print(f"[✓] Faster-Whisper ready (small · int8 · CPU) [{root or 'default cache'}].", flush=True) + return + except Exception as e: + print(f"[!] Faster-Whisper load failed ({root or 'default cache'}): {e}", flush=True) + print("[!] Faster-Whisper not available.", flush=True) + +threading.Thread(target=_load_whisper, daemon=True).start() + +def transcribe_audio(path: str) -> str: + """Transcribe using Faster-Whisper; returns plain text.""" + if whisper_mdl is None: + raise RuntimeError("Faster-Whisper not loaded") + segments, _ = whisper_mdl.transcribe(path, beam_size=5) + return " ".join(s.text for s in segments).strip() + +# ── Translator ──────────────────────────────────────────────────────── +try: + from deep_translator import GoogleTranslator + TRANSLATOR_OK = True +except Exception: + TRANSLATOR_OK = False + +# ── History helpers ─────────────────────────────────────────────────── +def load_history(): + try: + return json.load(open(HISTORY_FILE)) if os.path.exists(HISTORY_FILE) else [] + except Exception: + return [] + +def save_history(h): + json.dump(h, open(HISTORY_FILE, "w"), ensure_ascii=False, indent=2) + trigger_cloud_backup() + +MAX_HISTORY = 300 +MAX_OUTPUTS = 200 + +def _prune_outputs(): + try: + wavs = sorted( + [f for f in os.listdir(OUTPUT_DIR) if f.endswith(".wav")], + key=lambda f: os.path.getmtime(os.path.join(OUTPUT_DIR, f)) + ) + for old in wavs[:-MAX_OUTPUTS]: + try: os.remove(os.path.join(OUTPUT_DIR, old)) + except: pass + except Exception: + pass + +def append_history(filename, text, language, mode, char_count=0): + h = load_history() + h.append(dict(filename=filename, text=text[:150], language=language, + mode=mode, char_count=char_count, ts=int(time.time()))) + if len(h) > MAX_HISTORY: + h = h[-MAX_HISTORY:] + save_history(h) + _prune_outputs() + +# ── Parameter Engineering ───────────────────────────────────────────── +def stability_to_temperature(s): return round(0.85 - (s * 0.40), 3) +def clarity_to_top_p(c): return round(0.70 + (c * 0.25), 3) +def style_to_rep_penalty(st): return round(2.00 + (st * 2.00), 2) + +# ── Expression Presets ──────────────────────────────────────────────── +# Each preset overrides stability/temperature/top_p/rep_penalty to push +# XTTS toward a specific emotional character. +EXPRESSION_PRESETS = { + "natural": dict(temperature=0.72, top_p=0.88, rep_penalty=2.0, speed_mult=1.00), + "warm": dict(temperature=0.78, top_p=0.90, rep_penalty=1.80, speed_mult=0.97), + "excited": dict(temperature=0.88, top_p=0.92, rep_penalty=1.65, speed_mult=1.10), + "sad": dict(temperature=0.65, top_p=0.85, rep_penalty=2.20, speed_mult=0.88), + "serious": dict(temperature=0.55, top_p=0.82, rep_penalty=2.50, speed_mult=0.95), + "story": dict(temperature=0.82, top_p=0.93, rep_penalty=1.75, speed_mult=0.96), + "news": dict(temperature=0.60, top_p=0.84, rep_penalty=2.40, speed_mult=1.02), + "child": dict(temperature=0.90, top_p=0.95, rep_penalty=1.60, speed_mult=1.05), +} + +def apply_expression_preset(preset_name: str, speed: float) -> dict: + p = EXPRESSION_PRESETS.get(preset_name, EXPRESSION_PRESETS["natural"]) + return { + "temperature": p["temperature"], + "top_p": p["top_p"], + "repetition_penalty": p["rep_penalty"], + "speed": round(speed * p["speed_mult"], 3), + } + +# ── Text Expressiveness Enhancer ────────────────────────────────────── +# XTTS is very sensitive to punctuation and sentence rhythm. +# Adding deliberate pauses, ellipses, and varied sentence endings +# dramatically improves naturalness and eliminates the "robot reading" feel. +def enhance_text_expression(text: str, lang: str, mode: str) -> str: + """ + Enrich text with punctuation cues that guide XTTS prosody. + Does NOT change words — only adds/adjusts punctuation & whitespace. + """ + if mode == "natural" or not text.strip(): + return text + + t = text.strip() + + # 1. Normalise whitespace + t = re.sub(r'[ \t]+', ' ', t) + + # 2. Ensure sentences end with terminal punctuation + # (XTTS pauses longer on . than on nothing) + t = re.sub(r'([^\.\!\?؟،,\n])\n', r'\1.\n', t) + + # 3. Mode-specific rhythm adjustments + if mode == "excited": + # Shorten pauses between sentences → rapid delivery + t = re.sub(r'\.\s+', '. ', t) + # Add emphasis markers via comma-pauses before key conjunctions + for conj in ['لكن','بل','إلا','however','but','yet','still']: + t = re.sub(rf'\b({conj})\b', r', \1', t, flags=re.IGNORECASE) + + elif mode == "sad": + # Extend pauses → slower, heavier delivery + t = re.sub(r'\.\s+', '… ', t) + t = re.sub(r'،\s+', '، ', t) + + elif mode == "story": + # Add brief breath-pause after scene-setting clauses + t = re.sub(r'(كان|كانت|في يوم|ذات يوم|once upon|there was)', + r'\1،', t, flags=re.IGNORECASE) + + elif mode == "news": + # Crisp, no trailing breath + t = re.sub(r'\s*…+\s*', '. ', t) + + elif mode == "child": + # Short bursts with upward inflection hints + t = re.sub(r'\.\s+([A-Zا-ي])', r'! \1', t) + + # 4. Collapse multiple punctuation + t = re.sub(r'([.!?؟،,]){2,}', r'\1', t) + t = re.sub(r'…{2,}', '…', t) + + return t + +# ╔══════════════════════════════════════════════════════════════════════╗ +# ║ P H R A S E - L E V E L P R O S O D Y V A R I A T I O N ║ +# ║ Each sentence is analysed for emotional content. ║ +# ║ XTTS parameters (temperature, speed, repetition_penalty) shift ║ +# ║ per-phrase so the delivery feels alive, not machine-read. ║ +# ║ Post-generation volume envelopes reinforce the prosodic contour. ║ +# ╚══════════════════════════════════════════════════════════════════════╝ + +# ── Per-emotion XTTS deltas ─────────────────────────────────────────── +# (applied on top of the base params; scaled by user's variation_strength) +EMOTION_DELTAS = { + # temp speed_mult rep_penalty silence_ms + "neutral": ( 0.00, 1.000, 0.00, 110 ), + "question": (+0.07, 0.960, -0.10, 140 ), # slower, more uncertain + "exclaim": (+0.12, 1.080, -0.30, 75 ), # faster, more energy + "emphasis": (-0.06, 0.900, +0.30, 155 ), # deliberate, weighty + "sadness": (-0.10, 0.870, +0.35, 185 ), # slow, heavy + "joy": (+0.10, 1.060, -0.25, 85 ), # bright, forward + "anger": (+0.08, 1.040, -0.20, 95 ), # tense, clipped + "soft": (-0.09, 0.920, +0.20, 160 ), # tender, gentle + "urgency": (+0.11, 1.120, -0.25, 65 ), # fast, alert + "story": (+0.06, 0.950, -0.10, 135 ), # varied, narrative +} + +# ── Keyword patterns (Arabic + English) ────────────────────────────── +EMOTION_PATTERNS = { + "joy": [ + # Arabic + r'سعيد|فرح|رائع|مذهل|عظيم|ممتاز|برافو|مبروك|ياه|يا لروع|' + r'جميل|بديع|أحب|نجح|وفق|الله|يسعد|سرور|ابتهاج|بهجة|نشوة', + # English + r'happy|wonderful|amazing|great|excellent|love|joy|fantastic|' + r'brilliant|perfect|congrats|hooray|yay|delightful|terrific', + ], + "sadness": [ + r'حزين|للأسف|مؤسف|وداع|ألم|فقدنا|صعب|مؤلم|بكى|دموع|' + r'خسر|رحل|غاب|اشتاق|أفتقد|حسرة|مأساة|مصيبة|فراق|وجع', + r'sad|unfortunately|sorry|pain|lost|difficult|hard|tears|' + r'miss|regret|grieve|mourn|tragedy|farewell|ache', + ], + "anger": [ + r'غضب|لا أقبل|مستحيل|يكفي|كفى|ظلم|حرام|لا يجوز|رفض|أبداً|' + r'لن أتحمل|سئمت|ضاق|احتج|رفض|نقمة|سخط|فساد', + r'never|impossible|stop|enough|angry|refuse|unacceptable|' + r'outrage|disgusting|unbelievable|ridiculous|furious', + ], + "emphasis": [ + r'بالتأكيد|دون شك|قطعاً|أكيد|لا شك|يجب|ضرورة|مهم|انتبه|' + r'اسمع|لا بد|حتماً|بلا شك|من المؤكد|بوضوح|صراحةً|جداً', + r'definitely|absolutely|certainly|must|important|listen|' + r'crucial|critical|clearly|obviously|indeed|precisely', + ], + "soft": [ + r'ربما|لعل|عسى|أتمنى|أرجو|بلطف|برفق|هدوء|سلام|' + r'هناء|رويداً|بهدوء|بتأن|رفيق|حنان|لطيف|رحمة|أمل', + r'perhaps|maybe|hopefully|gently|softly|peace|calm|' + r'wish|hope|kindly|tenderly|quietly|sweetly', + ], + "urgency": [ + r'الآن|فوراً|سريعاً|عاجل|انتبه|احذر|خطر|أسرع|بسرعة|' + r'عاجلاً|في الحال|لا تتأخر|إنذار|تحذير|يستعجل|عجلة', + r'now|immediately|quickly|urgent|warning|danger|hurry|' + r'fast|alert|emergency|asap|right away|no time', + ], + "story": [ + r'كان يا مكان|ذات يوم|في قديم|يحكى أن|وفي ليلة|' + r'ثم قال|فأجاب|نظر|قرر|انطلق|توجه|وصل|لقي|رأى', + r'once upon|there was|long ago|he said|she replied|' + r'suddenly|then he|and so|meanwhile|the next day', + ], +} + +# ── Phrase emotion detector ─────────────────────────────────────────── +def analyze_phrase_emotion(phrase: str, lang: str) -> tuple[str, float]: + """ + Detect the dominant emotion in a phrase. + Returns (emotion_name, confidence_0_to_1). + Confidence drives how strongly the deltas are applied. + """ + p = phrase.strip() + + # 1. Punctuation-first rules (fastest, highest confidence) + if re.search(r'[؟?]$', p): + return "question", 0.90 + count_exclaim = len(re.findall(r'[!]', p)) + if count_exclaim >= 2: + return "exclaim", 0.95 + if count_exclaim == 1 and len(p) < 60: + return "exclaim", 0.75 + + # 2. Keyword scan + scores: dict[str, float] = {} + for emotion, patterns in EMOTION_PATTERNS.items(): + for pat in patterns: + matches = re.findall(pat, p, re.IGNORECASE) + if matches: + # Score = matches / words — more keywords → higher confidence + words = max(1, len(p.split())) + scores[emotion] = scores.get(emotion, 0) + len(matches) / words + + if scores: + best = max(scores, key=scores.get) + conf = min(0.92, scores[best] * 3.0) # cap at 0.92 + return best, conf + + # 3. Structural heuristics + if len(p) < 20: + return "emphasis", 0.35 # short phrases tend to be punchy + if p.endswith('…') or p.endswith('...'): + return "soft", 0.50 # trailing ellipsis = trailing/wistful + + return "neutral", 0.0 + + +# ── Per-phrase XTTS param builder ───────────────────────────────────── +def make_phrase_params(base: dict, emotion: str, conf: float, + variation: float, pos_ratio: float) -> dict: + """ + variation : user slider 0-2 (0=none, 1=natural, 2=dramatic) + pos_ratio : 0.0 = first phrase, 1.0 = last phrase + Returns a NEW params dict with emotion-driven adjustments. + """ + if variation < 0.02: + return base.copy() + + d = EMOTION_DELTAS.get(emotion, EMOTION_DELTAS["neutral"]) + temp_d, speed_m, rep_d, _ = d + strength = conf * variation # 0 … ~1.84 + + p = base.copy() + p["temperature"] = float(np.clip( + base["temperature"] + temp_d * strength, 0.40, 0.96)) + p["speed"] = float(np.clip( + base["speed"] * (1.0 + (speed_m - 1.0) * strength), 0.60, 1.65)) + p["repetition_penalty"] = float(np.clip( + base["repetition_penalty"] + rep_d * strength, 1.30, 3.80)) + + # ── Position-based natural drift ──────────────────────────────── + # Opening: slightly more controlled (lower temp, slower) + if pos_ratio < 0.08: + p["temperature"] = float(np.clip(p["temperature"] - 0.04 * variation, 0.40, 0.96)) + p["speed"] = float(np.clip(p["speed"] * (1.0 - 0.04 * variation), 0.60, 1.65)) + + # Closing: natural deceleration (the "landing" of a speech) + if pos_ratio > 0.88: + p["speed"] = float(np.clip(p["speed"] * (1.0 - 0.06 * variation), 0.60, 1.65)) + p["temperature"] = float(np.clip(p["temperature"] - 0.03 * variation, 0.40, 0.96)) + + # ── Micro-random jitter: prevent identical params on consecutive ─ + # neutral phrases (which would sound machine-even) + if emotion == "neutral" and variation > 0.3: + # deterministic pseudo-random from phrase content hash + h = abs(hash(base.get("_phrase_hint", ""))) % 1000 / 1000.0 + p["temperature"] += (h - 0.5) * 0.06 * variation + p["speed"] *= 1.0 + (h - 0.5) * 0.04 * variation + p["temperature"] = float(np.clip(p["temperature"], 0.40, 0.96)) + p["speed"] = float(np.clip(p["speed"], 0.60, 1.65)) + + return p + + +# ── Post-generation phrase volume envelope ──────────────────────────── +def apply_phrase_dynamics(data: np.ndarray, sr: int, + emotion: str, variation: float) -> np.ndarray: + """ + Shape amplitude envelope after XTTS generation. + This adds prosodic *weight* that XTTS parameter variation alone can't provide. + variation : 0-2 + """ + if variation < 0.02 or len(data) == 0: + return data + + n = len(data) + t = np.linspace(0.0, 1.0, n, dtype=np.float32) + env = np.ones(n, dtype=np.float32) + + v = min(variation, 2.0) + + if emotion == "question": + # Rising volume in last 25% — mimics upward intonation contour + rise_start = 0.75 + env += np.where(t > rise_start, + (t - rise_start) / (1.0 - rise_start) * 0.07 * v, 0) + + elif emotion == "exclaim": + # Forward energy burst — louder in first half, taper + env += (1.0 - t) * 0.05 * v + env += np.sin(t * np.pi * 2) * 0.03 * v # subtle tremolo feel + + elif emotion == "emphasis": + # Bell curve — maximum weight in the centre + env += np.sin(t * np.pi) * 0.10 * v + + elif emotion == "sadness": + # Gradual fade throughout — speech drains away + env -= t * 0.08 * v + env = np.clip(env, 0.70, 1.0) + + elif emotion == "joy": + # Bright forward motion + env += t * 0.04 * v + 0.02 * v + + elif emotion == "anger": + # Sharp, compressed — no gentle tails + attack = np.where(t < 0.05, t / 0.05, 1.0) + env *= attack + env += 0.04 * v + + elif emotion == "soft": + # Gentle, backed-off + env -= 0.06 * v + + elif emotion == "urgency": + # Flat high energy — no decay + env += 0.06 * v + + elif emotion == "story": + # Slow arc: quiet start, rise, settle + env += np.sin(t * np.pi * 0.7) * 0.05 * v + + # Smooth envelope (25 ms window to avoid clicks) + k = max(1, int(0.025 * sr)) + env = np.convolve(env, np.ones(k) / k, mode='same').astype(np.float32) + env = np.clip(env, 0.50, 1.30) + + result = data * env + # Soft-limit peaks + peak = np.max(np.abs(result)) + if peak > 0.95: + result = result / peak * 0.93 + return result.astype(np.float32) + + +def silence_samples(sr: int, ms: int = 120) -> np.ndarray: + """Short silence gap inserted between sentence chunks.""" + return np.zeros(int(sr * ms / 1000), dtype=np.float32) + +# ── Soft Clipping (tanh) — prevents digital distortion ──────────────── +def apply_speaker_boost(path): + data, sr = sf.read(path) + peak = np.max(np.abs(data)) + if peak > 0: + data = data / peak * 0.95 + # Soft clipping via hyperbolic tangent — smooth, natural saturation + # Unlike np.clip (hard clipping), tanh curves the peaks gently + data = np.tanh(data * 1.8) * (1.0 / np.tanh(1.8)) + sf.write(path, data, sr, subtype='PCM_16') + + +# ╔══════════════════════════════════════════════════════════════════════╗ +# ║ H U M A N I Z E — Pure-NumPy/SciPy DSP Chain ║ +# ║ Simulates recording in a real acoustic environment. ║ +# ║ No extra libraries. No IR files. All synthesized algorithmically. ║ +# ╚══════════════════════════════════════════════════════════════════════╝ + +# ── Environment presets ─────────────────────────────────────────────── +# Each dict drives the parameters of every DSP stage below. +HUMANIZE_PRESETS = { + "studio": dict( + # Clean, slightly padded room — professional voiceover booth + reverb_room=0.06, reverb_damping=0.85, reverb_wet=0.04, + noise_floor=-68, noise_color=0.5, + mic_hp_hz=90, mic_presence_db=1.5, mic_presence_hz=5500, + mic_air_db=-1.0, mic_air_hz=14000, + saturation=0.04, pitch_drift_cents=0.4, jitter_ms=0.3, + breathing=False, + ), + "bedroom": dict( + # Untreated small room — YouTube / indie podcast feel + reverb_room=0.22, reverb_damping=0.60, reverb_wet=0.09, + noise_floor=-62, noise_color=0.7, + mic_hp_hz=80, mic_presence_db=2.0, mic_presence_hz=4800, + mic_air_db=-0.5, mic_air_hz=13000, + saturation=0.08, pitch_drift_cents=0.8, jitter_ms=0.6, + breathing=False, + ), + "podcast": dict( + # Dynamic-mic, slightly driven — warm, intimate + reverb_room=0.10, reverb_damping=0.78, reverb_wet=0.05, + noise_floor=-65, noise_color=0.6, + mic_hp_hz=100, mic_presence_db=2.5, mic_presence_hz=3800, + mic_air_db=-2.5, mic_air_hz=12000, + saturation=0.14, pitch_drift_cents=0.6, jitter_ms=0.4, + breathing=False, + ), + "radio": dict( + # Classic broadcast: bandpass 200–8 kHz, compressed + reverb_room=0.04, reverb_damping=0.90, reverb_wet=0.02, + noise_floor=-72, noise_color=0.3, + mic_hp_hz=200, mic_presence_db=3.0, mic_presence_hz=3000, + mic_air_db=-8.0, mic_air_hz=8000, + saturation=0.18, pitch_drift_cents=0.3, jitter_ms=0.2, + breathing=False, + ), + "phone": dict( + # GSM / telephone: narrow band 300–3400 Hz, heavy saturation + reverb_room=0.02, reverb_damping=0.95, reverb_wet=0.01, + noise_floor=-55, noise_color=0.9, + mic_hp_hz=300, mic_presence_db=1.0, mic_presence_hz=2200, + mic_air_db=-20.0, mic_air_hz=3500, + saturation=0.28, pitch_drift_cents=1.2, jitter_ms=1.2, + breathing=False, + ), + "cafe": dict( + # Mid-sized reverberant room, background murmur + reverb_room=0.42, reverb_damping=0.45, reverb_wet=0.16, + noise_floor=-54, noise_color=0.8, + mic_hp_hz=70, mic_presence_db=1.0, mic_presence_hz=4000, + mic_air_db=-1.0, mic_air_hz=14000, + saturation=0.06, pitch_drift_cents=1.0, jitter_ms=0.8, + breathing=False, + ), + "outdoor": dict( + # Open space — very short reverb, wind-ish noise + reverb_room=0.08, reverb_damping=0.92, reverb_wet=0.03, + noise_floor=-50, noise_color=0.95, + mic_hp_hz=60, mic_presence_db=0.5, mic_presence_hz=3500, + mic_air_db=0.0, mic_air_hz=16000, + saturation=0.05, pitch_drift_cents=1.5, jitter_ms=1.5, + breathing=False, + ), +} + +# ── DSP helpers ─────────────────────────────────────────────────────── + +def _biquad_hp(data: np.ndarray, sr: int, fc: float) -> np.ndarray: + """2nd-order Butterworth high-pass filter — clamped to safe range.""" + from scipy.signal import butter, sosfilt + fc = float(np.clip(fc, 1.0, sr * 0.49)) # never hit Nyquist + sos = butter(2, fc / (sr / 2), btype='high', output='sos') + out = sosfilt(sos, data.astype(np.float64)) + if not np.all(np.isfinite(out)): + return data.astype(np.float32) + return out.astype(np.float32) + +def _biquad_peak(data: np.ndarray, sr: int, fc: float, + gain_db: float, Q: float = 1.4) -> np.ndarray: + """Parametric peak EQ biquad — clamped and guarded.""" + from scipy.signal import sosfilt + fc = float(np.clip(fc, 20.0, sr * 0.45)) + gain_db = float(np.clip(gain_db, -40.0, 20.0)) + Q = float(np.clip(Q, 0.1, 20.0)) + w0 = 2 * np.pi * fc / sr + A = 10 ** (gain_db / 40.0) + cos = np.cos(w0); sin = np.sin(w0) + alpha = sin / (2 * Q) + b0 = 1 + alpha * A; b1 = -2 * cos; b2 = 1 - alpha * A + a0 = 1 + alpha / A; a1 = -2 * cos; a2 = 1 - alpha / A + if abs(a0) < 1e-10: + return data.astype(np.float32) + sos = np.array([[b0/a0, b1/a0, b2/a0, 1.0, a1/a0, a2/a0]], dtype=np.float64) + if not np.all(np.isfinite(sos)): + return data.astype(np.float32) + out = sosfilt(sos, data.astype(np.float64)) + if not np.all(np.isfinite(out)): + return data.astype(np.float32) + return out.astype(np.float32) + +def _biquad_shelf_hi(data: np.ndarray, sr: int, fc: float, + gain_db: float) -> np.ndarray: + """ + High-shelf filter (air band roll-off / boost). + Fixed: clamp fc below Nyquist and guard alpha against overflow/NaN. + """ + from scipy.signal import sosfilt + # Clamp cutoff to a safe range (never exceed 90% of Nyquist) + fc = float(np.clip(fc, 20.0, sr * 0.45)) + gain_db = float(np.clip(gain_db, -40.0, 20.0)) + + w0 = 2 * np.pi * fc / sr + A = 10 ** (gain_db / 40.0) + cos = np.cos(w0) + sin = np.sin(w0) + sqA = float(np.sqrt(max(A, 1e-12))) + S = 1.0 + + # Guard the sqrt argument — clamp to avoid negative/NaN + sqrt_arg = (A + 1.0 / max(A, 1e-12)) * (1.0 / max(S, 1e-9) - 1.0) + 2.0 + sqrt_arg = max(sqrt_arg, 0.0) + alpha = sin / 2.0 * np.sqrt(sqrt_arg) + + b0 = A * ((A + 1) + (A - 1) * cos + 2 * sqA * alpha) + b1 = -2 * A * ((A - 1) + (A + 1) * cos) + b2 = A * ((A + 1) + (A - 1) * cos - 2 * sqA * alpha) + a0 = (A + 1) - (A - 1) * cos + 2 * sqA * alpha + a1 = 2 * ((A - 1) - (A + 1) * cos) + a2 = (A + 1) - (A - 1) * cos - 2 * sqA * alpha + + # Guard against degenerate filter (a0 near zero) + if abs(a0) < 1e-10: + return data.astype(np.float32) + + sos = np.array([[b0/a0, b1/a0, b2/a0, 1.0, a1/a0, a2/a0]], dtype=np.float64) + + # Final guard: if any coefficient is NaN/Inf, bypass + if not np.all(np.isfinite(sos)): + return data.astype(np.float32) + + result = sosfilt(sos, data.astype(np.float64)) + + # Guard output (should never happen now, but safety net) + if not np.all(np.isfinite(result)): + return data.astype(np.float32) + + return result.astype(np.float32) + +def _pink_noise(n: int, rng: np.random.Generator) -> np.ndarray: + """FFT-based pink noise — O(N log N), no sample loops.""" + white = rng.standard_normal(n).astype(np.float64) + fft_w = np.fft.rfft(white) + freqs = np.arange(1, len(fft_w) + 1, dtype=np.float64) + fft_w /= np.sqrt(freqs) # 1/sqrt(f) → pink (1/f power) + pink = np.fft.irfft(fft_w, n=n) + peak = np.max(np.abs(pink)) + if peak > 0: pink /= peak + return pink.astype(np.float32) + +def _comb_reverb(data: np.ndarray, sr: int, + room: float, damping: float, wet: float) -> np.ndarray: + """ + Schroeder reverb via scipy.signal.lfilter — fully vectorised, no Python loops. + 4 parallel comb filters + 2 series allpass. + room : 0–1 (room size; affects delay lengths) + damping : 0–1 (high-frequency absorption) + wet : 0–1 (wet/dry mix) + """ + if wet < 0.001: + return data + from scipy.signal import lfilter + + comb_delays_ms = [29.7, 37.1, 41.1, 43.7] + comb_base_gains = [0.805, 0.827, 0.783, 0.764] + allpass_delays_ms = [5.0, 1.7] + allpass_gain = 0.7 + scale = 0.5 + room * 0.5 + damp_factor = 1.0 - damping * 0.35 # approximate HF absorption + + sig64 = data.astype(np.float64) + rev = np.zeros_like(sig64) + + for dm, g_base in zip(comb_delays_ms, comb_base_gains): + N = max(2, int(dm * scale * sr / 1000)) + g = min(g_base * (0.5 + room * 0.5) * damp_factor, 0.97) + b = np.zeros(N + 1); b[0] = 1.0 + a = np.zeros(N + 1); a[0] = 1.0; a[N] = -g + rev += lfilter(b, a, sig64) + + rev *= 0.25 + + for dm in allpass_delays_ms: + N = max(2, int(dm * sr / 1000)) + g = allpass_gain + b = np.zeros(N + 1); b[0] = -g; b[N] = 1.0 + a = np.zeros(N + 1); a[0] = 1.0; a[N] = -g + rev = lfilter(b, a, rev) + + return (data * (1.0 - wet) + rev.astype(np.float32) * wet).astype(np.float32) + +def _micro_pitch_drift(data: np.ndarray, sr: int, + cents: float, rng: np.random.Generator) -> np.ndarray: + """ + Subtle continuous pitch drift ±cents via nearest-neighbour resampling. + Creates the organic wobble of a real human voice. + """ + if cents < 0.05: + return data + # Generate a slow-moving drift signal (0.1–0.5 Hz sinusoid + noise) + t = np.arange(len(data)) / sr + freq_hz = 0.2 + rng.uniform(0, 0.15) + drift = np.sin(2 * np.pi * freq_hz * t) + drift += 0.3 * rng.standard_normal(len(data)) # add gentle randomness + # Smooth with a 200 ms window + k = max(1, int(0.2 * sr)) + drift = np.convolve(drift, np.ones(k)/k, mode='same') + # Scale to ±ratio + ratio = 2 ** (cents / 1200) # cents → semitone ratio + factor = 1.0 + (ratio - 1.0) * drift # ±ratio around 1.0 + factor = np.clip(factor, 0.90, 1.10) + + # Nearest-neighbour reindex + indices = np.cumsum(factor) + indices = indices / indices[-1] * (len(data) - 1) + indices = np.clip(indices.astype(np.int32), 0, len(data) - 1) + return data[indices].astype(np.float32) + +def _timing_jitter(data: np.ndarray, sr: int, jitter_ms: float, + rng: np.random.Generator) -> np.ndarray: + """ + Apply block-level timing jitter: randomly shift 20 ms blocks by ±jitter_ms. + Breaks the perfectly even machine cadence of synthetic speech. + """ + if jitter_ms < 0.1: + return data + block = int(0.020 * sr) # 20 ms blocks + out = data.copy() + shift_max = int(jitter_ms * sr / 1000) + for start in range(0, len(data) - block, block): + shift = rng.integers(-shift_max, shift_max + 1) + src_start = max(0, start + shift) + src_end = min(len(data), start + block + shift) + dst_len = min(block, len(data) - start) + src_len = src_end - src_start + n = min(dst_len, src_len) + if n > 0: + out[start:start + n] = data[src_start:src_start + n] + return out + +def _soft_saturate(data: np.ndarray, amount: float) -> np.ndarray: + """ + Soft harmonic saturation — adds 2nd/3rd harmonics like analog tape/tube. + amount: 0 = bypass, 1 = heavy saturation + """ + if amount < 0.005: + return data + drive = 1.0 + amount * 6.0 # 1x … 7x overdrive + driven = data * drive + # tanh saturation, then scale back down + sat = np.tanh(driven) / np.tanh(drive) + return (data * (1.0 - amount) + sat * amount).astype(np.float32) + +# ── Master humanize function ────────────────────────────────────────── +def humanize_audio(src_path: str, out_path: str, preset_name: str, + intensity: float = 1.0, seed: int = 42): + """ + Apply full humanization DSP chain to a WAV file. + intensity: 0.0 = almost dry, 1.0 = full preset, >1.0 = exaggerated. + Each stage is wrapped in a try/except so a failure degrades gracefully + instead of corrupting the output file. + """ + p = HUMANIZE_PRESETS.get(preset_name, HUMANIZE_PRESETS["bedroom"]) + rng = np.random.default_rng(seed) + + data, sr = sf.read(src_path) + if data.ndim > 1: + data = data.mean(axis=1) + data = data.astype(np.float32) + + if len(data) == 0: + raise ValueError("ملف الصوت فارغ.") + + mix = float(np.clip(intensity, 0.0, 2.0)) + + def _safe(fn, label, *args, **kwargs): + """Run fn(*args, **kwargs), return original data on failure.""" + nonlocal data + try: + result = fn(*args, **kwargs) + # Reject NaN / Inf output — fall back to unchanged + if result is not None and len(result) > 0 and np.all(np.isfinite(result)): + data = result.astype(np.float32) + else: + print(f"[humanize] {label}: non-finite output — stage skipped") + except Exception as e: + print(f"[humanize] {label}: {e} — stage skipped") + + # 1. Mic coloring ───────────────────────────────────────────────── + _safe(_biquad_hp, "HP filter", data, sr, p["mic_hp_hz"]) + if abs(p["mic_presence_db"]) > 0.1: + _safe(_biquad_peak, "Peak EQ", data, sr, + p["mic_presence_hz"], p["mic_presence_db"] * mix, Q=1.2) + if abs(p["mic_air_db"]) > 0.1: + _safe(_biquad_shelf_hi, "Hi-shelf", data, sr, + p["mic_air_hz"], p["mic_air_db"] * mix) + + # 2. Harmonic saturation ────────────────────────────────────────── + _safe(_soft_saturate, "Saturation", data, p["saturation"] * mix) + + # 3. Timing jitter ──────────────────────────────────────────────── + _safe(_timing_jitter, "Jitter", data, sr, p["jitter_ms"] * mix, rng) + + # 4. Micro pitch drift ──────────────────────────────────────────── + _safe(_micro_pitch_drift, "Pitch drift", data, sr, + p["pitch_drift_cents"] * mix, rng) + + # 5. Room reverb ────────────────────────────────────────────────── + _safe(_comb_reverb, "Reverb", data, sr, + p["reverb_room"], p["reverb_damping"], p["reverb_wet"] * mix) + + # 6. Noise floor ────────────────────────────────────────────────── + try: + floor_db = p["noise_floor"] + (1.0 - mix) * 20 + floor_db = float(np.clip(floor_db, -96.0, -30.0)) + noise_amp = 10 ** (floor_db / 20.0) + color = float(np.clip(p["noise_color"], 0.0, 1.0)) + pink = _pink_noise(len(data), rng) + white = rng.standard_normal(len(data)).astype(np.float32) + noise_mix = pink * color + white * (1.0 - color) + nm_peak = np.max(np.abs(noise_mix)) + if nm_peak > 1e-9: + noise_mix = noise_mix / nm_peak * noise_amp + candidate = data + noise_mix.astype(np.float32) + if np.all(np.isfinite(candidate)): + data = candidate + except Exception as e: + print(f"[humanize] Noise floor: {e} — stage skipped") + + # 7. Final normalize + soft ceiling ────────────────────────────── + peak = float(np.max(np.abs(data))) + if peak > 0: + data = data / peak * 0.92 + # tanh soft-limiter (can't produce NaN on finite input) + data = (np.tanh(data * 1.1) * (1.0 / np.tanh(1.1))).astype(np.float32) + + sf.write(out_path, data, sr, subtype='PCM_16') + +# ── Text Splitting for Real Progress ───────────────────────────────── +def split_text_sentences(text: str, max_chunk: int = 280) -> list: + """Split text into sentence-sized chunks with a max character limit.""" + parts = re.split(r'(?<=[.!?؟\n،])\s+', text.strip()) + chunks, cur = [], "" + for p in parts: + if not p.strip(): + continue + if len(cur) + len(p) + 1 <= max_chunk: + cur = (cur + " " + p).strip() if cur else p + else: + if cur: + chunks.append(cur) + if len(p) > max_chunk: + # Sub-split on comma/semicolon + sub_parts = re.split(r'(?<=[،,;])\s+', p) + sc = "" + for s in sub_parts: + if len(sc) + len(s) + 1 <= max_chunk: + sc = (sc + " " + s).strip() if sc else s + else: + if sc: chunks.append(sc) + sc = s + if sc: chunks.append(sc) + cur = "" + else: + cur = p + if cur: + chunks.append(cur) + return chunks if chunks else [text] + +# ── Core TTS call ───────────────────────────────────────────────────── +def run_tts(text, refs, lang, tts_params, split, out_path): + if not xtts_ready or xtts is None: + raise RuntimeError("النموذج لا يزال يُحمَّل، يُرجى الانتظار قليلاً ثم المحاولة مجدداً.") + p = {k: v for k, v in tts_params.items() if not k.startswith("_")} + xtts.tts_to_file( + text=text, speaker_wav=refs, language=lang, file_path=out_path, + enable_text_splitting=split, **p, + ) + +def run_tts_chunked(text, refs, lang, base_params, + out_path, job_id, + silence_ms: int = 110, + variation: float = 0.0, + ) -> bool: + """ + Split text → per-phrase emotion analysis → per-phrase XTTS params + → per-phrase dynamics shaping → silence gaps → merge. + variation=0 → all chunks use base_params (original behaviour). + Returns True on success, False if cancelled. + """ + cancel_ev = cancel_events.get(job_id) + chunks = split_text_sentences(text) if len(text) > 280 else [text] + total = len(chunks) + job_progress[job_id] = {"current": 0, "total": total, "status": "running"} + + # ── Single-chunk fast path ──────────────────────────────────────── + if total == 1: + if cancel_ev and cancel_ev.is_set(): + job_progress[job_id]["status"] = "cancelled"; return False + phrase_p = base_params + sc_emotion = "neutral" + if variation > 0.02: + sc_emotion, sc_conf = analyze_phrase_emotion(text, lang) # ONE call + phrase_p = make_phrase_params(dict(base_params, _phrase_hint=text[:30]), + sc_emotion, sc_conf, variation, 0.5) + run_tts(text, refs, lang, phrase_p, True, out_path) + if variation > 0.02 and sc_emotion != "neutral": + data, sr = sf.read(out_path) + if data.ndim > 1: data = data.mean(axis=1) + data = apply_phrase_dynamics(data.astype(np.float32), sr, sc_emotion, variation) + sf.write(out_path, data, sr, subtype="PCM_16") + job_progress[job_id] = {"current": 1, "total": 1, "status": "done", "ts": time.time()} + return True + + # ── Multi-chunk expressive path ─────────────────────────────────── + emotions = [analyze_phrase_emotion(c, lang) for c in chunks] + + tmp_files, arrays, sr_out = [], [], None + try: + for i, (chunk, (emotion, conf)) in enumerate(zip(chunks, emotions)): + if cancel_ev and cancel_ev.is_set(): + job_progress[job_id]["status"] = "cancelled"; return False + + pos = i / max(total - 1, 1) + phrase_p = make_phrase_params( + dict(base_params, _phrase_hint=chunk[:30]), + emotion, conf, variation, pos) + + gap_ms = EMOTION_DELTAS.get(emotion, EMOTION_DELTAS["neutral"])[3] + actual_gap = int(silence_ms * 0.4 + gap_ms * 0.6) if variation > 0.1 else silence_ms + + tmp = f"/tmp/ch_{job_id}_{i}.wav" + run_tts(chunk, refs, lang, phrase_p, False, tmp) + tmp_files.append(tmp) + + data, sr = sf.read(tmp) + if sr_out is None: sr_out = sr + if data.ndim > 1: data = data.mean(axis=1) + data = data.astype(np.float32) + if variation > 0.02: + data = apply_phrase_dynamics(data, sr, emotion, variation) + arrays.append(data) + arrays.append(silence_samples(sr, actual_gap)) + job_progress[job_id]["current"] = i + 1 + + combined = np.concatenate(arrays) if arrays else np.zeros(sr_out or 22050, np.float32) + sf.write(out_path, combined, sr_out or 22050, subtype="PCM_16") + job_progress[job_id]["status"] = "done" + job_progress[job_id]["ts"] = time.time() + return True + finally: + for tf in tmp_files: + try: os.remove(tf) + except: pass + +def collect_refs(files, voice_name): + paths = [] + for f in files: + if not f or not getattr(f, "filename", None): continue + p = f"/tmp/ref_{uuid.uuid4().hex}_{f.filename}" + with open(p, "wb") as buf: shutil.copyfileobj(f.file, buf) + paths.append(p) + if voice_name and voice_name not in ("", "null", "none", "undefined"): + lib_dir = os.path.join(VOICE_LIB, voice_name) + if os.path.isdir(lib_dir): + paths += [os.path.join(lib_dir, fn) for fn in sorted(os.listdir(lib_dir)) + if fn.lower().endswith((".wav", ".mp3", ".flac", ".ogg"))] + return paths + +def cleanup(paths): + for p in paths: + if p and p.startswith("/tmp/"): + try: os.remove(p) + except: pass + +# ── FastAPI ─────────────────────────────────────────────────────────── +app = FastAPI(title="XTTS Voice Studio CPU Optimized") +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) + +LANGUAGES = { + "ar":"العربية","en":"English","es":"Español","fr":"Français", + "de":"Deutsch","it":"Italiano","pt":"Português","ru":"Русский", + "zh-cn":"中文","ja":"日本語","ko":"한국어","tr":"Türkçe", + "nl":"Nederlands","pl":"Polski","cs":"Čeština","hi":"हिन्दी", +} +LANG_MAP = {"zh-cn":"zh-CN"} + +# ══════════════════════════════════════════════════════════════════════ +# F R O N T E N D +# ══════════════════════════════════════════════════════════════════════ +HTML = r""" + +
+ + +