23v commited on
Commit
fbe7c20
·
verified ·
1 Parent(s): c5a473f

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +454 -198
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 = "1"
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 enhance_reference(path, strength=1.0, target_sec=24.0, denoise=True):
335
- """→ (clean float32 @22050, report dict)."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- pk = float(np.max(np.abs(raw)) + 1e-9)
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
- z = _compact_speech(x, REF_SR, db, w, h, mask) if mask.any() else x
356
- z = _best_blocks(z, REF_SR, target_sec)
357
-
358
- dbz, wz, hz = frame_db(z, REF_SR)
359
- mz = _voiced_mask(dbz)
360
- lvl = float(np.sqrt(np.mean(np.square(np.concatenate(
361
- [z[s * hz:e * hz + wz] for s, e in _segments(mz)]) if mz.any() else z))) + 1e-9)
362
- z = z * (10 ** (-20 / 20) / lvl) # voiced RMS → −20 dBFS
363
- z = soft_limit(z, 0.95, 0.85)
364
- z = _fade(z, REF_SR, 10, 30)
365
- after = _analyze(z, fs)
 
 
 
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
- score -= max(0, 20 - before["snr_db"]) * 1.5
380
- score -= min(30, clip_pct * 10)
381
- score -= max(0, 6 - speech_sec_all) * 5
382
- score -= 15 if before["bandwidth_hz"] < 6000 else 0
383
- score = int(np.clip(score, 0, 100))
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
- max_total=30.0, denoise=True):
399
- """Clean every file, merge best-first into ONE reference (XTTS only reads
400
- the first `gpt_cond_len` s of the concatenation, so ordering matters).
401
- → (merged_wav_path, [per-file reports], summary)."""
 
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}|{max_total}|{denoise}|{VQ_VERSION}").encode()).hexdigest()[:20]
405
- out, meta = os.path.join(cache_dir, f"ref_{key}.wav"), os.path.join(cache_dir, f"ref_{key}.json")
406
- if os.path.exists(out) and os.path.exists(meta):
407
  d = json.load(open(meta, encoding="utf-8"))
408
- return out, d["reports"], d["summary"]
 
409
 
410
- per = target_sec if len(paths) == 1 else max(8.0, target_sec / len(paths) * 1.25)
411
- items = []
412
- for p in paths:
413
  try:
414
- z, rep = enhance_reference(p, strength, per, denoise)
415
  rep["file"] = os.path.basename(p)[-40:]
416
- items.append((z, rep))
 
417
  except Exception as e:
418
- items.append((None, dict(file=os.path.basename(p)[-40:], error=str(e), score=0,
419
- warnings=[f"تعذّرت معالجة الملف: {e}"], tips=[])))
420
- good = [(z, r) for z, r in items if z is not None]
421
- if not good:
422
- raise ValueError("تعذّرت معالجة كل ملفات العينة: " + "; ".join(r.get("error", "?") for _, r in items))
423
- good.sort(key=lambda t: t[1]["score"], reverse=True)
424
- gap = np.zeros(int(0.25 * REF_SR), np.float32)
425
- merged = np.concatenate([np.concatenate([z, gap]) for z, _ in good])[: int(max_total * REF_SR)]
426
- sf.write(out, merged, REF_SR, subtype="PCM_16")
427
- reports = [r for _, r in items]
428
- best = good[0][1]
429
- summary = dict(score=int(np.mean([r["score"] for _, r in good])),
430
- duration=round(len(merged) / REF_SR, 1),
431
- files=len(paths), used=len(good),
432
- warnings=[w for _, r in good for w in r["warnings"]][:6],
433
- tips=list(dict.fromkeys(t for _, r in good for t in r["tips"]))[:4],
 
 
 
 
434
  snr_before=best["before"]["snr_db"], snr_after=best["after"]["snr_db"],
435
- denoised=any(r["denoised"] for _, r in good))
436
- json.dump(dict(reports=reports, summary=summary), open(meta, "w", encoding="utf-8"), ensure_ascii=False)
437
- return out, reports, summary
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
- def take_info(wav, sr, text, asr_fn=None):
 
 
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 4.0 <= cps <= 28.0
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 synth_best(gen_fn, text, attempts=1, asr_fn=None, sr=XTTS_SR, seed=1234, accept_sim=0.90):
557
- """gen_fn(seed:int) → float32 wav. Returns (wav, info, n_generated)."""
558
- takes = []
559
- for k in range(max(1, attempts)):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
560
  wav = trim_take(gen_fn(seed + 7919 * k), sr)
561
- info = take_info(wav, sr, text, asr_fn)
562
- takes.append((wav, info))
563
- if attempts > 1 and info["ok"] and info["sim"] is not None and info["sim"] >= accept_sim:
564
- break
 
 
 
 
 
 
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
- def score(i):
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
- try:
779
- HfApi(token=HF_TOKEN).upload_folder(
780
- folder_path=PERSISTENT_DIR, repo_id=DATASET_REPO_ID,
781
- repo_type="dataset", commit_message=f"Auto-backup: {int(time.time())}")
782
- except Exception as e:
783
- print(f"[!] Cloud backup failed: {e}")
784
- threading.Thread(target=_run).start()
 
 
 
 
 
 
 
 
 
 
 
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
- try:
882
- return json.load(open(HISTORY_FILE)) if os.path.exists(HISTORY_FILE) else []
883
- except Exception:
884
- return []
 
 
 
 
 
885
 
886
  def save_history(h):
887
- json.dump(h, open(HISTORY_FILE, "w"), ensure_ascii=False, indent=2)
 
 
 
 
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
- h = load_history()
907
- h.append(dict(filename=filename, text=text[:150], language=language,
908
- mode=mode, char_count=char_count, ts=int(time.time())))
909
- if len(h) > MAX_HISTORY:
910
- h = h[-MAX_HISTORY:]
911
- save_history(h)
 
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
- path, _reports, summary = vq.enhance_reference_set(refs, REF_CACHE_DIR)
1706
- return [path], summary
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
- lat = model.get_conditioning_latents(
1718
- audio_path=list(ref_paths),
1719
- gpt_cond_len=int(getattr(c, "gpt_cond_len", 30)),
1720
- gpt_cond_chunk_len=int(getattr(c, "gpt_cond_chunk_len", 4)),
1721
- max_ref_length=int(getattr(c, "max_ref_len", 30)),
1722
- sound_norm_refs=bool(getattr(c, "sound_norm_refs", False)))
 
 
 
 
 
 
 
 
 
 
 
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) -> bool:
 
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 = vq.normalize_text(text, lang) if clean_text else text.strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- attempts = int(np.clip(attempts, 1, 4))
1811
- asr = _asr_for_scoring(lang) if attempts > 1 else None
 
 
 
 
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
- for f in files:
1865
- if not f or not getattr(f, "filename", None): continue
1866
- p = f"/tmp/ref_{uuid.uuid4().hex}_{f.filename}"
1867
- with open(p, "wb") as buf: shutil.copyfileobj(f.file, buf)
1868
- paths.append(p)
1869
- if voice_name and voice_name not in ("", "null", "none", "undefined"):
1870
- lib_dir = os.path.join(VOICE_LIB, voice_name)
1871
- if os.path.isdir(lib_dir):
1872
- paths += [os.path.join(lib_dir, fn) for fn in sorted(os.listdir(lib_dir))
1873
- if fn.lower().endswith((".wav", ".mp3", ".flac", ".ogg"))]
 
 
 
 
 
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 = os.path.join(OUTPUT_DIR, filename)
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 = os.path.join(OUTPUT_DIR, filename)
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
- path, _reports, summary = await asyncio.to_thread(vq.enhance_reference_set, refs, REF_CACHE_DIR)
4035
- fn = "refclean_" + os.path.basename(path)[4:-4] + ".wav"
4036
- shutil.copyfile(path, os.path.join(OUTPUT_DIR, fn))
4037
- return {"url": f"/audio/{fn}", "report": summary}
 
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
- with open(src, "wb") as f: shutil.copyfileobj(source_audio.file, f)
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
- with open(src, "wb") as f: shutil.copyfileobj(source_audio.file, f)
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 = name.strip().replace("/","_").replace("..","_")[:40]
4236
- lib = os.path.join(VOICE_LIB, safe)
 
 
 
 
 
 
 
 
 
4237
  os.makedirs(lib, exist_ok=True)
4238
- for f in ([file, file2] if file2 and file2.filename else [file]):
4239
- safe_fn = re.sub(r"[^A-Za-z0-9_.\-]", "_", os.path.basename(f.filename))[:80]
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": safe}
4244
 
4245
  @app.delete("/voices/{name}")
4246
  def delete_voice(background_tasks: BackgroundTasks, name: str):
4247
- lib = os.path.join(VOICE_LIB, name)
4248
- if os.path.isdir(lib): shutil.rmtree(lib)
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 = os.path.join(VOICE_LIB, name)
4255
- if not os.path.isdir(lib): raise HTTPException(404, "صوت غير موجود.")
4256
- for fn in os.listdir(lib):
4257
- if fn.lower().endswith((".wav",".mp3",".flac",".ogg")):
4258
- mt = "audio/wav" if fn.endswith(".wav") else "audio/mpeg"
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