Spaces:
Running on Zero
Running on Zero
Upload app.py
Browse files
app.py
CHANGED
|
@@ -184,7 +184,7 @@ from scipy.ndimage import uniform_filter, binary_closing, binary_opening
|
|
| 184 |
|
| 185 |
REF_SR = 22050 # XTTS conditioning sample-rate
|
| 186 |
XTTS_SR = 24000 # XTTS output sample-rate
|
| 187 |
-
VQ_VERSION = "
|
| 188 |
|
| 189 |
# ── generic helpers ───────────────────────────────────────────────────
|
| 190 |
def _db(x): return 20.0 * np.log10(np.maximum(x, 1e-9))
|
|
@@ -275,52 +275,6 @@ def _segments(mask):
|
|
| 275 |
d = np.diff(np.concatenate([[0], mask.astype(np.int8), [0]]))
|
| 276 |
return list(zip(np.where(d == 1)[0], np.where(d == -1)[0]))
|
| 277 |
|
| 278 |
-
def _snap_low(db, i, span=50):
|
| 279 |
-
lo, hi = max(0, i - span), min(len(db), i + span + 1)
|
| 280 |
-
return lo + int(np.argmin(db[lo:hi]))
|
| 281 |
-
|
| 282 |
-
def _compact_speech(x, sr, db, w, h, mask):
|
| 283 |
-
"""Keep speech + short natural pauses; squeeze long dead air."""
|
| 284 |
-
segs = _segments(mask)
|
| 285 |
-
if not segs: return x
|
| 286 |
-
pad = int(0.06 * sr)
|
| 287 |
-
parts, prev_end = [], None
|
| 288 |
-
for s, e in segs:
|
| 289 |
-
a = max(0, s * h - pad); b = min(len(x), e * h + w + pad)
|
| 290 |
-
if prev_end is not None and a > prev_end:
|
| 291 |
-
gap = x[prev_end:a]
|
| 292 |
-
keep = int(0.30 * sr)
|
| 293 |
-
gap = gap if len(gap) <= keep else np.concatenate([gap[: keep // 2], gap[-keep // 2:]])
|
| 294 |
-
parts.append(_fade(gap, sr, 10, 10))
|
| 295 |
-
parts.append(x[max(a, prev_end or 0):b])
|
| 296 |
-
prev_end = b
|
| 297 |
-
return np.concatenate(parts).astype(np.float32)
|
| 298 |
-
|
| 299 |
-
def _best_blocks(z, sr, target_sec, block_sec=6.0):
|
| 300 |
-
"""Pick the cleanest ~target_sec of audio (blocks cut on low-energy frames)."""
|
| 301 |
-
if len(z) <= target_sec * sr: return z
|
| 302 |
-
db, w, h = frame_db(z, sr)
|
| 303 |
-
nfr = len(db)
|
| 304 |
-
per = int(block_sec * 100) # frames per block (10 ms hop)
|
| 305 |
-
cuts = sorted({0, nfr, *[_snap_low(db, i) for i in range(per, nfr - per // 2, per)]})
|
| 306 |
-
blocks = [(a, b) for a, b in zip(cuts[:-1], cuts[1:]) if b - a > 150]
|
| 307 |
-
if not blocks: return z[: int(target_sec * sr)]
|
| 308 |
-
scores = []
|
| 309 |
-
for a, b in blocks:
|
| 310 |
-
seg = db[a:b]; v = seg > max(np.percentile(seg, 10) + 10, np.percentile(seg, 95) - 28)
|
| 311 |
-
clip = float(np.mean(np.abs(z[a * h:b * h + w]) > 0.98))
|
| 312 |
-
snr = (seg[v].mean() - np.percentile(seg, 10)) if v.any() else 0.0
|
| 313 |
-
sd = float(seg[v].std()) if v.sum() > 3 else 12.0
|
| 314 |
-
scores.append(snr - 0.5 * sd - 300 * clip)
|
| 315 |
-
order = np.argsort(scores)[::-1]
|
| 316 |
-
chosen, tot = [], 0.0
|
| 317 |
-
for i in order:
|
| 318 |
-
a, b = blocks[i]; d = (b - a) / 100.0
|
| 319 |
-
if tot >= target_sec: break
|
| 320 |
-
chosen.append(i); tot += d
|
| 321 |
-
chosen.sort()
|
| 322 |
-
return np.concatenate([z[blocks[i][0] * h: blocks[i][1] * h + w] for i in chosen]).astype(np.float32)
|
| 323 |
-
|
| 324 |
def _analyze(x_native_norm, fs_native, sr=REF_SR):
|
| 325 |
"""Diagnostics on a (peak-normalised) signal."""
|
| 326 |
db, w, h = frame_db(x_native_norm, sr)
|
|
@@ -331,16 +285,57 @@ def _analyze(x_native_norm, fs_native, sr=REF_SR):
|
|
| 331 |
return dict(noise_floor_db=round(noise, 1), snr_db=round(peak - noise, 1),
|
| 332 |
bandwidth_hz=int(min(rolloff, fs_native / 2)), native_sr=int(fs_native))
|
| 333 |
|
| 334 |
-
def
|
| 335 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 336 |
raw, fs = load_mono(path)
|
| 337 |
if len(raw) < 0.5 * REF_SR:
|
| 338 |
raise ValueError("العينة قصيرة جداً (أقل من نصف ثانية).")
|
| 339 |
dur_in = len(raw) / REF_SR
|
| 340 |
clip_pct = float(np.mean(np.abs(raw) >= 0.995) * 100)
|
| 341 |
raw = raw - float(np.mean(raw))
|
| 342 |
-
|
| 343 |
-
x = raw / pk * 0.7
|
| 344 |
before = _analyze(x, fs)
|
| 345 |
|
| 346 |
x = _highpass(x, REF_SR, 70)
|
|
@@ -348,21 +343,25 @@ def enhance_reference(path, strength=1.0, target_sec=24.0, denoise=True):
|
|
| 348 |
if denoise and before["noise_floor_db"] > -62:
|
| 349 |
s = float(np.clip((before["noise_floor_db"] + 62) / 20.0, 0.35, 1.0)) * strength
|
| 350 |
x = spectral_denoise(x, REF_SR, s); did_denoise = True
|
|
|
|
| 351 |
|
| 352 |
db, w, h = frame_db(x, REF_SR)
|
| 353 |
mask = _voiced_mask(db)
|
| 354 |
speech_sec_all = float(mask.sum() * 0.01)
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
|
|
|
|
|
|
|
|
|
| 366 |
|
| 367 |
warns, tips = [], []
|
| 368 |
if speech_sec_all < 6:
|
|
@@ -375,18 +374,12 @@ def enhance_reference(path, strength=1.0, target_sec=24.0, denoise=True):
|
|
| 375 |
warns.append(f"جودة ترددية منخفضة (حتى {before['bandwidth_hz']} Hz) — غالباً هاتف/ضغط شديد."); tips.append("استخدم WAV/FLAC أو تسجيلاً بجودة أعلى.")
|
| 376 |
if before["native_sr"] < 16000:
|
| 377 |
warns.append(f"معدل العينة الأصلي {before['native_sr']} Hz منخفض.")
|
| 378 |
-
score = 100.0
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
report = dict(duration_in=round(dur_in, 1), duration_out=round(len(z) / REF_SR, 1),
|
| 386 |
-
speech_sec=round(speech_sec_all, 1), clipping_pct=round(clip_pct, 2),
|
| 387 |
-
denoised=did_denoise, before=before, after=after,
|
| 388 |
-
score=score, warnings=warns, tips=tips)
|
| 389 |
-
return z.astype(np.float32), report
|
| 390 |
|
| 391 |
def _file_sha(path):
|
| 392 |
h = hashlib.sha1()
|
|
@@ -395,46 +388,52 @@ def _file_sha(path):
|
|
| 395 |
return h.hexdigest()
|
| 396 |
|
| 397 |
def enhance_reference_set(paths, cache_dir, strength=1.0, target_sec=24.0,
|
| 398 |
-
|
| 399 |
-
"""Clean
|
| 400 |
-
|
| 401 |
-
|
|
|
|
| 402 |
os.makedirs(cache_dir, exist_ok=True)
|
| 403 |
key = hashlib.sha1(("|".join(_file_sha(p) for p in paths) +
|
| 404 |
-
f"|{strength}|{target_sec}|{
|
| 405 |
-
|
| 406 |
-
if os.path.exists(
|
| 407 |
d = json.load(open(meta, encoding="utf-8"))
|
| 408 |
-
|
|
|
|
| 409 |
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
for p in paths:
|
| 413 |
try:
|
| 414 |
-
|
| 415 |
rep["file"] = os.path.basename(p)[-40:]
|
| 416 |
-
|
|
|
|
| 417 |
except Exception as e:
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 434 |
snr_before=best["before"]["snr_db"], snr_after=best["after"]["snr_db"],
|
| 435 |
-
denoised=any(r["denoised"] for
|
| 436 |
-
json.dump(dict(reports=reports, summary=summary), open(meta, "w", encoding="utf-8"), ensure_ascii=False)
|
| 437 |
-
return
|
| 438 |
|
| 439 |
# ═══════════════════════════════════════════════════════════════════════
|
| 440 |
# 2. TEXT NORMALISATION
|
|
@@ -541,36 +540,53 @@ def text_similarity(a, b):
|
|
| 541 |
if not a or not b: return 0.0
|
| 542 |
return difflib.SequenceMatcher(None, a, b, autojunk=False).ratio()
|
| 543 |
|
| 544 |
-
|
|
|
|
|
|
|
| 545 |
dur = len(wav) / sr
|
| 546 |
peak = float(np.max(np.abs(wav))) if len(wav) else 0.0
|
| 547 |
cps = len(norm_for_compare(text)) / max(dur, 1e-3)
|
|
|
|
| 548 |
sim = None
|
| 549 |
if asr_fn is not None and dur > 0.2:
|
| 550 |
try: sim = text_similarity(text, asr_fn(wav, sr))
|
| 551 |
except Exception: sim = None
|
| 552 |
-
ok = peak > 0.02 and
|
| 553 |
-
return dict(dur=dur, cps=cps, peak=peak, sim=sim, ok=ok,
|
| 554 |
clip=float(np.mean(np.abs(wav) > 0.99)) if len(wav) else 0.0)
|
| 555 |
|
| 556 |
-
def
|
| 557 |
-
""
|
| 558 |
-
|
| 559 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 560 |
wav = trim_take(gen_fn(seed + 7919 * k), sr)
|
| 561 |
-
info = take_info(wav, sr, text, asr_fn)
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 565 |
if len(takes) == 1: return takes[0][0], takes[0][1], 1
|
| 566 |
med = float(np.median([i["dur"] for _, i in takes]))
|
| 567 |
-
|
| 568 |
-
s = (i["sim"] * 100.0) if i["sim"] is not None else 60.0
|
| 569 |
-
s -= 25.0 * abs(math.log(max(i["dur"], 1e-3) / max(med, 1e-3)))
|
| 570 |
-
s -= 300.0 * i["clip"]
|
| 571 |
-
s -= 0 if i["ok"] else 30.0
|
| 572 |
-
return s
|
| 573 |
-
best = max(takes, key=lambda t: score(t[1]))
|
| 574 |
return best[0], best[1], len(takes)
|
| 575 |
|
| 576 |
# ═══════════════════════════════════════════════════════════════════════
|
|
@@ -772,16 +788,38 @@ try:
|
|
| 772 |
except ImportError:
|
| 773 |
HUB_AVAILABLE = False
|
| 774 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 775 |
def trigger_cloud_backup():
|
|
|
|
|
|
|
| 776 |
if not (HUB_AVAILABLE and HF_TOKEN and DATASET_REPO_ID): return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 777 |
def _run():
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 785 |
|
| 786 |
# ── XTTS Model ────────────────��───────────────────────────────────────
|
| 787 |
from TTS.api import TTS
|
|
@@ -877,19 +915,29 @@ except Exception:
|
|
| 877 |
TRANSLATOR_OK = False
|
| 878 |
|
| 879 |
# ── History helpers ───────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
| 880 |
def load_history():
|
| 881 |
-
|
| 882 |
-
|
| 883 |
-
|
| 884 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 885 |
|
| 886 |
def save_history(h):
|
| 887 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 888 |
trigger_cloud_backup()
|
| 889 |
|
| 890 |
-
MAX_HISTORY = 300
|
| 891 |
-
MAX_OUTPUTS = 200
|
| 892 |
-
|
| 893 |
def _prune_outputs():
|
| 894 |
try:
|
| 895 |
wavs = sorted(
|
|
@@ -898,17 +946,18 @@ def _prune_outputs():
|
|
| 898 |
)
|
| 899 |
for old in wavs[:-MAX_OUTPUTS]:
|
| 900 |
try: os.remove(os.path.join(OUTPUT_DIR, old))
|
| 901 |
-
except: pass
|
| 902 |
except Exception:
|
| 903 |
pass
|
| 904 |
|
| 905 |
def append_history(filename, text, language, mode, char_count=0):
|
| 906 |
-
|
| 907 |
-
|
| 908 |
-
|
| 909 |
-
|
| 910 |
-
h
|
| 911 |
-
|
|
|
|
| 912 |
_prune_outputs()
|
| 913 |
|
| 914 |
# ── Parameter Engineering ─────────────────────────────────────────────
|
|
@@ -1702,24 +1751,39 @@ def prepare_refs(refs, enhance=True):
|
|
| 1702 |
if not enhance:
|
| 1703 |
return list(refs), None
|
| 1704 |
try:
|
| 1705 |
-
|
| 1706 |
-
return
|
| 1707 |
except Exception as e:
|
| 1708 |
print(f"[!] Reference enhancement failed — using the raw sample: {e}", flush=True)
|
| 1709 |
return list(refs), None
|
| 1710 |
|
| 1711 |
def get_latents(model, ref_paths):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1712 |
key = tuple((p, os.path.getsize(p), int(os.path.getmtime(p))) for p in ref_paths)
|
| 1713 |
if key in _LAT_CACHE:
|
| 1714 |
_LAT_CACHE.move_to_end(key)
|
| 1715 |
return _LAT_CACHE[key]
|
| 1716 |
c = model.config
|
| 1717 |
-
|
| 1718 |
-
|
| 1719 |
-
|
| 1720 |
-
|
| 1721 |
-
|
| 1722 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1723 |
_LAT_CACHE[key] = lat
|
| 1724 |
while len(_LAT_CACHE) > 6:
|
| 1725 |
_LAT_CACHE.popitem(last=False)
|
|
@@ -1774,10 +1838,63 @@ def _asr_for_scoring(lang):
|
|
| 1774 |
return " ".join(s.text for s in segs)
|
| 1775 |
return fn
|
| 1776 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1777 |
def run_tts_chunked(text, refs, lang, base_params, out_path, job_id,
|
| 1778 |
silence_ms: int = 110, variation: float = 0.0,
|
| 1779 |
attempts: int = 1, enhance_ref: bool = True,
|
| 1780 |
-
clean_text: bool = True, ref_summary_out: dict = None
|
|
|
|
| 1781 |
"""Quality pipeline. Falls back to the legacy pipeline if anything unexpected
|
| 1782 |
goes wrong, so a bug here can never block generation."""
|
| 1783 |
model = _tts_model()
|
|
@@ -1785,30 +1902,47 @@ def run_tts_chunked(text, refs, lang, base_params, out_path, job_id,
|
|
| 1785 |
return _run_tts_chunked_legacy(text, refs, lang, base_params, out_path, job_id, silence_ms, variation)
|
| 1786 |
try:
|
| 1787 |
return _run_tts_quality(model, text, refs, lang, base_params, out_path, job_id,
|
| 1788 |
-
silence_ms, variation, attempts, enhance_ref, clean_text, ref_summary_out)
|
| 1789 |
except Exception:
|
| 1790 |
print("[!] Quality pipeline failed — falling back to legacy pipeline:", flush=True)
|
| 1791 |
traceback.print_exc()
|
| 1792 |
return _run_tts_chunked_legacy(text, refs, lang, base_params, out_path, job_id, silence_ms, variation)
|
| 1793 |
|
| 1794 |
def _run_tts_quality(model, text, refs, lang, base_params, out_path, job_id,
|
| 1795 |
-
silence_ms, variation, attempts, enhance_ref, clean_text, ref_summary_out):
|
| 1796 |
cancel_ev = cancel_events.get(job_id)
|
| 1797 |
job_progress[job_id] = {"current": 0, "total": 1, "status": "running"}
|
|
|
|
| 1798 |
ref_paths, summary = prepare_refs(refs, enhance_ref)
|
| 1799 |
if summary and ref_summary_out is not None:
|
| 1800 |
ref_summary_out.update(summary)
|
| 1801 |
-
lat_gpt, lat_spk = get_latents(model, ref_paths)
|
| 1802 |
|
| 1803 |
-
txt
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1804 |
limits = getattr(getattr(model, "tokenizer", None), "char_limits", None)
|
| 1805 |
chunks = vq.split_for_xtts(txt, lang, limits)
|
| 1806 |
total = len(chunks)
|
| 1807 |
job_progress[job_id] = {"current": 0, "total": total, "status": "running"}
|
| 1808 |
|
| 1809 |
d = _model_defaults(model)
|
| 1810 |
-
|
| 1811 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1812 |
use_var = variation > 0.02
|
| 1813 |
emotions = [analyze_phrase_emotion(c, lang) for c, _ in chunks] if use_var else [("neutral", 0.0)] * total
|
| 1814 |
seed0 = zlib.crc32(txt.encode("utf-8")) & 0x7FFFFFFF
|
|
@@ -1830,7 +1964,9 @@ def _run_tts_quality(model, text, refs, lang, base_params, out_path, job_id,
|
|
| 1830 |
out = model.inference(chunk, lang, lat_gpt, lat_spk, enable_text_splitting=False, **kw)
|
| 1831 |
return np.asarray(out["wav"], np.float32).reshape(-1)
|
| 1832 |
|
| 1833 |
-
wav, _info, _n = vq.synth_best(gen, chunk, attempts, asr, vq.XTTS_SR, seed0 + 101 * i
|
|
|
|
|
|
|
| 1834 |
wav = _residual_stretch(wav, resid)
|
| 1835 |
if use_var and emo != "neutral":
|
| 1836 |
wav = apply_phrase_dynamics(wav, vq.XTTS_SR, emo, variation * 0.5)
|
|
@@ -1841,8 +1977,15 @@ def _run_tts_quality(model, text, refs, lang, base_params, out_path, job_id,
|
|
| 1841 |
|
| 1842 |
audio = vq.finalize_audio(vq.assemble(takes, pauses, vq.XTTS_SR, 1.0, seed0), vq.XTTS_SR)
|
| 1843 |
sf.write(out_path, audio, vq.XTTS_SR, subtype="PCM_16")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1844 |
job_progress[job_id]["status"] = "done"
|
| 1845 |
job_progress[job_id]["ts"] = time.time()
|
|
|
|
|
|
|
| 1846 |
return True
|
| 1847 |
|
| 1848 |
def apply_depth(path, amount=0.5, space="studio"):
|
|
@@ -1859,18 +2002,74 @@ def apply_depth(path, amount=0.5, space="studio"):
|
|
| 1859 |
print("[!] depth processing failed — file left dry:", flush=True)
|
| 1860 |
traceback.print_exc()
|
| 1861 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1862 |
def collect_refs(files, voice_name):
|
| 1863 |
paths = []
|
| 1864 |
-
|
| 1865 |
-
|
| 1866 |
-
|
| 1867 |
-
|
| 1868 |
-
|
| 1869 |
-
|
| 1870 |
-
|
| 1871 |
-
|
| 1872 |
-
|
| 1873 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1874 |
return paths
|
| 1875 |
|
| 1876 |
def cleanup(paths):
|
|
@@ -1883,6 +2082,19 @@ def cleanup(paths):
|
|
| 1883 |
app = FastAPI(title="XTTS Voice Studio CPU Optimized")
|
| 1884 |
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
| 1885 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1886 |
LANGUAGES = {
|
| 1887 |
"ar":"العربية","en":"English","es":"Español","fr":"Français",
|
| 1888 |
"de":"Deutsch","it":"Italiano","pt":"Português","ru":"Русский",
|
|
@@ -3141,6 +3353,7 @@ function GenerateTab({ voices, onVoicesRefresh, sharedSettings }) {
|
|
| 3141 |
if (!data) return; // cancelled
|
| 3142 |
setResult({url:`/audio/${data.filename}`,filename:data.filename});
|
| 3143 |
toast("success","✅ تم توليد الصوت بنجاح");
|
|
|
|
| 3144 |
const rr=data.ref_report;
|
| 3145 |
if(rr&&(rr.score<70||(rr.warnings||[]).length))
|
| 3146 |
toast("info",`جودة العينة ${rr.score}/100 — ${(rr.warnings||[])[0]||"جرّب عينة أنقى"}`,8000);
|
|
@@ -3191,7 +3404,7 @@ function GenerateTab({ voices, onVoicesRefresh, sharedSettings }) {
|
|
| 3191 |
</button>
|
| 3192 |
{refCheck&&refCheck.report&&(
|
| 3193 |
<div className="col" style={{gap:6}}>
|
| 3194 |
-
<div className="fz-12"><b>جودة العينة: {refCheck.report.score}/100</b> · المدة بعد التنقية {refCheck.report.duration}ث · SNR {refCheck.report.snr_before}→{refCheck.report.snr_after} dB{refCheck.report.denoised?" · تم تقليل الضجيج":""}</div>
|
| 3195 |
{(refCheck.report.warnings||[]).map((w,i)=><div key={"w"+i} className="fz-12" style={{color:"#f59e0b"}}>⚠ {w}</div>)}
|
| 3196 |
{(refCheck.report.tips||[]).map((w,i)=><div key={"t"+i} className="fz-12 text-muted">💡 {w}</div>)}
|
| 3197 |
<audio controls src={refCheck.url} style={{width:"100%"}}/>
|
|
@@ -3820,8 +4033,41 @@ def model_status():
|
|
| 3820 |
"ready": xtts_ready,
|
| 3821 |
"error": xtts_error,
|
| 3822 |
"device": device,
|
|
|
|
|
|
|
| 3823 |
})
|
| 3824 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3825 |
# ── Job control endpoints ─────────────────────────────────────────────
|
| 3826 |
@app.get("/progress/{job_id}")
|
| 3827 |
def get_progress(job_id: str):
|
|
@@ -3851,7 +4097,7 @@ async def edit_audio(
|
|
| 3851 |
):
|
| 3852 |
"""Edit a generated audio file without re-running TTS.
|
| 3853 |
Always writes a NEW file — the original is never modified."""
|
| 3854 |
-
src =
|
| 3855 |
if not os.path.exists(src):
|
| 3856 |
raise HTTPException(404, "الملف غير موجود.")
|
| 3857 |
|
|
@@ -3935,7 +4181,7 @@ async def humanize_endpoint(
|
|
| 3935 |
):
|
| 3936 |
"""Apply acoustic environment simulation to a generated WAV.
|
| 3937 |
Returns a brand-new file — original untouched."""
|
| 3938 |
-
src =
|
| 3939 |
if not os.path.exists(src):
|
| 3940 |
raise HTTPException(404, "الملف غير موجود.")
|
| 3941 |
if preset not in HUMANIZE_PRESETS:
|
|
@@ -3981,6 +4227,7 @@ async def generate(
|
|
| 3981 |
if not xtts_ready: raise HTTPException(503, f"❌ فشل تحميل النموذج: {xtts_error}")
|
| 3982 |
if not text.strip(): raise HTTPException(400, "النص فارغ.")
|
| 3983 |
if len(text) > MAX_CHARS: raise HTTPException(400, f"النص يتجاوز {MAX_CHARS} حرف.")
|
|
|
|
| 3984 |
if not job_id:
|
| 3985 |
job_id = uuid.uuid4().hex
|
| 3986 |
|
|
@@ -4000,13 +4247,13 @@ async def generate(
|
|
| 4000 |
cancel_events[job_id] = threading.Event()
|
| 4001 |
silence_ms = {"story":160,"sad":180,"news":80,"excited":70}.get(expression_mode, 110)
|
| 4002 |
variation = float(np.clip(variation_strength, 0.0, 2.0))
|
| 4003 |
-
ref_summary = {}
|
| 4004 |
try:
|
| 4005 |
async with get_tts_lock():
|
| 4006 |
success = await asyncio.to_thread(
|
| 4007 |
run_tts_chunked, enhanced_text, refs, language,
|
| 4008 |
tts_params, out, job_id, silence_ms, variation,
|
| 4009 |
-
attempts, enhance_ref, clean_text, ref_summary
|
| 4010 |
)
|
| 4011 |
if not success:
|
| 4012 |
if os.path.exists(out): os.remove(out)
|
|
@@ -4019,7 +4266,8 @@ async def generate(
|
|
| 4019 |
cancel_events.pop(job_id, None)
|
| 4020 |
|
| 4021 |
append_history(os.path.basename(out), text, language, "tts", len(text))
|
| 4022 |
-
return {"filename": os.path.basename(out), "ref_report": ref_summary or None
|
|
|
|
| 4023 |
|
| 4024 |
|
| 4025 |
# ── Reference check: clean the sample and let the user LISTEN to it ───
|
|
@@ -4031,10 +4279,11 @@ async def enhance_ref_endpoint(
|
|
| 4031 |
refs = collect_refs(files, voice_name)
|
| 4032 |
if not refs: raise HTTPException(400, "حدد عينة صوتية أولاً.")
|
| 4033 |
try:
|
| 4034 |
-
|
| 4035 |
-
|
| 4036 |
-
|
| 4037 |
-
|
|
|
|
| 4038 |
except ValueError as e:
|
| 4039 |
raise HTTPException(422, str(e))
|
| 4040 |
finally:
|
|
@@ -4064,7 +4313,7 @@ async def sts(
|
|
| 4064 |
if not WHISPER_OK: raise HTTPException(503, "Faster-Whisper غير جاهز بعد (لا يزال يُحمَّل أو فشل تحميله)، أعد المحاولة بعد قليل.")
|
| 4065 |
if not job_id: job_id = uuid.uuid4().hex
|
| 4066 |
src = f"/tmp/sts_{uuid.uuid4().hex}.wav"
|
| 4067 |
-
|
| 4068 |
refs = []
|
| 4069 |
cancel_events[job_id] = threading.Event()
|
| 4070 |
try:
|
|
@@ -4122,7 +4371,7 @@ async def dub(
|
|
| 4122 |
if not TRANSLATOR_OK: raise HTTPException(503, "deep-translator غير مثبّت.")
|
| 4123 |
if not job_id: job_id = uuid.uuid4().hex
|
| 4124 |
src = f"/tmp/dub_{uuid.uuid4().hex}.wav"
|
| 4125 |
-
|
| 4126 |
refs = []
|
| 4127 |
cancel_events[job_id] = threading.Event()
|
| 4128 |
try:
|
|
@@ -4232,30 +4481,37 @@ async def save_voice(background_tasks: BackgroundTasks,
|
|
| 4232 |
name: str = Form(...),
|
| 4233 |
file: UploadFile = File(...),
|
| 4234 |
file2: UploadFile = File(default=None)):
|
| 4235 |
-
safe =
|
| 4236 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4237 |
os.makedirs(lib, exist_ok=True)
|
| 4238 |
-
for f in (
|
| 4239 |
-
|
| 4240 |
-
with open(os.path.join(lib, safe_fn), "wb") as buf:
|
| 4241 |
-
shutil.copyfileobj(f.file, buf)
|
| 4242 |
background_tasks.add_task(trigger_cloud_backup)
|
| 4243 |
-
return {"name":
|
| 4244 |
|
| 4245 |
@app.delete("/voices/{name}")
|
| 4246 |
def delete_voice(background_tasks: BackgroundTasks, name: str):
|
| 4247 |
-
lib =
|
| 4248 |
-
|
| 4249 |
background_tasks.add_task(trigger_cloud_backup)
|
| 4250 |
return {"deleted": name}
|
| 4251 |
|
| 4252 |
@app.get("/voices/{name}/preview")
|
| 4253 |
def preview_voice(name: str):
|
| 4254 |
-
lib =
|
| 4255 |
-
|
| 4256 |
-
|
| 4257 |
-
|
| 4258 |
-
|
| 4259 |
return FileResponse(os.path.join(lib, fn), media_type=mt)
|
| 4260 |
raise HTTPException(404, "لا توجد ملفات صوتية.")
|
| 4261 |
|
|
|
|
| 184 |
|
| 185 |
REF_SR = 22050 # XTTS conditioning sample-rate
|
| 186 |
XTTS_SR = 24000 # XTTS output sample-rate
|
| 187 |
+
VQ_VERSION = "2"
|
| 188 |
|
| 189 |
# ── generic helpers ───────────────────────────────────────────────────
|
| 190 |
def _db(x): return 20.0 * np.log10(np.maximum(x, 1e-9))
|
|
|
|
| 275 |
d = np.diff(np.concatenate([[0], mask.astype(np.int8), [0]]))
|
| 276 |
return list(zip(np.where(d == 1)[0], np.where(d == -1)[0]))
|
| 277 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
def _analyze(x_native_norm, fs_native, sr=REF_SR):
|
| 279 |
"""Diagnostics on a (peak-normalised) signal."""
|
| 280 |
db, w, h = frame_db(x_native_norm, sr)
|
|
|
|
| 285 |
return dict(noise_floor_db=round(noise, 1), snr_db=round(peak - noise, 1),
|
| 286 |
bandwidth_hz=int(min(rolloff, fs_native / 2)), native_sr=int(fs_native))
|
| 287 |
|
| 288 |
+
def _stretches(mask, keep_gap=90):
|
| 289 |
+
"""Group voiced segments separated by pauses ≤ 0.9 s into 'stretches', so natural
|
| 290 |
+
phrasing (sentence-to-sentence pauses) stays exactly as recorded."""
|
| 291 |
+
out = []
|
| 292 |
+
for s, e in _segments(mask):
|
| 293 |
+
s, e = int(s), int(e)
|
| 294 |
+
if out and s - out[-1][1] <= keep_gap: out[-1][1] = e
|
| 295 |
+
else: out.append([s, e])
|
| 296 |
+
return out
|
| 297 |
+
|
| 298 |
+
def _split_stretch(db, s, e, max_frames):
|
| 299 |
+
"""Cut an over-long stretch at its deepest-energy frames (= inside pauses).
|
| 300 |
+
Nothing is ever joined afterwards, so a cut can never become an audible splice."""
|
| 301 |
+
smooth = uniform_filter(db, size=7, mode="nearest")
|
| 302 |
+
cuts, pos = [], s
|
| 303 |
+
while e - pos > max_frames:
|
| 304 |
+
lo, hi = pos + int(0.55 * max_frames), pos + max_frames
|
| 305 |
+
c = lo + int(np.argmin(smooth[lo:hi]))
|
| 306 |
+
cuts.append((pos, c)); pos = c
|
| 307 |
+
cuts.append((pos, e))
|
| 308 |
+
return cuts
|
| 309 |
+
|
| 310 |
+
def _score_window(x, db, w, h, a, b, mask):
|
| 311 |
+
seg, v = db[a:b], mask[a:b]
|
| 312 |
+
snr = float(seg[v].mean() - np.percentile(seg, 10)) if v.any() else 0.0
|
| 313 |
+
sd = float(seg[v].std()) if v.sum() > 3 else 12.0
|
| 314 |
+
clip = float(np.mean(np.abs(x[a * h: b * h + w]) > 0.98))
|
| 315 |
+
sp = float(v.sum() * 0.01)
|
| 316 |
+
return snr - 0.5 * sd - 300.0 * clip + 0.5 * min(sp, 20.0), sp
|
| 317 |
+
|
| 318 |
+
def _finish_segment(x, sr):
|
| 319 |
+
"""Level-match one CONTIGUOUS segment (voiced RMS → −20 dBFS) and fade its ends
|
| 320 |
+
inside the surrounding silence."""
|
| 321 |
+
z = x.astype(np.float32).copy()
|
| 322 |
+
db, w, h = frame_db(z, sr)
|
| 323 |
+
m = _voiced_mask(db)
|
| 324 |
+
vs = [z[s * h: e * h + w] for s, e in _segments(m)]
|
| 325 |
+
lvl = float(np.sqrt(np.mean(np.square(np.concatenate(vs)))) + 1e-9) if vs else float(np.sqrt(np.mean(z ** 2)) + 1e-9)
|
| 326 |
+
z = soft_limit(z * (10 ** (-20 / 20) / lvl), 0.95, 0.85)
|
| 327 |
+
return _fade(z, sr, 15, 45)
|
| 328 |
+
|
| 329 |
+
def enhance_reference(path, strength=1.0, denoise=True, max_seg_sec=28.0, min_speech_sec=1.5):
|
| 330 |
+
"""Clean one recording and return CONTIGUOUS candidate segments (no cutting-and-
|
| 331 |
+
pasting): → (candidates [{wav, speech, score}], report)."""
|
| 332 |
raw, fs = load_mono(path)
|
| 333 |
if len(raw) < 0.5 * REF_SR:
|
| 334 |
raise ValueError("العينة قصيرة جداً (أقل من نصف ثانية).")
|
| 335 |
dur_in = len(raw) / REF_SR
|
| 336 |
clip_pct = float(np.mean(np.abs(raw) >= 0.995) * 100)
|
| 337 |
raw = raw - float(np.mean(raw))
|
| 338 |
+
x = raw / (float(np.max(np.abs(raw))) + 1e-9) * 0.7
|
|
|
|
| 339 |
before = _analyze(x, fs)
|
| 340 |
|
| 341 |
x = _highpass(x, REF_SR, 70)
|
|
|
|
| 343 |
if denoise and before["noise_floor_db"] > -62:
|
| 344 |
s = float(np.clip((before["noise_floor_db"] + 62) / 20.0, 0.35, 1.0)) * strength
|
| 345 |
x = spectral_denoise(x, REF_SR, s); did_denoise = True
|
| 346 |
+
after = _analyze(x, fs)
|
| 347 |
|
| 348 |
db, w, h = frame_db(x, REF_SR)
|
| 349 |
mask = _voiced_mask(db)
|
| 350 |
speech_sec_all = float(mask.sum() * 0.01)
|
| 351 |
+
cands = []
|
| 352 |
+
lead, tail = int(0.15 * REF_SR), int(0.30 * REF_SR)
|
| 353 |
+
for s, e in _stretches(mask):
|
| 354 |
+
for a, b in _split_stretch(db, s, e, int(max_seg_sec * 100)):
|
| 355 |
+
sc, sp = _score_window(x, db, w, h, a, b, mask)
|
| 356 |
+
if sp < min_speech_sec: continue
|
| 357 |
+
i0 = max(0, a * h - (lead if a == s else 0))
|
| 358 |
+
i1 = min(len(x), b * h + w + (tail if b == e else 0))
|
| 359 |
+
cands.append(dict(wav=_finish_segment(x[i0:i1], REF_SR), speech=sp, score=sc))
|
| 360 |
+
if not cands: # no clear phrasing: keep one contiguous voiced span
|
| 361 |
+
idx = np.where(mask)[0]
|
| 362 |
+
i0, i1 = (max(0, idx[0] * h - lead), min(len(x), idx[-1] * h + w + tail)) if len(idx) else (0, len(x))
|
| 363 |
+
i1 = min(i1, i0 + int(max_seg_sec * REF_SR))
|
| 364 |
+
cands.append(dict(wav=_finish_segment(x[i0:i1], REF_SR), speech=speech_sec_all, score=0.0))
|
| 365 |
|
| 366 |
warns, tips = [], []
|
| 367 |
if speech_sec_all < 6:
|
|
|
|
| 374 |
warns.append(f"جودة ترددية منخفضة (حتى {before['bandwidth_hz']} Hz) — غالباً هاتف/ضغط شديد."); tips.append("استخدم WAV/FLAC أو تسجيلاً بجودة أعلى.")
|
| 375 |
if before["native_sr"] < 16000:
|
| 376 |
warns.append(f"معدل العينة الأصلي {before['native_sr']} Hz منخفض.")
|
| 377 |
+
score = 100.0 - max(0, 20 - before["snr_db"]) * 1.5 - min(30, clip_pct * 10) \
|
| 378 |
+
- max(0, 6 - speech_sec_all) * 5 - (15 if before["bandwidth_hz"] < 6000 else 0)
|
| 379 |
+
report = dict(duration_in=round(dur_in, 1), speech_sec=round(speech_sec_all, 1), clipping_pct=round(clip_pct, 2),
|
| 380 |
+
denoised=did_denoise, before=before, after=after, score=int(np.clip(score, 0, 100)),
|
| 381 |
+
warnings=warns, tips=tips, candidates=len(cands))
|
| 382 |
+
return cands, report
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 383 |
|
| 384 |
def _file_sha(path):
|
| 385 |
h = hashlib.sha1()
|
|
|
|
| 388 |
return h.hexdigest()
|
| 389 |
|
| 390 |
def enhance_reference_set(paths, cache_dir, strength=1.0, target_sec=24.0,
|
| 391 |
+
max_segments=4, denoise=True):
|
| 392 |
+
"""Clean the recordings and pick the best CONTIGUOUS segments (≤ max_segments, ≈target_sec
|
| 393 |
+
of speech in total). Segments are written as SEPARATE files and never concatenated —
|
| 394 |
+
each one's conditioning latents are computed on their own and averaged by the caller.
|
| 395 |
+
→ ([segment_wav_paths], [per-file reports], summary)."""
|
| 396 |
os.makedirs(cache_dir, exist_ok=True)
|
| 397 |
key = hashlib.sha1(("|".join(_file_sha(p) for p in paths) +
|
| 398 |
+
f"|{strength}|{target_sec}|{max_segments}|{denoise}|{VQ_VERSION}").encode()).hexdigest()[:20]
|
| 399 |
+
meta = os.path.join(cache_dir, f"ref_{key}.json")
|
| 400 |
+
if os.path.exists(meta):
|
| 401 |
d = json.load(open(meta, encoding="utf-8"))
|
| 402 |
+
if d["paths"] and all(os.path.exists(p) for p in d["paths"]):
|
| 403 |
+
return d["paths"], d["reports"], d["summary"]
|
| 404 |
|
| 405 |
+
pool, reports = [], []
|
| 406 |
+
for fi, p in enumerate(paths):
|
|
|
|
| 407 |
try:
|
| 408 |
+
cands, rep = enhance_reference(p, strength, denoise)
|
| 409 |
rep["file"] = os.path.basename(p)[-40:]
|
| 410 |
+
reports.append(rep)
|
| 411 |
+
for c in cands: c["fscore"] = rep["score"]; pool.append(c)
|
| 412 |
except Exception as e:
|
| 413 |
+
reports.append(dict(file=os.path.basename(p)[-40:], error=str(e), score=0,
|
| 414 |
+
warnings=[f"تعذّرت معالجة الملف: {e}"], tips=[]))
|
| 415 |
+
if not pool:
|
| 416 |
+
raise ValueError("تعذّرت معالجة كل ملفات العينة: " + "; ".join(r.get("error", "?") for r in reports))
|
| 417 |
+
chosen, total = [], 0.0
|
| 418 |
+
for c in sorted(pool, key=lambda c: c["score"] + 0.1 * c["fscore"], reverse=True):
|
| 419 |
+
if len(chosen) >= max_segments or total >= target_sec: break
|
| 420 |
+
chosen.append(c); total += c["speech"]
|
| 421 |
+
|
| 422 |
+
out_paths = []
|
| 423 |
+
for i, c in enumerate(chosen):
|
| 424 |
+
op = os.path.join(cache_dir, f"ref_{key}_{i}.wav")
|
| 425 |
+
sf.write(op, c["wav"], REF_SR, subtype="PCM_16"); out_paths.append(op)
|
| 426 |
+
good = [r for r in reports if "error" not in r]
|
| 427 |
+
best = max(good, key=lambda r: r["score"])
|
| 428 |
+
summary = dict(score=int(np.mean([r["score"] for r in good])),
|
| 429 |
+
duration=round(sum(len(c["wav"]) for c in chosen) / REF_SR, 1),
|
| 430 |
+
speech=round(total, 1), segments=len(chosen), files=len(paths), used=len(good),
|
| 431 |
+
warnings=[w for r in good for w in r["warnings"]][:6],
|
| 432 |
+
tips=list(dict.fromkeys(t for r in good for t in r["tips"]))[:4],
|
| 433 |
snr_before=best["before"]["snr_db"], snr_after=best["after"]["snr_db"],
|
| 434 |
+
denoised=any(r["denoised"] for r in good))
|
| 435 |
+
json.dump(dict(paths=out_paths, reports=reports, summary=summary), open(meta, "w", encoding="utf-8"), ensure_ascii=False)
|
| 436 |
+
return out_paths, reports, summary
|
| 437 |
|
| 438 |
# ═══════════════════════════════════════════════════════════════════════
|
| 439 |
# 2. TEXT NORMALISATION
|
|
|
|
| 540 |
if not a or not b: return 0.0
|
| 541 |
return difflib.SequenceMatcher(None, a, b, autojunk=False).ratio()
|
| 542 |
|
| 543 |
+
CPS_RANGE = {"zh": (1.5, 14.0), "ja": (2.0, 16.0), "ko": (3.0, 20.0)} # chars/sec sanity bounds
|
| 544 |
+
|
| 545 |
+
def take_info(wav, sr, text, asr_fn=None, lang="ar"):
|
| 546 |
dur = len(wav) / sr
|
| 547 |
peak = float(np.max(np.abs(wav))) if len(wav) else 0.0
|
| 548 |
cps = len(norm_for_compare(text)) / max(dur, 1e-3)
|
| 549 |
+
lo, hi = CPS_RANGE.get(lang.split("-")[0], (4.0, 28.0))
|
| 550 |
sim = None
|
| 551 |
if asr_fn is not None and dur > 0.2:
|
| 552 |
try: sim = text_similarity(text, asr_fn(wav, sr))
|
| 553 |
except Exception: sim = None
|
| 554 |
+
ok = peak > 0.02 and lo <= cps <= hi
|
| 555 |
+
return dict(dur=dur, cps=cps, peak=peak, sim=sim, ok=ok, secs=None,
|
| 556 |
clip=float(np.mean(np.abs(wav) > 0.99)) if len(wav) else 0.0)
|
| 557 |
|
| 558 |
+
def _take_score(i, med):
|
| 559 |
+
s = (i["sim"] * 100.0) if i["sim"] is not None else 60.0
|
| 560 |
+
if i.get("secs") is not None:
|
| 561 |
+
s += 80.0 * (i["secs"] - 0.6) # speaker-identity consistency
|
| 562 |
+
s -= 25.0 * abs(math.log(max(i["dur"], 1e-3) / max(med, 1e-3)))
|
| 563 |
+
s -= 300.0 * i["clip"]
|
| 564 |
+
s -= 0.0 if i["ok"] else 30.0
|
| 565 |
+
return s
|
| 566 |
+
|
| 567 |
+
def synth_best(gen_fn, text, attempts=1, asr_fn=None, sr=XTTS_SR, seed=1234,
|
| 568 |
+
accept_sim=0.90, secs_fn=None, lang="ar", guard_retries=2):
|
| 569 |
+
"""gen_fn(seed:int) → float32 wav. Returns (wav, info, n_generated).
|
| 570 |
+
attempts==1 : cheap self-healing guard — regenerate (≤ guard_retries) only if the
|
| 571 |
+
take is obviously broken (silent / absurd speech-rate / clipped).
|
| 572 |
+
attempts>1 : best-of-N ranked by ASR similarity + speaker similarity + duration."""
|
| 573 |
+
multi = attempts > 1
|
| 574 |
+
takes, k = [], 0
|
| 575 |
+
while True:
|
| 576 |
wav = trim_take(gen_fn(seed + 7919 * k), sr)
|
| 577 |
+
info = take_info(wav, sr, text, asr_fn if multi else None, lang)
|
| 578 |
+
if multi and secs_fn is not None and len(wav) > int(0.3 * sr):
|
| 579 |
+
try: info["secs"] = float(secs_fn(wav, sr))
|
| 580 |
+
except Exception: info["secs"] = None
|
| 581 |
+
takes.append((wav, info)); k += 1
|
| 582 |
+
if multi:
|
| 583 |
+
if k >= attempts: break
|
| 584 |
+
if info["ok"] and info["sim"] is not None and info["sim"] >= accept_sim: break
|
| 585 |
+
else:
|
| 586 |
+
if (info["ok"] and info["clip"] < 0.005) or k > guard_retries: break
|
| 587 |
if len(takes) == 1: return takes[0][0], takes[0][1], 1
|
| 588 |
med = float(np.median([i["dur"] for _, i in takes]))
|
| 589 |
+
best = max(takes, key=lambda t: _take_score(t[1], med))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 590 |
return best[0], best[1], len(takes)
|
| 591 |
|
| 592 |
# ═══════════════════════════════════════════════════════════════════════
|
|
|
|
| 788 |
except ImportError:
|
| 789 |
HUB_AVAILABLE = False
|
| 790 |
|
| 791 |
+
_backup_lock = threading.Lock()
|
| 792 |
+
_backup_state = {"running": False, "pending": False, "last": 0.0}
|
| 793 |
+
BACKUP_MIN_INTERVAL = 90 # seconds between uploads
|
| 794 |
+
|
| 795 |
def trigger_cloud_backup():
|
| 796 |
+
"""Single-flight + coalesced + rate-limited. (Previously every save/delete spawned
|
| 797 |
+
its own thread uploading the whole data folder, so uploads could pile up.)"""
|
| 798 |
if not (HUB_AVAILABLE and HF_TOKEN and DATASET_REPO_ID): return
|
| 799 |
+
with _backup_lock:
|
| 800 |
+
if _backup_state["running"]:
|
| 801 |
+
_backup_state["pending"] = True
|
| 802 |
+
return
|
| 803 |
+
_backup_state["running"] = True
|
| 804 |
def _run():
|
| 805 |
+
while True:
|
| 806 |
+
wait = BACKUP_MIN_INTERVAL - (time.time() - _backup_state["last"])
|
| 807 |
+
if wait > 0: time.sleep(wait)
|
| 808 |
+
try:
|
| 809 |
+
HfApi(token=HF_TOKEN).upload_folder(
|
| 810 |
+
folder_path=PERSISTENT_DIR, repo_id=DATASET_REPO_ID, repo_type="dataset",
|
| 811 |
+
commit_message=f"Auto-backup: {int(time.time())}",
|
| 812 |
+
ignore_patterns=["*.tmp", "*.corrupt", ".cache/*"])
|
| 813 |
+
except Exception as e:
|
| 814 |
+
print(f"[!] Cloud backup failed: {e}", flush=True)
|
| 815 |
+
with _backup_lock:
|
| 816 |
+
_backup_state["last"] = time.time()
|
| 817 |
+
if _backup_state["pending"]:
|
| 818 |
+
_backup_state["pending"] = False
|
| 819 |
+
continue
|
| 820 |
+
_backup_state["running"] = False
|
| 821 |
+
return
|
| 822 |
+
threading.Thread(target=_run, daemon=True, name="cloud-backup").start()
|
| 823 |
|
| 824 |
# ── XTTS Model ────────────────��───────────────────────────────────────
|
| 825 |
from TTS.api import TTS
|
|
|
|
| 915 |
TRANSLATOR_OK = False
|
| 916 |
|
| 917 |
# ── History helpers ───────────────────────────────────────────────────
|
| 918 |
+
_history_lock = threading.RLock()
|
| 919 |
+
MAX_HISTORY = 300
|
| 920 |
+
MAX_OUTPUTS = 200
|
| 921 |
+
|
| 922 |
def load_history():
|
| 923 |
+
with _history_lock:
|
| 924 |
+
try:
|
| 925 |
+
if not os.path.exists(HISTORY_FILE): return []
|
| 926 |
+
with open(HISTORY_FILE, encoding="utf-8") as f:
|
| 927 |
+
return json.load(f)
|
| 928 |
+
except Exception:
|
| 929 |
+
try: shutil.copyfile(HISTORY_FILE, HISTORY_FILE + ".corrupt") # keep it, don't silently overwrite
|
| 930 |
+
except Exception: pass
|
| 931 |
+
return []
|
| 932 |
|
| 933 |
def save_history(h):
|
| 934 |
+
with _history_lock: # atomic: write temp → replace
|
| 935 |
+
tmp = HISTORY_FILE + ".tmp"
|
| 936 |
+
with open(tmp, "w", encoding="utf-8") as f:
|
| 937 |
+
json.dump(h, f, ensure_ascii=False, indent=2)
|
| 938 |
+
os.replace(tmp, HISTORY_FILE)
|
| 939 |
trigger_cloud_backup()
|
| 940 |
|
|
|
|
|
|
|
|
|
|
| 941 |
def _prune_outputs():
|
| 942 |
try:
|
| 943 |
wavs = sorted(
|
|
|
|
| 946 |
)
|
| 947 |
for old in wavs[:-MAX_OUTPUTS]:
|
| 948 |
try: os.remove(os.path.join(OUTPUT_DIR, old))
|
| 949 |
+
except Exception: pass
|
| 950 |
except Exception:
|
| 951 |
pass
|
| 952 |
|
| 953 |
def append_history(filename, text, language, mode, char_count=0):
|
| 954 |
+
with _history_lock: # read-modify-write must be one unit
|
| 955 |
+
h = load_history()
|
| 956 |
+
h.append(dict(filename=filename, text=text[:150], language=language,
|
| 957 |
+
mode=mode, char_count=char_count, ts=int(time.time())))
|
| 958 |
+
if len(h) > MAX_HISTORY:
|
| 959 |
+
h = h[-MAX_HISTORY:]
|
| 960 |
+
save_history(h)
|
| 961 |
_prune_outputs()
|
| 962 |
|
| 963 |
# ── Parameter Engineering ─────────────────────────────────────────────
|
|
|
|
| 1751 |
if not enhance:
|
| 1752 |
return list(refs), None
|
| 1753 |
try:
|
| 1754 |
+
paths, _reports, summary = vq.enhance_reference_set(refs, REF_CACHE_DIR)
|
| 1755 |
+
return list(paths), summary
|
| 1756 |
except Exception as e:
|
| 1757 |
print(f"[!] Reference enhancement failed — using the raw sample: {e}", flush=True)
|
| 1758 |
return list(refs), None
|
| 1759 |
|
| 1760 |
def get_latents(model, ref_paths):
|
| 1761 |
+
"""Conditioning latents for one or several reference files.
|
| 1762 |
+
With several files each one is encoded ON ITS OWN and the results are averaged
|
| 1763 |
+
(duration-weighted). XTTS' own multi-file path glues the waveforms together with
|
| 1764 |
+
torch.cat before encoding — an audible splice that the model then imitates."""
|
| 1765 |
key = tuple((p, os.path.getsize(p), int(os.path.getmtime(p))) for p in ref_paths)
|
| 1766 |
if key in _LAT_CACHE:
|
| 1767 |
_LAT_CACHE.move_to_end(key)
|
| 1768 |
return _LAT_CACHE[key]
|
| 1769 |
c = model.config
|
| 1770 |
+
kw = dict(gpt_cond_len=int(getattr(c, "gpt_cond_len", 30)),
|
| 1771 |
+
gpt_cond_chunk_len=int(getattr(c, "gpt_cond_chunk_len", 4)),
|
| 1772 |
+
max_ref_length=int(getattr(c, "max_ref_len", 30)),
|
| 1773 |
+
sound_norm_refs=bool(getattr(c, "sound_norm_refs", False)))
|
| 1774 |
+
paths = list(ref_paths)
|
| 1775 |
+
if len(paths) == 1:
|
| 1776 |
+
lat = model.get_conditioning_latents(audio_path=paths, **kw)
|
| 1777 |
+
else:
|
| 1778 |
+
parts, weights = [], []
|
| 1779 |
+
for p in paths:
|
| 1780 |
+
parts.append(model.get_conditioning_latents(audio_path=[p], **kw))
|
| 1781 |
+
try: dur = float(sf.info(p).duration)
|
| 1782 |
+
except Exception: dur = float(kw["gpt_cond_len"])
|
| 1783 |
+
weights.append(max(0.5, min(dur, float(kw["gpt_cond_len"]))))
|
| 1784 |
+
wsum = sum(weights)
|
| 1785 |
+
lat = (sum(w * g for w, (g, _) in zip(weights, parts)) / wsum,
|
| 1786 |
+
sum(w * s for w, (_, s) in zip(weights, parts)) / wsum)
|
| 1787 |
_LAT_CACHE[key] = lat
|
| 1788 |
while len(_LAT_CACHE) > 6:
|
| 1789 |
_LAT_CACHE.popitem(last=False)
|
|
|
|
| 1838 |
return " ".join(s.text for s in segs)
|
| 1839 |
return fn
|
| 1840 |
|
| 1841 |
+
DRY_CACHE_DIR = os.path.join(tempfile.gettempdir(), "vs_drycache")
|
| 1842 |
+
os.makedirs(DRY_CACHE_DIR, exist_ok=True)
|
| 1843 |
+
os.makedirs(REF_CACHE_DIR, exist_ok=True)
|
| 1844 |
+
RECENT_JOBS = collections.deque(maxlen=20)
|
| 1845 |
+
|
| 1846 |
+
def _prune_dir(path, max_files, max_bytes):
|
| 1847 |
+
try:
|
| 1848 |
+
fs = [os.path.join(path, f) for f in os.listdir(path)]
|
| 1849 |
+
fs = sorted((f for f in fs if os.path.isfile(f)), key=os.path.getmtime)
|
| 1850 |
+
total = sum(os.path.getsize(f) for f in fs)
|
| 1851 |
+
while fs and (len(fs) > max_files or total > max_bytes):
|
| 1852 |
+
f = fs.pop(0); total -= os.path.getsize(f); os.remove(f)
|
| 1853 |
+
except Exception:
|
| 1854 |
+
pass
|
| 1855 |
+
|
| 1856 |
+
_prune_dir(REF_CACHE_DIR, 40, 300 << 20)
|
| 1857 |
+
_prune_dir(DRY_CACHE_DIR, 60, 400 << 20)
|
| 1858 |
+
|
| 1859 |
+
def _dry_cache_key(txt, lang, ref_paths, base_params, silence_ms, variation, attempts, asr_on):
|
| 1860 |
+
"""Everything that changes the synthesised (dry) audio. Depth / boost are post-FX
|
| 1861 |
+
and deliberately NOT part of the key → changing them re-renders instantly."""
|
| 1862 |
+
payload = dict(v=vq.VQ_VERSION + "/dry2", t=txt, l=lang, r=[vq._file_sha(p) for p in ref_paths],
|
| 1863 |
+
p={k: round(float(v), 4) for k, v in base_params.items() if isinstance(v, (int, float))},
|
| 1864 |
+
s=int(silence_ms), var=round(float(variation), 3), a=int(attempts), asr=bool(asr_on))
|
| 1865 |
+
return hashlib.sha1(json.dumps(payload, sort_keys=True, ensure_ascii=False).encode()).hexdigest()[:24]
|
| 1866 |
+
|
| 1867 |
+
def _record_job(stats_out, job_id, **kw):
|
| 1868 |
+
rec = dict(job=str(job_id)[:8], ts=int(time.time()), **kw)
|
| 1869 |
+
if rec.get("audio_sec") and rec.get("wall") is not None:
|
| 1870 |
+
rec["rtf"] = round(rec["wall"] / rec["audio_sec"], 2)
|
| 1871 |
+
for k in ("wall", "audio_sec"):
|
| 1872 |
+
if k in rec: rec[k] = round(float(rec[k]), 1)
|
| 1873 |
+
RECENT_JOBS.append(rec)
|
| 1874 |
+
if stats_out is not None: stats_out.update(rec)
|
| 1875 |
+
print(f"[job {rec['job']}] " + ", ".join(f"{k}={v}" for k, v in rec.items() if k not in ("job", "ts")), flush=True)
|
| 1876 |
+
|
| 1877 |
+
def _secs_fn(model, ref_spk):
|
| 1878 |
+
"""Cosine similarity between a take and the reference, using XTTS's own speaker encoder."""
|
| 1879 |
+
ref = ref_spk.detach().flatten().float().cpu()
|
| 1880 |
+
warned = []
|
| 1881 |
+
def fn(wav, sr):
|
| 1882 |
+
try:
|
| 1883 |
+
with torch.inference_mode():
|
| 1884 |
+
e = model.get_speaker_embedding(torch.from_numpy(np.ascontiguousarray(wav, dtype=np.float32))[None, :], sr)
|
| 1885 |
+
return float(torch.nn.functional.cosine_similarity(e.detach().flatten().float().cpu(), ref, dim=0))
|
| 1886 |
+
except Exception as ex:
|
| 1887 |
+
if not warned: # log once, never spam
|
| 1888 |
+
warned.append(1)
|
| 1889 |
+
print(f"[!] speaker-similarity scoring unavailable ({type(ex).__name__}: {ex}) — ranking without it.", flush=True)
|
| 1890 |
+
raise
|
| 1891 |
+
return fn
|
| 1892 |
+
|
| 1893 |
def run_tts_chunked(text, refs, lang, base_params, out_path, job_id,
|
| 1894 |
silence_ms: int = 110, variation: float = 0.0,
|
| 1895 |
attempts: int = 1, enhance_ref: bool = True,
|
| 1896 |
+
clean_text: bool = True, ref_summary_out: dict = None,
|
| 1897 |
+
stats_out: dict = None) -> bool:
|
| 1898 |
"""Quality pipeline. Falls back to the legacy pipeline if anything unexpected
|
| 1899 |
goes wrong, so a bug here can never block generation."""
|
| 1900 |
model = _tts_model()
|
|
|
|
| 1902 |
return _run_tts_chunked_legacy(text, refs, lang, base_params, out_path, job_id, silence_ms, variation)
|
| 1903 |
try:
|
| 1904 |
return _run_tts_quality(model, text, refs, lang, base_params, out_path, job_id,
|
| 1905 |
+
silence_ms, variation, attempts, enhance_ref, clean_text, ref_summary_out, stats_out)
|
| 1906 |
except Exception:
|
| 1907 |
print("[!] Quality pipeline failed — falling back to legacy pipeline:", flush=True)
|
| 1908 |
traceback.print_exc()
|
| 1909 |
return _run_tts_chunked_legacy(text, refs, lang, base_params, out_path, job_id, silence_ms, variation)
|
| 1910 |
|
| 1911 |
def _run_tts_quality(model, text, refs, lang, base_params, out_path, job_id,
|
| 1912 |
+
silence_ms, variation, attempts, enhance_ref, clean_text, ref_summary_out, stats_out=None):
|
| 1913 |
cancel_ev = cancel_events.get(job_id)
|
| 1914 |
job_progress[job_id] = {"current": 0, "total": 1, "status": "running"}
|
| 1915 |
+
t_start = time.time()
|
| 1916 |
ref_paths, summary = prepare_refs(refs, enhance_ref)
|
| 1917 |
if summary and ref_summary_out is not None:
|
| 1918 |
ref_summary_out.update(summary)
|
|
|
|
| 1919 |
|
| 1920 |
+
txt = vq.normalize_text(text, lang) if clean_text else text.strip()
|
| 1921 |
+
attempts = int(np.clip(attempts, 1, 4))
|
| 1922 |
+
asr = _asr_for_scoring(lang) if attempts > 1 else None
|
| 1923 |
+
cache_path = os.path.join(DRY_CACHE_DIR, _dry_cache_key(
|
| 1924 |
+
txt, lang, ref_paths, base_params, silence_ms, variation, attempts, asr is not None) + ".wav")
|
| 1925 |
+
if os.path.exists(cache_path): # identical request → skip synthesis entirely
|
| 1926 |
+
shutil.copyfile(cache_path, out_path)
|
| 1927 |
+
try: os.utime(cache_path, None)
|
| 1928 |
+
except Exception: pass
|
| 1929 |
+
job_progress[job_id] = {"current": 1, "total": 1, "status": "done", "ts": time.time()}
|
| 1930 |
+
_record_job(stats_out, job_id, cached=True, audio_sec=sf.info(out_path).duration,
|
| 1931 |
+
wall=time.time() - t_start)
|
| 1932 |
+
return True
|
| 1933 |
+
lat_gpt, lat_spk = get_latents(model, ref_paths)
|
| 1934 |
limits = getattr(getattr(model, "tokenizer", None), "char_limits", None)
|
| 1935 |
chunks = vq.split_for_xtts(txt, lang, limits)
|
| 1936 |
total = len(chunks)
|
| 1937 |
job_progress[job_id] = {"current": 0, "total": total, "status": "running"}
|
| 1938 |
|
| 1939 |
d = _model_defaults(model)
|
| 1940 |
+
try:
|
| 1941 |
+
secs_fn = _secs_fn(model, lat_spk) if attempts > 1 else None
|
| 1942 |
+
except Exception as ex: # scoring is a bonus — never block generation
|
| 1943 |
+
print(f"[!] speaker-similarity scoring disabled: {ex}", flush=True)
|
| 1944 |
+
secs_fn = None
|
| 1945 |
+
retries = 0
|
| 1946 |
use_var = variation > 0.02
|
| 1947 |
emotions = [analyze_phrase_emotion(c, lang) for c, _ in chunks] if use_var else [("neutral", 0.0)] * total
|
| 1948 |
seed0 = zlib.crc32(txt.encode("utf-8")) & 0x7FFFFFFF
|
|
|
|
| 1964 |
out = model.inference(chunk, lang, lat_gpt, lat_spk, enable_text_splitting=False, **kw)
|
| 1965 |
return np.asarray(out["wav"], np.float32).reshape(-1)
|
| 1966 |
|
| 1967 |
+
wav, _info, _n = vq.synth_best(gen, chunk, attempts, asr, vq.XTTS_SR, seed0 + 101 * i,
|
| 1968 |
+
secs_fn=secs_fn, lang=lang)
|
| 1969 |
+
retries += _n - 1
|
| 1970 |
wav = _residual_stretch(wav, resid)
|
| 1971 |
if use_var and emo != "neutral":
|
| 1972 |
wav = apply_phrase_dynamics(wav, vq.XTTS_SR, emo, variation * 0.5)
|
|
|
|
| 1977 |
|
| 1978 |
audio = vq.finalize_audio(vq.assemble(takes, pauses, vq.XTTS_SR, 1.0, seed0), vq.XTTS_SR)
|
| 1979 |
sf.write(out_path, audio, vq.XTTS_SR, subtype="PCM_16")
|
| 1980 |
+
try:
|
| 1981 |
+
shutil.copyfile(out_path, cache_path)
|
| 1982 |
+
_prune_dir(DRY_CACHE_DIR, 60, 400 << 20)
|
| 1983 |
+
except Exception:
|
| 1984 |
+
pass
|
| 1985 |
job_progress[job_id]["status"] = "done"
|
| 1986 |
job_progress[job_id]["ts"] = time.time()
|
| 1987 |
+
_record_job(stats_out, job_id, cached=False, chunks=total, attempts=attempts, extra_takes=retries,
|
| 1988 |
+
audio_sec=len(audio) / vq.XTTS_SR, wall=time.time() - t_start)
|
| 1989 |
return True
|
| 1990 |
|
| 1991 |
def apply_depth(path, amount=0.5, space="studio"):
|
|
|
|
| 2002 |
print("[!] depth processing failed — file left dry:", flush=True)
|
| 2003 |
traceback.print_exc()
|
| 2004 |
|
| 2005 |
+
MAX_UPLOAD_BYTES = 30 * 1024 * 1024 # per file
|
| 2006 |
+
MAX_REQUEST_BYTES = 130 * 1024 * 1024 # per request (Content-Length)
|
| 2007 |
+
MAX_REF_FILES = 4
|
| 2008 |
+
_AUDIO_EXT = (".wav", ".mp3", ".flac", ".ogg", ".m4a", ".opus", ".webm", ".aac")
|
| 2009 |
+
|
| 2010 |
+
def _copy_limited(fobj, dest, limit=MAX_UPLOAD_BYTES):
|
| 2011 |
+
"""Copy an upload to disk, refusing anything above `limit` bytes."""
|
| 2012 |
+
size = 0
|
| 2013 |
+
try:
|
| 2014 |
+
with open(dest, "wb") as out:
|
| 2015 |
+
while True:
|
| 2016 |
+
chunk = fobj.read(1 << 20)
|
| 2017 |
+
if not chunk: break
|
| 2018 |
+
size += len(chunk)
|
| 2019 |
+
if size > limit:
|
| 2020 |
+
raise HTTPException(413, f"الملف أكبر من {limit >> 20} MB.")
|
| 2021 |
+
out.write(chunk)
|
| 2022 |
+
except Exception:
|
| 2023 |
+
try: os.remove(dest)
|
| 2024 |
+
except Exception: pass
|
| 2025 |
+
raise
|
| 2026 |
+
|
| 2027 |
+
_VOICE_NAME_RE = re.compile(r"[^\w\- ]", re.UNICODE)
|
| 2028 |
+
def safe_voice_name(name) -> str:
|
| 2029 |
+
return _VOICE_NAME_RE.sub("_", (name or "").strip()).strip(" ._")[:40]
|
| 2030 |
+
|
| 2031 |
+
def voice_dir(name, must_exist=False) -> str:
|
| 2032 |
+
"""Resolve a voice-library folder, guaranteed to live directly inside VOICE_LIB
|
| 2033 |
+
(blocks '..', absolute paths and separators). Names saved by older versions
|
| 2034 |
+
are still found via an exact-name lookup."""
|
| 2035 |
+
lib = os.path.realpath(VOICE_LIB)
|
| 2036 |
+
raw = (name or "").strip()
|
| 2037 |
+
found = None
|
| 2038 |
+
for cand in dict.fromkeys([raw, safe_voice_name(raw)]):
|
| 2039 |
+
if not cand or cand in (".", "..") or any(c in cand for c in "/\\\x00"):
|
| 2040 |
+
continue
|
| 2041 |
+
p = os.path.realpath(os.path.join(lib, cand))
|
| 2042 |
+
if os.path.dirname(p) != lib:
|
| 2043 |
+
continue
|
| 2044 |
+
if os.path.isdir(p): return p
|
| 2045 |
+
found = found or p
|
| 2046 |
+
if found is None: raise HTTPException(400, "اسم الصوت غير صالح.")
|
| 2047 |
+
if must_exist: raise HTTPException(404, "صوت غير موجود.")
|
| 2048 |
+
return found
|
| 2049 |
+
|
| 2050 |
+
def _output_path(filename: str) -> str:
|
| 2051 |
+
safe = os.path.basename(filename or "")
|
| 2052 |
+
if not safe or safe != filename or ".." in safe:
|
| 2053 |
+
raise HTTPException(400, "اسم ملف غير صالح.")
|
| 2054 |
+
return os.path.join(OUTPUT_DIR, safe)
|
| 2055 |
+
|
| 2056 |
def collect_refs(files, voice_name):
|
| 2057 |
paths = []
|
| 2058 |
+
try:
|
| 2059 |
+
for f in files:
|
| 2060 |
+
if not f or not getattr(f, "filename", None): continue
|
| 2061 |
+
if len(paths) >= MAX_REF_FILES: break
|
| 2062 |
+
base = re.sub(r"[^\w.\-]", "_", os.path.basename(f.filename), flags=re.UNICODE)[-60:] or "ref.wav"
|
| 2063 |
+
p = f"/tmp/ref_{uuid.uuid4().hex}_{base}"
|
| 2064 |
+
_copy_limited(f.file, p)
|
| 2065 |
+
paths.append(p)
|
| 2066 |
+
if voice_name and voice_name not in ("", "null", "none", "undefined"):
|
| 2067 |
+
lib_dir = voice_dir(voice_name)
|
| 2068 |
+
if os.path.isdir(lib_dir):
|
| 2069 |
+
paths += [os.path.join(lib_dir, fn) for fn in sorted(os.listdir(lib_dir))
|
| 2070 |
+
if fn.lower().endswith(_AUDIO_EXT)]
|
| 2071 |
+
except Exception:
|
| 2072 |
+
cleanup(paths); raise
|
| 2073 |
return paths
|
| 2074 |
|
| 2075 |
def cleanup(paths):
|
|
|
|
| 2082 |
app = FastAPI(title="XTTS Voice Studio CPU Optimized")
|
| 2083 |
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
| 2084 |
|
| 2085 |
+
@app.middleware("http")
|
| 2086 |
+
async def _limit_request_size(request, call_next):
|
| 2087 |
+
cl = request.headers.get("content-length", "")
|
| 2088 |
+
if request.method in ("POST", "PUT") and cl.isdigit() and int(cl) > MAX_REQUEST_BYTES:
|
| 2089 |
+
return JSONResponse({"detail": f"الطلب أكبر من {MAX_REQUEST_BYTES >> 20} MB."}, status_code=413)
|
| 2090 |
+
return await call_next(request)
|
| 2091 |
+
|
| 2092 |
+
@app.exception_handler(Exception)
|
| 2093 |
+
async def _unhandled_error(request, exc):
|
| 2094 |
+
print(f"[!] Unhandled error on {request.method} {request.url.path}:", flush=True)
|
| 2095 |
+
traceback.print_exception(type(exc), exc, exc.__traceback__)
|
| 2096 |
+
return JSONResponse({"detail": f"خطأ داخلي ({type(exc).__name__}). راجع سجلات الخادم."}, status_code=500)
|
| 2097 |
+
|
| 2098 |
LANGUAGES = {
|
| 2099 |
"ar":"العربية","en":"English","es":"Español","fr":"Français",
|
| 2100 |
"de":"Deutsch","it":"Italiano","pt":"Português","ru":"Русский",
|
|
|
|
| 3353 |
if (!data) return; // cancelled
|
| 3354 |
setResult({url:`/audio/${data.filename}`,filename:data.filename});
|
| 3355 |
toast("success","✅ تم توليد الصوت بنجاح");
|
| 3356 |
+
if(data.cached) toast("info","⚡ أُعيدت المعالجة النهائية فقط من الذاكرة المؤقتة (لم يُعاد توليد الصوت).",5000);
|
| 3357 |
const rr=data.ref_report;
|
| 3358 |
if(rr&&(rr.score<70||(rr.warnings||[]).length))
|
| 3359 |
toast("info",`جودة العينة ${rr.score}/100 — ${(rr.warnings||[])[0]||"جرّب عينة أنقى"}`,8000);
|
|
|
|
| 3404 |
</button>
|
| 3405 |
{refCheck&&refCheck.report&&(
|
| 3406 |
<div className="col" style={{gap:6}}>
|
| 3407 |
+
<div className="fz-12"><b>جودة العينة: {refCheck.report.score}/100</b> · المدة بعد التنقية {refCheck.report.duration}ث{refCheck.segments>1?" ("+refCheck.segments+" مقاطع متجاورة، المعروض أفضلها)":""} · SNR {refCheck.report.snr_before}→{refCheck.report.snr_after} dB{refCheck.report.denoised?" · تم تقليل الضجيج":""}</div>
|
| 3408 |
{(refCheck.report.warnings||[]).map((w,i)=><div key={"w"+i} className="fz-12" style={{color:"#f59e0b"}}>⚠ {w}</div>)}
|
| 3409 |
{(refCheck.report.tips||[]).map((w,i)=><div key={"t"+i} className="fz-12 text-muted">💡 {w}</div>)}
|
| 3410 |
<audio controls src={refCheck.url} style={{width:"100%"}}/>
|
|
|
|
| 4033 |
"ready": xtts_ready,
|
| 4034 |
"error": xtts_error,
|
| 4035 |
"device": device,
|
| 4036 |
+
"whisper": bool(WHISPER_OK),
|
| 4037 |
+
"zerogpu": bool(ZEROGPU),
|
| 4038 |
})
|
| 4039 |
|
| 4040 |
+
_STARTED_AT = time.time()
|
| 4041 |
+
|
| 4042 |
+
@app.get("/health")
|
| 4043 |
+
def health():
|
| 4044 |
+
"""Always HTTP 200 (so health checks never restart a model that is still loading)."""
|
| 4045 |
+
return {"status": "ok" if xtts_ready else ("loading" if xtts_loading else "error"),
|
| 4046 |
+
"model_ready": bool(xtts_ready), "whisper_ready": bool(WHISPER_OK),
|
| 4047 |
+
"busy": get_tts_lock().locked(), "uptime_s": int(time.time() - _STARTED_AT)}
|
| 4048 |
+
|
| 4049 |
+
@app.get("/diagnostics")
|
| 4050 |
+
def diagnostics():
|
| 4051 |
+
import importlib.metadata as md
|
| 4052 |
+
def ver(n):
|
| 4053 |
+
try: return md.version(n)
|
| 4054 |
+
except Exception: return "n/a"
|
| 4055 |
+
def folder(d):
|
| 4056 |
+
try:
|
| 4057 |
+
fs = [os.path.join(d, f) for f in os.listdir(d)]
|
| 4058 |
+
return {"files": len(fs), "mb": round(sum(os.path.getsize(f) for f in fs if os.path.isfile(f)) / 1e6, 1)}
|
| 4059 |
+
except Exception:
|
| 4060 |
+
return None
|
| 4061 |
+
return {
|
| 4062 |
+
"versions": {k: ver(k) for k in ("torch", "coqui-tts", "transformers", "faster-whisper", "numpy", "spaces")},
|
| 4063 |
+
"device": device, "zerogpu": bool(ZEROGPU), "cpu_threads": CPU_THREADS,
|
| 4064 |
+
"model_ready": bool(xtts_ready), "model_error": xtts_error, "whisper_ready": bool(WHISPER_OK),
|
| 4065 |
+
"caches": {"reference": folder(REF_CACHE_DIR), "dry_audio": folder(DRY_CACHE_DIR), "outputs": folder(OUTPUT_DIR)},
|
| 4066 |
+
"latent_cache_entries": len(_LAT_CACHE),
|
| 4067 |
+
"engine": "voice-quality v" + vq.VQ_VERSION,
|
| 4068 |
+
"recent_jobs": list(RECENT_JOBS),
|
| 4069 |
+
}
|
| 4070 |
+
|
| 4071 |
# ── Job control endpoints ─────────────────────────────────────────────
|
| 4072 |
@app.get("/progress/{job_id}")
|
| 4073 |
def get_progress(job_id: str):
|
|
|
|
| 4097 |
):
|
| 4098 |
"""Edit a generated audio file without re-running TTS.
|
| 4099 |
Always writes a NEW file — the original is never modified."""
|
| 4100 |
+
src = _output_path(filename)
|
| 4101 |
if not os.path.exists(src):
|
| 4102 |
raise HTTPException(404, "الملف غير موجود.")
|
| 4103 |
|
|
|
|
| 4181 |
):
|
| 4182 |
"""Apply acoustic environment simulation to a generated WAV.
|
| 4183 |
Returns a brand-new file — original untouched."""
|
| 4184 |
+
src = _output_path(filename)
|
| 4185 |
if not os.path.exists(src):
|
| 4186 |
raise HTTPException(404, "الملف غير موجود.")
|
| 4187 |
if preset not in HUMANIZE_PRESETS:
|
|
|
|
| 4227 |
if not xtts_ready: raise HTTPException(503, f"❌ فشل تحميل النموذج: {xtts_error}")
|
| 4228 |
if not text.strip(): raise HTTPException(400, "النص فارغ.")
|
| 4229 |
if len(text) > MAX_CHARS: raise HTTPException(400, f"النص يتجاوز {MAX_CHARS} حرف.")
|
| 4230 |
+
if language not in LANGUAGES: raise HTTPException(400, f"لغة غير مدعومة: {language}")
|
| 4231 |
if not job_id:
|
| 4232 |
job_id = uuid.uuid4().hex
|
| 4233 |
|
|
|
|
| 4247 |
cancel_events[job_id] = threading.Event()
|
| 4248 |
silence_ms = {"story":160,"sad":180,"news":80,"excited":70}.get(expression_mode, 110)
|
| 4249 |
variation = float(np.clip(variation_strength, 0.0, 2.0))
|
| 4250 |
+
ref_summary, job_stats = {}, {}
|
| 4251 |
try:
|
| 4252 |
async with get_tts_lock():
|
| 4253 |
success = await asyncio.to_thread(
|
| 4254 |
run_tts_chunked, enhanced_text, refs, language,
|
| 4255 |
tts_params, out, job_id, silence_ms, variation,
|
| 4256 |
+
attempts, enhance_ref, clean_text, ref_summary, job_stats
|
| 4257 |
)
|
| 4258 |
if not success:
|
| 4259 |
if os.path.exists(out): os.remove(out)
|
|
|
|
| 4266 |
cancel_events.pop(job_id, None)
|
| 4267 |
|
| 4268 |
append_history(os.path.basename(out), text, language, "tts", len(text))
|
| 4269 |
+
return {"filename": os.path.basename(out), "ref_report": ref_summary or None,
|
| 4270 |
+
"cached": bool(job_stats.get("cached")), "stats": job_stats or None}
|
| 4271 |
|
| 4272 |
|
| 4273 |
# ── Reference check: clean the sample and let the user LISTEN to it ───
|
|
|
|
| 4279 |
refs = collect_refs(files, voice_name)
|
| 4280 |
if not refs: raise HTTPException(400, "حدد عينة صوتية أولاً.")
|
| 4281 |
try:
|
| 4282 |
+
paths, _reports, summary = await asyncio.to_thread(vq.enhance_reference_set, refs, REF_CACHE_DIR)
|
| 4283 |
+
primary = paths[0] # best contiguous segment (others are used too, never joined)
|
| 4284 |
+
fn = "refclean_" + os.path.basename(primary)[4:-4] + ".wav"
|
| 4285 |
+
shutil.copyfile(primary, os.path.join(OUTPUT_DIR, fn))
|
| 4286 |
+
return {"url": f"/audio/{fn}", "report": summary, "segments": len(paths)}
|
| 4287 |
except ValueError as e:
|
| 4288 |
raise HTTPException(422, str(e))
|
| 4289 |
finally:
|
|
|
|
| 4313 |
if not WHISPER_OK: raise HTTPException(503, "Faster-Whisper غير جاهز بعد (لا يزال يُحمَّل أو فشل تحميله)، أعد المحاولة بعد قليل.")
|
| 4314 |
if not job_id: job_id = uuid.uuid4().hex
|
| 4315 |
src = f"/tmp/sts_{uuid.uuid4().hex}.wav"
|
| 4316 |
+
_copy_limited(source_audio.file, src)
|
| 4317 |
refs = []
|
| 4318 |
cancel_events[job_id] = threading.Event()
|
| 4319 |
try:
|
|
|
|
| 4371 |
if not TRANSLATOR_OK: raise HTTPException(503, "deep-translator غير مثبّت.")
|
| 4372 |
if not job_id: job_id = uuid.uuid4().hex
|
| 4373 |
src = f"/tmp/dub_{uuid.uuid4().hex}.wav"
|
| 4374 |
+
_copy_limited(source_audio.file, src)
|
| 4375 |
refs = []
|
| 4376 |
cancel_events[job_id] = threading.Event()
|
| 4377 |
try:
|
|
|
|
| 4481 |
name: str = Form(...),
|
| 4482 |
file: UploadFile = File(...),
|
| 4483 |
file2: UploadFile = File(default=None)):
|
| 4484 |
+
safe = safe_voice_name(name)
|
| 4485 |
+
if not safe: raise HTTPException(400, "اسم الصوت غير صالح.")
|
| 4486 |
+
uploads = [f for f in ([file, file2] if file2 and file2.filename else [file]) if f and f.filename]
|
| 4487 |
+
names = []
|
| 4488 |
+
for i, f in enumerate(uploads):
|
| 4489 |
+
fn = re.sub(r"[^\w.\-]", "_", os.path.basename(f.filename), flags=re.UNICODE)[:80] or f"ref{i}.wav"
|
| 4490 |
+
if not fn.lower().endswith(_AUDIO_EXT):
|
| 4491 |
+
raise HTTPException(400, "صيغة الملف غير مدعومة (wav / mp3 / flac / ogg / m4a).")
|
| 4492 |
+
names.append(fn)
|
| 4493 |
+
if not uploads: raise HTTPException(400, "لا يوجد ملف صوتي.")
|
| 4494 |
+
lib = voice_dir(safe)
|
| 4495 |
os.makedirs(lib, exist_ok=True)
|
| 4496 |
+
for f, fn in zip(uploads, names):
|
| 4497 |
+
_copy_limited(f.file, os.path.join(lib, fn))
|
|
|
|
|
|
|
| 4498 |
background_tasks.add_task(trigger_cloud_backup)
|
| 4499 |
+
return {"name": os.path.basename(lib)}
|
| 4500 |
|
| 4501 |
@app.delete("/voices/{name}")
|
| 4502 |
def delete_voice(background_tasks: BackgroundTasks, name: str):
|
| 4503 |
+
lib = voice_dir(name, must_exist=True)
|
| 4504 |
+
shutil.rmtree(lib)
|
| 4505 |
background_tasks.add_task(trigger_cloud_backup)
|
| 4506 |
return {"deleted": name}
|
| 4507 |
|
| 4508 |
@app.get("/voices/{name}/preview")
|
| 4509 |
def preview_voice(name: str):
|
| 4510 |
+
lib = voice_dir(name, must_exist=True)
|
| 4511 |
+
for fn in sorted(os.listdir(lib)):
|
| 4512 |
+
if fn.lower().endswith(_AUDIO_EXT):
|
| 4513 |
+
mt = {"wav": "audio/wav", "mp3": "audio/mpeg", "flac": "audio/flac", "ogg": "audio/ogg",
|
| 4514 |
+
"m4a": "audio/mp4", "opus": "audio/ogg", "webm": "audio/webm", "aac": "audio/aac"}[fn.rsplit(".", 1)[-1].lower()]
|
| 4515 |
return FileResponse(os.path.join(lib, fn), media_type=mt)
|
| 4516 |
raise HTTPException(404, "لا توجد ملفات صوتية.")
|
| 4517 |
|