| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| import spaces |
|
|
| import os, re, time, json, uuid, shutil, asyncio, threading, hashlib, secrets |
| import numpy as np |
| import soundfile as sf |
| |
| |
| |
| |
| 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 |
|
|
| |
| 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_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() |
|
|
| |
| |
| |
| |
| |
| |
| 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") |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| from chatterbox.mtl_tts import ChatterboxMultilingualTTS |
| import inspect as _inspect |
|
|
| device = "cuda" if torch.cuda.is_available() else "cpu" |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| xtts = None |
| xtts_ready = False |
| xtts_loading = True |
| xtts_error = None |
|
|
| |
| |
| |
| _generate_valid_params = set() |
|
|
| try: |
| print(f"[*] Loading Chatterbox Multilingual V3 on {device.upper()} โฆ") |
| try: |
| |
| |
| |
| |
| 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 = 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_progress: dict = {} |
| cancel_events: dict = {} |
| 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) |
|
|
| |
| 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() |
|
|
| |
| try: |
| from deep_translator import GoogleTranslator |
| TRANSLATOR_OK = True |
| except Exception: |
| TRANSLATOR_OK = False |
|
|
| |
| 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 |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| def stability_to_temperature(s): return round(0.95 - (float(s) * 0.55), 3) |
| def clarity_to_cfg_weight(c): return round(0.15 + (float(c) * 0.65), 3) |
| def style_to_exaggeration(st): return round(0.20 + (float(st) * 1.10), 3) |
|
|
| |
| |
| |
| 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), |
| } |
|
|
| |
| |
| |
| |
| 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() |
|
|
| |
| t = re.sub(r'[ \t]+', ' ', t) |
|
|
| |
| |
| t = re.sub(r'([^\.\!\?ุุ,\n])\n', r'\1.\n', t) |
|
|
| |
| if mode == "excited": |
| |
| t = re.sub(r'\.\s+', '. ', t) |
| |
| for conj in ['ููู','ุจู','ุฅูุง','however','but','yet','still']: |
| t = re.sub(rf'\b({conj})\b', r', \1', t, flags=re.IGNORECASE) |
|
|
| elif mode == "sad": |
| |
| t = re.sub(r'\.\s+', 'โฆ ', t) |
| t = re.sub(r'ุ\s+', 'ุ ', t) |
|
|
| elif mode == "story": |
| |
| t = re.sub(r'(ูุงู|ูุงูุช|ูู ููู
|ุฐุงุช ููู
|once upon|there was)', |
| r'\1ุ', t, flags=re.IGNORECASE) |
|
|
| elif mode == "news": |
| |
| t = re.sub(r'\s*โฆ+\s*', '. ', t) |
|
|
| elif mode == "child": |
| |
| t = re.sub(r'\.\s+([A-Zุง-ู])', r'! \1', t) |
|
|
| |
| t = re.sub(r'([.!?ุุ,]){2,}', r'\1', t) |
| t = re.sub(r'โฆ{2,}', 'โฆ', t) |
|
|
| return t |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| EMOTION_DELTAS = { |
| |
| "neutral": ( 0.00, 1.000, 0.00, 110 ), |
| "question": (-0.05, 0.960, +0.15, 140 ), |
| "exclaim": (+0.35, 1.080, -0.20, 75 ), |
| "emphasis": (+0.15, 0.900, +0.10, 155 ), |
| "sadness": (-0.15, 0.870, +0.10, 185 ), |
| "joy": (+0.30, 1.060, -0.15, 85 ), |
| "anger": (+0.25, 1.040, -0.10, 95 ), |
| "soft": (-0.20, 0.920, +0.15, 160 ), |
| "urgency": (+0.30, 1.120, -0.15, 65 ), |
| "story": (+0.15, 0.950, -0.05, 135 ), |
| } |
|
|
| |
| EMOTION_PATTERNS = { |
| "joy": [ |
| |
| r'ุณุนูุฏ|ูุฑุญ|ุฑุงุฆุน|ู
ุฐูู|ุนุธูู
|ู
ู
ุชุงุฒ|ุจุฑุงูู|ู
ุจุฑูู|ูุงู|ูุง ูุฑูุน|' |
| r'ุฌู
ูู|ุจุฏูุน|ุฃุญุจ|ูุฌุญ|ููู|ุงููู|ูุณุนุฏ|ุณุฑูุฑ|ุงุจุชูุงุฌ|ุจูุฌุฉ|ูุดูุฉ', |
| |
| 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', |
| ], |
| } |
|
|
| |
| 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() |
|
|
| |
| 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 |
|
|
| |
| scores: dict[str, float] = {} |
| for emotion, patterns in EMOTION_PATTERNS.items(): |
| for pat in patterns: |
| matches = re.findall(pat, p, re.IGNORECASE) |
| if matches: |
| |
| 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) |
| return best, conf |
|
|
| |
| if len(p) < 20: |
| return "emphasis", 0.35 |
| if p.endswith('โฆ') or p.endswith('...'): |
| return "soft", 0.50 |
|
|
| return "neutral", 0.0 |
|
|
|
|
| |
| 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 |
|
|
| 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)) |
|
|
| |
| |
| 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)) |
|
|
| |
| 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)) |
|
|
| |
| |
| 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 |
|
|
|
|
| |
| 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": |
| |
| rise_start = 0.75 |
| env += np.where(t > rise_start, |
| (t - rise_start) / (1.0 - rise_start) * 0.07 * v, 0) |
|
|
| elif emotion == "exclaim": |
| |
| env += (1.0 - t) * 0.05 * v |
| env += np.sin(t * np.pi * 2) * 0.03 * v |
|
|
| elif emotion == "emphasis": |
| |
| env += np.sin(t * np.pi) * 0.10 * v |
|
|
| elif emotion == "sadness": |
| |
| env -= t * 0.08 * v |
| env = np.clip(env, 0.70, 1.0) |
|
|
| elif emotion == "joy": |
| |
| env += t * 0.04 * v + 0.02 * v |
|
|
| elif emotion == "anger": |
| |
| attack = np.where(t < 0.05, t / 0.05, 1.0) |
| env *= attack |
| env += 0.04 * v |
|
|
| elif emotion == "soft": |
| |
| env -= 0.06 * v |
|
|
| elif emotion == "urgency": |
| |
| env += 0.06 * v |
|
|
| elif emotion == "story": |
| |
| env += np.sin(t * np.pi * 0.7) * 0.05 * v |
|
|
| |
| 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 |
| |
| 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) |
|
|
| |
| def apply_speaker_boost(path): |
| data, sr = sf.read(path) |
| peak = np.max(np.abs(data)) |
| if peak > 0: |
| data = data / peak * 0.95 |
| |
| |
| data = np.tanh(data * 1.8) * (1.0 / np.tanh(1.8)) |
| sf.write(path, data, sr, subtype='PCM_16') |
|
|
|
|
| |
| |
| |
| |
| |
|
|
| |
| |
| HUMANIZE_PRESETS = { |
| "studio": dict( |
| |
| 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( |
| |
| 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( |
| |
| 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( |
| |
| 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( |
| |
| 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( |
| |
| 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( |
| |
| 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, |
| ), |
| } |
|
|
| |
|
|
| 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)) |
| 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 |
| |
| 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 |
|
|
| |
| 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 |
|
|
| |
| 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) |
|
|
| result = sosfilt(sos, data.astype(np.float64)) |
|
|
| |
| 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) |
| 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 |
|
|
| 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 |
| |
| 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)) |
| |
| k = max(1, int(0.2 * sr)) |
| drift = np.convolve(drift, np.ones(k)/k, mode='same') |
| |
| ratio = 2 ** (cents / 1200) |
| factor = 1.0 + (ratio - 1.0) * drift |
| factor = np.clip(factor, 0.90, 1.10) |
|
|
| |
| 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) |
| 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 |
| driven = data * drive |
| |
| sat = np.tanh(driven) / np.tanh(drive) |
| return (data * (1.0 - amount) + sat * amount).astype(np.float32) |
|
|
| |
| 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) |
| |
| 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") |
|
|
| |
| _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) |
|
|
| |
| _safe(_soft_saturate, "Saturation", data, p["saturation"] * mix) |
|
|
| |
| _safe(_timing_jitter, "Jitter", data, sr, p["jitter_ms"] * mix, rng) |
|
|
| |
| _safe(_micro_pitch_drift, "Pitch drift", data, sr, |
| p["pitch_drift_cents"] * mix, rng) |
|
|
| |
| _safe(_comb_reverb, "Reverb", data, sr, |
| p["reverb_room"], p["reverb_damping"], p["reverb_wet"] * mix) |
|
|
| |
| 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") |
|
|
| |
| peak = float(np.max(np.abs(data))) |
| if peak > 0: |
| data = data / peak * 0.92 |
| |
| data = (np.tanh(data * 1.1) * (1.0 / np.tanh(1.1))).astype(np.float32) |
|
|
| sf.write(out_path, data, sr, subtype='PCM_16') |
|
|
|
|
| |
| 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_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] |
|
|
| |
| |
| |
| |
| |
| |
| 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) |
|
|
| |
| |
| |
| |
| |
| |
| 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)) |
| 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 |
|
|
| |
| 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)), |
| } |
| |
| 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"} |
|
|
| |
| |
| prompt_path = prepare_voice_prompt(refs) |
| prompt_refs = [prompt_path] |
| is_temp_prompt = prompt_path.startswith("/tmp/prompt_") |
|
|
| try: |
| |
| 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) |
| 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 |
|
|
| |
| 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 = 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) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| app.add_middleware( |
| SessionMiddleware, secret_key=SESSION_SECRET, session_cookie="xtts_session", |
| max_age=60 * 60 * 24 * 7, same_site="none", https_only=True, |
| ) |
|
|
| |
| |
| |
| |
| |
| |
| |
| 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"} |
|
|
| |
| |
| |
| 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>""" |
|
|
| |
| |
| |
| @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)) |
|
|
|
|
| |
| @app.get("/model_status") |
| def model_status(): |
| return JSONResponse({ |
| "loading": xtts_loading, |
| "ready": xtts_ready, |
| "error": xtts_error, |
| "device": device, |
| }) |
|
|
| |
| @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} |
|
|
|
|
| |
| @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), |
| fade_out: float = Form(0.0), |
| normalize: bool = Form(False), |
| ): |
| """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) |
|
|
| |
| 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("ุงูู
ูู ูุงุฑุบ ุจุนุฏ ุงููุทุน โ ูููู ููู
ุงููุทุน.") |
|
|
| |
| 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 |
|
|
| |
| if normalize: |
| peak = np.max(np.abs(data)) |
| if peak > 0: |
| data = data / peak * 0.98 |
|
|
| |
| 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)) |
|
|
| |
| if fade_in > 0: |
| n = min(int(fade_in * sr), len(data)) |
| data[:n] *= np.linspace(0.0, 1.0, n, dtype=np.float32) |
|
|
| |
| 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} |
|
|
|
|
| |
| @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)} |
|
|
|
|
| |
| @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} |
|
|
|
|
| |
| @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} |
|
|
|
|
| |
| @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)} |
|
|
|
|
| |
| @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, "ูุง ุชูุฌุฏ ู
ููุงุช ุตูุชูุฉ.") |
|
|
|
|
| |
| |
| |
| 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(server_name="0.0.0.0", server_port=7860) |