po / app.py
gijl's picture
Update app.py
af042ab verified
Raw
History Blame Contribute Delete
194 kB
# โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—
# โ•‘ XTTS v2 Voice Studio โ€” Professional Edition v11 โ•‘
# โ•‘ โ€ข Faster-Whisper โ€ข Soft Clipping โ€ข Real Progress โ€ข Cancel Jobs โ•‘
# โ•‘ โ€ข Audio Post-Editor โ€ข Full Mobile Responsive UI โ•‘
# โ•‘ โ€ข ZeroGPU (gradio.Server backend, no Gradio UI) โ€ข Per-user Login โ•‘
# โ•‘ โ€ข Privacy Notice โ•‘
# โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# โ”€โ”€ ZeroGPU: `spaces` MUST be imported before torch / any CUDA-touching
# library. It monkey-patches torch.cuda.* so GPU allocation can be
# deferred and handed out on-demand by Hugging Face's ZeroGPU pool. โ”€โ”€
import spaces
import os, re, time, json, uuid, shutil, asyncio, threading, hashlib, secrets
import numpy as np
import soundfile as sf
# โ”€โ”€ gradio.Server: a FastAPI subclass. It gives ZeroGPU eligibility,
# request queuing/concurrency control on Spaces, but ships NO Gradio
# UI components โ€” every route below is a plain FastAPI/Starlette
# route serving our own HTML/JSON, exactly like `FastAPI()` would. โ”€โ”€
from gradio import Server
from fastapi import Form, File, UploadFile, HTTPException, BackgroundTasks, Request
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse, RedirectResponse
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.sessions import SessionMiddleware
import torch
# โ”€โ”€ Environment โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
os.environ["COQUI_TOS_AGREED"] = "1"
os.environ.setdefault("HF_HOME", "/home/user/.cache/huggingface")
os.environ["OMP_NUM_THREADS"] = "4"
os.environ["MKL_NUM_THREADS"] = "4"
# โ”€โ”€ Persistent Storage โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
PERSISTENT_DIR = "/data" if os.path.exists("/data") else os.path.join(os.getcwd(), "app_data")
VOICE_LIB = os.path.join(PERSISTENT_DIR, "voice_library")
OUTPUT_DIR = os.path.join(PERSISTENT_DIR, "outputs")
HISTORY_FILE = os.path.join(PERSISTENT_DIR, "history.json")
MAX_CHARS = 5000
for d in [VOICE_LIB, OUTPUT_DIR]:
os.makedirs(d, exist_ok=True)
HF_TOKEN = os.environ.get("HF_TOKEN")
DATASET_REPO_ID = os.environ.get("DATASET_REPO_ID")
try:
from huggingface_hub import HfApi, snapshot_download
HUB_AVAILABLE = True
if HF_TOKEN and DATASET_REPO_ID:
print("[*] Restoring data from Hugging Face Hub โ€ฆ")
try:
snapshot_download(repo_id=DATASET_REPO_ID, repo_type="dataset",
local_dir=PERSISTENT_DIR, token=HF_TOKEN)
print("[โœ“] Data restoration complete.")
except Exception as e:
print(f"[!] Warning: Could not download backup ({e})")
except ImportError:
HUB_AVAILABLE = False
def trigger_cloud_backup():
if not (HUB_AVAILABLE and HF_TOKEN and DATASET_REPO_ID): return
def _run():
try:
HfApi(token=HF_TOKEN).upload_folder(
folder_path=PERSISTENT_DIR, repo_id=DATASET_REPO_ID,
repo_type="dataset", commit_message=f"Auto-backup: {int(time.time())}")
except Exception as e:
print(f"[!] Cloud backup failed: {e}")
threading.Thread(target=_run).start()
# โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—
# โ•‘ A U T H & U S E R M A N A G E M E N T โ•‘
# โ•‘ Accounts are stored in the persistent /data mount so they survive โ•‘
# โ•‘ restarts. Passwords are never stored in plain text (PBKDF2-SHA256 โ•‘
# โ•‘ with a per-user random salt, 200k iterations). โ•‘
# โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
USERS_FILE = os.path.join(PERSISTENT_DIR, "users.json")
SECRET_FILE = os.path.join(PERSISTENT_DIR, ".session_secret")
if os.path.exists(SECRET_FILE):
SESSION_SECRET = open(SECRET_FILE, "r", encoding="utf-8").read().strip()
else:
SESSION_SECRET = secrets.token_hex(32)
with open(SECRET_FILE, "w", encoding="utf-8") as f:
f.write(SESSION_SECRET)
PASSWORD_RULE = re.compile(r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).{8,}$')
def is_strong_password(pw: str) -> bool:
"""8+ chars, upper+lower+digit+symbol. Required for every account."""
return bool(PASSWORD_RULE.match(pw or ""))
def _hash_password(password: str, salt: bytes = None) -> str:
salt = salt or secrets.token_bytes(16)
dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 200_000)
return salt.hex() + "$" + dk.hex()
def _verify_password(password: str, stored: str) -> bool:
try:
salt_hex, hash_hex = stored.split("$")
dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), bytes.fromhex(salt_hex), 200_000)
return secrets.compare_digest(dk.hex(), hash_hex)
except Exception:
return False
def load_users() -> dict:
try:
return json.load(open(USERS_FILE, encoding="utf-8")) if os.path.exists(USERS_FILE) else {}
except Exception:
return {}
def save_users(u: dict):
json.dump(u, open(USERS_FILE, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
trigger_cloud_backup()
def safe_username(name: str) -> str:
return re.sub(r"[^A-Za-z0-9_.\-]", "_", (name or "").strip())[:40]
def bootstrap_admin():
"""Create the developer/admin account on first run if none exists."""
users = load_users()
if users:
return
admin_user = safe_username(os.environ.get("ADMIN_USERNAME", "admin")) or "admin"
admin_pass = os.environ.get("ADMIN_PASSWORD", "")
if not is_strong_password(admin_pass):
admin_pass = secrets.token_urlsafe(9) + "Aa1!"
print("=" * 72)
print("[!] ู„ู… ูŠุชู… ุถุจุท ูƒู„ู…ุฉ ู…ุฑูˆุฑ ู‚ูˆูŠุฉ ุนุจุฑ ู…ุชุบูŠู‘ุฑ ุงู„ุจูŠุฆุฉ ADMIN_PASSWORD.")
print("[!] ุชู… ุฅู†ุดุงุก ุญุณุงุจ ุงู„ู…ุทูˆู‘ุฑ ุชู„ู‚ุงุฆูŠุงู‹ ุจุงู„ุจูŠุงู†ุงุช ุงู„ุชุงู„ูŠุฉ (ุณุฌู‘ู„ ุงู„ุฏุฎูˆู„ ูˆุบูŠู‘ุฑู‡ุง ููˆุฑุงู‹):")
print(f" ุงุณู… ุงู„ู…ุณุชุฎุฏู… : {admin_user}")
print(f" ูƒู„ู…ุฉ ุงู„ู…ุฑูˆุฑ : {admin_pass}")
print("[!] ูŠููุถู‘ู„ ุถุจุท ADMIN_USERNAME / ADMIN_PASSWORD ูƒู…ุชุบูŠู‘ุฑุงุช ุจูŠุฆุฉ ุณุฑู‘ูŠุฉ (Secrets) ุจุฏู„ุงู‹ ู…ู† ุฐู„ูƒ.")
print("=" * 72)
users[admin_user] = {
"password": _hash_password(admin_pass), "role": "admin", "created": int(time.time())
}
save_users(users)
bootstrap_admin()
def get_session_user(request: Request):
return request.session.get("user")
# โ”€โ”€ Chatterbox Multilingual V3 Model โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Replaces XTTS v2. MIT-licensed, ~500M params, native Arabic support,
# and โ€” per Resemble AI's own Podonos blind evaluations โ€” preferred over
# ElevenLabs by listeners. Internal variable names (xtts / xtts_ready /
# xtts_loading / xtts_error) are kept as-is on purpose: they're referenced
# throughout /generate, /sts, /dub, /sfx and /model_status, and renaming
# them everywhere would add risk for zero benefit โ€” they now simply hold
# the Chatterbox model instead of XTTS.
from chatterbox.mtl_tts import ChatterboxMultilingualTTS
import inspect as _inspect
device = "cuda" if torch.cuda.is_available() else "cpu"
# โ”€โ”€ Model loading โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# NOTE: on ZeroGPU, `spaces`'s CUDA-emulation shim only intercepts
# `.to("cuda")` (or `device="cuda"` passed into `from_pretrained`) when it
# runs synchronously at module scope in the main thread โ€” this is exactly
# HF's documented pattern (`pipe.to("cuda")` right after `from_pretrained`,
# no threading involved). Loading the model inside a background
# `threading.Thread` (so the server could start immediately) escapes that
# emulation hook, so a *real* low-level CUDA init is reached in a
# stateless-GPU process, and ZeroGPU aborts with:
# "Low-level CUDA init (`torch._C._cuda_init`) reached ..."
# So the model is loaded synchronously here, before the server starts โ€”
# same pattern as before, just a different model class.
xtts = None # holds the ChatterboxMultilingualTTS instance
xtts_ready = False
xtts_loading = True
xtts_error = None
# Cache of the keyword arguments xtts.generate() actually accepts, so we
# never crash on a param name mismatch between Chatterbox releases โ€” we
# just silently drop anything the installed version doesn't support.
_generate_valid_params = set()
try:
print(f"[*] Loading Chatterbox Multilingual V3 on {device.upper()} โ€ฆ")
try:
# "t3_model=v3" selects the newer V3 checkpoint on releases that
# support it. Older/newer chatterbox-tts builds may not accept
# this kwarg at all โ€” fall back to the plain call if so, rather
# than failing the whole model load over one optional argument.
xtts = ChatterboxMultilingualTTS.from_pretrained(device=device, t3_model="v3")
except TypeError:
print("[i] Installed chatterbox-tts build doesn't accept t3_model= โ€” loading default checkpoint.")
xtts = ChatterboxMultilingualTTS.from_pretrained(device=device)
try:
_generate_valid_params = set(_inspect.signature(xtts.generate).parameters.keys())
except (TypeError, ValueError):
_generate_valid_params = {"language_id", "audio_prompt_path", "exaggeration",
"cfg_weight", "temperature"}
xtts_ready = True
print("[โœ“] Chatterbox ready.")
except Exception as e:
xtts_error = str(e)
print(f"[!] Chatterbox loading failed: {e}")
finally:
xtts_loading = False
# tts_lock is created lazily inside the event loop to avoid asyncio issues
_tts_lock = None
def get_tts_lock():
global _tts_lock
if _tts_lock is None:
_tts_lock = asyncio.Lock()
return _tts_lock
def _startup_cleanup():
for f in os.listdir("/tmp"):
if any(f.startswith(p) for p in ("ch_","ref_","sts_","dub_")):
try: os.remove(os.path.join("/tmp",f))
except: pass
_startup_cleanup()
# โ”€โ”€ Job Management: Cancellation & Progress โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
job_progress: dict = {} # job_id -> {current, total, status, ts}
cancel_events: dict = {} # job_id -> threading.Event
JOB_TTL_SEC = 3600
def _gc_jobs():
cutoff = time.time() - JOB_TTL_SEC
for k in [k for k,v in list(job_progress.items())
if v.get("status") != "running" and v.get("ts",0) < cutoff]:
job_progress.pop(k, None)
# โ”€โ”€ Faster-Whisper (replaces openai-whisper) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
try:
from faster_whisper import WhisperModel
whisper_mdl = WhisperModel("small", device="cpu", compute_type="int8")
WHISPER_OK = True
print("[โœ“] Faster-Whisper ready (small ยท int8 ยท CPU).")
except Exception as e:
WHISPER_OK, whisper_mdl = False, None
print(f"[!] Faster-Whisper not available: {e}")
def transcribe_audio(path: str) -> str:
"""Transcribe using Faster-Whisper; returns plain text."""
segments, _ = whisper_mdl.transcribe(path, beam_size=5)
return " ".join(s.text for s in segments).strip()
# โ”€โ”€ Translator โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
try:
from deep_translator import GoogleTranslator
TRANSLATOR_OK = True
except Exception:
TRANSLATOR_OK = False
# โ”€โ”€ History helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def load_history():
try:
return json.load(open(HISTORY_FILE)) if os.path.exists(HISTORY_FILE) else []
except Exception:
return []
def save_history(h):
json.dump(h, open(HISTORY_FILE, "w"), ensure_ascii=False, indent=2)
trigger_cloud_backup()
MAX_HISTORY = 300
MAX_OUTPUTS = 200
def _prune_outputs():
try:
wavs = sorted(
[f for f in os.listdir(OUTPUT_DIR) if f.endswith(".wav")],
key=lambda f: os.path.getmtime(os.path.join(OUTPUT_DIR, f))
)
for old in wavs[:-MAX_OUTPUTS]:
try: os.remove(os.path.join(OUTPUT_DIR, old))
except: pass
except Exception:
pass
def append_history(filename, text, language, mode, char_count=0, owner="unknown"):
h = load_history()
h.append(dict(filename=filename, text=text[:150], language=language,
mode=mode, char_count=char_count, ts=int(time.time()), owner=owner))
if len(h) > MAX_HISTORY:
h = h[-MAX_HISTORY:]
save_history(h)
_prune_outputs()
def owns_output_file(filename: str, username: str) -> bool:
"""True if `username` generated `filename` (per the history log)."""
for entry in load_history():
if entry.get("filename") == filename:
return entry.get("owner") == username
return False
# โ”€โ”€ Parameter Engineering โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Chatterbox Multilingual exposes three real generation knobs:
# exaggeration : emotional intensity / expressiveness (default 0.5)
# cfg_weight : how tightly generation follows the reference voice โ€”
# lower = looser & more dramatic, higher = literal (default 0.5)
# temperature : sampling randomness (default ~0.8)
# `speed` is NOT a native Chatterbox parameter โ€” we apply it ourselves as
# a post-generation time-stretch (see _apply_speed_factor / edit_audio).
#
# Our four legacy UI sliders (Stability / Clarity / Style / Speed) are kept
# as-is in the UI (people are used to them) and mapped onto the three real
# knobs below:
# Stability (0=expressive .. 1=consistent) โ†’ inverse โ†’ temperature
# Clarity (0=varied .. 1=matches ref) โ†’ direct โ†’ cfg_weight
# Style Exaggeration (0=natural .. 1=dramatic) โ†’ direct โ†’ exaggeration
def stability_to_temperature(s): return round(0.95 - (float(s) * 0.55), 3) # 0.95 .. 0.40
def clarity_to_cfg_weight(c): return round(0.15 + (float(c) * 0.65), 3) # 0.15 .. 0.80
def style_to_exaggeration(st): return round(0.20 + (float(st) * 1.10), 3) # 0.20 .. 1.30
# โ”€โ”€ Expression Presets โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Each preset overrides exaggeration/cfg_weight/temperature to push
# Chatterbox toward a specific emotional character.
EXPRESSION_PRESETS = {
"natural": dict(exaggeration=0.50, cfg_weight=0.50, temperature=0.80, speed_mult=1.00),
"warm": dict(exaggeration=0.55, cfg_weight=0.55, temperature=0.82, speed_mult=0.97),
"excited": dict(exaggeration=0.85, cfg_weight=0.35, temperature=0.90, speed_mult=1.10),
"sad": dict(exaggeration=0.35, cfg_weight=0.60, temperature=0.70, speed_mult=0.88),
"serious": dict(exaggeration=0.30, cfg_weight=0.65, temperature=0.60, speed_mult=0.95),
"story": dict(exaggeration=0.65, cfg_weight=0.45, temperature=0.85, speed_mult=0.96),
"news": dict(exaggeration=0.30, cfg_weight=0.60, temperature=0.62, speed_mult=1.02),
"child": dict(exaggeration=0.80, cfg_weight=0.35, temperature=0.92, speed_mult=1.05),
}
def apply_expression_preset(preset_name: str, speed: float) -> dict:
p = EXPRESSION_PRESETS.get(preset_name, EXPRESSION_PRESETS["natural"])
return {
"exaggeration": p["exaggeration"],
"cfg_weight": p["cfg_weight"],
"temperature": p["temperature"],
"speed": round(speed * p["speed_mult"], 3),
}
# โ”€โ”€ Text Expressiveness Enhancer โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# XTTS is very sensitive to punctuation and sentence rhythm.
# Adding deliberate pauses, ellipses, and varied sentence endings
# dramatically improves naturalness and eliminates the "robot reading" feel.
def enhance_text_expression(text: str, lang: str, mode: str) -> str:
"""
Enrich text with punctuation cues that guide XTTS prosody.
Does NOT change words โ€” only adds/adjusts punctuation & whitespace.
"""
if mode == "natural" or not text.strip():
return text
t = text.strip()
# 1. Normalise whitespace
t = re.sub(r'[ \t]+', ' ', t)
# 2. Ensure sentences end with terminal punctuation
# (XTTS pauses longer on . than on nothing)
t = re.sub(r'([^\.\!\?ุŸุŒ,\n])\n', r'\1.\n', t)
# 3. Mode-specific rhythm adjustments
if mode == "excited":
# Shorten pauses between sentences โ†’ rapid delivery
t = re.sub(r'\.\s+', '. ', t)
# Add emphasis markers via comma-pauses before key conjunctions
for conj in ['ู„ูƒู†','ุจู„','ุฅู„ุง','however','but','yet','still']:
t = re.sub(rf'\b({conj})\b', r', \1', t, flags=re.IGNORECASE)
elif mode == "sad":
# Extend pauses โ†’ slower, heavier delivery
t = re.sub(r'\.\s+', 'โ€ฆ ', t)
t = re.sub(r'ุŒ\s+', 'ุŒ ', t)
elif mode == "story":
# Add brief breath-pause after scene-setting clauses
t = re.sub(r'(ูƒุงู†|ูƒุงู†ุช|ููŠ ูŠูˆู…|ุฐุงุช ูŠูˆู…|once upon|there was)',
r'\1ุŒ', t, flags=re.IGNORECASE)
elif mode == "news":
# Crisp, no trailing breath
t = re.sub(r'\s*โ€ฆ+\s*', '. ', t)
elif mode == "child":
# Short bursts with upward inflection hints
t = re.sub(r'\.\s+([A-Zุง-ูŠ])', r'! \1', t)
# 4. Collapse multiple punctuation
t = re.sub(r'([.!?ุŸุŒ,]){2,}', r'\1', t)
t = re.sub(r'โ€ฆ{2,}', 'โ€ฆ', t)
return t
# โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—
# โ•‘ P H R A S E - L E V E L P R O S O D Y V A R I A T I O N โ•‘
# โ•‘ Each sentence is analysed for emotional content. โ•‘
# โ•‘ Chatterbox parameters (exaggeration, cfg_weight, speed) shift โ•‘
# โ•‘ per-phrase so the delivery feels alive, not machine-read. โ•‘
# โ•‘ Post-generation volume envelopes reinforce the prosodic contour. โ•‘
# โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# โ”€โ”€ Per-emotion Chatterbox deltas โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# (applied on top of the base params; scaled by user's variation_strength)
# exaggeration_delta : shifts Chatterbox's own expressiveness knob
# speed_mult : multiplies our post-generation time-stretch
# cfg_weight_delta : shifts how tightly generation follows the reference
# (lower = looser/more dramatic, higher = more literal)
EMOTION_DELTAS = {
# exagg speed_mult cfg_weight silence_ms
"neutral": ( 0.00, 1.000, 0.00, 110 ),
"question": (-0.05, 0.960, +0.15, 140 ), # careful, rising uncertainty
"exclaim": (+0.35, 1.080, -0.20, 75 ), # faster, more energy
"emphasis": (+0.15, 0.900, +0.10, 155 ), # deliberate, weighty
"sadness": (-0.15, 0.870, +0.10, 185 ), # slow, heavy, restrained
"joy": (+0.30, 1.060, -0.15, 85 ), # bright, forward
"anger": (+0.25, 1.040, -0.10, 95 ), # tense, clipped
"soft": (-0.20, 0.920, +0.15, 160 ), # tender, gentle
"urgency": (+0.30, 1.120, -0.15, 65 ), # fast, alert
"story": (+0.15, 0.950, -0.05, 135 ), # varied, narrative
}
# โ”€โ”€ Keyword patterns (Arabic + English) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
EMOTION_PATTERNS = {
"joy": [
# Arabic
r'ุณุนูŠุฏ|ูุฑุญ|ุฑุงุฆุน|ู…ุฐู‡ู„|ุนุธูŠู…|ู…ู…ุชุงุฒ|ุจุฑุงููˆ|ู…ุจุฑูˆูƒ|ูŠุงู‡|ูŠุง ู„ุฑูˆุน|'
r'ุฌู…ูŠู„|ุจุฏูŠุน|ุฃุญุจ|ู†ุฌุญ|ูˆูู‚|ุงู„ู„ู‡|ูŠุณุนุฏ|ุณุฑูˆุฑ|ุงุจุชู‡ุงุฌ|ุจู‡ุฌุฉ|ู†ุดูˆุฉ',
# English
r'happy|wonderful|amazing|great|excellent|love|joy|fantastic|'
r'brilliant|perfect|congrats|hooray|yay|delightful|terrific',
],
"sadness": [
r'ุญุฒูŠู†|ู„ู„ุฃุณู|ู…ุคุณู|ูˆุฏุงุน|ุฃู„ู…|ูู‚ุฏู†ุง|ุตุนุจ|ู…ุคู„ู…|ุจูƒู‰|ุฏู…ูˆุน|'
r'ุฎุณุฑ|ุฑุญู„|ุบุงุจ|ุงุดุชุงู‚|ุฃูุชู‚ุฏ|ุญุณุฑุฉ|ู…ุฃุณุงุฉ|ู…ุตูŠุจุฉ|ูุฑุงู‚|ูˆุฌุน',
r'sad|unfortunately|sorry|pain|lost|difficult|hard|tears|'
r'miss|regret|grieve|mourn|tragedy|farewell|ache',
],
"anger": [
r'ุบุถุจ|ู„ุง ุฃู‚ุจู„|ู…ุณุชุญูŠู„|ูŠูƒููŠ|ูƒูู‰|ุธู„ู…|ุญุฑุงู…|ู„ุง ูŠุฌูˆุฒ|ุฑูุถ|ุฃุจุฏุงู‹|'
r'ู„ู† ุฃุชุญู…ู„|ุณุฆู…ุช|ุถุงู‚|ุงุญุชุฌ|ุฑูุถ|ู†ู‚ู…ุฉ|ุณุฎุท|ูุณุงุฏ',
r'never|impossible|stop|enough|angry|refuse|unacceptable|'
r'outrage|disgusting|unbelievable|ridiculous|furious',
],
"emphasis": [
r'ุจุงู„ุชุฃูƒูŠุฏ|ุฏูˆู† ุดูƒ|ู‚ุทุนุงู‹|ุฃูƒูŠุฏ|ู„ุง ุดูƒ|ูŠุฌุจ|ุถุฑูˆุฑุฉ|ู…ู‡ู…|ุงู†ุชุจู‡|'
r'ุงุณู…ุน|ู„ุง ุจุฏ|ุญุชู…ุงู‹|ุจู„ุง ุดูƒ|ู…ู† ุงู„ู…ุคูƒุฏ|ุจูˆุถูˆุญ|ุตุฑุงุญุฉู‹|ุฌุฏุงู‹',
r'definitely|absolutely|certainly|must|important|listen|'
r'crucial|critical|clearly|obviously|indeed|precisely',
],
"soft": [
r'ุฑุจู…ุง|ู„ุนู„|ุนุณู‰|ุฃุชู…ู†ู‰|ุฃุฑุฌูˆ|ุจู„ุทู|ุจุฑูู‚|ู‡ุฏูˆุก|ุณู„ุงู…|'
r'ู‡ู†ุงุก|ุฑูˆูŠุฏุงู‹|ุจู‡ุฏูˆุก|ุจุชุฃู†|ุฑููŠู‚|ุญู†ุงู†|ู„ุทูŠู|ุฑุญู…ุฉ|ุฃู…ู„',
r'perhaps|maybe|hopefully|gently|softly|peace|calm|'
r'wish|hope|kindly|tenderly|quietly|sweetly',
],
"urgency": [
r'ุงู„ุขู†|ููˆุฑุงู‹|ุณุฑูŠุนุงู‹|ุนุงุฌู„|ุงู†ุชุจู‡|ุงุญุฐุฑ|ุฎุทุฑ|ุฃุณุฑุน|ุจุณุฑุนุฉ|'
r'ุนุงุฌู„ุงู‹|ููŠ ุงู„ุญุงู„|ู„ุง ุชุชุฃุฎุฑ|ุฅู†ุฐุงุฑ|ุชุญุฐูŠุฑ|ูŠุณุชุนุฌู„|ุนุฌู„ุฉ',
r'now|immediately|quickly|urgent|warning|danger|hurry|'
r'fast|alert|emergency|asap|right away|no time',
],
"story": [
r'ูƒุงู† ูŠุง ู…ูƒุงู†|ุฐุงุช ูŠูˆู…|ููŠ ู‚ุฏูŠู…|ูŠุญูƒู‰ ุฃู†|ูˆููŠ ู„ูŠู„ุฉ|'
r'ุซู… ู‚ุงู„|ูุฃุฌุงุจ|ู†ุธุฑ|ู‚ุฑุฑ|ุงู†ุทู„ู‚|ุชูˆุฌู‡|ูˆุตู„|ู„ู‚ูŠ|ุฑุฃู‰',
r'once upon|there was|long ago|he said|she replied|'
r'suddenly|then he|and so|meanwhile|the next day',
],
}
# โ”€โ”€ Phrase emotion detector โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def analyze_phrase_emotion(phrase: str, lang: str) -> tuple[str, float]:
"""
Detect the dominant emotion in a phrase.
Returns (emotion_name, confidence_0_to_1).
Confidence drives how strongly the deltas are applied.
"""
p = phrase.strip()
# 1. Punctuation-first rules (fastest, highest confidence)
if re.search(r'[ุŸ?]$', p):
return "question", 0.90
count_exclaim = len(re.findall(r'[!]', p))
if count_exclaim >= 2:
return "exclaim", 0.95
if count_exclaim == 1 and len(p) < 60:
return "exclaim", 0.75
# 2. Keyword scan
scores: dict[str, float] = {}
for emotion, patterns in EMOTION_PATTERNS.items():
for pat in patterns:
matches = re.findall(pat, p, re.IGNORECASE)
if matches:
# Score = matches / words โ€” more keywords โ†’ higher confidence
words = max(1, len(p.split()))
scores[emotion] = scores.get(emotion, 0) + len(matches) / words
if scores:
best = max(scores, key=scores.get)
conf = min(0.92, scores[best] * 3.0) # cap at 0.92
return best, conf
# 3. Structural heuristics
if len(p) < 20:
return "emphasis", 0.35 # short phrases tend to be punchy
if p.endswith('โ€ฆ') or p.endswith('...'):
return "soft", 0.50 # trailing ellipsis = trailing/wistful
return "neutral", 0.0
# โ”€โ”€ Per-phrase Chatterbox param builder โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def make_phrase_params(base: dict, emotion: str, conf: float,
variation: float, pos_ratio: float) -> dict:
"""
variation : user slider 0-2 (0=none, 1=natural, 2=dramatic)
pos_ratio : 0.0 = first phrase, 1.0 = last phrase
Returns a NEW params dict with emotion-driven adjustments applied to
Chatterbox's own expressiveness knobs (exaggeration / cfg_weight),
plus our own post-generation `speed` (time-stretch applied after
generation, since Chatterbox has no native speed parameter).
"""
if variation < 0.02:
return base.copy()
d = EMOTION_DELTAS.get(emotion, EMOTION_DELTAS["neutral"])
exagg_d, speed_m, cfg_d, _ = d
strength = conf * variation # 0 โ€ฆ ~1.84
p = base.copy()
p["exaggeration"] = float(np.clip(
base.get("exaggeration", 0.5) + exagg_d * strength, 0.05, 1.8))
p["cfg_weight"] = float(np.clip(
base.get("cfg_weight", 0.5) + cfg_d * strength, 0.0, 1.0))
p["speed"] = float(np.clip(
base.get("speed", 1.0) * (1.0 + (speed_m - 1.0) * strength), 0.60, 1.65))
# โ”€โ”€ Position-based natural drift โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Opening: slightly more controlled (lower exaggeration, slower)
if pos_ratio < 0.08:
p["exaggeration"] = float(np.clip(p["exaggeration"] - 0.05 * variation, 0.05, 1.8))
p["speed"] = float(np.clip(p["speed"] * (1.0 - 0.04 * variation), 0.60, 1.65))
# Closing: natural deceleration (the "landing" of a speech)
if pos_ratio > 0.88:
p["speed"] = float(np.clip(p["speed"] * (1.0 - 0.06 * variation), 0.60, 1.65))
p["exaggeration"] = float(np.clip(p["exaggeration"] - 0.04 * variation, 0.05, 1.8))
# โ”€โ”€ Micro-random jitter: prevent identical params on consecutive โ”€
# neutral phrases (which would sound machine-even)
if emotion == "neutral" and variation > 0.3:
h = abs(hash(base.get("_phrase_hint", ""))) % 1000 / 1000.0
p["exaggeration"] += (h - 0.5) * 0.08 * variation
p["speed"] *= 1.0 + (h - 0.5) * 0.04 * variation
p["exaggeration"] = float(np.clip(p["exaggeration"], 0.05, 1.8))
p["speed"] = float(np.clip(p["speed"], 0.60, 1.65))
return p
# โ”€โ”€ Post-generation phrase volume envelope โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def apply_phrase_dynamics(data: np.ndarray, sr: int,
emotion: str, variation: float) -> np.ndarray:
"""
Shape amplitude envelope after generation.
This adds prosodic *weight* that model parameter variation alone can't provide.
variation : 0-2
"""
if variation < 0.02 or len(data) == 0:
return data
n = len(data)
t = np.linspace(0.0, 1.0, n, dtype=np.float32)
env = np.ones(n, dtype=np.float32)
v = min(variation, 2.0)
if emotion == "question":
# Rising volume in last 25% โ€” mimics upward intonation contour
rise_start = 0.75
env += np.where(t > rise_start,
(t - rise_start) / (1.0 - rise_start) * 0.07 * v, 0)
elif emotion == "exclaim":
# Forward energy burst โ€” louder in first half, taper
env += (1.0 - t) * 0.05 * v
env += np.sin(t * np.pi * 2) * 0.03 * v # subtle tremolo feel
elif emotion == "emphasis":
# Bell curve โ€” maximum weight in the centre
env += np.sin(t * np.pi) * 0.10 * v
elif emotion == "sadness":
# Gradual fade throughout โ€” speech drains away
env -= t * 0.08 * v
env = np.clip(env, 0.70, 1.0)
elif emotion == "joy":
# Bright forward motion
env += t * 0.04 * v + 0.02 * v
elif emotion == "anger":
# Sharp, compressed โ€” no gentle tails
attack = np.where(t < 0.05, t / 0.05, 1.0)
env *= attack
env += 0.04 * v
elif emotion == "soft":
# Gentle, backed-off
env -= 0.06 * v
elif emotion == "urgency":
# Flat high energy โ€” no decay
env += 0.06 * v
elif emotion == "story":
# Slow arc: quiet start, rise, settle
env += np.sin(t * np.pi * 0.7) * 0.05 * v
# Smooth envelope (25 ms window to avoid clicks)
k = max(1, int(0.025 * sr))
env = np.convolve(env, np.ones(k) / k, mode='same').astype(np.float32)
env = np.clip(env, 0.50, 1.30)
result = data * env
# Soft-limit peaks
peak = np.max(np.abs(result))
if peak > 0.95:
result = result / peak * 0.93
return result.astype(np.float32)
def silence_samples(sr: int, ms: int = 120) -> np.ndarray:
"""Short silence gap inserted between sentence chunks."""
return np.zeros(int(sr * ms / 1000), dtype=np.float32)
# โ”€โ”€ Soft Clipping (tanh) โ€” prevents digital distortion โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def apply_speaker_boost(path):
data, sr = sf.read(path)
peak = np.max(np.abs(data))
if peak > 0:
data = data / peak * 0.95
# Soft clipping via hyperbolic tangent โ€” smooth, natural saturation
# Unlike np.clip (hard clipping), tanh curves the peaks gently
data = np.tanh(data * 1.8) * (1.0 / np.tanh(1.8))
sf.write(path, data, sr, subtype='PCM_16')
# โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—
# โ•‘ H U M A N I Z E โ€” Pure-NumPy/SciPy DSP Chain โ•‘
# โ•‘ Simulates recording in a real acoustic environment. โ•‘
# โ•‘ No extra libraries. No IR files. All synthesized algorithmically. โ•‘
# โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# โ”€โ”€ Environment presets โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Each dict drives the parameters of every DSP stage below.
HUMANIZE_PRESETS = {
"studio": dict(
# Clean, slightly padded room โ€” professional voiceover booth
reverb_room=0.06, reverb_damping=0.85, reverb_wet=0.04,
noise_floor=-68, noise_color=0.5,
mic_hp_hz=90, mic_presence_db=1.5, mic_presence_hz=5500,
mic_air_db=-1.0, mic_air_hz=14000,
saturation=0.04, pitch_drift_cents=0.4, jitter_ms=0.3,
breathing=False,
),
"bedroom": dict(
# Untreated small room โ€” YouTube / indie podcast feel
reverb_room=0.22, reverb_damping=0.60, reverb_wet=0.09,
noise_floor=-62, noise_color=0.7,
mic_hp_hz=80, mic_presence_db=2.0, mic_presence_hz=4800,
mic_air_db=-0.5, mic_air_hz=13000,
saturation=0.08, pitch_drift_cents=0.8, jitter_ms=0.6,
breathing=False,
),
"podcast": dict(
# Dynamic-mic, slightly driven โ€” warm, intimate
reverb_room=0.10, reverb_damping=0.78, reverb_wet=0.05,
noise_floor=-65, noise_color=0.6,
mic_hp_hz=100, mic_presence_db=2.5, mic_presence_hz=3800,
mic_air_db=-2.5, mic_air_hz=12000,
saturation=0.14, pitch_drift_cents=0.6, jitter_ms=0.4,
breathing=False,
),
"radio": dict(
# Classic broadcast: bandpass 200โ€“8 kHz, compressed
reverb_room=0.04, reverb_damping=0.90, reverb_wet=0.02,
noise_floor=-72, noise_color=0.3,
mic_hp_hz=200, mic_presence_db=3.0, mic_presence_hz=3000,
mic_air_db=-8.0, mic_air_hz=8000,
saturation=0.18, pitch_drift_cents=0.3, jitter_ms=0.2,
breathing=False,
),
"phone": dict(
# GSM / telephone: narrow band 300โ€“3400 Hz, heavy saturation
reverb_room=0.02, reverb_damping=0.95, reverb_wet=0.01,
noise_floor=-55, noise_color=0.9,
mic_hp_hz=300, mic_presence_db=1.0, mic_presence_hz=2200,
mic_air_db=-20.0, mic_air_hz=3500,
saturation=0.28, pitch_drift_cents=1.2, jitter_ms=1.2,
breathing=False,
),
"cafe": dict(
# Mid-sized reverberant room, background murmur
reverb_room=0.42, reverb_damping=0.45, reverb_wet=0.16,
noise_floor=-54, noise_color=0.8,
mic_hp_hz=70, mic_presence_db=1.0, mic_presence_hz=4000,
mic_air_db=-1.0, mic_air_hz=14000,
saturation=0.06, pitch_drift_cents=1.0, jitter_ms=0.8,
breathing=False,
),
"outdoor": dict(
# Open space โ€” very short reverb, wind-ish noise
reverb_room=0.08, reverb_damping=0.92, reverb_wet=0.03,
noise_floor=-50, noise_color=0.95,
mic_hp_hz=60, mic_presence_db=0.5, mic_presence_hz=3500,
mic_air_db=0.0, mic_air_hz=16000,
saturation=0.05, pitch_drift_cents=1.5, jitter_ms=1.5,
breathing=False,
),
}
# โ”€โ”€ DSP helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _biquad_hp(data: np.ndarray, sr: int, fc: float) -> np.ndarray:
"""2nd-order Butterworth high-pass filter โ€” clamped to safe range."""
from scipy.signal import butter, sosfilt
fc = float(np.clip(fc, 1.0, sr * 0.49)) # never hit Nyquist
sos = butter(2, fc / (sr / 2), btype='high', output='sos')
out = sosfilt(sos, data.astype(np.float64))
if not np.all(np.isfinite(out)):
return data.astype(np.float32)
return out.astype(np.float32)
def _biquad_peak(data: np.ndarray, sr: int, fc: float,
gain_db: float, Q: float = 1.4) -> np.ndarray:
"""Parametric peak EQ biquad โ€” clamped and guarded."""
from scipy.signal import sosfilt
fc = float(np.clip(fc, 20.0, sr * 0.45))
gain_db = float(np.clip(gain_db, -40.0, 20.0))
Q = float(np.clip(Q, 0.1, 20.0))
w0 = 2 * np.pi * fc / sr
A = 10 ** (gain_db / 40.0)
cos = np.cos(w0); sin = np.sin(w0)
alpha = sin / (2 * Q)
b0 = 1 + alpha * A; b1 = -2 * cos; b2 = 1 - alpha * A
a0 = 1 + alpha / A; a1 = -2 * cos; a2 = 1 - alpha / A
if abs(a0) < 1e-10:
return data.astype(np.float32)
sos = np.array([[b0/a0, b1/a0, b2/a0, 1.0, a1/a0, a2/a0]], dtype=np.float64)
if not np.all(np.isfinite(sos)):
return data.astype(np.float32)
out = sosfilt(sos, data.astype(np.float64))
if not np.all(np.isfinite(out)):
return data.astype(np.float32)
return out.astype(np.float32)
def _biquad_shelf_hi(data: np.ndarray, sr: int, fc: float,
gain_db: float) -> np.ndarray:
"""
High-shelf filter (air band roll-off / boost).
Fixed: clamp fc below Nyquist and guard alpha against overflow/NaN.
"""
from scipy.signal import sosfilt
# Clamp cutoff to a safe range (never exceed 90% of Nyquist)
fc = float(np.clip(fc, 20.0, sr * 0.45))
gain_db = float(np.clip(gain_db, -40.0, 20.0))
w0 = 2 * np.pi * fc / sr
A = 10 ** (gain_db / 40.0)
cos = np.cos(w0)
sin = np.sin(w0)
sqA = float(np.sqrt(max(A, 1e-12)))
S = 1.0
# Guard the sqrt argument โ€” clamp to avoid negative/NaN
sqrt_arg = (A + 1.0 / max(A, 1e-12)) * (1.0 / max(S, 1e-9) - 1.0) + 2.0
sqrt_arg = max(sqrt_arg, 0.0)
alpha = sin / 2.0 * np.sqrt(sqrt_arg)
b0 = A * ((A + 1) + (A - 1) * cos + 2 * sqA * alpha)
b1 = -2 * A * ((A - 1) + (A + 1) * cos)
b2 = A * ((A + 1) + (A - 1) * cos - 2 * sqA * alpha)
a0 = (A + 1) - (A - 1) * cos + 2 * sqA * alpha
a1 = 2 * ((A - 1) - (A + 1) * cos)
a2 = (A + 1) - (A - 1) * cos - 2 * sqA * alpha
# Guard against degenerate filter (a0 near zero)
if abs(a0) < 1e-10:
return data.astype(np.float32)
sos = np.array([[b0/a0, b1/a0, b2/a0, 1.0, a1/a0, a2/a0]], dtype=np.float64)
# Final guard: if any coefficient is NaN/Inf, bypass
if not np.all(np.isfinite(sos)):
return data.astype(np.float32)
result = sosfilt(sos, data.astype(np.float64))
# Guard output (should never happen now, but safety net)
if not np.all(np.isfinite(result)):
return data.astype(np.float32)
return result.astype(np.float32)
def _pink_noise(n: int, rng: np.random.Generator) -> np.ndarray:
"""FFT-based pink noise โ€” O(N log N), no sample loops."""
white = rng.standard_normal(n).astype(np.float64)
fft_w = np.fft.rfft(white)
freqs = np.arange(1, len(fft_w) + 1, dtype=np.float64)
fft_w /= np.sqrt(freqs) # 1/sqrt(f) โ†’ pink (1/f power)
pink = np.fft.irfft(fft_w, n=n)
peak = np.max(np.abs(pink))
if peak > 0: pink /= peak
return pink.astype(np.float32)
def _comb_reverb(data: np.ndarray, sr: int,
room: float, damping: float, wet: float) -> np.ndarray:
"""
Schroeder reverb via scipy.signal.lfilter โ€” fully vectorised, no Python loops.
4 parallel comb filters + 2 series allpass.
room : 0โ€“1 (room size; affects delay lengths)
damping : 0โ€“1 (high-frequency absorption)
wet : 0โ€“1 (wet/dry mix)
"""
if wet < 0.001:
return data
from scipy.signal import lfilter
comb_delays_ms = [29.7, 37.1, 41.1, 43.7]
comb_base_gains = [0.805, 0.827, 0.783, 0.764]
allpass_delays_ms = [5.0, 1.7]
allpass_gain = 0.7
scale = 0.5 + room * 0.5
damp_factor = 1.0 - damping * 0.35 # approximate HF absorption
sig64 = data.astype(np.float64)
rev = np.zeros_like(sig64)
for dm, g_base in zip(comb_delays_ms, comb_base_gains):
N = max(2, int(dm * scale * sr / 1000))
g = min(g_base * (0.5 + room * 0.5) * damp_factor, 0.97)
b = np.zeros(N + 1); b[0] = 1.0
a = np.zeros(N + 1); a[0] = 1.0; a[N] = -g
rev += lfilter(b, a, sig64)
rev *= 0.25
for dm in allpass_delays_ms:
N = max(2, int(dm * sr / 1000))
g = allpass_gain
b = np.zeros(N + 1); b[0] = -g; b[N] = 1.0
a = np.zeros(N + 1); a[0] = 1.0; a[N] = -g
rev = lfilter(b, a, rev)
return (data * (1.0 - wet) + rev.astype(np.float32) * wet).astype(np.float32)
def _micro_pitch_drift(data: np.ndarray, sr: int,
cents: float, rng: np.random.Generator) -> np.ndarray:
"""
Subtle continuous pitch drift ยฑcents via nearest-neighbour resampling.
Creates the organic wobble of a real human voice.
"""
if cents < 0.05:
return data
# Generate a slow-moving drift signal (0.1โ€“0.5 Hz sinusoid + noise)
t = np.arange(len(data)) / sr
freq_hz = 0.2 + rng.uniform(0, 0.15)
drift = np.sin(2 * np.pi * freq_hz * t)
drift += 0.3 * rng.standard_normal(len(data)) # add gentle randomness
# Smooth with a 200 ms window
k = max(1, int(0.2 * sr))
drift = np.convolve(drift, np.ones(k)/k, mode='same')
# Scale to ยฑratio
ratio = 2 ** (cents / 1200) # cents โ†’ semitone ratio
factor = 1.0 + (ratio - 1.0) * drift # ยฑratio around 1.0
factor = np.clip(factor, 0.90, 1.10)
# Nearest-neighbour reindex
indices = np.cumsum(factor)
indices = indices / indices[-1] * (len(data) - 1)
indices = np.clip(indices.astype(np.int32), 0, len(data) - 1)
return data[indices].astype(np.float32)
def _timing_jitter(data: np.ndarray, sr: int, jitter_ms: float,
rng: np.random.Generator) -> np.ndarray:
"""
Apply block-level timing jitter: randomly shift 20 ms blocks by ยฑjitter_ms.
Breaks the perfectly even machine cadence of synthetic speech.
"""
if jitter_ms < 0.1:
return data
block = int(0.020 * sr) # 20 ms blocks
out = data.copy()
shift_max = int(jitter_ms * sr / 1000)
for start in range(0, len(data) - block, block):
shift = rng.integers(-shift_max, shift_max + 1)
src_start = max(0, start + shift)
src_end = min(len(data), start + block + shift)
dst_len = min(block, len(data) - start)
src_len = src_end - src_start
n = min(dst_len, src_len)
if n > 0:
out[start:start + n] = data[src_start:src_start + n]
return out
def _soft_saturate(data: np.ndarray, amount: float) -> np.ndarray:
"""
Soft harmonic saturation โ€” adds 2nd/3rd harmonics like analog tape/tube.
amount: 0 = bypass, 1 = heavy saturation
"""
if amount < 0.005:
return data
drive = 1.0 + amount * 6.0 # 1x โ€ฆ 7x overdrive
driven = data * drive
# tanh saturation, then scale back down
sat = np.tanh(driven) / np.tanh(drive)
return (data * (1.0 - amount) + sat * amount).astype(np.float32)
# โ”€โ”€ Master humanize function โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def humanize_audio(src_path: str, out_path: str, preset_name: str,
intensity: float = 1.0, seed: int = 42):
"""
Apply full humanization DSP chain to a WAV file.
intensity: 0.0 = almost dry, 1.0 = full preset, >1.0 = exaggerated.
Each stage is wrapped in a try/except so a failure degrades gracefully
instead of corrupting the output file.
"""
p = HUMANIZE_PRESETS.get(preset_name, HUMANIZE_PRESETS["bedroom"])
rng = np.random.default_rng(seed)
data, sr = sf.read(src_path)
if data.ndim > 1:
data = data.mean(axis=1)
data = data.astype(np.float32)
if len(data) == 0:
raise ValueError("ู…ู„ู ุงู„ุตูˆุช ูุงุฑุบ.")
mix = float(np.clip(intensity, 0.0, 2.0))
def _safe(fn, label, *args, **kwargs):
"""Run fn(*args, **kwargs), return original data on failure."""
nonlocal data
try:
result = fn(*args, **kwargs)
# Reject NaN / Inf output โ€” fall back to unchanged
if result is not None and len(result) > 0 and np.all(np.isfinite(result)):
data = result.astype(np.float32)
else:
print(f"[humanize] {label}: non-finite output โ€” stage skipped")
except Exception as e:
print(f"[humanize] {label}: {e} โ€” stage skipped")
# 1. Mic coloring โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
_safe(_biquad_hp, "HP filter", data, sr, p["mic_hp_hz"])
if abs(p["mic_presence_db"]) > 0.1:
_safe(_biquad_peak, "Peak EQ", data, sr,
p["mic_presence_hz"], p["mic_presence_db"] * mix, Q=1.2)
if abs(p["mic_air_db"]) > 0.1:
_safe(_biquad_shelf_hi, "Hi-shelf", data, sr,
p["mic_air_hz"], p["mic_air_db"] * mix)
# 2. Harmonic saturation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
_safe(_soft_saturate, "Saturation", data, p["saturation"] * mix)
# 3. Timing jitter โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
_safe(_timing_jitter, "Jitter", data, sr, p["jitter_ms"] * mix, rng)
# 4. Micro pitch drift โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
_safe(_micro_pitch_drift, "Pitch drift", data, sr,
p["pitch_drift_cents"] * mix, rng)
# 5. Room reverb โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
_safe(_comb_reverb, "Reverb", data, sr,
p["reverb_room"], p["reverb_damping"], p["reverb_wet"] * mix)
# 6. Noise floor โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
try:
floor_db = p["noise_floor"] + (1.0 - mix) * 20
floor_db = float(np.clip(floor_db, -96.0, -30.0))
noise_amp = 10 ** (floor_db / 20.0)
color = float(np.clip(p["noise_color"], 0.0, 1.0))
pink = _pink_noise(len(data), rng)
white = rng.standard_normal(len(data)).astype(np.float32)
noise_mix = pink * color + white * (1.0 - color)
nm_peak = np.max(np.abs(noise_mix))
if nm_peak > 1e-9:
noise_mix = noise_mix / nm_peak * noise_amp
candidate = data + noise_mix.astype(np.float32)
if np.all(np.isfinite(candidate)):
data = candidate
except Exception as e:
print(f"[humanize] Noise floor: {e} โ€” stage skipped")
# 7. Final normalize + soft ceiling โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
peak = float(np.max(np.abs(data)))
if peak > 0:
data = data / peak * 0.92
# tanh soft-limiter (can't produce NaN on finite input)
data = (np.tanh(data * 1.1) * (1.0 / np.tanh(1.1))).astype(np.float32)
sf.write(out_path, data, sr, subtype='PCM_16')
# โ”€โ”€ Text Splitting for Real Progress โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def split_text_sentences(text: str, max_chunk: int = 280) -> list:
"""Split text into sentence-sized chunks with a max character limit."""
parts = re.split(r'(?<=[.!?ุŸ\nุŒ])\s+', text.strip())
chunks, cur = [], ""
for p in parts:
if not p.strip():
continue
if len(cur) + len(p) + 1 <= max_chunk:
cur = (cur + " " + p).strip() if cur else p
else:
if cur:
chunks.append(cur)
if len(p) > max_chunk:
# Sub-split on comma/semicolon
sub_parts = re.split(r'(?<=[ุŒ,;])\s+', p)
sc = ""
for s in sub_parts:
if len(sc) + len(s) + 1 <= max_chunk:
sc = (sc + " " + s).strip() if sc else s
else:
if sc: chunks.append(sc)
sc = s
if sc: chunks.append(sc)
cur = ""
else:
cur = p
if cur:
chunks.append(cur)
return chunks if chunks else [text]
# โ”€โ”€ Post-generation speed control โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Chatterbox has no native "speed" knob (unlike XTTS). We apply it
# ourselves via polyphase resampling โ€” the same technique the audio
# editor's Speed slider already uses in /edit_audio, so the two stay
# consistent. Trade-off: this shifts pitch slightly at extreme values,
# same as before.
def _resample_speed(data: np.ndarray, sr: int, factor: float) -> np.ndarray:
if abs(factor - 1.0) < 0.01 or len(data) == 0:
return data
try:
from scipy.signal import resample_poly
from math import gcd
p = max(1, round(factor * 1000))
q = 1000
g = gcd(p, q)
return resample_poly(data, q // g, p // g).astype(np.float32)
except Exception as e:
print(f"[speed] resample failed: {e} โ€” returning unmodified audio")
return data.astype(np.float32)
# โ”€โ”€ Voice prompt preparation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# XTTS accepted a LIST of speaker_wav files. Chatterbox's `generate()`
# takes exactly one `audio_prompt_path`. If more than one reference clip
# was supplied (main sample + "extra sample" + library folder with
# several files), we concatenate them into a single prompt clip, capped
# at 30s (plenty for cloning, keeps prompt-encoding fast).
def prepare_voice_prompt(refs: list) -> str:
if not refs:
raise RuntimeError("ู„ุง ุชูˆุฌุฏ ุนูŠู‘ู†ุฉ ุตูˆุชูŠุฉ ู…ุฑุฌุนูŠุฉ.")
if len(refs) == 1:
return refs[0]
MAX_SEC = 30
arrays, sr_out = [], None
for p in refs:
try:
data, sr = sf.read(p)
if data.ndim > 1:
data = data.mean(axis=1)
if sr_out is None:
sr_out = sr
elif sr != sr_out:
from scipy.signal import resample
data = resample(data, int(len(data) * sr_out / sr))
arrays.append(data.astype(np.float32))
arrays.append(np.zeros(int(sr_out * 0.15), dtype=np.float32)) # 150ms gap
except Exception as e:
print(f"[voice_prompt] skipped {p}: {e}")
if not arrays:
return refs[0]
combined = np.concatenate(arrays)
max_samples = int(MAX_SEC * (sr_out or 24000))
if len(combined) > max_samples:
combined = combined[:max_samples]
out_path = f"/tmp/prompt_{uuid.uuid4().hex}.wav"
sf.write(out_path, combined, sr_out or 24000, subtype="PCM_16")
return out_path
# โ”€โ”€ Core TTS call (Chatterbox Multilingual) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def run_tts(text, refs, lang, tts_params, split, out_path):
"""
`refs` must already be resolved to a single-element list (callers go
through run_tts_chunked, which prepares the voice prompt ONCE and
reuses it across every phrase/chunk instead of re-concatenating
reference clips on every sentence).
`split` is accepted for interface compatibility with the old XTTS
call signature but has no effect โ€” chunking is handled entirely by
run_tts_chunked() before this function is ever called.
"""
if not xtts_ready or xtts is None:
raise RuntimeError("ุงู„ู†ู…ูˆุฐุฌ ู„ุง ูŠุฒุงู„ ูŠูุญู…ูŽู‘ู„ุŒ ูŠูุฑุฌู‰ ุงู„ุงู†ุชุธุงุฑ ู‚ู„ูŠู„ุงู‹ ุซู… ุงู„ู…ุญุงูˆู„ุฉ ู…ุฌุฏุฏุงู‹.")
prompt_path = prepare_voice_prompt(refs)
speed = float(tts_params.get("speed", 1.0))
gen_kwargs = {
"language_id": lang,
"audio_prompt_path": prompt_path,
"exaggeration": float(tts_params.get("exaggeration", 0.5)),
"cfg_weight": float(tts_params.get("cfg_weight", 0.5)),
"temperature": float(tts_params.get("temperature", 0.8)),
}
# Defensive: only pass kwargs the installed Chatterbox version accepts.
if _generate_valid_params:
gen_kwargs = {k: v for k, v in gen_kwargs.items() if k in _generate_valid_params}
wav = xtts.generate(text, **gen_kwargs)
data = wav.detach().cpu().numpy() if hasattr(wav, "detach") else np.asarray(wav)
if data.ndim > 1:
data = data.mean(axis=0)
data = data.astype(np.float32)
if abs(speed - 1.0) > 0.01:
data = _resample_speed(data, xtts.sr, speed)
sf.write(out_path, data, xtts.sr, subtype="PCM_16")
@spaces.GPU(duration=180)
def run_tts_chunked(text, refs, lang, base_params,
out_path, job_id,
silence_ms: int = 110,
variation: float = 0.0,
) -> bool:
"""
Split text โ†’ per-phrase emotion analysis โ†’ per-phrase Chatterbox params
โ†’ per-phrase dynamics shaping โ†’ silence gaps โ†’ merge.
variation=0 โ†’ all chunks use base_params (original behaviour).
Returns True on success, False if cancelled.
"""
cancel_ev = cancel_events.get(job_id)
chunks = split_text_sentences(text) if len(text) > 280 else [text]
total = len(chunks)
job_progress[job_id] = {"current": 0, "total": total, "status": "running"}
# Build the voice prompt ONCE โ€” reused across every chunk instead of
# re-concatenating reference clips on every sentence.
prompt_path = prepare_voice_prompt(refs)
prompt_refs = [prompt_path]
is_temp_prompt = prompt_path.startswith("/tmp/prompt_")
try:
# โ”€โ”€ Single-chunk fast path โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
if total == 1:
if cancel_ev and cancel_ev.is_set():
job_progress[job_id]["status"] = "cancelled"; return False
phrase_p = base_params
sc_emotion = "neutral"
if variation > 0.02:
sc_emotion, sc_conf = analyze_phrase_emotion(text, lang) # ONE call
phrase_p = make_phrase_params(dict(base_params, _phrase_hint=text[:30]),
sc_emotion, sc_conf, variation, 0.5)
run_tts(text, prompt_refs, lang, phrase_p, True, out_path)
if variation > 0.02 and sc_emotion != "neutral":
data, sr = sf.read(out_path)
if data.ndim > 1: data = data.mean(axis=1)
data = apply_phrase_dynamics(data.astype(np.float32), sr, sc_emotion, variation)
sf.write(out_path, data, sr, subtype="PCM_16")
job_progress[job_id] = {"current": 1, "total": 1, "status": "done", "ts": time.time()}
return True
# โ”€โ”€ Multi-chunk expressive path โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
emotions = [analyze_phrase_emotion(c, lang) for c in chunks]
tmp_files, arrays, sr_out = [], [], None
try:
for i, (chunk, (emotion, conf)) in enumerate(zip(chunks, emotions)):
if cancel_ev and cancel_ev.is_set():
job_progress[job_id]["status"] = "cancelled"; return False
pos = i / max(total - 1, 1)
phrase_p = make_phrase_params(
dict(base_params, _phrase_hint=chunk[:30]),
emotion, conf, variation, pos)
gap_ms = EMOTION_DELTAS.get(emotion, EMOTION_DELTAS["neutral"])[3]
actual_gap = int(silence_ms * 0.4 + gap_ms * 0.6) if variation > 0.1 else silence_ms
tmp = f"/tmp/ch_{job_id}_{i}.wav"
run_tts(chunk, prompt_refs, lang, phrase_p, False, tmp)
tmp_files.append(tmp)
data, sr = sf.read(tmp)
if sr_out is None: sr_out = sr
if data.ndim > 1: data = data.mean(axis=1)
data = data.astype(np.float32)
if variation > 0.02:
data = apply_phrase_dynamics(data, sr, emotion, variation)
arrays.append(data)
arrays.append(silence_samples(sr, actual_gap))
job_progress[job_id]["current"] = i + 1
combined = np.concatenate(arrays) if arrays else np.zeros(sr_out or 22050, np.float32)
sf.write(out_path, combined, sr_out or 22050, subtype="PCM_16")
job_progress[job_id]["status"] = "done"
job_progress[job_id]["ts"] = time.time()
return True
finally:
for tf in tmp_files:
try: os.remove(tf)
except: pass
finally:
if is_temp_prompt:
try: os.remove(prompt_path)
except: pass
def user_voice_dir(username: str) -> str:
d = os.path.join(VOICE_LIB, safe_username(username))
os.makedirs(d, exist_ok=True)
return d
def collect_refs(files, voice_name, username):
paths = []
for f in files:
if not f or not getattr(f, "filename", None): continue
p = f"/tmp/ref_{uuid.uuid4().hex}_{f.filename}"
with open(p, "wb") as buf: shutil.copyfileobj(f.file, buf)
paths.append(p)
if voice_name and voice_name not in ("", "null", "none", "undefined"):
lib_dir = os.path.join(user_voice_dir(username), voice_name)
if os.path.isdir(lib_dir):
paths += [os.path.join(lib_dir, fn) for fn in sorted(os.listdir(lib_dir))
if fn.lower().endswith((".wav", ".mp3", ".flac", ".ogg"))]
return paths
def cleanup(paths):
for p in paths:
if p and p.startswith("/tmp/"):
try: os.remove(p)
except: pass
# โ”€โ”€ App (gradio.Server โ€” FastAPI-compatible, no Gradio UI mounted) โ”€โ”€โ”€โ”€โ”€
app = Server(title="XTTS Voice Studio CPU Optimized")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
PUBLIC_PATHS = {"/login", "/signup", "/favicon.ico", "/model_status"}
@app.middleware("http")
async def auth_gate(request: Request, call_next):
"""Blocks every route (API + pages) unless the visitor has a valid
session, except the login page itself and the health-check endpoint.
Also fences off /admin/* to the developer/admin role only.
`/gradio_api/*` is gradio.Server's own internal plumbing (startup
check, queue, ZeroGPU broker calls, etc.) and must stay open โ€” it's
not user-facing content, and gradio.Server won't even finish
starting if these routes require a login."""
path = request.url.path
if path in PUBLIC_PATHS or path.startswith("/gradio_api"):
return await call_next(request)
user = request.session.get("user")
if not user:
if request.method == "GET" and "text/html" in request.headers.get("accept", ""):
return RedirectResponse(url="/login")
return JSONResponse({"detail": "ูŠุฌุจ ุชุณุฌูŠู„ ุงู„ุฏุฎูˆู„ ุฃูˆู„ุงู‹."}, status_code=401)
if path.startswith("/admin") and user.get("role") != "admin":
return JSONResponse({"detail": "ู‡ุฐู‡ ุงู„ุตูุญุฉ ู…ุฎุตู‘ุตุฉ ู„ุญุณุงุจ ุงู„ู…ุทูˆู‘ุฑ ูู‚ุท."}, status_code=403)
return await call_next(request)
# SessionMiddleware is added AFTER auth_gate on purpose. Starlette treats
# whichever middleware was registered LAST as the OUTERMOST layer (it
# wraps every other one and therefore runs FIRST on each request) โ€” so
# adding it here guarantees request.session exists by the time auth_gate
# runs. Adding it earlier (before auth_gate) is what caused
# "SessionMiddleware must be installed to access request.session".
# Hugging Face Spaces are normally viewed inside an iframe on
# huggingface.co, so โ€” from the browser's point of view โ€” the Space's
# own hf.space origin is a *cross-site* (third-party) context relative
# to the page in the address bar, even for requests the iframe makes to
# itself. A `same_site="lax"` cookie is dropped in that situation, so
# the session looks empty on the very next request and auth_gate bounces
# the user straight back to /login โ€” a login loop. `same_site="none"`
# fixes this, but browsers require `Secure` on any SameSite=None cookie,
# hence `https_only=True` (Spaces are always served over HTTPS, so this
# is safe; only disable it if you run this file locally over plain http).
app.add_middleware(
SessionMiddleware, secret_key=SESSION_SECRET, session_cookie="xtts_session",
max_age=60 * 60 * 24 * 7, same_site="none", https_only=True,
)
# โ”€โ”€ Supported languages โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Matches Chatterbox Multilingual V3's 23 supported languages exactly
# (github.com/resemble-ai/chatterbox โ€” "Supported Languages"). Arabic is
# listed first since it's this app's primary audience. `LANG_MAP` is used
# ONLY for the /dub translation step (deep-translator's Google Translate
# codes differ slightly from Chatterbox's language_id codes in a couple
# of cases) โ€” the TTS call itself always uses the raw key below.
LANGUAGES = {
"ar":"ุงู„ุนุฑุจูŠุฉ","en":"English","es":"Espaรฑol","fr":"Franรงais",
"de":"Deutsch","it":"Italiano","pt":"Portuguรชs","ru":"ะ ัƒััะบะธะน",
"zh":"ไธญๆ–‡","ja":"ๆ—ฅๆœฌ่ชž","ko":"ํ•œ๊ตญ์–ด","tr":"Tรผrkรงe",
"nl":"Nederlands","pl":"Polski","hi":"เคนเคฟเคจเฅเคฆเฅ€","he":"ืขื‘ืจื™ืช",
"da":"Dansk","el":"ฮ•ฮปฮปฮทฮฝฮนฮบฮฌ","fi":"Suomi","ms":"Bahasa Melayu",
"no":"Norsk","sv":"Svenska","sw":"Kiswahili",
}
LANG_MAP = {"zh":"zh-CN"} # deep-translator target-code override (dub only)
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# F R O N T E N D
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
HTML = r"""<!DOCTYPE html>
<html lang="ar">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>Chatterbox Voice Studio</title>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500;600&family=Tajawal:wght@300;400;500;700&display=swap" rel="stylesheet"/>
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<style>
/* โ•โ•โ• DESIGN SYSTEM โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */
:root{
--bg:#090a0d; --bg2:#0f1117; --surface:#141720; --surface2:#1b1f2e;
--border:rgba(255,255,255,.07); --border-hi:rgba(255,255,255,.15);
--amber:#f0a500; --amber2:#ffc94d; --amber-glow:rgba(240,165,0,.15);
--green:#22c55e; --red:#ef4444; --blue:#3b82f6; --purple:#a78bfa;
--text:#e2e8f0; --text2:#94a3b8; --text3:#475569;
--mono:'IBM Plex Mono',monospace; --sans:'Tajawal',sans-serif;
--radius:10px; --radius-sm:6px;
--sidebar:220px; --topbar:56px;
--trans:.18s cubic-bezier(.4,0,.2,1);
}
[data-theme=light]{
--bg:#f1f5f9; --bg2:#e8edf4; --surface:#ffffff; --surface2:#f8fafc;
--border:rgba(0,0,0,.08); --border-hi:rgba(0,0,0,.15);
--text:#1e293b; --text2:#64748b; --text3:#94a3b8;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body,#root{height:100%}
body{font-family:var(--sans);background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased}
::-webkit-scrollbar{width:4px;height:4px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:var(--border-hi);border-radius:4px}
/* โ•โ•โ• LAYOUT โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */
.app{display:grid;grid-template-columns:var(--sidebar) 1fr;grid-template-rows:var(--topbar) 1fr;height:100%;overflow:hidden}
.sidebar{grid-row:1/-1;background:var(--bg2);border-right:1px solid var(--border);
display:flex;flex-direction:column;padding:16px 0;overflow:hidden;z-index:20;
transition:transform .25s cubic-bezier(.4,0,.2,1)}
.sidebar-logo{padding:0 16px 20px;border-bottom:1px solid var(--border);margin-bottom:8px}
.sidebar-logo-text{font-family:var(--mono);font-size:15px;font-weight:600;letter-spacing:-.02em}
.sidebar-logo-text .hi{color:var(--amber)}
.sidebar-logo-sub{font-family:var(--mono);font-size:9px;color:var(--text3);letter-spacing:.1em;text-transform:uppercase;margin-top:2px}
.sidebar-section{padding:4px 10px;margin-top:4px}
.sidebar-section-label{font-family:var(--mono);font-size:9px;color:var(--text3);letter-spacing:.12em;text-transform:uppercase;padding:0 6px;margin-bottom:4px}
.nav-item{display:flex;align-items:center;gap:10px;padding:9px 10px;border-radius:var(--radius-sm);
cursor:pointer;transition:background var(--trans),color var(--trans);color:var(--text2);font-size:13px;font-weight:500;
border:1px solid transparent;user-select:none}
.nav-item .ni-icon{font-size:15px;width:20px;text-align:center;flex-shrink:0}
.nav-item:hover{background:var(--surface);color:var(--text)}
.nav-item.active{background:var(--amber-glow);color:var(--amber);border-color:rgba(240,165,0,.2)}
.sidebar-footer{margin-top:auto;padding:12px 16px;border-top:1px solid var(--border)}
.status-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}
.status-dot.green{background:var(--green);box-shadow:0 0 6px var(--green)}
.status-dot.red{background:var(--red)}
.status-row{display:flex;align-items:center;gap:8px;font-family:var(--mono);font-size:10px;color:var(--text3);margin-bottom:4px}
.topbar{grid-column:2;display:flex;align-items:center;justify-content:space-between;
padding:0 24px;border-bottom:1px solid var(--border);background:var(--bg2);z-index:10}
.topbar-title{font-family:var(--mono);font-size:13px;font-weight:500;color:var(--text2);letter-spacing:.05em}
.topbar-title span{color:var(--amber);font-weight:600}
.topbar-right{display:flex;align-items:center;gap:10px}
.hamburger{display:none;flex-direction:column;gap:4px;cursor:pointer;padding:6px;
border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--surface2)}
.hamburger span{display:block;width:18px;height:2px;background:var(--text2);border-radius:1px;
transition:all .2s}
.main{overflow-y:auto;padding:28px;background:var(--bg);grid-column:2}
.main-inner{max-width:900px;margin:0 auto;display:flex;flex-direction:column;gap:20px}
.sidebar-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:19}
/* โ•โ•โ• PROGRESS REAL โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•= */
.progress-wrap{margin-bottom:8px}
.progress-label{font-family:var(--mono);font-size:11px;color:var(--text3);margin-bottom:6px;
display:flex;justify-content:space-between;align-items:center}
.progress-track{height:6px;background:var(--surface2);border-radius:3px;overflow:hidden;
border:1px solid var(--border)}
.progress-fill{height:100%;border-radius:3px;background:linear-gradient(90deg,var(--amber),var(--amber2));
transition:width .35s ease;min-width:4px}
.progress-indeterminate{animation:prog-ind 1.4s ease-in-out infinite}
@keyframes prog-ind{0%{width:0%;margin-right:100%}50%{width:50%;margin-right:50%}100%{width:0%;margin-right:0%}}
/* โ•โ•โ• CARDS โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden}
.card-header{display:flex;align-items:center;justify-content:space-between;
padding:14px 18px;border-bottom:1px solid var(--border);gap:12px}
.card-title{font-family:var(--mono);font-size:11px;font-weight:500;color:var(--text2);
letter-spacing:.1em;text-transform:uppercase;display:flex;align-items:center;gap:8px}
.card-body{padding:18px}
/* โ•โ•โ• FORM โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */
.textarea{width:100%;background:transparent;border:none;color:var(--text);
font-family:var(--sans);font-size:15px;line-height:1.75;resize:none;outline:none;
min-height:140px;padding:0}
.textarea::placeholder{color:var(--text3)}
.char-bar{display:flex;justify-content:space-between;align-items:center;
padding:10px 18px;border-top:1px solid var(--border);background:var(--bg2)}
.char-count{font-family:var(--mono);font-size:11px;color:var(--text3)}
.char-count.warn{color:#f59e0b}
.char-count.limit{color:var(--red)}
select{background:var(--surface2);border:1px solid var(--border);color:var(--text);
border-radius:var(--radius-sm);padding:6px 28px 6px 10px;font-family:var(--sans);
font-size:13px;outline:none;cursor:pointer;-webkit-appearance:none;
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%2394a3b8'/%3E%3C/svg%3E");
background-repeat:no-repeat;background-position:right 8px center}
select:focus{border-color:var(--amber)}
input[type=text]{background:var(--surface2);border:1px solid var(--border);color:var(--text);
border-radius:var(--radius-sm);padding:8px 12px;font-family:var(--sans);font-size:13px;
outline:none;width:100%;transition:border-color var(--trans)}
input[type=text]:focus{border-color:var(--amber)}
input[type=text]::placeholder{color:var(--text3)}
/* โ•โ•โ• SLIDERS โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•= */
.slider-track{position:relative;height:4px;border-radius:2px;background:var(--surface2);cursor:pointer}
.slider-fill{position:absolute;left:0;top:0;height:100%;border-radius:2px;
background:linear-gradient(90deg,var(--amber),var(--amber2));pointer-events:none;transition:width .05s}
input[type=range]{position:absolute;inset:0;width:100%;opacity:0;height:4px;cursor:pointer;margin:0;-webkit-appearance:none}
.slider-wrap{padding:2px 0}
.slider-meta{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px}
.slider-label{font-family:var(--mono);font-size:11px;color:var(--text2)}
.slider-value{font-family:var(--mono);font-size:11px;color:var(--amber);
background:var(--amber-glow);border:1px solid rgba(240,165,0,.25);
border-radius:4px;padding:1px 7px}
.slider-hints{display:flex;justify-content:space-between;margin-top:6px}
.slider-hint{font-family:var(--mono);font-size:9px;color:var(--text3);letter-spacing:.05em}
/* โ•โ•โ• TOGGLE โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•= */
.toggle{display:flex;align-items:center;gap:10px;cursor:pointer;user-select:none}
.toggle-track{width:38px;height:21px;border-radius:11px;background:var(--surface2);
border:1px solid var(--border);position:relative;transition:background var(--trans),border-color var(--trans);flex-shrink:0}
.toggle-track.on{background:var(--amber);border-color:var(--amber)}
.toggle-thumb{position:absolute;width:15px;height:15px;border-radius:50%;background:#fff;
top:2px;left:2px;transition:left var(--trans);box-shadow:0 1px 3px rgba(0,0,0,.4)}
.toggle-track.on .toggle-thumb{left:19px}
.toggle-label{font-size:13px;color:var(--text2)}
/* โ•โ•โ• BUTTONS โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•= */
.btn-primary{background:var(--amber);color:#000;font-family:var(--sans);font-weight:700;
font-size:15px;border:none;border-radius:var(--radius);padding:14px 28px;cursor:pointer;
width:100%;display:flex;align-items:center;justify-content:center;gap:10px;
transition:opacity var(--trans),transform var(--trans);letter-spacing:.01em}
.btn-primary:hover:not(:disabled){opacity:.88}
.btn-primary:active:not(:disabled){transform:scale(.98)}
.btn-primary:disabled{opacity:.35;cursor:not-allowed}
.btn-cancel{background:rgba(239,68,68,.12);color:var(--red);font-family:var(--sans);font-weight:600;
font-size:14px;border:1px solid rgba(239,68,68,.35);border-radius:var(--radius);padding:10px 20px;
cursor:pointer;width:100%;display:flex;align-items:center;justify-content:center;gap:8px;
transition:background var(--trans)}
.btn-cancel:hover{background:rgba(239,68,68,.22)}
.btn-secondary{background:var(--surface2);color:var(--text2);font-family:var(--sans);
font-size:13px;border:1px solid var(--border);border-radius:var(--radius-sm);
padding:8px 14px;cursor:pointer;display:flex;align-items:center;gap:6px;
transition:color var(--trans),border-color var(--trans);white-space:nowrap}
.btn-secondary:hover{color:var(--text);border-color:var(--border-hi)}
.btn-sm{padding:5px 12px;font-size:12px}
.btn-icon-round{width:32px;height:32px;border-radius:50%;background:var(--surface2);
border:1px solid var(--border);cursor:pointer;display:flex;align-items:center;
justify-content:center;font-size:14px;transition:border-color var(--trans)}
.btn-icon-round:hover{border-color:var(--amber)}
/* โ•โ•โ• BADGES โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•= */
.badge{display:inline-flex;align-items:center;gap:4px;font-family:var(--mono);font-size:10px;
border-radius:20px;padding:2px 9px;white-space:nowrap}
.badge-amber{background:var(--amber-glow);border:1px solid rgba(240,165,0,.3);color:var(--amber)}
.badge-green{background:rgba(34,197,94,.1);border:1px solid rgba(34,197,94,.3);color:var(--green)}
.badge-red{background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.3);color:var(--red)}
.badge-blue{background:rgba(59,130,246,.1);border:1px solid rgba(59,130,246,.3);color:var(--blue)}
.badge-purple{background:rgba(167,139,250,.1);border:1px solid rgba(167,139,250,.3);color:var(--purple)}
/* โ•โ•โ• FILE UPLOAD โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•= */
.dropzone{border:1.5px dashed var(--border);border-radius:var(--radius);
padding:16px;text-align:center;cursor:pointer;position:relative;
transition:border-color var(--trans),background var(--trans)}
.dropzone:hover,.dropzone.drag{border-color:var(--amber);background:var(--amber-glow)}
.dropzone input{position:absolute;inset:0;opacity:0;cursor:pointer;font-size:0}
.dropzone-icon{font-size:22px;margin-bottom:6px}
.dropzone-label{font-size:12px;color:var(--text3)}
.dropzone-hint{font-family:var(--mono);font-size:10px;color:var(--text3);margin-top:3px}
.file-chip{display:flex;align-items:center;gap:8px;padding:8px 12px;
background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm)}
.file-chip-icon{font-size:16px}
.file-chip-name{flex:1;font-size:12px;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.file-chip-size{font-family:var(--mono);font-size:10px;color:var(--text3)}
.file-chip-rm{background:none;border:none;color:var(--text3);cursor:pointer;font-size:14px;
line-height:1;padding:2px;transition:color var(--trans)}
.file-chip-rm:hover{color:var(--red)}
/* โ•โ•โ• VOICE CARD โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•= */
.voice-card{display:flex;align-items:center;gap:12px;padding:12px 14px;
border-radius:var(--radius);border:1px solid var(--border);background:var(--surface2);
cursor:pointer;transition:border-color var(--trans),background var(--trans)}
.voice-card:hover{border-color:var(--border-hi)}
.voice-card.selected{border-color:var(--amber);background:var(--amber-glow)}
.voice-avatar{width:36px;height:36px;border-radius:50%;display:flex;align-items:center;
justify-content:center;font-family:var(--mono);font-size:13px;font-weight:600;
flex-shrink:0;border:1.5px solid var(--border)}
.voice-info{flex:1;min-width:0}
.voice-name{font-size:13px;font-weight:600;color:var(--text);margin-bottom:3px;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.voice-tags{display:flex;gap:4px;flex-wrap:wrap}
.voice-check{font-size:16px;color:var(--amber);flex-shrink:0}
/* โ•โ•โ• AUDIO PLAYER โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•= */
.player{background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden}
.player-wave{padding:0 4px;cursor:pointer}
.player-wave canvas{width:100%;height:56px;display:block}
.player-controls{display:flex;align-items:center;gap:12px;padding:12px 16px;border-top:1px solid var(--border)}
.player-play{width:38px;height:38px;border-radius:50%;background:var(--amber);border:none;
cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:14px;flex-shrink:0;
transition:opacity var(--trans);color:#000}
.player-play:hover{opacity:.85}
.player-time{font-family:var(--mono);font-size:11px;color:var(--text2);white-space:nowrap}
.player-seek{flex:1;height:3px;border-radius:2px;background:var(--surface);cursor:pointer;position:relative}
.player-seek-fill{position:absolute;left:0;top:0;height:100%;border-radius:2px;background:var(--amber);transition:width .1s linear}
.player-actions{display:flex;gap:6px}
/* โ•โ•โ• AUDIO EDITOR โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•= */
.editor-card{border:1px solid rgba(240,165,0,.25);background:rgba(240,165,0,.04)}
/* โ•โ•โ• HISTORY โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•= */
.hist-item{display:flex;align-items:center;gap:12px;padding:12px 16px;
border:1px solid var(--border);border-radius:var(--radius);background:var(--surface);
transition:border-color var(--trans)}
.hist-item:hover{border-color:var(--border-hi)}
.hist-text{flex:1;min-width:0}
.hist-preview{font-size:13px;color:var(--text);overflow:hidden;text-overflow:ellipsis;
white-space:nowrap;margin-bottom:5px}
.hist-meta{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
.hist-time{font-family:var(--mono);font-size:10px;color:var(--text3)}
.hist-actions{display:flex;gap:6px;flex-shrink:0}
/* โ•โ•โ• INFO BOX โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•= */
.info-box{padding:12px 16px;border-radius:var(--radius-sm);background:var(--surface2);
border:1px solid var(--border);font-size:13px;color:var(--text2);line-height:1.7;
display:flex;gap:10px}
.info-box .info-icon{font-size:16px;flex-shrink:0;margin-top:1px}
/* โ•โ•โ• GRID โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•= */
.grid-2{display:grid;grid-template-columns:1fr 1fr;gap:14px}
.grid-3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px}
/* โ•โ•โ• TOAST โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•= */
.toast-root{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);
z-index:999;display:flex;flex-direction:column;gap:8px;align-items:center;pointer-events:none}
.toast{display:flex;align-items:center;gap:10px;padding:10px 16px;border-radius:var(--radius);
font-size:13px;font-weight:500;box-shadow:0 8px 32px rgba(0,0,0,.5);
pointer-events:auto;animation:toast-in .25s ease;white-space:nowrap;max-width:90vw}
@keyframes toast-in{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
.toast.success{background:#166534;color:#dcfce7;border:1px solid #15803d}
.toast.error {background:#7f1d1d;color:#fee2e2;border:1px solid #b91c1c}
.toast.info {background:var(--surface2);color:var(--text);border:1px solid var(--border)}
/* โ•โ•โ• WAVE ANIMATION โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•= */
.wave-bars{display:flex;align-items:center;gap:3px;height:18px}
.wave-bars span{width:3px;border-radius:2px;background:currentColor;
animation:wb .9s ease-in-out infinite}
.wave-bars span:nth-child(2){animation-delay:.15s}
.wave-bars span:nth-child(3){animation-delay:.3s}
.wave-bars span:nth-child(4){animation-delay:.15s}
@keyframes wb{0%,100%{height:3px}50%{height:16px}}
/* โ•โ•โ• MISC โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */
.empty{text-align:center;padding:48px 24px;color:var(--text3)}
.empty-icon{font-size:36px;margin-bottom:12px;opacity:.5}
.empty-title{font-size:15px;font-weight:600;color:var(--text2);margin-bottom:6px}
.empty-sub{font-size:13px;line-height:1.6}
.divider{height:1px;background:var(--border);margin:4px 0}
.row{display:flex;align-items:center;gap:10px}
.col{display:flex;flex-direction:column;gap:12px}
.label{font-family:var(--mono);font-size:10px;color:var(--text3);letter-spacing:.1em;text-transform:uppercase;margin-bottom:6px}
.section-gap{display:flex;flex-direction:column;gap:16px}
.text-amber{color:var(--amber)}
.text-muted{color:var(--text2)}
.fz-12{font-size:12px}
.fz-13{font-size:13px}
.mt-4{margin-top:4px}
.mt-8{margin-top:8px}
.mt-12{margin-top:12px}
/* โ•โ•โ• MOBILE RESPONSIVE โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•= */
@media (max-width:768px){
:root{ --sidebar:240px; --topbar:52px; }
.app{ grid-template-columns:1fr; grid-template-rows:var(--topbar) 1fr; }
.sidebar{
position:fixed; top:0; right:0; height:100%;
transform:translateX(100%); transition:transform .28s cubic-bezier(.4,0,.2,1);
z-index:30; border-right:none; border-left:1px solid var(--border);
width:var(--sidebar);
}
.sidebar.mobile-open{ transform:translateX(0); }
.sidebar-overlay{ display:block; opacity:0; pointer-events:none; transition:opacity .28s; }
.sidebar-overlay.visible{ opacity:1; pointer-events:auto; }
.topbar{ grid-column:1; padding:0 16px; }
.topbar-title{ font-size:11px; }
.hamburger{ display:flex; }
.main{ grid-column:1; padding:16px; }
.main-inner{ max-width:100%; }
.grid-2{ grid-template-columns:1fr; }
.grid-3{ grid-template-columns:1fr 1fr; }
.card-header{ padding:12px 14px; flex-wrap:wrap; gap:8px; }
.card-body{ padding:14px; }
.btn-primary{ font-size:14px; padding:12px 20px; }
.player-controls{ padding:10px 12px; gap:8px; }
.hist-actions{ flex-direction:column; gap:4px; }
.char-bar{ flex-direction:column; gap:8px; align-items:flex-start; }
}
@media (max-width:480px){
.grid-3{ grid-template-columns:1fr; }
.topbar-title .title-long{ display:none; }
}
</style>
</head>
<body>
<div id="privacy-banner" style="position:sticky;top:0;z-index:9999;background:#3a2c00;color:#ffd98a;
font-size:12.5px;line-height:1.7;padding:8px 14px;display:flex;gap:10px;align-items:center;
justify-content:space-between;flex-wrap:wrap;border-bottom:1px solid #ffb02255;
font-family:'Segoe UI',Tahoma,Arial,sans-serif" dir="rtl">
<span>๐Ÿ”’ ูŠุชู… ุญูุธ ุงู„ุฃุตูˆุงุช ุงู„ู…ูุฏุฎู„ุฉ (ุงู„ุนูŠู‘ู†ุงุช ุงู„ู…ุฑุฌุนูŠุฉ) ูˆุงู„ู…ุฎุฑุฌุงุช ุงู„ุตูˆุชูŠุฉ ูˆุงู„ู†ุตูˆุต ุงู„ู…ุณุชุฎุฏู…ุฉ ููŠ ุชุฎุฒูŠู†
ุฏุงุฆู… ุฎุงุต ุจุญุณุงุจ <b>USER_PH</b> ูู‚ุท. ู„ุง ูŠุณุชุทูŠุน ุฃูŠ ู…ุณุชุฎุฏู… ุขุฎุฑ ุฑุคูŠุฉ ู…ู„ูุงุชูƒ. ูƒู„ ู…ู‚ุทุน ุตูˆุชูŠ ู…ููˆู„ูŽู‘ุฏ ูŠุญู…ู„
ุจุตู…ุฉ ุชุญู‚ู‚ ุบูŠุฑ ู…ุณู…ูˆุนุฉ (PerTh watermark) ู„ุฅุซุจุงุช ุฃู†ู‡ ู…ูˆู„ูŽู‘ุฏ ุจุงู„ุฐูƒุงุก ุงู„ุงุตุทู†ุงุนูŠ.</span>
<span style="white-space:nowrap">
ADMIN_LINK_PH
<a href="/logout" style="color:#ffd98a;text-decoration:underline">ุชุณุฌูŠู„ ุงู„ุฎุฑูˆุฌ</a>
</span>
</div>
<div id="root"></div>
<script type="text/babel">
const { useState, useEffect, useRef, useCallback, useMemo } = React;
const DEVICE = "DEVICE_PH";
const LANGS = LANGS_PH;
const MAX_CH = 5000;
const fmt2 = v => parseFloat(v).toFixed(2);
const fmtSize = b => b>1048576 ? (b/1048576).toFixed(1)+"MB" : (b/1024).toFixed(0)+"KB";
const fmtTime = s => `${Math.floor(s/60)}:${String(Math.floor(s%60)).padStart(2,"0")}`;
const genId = () => (typeof crypto!=="undefined" && crypto.randomUUID)
? crypto.randomUUID() : (Date.now().toString(36)+Math.random().toString(36).slice(2));
/* โ•โ• PERSISTENT STATE โ€” survives refresh & tab-switch โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */
/**
* useLS(key, def) โ€“ localStorage-backed useState.
* Values are JSON-serialised. File objects (non-serialisable) are
* intentionally NOT persisted โ€” only primitive / plain-object state.
*/
function useLS(key, def) {
const [val, setVal] = useState(()=>{
try {
const raw = localStorage.getItem(key);
return raw !== null ? JSON.parse(raw) : def;
} catch { return def; }
});
const set = useCallback(v => {
setVal(prev => {
const next = typeof v === "function" ? v(prev) : v;
try { localStorage.setItem(key, JSON.stringify(next)); } catch {}
return next;
});
}, [key]);
return [val, set];
}
/** Persist the last generated result so it reloads after a page refresh. */
function usePersistedResult(storageKey) {
const [result, _setResult] = useLS(storageKey, null);
// Verify the file still exists on the server before restoring
const [verified, setVerified] = useState(false);
useEffect(()=>{
if (!result) { setVerified(true); return; }
fetch(result.url, {method:"HEAD"})
.then(r=>{ if(!r.ok) _setResult(null); setVerified(true); })
.catch(()=>{ _setResult(null); setVerified(true); });
}, []);
const setResult = useCallback(v => { _setResult(v); }, []);
return [verified ? result : null, setResult];
}
/* โ•โ• TOAST โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */
let _setToasts = null;
const toast = (type, msg, ms=3500) => {
const id = Date.now();
_setToasts(t => [...t, {id,type,msg}]);
setTimeout(() => _setToasts(t => t.filter(x=>x.id!==id)), ms);
};
function ToastRoot() {
const [toasts, setToasts] = useState([]);
useEffect(()=>{ _setToasts=setToasts; },[]);
return (
<div className="toast-root">
{toasts.map(t=>(
<div key={t.id} className={`toast ${t.type}`}>
<span>{t.type==="success"?"โœ“":t.type==="error"?"โœ•":"โ„น"}</span>
<span>{t.msg}</span>
</div>
))}
</div>
);
}
/* โ•โ• REAL PROGRESS BAR โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */
function ProgressBar({ progress }) {
if (!progress) return null;
const { current, total, status } = progress;
const indeterminate = total <= 1;
const pct = total > 1 ? Math.round((current/total)*100) : 0;
return (
<div className="progress-wrap">
<div className="progress-label">
<span>
{status==="running"
? (total > 1 ? `ุชูˆู„ูŠุฏ ุฌู…ู„ุฉ ${current} ู…ู† ${total}` : "ุฌุงุฑูŠ ุงู„ุชูˆู„ูŠุฏโ€ฆ")
: status==="cancelled" ? "ุชู… ุงู„ุฅู„ุบุงุก"
: ""}
</span>
{total > 1 && <span style={{color:"var(--amber)"}}>{pct}%</span>}
</div>
<div className="progress-track">
{indeterminate
? <div className="progress-fill progress-indeterminate" style={{width:"40%"}}/>
: <div className="progress-fill" style={{width:`${pct}%`}}/>
}
</div>
</div>
);
}
/* โ•โ• AUDIO PLAYER โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */
function AudioPlayer({ url, filename }) {
const canvasRef = useRef(null);
const audioRef = useRef(new Audio());
const actxRef = useRef(null);
const [playing, setPlaying] = useState(false);
const [current, setCurrent] = useState(0);
const [duration, setDuration] = useState(0);
const [drawn, setDrawn] = useState(false);
useEffect(()=>{
const a = audioRef.current;
a.src=url; setPlaying(false); setCurrent(0); setDuration(0); setDrawn(false);
const onMeta = ()=>setDuration(a.duration||0);
const onTime = ()=>setCurrent(a.currentTime);
const onEnd = ()=>{ setPlaying(false); setCurrent(0); a.currentTime=0; };
a.addEventListener("loadedmetadata",onMeta);
a.addEventListener("timeupdate",onTime);
a.addEventListener("ended",onEnd);
drawWaveform(url);
return ()=>{ a.pause();
a.removeEventListener("loadedmetadata",onMeta);
a.removeEventListener("timeupdate",onTime);
a.removeEventListener("ended",onEnd); };
},[url]);
const drawWaveform = async (src)=>{
try{
const res=await fetch(src);
const buf=await res.arrayBuffer();
if(!actxRef.current||actxRef.current.state==="closed")
actxRef.current=new(window.AudioContext||window.webkitAudioContext)();
const actx=actxRef.current;
const decoded=await actx.decodeAudioData(buf.slice(0));
const data=decoded.getChannelData(0);
const canvas=canvasRef.current; if(!canvas) return;
const W=canvas.clientWidth*window.devicePixelRatio||600;
const H=canvas.clientHeight*window.devicePixelRatio||112;
canvas.width=W; canvas.height=H;
const ctx2d=canvas.getContext("2d");
ctx2d.clearRect(0,0,W,H);
const step=Math.ceil(data.length/W), mid=H/2;
ctx2d.strokeStyle="rgba(240,165,0,0.6)"; ctx2d.lineWidth=1;
for(let i=0;i<W;i++){
let min=1,max=-1;
for(let j=0;j<step;j++){const d=data[i*step+j]||0;if(d<min)min=d;if(d>max)max=d;}
ctx2d.beginPath();
ctx2d.moveTo(i,mid+min*mid*0.9);
ctx2d.lineTo(i,mid+max*mid*0.9);
ctx2d.stroke();
}
setDrawn(true);
}catch(e){}
};
const togglePlay=()=>{
const a=audioRef.current;
if(playing){a.pause();setPlaying(false);}
else{a.play().catch(()=>{});setPlaying(true);}
};
const seek=(e)=>{
const rect=e.currentTarget.getBoundingClientRect();
const pct=(e.clientX-rect.left)/rect.width;
audioRef.current.currentTime=pct*(duration||0);
};
const pct=duration>0?(current/duration*100).toFixed(1):0;
return (
<div className="player">
<div className="player-wave" onClick={seek}>
<canvas ref={canvasRef} style={{height:56}}/>
{!drawn&&(
<div style={{display:"flex",alignItems:"center",justifyContent:"center",
height:56,color:"var(--text3)",fontFamily:"var(--mono)",fontSize:11}}>
ุฌุงุฑูŠ ุชุญู„ูŠู„ ุงู„ู…ูˆุฌุฉโ€ฆ
</div>
)}
</div>
<div className="player-controls">
<button className="player-play" onClick={togglePlay}>{playing?"โธ":"โ–ถ"}</button>
<span className="player-time">{fmtTime(current)}</span>
<div className="player-seek" onClick={seek}>
<div className="player-seek-fill" style={{width:`${pct}%`}}/>
</div>
<span className="player-time">{fmtTime(duration)}</span>
<div className="player-actions">
<a href={url} download={filename} style={{textDecoration:"none"}}>
<button className="btn-secondary btn-sm" title="ุชุญู…ูŠู„">โฌ‡ ุชุญู…ูŠู„</button>
</a>
</div>
</div>
</div>
);
}
/* โ•โ• AUDIO EDITOR โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */
function AudioEditor({ filename, onEdited }) {
const [volume, setVolume] = useState(0);
const [speed, setSpeed] = useState(1.0);
const [trimStart, setTrimStart] = useState(0);
const [trimEnd, setTrimEnd] = useState(0);
const [fadeIn, setFadeIn] = useState(0);
const [fadeOut, setFadeOut] = useState(0);
const [normalize, setNormalize] = useState(false);
const [applying, setApplying] = useState(false);
const dirty = volume!==0||speed!==1||trimStart!==0||trimEnd!==0
||fadeIn!==0||fadeOut!==0||normalize;
const reset = ()=>{
setVolume(0);setSpeed(1);setTrimStart(0);setTrimEnd(0);
setFadeIn(0);setFadeOut(0);setNormalize(false);
};
const apply = async ()=>{
setApplying(true);
const fd=new FormData();
fd.append("filename",filename); fd.append("volume_db",volume);
fd.append("speed_factor",speed); fd.append("trim_start",trimStart);
fd.append("trim_end",trimEnd); fd.append("fade_in",fadeIn);
fd.append("fade_out",fadeOut); fd.append("normalize",normalize);
try{
const r=await fetch("/edit_audio",{method:"POST",body:fd});
if(!r.ok){const e=await r.json();throw new Error(e.detail||"ุฎุทุฃ");}
const data=await r.json();
onEdited({url:`/audio/${data.filename}`,filename:data.filename});
reset(); toast("success","โœ… ุชู… ุญูุธ ู…ู„ู ู…ุนุฏูŽู‘ู„ ุฌุฏูŠุฏ");
}catch(e){toast("error",e.message);}
finally{setApplying(false);}
};
return (
<div style={{paddingTop:16}}>
<div className="row" style={{marginBottom:14,justifyContent:"space-between"}}>
<span style={{fontFamily:"var(--mono)",fontSize:10,color:"var(--text2)",
letterSpacing:".1em",textTransform:"uppercase"}}>โœ‚ ุชุนุฏูŠู„ ุงู„ุตูˆุช</span>
{dirty&&<button className="btn-secondary btn-sm"
style={{fontSize:11,color:"var(--text3)"}} onClick={reset}>โ†บ ุฅุนุงุฏุฉ ุถุจุท</button>}
</div>
<div className="col" style={{gap:14}}>
<div className="grid-2" style={{gap:12}}>
<Slider label="ู…ุณุชูˆู‰ ุงู„ุตูˆุช" min={-20} max={20} step={1}
value={volume} onChange={setVolume} left="-20 dB" right="+20 dB"/>
<Slider label="ุณุฑุนุฉ ุงู„ุชุดุบูŠู„" min={0.5} max={2.0} step={0.05}
value={speed} onChange={setSpeed} left="ร—0.5" right="ร—2.0"/>
</div>
<div className="grid-2" style={{gap:12}}>
<Slider label="ู‚ุทุน ู…ู† ุงู„ุจุฏุงูŠุฉ (ุซ)" min={0} max={20} step={0.1}
value={trimStart} onChange={setTrimStart} left="0" right="20 ุซ"/>
<Slider label="ู‚ุทุน ู…ู† ุงู„ู†ู‡ุงูŠุฉ (ุซ)" min={0} max={20} step={0.1}
value={trimEnd} onChange={setTrimEnd} left="0" right="20 ุซ"/>
</div>
<div className="grid-2" style={{gap:12}}>
<Slider label="Fade In (ุซ)" min={0} max={5} step={0.1}
value={fadeIn} onChange={setFadeIn} left="0" right="5 ุซ"/>
<Slider label="Fade Out (ุซ)" min={0} max={5} step={0.1}
value={fadeOut} onChange={setFadeOut} left="0" right="5 ุซ"/>
</div>
<div className="row" style={{justifyContent:"space-between",flexWrap:"wrap",gap:10}}>
<Toggle label="Normalize โ€” ุฑูุน ุฃุนู„ู‰ ุฐุฑูˆุฉ ุฅู„ู‰ 0 dB"
value={normalize} onChange={setNormalize}/>
<button onClick={apply} disabled={applying} style={{
background:dirty?"var(--amber)":"var(--surface2)",
color:dirty?"#000":"var(--text3)",
border:dirty?"none":"1px solid var(--border)",
fontFamily:"var(--sans)",fontWeight:700,fontSize:13,
borderRadius:"var(--radius-sm)",padding:"9px 20px",
cursor:applying?"not-allowed":"pointer",
display:"flex",alignItems:"center",gap:8,
opacity:applying?.6:1,whiteSpace:"nowrap",flexShrink:0,
}}>
{applying
? <><div className="wave-bars">{[1,2,3].map(i=><span key={i}/>)}</div>ุฌุงุฑูŠ ุงู„ุชุนุฏูŠู„โ€ฆ</>
: "โœ… ุชุทุจูŠู‚ ูˆุญูุธ ูƒู…ู„ู ุฌุฏูŠุฏ"}
</button>
</div>
</div>
</div>
);
}
/* โ•โ• HUMANIZE PANEL โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */
const HUMANIZE_PRESETS_UI = [
{ id:"studio", icon:"๐ŸŽ™", label:"ุงุณุชูˆุฏูŠูˆ", desc:"ุจูˆุซ ุงุญุชุฑุงููŠ โ€” ู†ุธูŠูุŒ ู‡ุงุฏุฆุŒ ู…ูˆุซูˆู‚" },
{ id:"bedroom", icon:"๐Ÿ ", label:"ุบุฑูุฉ", desc:"ุบุฑูุฉ ุตุบูŠุฑุฉ ุบูŠุฑ ู…ุนุงู„ูŽุฌุฉ โ€” ูŠูˆุชูŠูˆุจ / ุจูˆุฏูƒุงุณุช ู‡ูˆุงุฉ" },
{ id:"podcast", icon:"๐ŸŽง", label:"ุจูˆุฏูƒุงุณุช", desc:"ู…ูŠูƒุฑูˆููˆู† ุฏูŠู†ุงู…ูŠูƒูŠ ุฏุงูุฆ โ€” ู‚ุฑูŠุจ ูˆุญู…ูŠู…ูŠ" },
{ id:"radio", icon:"๐Ÿ“ป", label:"ุฑุงุฏูŠูˆ", desc:"ุจุซ ูƒู„ุงุณูŠูƒูŠ โ€” ู†ุทุงู‚ ุชุฑุฏุฏูŠ ู…ุญุฏูˆุฏุŒ ุถุบุท ู…ุฑุชูุน" },
{ id:"phone", icon:"๐Ÿ“ฑ", label:"ู‡ุงุชู", desc:"GSM / ุชูŠู„ูŠููˆู† โ€” ู†ุทุงู‚ ุถูŠู‚ 300โ€“3400 ู‡ุฑุชุฒ" },
{ id:"cafe", icon:"โ˜•", label:"ู…ู‚ู‡ู‰", desc:"ุบุฑูุฉ ูˆุงุณุนุฉ ู…ุน ุถุฌูŠุฌ ุฎููŠู ููŠ ุงู„ุฎู„ููŠุฉ" },
{ id:"outdoor", icon:"๐ŸŒฟ", label:"ุฎุงุฑุฌูŠ", desc:"ูุถุงุก ู…ูุชูˆุญ ู…ุน ุฑูŠุญ ูˆุฃุฌูˆุงุก ุทุจูŠุนูŠุฉ" },
];
function HumanizePanel({ filename, onHumanized, storageKey }) {
const sk = storageKey || "x";
const [preset, setPreset] = useLS("hum.preset." + sk, "bedroom");
const [intensity, setIntensity] = useLS("hum.intensity." + sk, 0.8);
const [applying, setApplying] = useState(false);
const [lastOut, setLastOut] = useState(null); // last humanized result
const apply = async ()=>{
setApplying(true);
const fd=new FormData();
fd.append("filename",filename);
fd.append("preset",preset);
fd.append("intensity",intensity);
fd.append("seed", Math.floor(Math.random()*9999));
try{
const r=await fetch("/humanize",{method:"POST",body:fd});
if(!r.ok){const e=await r.json();throw new Error(e.detail||"ุฎุทุฃ");}
const data=await r.json();
const res={url:`/audio/${data.filename}`,filename:data.filename};
setLastOut(res);
onHumanized(res);
toast("success","๐ŸŽ™ ุชู…ุช ุงู„ู…ุญุงูƒุงุฉ ุงู„ุตูˆุชูŠุฉ โ€” ุงุณุชู…ุน ุฅู„ู‰ ุงู„ูุฑู‚");
}catch(e){toast("error",e.message);}
finally{setApplying(false);}
};
return (
<div style={{paddingTop:16}}>
{/* Section header */}
<div className="row" style={{marginBottom:16,gap:10}}>
<span style={{fontFamily:"var(--mono)",fontSize:10,color:"var(--text2)",
letterSpacing:".1em",textTransform:"uppercase"}}>๐ŸŽ™ ุจูŠุฆุฉ ุงู„ุชุณุฌูŠู„ ุงู„ุตูˆุชูŠุฉ</span>
<span className="badge badge-purple">DSP</span>
</div>
{/* Info box */}
<div style={{
padding:"10px 14px",borderRadius:"var(--radius-sm)",
background:"rgba(167,139,250,.07)",border:"1px solid rgba(167,139,250,.2)",
fontSize:12,color:"var(--text2)",lineHeight:1.7,marginBottom:16,
}}>
ุชูุถูŠู ู‡ุฐู‡ ุงู„ุฃุฏุงุฉ ุทุงุจุน ุงู„ุชุณุฌูŠู„ ุงู„ุจุดุฑูŠ ุงู„ุญู‚ูŠู‚ูŠ: ุถุฌูŠุฌ ุงู„ุบุฑูุฉ ุงู„ุทุจูŠุนูŠุŒ ุฏูุก ู…ูŠูƒุฑูˆููˆู†
ุญู‚ูŠู‚ูŠุŒ ุงู†ุฌุฑุงู ุทููŠู ููŠ ุงู„ู†ุจุฑุฉุŒ ุงุฑุชุนุงุด ููŠ ุงู„ุชูˆู‚ูŠุชุŒ ุชุดุจู‘ุน ู‡ุงุฑู…ูˆู†ูŠูƒูŠ (tape/tube)ุŒ
ูˆุตุฏู‰ ุจูŠุฆูŠ โ€” ูƒู„ ุฐู„ูƒ ุจุฏูˆู† ุฅุนุงุฏุฉ ุชูˆู„ูŠุฏ ุงู„ุตูˆุช.
</div>
{/* Preset grid */}
<div className="label" style={{marginBottom:10}}>ุงุฎุชุฑ ุงู„ุจูŠุฆุฉ ุงู„ุตูˆุชูŠุฉ</div>
<div style={{
display:"grid",
gridTemplateColumns:"repeat(auto-fill,minmax(130px,1fr))",
gap:8, marginBottom:20,
}}>
{HUMANIZE_PRESETS_UI.map(p=>(
<div key={p.id} onClick={()=>setPreset(p.id)} title={p.desc}
style={{
display:"flex",flexDirection:"column",gap:5,
padding:"10px 12px",borderRadius:"var(--radius-sm)",
border: preset===p.id
? "1.5px solid var(--amber)"
: "1px solid var(--border)",
background: preset===p.id ? "var(--amber-glow)" : "var(--surface2)",
cursor:"pointer",
transition:"border-color var(--trans),background var(--trans)",
}}>
<div style={{fontSize:22,lineHeight:1}}>{p.icon}</div>
<div style={{
fontSize:13,fontWeight:700,
color:preset===p.id?"var(--amber)":"var(--text)",
}}>{p.label}</div>
<div style={{fontSize:10,color:"var(--text3)",lineHeight:1.4}}>{p.desc}</div>
</div>
))}
</div>
{/* Intensity slider */}
<Slider
label="ุดุฏุฉ ุงู„ุชุฃุซูŠุฑ"
min={0.1} max={2.0} step={0.05}
value={intensity} onChange={setIntensity}
left="ุฎููŠู ุฌุฏุงู‹" right="ู…ุจุงู„ูŽุบ ููŠู‡"
hint="1.0 = ุงู„ู‚ูŠู…ุฉ ุงู„ู…ูˆุตู‰ ุจู‡ุง. ุฃูƒุซุฑ ู…ู† 1.5 ู„ุชุฃุซูŠุฑุงุช ุฏุฑุงู…ูŠุฉ (ู‡ุงุชู ู‚ุฏูŠู… / ุฑุงุฏูŠูˆ ุฎู…ุณูŠู†ุงุช)."
/>
{/* Apply button */}
<button onClick={apply} disabled={applying} style={{
marginTop:18, width:"100%",
background:"linear-gradient(135deg,rgba(167,139,250,.85),rgba(139,92,246,.9))",
color:"#fff", border:"none",
fontFamily:"var(--sans)", fontWeight:700, fontSize:15,
borderRadius:"var(--radius)", padding:"13px 20px",
cursor:applying?"not-allowed":"pointer",
display:"flex", alignItems:"center", justifyContent:"center", gap:10,
opacity:applying?.55:1,
transition:"opacity var(--trans)",
boxShadow:"0 4px 20px rgba(139,92,246,.3)",
}}>
{applying
? <><div className="wave-bars" style={{color:"#fff"}}>
{[1,2,3,4].map(i=><span key={i}/>)}
</div>ุฌุงุฑูŠ ู…ุญุงูƒุงุฉ ุงู„ุจูŠุฆุฉ ุงู„ุตูˆุชูŠุฉโ€ฆ</>
: <>๐ŸŽ™ ุชุทุจูŠู‚ ุงู„ุจูŠุฆุฉ ุงู„ุตูˆุชูŠุฉ โ€” ู…ู„ู ุฌุฏูŠุฏ</>}
</button>
{/* last result mini-badge */}
{lastOut && (
<div style={{marginTop:10,display:"flex",alignItems:"center",gap:8}}>
<span className="badge badge-purple">โœ“ ุขุฎุฑ ู†ุชูŠุฌุฉ</span>
<a href={lastOut.url} download={lastOut.filename}
style={{fontSize:12,color:"var(--purple)",textDecoration:"none"}}>
โฌ‡ {lastOut.filename}
</a>
</div>
)}
</div>
);
}
/* โ•โ• RESULT CARD โ€” shared across all tabs โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */
function ResultCard({ result, onUpdate, badge="โœ“ ุชู… ุงู„ุชูˆู„ูŠุฏ", title="๐Ÿ”Š ุงู„ู†ุชูŠุฌุฉ", storageKey="gen" }) {
const [panel, setPanel] = useLS("rc.panel." + storageKey, "edit");
const [editorKey, setEditorKey] = useState(0);
const handleUpdate = (r)=>{ onUpdate(r); setEditorKey(k=>k+1); };
const TAB_BTN = (id, label) => (
<button onClick={()=>setPanel(id)} style={{
padding:"6px 14px", borderRadius:"var(--radius-sm)",
border: panel===id ? "1.5px solid var(--amber)" : "1px solid var(--border)",
background: panel===id ? "var(--amber-glow)" : "transparent",
color: panel===id ? "var(--amber)" : "var(--text2)",
fontFamily:"var(--sans)", fontSize:12, fontWeight:600,
cursor:"pointer", transition:"all var(--trans)",
}}>{label}</button>
);
return (
<div className="card">
<div className="card-header">
<span className="card-title">{title}</span>
<div className="row" style={{gap:6,flexWrap:"wrap"}}>
<span className="badge badge-green">{badge}</span>
{TAB_BTN("edit","โœ‚ ุชุนุฏูŠู„")}
{TAB_BTN("humanize","๐ŸŽ™ ุจูŠุฆุฉ ุตูˆุชูŠุฉ")}
</div>
</div>
<div className="card-body">
<AudioPlayer url={result.url} filename={result.filename}/>
<div style={{borderTop:"1px solid var(--border)",marginTop:14}}>
{panel==="edit"
? <AudioEditor key={editorKey} filename={result.filename} onEdited={handleUpdate}/>
: <HumanizePanel filename={result.filename} onHumanized={handleUpdate} storageKey={storageKey}/>}
</div>
</div>
</div>
);
}
/* โ•โ• SLIDER โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */
function Slider({ label, min, max, step, value, onChange, left, right, hint }) {
const pct = ((value-min)/(max-min)*100).toFixed(1);
return (
<div>
<div className="slider-meta">
<span className="slider-label">{label}</span>
<span className="slider-value">{fmt2(value)}</span>
</div>
<div className="slider-wrap">
<div className="slider-track">
<div className="slider-fill" style={{width:`${pct}%`}}/>
<input type="range" min={min} max={max} step={step} value={value}
onChange={e=>onChange(parseFloat(e.target.value))}/>
</div>
</div>
{(left||right)&&<div className="slider-hints">
<span className="slider-hint">{left}</span>
<span className="slider-hint">{right}</span>
</div>}
{hint&&<div style={{marginTop:5,fontSize:11,color:"var(--text3)",lineHeight:1.5}}>{hint}</div>}
</div>
);
}
/* โ•โ• TOGGLE โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */
function Toggle({ label, value, onChange, hint }) {
return (
<div>
<div className="toggle" onClick={()=>onChange(!value)}>
<div className={`toggle-track ${value?"on":""}`}><div className="toggle-thumb"/></div>
<span className="toggle-label">{label}</span>
</div>
{hint&&<div style={{marginTop:4,fontSize:11,color:"var(--text3)",lineHeight:1.5,marginRight:48}}>{hint}</div>}
</div>
);
}
/* โ•โ• FILE UPLOAD โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */
function FileUpload({ label, file, onFile, hint }) {
const [drag, setDrag] = useState(false);
return (
<div>
{label&&<div className="label">{label}</div>}
{file ? (
<div className="file-chip">
<span className="file-chip-icon">๐ŸŽ™</span>
<span className="file-chip-name">{file.name}</span>
<span className="file-chip-size">{fmtSize(file.size)}</span>
<button className="file-chip-rm" onClick={()=>onFile(null)}>โœ•</button>
</div>
) : (
<div className={`dropzone ${drag?"drag":""}`}
onDragOver={e=>{e.preventDefault();setDrag(true)}}
onDragLeave={()=>setDrag(false)}
onDrop={e=>{e.preventDefault();setDrag(false);onFile(e.dataTransfer.files[0]);}}>
<input type="file" accept="audio/*" onChange={e=>onFile(e.target.files[0])}/>
<div className="dropzone-icon">๐ŸŽต</div>
<div className="dropzone-label">ุงุณุญุจ ู…ู„ูุงู‹ ุฃูˆ ุงู†ู‚ุฑ ู„ู„ุงุฎุชูŠุงุฑ</div>
{hint&&<div className="dropzone-hint">{hint}</div>}
</div>
)}
</div>
);
}
/* โ•โ• VOICE SELECTOR โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */
const AVATAR_COLORS=[
["#fbbf24","#78350f"],["#34d399","#064e3b"],["#818cf8","#1e1b4b"],
["#f472b6","#500724"],["#38bdf8","#082f49"],["#a78bfa","#2e1065"],
];
function voiceAvatar(name){
const i=name.charCodeAt(0)%AVATAR_COLORS.length;
const [bg,fg]=AVATAR_COLORS[i];
return {bg,fg,initials:name.slice(0,2).toUpperCase()};
}
function VoiceSelector({ voices, selected, onSelect, f1, setF1, f2, setF2,
saveName, setSaveName, onSave, saveStatus }) {
const [open, setOpen] = useState(false);
return (
<div>
<div className="label">ุงู„ุตูˆุช ุงู„ู…ุฑุฌุนูŠ</div>
{selected ? (
<div className="voice-card selected" onClick={()=>setOpen(v=>!v)}>
{(()=>{ const av=voiceAvatar(selected); return (
<div className="voice-avatar" style={{background:av.bg,color:av.fg,borderColor:av.bg}}>
{av.initials}
</div>
);})()}
<div className="voice-info">
<div className="voice-name">{selected}</div>
<div className="voice-tags"><span className="badge badge-green">ู…ู† ุงู„ู…ูƒุชุจุฉ</span></div>
</div>
<span className="voice-check">โœ“</span>
<button className="btn-secondary btn-sm"
onClick={e=>{e.stopPropagation();onSelect(null);}}>ุชุบูŠูŠุฑ</button>
</div>
) : (
<FileUpload label={null} file={f1} onFile={setF1}
hint="WAV ยท MP3 ยท FLAC ยท ู…ุฏุฉ 5 โ€“ 30 ุซุงู†ูŠุฉ ู…ุซุงู„ูŠุฉ"/>
)}
{f1 && !selected && (
<div className="mt-8">
<FileUpload label="ุนูŠู†ุฉ ุฅุถุงููŠุฉ (ุงุฎุชูŠุงุฑูŠ โ€” ุชุญุณู‘ู† ุงู„ุฌูˆุฏุฉ)" file={f2} onFile={setF2}/>
</div>
)}
{f1 && !selected && (
<div className="mt-8 row">
<input type="text" placeholder="ุงุญูุธ ู‡ุฐุง ุงู„ุตูˆุช ููŠ ุงู„ู…ูƒุชุจุฉโ€ฆ"
value={saveName} onChange={e=>setSaveName(e.target.value)} style={{flex:1}}/>
<button className="btn-secondary" onClick={onSave}>ุญูุธ</button>
{saveStatus&&<span style={{fontSize:12,color:"var(--green)"}}>{saveStatus}</span>}
</div>
)}
{voices.length>0 && !selected && (
<div className="mt-12">
<div className="row" style={{marginBottom:10}}>
<div className="label" style={{margin:0,flex:1}}>ุฃูˆ ุงุฎุชุฑ ู…ู† ู…ูƒุชุจุฉ ุงู„ุฃุตูˆุงุช</div>
<button className="btn-secondary btn-sm" onClick={()=>setOpen(v=>!v)}>
{open?"โ–ฒ ุฅุฎูุงุก":"โ–ผ ุนุฑุถ"} ({voices.length})
</button>
</div>
{open&&(
<div className="col" style={{gap:8}}>
{voices.map(v=>{
const av=voiceAvatar(v);
return (
<div key={v} className="voice-card" onClick={()=>{onSelect(v);setOpen(false);}}>
<div className="voice-avatar" style={{background:av.bg,color:av.fg,borderColor:av.bg}}>
{av.initials}
</div>
<div className="voice-info">
<div className="voice-name">{v}</div>
<div className="voice-tags"><span className="badge badge-amber">ู…ูƒุชุจุฉ</span></div>
</div>
<button className="btn-secondary btn-sm"
onClick={e=>{e.stopPropagation();onSelect(v);setOpen(false);}}>ุงุฎุชูŠุงุฑ</button>
</div>
);
})}
</div>
)}
</div>
)}
</div>
);
}
/* โ•โ• LIVE EMOTION BADGE โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */
const EMOJI_MAP={neutral:"๐Ÿ˜",question:"โ“",exclaim:"โ—",emphasis:"๐Ÿ’ฌ",
sadness:"๐Ÿ˜ข",joy:"๐Ÿ˜Š",anger:"๐Ÿ˜ก",soft:"๐ŸŒธ",urgency:"โšก",story:"๐Ÿ“–"};
const ECOLOR_MAP={neutral:"var(--text3)",question:"var(--blue)",exclaim:"var(--amber)",
emphasis:"var(--purple)",sadness:"#7dd3fc",joy:"#fde047",anger:"var(--red)",
soft:"#f9a8d4",urgency:"#fb923c",story:"#86efac"};
function detectEmotionClient(phrase) {
const p = phrase.trim();
if (!p) return "neutral";
if (/[ุŸ?]$/.test(p)) return "question";
const excl = (p.match(/!/g)||[]).length;
if (excl >= 2) return "exclaim";
if (excl === 1 && p.length < 60) return "exclaim";
const patterns = {
joy: /ุณุนูŠุฏ|ูุฑุญ|ุฑุงุฆุน|ู…ุฐู‡ู„|ู…ู…ุชุงุฒ|ู…ุจุฑูˆูƒ|ุฌู…ูŠู„|ุฃุญุจ|ู†ุฌุญ|happy|wonderful|amazing|great|love|joy|fantastic/i,
sadness: /ุญุฒูŠู†|ู„ู„ุฃุณู|ู…ุคุณู|ูˆุฏุงุน|ุฃู„ู…|ูู‚ุฏู†ุง|ุฏู…ูˆุน|ุงุดุชุงู‚|sad|unfortunately|sorry|pain|tears|miss|farewell/i,
anger: /ุบุถุจ|ู…ุณุชุญูŠู„|ูŠูƒููŠ|ุธู„ู…|ุณุฆู…ุช|never|impossible|stop|angry|refuse|furious|outrage/i,
emphasis: /ุจุงู„ุชุฃูƒูŠุฏ|ู‚ุทุนุงู‹|ุฃูƒูŠุฏ|ูŠุฌุจ|ู…ู‡ู…|ุงู†ุชุจู‡|definitely|absolutely|must|important|crucial|clearly/i,
soft: /ุฑุจู…ุง|ู„ุนู„|ุฃุชู…ู†ู‰|ุฃุฑุฌูˆ|ู‡ุฏูˆุก|ุณู„ุงู…|ุญู†ุงู†|perhaps|maybe|hopefully|gently|peace|calm|tenderly/i,
urgency: /ุงู„ุขู†|ููˆุฑุงู‹|ุนุงุฌู„|ุงุญุฐุฑ|ุฎุทุฑ|ุฃุณุฑุน|now|immediately|quickly|urgent|warning|danger|hurry/i,
story: /ูƒุงู† ูŠุง ู…ูƒุงู†|ุฐุงุช ูŠูˆู…|ูŠุญูƒู‰|ุซู… ู‚ุงู„|once upon|there was|long ago|suddenly|meanwhile/i,
};
for (const [em, re] of Object.entries(patterns)) { if (re.test(p)) return em; }
if (/[โ€ฆ]$/.test(p) || p.endsWith("...")) return "soft";
return "neutral";
}
function LiveEmotionBadge({ text, variation }) {
const emotions = useMemo(()=>{
if (!text.trim() || variation < 0.05) return {};
const sentences = text.replace(/\n+/g,' ').split(/[.!?\u061F\u060C]+/).map(s=>s.trim()).filter(Boolean);
const counts = {};
for (const s of sentences) { const em=detectEmotionClient(s); counts[em]=(counts[em]||0)+1; }
if (Object.keys(counts).length > 1) delete counts.neutral;
return counts;
}, [text, variation]);
const entries = Object.entries(emotions).sort((a,b)=>b[1]-a[1]).slice(0,4);
if (!entries.length) return null;
return (
<div style={{display:"flex",alignItems:"center",gap:5,flexWrap:"wrap"}}>
<span style={{fontFamily:"var(--mono)",fontSize:9,color:"var(--text3)",letterSpacing:".08em",textTransform:"uppercase"}}>ู…ูƒุชุดูŽู:</span>
{entries.map(([em,count])=>(
<span key={em} style={{display:"inline-flex",alignItems:"center",gap:3,
padding:"2px 8px",borderRadius:20,background:`${ECOLOR_MAP[em]}22`,
border:`1px solid ${ECOLOR_MAP[em]}55`,color:ECOLOR_MAP[em],
fontFamily:"var(--mono)",fontSize:10}}>
{EMOJI_MAP[em]} {em}{count>1&&<span style={{opacity:.7}}>ร—{count}</span>}
</span>
))}
</div>
);
}
/* โ•โ• EXPRESSION MODE SELECTOR โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */
const EXPRESSION_MODES = [
{ id:"natural", icon:"๐Ÿ˜", label:"ุทุจูŠุนูŠ", hint:"ุงู„ุฅุนุฏุงุฏุงุช ุงู„ูŠุฏูˆูŠุฉ ุชุชุญูƒู… ุจุงู„ูƒุงู…ู„" },
{ id:"warm", icon:"๐Ÿค—", label:"ุฏุงูุฆ", hint:"ู„ูŠู†ุŒ ูˆุฏูˆุฏุŒ ู…ุฑูŠุญ โ€” ู…ุซุงู„ูŠ ู„ู„ู…ุญุชูˆู‰ ุงู„ุชุนู„ูŠู…ูŠ" },
{ id:"excited", icon:"๐ŸŽ‰", label:"ู…ุชุญู…ุณ", hint:"ุฅูŠู‚ุงุน ุณุฑูŠุนุŒ ุทุงู‚ุฉ ุนุงู„ูŠุฉ โ€” ู„ู„ุฅุนู„ุงู†ุงุช ูˆุงู„ุชุฑูˆูŠุฌ" },
{ id:"sad", icon:"๐Ÿ˜ข", label:"ุญุฒูŠู†", hint:"ุจุทูŠุกุŒ ุซู‚ูŠู„ุŒ ุฅูŠู‚ุงุน ู‡ุงุฏุฆ โ€” ู„ู„ู‚ุตุต ุงู„ุนุงุทููŠุฉ" },
{ id:"serious", icon:"๐ŸŽ™", label:"ุฑุณู…ูŠ", hint:"ูˆุงุถุญุŒ ู…ูˆุซูˆู‚ุŒ ู…ุญุชุฑู โ€” ู„ู„ุฃุฎุจุงุฑ ูˆุงู„ุดุฑุญ" },
{ id:"story", icon:"๐Ÿ“–", label:"ู‚ุตุตูŠ", hint:"ุชู†ูˆุน ุฏุฑุงู…ูŠ ููŠ ุงู„ู†ุจุฑุฉ โ€” ู„ู„ุฑูˆุงูŠุงุช ูˆุงู„ุญูƒุงูŠุงุช" },
{ id:"news", icon:"๐Ÿ“ฐ", label:"ุฅุฎุจุงุฑูŠ", hint:"ุญุงุณู…ุŒ ู„ุง ุชูˆู‚ูุงุช ุฒุงุฆุฏุฉ โ€” ู„ู„ู†ุดุฑุงุช ุงู„ุฅุฎุจุงุฑูŠุฉ" },
{ id:"child", icon:"๐Ÿง’", label:"ุฃุทูุงู„", hint:"ุฎููŠูุŒ ู…ูุฑุญุŒ ุฌูู…ูŽู„ ู‚ุตูŠุฑุฉ โ€” ู„ู„ู…ุญุชูˆู‰ ุงู„ุชุนู„ูŠู…ูŠ" },
];
function ExpressionModeSelector({ value, onChange }) {
return (
<div>
<div className="label" style={{marginBottom:10}}>
๐ŸŽญ ุทุงุจุน ุงู„ุชุนุจูŠุฑ โ€” ูŠุคุซุฑ ุนู„ู‰ ุงู„ู†ุจุฑุฉ ูˆุงู„ุฅูŠู‚ุงุน ูˆุงู„ู†ููŽุณ
</div>
<div style={{display:"grid",gridTemplateColumns:"repeat(auto-fill,minmax(150px,1fr))",gap:8}}>
{EXPRESSION_MODES.map(m=>(
<div key={m.id}
onClick={()=>onChange(m.id)}
title={m.hint}
style={{
display:"flex", alignItems:"center", gap:9,
padding:"9px 12px", borderRadius:"var(--radius-sm)",
border: value===m.id
? "1.5px solid var(--amber)"
: "1px solid var(--border)",
background: value===m.id ? "var(--amber-glow)" : "var(--surface2)",
cursor:"pointer",
transition:"border-color var(--trans),background var(--trans)",
}}>
<span style={{fontSize:18,lineHeight:1,flexShrink:0}}>{m.icon}</span>
<div style={{minWidth:0}}>
<div style={{
fontSize:13, fontWeight:600,
color: value===m.id ? "var(--amber)" : "var(--text)",
whiteSpace:"nowrap",overflow:"hidden",textOverflow:"ellipsis",
}}>{m.label}</div>
<div style={{fontSize:10,color:"var(--text3)",
overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap",
maxWidth:110}}>{m.hint}</div>
</div>
</div>
))}
</div>
{value !== "natural" && (
<div style={{marginTop:8,fontSize:11,color:"var(--text3)",lineHeight:1.6}}>
โš  ููŠ ุงู„ุฃูˆุถุงุน ุบูŠุฑ ุงู„ุทุจูŠุนูŠุฉุŒ ูŠุชู… ุชุฌุงูˆุฒ ู…ุคุดุฑุงุช Stability/Clarity/Style
ูˆุงุณุชุฎุฏุงู… ุฅุนุฏุงุฏุงุช ู…ุนุฏู‘ุฉ ู…ุณุจู‚ุงู‹ ู„ุฃูุถู„ ุชุนุจูŠุฑ ุฅู†ุณุงู†ูŠ (exaggeration/cfg_weight/temperature).
</div>
)}
</div>
);
}
function VoiceSettings({ stability,setStability,clarity,setClarity,
styleExag,setStyleExag,speed,setSpeed,boost,setBoost,
splitText,setSplitText,variation,setVariation }) {
return (
<div className="col" style={{gap:20}}>
<Slider label="Stability โ€” ุงู„ุซุจุงุช (Temperature)" min={0} max={1} step={.05}
value={stability} onChange={setStability} left="ุชุนุจูŠุฑูŠ ุนุดูˆุงุฆูŠ" right="ู…ุชุณู‚ ุซุงุจุช"
hint="ูŠุชุญูƒู… ุจุนุดูˆุงุฆูŠุฉ Chatterbox (temperature). ู…ู†ุฎูุถ = ุชู†ูˆูŠุน ุฃูƒุจุฑ ุจุงู„ู†ุทู‚ุŒ ู…ู†ุงุณุจ ู„ู„ุชู…ุซูŠู„. ู…ุฑุชูุน = ู†ุทู‚ ู…ุชูˆู‚ูŽู‘ุน ูˆุซุงุจุชุŒ ู…ู†ุงุณุจ ู„ู„ูƒุชุจ ุงู„ุตูˆุชูŠุฉ."/>
<Slider label="Clarity + Similarity โ€” ุงู„ูˆุถูˆุญ (CFG Weight)" min={0} max={1} step={.05}
value={clarity} onChange={setClarity} left="ุญุฑูŠุฉ ุฃูƒุจุฑ ู„ู„ู†ู…ูˆุฐุฌ" right="ู…ุทุงุจู‚ ุญุฑููŠุงู‹ ู„ู„ู…ุฑุฌุน"
hint="ูŠุชุญูƒู… ุจู€ cfg_weight ููŠ Chatterbox. ู…ุฑุชูุน = ุงู„ุชุฒุงู… ุฃู‚ูˆู‰ ุจุงู„ุตูˆุช ุงู„ู…ุฑุฌุนูŠ ูˆู„ู‡ุฌุชู‡. ู…ู†ุฎูุถ = ู…ุณุงุญุฉ ุฃูƒุจุฑ ู„ู„ู†ู…ูˆุฐุฌุŒ ู…ููŠุฏ ุฅู† ูƒุงู†ุช ุงู„ุนูŠู‘ู†ุฉ ุงู„ู…ุฑุฌุนูŠุฉ ุณุฑูŠุนุฉ ุงู„ู†ุทู‚."/>
<Slider label="Style Exaggeration โ€” ุงู„ุฃุณู„ูˆุจ (Exaggeration)" min={0} max={1.5} step={.05}
value={styleExag} onChange={setStyleExag} left="ุทุจูŠุนูŠ ู‡ุงุฏุฆ" right="ู…ุณุฑุญูŠ ู…ุจุงู„ุบ"
hint="ู‡ุฐุง ู‡ูˆ ู…ู‚ูŠุงุณ exaggeration ุงู„ุฃุตู„ูŠ ููŠ Chatterbox ู…ุจุงุดุฑุฉู‹. 0.5 ุงู„ู‚ูŠู…ุฉ ุงู„ุงูุชุฑุงุถูŠุฉ ุงู„ู…ูˆุตู‰ ุจู‡ุง ู„ู…ุนุธู… ุงู„ุญุงู„ุงุช."/>
<Slider label="Speed โ€” ุงู„ุณุฑุนุฉ (ู…ุนุงู„ุฌุฉ ู„ุงุญู‚ุฉ)" min={.5} max={2} step={.05}
value={speed} onChange={setSpeed} left="ุจุทูŠุก ร—0.5" right="ุณุฑูŠุน ร—2.0"
hint="Chatterbox ู„ุง ูŠู…ู„ูƒ ู…ู‚ูŠุงุณ ุณุฑุนุฉ ุฃุตู„ูŠุงู‹ โ€” ุชูุทุจูŽู‘ู‚ ู‡ุฐู‡ ุงู„ู‚ูŠู…ุฉ ุชู„ู‚ุงุฆูŠุงู‹ ุจุนุฏ ุงู„ุชูˆู„ูŠุฏ ุนุจุฑ ุฅุนุงุฏุฉ ุฃุฎุฐ ุงู„ุนูŠู‘ู†ุงุช."/>
<div className="divider"/>
{/* โ”€โ”€ Prosody Variation โ€” the key expressiveness knob โ”€โ”€ */}
<Slider label="๐ŸŽญ Prosody Variation โ€” ุชู†ูˆุน ุชู„ู‚ุงุฆูŠ ู„ูƒู„ ุฌู…ู„ุฉ" min={0} max={2} step={.05}
value={variation} onChange={setVariation}
left="ู…ุณุชูˆู (ุขู„ูŠ)" right="ุฏุฑุงู…ูŠ ุฌุฏุงู‹"
hint="ูŠุญู„ู„ ูƒู„ ุฌู…ู„ุฉ ูˆูŠุบูŠุฑ ุชู„ู‚ุงุฆูŠุงู‹ exaggeration ูˆcfg_weight ูˆุงู„ุณุฑุนุฉ ูˆุดูƒู„ ู…ุณุชูˆู‰ ุงู„ุตูˆุช โ€” ุจู†ุงุกู‹ ุนู„ู‰ ุงู„ูƒู„ู…ุงุช ุงู„ู…ูุชุงุญูŠุฉ ูˆุงู„ุชุฑู‚ูŠู…. 0 = ุซุงุจุชุŒ 1.0 = ุทุจูŠุนูŠุŒ 2.0 = ู…ุจุงู„ูŽุบ."/>
<Toggle label="Speaker Boost โ€” ุชุนุฒูŠุฒ ุงู„ุตูˆุช (Soft Clip)" value={boost} onChange={setBoost}
hint="ูŠุฑูุน ู…ุณุชูˆู‰ ุงู„ุตูˆุช ู…ุน ุชุทุจูŠู‚ soft clipping ู„ุฅุฒุงู„ุฉ ุงู„ุชุดูˆูŠู‡ ุงู„ุฑู‚ู…ูŠ."/>
<Toggle label="ุชู‚ุณูŠู… ุงู„ู†ุต ุงู„ุชู„ู‚ุงุฆูŠ" value={splitText} onChange={setSplitText}
hint="ูŠู‚ุณู… ุงู„ู†ุตูˆุต ุงู„ุทูˆูŠู„ุฉ ุฅู„ู‰ ุฌู…ู„ ูˆูŠุนุฑุถ ุดุฑูŠุท ุชู‚ุฏู… ุญู‚ูŠู‚ูŠ."/>
</div>
);
}
/* โ•โ• HOOK: useJobRunner โ€” manages cancel, progress, beforeunload โ•โ•โ•โ• */
function useJobRunner() {
const abortRef = useRef(null);
const jobIdRef = useRef(null);
const pollRef = useRef(null);
const [running, setRunning] = useState(false);
const [progress, setProgress] = useState(null);
const startPoll = (jid) => {
stopPoll();
pollRef.current = setInterval(async ()=>{
try {
const r = await fetch(`/progress/${jid}`);
if (r.ok) {
const p = await r.json();
setProgress(p);
if (p.status !== "running") stopPoll();
}
} catch {}
}, 600);
};
const stopPoll = () => {
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current=null; }
};
const cancel = useCallback(async () => {
if (abortRef.current) { abortRef.current.abort(); abortRef.current=null; }
if (jobIdRef.current) {
try { await fetch(`/cancel/${jobIdRef.current}`, {method:"POST"}); } catch {}
}
stopPoll();
setRunning(false);
setProgress(null);
toast("info","โ›” ุชู… ุฅู„ุบุงุก ุงู„ุนู…ู„ูŠุฉ");
}, []);
useEffect(()=>{
const handler = ()=>{
if (jobIdRef.current) {
navigator.sendBeacon(`/cancel/${jobIdRef.current}`);
}
};
window.addEventListener("beforeunload", handler);
return ()=>window.removeEventListener("beforeunload", handler);
}, []);
const run = useCallback(async (url, formData) => {
if (abortRef.current) { abortRef.current.abort(); }
const jid = genId();
jobIdRef.current = jid;
formData.set("job_id", jid);
const controller = new AbortController();
abortRef.current = controller;
setRunning(true); setProgress({current:0,total:1,status:"running"});
startPoll(jid);
try {
const r = await fetch(url, {method:"POST", body:formData, signal:controller.signal});
if (!r.ok) { const e=await r.json(); throw new Error(e.detail||"ุฎุทุฃ ููŠ ุงู„ุฎุงุฏู…"); }
const data = await r.json();
stopPoll();
setProgress({current:1,total:1,status:"done"});
return data;
} catch(e) {
stopPoll();
setProgress(null);
if (e.name==="AbortError") return null;
throw e;
} finally {
setRunning(false);
abortRef.current = null;
jobIdRef.current = null;
}
}, []);
return { running, progress, run, cancel };
}
/* โ•โ• GENERATE TAB โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */
function GenerateTab({ voices, onVoicesRefresh, sharedSettings }) {
const { stability,setStability,clarity,setClarity,styleExag,setStyleExag,
speed,setSpeed,boost,setBoost,splitText,setSplitText,
variation,setVariation } = sharedSettings;
/* โ”€โ”€ persisted across refresh โ”€โ”€ */
const [text, setText] = useLS("gen.text", "");
const [lang, setLang] = useLS("gen.lang", "ar");
const [selVoice, setSelVoice] = useLS("gen.voice", null);
const [exprMode, setExprMode] = useLS("gen.expr", "warm");
const [result, setResult] = usePersistedResult("gen.result");
/* โ”€โ”€ session-only (files can't be serialised) โ”€โ”€ */
const [f1, setF1] = useState(null);
const [f2, setF2] = useState(null);
const [saveName, setSaveName] = useState("");
const [saveStatus,setSaveStatus]= useState("");
const { running, progress, run, cancel } = useJobRunner();
const isRTL = ["ar","fa","he","ur"].includes(lang);
const chars = text.length;
useEffect(()=>{
const handler=e=>{if((e.ctrlKey||e.metaKey)&&e.key==="Enter") generate();};
window.addEventListener("keydown",handler);
return ()=>window.removeEventListener("keydown",handler);
});
const saveVoice = async ()=>{
if (!saveName.trim()||!f1) return;
const fd=new FormData();
fd.append("name",saveName.trim()); fd.append("file",f1);
if(f2) fd.append("file2",f2);
try{
const r=await fetch("/voices/save",{method:"POST",body:fd});
if(!r.ok) throw new Error("ูุดู„ ุงู„ุญูุธ");
setSaveStatus("โœ“ ู…ุญููˆุธ"); onVoicesRefresh();
setTimeout(()=>setSaveStatus(""),2000);
} catch(e){ toast("error",e.message); }
};
const generate = async ()=>{
if (!text.trim()) return toast("error","ุฃุฏุฎู„ ุงู„ู†ุต ุฃูˆู„ุงู‹.");
if (!f1&&!selVoice) return toast("error","ุญุฏุฏ ุตูˆุชุงู‹ ู…ุฑุฌุนูŠุงู‹.");
if (chars>MAX_CH) return toast("error",`ุงู„ู†ุต ูŠุชุฌุงูˆุฒ ${MAX_CH} ุญุฑู.`);
const fd=new FormData();
fd.append("text",text); fd.append("language",lang);
fd.append("stability",stability); fd.append("clarity",clarity);
fd.append("style_exaggeration",styleExag); fd.append("speed",speed);
fd.append("speaker_boost",boost); fd.append("enable_text_splitting",splitText);
fd.append("expression_mode", exprMode);
fd.append("variation_strength", variation);
if(f1) fd.append("files",f1);
if(f2) fd.append("files",f2);
if(selVoice) fd.append("voice_name",selVoice);
try {
const data = await run("/generate", fd);
if (!data) return; // cancelled
setResult({url:`/audio/${data.filename}`,filename:data.filename});
toast("success","โœ… ุชู… ุชูˆู„ูŠุฏ ุงู„ุตูˆุช ุจู†ุฌุงุญ");
} catch(e) { toast("error",e.message); }
};
return (
<div className="col" style={{gap:16}}>
{running && <ProgressBar progress={progress}/>}
<div className="card">
<div className="card-header">
<span className="card-title">โœ ุงู„ู†ุต ุงู„ู…ุฑุงุฏ ุชุญูˆูŠู„ู‡</span>
<div className="row" style={{gap:8}}>
<span className="fz-12 text-muted">ุงู„ู„ุบุฉ:</span>
<select value={lang} onChange={e=>setLang(e.target.value)}>
{Object.entries(LANGS).map(([k,v])=><option key={k} value={k}>{v}</option>)}
</select>
</div>
</div>
<div className="card-body" style={{padding:0}}>
<textarea className="textarea" dir={isRTL?"rtl":"ltr"} style={{padding:"16px 18px"}}
rows={7} placeholder={isRTL?"ุงูƒุชุจ ุฃูˆ ุงู„ุตู‚ ุงู„ู†ุต ู‡ู†ุงโ€ฆ":"Type or paste text hereโ€ฆ"}
value={text} onChange={e=>setText(e.target.value.slice(0,MAX_CH+50))}/>
<div className="char-bar">
<span className={`char-count ${chars>MAX_CH?"limit":chars>MAX_CH*.8?"warn":""}`}>
{chars.toLocaleString()} / {MAX_CH.toLocaleString()} ุญุฑู
</span>
<div className="row" style={{gap:8}}>
{text&&<button className="btn-secondary btn-sm" onClick={()=>setText("")}>ู…ุณุญ</button>}
<span className="fz-12 text-muted" style={{fontFamily:"var(--mono)"}}>Ctrl+Enter ู„ู„ุชูˆู„ูŠุฏ</span>
</div>
</div>
</div>
</div>
<div className="grid-2">
<div className="card">
<div className="card-header"><span className="card-title">๐ŸŽ™ ุงู„ุตูˆุช ุงู„ู…ุฑุฌุนูŠ</span></div>
<div className="card-body">
<VoiceSelector voices={voices} selected={selVoice} onSelect={setSelVoice}
f1={f1} setF1={setF1} f2={f2} setF2={setF2}
saveName={saveName} setSaveName={setSaveName}
onSave={saveVoice} saveStatus={saveStatus}/>
</div>
</div>
<div className="card">
<div className="card-header"><span className="card-title">โš™ ุฅุนุฏุงุฏุงุช ุงู„ุตูˆุช</span></div>
<div className="card-body"><VoiceSettings {...sharedSettings}/></div>
</div>
</div>
{/* Expression mode โ€” full width card */}
<div className="card">
<div className="card-header"><span className="card-title">๐ŸŽญ ุทุงุจุน ุงู„ุชุนุจูŠุฑ ูˆุงู„ุฅู„ู‚ุงุก</span>{text.trim()&&<LiveEmotionBadge text={text} variation={variation}/>}</div>
<div className="card-body">
<ExpressionModeSelector value={exprMode} onChange={setExprMode}/>
</div>
</div>
{!running
? <button className="btn-primary" onClick={generate}>โšก ุชูˆู„ูŠุฏ ุงู„ุตูˆุช</button>
: <button className="btn-cancel" onClick={cancel}>
<div className="wave-bars">{[1,2,3,4].map(i=><span key={i}/>)}</div>
ุฅู„ุบุงุก ุงู„ุชูˆู„ูŠุฏ
</button>
}
{result&&(
<ResultCard result={result} onUpdate={setResult}
title="๐Ÿ”Š ุงู„ู†ุชูŠุฌุฉ" badge="โœ“ ุชู… ุงู„ุชูˆู„ูŠุฏ" storageKey="gen"/>
)}
</div>
);
}
/* โ•โ• STS TAB โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */
function STSTab({ voices, sharedSettings }) {
const [src, setSrc] = useState(null);
const [ref1, setRef1] = useState(null);
const [ref2, setRef2] = useState(null);
const [selVoice, setSelVoice] = useState(null);
const [outLang, setOutLang] = useState("ar");
const [result, setResult] = useState(null);
const [transcribed, setTranscribed] = useState("");
const { stability,clarity,styleExag,speed,boost,variation } = sharedSettings;
const { running, progress, run, cancel } = useJobRunner();
const doRun = async ()=>{
if(!src) return toast("error","ุงุฑูุน ุงู„ู…ู„ู ุงู„ุตูˆุชูŠ ุงู„ู…ุตุฏุฑ.");
if(!ref1&&!selVoice) return toast("error","ุญุฏุฏ ุตูˆุช ุงู„ู‡ุฏู.");
const fd=new FormData();
fd.append("source_audio",src); fd.append("language",outLang);
fd.append("stability",stability); fd.append("clarity",clarity);
fd.append("style_exaggeration",styleExag); fd.append("speed",speed);
fd.append("speaker_boost",boost); fd.append("variation_strength_sts",variation);
if(ref1) fd.append("ref_files",ref1);
if(ref2) fd.append("ref_files",ref2);
if(selVoice) fd.append("voice_name",selVoice);
try{
const data = await run("/sts", fd);
if(!data) return;
setResult({url:`/audio/${data.filename}`,filename:data.filename});
if(data.transcribed) setTranscribed(data.transcribed);
toast("success","ุชู… ุชุญูˆูŠู„ ุงู„ุตูˆุช ุจู†ุฌุงุญ");
} catch(e){ toast("error",e.message); }
};
return (
<div className="col" style={{gap:16}}>
{running&&<ProgressBar progress={progress}/>}
<div className="info-box">
<span className="info-icon">๐ŸŽญ</span>
<div><strong style={{color:"var(--text)"}}>Speech-to-Speech</strong> โ€” ูŠุฑูุน ุงู„ู…ู„ู ุงู„ุตูˆุชูŠุŒ
ูŠู†ุณุฎู‡ Faster-Whisper ุชู„ู‚ุงุฆูŠุงู‹ุŒ ุซู… ูŠุนูŠุฏ ุชูˆู„ูŠุฏู‡ ุจุตูˆุช ุงู„ู‡ุฏู.</div>
</div>
<div className="grid-2">
<div className="card">
<div className="card-header"><span className="card-title">๐ŸŽ™ ุงู„ุตูˆุช ุงู„ู…ุตุฏุฑ</span></div>
<div className="card-body"><FileUpload file={src} onFile={setSrc} hint="ุฃูŠ ู…ู„ู ุตูˆุชูŠ โ€” ูƒู„ุงู… ุจุฃูŠ ู„ุบุฉ"/></div>
</div>
<div className="card">
<div className="card-header">
<span className="card-title">๐ŸŽฏ ุตูˆุช ุงู„ู‡ุฏู</span>
<div className="row" style={{gap:6}}>
<span className="fz-12 text-muted">ู„ุบุฉ ุงู„ุฅุฎุฑุงุฌ:</span>
<select value={outLang} onChange={e=>setOutLang(e.target.value)}>
{Object.entries(LANGS).map(([k,v])=><option key={k} value={k}>{v}</option>)}
</select>
</div>
</div>
<div className="card-body">
<VoiceSelector voices={voices} selected={selVoice} onSelect={setSelVoice}
f1={ref1} setF1={setRef1} f2={ref2} setF2={setRef2}
saveName="" setSaveName={()=>{}} onSave={()=>{}} saveStatus=""/>
</div>
</div>
</div>
<div className="card">
<div className="card-header"><span className="card-title">โš™ ุฅุนุฏุงุฏุงุช ุงู„ุตูˆุช</span></div>
<div className="card-body"><VoiceSettings {...sharedSettings}/></div>
</div>
{!running
? <button className="btn-primary" onClick={doRun}>๐ŸŽญ ุชุญูˆูŠู„ ุงู„ุตูˆุช</button>
: <button className="btn-cancel" onClick={cancel}>
<div className="wave-bars">{[1,2,3,4].map(i=><span key={i}/>)}</div>
ุฅู„ุบุงุก ุงู„ุชุญูˆูŠู„
</button>
}
{transcribed&&(
<div className="info-box">
<span className="info-icon">๐Ÿ“</span>
<div><strong style={{color:"var(--text)"}}>ุงู„ู†ุต ุงู„ู…ู†ุณูˆุฎ: </strong>{transcribed}</div>
</div>
)}
{result&&(
<ResultCard result={result} onUpdate={setResult}
title="๐Ÿ”Š ุงู„ู†ุชูŠุฌุฉ" badge="โœ“ ุชู…" storageKey="sts"/>
)}
</div>
);
}
/* โ•โ• DUB TAB โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */
function DubTab({ voices, sharedSettings }) {
const [src, setSrc] = useState(null);
const [ref1, setRef1] = useState(null);
const [ref2, setRef2] = useState(null);
const [selVoice, setSelVoice] = useState(null);
const [tgtLang, setTgtLang] = useState("en");
const [result, setResult] = useState(null);
const [original, setOriginal] = useState("");
const [translated, setTranslated] = useState("");
const { stability,clarity,styleExag,speed,boost,variation } = sharedSettings;
const { running, progress, run, cancel } = useJobRunner();
const doRun = async ()=>{
if(!src) return toast("error","ุงุฑูุน ุงู„ู…ู„ู ุงู„ุตูˆุชูŠ.");
if(!ref1&&!selVoice) return toast("error","ุญุฏุฏ ุตูˆุช ุงู„ุฏุจู„ุฌุฉ.");
const fd=new FormData();
fd.append("source_audio",src); fd.append("target_language",tgtLang);
fd.append("stability",stability); fd.append("clarity",clarity);
fd.append("style_exaggeration",styleExag); fd.append("speed",speed);
fd.append("speaker_boost",boost); fd.append("variation_strength_dub",variation);
if(ref1) fd.append("ref_files",ref1);
if(ref2) fd.append("ref_files",ref2);
if(selVoice) fd.append("voice_name",selVoice);
try{
const data = await run("/dub", fd);
if(!data) return;
setResult({url:`/audio/${data.filename}`,filename:data.filename});
if(data.original) setOriginal(data.original);
if(data.translated) setTranslated(data.translated);
toast("success","ุชู…ุช ุงู„ุฏุจู„ุฌุฉ ุจู†ุฌุงุญ");
} catch(e){ toast("error",e.message); }
};
return (
<div className="col" style={{gap:16}}>
{running&&<ProgressBar progress={progress}/>}
<div className="info-box">
<span className="info-icon">๐ŸŒ</span>
<div><strong style={{color:"var(--text)"}}>ุงู„ุฏุจู„ุฌุฉ ู…ุชุนุฏุฏุฉ ุงู„ู„ุบุงุช</strong> โ€” ูŠู†ุณุฎ Faster-Whisper
ุงู„ูƒู„ุงู…ุŒ ูŠุชุฑุฌู…ู‡ ุฅู„ู‰ ุงู„ู„ุบุฉ ุงู„ู…ุฎุชุงุฑุฉุŒ ุซู… ูŠูˆู„ู‘ุฏ ุงู„ุตูˆุช ุจุงู„ุตูˆุช ุงู„ู…ุฑุฌุนูŠ.</div>
</div>
<div className="grid-2">
<div className="card">
<div className="card-header"><span className="card-title">๐ŸŽ™ ุงู„ู…ู„ู ุงู„ู…ุตุฏุฑ</span></div>
<div className="card-body"><FileUpload file={src} onFile={setSrc} hint="ุตูˆุช ุฃูˆ ู…ู‚ุทุน ููŠุฏูŠูˆ"/></div>
</div>
<div className="card">
<div className="card-header">
<span className="card-title">๐ŸŒ ุงู„ู„ุบุฉ ุงู„ู‡ุฏู</span>
<select value={tgtLang} onChange={e=>setTgtLang(e.target.value)}>
{Object.entries(LANGS).map(([k,v])=><option key={k} value={k}>{v}</option>)}
</select>
</div>
<div className="card-body">
<VoiceSelector voices={voices} selected={selVoice} onSelect={setSelVoice}
f1={ref1} setF1={setRef1} f2={ref2} setF2={setRef2}
saveName="" setSaveName={()=>{}} onSave={()=>{}} saveStatus=""/>
</div>
</div>
</div>
<div className="card">
<div className="card-header"><span className="card-title">โš™ ุฅุนุฏุงุฏุงุช ุงู„ุตูˆุช</span></div>
<div className="card-body"><VoiceSettings {...sharedSettings}/></div>
</div>
{!running
? <button className="btn-primary" onClick={doRun}>๐ŸŒ ุจุฏุก ุงู„ุฏุจู„ุฌุฉ</button>
: <button className="btn-cancel" onClick={cancel}>
<div className="wave-bars">{[1,2,3,4].map(i=><span key={i}/>)}</div>
ุฅู„ุบุงุก ุงู„ุฏุจู„ุฌุฉ
</button>
}
{(original||translated)&&(
<div className="grid-2" style={{gap:12}}>
{original&&<div className="info-box"><span className="info-icon">๐Ÿ“„</span><div><strong style={{color:"var(--text)"}}>ุงู„ุฃุตู„ูŠ:</strong> {original}</div></div>}
{translated&&<div className="info-box"><span className="info-icon">โœ</span><div><strong style={{color:"var(--text)"}}>ุงู„ู…ุชุฑุฌู…:</strong> {translated}</div></div>}
</div>
)}
{result&&(
<ResultCard result={result} onUpdate={setResult}
title="๐Ÿ”Š ุงู„ู†ุชูŠุฌุฉ ุงู„ู…ุฏุจู„ุฌุฉ" badge="โœ“ ุชู…" storageKey="dub"/>
)}
</div>
);
}
/* โ•โ• SFX TAB โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */
function SFXTab({ voices, sharedSettings }) {
const [desc, setDesc] = useState("");
const [ref, setRef] = useState(null);
const [selVoice,setSelVoice]= useState(null);
const [result, setResult] = useState(null);
const { stability,clarity,styleExag,speed,boost } = sharedSettings;
const { running, progress, run, cancel } = useJobRunner();
const EXAMPLES=["ุถุฑุจุฉ ุทุจู„ ุนู…ูŠู‚ุฉ ูˆุจุทูŠุฆุฉ","ุตูˆุช ุชุณุงู‚ุท ุงู„ู…ุทุฑ ุงู„ุบุฒูŠุฑ","ุฑูŠุงุญ ุนุงุชูŠุฉ ูˆุนูˆุงุก ุฐุฆุงุจ","ู…ูˆุฌุงุช ุจุญุฑ ุชุชูƒุณุฑ ุนู„ู‰ ุงู„ุตุฎูˆุฑ","ุตูˆุช ู…ุญุฑูƒ ุณูŠุงุฑุฉ ูŠูุดุนู„"];
const doRun = async ()=>{
if(!desc.trim()) return toast("error","ุงูƒุชุจ ูˆุตู ุงู„ู…ุคุซุฑ ุงู„ุตูˆุชูŠ.");
if(!ref&&!selVoice) return toast("error","ุญุฏุฏ ุตูˆุชุงู‹ ู…ุฑุฌุนูŠุงู‹ ู„ุถุจุท ุงู„ุทุงุจุน.");
const fd=new FormData();
fd.append("description",desc);
fd.append("stability",stability); fd.append("clarity",clarity);
fd.append("style_exaggeration",styleExag); fd.append("speed",speed);
fd.append("speaker_boost",boost);
if(ref) fd.append("ref_file",ref);
if(selVoice) fd.append("voice_name",selVoice);
try{
const data = await run("/sfx", fd);
if(!data) return;
setResult({url:`/audio/${data.filename}`,filename:data.filename});
toast("success","ุชู… ุชูˆู„ูŠุฏ ุงู„ู…ุคุซุฑ ุงู„ุตูˆุชูŠ");
} catch(e){ toast("error",e.message); }
};
return (
<div className="col" style={{gap:16}}>
{running&&<ProgressBar progress={progress}/>}
<div className="info-box">
<span className="info-icon">๐Ÿ’ฅ</span>
<div><strong style={{color:"var(--text)"}}>ุงู„ู…ุคุซุฑุงุช ุงู„ุตูˆุชูŠุฉ</strong> โ€” ุตู ุงู„ู…ุคุซุฑ ุงู„ุฐูŠ
ุชุฑูŠุฏู‡ ู†ุตูŠุงู‹ ูˆุณูŠููˆู„ู‘ุฏ ุงู„ู†ุธุงู… ุตูˆุชุงู‹ ูŠุนูƒุณู‡.</div>
</div>
<div className="card">
<div className="card-header"><span className="card-title">โœ ูˆุตู ุงู„ู…ุคุซุฑ</span></div>
<div className="card-body">
<textarea className="textarea" dir="rtl" rows={4}
placeholder="ุตู ุงู„ู…ุคุซุฑ ุงู„ุตูˆุชูŠ ุจุชูุตูŠู„โ€ฆ"
value={desc} onChange={e=>setDesc(e.target.value)} style={{minHeight:90}}/>
<div style={{marginTop:12}}>
<div className="label">ุฃู…ุซู„ุฉ ุณุฑูŠุนุฉ</div>
<div style={{display:"flex",flexWrap:"wrap",gap:6}}>
{EXAMPLES.map(ex=>(
<button key={ex} className="btn-secondary btn-sm" onClick={()=>setDesc(ex)}>{ex}</button>
))}
</div>
</div>
</div>
</div>
<div className="card">
<div className="card-header"><span className="card-title">๐ŸŽ™ ุงู„ุตูˆุช ุงู„ู…ุฑุฌุนูŠ</span></div>
<div className="card-body">
<VoiceSelector voices={voices} selected={selVoice} onSelect={setSelVoice}
f1={ref} setF1={setRef} f2={null} setF2={()=>{}}
saveName="" setSaveName={()=>{}} onSave={()=>{}} saveStatus=""/>
</div>
</div>
<div className="card">
<div className="card-header"><span className="card-title">โš™ ุฅุนุฏุงุฏุงุช</span></div>
<div className="card-body"><VoiceSettings {...sharedSettings}/></div>
</div>
{!running
? <button className="btn-primary" onClick={doRun}>๐Ÿ’ฅ ุชูˆู„ูŠุฏ ุงู„ู…ุคุซุฑ ุงู„ุตูˆุชูŠ</button>
: <button className="btn-cancel" onClick={cancel}>
<div className="wave-bars">{[1,2,3,4].map(i=><span key={i}/>)}</div>
ุฅู„ุบุงุก ุงู„ุชูˆู„ูŠุฏ
</button>
}
{result&&(
<ResultCard result={result} onUpdate={setResult}
title="๐Ÿ”Š ุงู„ู…ุคุซุฑ ุงู„ุตูˆุชูŠ" badge="โœ“ ุชู…" storageKey="sfx"/>
)}
</div>
);
}
/* โ•โ• LIBRARY TAB โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */
function LibraryTab({ voices, onRefresh }) {
const [f1,f1Set]=useState(null); const [f2,f2Set]=useState(null);
const [name,setName]=useState(""); const [saving,setSaving]=useState(false);
const save=async()=>{
if(!name.trim()) return toast("error","ุฃุฏุฎู„ ุงุณู… ุงู„ุตูˆุช.");
if(!f1) return toast("error","ุงุฑูุน ุนูŠู†ุฉ ุตูˆุชูŠุฉ ุฃูˆู„ุงู‹.");
setSaving(true);
const fd=new FormData();
fd.append("name",name.trim()); fd.append("file",f1);
if(f2) fd.append("file2",f2);
try{
const r=await fetch("/voices/save",{method:"POST",body:fd});
if(!r.ok) throw new Error("ูุดู„ ุงู„ุญูุธ");
toast("success",`ุชู… ุญูุธ ุงู„ุตูˆุช "${name.trim()}"`);
f1Set(null); f2Set(null); setName(""); onRefresh();
} catch(e){ toast("error",e.message); }
finally{ setSaving(false); }
};
const del=async(v)=>{
if(!confirm(`ุญุฐู ุงู„ุตูˆุช "${v}"ุŸ`)) return;
await fetch(`/voices/${v}`,{method:"DELETE"});
toast("info",`ุชู… ุญุฐู "${v}"`); onRefresh();
};
return (
<div className="col" style={{gap:16}}>
<div className="card">
<div className="card-header"><span className="card-title">โž• ุฅุถุงูุฉ ุตูˆุช ุฌุฏูŠุฏ</span></div>
<div className="card-body">
<div className="grid-2" style={{marginBottom:14}}>
<FileUpload label="ุนูŠู†ุฉ ุตูˆุชูŠุฉ 1 (ู…ุทู„ูˆุจุฉ)" file={f1} onFile={f1Set} hint="5โ€“30 ุซุงู†ูŠุฉ ูƒู„ุงู… ูˆุงุถุญ"/>
<FileUpload label="ุนูŠู†ุฉ ุตูˆุชูŠุฉ 2 (ุงุฎุชูŠุงุฑูŠ)" file={f2} onFile={f2Set} hint="ุชุญุณู‘ู† ุฏู‚ุฉ ุงู„ุงุณุชู†ุณุงุฎ"/>
</div>
<div className="row">
<input type="text" placeholder="ุงุณู… ุงู„ุตูˆุช ููŠ ุงู„ู…ูƒุชุจุฉโ€ฆ" value={name}
onChange={e=>setName(e.target.value)} style={{flex:1}}/>
<button className="btn-secondary" onClick={save} disabled={saving}>
{saving?"ุฌุงุฑูŠ ุงู„ุญูุธโ€ฆ":"ุญูุธ ููŠ ุงู„ู…ูƒุชุจุฉ"}
</button>
</div>
</div>
</div>
{voices.length===0 ? (
<div className="empty">
<div className="empty-icon">๐ŸŽ™</div>
<div className="empty-title">ุงู„ู…ูƒุชุจุฉ ูุงุฑุบุฉ</div>
<div className="empty-sub">ุฃุถู ุนูŠู†ุงุช ุตูˆุชูŠุฉ ู„ุชุธู‡ุฑ ู‡ู†ุง.</div>
</div>
) : (
<div className="col" style={{gap:10}}>
<div className="label">ุงู„ุฃุตูˆุงุช ุงู„ู…ุญููˆุธุฉ ({voices.length})</div>
{voices.map(v=>{
const av=voiceAvatar(v);
return (
<div key={v} className="voice-card" style={{cursor:"default"}}>
<div className="voice-avatar" style={{background:av.bg,color:av.fg,borderColor:av.bg}}>{av.initials}</div>
<div className="voice-info">
<div className="voice-name">{v}</div>
<div className="voice-tags"><span className="badge badge-amber">ู…ูƒุชุจุฉ</span></div>
</div>
<button className="btn-secondary btn-sm"
style={{color:"var(--red)",borderColor:"rgba(239,68,68,.3)"}}
onClick={()=>del(v)}>ุญุฐู</button>
</div>
);
})}
</div>
)}
</div>
);
}
/* โ•โ• HISTORY TAB โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */
function HistoryTab() {
const [history, setHistory] = useState([]);
const [search, setSearch] = useState("");
const [activeUrl, setActiveUrl] = useState(null);
const [activeFn, setActiveFn] = useState(null);
const [editKey, setEditKey] = useState(0);
useEffect(()=>{
fetch("/history").then(r=>r.json()).then(h=>setHistory([...h].reverse())).catch(()=>{});
},[]);
const openPreview = (filename) => {
setActiveUrl(`/audio/${filename}`);
setActiveFn(filename);
setEditKey(k=>k+1);
setTimeout(()=>{
document.getElementById("hist-preview-card")
?.scrollIntoView({behavior:"smooth",block:"start"});
}, 80);
};
const onEdited = ({url, filename}) => {
setActiveUrl(url);
setActiveFn(filename);
setEditKey(k=>k+1);
};
const MODE_INFO={tts:["badge-amber","โšก TTS"],sts:["badge-purple","๐ŸŽญ STS"],
dub:["badge-blue","๐ŸŒ Dub"],sfx:["badge-green","๐Ÿ’ฅ SFX"]};
const filtered=history.filter(h=>
!search||h.text.includes(search)||h.language.includes(search)||(h.mode||"").includes(search)
);
return (
<div className="col" style={{gap:14}}>
<div className="row">
<input type="text" placeholder="ุจุญุซ ููŠ ุงู„ุณุฌู„โ€ฆ" value={search}
onChange={e=>setSearch(e.target.value)} style={{flex:1}}/>
<span className="fz-12 text-muted" style={{fontFamily:"var(--mono)",whiteSpace:"nowrap"}}>
{filtered.length} ุนู…ู„ูŠุฉ
</span>
</div>
{activeUrl&&activeFn&&(
<ResultCard
key={editKey}
result={{url:activeUrl, filename:activeFn}}
onUpdate={onEdited}
title="๐Ÿ”Š ุงู„ู…ุนุงูŠู†ุฉ ูˆุงู„ุชุนุฏูŠู„" storageKey="hist"
badge={activeFn}
/>
)}
{filtered.length===0 ? (
<div className="empty">
<div className="empty-icon">๐Ÿ•˜</div>
<div className="empty-title">{search?"ู„ุง ู†ุชุงุฆุฌ":"ุงู„ุณุฌู„ ูุงุฑุบ"}</div>
<div className="empty-sub">{search?"ุฌุฑุจ ู…ุตุทู„ุญ ุจุญุซ ุขุฎุฑ":"ุณุชุธู‡ุฑ ุนู…ู„ูŠุงุช ุงู„ุชูˆู„ูŠุฏ ู‡ู†ุง ุชู„ู‚ุงุฆูŠุงู‹."}</div>
</div>
) : filtered.map((h,i)=>{
const [cls,label]=MODE_INFO[h.mode]||["badge-amber","โšก"];
const isActive = activeFn === h.filename;
return (
<div key={i} className="hist-item"
style={isActive?{borderColor:"var(--amber)",background:"var(--amber-glow)"}:{}}>
<div className="hist-text">
<div className="hist-preview" dir="rtl">{h.text}</div>
<div className="hist-meta">
<span className={`badge ${cls}`}>{label}</span>
<span className="badge badge-amber">{LANGS[h.language]||h.language}</span>
{h.char_count>0&&<span className="hist-time">{h.char_count} ุญุฑู</span>}
<span className="hist-time">{new Date(h.ts*1000).toLocaleString("ar-EG")}</span>
</div>
</div>
<div className="hist-actions">
<button className={`btn-secondary btn-sm ${isActive?"":"" }`}
style={isActive?{borderColor:"var(--amber)",color:"var(--amber)"}:{}}
onClick={()=> isActive ? setActiveUrl(null)||setActiveFn(null)
: openPreview(h.filename)}>
{isActive ? "โ–ถ ู†ุดุท" : "โ–ถ ุชุดุบูŠู„ + ุชุนุฏูŠู„"}
</button>
<a href={`/audio/${h.filename}`} download={h.filename} style={{textDecoration:"none"}}>
<button className="btn-secondary btn-sm">โฌ‡</button>
</a>
</div>
</div>
);
})}
</div>
);
}
/* โ•โ• APP ROOT โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */
function App() {
/* โ”€โ”€ persisted across refresh โ”€โ”€ */
const [tab, setTab] = useLS("app.tab", "generate");
const [theme, setTheme] = useLS("app.theme", "dark");
const [stability, setStability] = useLS("vs.stability", 0.5);
const [clarity, setClarity] = useLS("vs.clarity", 0.5);
const [styleExag, setStyleExag] = useLS("vs.styleExag", 0.5);
const [speed, setSpeed] = useLS("vs.speed", 1.0);
const [boost, setBoost] = useLS("vs.boost", false);
const [splitText, setSplitText] = useLS("vs.splitText", true);
const [variation, setVariation] = useLS("vs.variation", 1.0);
/* โ”€โ”€ session-only โ”€โ”€ */
const [voices, setVoices] = useState([]);
const [sidebarOpen,setSidebarOpen]= useState(false);
const sharedSettings={stability,setStability,clarity,setClarity,
styleExag,setStyleExag,speed,setSpeed,boost,setBoost,
splitText,setSplitText,variation,setVariation};
useEffect(()=>{ document.documentElement.setAttribute("data-theme",theme); },[theme]);
const refreshVoices=()=>fetch("/voices").then(r=>r.json()).then(setVoices).catch(()=>{});
useEffect(()=>{ refreshVoices(); },[]);
/* โ”€โ”€ Model loading status โ”€โ”€ */
const [modelStatus, setModelStatus] = useState({loading:true,ready:false,error:null});
useEffect(()=>{
const poll = () => {
fetch("/model_status").then(r=>r.json()).then(s=>{
setModelStatus(s);
if(s.loading) setTimeout(poll, 3000);
}).catch(()=>setTimeout(poll,5000));
};
poll();
},[]);
const TABS=[
{id:"generate",icon:"โšก",label:"ุชูˆู„ูŠุฏ ุตูˆุช"},
{id:"sts", icon:"๐ŸŽญ",label:"STS"},
{id:"dub", icon:"๐ŸŒ",label:"ุฏุจู„ุฌุฉ"},
{id:"sfx", icon:"๐Ÿ’ฅ",label:"ู…ุคุซุฑุงุช"},
null,
{id:"library", icon:"๐Ÿ“š",label:"ู…ูƒุชุจุฉ ุงู„ุฃุตูˆุงุช"},
{id:"history", icon:"๐Ÿ•˜",label:"ุงู„ุณุฌู„"},
];
const TAB_TITLES={
generate:"โšก ุชูˆู„ูŠุฏ ุงู„ุตูˆุช",sts:"๐ŸŽญ Speech-to-Speech",
dub:"๐ŸŒ ุงู„ุฏุจู„ุฌุฉ ู…ุชุนุฏุฏุฉ ุงู„ู„ุบุงุช",sfx:"๐Ÿ’ฅ ุงู„ู…ุคุซุฑุงุช ุงู„ุตูˆุชูŠุฉ",
library:"๐Ÿ“š ู…ูƒุชุจุฉ ุงู„ุฃุตูˆุงุช",history:"๐Ÿ•˜ ุณุฌู„ ุงู„ุนู…ู„ูŠุงุช",
};
const selectTab=(id)=>{ setTab(id); setSidebarOpen(false); };
return (
<>
<ToastRoot/>
<div className={`sidebar-overlay ${sidebarOpen?"visible":""}`}
onClick={()=>setSidebarOpen(false)}/>
<div className="app">
<aside className={`sidebar ${sidebarOpen?"mobile-open":""}`}>
<div className="sidebar-logo">
<div className="sidebar-logo-text"><span className="hi">Chatterbox</span> Studio</div>
<div className="sidebar-logo-sub">Multilingual V3 ยท MIT</div>
</div>
<div className="sidebar-section">
<div className="sidebar-section-label">ุงู„ุฃุฏูˆุงุช</div>
{TABS.map((t,i)=>t===null
? <div key={i} className="divider" style={{margin:"8px 10px"}}/>
: (
<div key={t.id} className={`nav-item ${tab===t.id?"active":""}`}
onClick={()=>selectTab(t.id)}>
<span className="ni-icon">{t.icon}</span>
<span>{t.label}</span>
</div>
)
)}
</div>
<div className="sidebar-footer">
<div className="status-row">
<div className={`status-dot ${DEVICE==="cuda"?"green":"red"}`}/>
<span>{DEVICE.toUpperCase()}</span>
</div>
<div className="status-row">
<div className="status-dot" style={{
background: modelStatus.error?"var(--red)":modelStatus.ready?"var(--green)":"var(--amber)",
animation: modelStatus.loading?"wb 1s ease-in-out infinite":undefined
}}/>
<span style={{color:modelStatus.error?"var(--red)":modelStatus.ready?"var(--green)":"var(--amber)"}}>
{modelStatus.error?"โŒ ูุดู„ ุงู„ุชุญู…ูŠู„":modelStatus.loading?"โณ ุฌุงุฑูŠ ุชุญู…ูŠู„ ุงู„ู†ู…ูˆุฐุฌโ€ฆ":"โœ“ Chatterbox ุฌุงู‡ุฒ"}
</span>
</div>
{modelStatus.ready&&<div className="status-row">
<div className="status-dot" style={{background:"var(--amber)"}}/>
<span>{voices.length} ุตูˆุช ู…ุญููˆุธ</span>
</div>}
{modelStatus.loading&&<div style={{marginTop:6,fontSize:10,color:"var(--text3)",fontFamily:"var(--mono)"}}>
ุงู„ูˆุงุฌู‡ุฉ ุชุนู…ู„ โ€” ุงู„ู…ูŠุฒุงุช ุณุชููุนูŽู‘ู„ ุจุนุฏ ุงูƒุชู…ุงู„ ุงู„ุชุญู…ูŠู„
</div>}
</div>
</aside>
<header className="topbar">
<div className="row" style={{gap:12}}>
<div className="hamburger" onClick={()=>setSidebarOpen(v=>!v)}>
<span/><span/><span/>
</div>
<span className="topbar-title">
<span>Chatterbox</span> <span className="title-long">Voice Studio โ€” </span>{TAB_TITLES[tab]}
</span>
</div>
<div className="topbar-right">
<button className="btn-icon-round"
onClick={()=>setTheme(t=>t==="dark"?"light":"dark")}
title="ุชุจุฏูŠู„ ุงู„ุซูŠู…">
{theme==="dark"?"โ˜€๏ธ":"๐ŸŒ™"}
</button>
</div>
</header>
<main className="main">
<div className="main-inner">
{tab==="generate"&&<GenerateTab voices={voices} onVoicesRefresh={refreshVoices} sharedSettings={sharedSettings}/>}
{tab==="sts" &&<STSTab voices={voices} sharedSettings={sharedSettings}/>}
{tab==="dub" &&<DubTab voices={voices} sharedSettings={sharedSettings}/>}
{tab==="sfx" &&<SFXTab voices={voices} sharedSettings={sharedSettings}/>}
{tab==="library" &&<LibraryTab voices={voices} onRefresh={refreshVoices}/>}
{tab==="history" &&<HistoryTab/>}
</div>
</main>
</div>
</>
);
}
ReactDOM.createRoot(document.getElementById("root")).render(<App/>);
</script>
</body>
</html>"""
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# R O U T E S
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
@app.get("/", response_class=HTMLResponse)
async def ui(request: Request):
user = request.session["user"]
admin_link = ('<a href="/admin" style="color:#ffd98a;text-decoration:underline;margin-left:10px">'
'ู„ูˆุญุฉ ุงู„ู…ุทูˆู‘ุฑ</a>') if user.get("role") == "admin" else ""
return (HTML
.replace("DEVICE_PH", device)
.replace("LANGS_PH", json.dumps(LANGUAGES, ensure_ascii=False))
.replace("USER_PH", user["username"])
.replace("ADMIN_LINK_PH", admin_link))
# โ”€โ”€ Model status endpoint โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@app.get("/model_status")
def model_status():
return JSONResponse({
"loading": xtts_loading,
"ready": xtts_ready,
"error": xtts_error,
"device": device,
})
# โ”€โ”€ Job control endpoints โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@app.get("/progress/{job_id}")
def get_progress(job_id: str):
_gc_jobs()
p = job_progress.get(job_id, {"current": 0, "total": 1, "status": "unknown"})
return JSONResponse(p)
@app.post("/cancel/{job_id}")
def cancel_job(job_id: str):
ev = cancel_events.get(job_id)
if ev:
ev.set()
return {"cancelled": True}
# โ”€โ”€ Audio post-edit endpoint โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@app.post("/edit_audio")
async def edit_audio(
request: Request,
filename: str = Form(...),
volume_db: float = Form(0.0),
speed_factor: float = Form(1.0),
trim_start: float = Form(0.0),
trim_end: float = Form(0.0),
fade_in: float = Form(0.0), # seconds
fade_out: float = Form(0.0), # seconds
normalize: bool = Form(False), # peak-normalize to 0 dBFS
):
"""Edit a generated audio file without re-running TTS.
Always writes a NEW file โ€” the original is never modified."""
user = request.session["user"]
if user.get("role") != "admin" and not owns_output_file(os.path.basename(filename), user["username"]):
raise HTTPException(403, "ู„ุง ุชู…ู„ูƒ ุตู„ุงุญูŠุฉ ุงู„ูˆุตูˆู„ ู„ู‡ุฐุง ุงู„ู…ู„ู.")
src = os.path.join(OUTPUT_DIR, filename)
if not os.path.exists(src):
raise HTTPException(404, "ุงู„ู…ู„ู ุบูŠุฑ ู…ูˆุฌูˆุฏ.")
def _edit():
data, sr = sf.read(src)
if data.ndim > 1:
data = data.mean(axis=1)
# โ”€โ”€ Trim start / end โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
if trim_start > 0:
data = data[min(int(trim_start * sr), len(data)):]
if trim_end > 0:
end_sample = max(0, len(data) - int(trim_end * sr))
data = data[:end_sample]
if len(data) == 0:
raise ValueError("ุงู„ู…ู„ู ูุงุฑุบ ุจุนุฏ ุงู„ู‚ุทุน โ€” ู‚ู„ู‘ู„ ู‚ูŠู… ุงู„ู‚ุทุน.")
# โ”€โ”€ Speed via polyphase resampling โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
if abs(speed_factor - 1.0) > 0.01:
try:
from scipy.signal import resample_poly
from math import gcd
p = round(speed_factor * 1000)
q = 1000
g = gcd(p, q)
data = resample_poly(data, q // g, p // g).astype(np.float32)
except ImportError:
try:
from scipy.signal import resample as sp_resample
new_len = max(1, int(len(data) / speed_factor))
data = sp_resample(data, new_len).astype(np.float32)
except ImportError:
pass
# โ”€โ”€ Normalize (peak to 0 dBFS) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
if normalize:
peak = np.max(np.abs(data))
if peak > 0:
data = data / peak * 0.98
# โ”€โ”€ Volume (dB) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
if abs(volume_db) > 0.01:
factor = 10 ** (volume_db / 20.0)
data = data * factor
data = np.tanh(data * 1.2) * (1.0 / np.tanh(1.2))
# โ”€โ”€ Fade In (linear ramp) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
if fade_in > 0:
n = min(int(fade_in * sr), len(data))
data[:n] *= np.linspace(0.0, 1.0, n, dtype=np.float32)
# โ”€โ”€ Fade Out (linear ramp) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
if fade_out > 0:
n = min(int(fade_out * sr), len(data))
data[-n:] *= np.linspace(1.0, 0.0, n, dtype=np.float32)
out = os.path.join(OUTPUT_DIR, f"edit_{uuid.uuid4().hex[:8]}.wav")
sf.write(out, data, sr, subtype='PCM_16')
return os.path.basename(out)
try:
new_filename = await asyncio.to_thread(_edit)
except ValueError as e:
raise HTTPException(400, str(e))
append_history(new_filename, f"[ุชุญุฑูŠุฑ] {filename}", "-", "edit", 0, owner=user["username"])
return {"filename": new_filename}
# โ”€โ”€ Humanize endpoint โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@app.post("/humanize")
async def humanize_endpoint(
request: Request,
filename: str = Form(...),
preset: str = Form("bedroom"),
intensity: float = Form(1.0),
seed: int = Form(42),
):
"""Apply acoustic environment simulation to a generated WAV.
Returns a brand-new file โ€” original untouched."""
user = request.session["user"]
if user.get("role") != "admin" and not owns_output_file(os.path.basename(filename), user["username"]):
raise HTTPException(403, "ู„ุง ุชู…ู„ูƒ ุตู„ุงุญูŠุฉ ุงู„ูˆุตูˆู„ ู„ู‡ุฐุง ุงู„ู…ู„ู.")
src = os.path.join(OUTPUT_DIR, filename)
if not os.path.exists(src):
raise HTTPException(404, "ุงู„ู…ู„ู ุบูŠุฑ ู…ูˆุฌูˆุฏ.")
if preset not in HUMANIZE_PRESETS:
raise HTTPException(400, f"ุจูŠุฆุฉ ุบูŠุฑ ู…ุนุฑูˆูุฉ: {preset}")
intensity = float(np.clip(intensity, 0.05, 2.0))
out = os.path.join(OUTPUT_DIR, f"hum_{uuid.uuid4().hex[:8]}.wav")
try:
await asyncio.to_thread(humanize_audio, src, out, preset, intensity, seed)
except Exception as e:
if os.path.exists(out):
os.remove(out)
raise HTTPException(500, f"ุฎุทุฃ ููŠ ุงู„ู…ุนุงู„ุฌุฉ: {e}")
append_history(os.path.basename(out), f"[ู…ุนุงู„ุฌุฉ ุจูŠุฆูŠุฉ] {filename}", "-", "humanize", 0, owner=user["username"])
return {"filename": os.path.basename(out)}
@app.get("/humanize/presets")
def humanize_presets():
return list(HUMANIZE_PRESETS.keys())
@app.post("/generate")
async def generate(
request: Request,
background_tasks: BackgroundTasks,
text: str = Form(...),
job_id: str = Form(default=""),
language: str = Form("ar"),
stability: float = Form(0.5),
clarity: float = Form(0.5),
style_exaggeration: float = Form(0.5),
speed: float = Form(1.0),
speaker_boost: bool = Form(False),
enable_text_splitting: bool = Form(True),
expression_mode: str = Form("natural"),
variation_strength: float = Form(1.0),
voice_name: str = Form(None),
files: list[UploadFile] = File(default=[]),
):
if xtts_loading: raise HTTPException(503, "โณ ุงู„ู†ู…ูˆุฐุฌ ู„ุง ูŠุฒุงู„ ูŠูุญู…ูŽู‘ู„ุŒ ูŠูุฑุฌู‰ ุงู„ุงู†ุชุธุงุฑ ู‚ู„ูŠู„ุงู‹ ุซู… ุงู„ู…ุญุงูˆู„ุฉ ู…ุฌุฏุฏุงู‹.")
if not xtts_ready: raise HTTPException(503, f"โŒ ูุดู„ ุชุญู…ูŠู„ ุงู„ู†ู…ูˆุฐุฌ: {xtts_error}")
if not text.strip(): raise HTTPException(400, "ุงู„ู†ุต ูุงุฑุบ.")
if len(text) > MAX_CHARS: raise HTTPException(400, f"ุงู„ู†ุต ูŠุชุฌุงูˆุฒ {MAX_CHARS} ุญุฑู.")
if not job_id:
job_id = uuid.uuid4().hex
enhanced_text = enhance_text_expression(text, language, expression_mode)
tts_params = apply_expression_preset(expression_mode, speed)
if expression_mode == "natural":
tts_params = dict(
temperature=stability_to_temperature(stability),
cfg_weight=clarity_to_cfg_weight(clarity),
exaggeration=style_to_exaggeration(style_exaggeration),
speed=speed,
)
username = request.session["user"]["username"]
refs = collect_refs(files, voice_name, username)
if not refs: raise HTTPException(400, "ูŠุฌุจ ุชุญุฏูŠุฏ ุนูŠู†ุฉ ุตูˆุชูŠุฉ ู…ุฑุฌุนูŠุฉ.")
out = os.path.join(OUTPUT_DIR, f"gen_{uuid.uuid4().hex[:10]}.wav")
cancel_events[job_id] = threading.Event()
silence_ms = {"story":160,"sad":180,"news":80,"excited":70}.get(expression_mode, 110)
variation = float(np.clip(variation_strength, 0.0, 2.0))
try:
async with get_tts_lock():
success = await asyncio.to_thread(
run_tts_chunked, enhanced_text, refs, language,
tts_params, out, job_id, silence_ms, variation
)
if not success:
if os.path.exists(out): os.remove(out)
raise HTTPException(499, "ุชู… ุฅู„ุบุงุก ุงู„ุชูˆู„ูŠุฏ.")
if speaker_boost:
await asyncio.to_thread(apply_speaker_boost, out)
finally:
cleanup(refs)
cancel_events.pop(job_id, None)
append_history(os.path.basename(out), text, language, "tts", len(text), owner=username)
return {"filename": os.path.basename(out)}
# โ”€โ”€ STS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@app.post("/sts")
async def sts(
request: Request,
background_tasks: BackgroundTasks,
source_audio: UploadFile = File(...),
job_id: str = Form(default=""),
language: str = Form("ar"),
stability: float = Form(0.5),
clarity: float = Form(0.5),
style_exaggeration: float = Form(0.5),
speed: float = Form(1.0),
speaker_boost: bool = Form(False),
variation_strength_sts: float = Form(1.0),
voice_name: str = Form(None),
ref_files: list[UploadFile] = File(default=[]),
):
if xtts_loading: raise HTTPException(503, "โณ ุงู„ู†ู…ูˆุฐุฌ ู„ุง ูŠุฒุงู„ ูŠูุญู…ูŽู‘ู„ุŒ ูŠูุฑุฌู‰ ุงู„ุงู†ุชุธุงุฑ.")
if not xtts_ready: raise HTTPException(503, f"โŒ ูุดู„ ุชุญู…ูŠู„ ุงู„ู†ู…ูˆุฐุฌ: {xtts_error}")
if not WHISPER_OK: raise HTTPException(503, "Faster-Whisper ุบูŠุฑ ู…ุซุจู‘ุช.")
if not job_id: job_id = uuid.uuid4().hex
username = request.session["user"]["username"]
src = f"/tmp/sts_{uuid.uuid4().hex}.wav"
with open(src, "wb") as f: shutil.copyfileobj(source_audio.file, f)
refs = []
cancel_events[job_id] = threading.Event()
try:
text = await asyncio.to_thread(transcribe_audio, src)
if not text: raise HTTPException(422, "ู„ู… ูŠุชู…ูƒู† Whisper ู…ู† ู†ุณุฎ ุงู„ุตูˆุช.")
refs = collect_refs(ref_files, voice_name, username)
if not refs: raise HTTPException(400, "ุญุฏุฏ ุตูˆุช ุงู„ู‡ุฏู.")
out = os.path.join(OUTPUT_DIR, f"sts_{uuid.uuid4().hex[:10]}.wav")
sts_params = dict(
temperature=stability_to_temperature(stability),
cfg_weight=clarity_to_cfg_weight(clarity),
exaggeration=style_to_exaggeration(style_exaggeration),
speed=speed,
)
var_sts = float(np.clip(variation_strength_sts, 0.0, 2.0))
async with get_tts_lock():
success = await asyncio.to_thread(
run_tts_chunked, text, refs, language, sts_params, out, job_id, 110, var_sts
)
if not success:
if os.path.exists(out): os.remove(out)
raise HTTPException(499, "ุชู… ุฅู„ุบุงุก ุงู„ุชุญูˆูŠู„.")
if speaker_boost:
await asyncio.to_thread(apply_speaker_boost, out)
finally:
cleanup([src]); cleanup(refs)
cancel_events.pop(job_id, None)
append_history(os.path.basename(out), text, language, "sts", len(text), owner=username)
return {"filename": os.path.basename(out), "transcribed": text}
# โ”€โ”€ DUB โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@app.post("/dub")
async def dub(
request: Request,
background_tasks: BackgroundTasks,
source_audio: UploadFile = File(...),
job_id: str = Form(default=""),
target_language: str = Form("en"),
stability: float = Form(0.5),
clarity: float = Form(0.5),
style_exaggeration: float = Form(0.5),
speed: float = Form(1.0),
speaker_boost: bool = Form(False),
variation_strength_dub: float = Form(1.0),
voice_name: str = Form(None),
ref_files: list[UploadFile] = File(default=[]),
):
if xtts_loading: raise HTTPException(503, "โณ ุงู„ู†ู…ูˆุฐุฌ ู„ุง ูŠุฒุงู„ ูŠูุญู…ูŽู‘ู„ุŒ ูŠูุฑุฌู‰ ุงู„ุงู†ุชุธุงุฑ.")
if not xtts_ready: raise HTTPException(503, f"โŒ ูุดู„ ุชุญู…ูŠู„ ุงู„ู†ู…ูˆุฐุฌ: {xtts_error}")
if not WHISPER_OK: raise HTTPException(503, "Faster-Whisper ุบูŠุฑ ู…ุซุจู‘ุช.")
if not TRANSLATOR_OK: raise HTTPException(503, "deep-translator ุบูŠุฑ ู…ุซุจู‘ุช.")
if not job_id: job_id = uuid.uuid4().hex
username = request.session["user"]["username"]
src = f"/tmp/dub_{uuid.uuid4().hex}.wav"
with open(src, "wb") as f: shutil.copyfileobj(source_audio.file, f)
refs = []
cancel_events[job_id] = threading.Event()
try:
original = await asyncio.to_thread(transcribe_audio, src)
if not original: raise HTTPException(422, "ู„ู… ูŠุชู…ูƒู† Whisper ู…ู† ู†ุณุฎ ุงู„ุตูˆุช.")
tgt_code = LANG_MAP.get(target_language, target_language)
translated = await asyncio.to_thread(
GoogleTranslator(source="auto", target=tgt_code).translate, original)
if not translated: raise HTTPException(422, "ูุดู„ุช ุงู„ุชุฑุฌู…ุฉ.")
refs = collect_refs(ref_files, voice_name, username)
if not refs: raise HTTPException(400, "ุญุฏุฏ ุตูˆุช ุงู„ุฏุจู„ุฌุฉ.")
out = os.path.join(OUTPUT_DIR, f"dub_{uuid.uuid4().hex[:10]}.wav")
dub_params = dict(
temperature=stability_to_temperature(stability),
cfg_weight=clarity_to_cfg_weight(clarity),
exaggeration=style_to_exaggeration(style_exaggeration),
speed=speed,
)
var_dub = float(np.clip(variation_strength_dub, 0.0, 2.0))
async with get_tts_lock():
success = await asyncio.to_thread(
run_tts_chunked, translated, refs, target_language, dub_params, out, job_id, 110, var_dub
)
if not success:
if os.path.exists(out): os.remove(out)
raise HTTPException(499, "ุชู… ุฅู„ุบุงุก ุงู„ุฏุจู„ุฌุฉ.")
if speaker_boost:
await asyncio.to_thread(apply_speaker_boost, out)
finally:
cleanup([src]); cleanup(refs)
cancel_events.pop(job_id, None)
append_history(os.path.basename(out), translated, target_language, "dub", len(translated), owner=username)
return {"filename": os.path.basename(out), "original": original, "translated": translated}
# โ”€โ”€ SFX โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@app.post("/sfx")
async def sfx(
request: Request,
background_tasks: BackgroundTasks,
description: str = Form(...),
job_id: str = Form(default=""),
stability: float = Form(0.2),
clarity: float = Form(0.4),
style_exaggeration: float = Form(1.1),
speed: float = Form(0.85),
speaker_boost: bool = Form(False),
voice_name: str = Form(None),
ref_file: UploadFile = File(default=None),
):
if xtts_loading: raise HTTPException(503, "โณ ุงู„ู†ู…ูˆุฐุฌ ู„ุง ูŠุฒุงู„ ูŠูุญู…ูŽู‘ู„ุŒ ูŠูุฑุฌู‰ ุงู„ุงู†ุชุธุงุฑ.")
if not xtts_ready: raise HTTPException(503, f"โŒ ูุดู„ ุชุญู…ูŠู„ ุงู„ู†ู…ูˆุฐุฌ: {xtts_error}")
if not description.strip(): raise HTTPException(400, "ุงู„ูˆุตู ูุงุฑุบ.")
if not job_id: job_id = uuid.uuid4().hex
username = request.session["user"]["username"]
ref_list = [ref_file] if (ref_file and getattr(ref_file, "filename", None)) else []
refs = collect_refs(ref_list, voice_name, username)
if not refs: raise HTTPException(400, "ุญุฏุฏ ุตูˆุชุงู‹ ู…ุฑุฌุนูŠุงู‹.")
out = os.path.join(OUTPUT_DIR, f"sfx_{uuid.uuid4().hex[:10]}.wav")
cancel_events[job_id] = threading.Event()
sfx_params = dict(
temperature=stability_to_temperature(stability),
cfg_weight=clarity_to_cfg_weight(clarity),
exaggeration=style_to_exaggeration(style_exaggeration),
speed=speed,
)
try:
async with get_tts_lock():
success = await asyncio.to_thread(
run_tts_chunked, description, refs, "en", sfx_params, out, job_id
)
if not success:
if os.path.exists(out): os.remove(out)
raise HTTPException(499, "ุชู… ุฅู„ุบุงุก ุงู„ุชูˆู„ูŠุฏ.")
if speaker_boost:
await asyncio.to_thread(apply_speaker_boost, out)
finally:
cleanup(refs)
cancel_events.pop(job_id, None)
append_history(os.path.basename(out), description, "en", "sfx", len(description), owner=username)
return {"filename": os.path.basename(out)}
# โ”€โ”€ Audio / Voice endpoints (per-user privacy scoped) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@app.get("/audio/{filename}")
def audio(filename: str, request: Request):
safe = os.path.basename(filename)
if safe != filename or ".." in safe:
raise HTTPException(400, "ุงุณู… ู…ู„ู ุบูŠุฑ ุตุงู„ุญ.")
user = request.session["user"]
if user.get("role") != "admin" and not owns_output_file(safe, user["username"]):
raise HTTPException(403, "ู„ุง ุชู…ู„ูƒ ุตู„ุงุญูŠุฉ ุงู„ูˆุตูˆู„ ู„ู‡ุฐุง ุงู„ู…ู„ู.")
p = os.path.join(OUTPUT_DIR, safe)
if not os.path.exists(p): raise HTTPException(404, "ู…ู„ู ุบูŠุฑ ู…ูˆุฌูˆุฏ.")
return FileResponse(p, media_type="audio/wav",
headers={"Accept-Ranges": "bytes", "Cache-Control": "no-cache"})
@app.get("/history")
def get_history(request: Request):
user = request.session["user"]
h = load_history()
if user.get("role") != "admin":
h = [e for e in h if e.get("owner") == user["username"]]
return JSONResponse(h)
@app.get("/voices")
def list_voices(request: Request):
lib = user_voice_dir(request.session["user"]["username"])
return [d for d in os.listdir(lib) if os.path.isdir(os.path.join(lib, d))]
@app.post("/voices/save")
async def save_voice(request: Request, background_tasks: BackgroundTasks,
name: str = Form(...),
file: UploadFile = File(...),
file2: UploadFile = File(default=None)):
safe = name.strip().replace("/","_").replace("..","_")[:40]
lib = os.path.join(user_voice_dir(request.session["user"]["username"]), safe)
os.makedirs(lib, exist_ok=True)
for f in ([file, file2] if file2 and file2.filename else [file]):
safe_fn = re.sub(r"[^A-Za-z0-9_.\-]", "_", os.path.basename(f.filename))[:80]
with open(os.path.join(lib, safe_fn), "wb") as buf:
shutil.copyfileobj(f.file, buf)
background_tasks.add_task(trigger_cloud_backup)
return {"name": safe}
@app.delete("/voices/{name}")
def delete_voice(request: Request, background_tasks: BackgroundTasks, name: str):
lib = os.path.join(user_voice_dir(request.session["user"]["username"]), name)
if os.path.isdir(lib): shutil.rmtree(lib)
background_tasks.add_task(trigger_cloud_backup)
return {"deleted": name}
@app.get("/voices/{name}/preview")
def preview_voice(request: Request, name: str):
lib = os.path.join(user_voice_dir(request.session["user"]["username"]), name)
if not os.path.isdir(lib): raise HTTPException(404, "ุตูˆุช ุบูŠุฑ ู…ูˆุฌูˆุฏ.")
for fn in os.listdir(lib):
if fn.lower().endswith((".wav",".mp3",".flac",".ogg")):
mt = "audio/wav" if fn.endswith(".wav") else "audio/mpeg"
return FileResponse(os.path.join(lib, fn), media_type=mt)
raise HTTPException(404, "ู„ุง ุชูˆุฌุฏ ู…ู„ูุงุช ุตูˆุชูŠุฉ.")
# โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—
# โ•‘ L O G I N / L O G O U T / A D M I N P A N E L โ•‘
# โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
LOGIN_HTML = r"""<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>ุชุณุฌูŠู„ ุงู„ุฏุฎูˆู„ โ€” Chatterbox Voice Studio</title>
<style>
*{box-sizing:border-box;font-family:'Segoe UI',Tahoma,Arial,sans-serif}
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
background:linear-gradient(135deg,#151a2e,#1f2745);padding:16px}
.card{background:#ffffff10;backdrop-filter:blur(10px);border:1px solid #ffffff22;
border-radius:16px;padding:32px 28px;max-width:400px;width:100%;color:#f2f3f8}
h1{font-size:20px;margin:0 0 6px;text-align:center}
p.sub{color:#b7bcd6;font-size:13px;text-align:center;margin:0 0 22px}
label{font-size:13px;color:#d3d6ea;display:block;margin:14px 0 6px}
input{width:100%;padding:11px 12px;border-radius:9px;border:1px solid #ffffff33;
background:#ffffff12;color:#fff;font-size:14px}
input:focus{outline:none;border-color:#7c8cff}
button{width:100%;margin-top:20px;padding:12px;border:none;border-radius:9px;
background:#5865f2;color:#fff;font-size:15px;font-weight:600;cursor:pointer}
button:hover{background:#4752c4}
.err{background:#ff525233;border:1px solid #ff5252;color:#ffd6d6;padding:9px 12px;
border-radius:8px;font-size:13px;margin-top:14px;text-align:center}
.privacy{margin-top:22px;padding:12px 14px;border-radius:10px;background:#ffb02233;
border:1px solid #ffb02255;font-size:12.5px;line-height:1.8;color:#ffe6b3}
.privacy b{color:#fff}
.tabs{display:flex;gap:8px;margin-bottom:20px;background:#ffffff0d;padding:5px;border-radius:11px}
.tab{flex:1;text-align:center;padding:9px 6px;border-radius:8px;font-size:13px;font-weight:600;
color:#b7bcd6;cursor:pointer;user-select:none;transition:.15s}
.tab.active{background:#5865f2;color:#fff}
.tab.dev.active{background:#e8720c}
.signup-link{text-align:center;margin-top:16px;font-size:13px;color:#b7bcd6}
.signup-link a{color:#8fa0ff;text-decoration:none;font-weight:600}
.signup-link a:hover{text-decoration:underline}
</style>
</head>
<body>
<div class="card">
<h1 id="heading">๐ŸŽ™๏ธ Chatterbox Voice Studio</h1>
<p class="sub" id="subheading">ุงู„ุฑุฌุงุก ุชุณุฌูŠู„ ุงู„ุฏุฎูˆู„ ู„ู„ู…ุชุงุจุนุฉ</p>
<div class="tabs">
<div class="tab active" id="tabUser" onclick="setMode('user')">๐Ÿ‘ค ู…ุณุชุฎุฏู…</div>
<div class="tab dev" id="tabDev" onclick="setMode('dev')">๐Ÿ› ๏ธ ู…ุทูˆู‘ุฑ</div>
</div>
<form method="post" action="/login">
<label id="userLabel">ุงุณู… ุงู„ู…ุณุชุฎุฏู…</label>
<input type="text" name="username" required autocomplete="username" autofocus/>
<label id="passLabel">ูƒู„ู…ุฉ ุงู„ู…ุฑูˆุฑ</label>
<input type="password" name="password" required autocomplete="current-password"/>
<button type="submit" id="submitBtn">ุชุณุฌูŠู„ ุงู„ุฏุฎูˆู„</button>
</form>
ERROR_PH
<div class="signup-link" id="signupLink">
ู„ูŠุณ ู„ุฏูŠูƒ ุญุณุงุจุŸ <a href="/signup">ุฅู†ุดุงุก ุญุณุงุจ ุฌุฏูŠุฏ</a>
</div>
<div class="privacy">
๐Ÿ”’ <b>ุชู†ูˆูŠู‡ ุฎุตูˆุตูŠุฉ:</b> ูŠู‚ูˆู… ู‡ุฐุง ุงู„ุชุทุจูŠู‚ ุจุญูุธ ุงู„ุฃุตูˆุงุช ุงู„ู…ูุฏุฎู„ุฉ (ุงู„ุนูŠู‘ู†ุงุช ุงู„ู…ุฑุฌุนูŠุฉ)
ูˆุงู„ู…ุฎุฑุฌุงุช ุงู„ุตูˆุชูŠุฉ ุงู„ู†ุงุชุฌุฉุŒ ูˆูƒุฐู„ูƒ ุงู„ู†ุตูˆุต ุงู„ู…ุณุชุฎุฏู…ุฉุŒ ูˆุฐู„ูƒ ููŠ ุชุฎุฒูŠู† ุฏุงุฆู… ุฎุงุต ุจุญุณุงุจูƒ
ู„ุชุชู…ูƒู† ู…ู† ุงุณุชุฑุฌุงุนู‡ุง ู„ุงุญู‚ุงู‹. ู„ุง ูŠู…ูƒู† ู„ุฃูŠ ู…ุณุชุฎุฏู… ุขุฎุฑ ุงู„ูˆุตูˆู„ ุฅู„ู‰ ู…ู„ูุงุชูƒ ุฃูˆ ุณุฌู„ู‘ูƒ.
ูŠู…ูƒู† ู„ู…ุงู„ูƒ ุงู„ุฎุฏู…ุฉ (ุญุณุงุจ ุงู„ู…ุทูˆู‘ุฑ) ุงู„ูˆุตูˆู„ ู„ุฃุบุฑุงุถ ุงู„ุตูŠุงู†ุฉ ูˆุงู„ุงู…ุชุซุงู„ ูู‚ุท.
ูƒู„ ู…ู‚ุทุน ุตูˆุชูŠ ู…ูˆู„ูŽู‘ุฏ ูŠุญู…ู„ ุจุตู…ุฉ ุชุญู‚ู‚ ุบูŠุฑ ู…ุณู…ูˆุนุฉ (PerTh) ุชุซุจุช ุฃู†ู‡ ู…ู† ุฅู†ุชุงุฌ ุงู„ุฐูƒุงุก ุงู„ุงุตุทู†ุงุนูŠ.
ุจุฑุฌุงุก ุนุฏู… ุฑูุน ุชุณุฌูŠู„ุงุช ุตูˆุชูŠุฉ ู„ุฃุดุฎุงุต ุฏูˆู† ู…ูˆุงูู‚ุชู‡ู….
</div>
</div>
<script>
function setMode(mode) {
const dev = mode === 'dev';
document.getElementById('tabUser').classList.toggle('active', !dev);
document.getElementById('tabDev').classList.toggle('active', dev);
document.getElementById('heading').textContent = dev ? '๐Ÿ› ๏ธ ุฏุฎูˆู„ ุงู„ู…ุทูˆู‘ุฑ' : '๐ŸŽ™๏ธ Chatterbox Voice Studio';
document.getElementById('subheading').textContent = dev
? 'ุฃุฏุฎู„ ุจูŠุงู†ุงุช ุญุณุงุจ ุงู„ู…ุทูˆู‘ุฑ (ADMIN_USERNAME / ADMIN_PASSWORD)'
: 'ุงู„ุฑุฌุงุก ุชุณุฌูŠู„ ุงู„ุฏุฎูˆู„ ู„ู„ู…ุชุงุจุนุฉ';
document.getElementById('userLabel').textContent = dev ? 'ุงุณู… ู…ุณุชุฎุฏู… ุงู„ู…ุทูˆู‘ุฑ' : 'ุงุณู… ุงู„ู…ุณุชุฎุฏู…';
document.getElementById('passLabel').textContent = dev ? 'ูƒู„ู…ุฉ ู…ุฑูˆุฑ ุงู„ู…ุทูˆู‘ุฑ' : 'ูƒู„ู…ุฉ ุงู„ู…ุฑูˆุฑ';
document.getElementById('submitBtn').textContent = dev ? 'ุฏุฎูˆู„ ูƒู…ุทูˆู‘ุฑ' : 'ุชุณุฌูŠู„ ุงู„ุฏุฎูˆู„';
document.getElementById('submitBtn').style.background = dev ? '#e8720c' : '#5865f2';
document.getElementById('signupLink').style.display = dev ? 'none' : 'block';
}
</script>
</body>
</html>"""
@app.get("/login", response_class=HTMLResponse)
async def login_page(error: str = ""):
msg = ""
if error == "1":
msg = '<div class="err">ุงุณู… ุงู„ู…ุณุชุฎุฏู… ุฃูˆ ูƒู„ู…ุฉ ุงู„ู…ุฑูˆุฑ ุบูŠุฑ ุตุญูŠุญุฉ.</div>'
return LOGIN_HTML.replace("ERROR_PH", msg)
@app.post("/login")
async def login_submit(request: Request, username: str = Form(...), password: str = Form(...)):
users = load_users()
uname = safe_username(username)
rec = users.get(uname)
if not rec or not _verify_password(password, rec["password"]):
return RedirectResponse(url="/login?error=1", status_code=303)
request.session["user"] = {"username": uname, "role": rec.get("role", "user")}
return RedirectResponse(url="/", status_code=303)
@app.get("/logout")
async def logout(request: Request):
request.session.clear()
return RedirectResponse(url="/login")
SIGNUP_HTML = r"""<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>ุฅู†ุดุงุก ุญุณุงุจ โ€” Chatterbox Voice Studio</title>
<style>
*{box-sizing:border-box;font-family:'Segoe UI',Tahoma,Arial,sans-serif}
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
background:linear-gradient(135deg,#151a2e,#1f2745);padding:16px}
.card{background:#ffffff10;backdrop-filter:blur(10px);border:1px solid #ffffff22;
border-radius:16px;padding:32px 28px;max-width:400px;width:100%;color:#f2f3f8}
h1{font-size:20px;margin:0 0 6px;text-align:center}
p.sub{color:#b7bcd6;font-size:13px;text-align:center;margin:0 0 22px}
label{font-size:13px;color:#d3d6ea;display:block;margin:14px 0 6px}
input{width:100%;padding:11px 12px;border-radius:9px;border:1px solid #ffffff33;
background:#ffffff12;color:#fff;font-size:14px}
input:focus{outline:none;border-color:#7c8cff}
button{width:100%;margin-top:20px;padding:12px;border:none;border-radius:9px;
background:#5865f2;color:#fff;font-size:15px;font-weight:600;cursor:pointer}
button:hover{background:#4752c4}
.err{background:#ff525233;border:1px solid #ff5252;color:#ffd6d6;padding:9px 12px;
border-radius:8px;font-size:13px;margin-top:14px;text-align:center}
.hint{color:#8d92b0;font-size:12px;margin-top:6px}
.login-link{text-align:center;margin-top:16px;font-size:13px;color:#b7bcd6}
.login-link a{color:#8fa0ff;text-decoration:none;font-weight:600}
.login-link a:hover{text-decoration:underline}
</style>
</head>
<body>
<div class="card">
<h1>๐ŸŽ™๏ธ ุฅู†ุดุงุก ุญุณุงุจ ุฌุฏูŠุฏ</h1>
<p class="sub">ุณุฌู‘ู„ ู„ู„ุญุตูˆู„ ุนู„ู‰ ู…ุณุงุญุชูƒ ุงู„ุฎุงุตุฉ ููŠ Chatterbox Voice Studio</p>
<form method="post" action="/signup">
<label>ุงุณู… ุงู„ู…ุณุชุฎุฏู…</label>
<input type="text" name="username" required autocomplete="username" autofocus minlength="3" maxlength="40"/>
<label>ูƒู„ู…ุฉ ุงู„ู…ุฑูˆุฑ</label>
<input type="password" name="password" required autocomplete="new-password"/>
<div class="hint">8 ุฃุญุฑู ุนู„ู‰ ุงู„ุฃู‚ู„ุŒ ูˆุชุญุชูˆูŠ ุนู„ู‰ ุญุฑู ูƒุจูŠุฑ ูˆุญุฑู ุตุบูŠุฑ ูˆุฑู‚ู… ูˆุฑู…ุฒ.</div>
<label>ุชุฃูƒูŠุฏ ูƒู„ู…ุฉ ุงู„ู…ุฑูˆุฑ</label>
<input type="password" name="password2" required autocomplete="new-password"/>
<button type="submit">ุฅู†ุดุงุก ุงู„ุญุณุงุจ</button>
</form>
ERROR_PH
<div class="login-link">
ู„ุฏูŠูƒ ุญุณุงุจ ุจุงู„ูุนู„ุŸ <a href="/login">ุชุณุฌูŠู„ ุงู„ุฏุฎูˆู„</a>
</div>
</div>
</body>
</html>"""
SIGNUP_ERRORS = {
"badname": "ุงุณู… ุงู„ู…ุณุชุฎุฏู… ุบูŠุฑ ุตุงู„ุญ (3 ุฃุญุฑู ุนู„ู‰ ุงู„ุฃู‚ู„).",
"exists": "ุงุณู… ุงู„ู…ุณุชุฎุฏู… ู…ุณุชุฎุฏู… ุจุงู„ูุนู„ุŒ ุฌุฑู‘ุจ ุงุณู…ุงู‹ ุขุฎุฑ.",
"weak": "ูƒู„ู…ุฉ ุงู„ู…ุฑูˆุฑ ุถุนูŠูุฉ: ูŠุฌุจ ุฃู† ุชุญุชูˆูŠ 8 ุฃุญุฑู ุนู„ู‰ ุงู„ุฃู‚ู„ุŒ ุญุฑู ูƒุจูŠุฑ ูˆุญุฑู ุตุบูŠุฑ ูˆุฑู‚ู… ูˆุฑู…ุฒ.",
"mismatch": "ูƒู„ู…ุฉ ุงู„ู…ุฑูˆุฑ ูˆุชุฃูƒูŠุฏู‡ุง ุบูŠุฑ ู…ุชุทุงุจู‚ูŠู†.",
}
@app.get("/signup", response_class=HTMLResponse)
async def signup_page(error: str = ""):
msg = f'<div class="err">{SIGNUP_ERRORS[error]}</div>' if error in SIGNUP_ERRORS else ""
return SIGNUP_HTML.replace("ERROR_PH", msg)
@app.post("/signup")
async def signup_submit(request: Request, username: str = Form(...),
password: str = Form(...), password2: str = Form(...)):
uname = safe_username(username)
if not uname or len(uname) < 3:
return RedirectResponse(url="/signup?error=badname", status_code=303)
if password != password2:
return RedirectResponse(url="/signup?error=mismatch", status_code=303)
if not is_strong_password(password):
return RedirectResponse(url="/signup?error=weak", status_code=303)
users = load_users()
if uname in users:
return RedirectResponse(url="/signup?error=exists", status_code=303)
users[uname] = {"password": _hash_password(password), "role": "user", "created": int(time.time())}
save_users(users)
request.session["user"] = {"username": uname, "role": "user"}
return RedirectResponse(url="/", status_code=303)
ADMIN_HTML = r"""<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>ู„ูˆุญุฉ ุงู„ู…ุทูˆู‘ุฑ โ€” Chatterbox Voice Studio</title>
<style>
*{box-sizing:border-box;font-family:'Segoe UI',Tahoma,Arial,sans-serif}
body{margin:0;background:#0f1220;color:#eef;padding:24px}
.wrap{max-width:640px;margin:0 auto}
h1{font-size:20px} a{color:#8ea1ff}
.card{background:#ffffff10;border:1px solid #ffffff22;border-radius:14px;padding:18px;margin-bottom:18px}
table{width:100%;border-collapse:collapse;font-size:14px}
th,td{padding:9px 6px;border-bottom:1px solid #ffffff22;text-align:right}
input,select{width:100%;padding:9px;border-radius:8px;border:1px solid #ffffff33;
background:#ffffff12;color:#fff;margin-top:6px}
button{padding:8px 14px;border:none;border-radius:8px;background:#5865f2;color:#fff;
font-weight:600;cursor:pointer;margin-top:14px}
button.del{background:#e5484d;padding:5px 10px;font-weight:500;margin:0}
.msg{background:#ffb02233;border:1px solid #ffb02255;padding:8px 12px;border-radius:8px;font-size:13px}
.top{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}
</style>
</head>
<body>
<div class="wrap">
<div class="top">
<h1>ู„ูˆุญุฉ ุงู„ู…ุทูˆู‘ุฑ โ€” ุฅุฏุงุฑุฉ ุงู„ู…ุณุชุฎุฏู…ูŠู†</h1>
<a href="/">โ‡ฆ ุฑุฌูˆุน ู„ู„ุชุทุจูŠู‚</a>
</div>
<p>ู…ุณุฌู‘ู„ ุญุงู„ูŠุงู‹: <b>USERNAME_PH</b> ยท <a href="/logout">ุชุณุฌูŠู„ ุงู„ุฎุฑูˆุฌ</a></p>
MSG_PH
<div class="card">
<h3>ุงู„ู…ุณุชุฎุฏู…ูˆู† ุงู„ุญุงู„ูŠูˆู†</h3>
<table><tr><th>ุงุณู… ุงู„ู…ุณุชุฎุฏู…</th><th>ุงู„ุตู„ุงุญูŠุฉ</th><th></th></tr>ROWS_PH</table>
</div>
<div class="card">
<h3>ุฅุถุงูุฉ ู…ุณุชุฎุฏู… ุฌุฏูŠุฏ</h3>
<form method="post" action="/admin/users/create">
<label>ุงุณู… ุงู„ู…ุณุชุฎุฏู…</label>
<input name="username" required/>
<label>ูƒู„ู…ุฉ ู…ุฑูˆุฑ ู‚ูˆูŠุฉ (8+ ุญุฑูˆูุŒ ูƒุจูŠุฑุฉ+ุตุบูŠุฑุฉ+ุฑู‚ู…+ุฑู…ุฒ)</label>
<input name="password" type="password" required/>
<label>ุงู„ุตู„ุงุญูŠุฉ</label>
<select name="role"><option value="user">ู…ุณุชุฎุฏู…</option><option value="admin">ู…ุทูˆู‘ุฑ</option></select>
<button type="submit">ุฅู†ุดุงุก ุงู„ุญุณุงุจ</button>
</form>
</div>
</div>
</body>
</html>"""
@app.get("/admin", response_class=HTMLResponse)
async def admin_page(request: Request, error: str = ""):
users = load_users()
me = request.session["user"]["username"]
rows = "".join(
f"<tr><td>{u}</td><td>{d.get('role')}</td><td>"
+ (f"<form method='post' action='/admin/users/delete' style='display:inline'>"
f"<input type='hidden' name='username' value='{u}'>"
f"<button class='del' type='submit'>ุญุฐู</button></form>" if u != me else "โ€”")
+ "</td></tr>"
for u, d in users.items()
)
msg = ""
if error == "weak":
msg = '<div class="msg">ูƒู„ู…ุฉ ุงู„ู…ุฑูˆุฑ ุถุนูŠูุฉ โ€” ูŠุฌุจ 8 ุฃุญุฑู ุนู„ู‰ ุงู„ุฃู‚ู„ ูˆุชุญุชูˆูŠ ุญุฑูุงู‹ ูƒุจูŠุฑุงู‹ ูˆุตุบูŠุฑุงู‹ ูˆุฑู‚ู…ุงู‹ ูˆุฑู…ุฒุงู‹.</div>'
elif error == "badname":
msg = '<div class="msg">ุงุณู… ู…ุณุชุฎุฏู… ุบูŠุฑ ุตุงู„ุญ ุฃูˆ ู…ูˆุฌูˆุฏ ู…ุณุจู‚ุงู‹.</div>'
elif error == "self":
msg = '<div class="msg">ู„ุง ูŠู…ูƒู†ูƒ ุญุฐู ุญุณุงุจูƒ ุงู„ุญุงู„ูŠ.</div>'
return (ADMIN_HTML.replace("USERNAME_PH", me)
.replace("ROWS_PH", rows)
.replace("MSG_PH", msg))
@app.post("/admin/users/create")
async def admin_create_user(username: str = Form(...), password: str = Form(...), role: str = Form("user")):
uname = safe_username(username)
if not uname or len(uname) < 3:
return RedirectResponse(url="/admin?error=badname", status_code=303)
if not is_strong_password(password):
return RedirectResponse(url="/admin?error=weak", status_code=303)
users = load_users()
if uname in users:
return RedirectResponse(url="/admin?error=badname", status_code=303)
users[uname] = {
"password": _hash_password(password),
"role": "admin" if role == "admin" else "user",
"created": int(time.time()),
}
save_users(users)
return RedirectResponse(url="/admin", status_code=303)
@app.post("/admin/users/delete")
async def admin_delete_user(request: Request, username: str = Form(...)):
if username == request.session["user"]["username"]:
return RedirectResponse(url="/admin?error=self", status_code=303)
users = load_users()
users.pop(username, None)
save_users(users)
return RedirectResponse(url="/admin", status_code=303)
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
if __name__ == "__main__":
# app.launch(...) โ€” not uvicorn.run(...) โ€” is what wires this Server
# up to Gradio's queuing engine and the ZeroGPU broker on Spaces.
app.launch(server_name="0.0.0.0", server_port=7860)