Arabi32 commited on
Commit
854c400
·
verified ·
1 Parent(s): 1d30ce9

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +880 -0
app.py ADDED
@@ -0,0 +1,880 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ╔══════════════════════════════════════════════════════════════════╗
2
+ # ║ XTTS v2 Advanced Voice Studio — Hugging Face Edition ║
3
+ # ╚══════════════════════════════════════════════════════════════════╝
4
+
5
+ import os, sys, time, json, uuid, shutil, threading, subprocess
6
+ import nest_asyncio
7
+ import uvicorn
8
+ from fastapi import FastAPI, Form, File, UploadFile, HTTPException
9
+ from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
10
+ from fastapi.middleware.cors import CORSMiddleware
11
+ from huggingface_hub import HfApi, snapshot_download
12
+
13
+ # ── Env & Configuration ──────────────────────────────────────────────
14
+ os.environ["COQUI_TOS_AGREED"] = "1"
15
+
16
+ # Directory structure for easy syncing
17
+ DATA_DIR = "data"
18
+ VOICE_LIB = os.path.join(DATA_DIR, "voice_library")
19
+ OUTPUT_DIR = os.path.join(DATA_DIR, "outputs")
20
+ HISTORY_FILE = os.path.join(DATA_DIR, "history.json")
21
+
22
+ for d in [DATA_DIR, VOICE_LIB, OUTPUT_DIR]:
23
+ os.makedirs(d, exist_ok=True)
24
+
25
+ # Hugging Face Dataset Sync Logic
26
+ HF_TOKEN = os.environ.get("HF_TOKEN")
27
+ HF_DATASET_REPO = os.environ.get("HF_DATASET_REPO") # e.g., "username/xtts-data"
28
+
29
+ def pull_data_from_hf():
30
+ """تحميل البيانات المحفوظة من الـ Dataset عند بدء تشغيل التطبيق"""
31
+ if HF_TOKEN and HF_DATASET_REPO:
32
+ try:
33
+ print(f"[*] Downloading data from dataset: {HF_DATASET_REPO}...")
34
+ snapshot_download(
35
+ repo_id=HF_DATASET_REPO,
36
+ repo_type="dataset",
37
+ local_dir=DATA_DIR,
38
+ token=HF_TOKEN
39
+ )
40
+ print("[✓] Data loaded successfully.")
41
+ except Exception as e:
42
+ print(f"[!] Warning: Could not download data from HF: {e}")
43
+
44
+ def push_data_to_hf():
45
+ """رفع البيانات إلى الـ Dataset في الخلفية لحفظها دائمًا"""
46
+ if HF_TOKEN and HF_DATASET_REPO:
47
+ try:
48
+ api = HfApi()
49
+ api.upload_folder(
50
+ folder_path=DATA_DIR,
51
+ repo_id=HF_DATASET_REPO,
52
+ repo_type="dataset",
53
+ token=HF_TOKEN,
54
+ commit_message=f"Auto-sync at {time.time()}"
55
+ )
56
+ print("[✓] Data synced to Hugging Face Dataset.")
57
+ except Exception as e:
58
+ print(f"[!] Sync error: {e}")
59
+
60
+ def background_sync():
61
+ """تشغيل المزامنة في Thread منفصل لكي لا يتوقف الخادم"""
62
+ threading.Thread(target=push_data_to_hf, daemon=True).start()
63
+
64
+ # Load initial data
65
+ pull_data_from_hf()
66
+
67
+ # Kill leftover processes on port 7860 (HF default port)
68
+ try:
69
+ subprocess.run(["fuser", "-k", "7860/tcp"],
70
+ stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL)
71
+ except:
72
+ pass
73
+
74
+ # ── Install & Load TTS ───────────────────────────────────────────────
75
+ try:
76
+ from TTS.api import TTS
77
+ except ImportError:
78
+ subprocess.check_call([sys.executable, "-m", "pip", "install", "coqui-tts"])
79
+ from TTS.api import TTS
80
+
81
+ import torch
82
+ device = "cuda" if torch.cuda.is_available() else "cpu"
83
+
84
+ if "xtts_engine" not in globals():
85
+ print(f"[*] Loading XTTS v2 on {device.upper()} …")
86
+ xtts_engine = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to(device)
87
+ print("[✓] Model ready.")
88
+
89
+ # ── History helpers ──────────────────────────────────────────────────
90
+ def load_history():
91
+ if os.path.exists(HISTORY_FILE):
92
+ try:
93
+ return json.load(open(HISTORY_FILE))
94
+ except:
95
+ pass
96
+ return []
97
+
98
+ def save_history(h):
99
+ with open(HISTORY_FILE, "w", encoding="utf-8") as f:
100
+ json.dump(h, f, ensure_ascii=False, indent=2)
101
+
102
+ # ── FastAPI app ──────────────────────────────────────────────────────
103
+ app = FastAPI(title="XTTS Studio")
104
+ app.add_middleware(CORSMiddleware, allow_origins=["*"],
105
+ allow_methods=["*"], allow_headers=["*"])
106
+
107
+ # ── Supported languages ──────────────────────────────────────────────
108
+ LANGUAGES = {
109
+ "ar": "العربية", "en": "English", "es": "Español",
110
+ "fr": "Français", "de": "Deutsch", "it": "Italiano",
111
+ "pt": "Português","ru": "Русский", "zh-cn": "中文",
112
+ "ja": "日本語", "ko": "한국어", "tr": "Türkçe",
113
+ "nl": "Nederlands","pl": "Polski", "cs": "Čeština",
114
+ "hi": "हिन्दी",
115
+ }
116
+
117
+ # ══════════════════════════════════════════════════════════════════════
118
+ # HTML / React Frontend
119
+ # ══════════════════════════════════════════════════════════════════════
120
+ HTML = r"""<!DOCTYPE html>
121
+ <html lang="ar" dir="rtl">
122
+ <head>
123
+ <meta charset="UTF-8">
124
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
125
+ <title>XTTS Voice Studio</title>
126
+ <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=IBM+Plex+Sans+Arabic:wght@300;400;600;700&display=swap" rel="stylesheet">
127
+ <script src="https://cdn.tailwindcss.com"></script>
128
+ <script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
129
+ <script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
130
+ <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
131
+ <style>
132
+ :root {
133
+ --bg: #0b0c0f;
134
+ --surface: #13151a;
135
+ --border: #1f2330;
136
+ --amber: #f5a623;
137
+ --amber-dim:#a06a10;
138
+ --green: #3ddc84;
139
+ --red: #ff5252;
140
+ --text: #e8eaf0;
141
+ --muted: #6b7280;
142
+ --mono: 'IBM Plex Mono', monospace;
143
+ --sans: 'IBM Plex Sans Arabic', sans-serif;
144
+ }
145
+ * { box-sizing: border-box; }
146
+ body {
147
+ margin: 0;
148
+ background: var(--bg);
149
+ color: var(--text);
150
+ font-family: var(--sans);
151
+ min-height: 100vh;
152
+ }
153
+ ::-webkit-scrollbar { width: 5px; }
154
+ ::-webkit-scrollbar-track { background: var(--surface); }
155
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
156
+ body::before {
157
+ content: "";
158
+ position: fixed; inset: 0; pointer-events: none; z-index: 0;
159
+ background:
160
+ repeating-linear-gradient(0deg, transparent, transparent 39px,
161
+ rgba(255,255,255,.02) 40px);
162
+ }
163
+ .card {
164
+ background: var(--surface);
165
+ border: 1px solid var(--border);
166
+ border-radius: 12px;
167
+ }
168
+ .amber { color: var(--amber); }
169
+ .tag {
170
+ font-family: var(--mono);
171
+ font-size: 10px;
172
+ letter-spacing: .12em;
173
+ text-transform: uppercase;
174
+ color: var(--muted);
175
+ }
176
+ input[type=range] {
177
+ -webkit-appearance: none;
178
+ width: 100%; height: 3px;
179
+ background: var(--border);
180
+ border-radius: 2px;
181
+ outline: none;
182
+ }
183
+ input[type=range]::-webkit-slider-thumb {
184
+ -webkit-appearance: none;
185
+ width: 14px; height: 14px;
186
+ background: var(--amber);
187
+ border-radius: 50%;
188
+ cursor: pointer;
189
+ transition: transform .15s;
190
+ }
191
+ input[type=range]::-webkit-slider-thumb:hover { transform: scale(1.3); }
192
+ select {
193
+ background: var(--bg);
194
+ border: 1px solid var(--border);
195
+ color: var(--text);
196
+ border-radius: 6px;
197
+ padding: 6px 10px;
198
+ font-family: var(--sans);
199
+ outline: none;
200
+ cursor: pointer;
201
+ }
202
+ select:focus { border-color: var(--amber); }
203
+ textarea {
204
+ background: var(--bg);
205
+ border: 1px solid var(--border);
206
+ color: var(--text);
207
+ border-radius: 8px;
208
+ font-family: var(--sans);
209
+ resize: vertical;
210
+ outline: none;
211
+ transition: border-color .2s;
212
+ padding: 12px;
213
+ width: 100%;
214
+ }
215
+ textarea:focus { border-color: var(--amber); }
216
+ .file-drop {
217
+ border: 2px dashed var(--border);
218
+ border-radius: 8px;
219
+ padding: 16px;
220
+ text-align: center;
221
+ cursor: pointer;
222
+ transition: border-color .2s, background .2s;
223
+ position: relative;
224
+ }
225
+ .file-drop:hover, .file-drop.active {
226
+ border-color: var(--amber);
227
+ background: rgba(245,166,35,.05);
228
+ }
229
+ .file-drop input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
230
+ .btn-primary {
231
+ background: var(--amber);
232
+ color: #000;
233
+ font-weight: 700;
234
+ border: none;
235
+ border-radius: 8px;
236
+ padding: 14px 24px;
237
+ cursor: pointer;
238
+ font-family: var(--sans);
239
+ font-size: 15px;
240
+ width: 100%;
241
+ transition: opacity .2s, transform .1s;
242
+ }
243
+ .btn-primary:hover { opacity: .9; }
244
+ .btn-primary:active { transform: scale(.98); }
245
+ .btn-primary:disabled { opacity: .4; cursor: not-allowed; }
246
+ .btn-ghost {
247
+ background: transparent;
248
+ border: 1px solid var(--border);
249
+ color: var(--muted);
250
+ border-radius: 6px;
251
+ padding: 6px 12px;
252
+ cursor: pointer;
253
+ font-size: 12px;
254
+ transition: color .2s, border-color .2s;
255
+ }
256
+ .btn-ghost:hover { color: var(--text); border-color: var(--muted); }
257
+ .badge {
258
+ display: inline-flex; align-items: center; gap: 4px;
259
+ background: rgba(245,166,35,.12);
260
+ border: 1px solid rgba(245,166,35,.3);
261
+ color: var(--amber);
262
+ border-radius: 20px;
263
+ padding: 2px 10px;
264
+ font-size: 11px;
265
+ font-family: var(--mono);
266
+ }
267
+ .badge.green {
268
+ background: rgba(61,220,132,.1);
269
+ border-color: rgba(61,220,132,.3);
270
+ color: var(--green);
271
+ }
272
+ .badge.red {
273
+ background: rgba(255,82,82,.1);
274
+ border-color: rgba(255,82,82,.3);
275
+ color: var(--red);
276
+ }
277
+ .pulse { animation: pulse 1.5s infinite; }
278
+ @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
279
+ .waveform {
280
+ display: flex; align-items: center; gap: 3px;
281
+ height: 28px;
282
+ }
283
+ .waveform span {
284
+ flex: 1;
285
+ background: var(--amber);
286
+ border-radius: 2px;
287
+ animation: wave 1s ease-in-out infinite;
288
+ opacity: .7;
289
+ }
290
+ @keyframes wave { 0%,100%{height:4px} 50%{height:24px} }
291
+ .waveform span:nth-child(2){animation-delay:.1s}
292
+ .waveform span:nth-child(3){animation-delay:.2s}
293
+ .waveform span:nth-child(4){animation-delay:.3s}
294
+ .waveform span:nth-child(5){animation-delay:.2s}
295
+ .waveform span:nth-child(6){animation-delay:.1s}
296
+ .waveform span:nth-child(7){animation-delay:.05s}
297
+ .tab { cursor:pointer; padding:8px 16px; border-radius:6px;
298
+ font-size:13px; color:var(--muted); transition:all .2s; }
299
+ .tab.active { background:rgba(245,166,35,.15); color:var(--amber); }
300
+ .tab:hover:not(.active) { color:var(--text); }
301
+ audio { width:100%; accent-color:var(--amber); }
302
+ audio::-webkit-media-controls-panel { background:var(--surface); }
303
+ .history-row {
304
+ display:flex; align-items:center; gap:12px;
305
+ padding:10px 14px; border-radius:8px;
306
+ border:1px solid var(--border);
307
+ background:var(--bg);
308
+ transition:border-color .2s;
309
+ }
310
+ .history-row:hover { border-color: var(--amber-dim); }
311
+ .param-row {
312
+ display:grid; grid-template-columns:140px 1fr 48px;
313
+ align-items:center; gap:12px;
314
+ }
315
+ .param-label { font-size:12px; color:var(--muted); font-family:var(--mono); }
316
+ .param-val { font-size:13px; color:var(--amber); font-family:var(--mono); text-align:right; }
317
+ .voice-card {
318
+ padding:10px 14px; border-radius:8px;
319
+ border:1px solid var(--border);
320
+ background:var(--bg); cursor:pointer;
321
+ transition:all .2s;
322
+ display:flex; align-items:center; justify-content:space-between;
323
+ }
324
+ .voice-card:hover { border-color:var(--amber-dim); }
325
+ .voice-card.selected {
326
+ border-color:var(--amber);
327
+ background:rgba(245,166,35,.06);
328
+ }
329
+ </style>
330
+ </head>
331
+ <body>
332
+ <div id="root"></div>
333
+ <script type="text/babel">
334
+ const { useState, useEffect, useRef, useCallback } = React;
335
+
336
+ const fmt = v => parseFloat(v).toFixed(2);
337
+ const apiPost = (url, body) =>
338
+ fetch(url, { method:"POST", body }).then(r => {
339
+ if (!r.ok) return r.json().then(e => { throw new Error(e.detail || "Server error"); });
340
+ return r.json();
341
+ });
342
+
343
+ function Slider({ label, min, max, step, value, onChange, hint }) {
344
+ return (
345
+ <div className="param-row">
346
+ <span className="param-label">{label}</span>
347
+ <input type="range" min={min} max={max} step={step}
348
+ value={value} onChange={e => onChange(parseFloat(e.target.value))} />
349
+ <span className="param-val">{fmt(value)}</span>
350
+ </div>
351
+ );
352
+ }
353
+
354
+ function FileZone({ label, file, onFile }) {
355
+ const [active, setActive] = useState(false);
356
+ return (
357
+ <div>
358
+ <div className="tag mb-1">{label}</div>
359
+ <div className={`file-drop ${active?"active":""}`}
360
+ onDragOver={e=>{e.preventDefault();setActive(true)}}
361
+ onDragLeave={()=>setActive(false)}
362
+ onDrop={e=>{e.preventDefault();setActive(false);onFile(e.dataTransfer.files[0]);}}>
363
+ <input type="file" accept="audio/*"
364
+ onChange={e=>onFile(e.target.files[0])} />
365
+ {file
366
+ ? <span style={{color:"var(--green)",fontSize:12}}>✓ {file.name}</span>
367
+ : <span style={{color:"var(--muted)",fontSize:12}}>اسحب ملفاً أو انقر للاختيار</span>}
368
+ </div>
369
+ </div>
370
+ );
371
+ }
372
+
373
+ function WaveAnim() {
374
+ return (
375
+ <div className="waveform">
376
+ {[1,2,3,4,5,6,7].map(i=><span key={i}/>)}
377
+ </div>
378
+ );
379
+ }
380
+
381
+ function App() {
382
+ const [tab, setTab] = useState("generate");
383
+ const [text, setText] = useState("");
384
+ const [lang, setLang] = useState("ar");
385
+ const [file1, setFile1] = useState(null);
386
+ const [file2, setFile2] = useState(null);
387
+ const [temperature, setTemp] = useState(0.75);
388
+ const [speed, setSpeed] = useState(1.0);
389
+ const [topK, setTopK] = useState(50);
390
+ const [topP, setTopP] = useState(0.85);
391
+ const [repPenalty, setRepPenalty] = useState(5.0);
392
+ const [splitText, setSplitText] = useState(true);
393
+ const [status, setStatus] = useState("idle");
394
+ const [statusMsg, setStatusMsg] = useState("");
395
+ const [audioUrl, setAudioUrl] = useState(null);
396
+ const [audioFilename, setAudioFilename] = useState(null);
397
+ const [history, setHistory] = useState([]);
398
+ const [voices, setVoices] = useState([]);
399
+ const [selVoice, setSelVoice] = useState(null);
400
+ const [saveName, setSaveName] = useState("");
401
+ const [saveStatus, setSaveStatus] = useState("");
402
+
403
+ const languages = LANGUAGES_JSON;
404
+
405
+ useEffect(() => {
406
+ fetch("/history").then(r=>r.json()).then(setHistory).catch(()=>{});
407
+ fetch("/voices").then(r=>r.json()).then(setVoices).catch(()=>{});
408
+ }, []);
409
+
410
+ const generate = async () => {
411
+ if (!text.trim()) return setStatusMsg("أدخل النص أولاً.");
412
+ const hasRef = file1 || selVoice;
413
+ if (!hasRef) return setStatusMsg("يجب تحديد عينة صوتية مرجعية أو اختيار صوت من المكتبة.");
414
+ setStatus("running"); setStatusMsg(""); setAudioUrl(null);
415
+
416
+ const fd = new FormData();
417
+ fd.append("text", text);
418
+ fd.append("language", lang);
419
+ fd.append("temperature", temperature);
420
+ fd.append("speed", speed);
421
+ fd.append("top_k", topK);
422
+ fd.append("top_p", topP);
423
+ fd.append("repetition_penalty", repPenalty);
424
+ fd.append("enable_text_splitting", splitText);
425
+ if (file1) fd.append("files", file1);
426
+ if (file2) fd.append("files", file2);
427
+ if (selVoice) fd.append("voice_name", selVoice);
428
+
429
+ try {
430
+ const data = await apiPost("/generate", fd);
431
+ setAudioUrl(`/audio/${data.filename}`);
432
+ setAudioFilename(data.filename);
433
+ setStatus("done");
434
+ fetch("/history").then(r=>r.json()).then(setHistory).catch(()=>{});
435
+ } catch(e) {
436
+ setStatus("error");
437
+ setStatusMsg(e.message);
438
+ }
439
+ };
440
+
441
+ const saveVoice = async () => {
442
+ if (!saveName.trim() || !file1) return;
443
+ const fd = new FormData();
444
+ fd.append("name", saveName.trim());
445
+ fd.append("file", file1);
446
+ if (file2) fd.append("file2", file2);
447
+ try {
448
+ await apiPost("/voices/save", fd);
449
+ setSaveStatus("✓ تم الحفظ");
450
+ fetch("/voices").then(r=>r.json()).then(setVoices).catch(()=>{});
451
+ setTimeout(()=>setSaveStatus(""),2000);
452
+ } catch(e) { setSaveStatus("خطأ: " + e.message); }
453
+ };
454
+
455
+ const deleteVoice = async name => {
456
+ await fetch(`/voices/${name}`, {method:"DELETE"});
457
+ setVoices(v => v.filter(x=>x!==name));
458
+ if (selVoice===name) setSelVoice(null);
459
+ };
460
+
461
+ const isRTL = ["ar","fa","he","ur"].includes(lang);
462
+
463
+ return (
464
+ <div style={{maxWidth:720,margin:"0 auto",padding:"24px 16px",position:"relative",zIndex:1}}>
465
+ <div style={{marginBottom:28,textAlign:"center"}}>
466
+ <div className="tag" style={{marginBottom:6}}>XTTS V2 MULTILINGUAL</div>
467
+ <h1 style={{margin:0,fontSize:26,fontWeight:700,letterSpacing:"-.02em"}}>
468
+ <span className="amber">Voice</span> Studio
469
+ </h1>
470
+ <div style={{marginTop:8,display:"flex",justifyContent:"center",gap:6}}>
471
+ <span className={`badge ${device==="cuda"?"green":"red"}`}>
472
+ ● {device.toUpperCase()}
473
+ </span>
474
+ <span className="badge">Hugging Face Space</span>
475
+ </div>
476
+ </div>
477
+
478
+ <div style={{display:"flex",gap:4,marginBottom:20,
479
+ background:"var(--surface)",border:"1px solid var(--border)",
480
+ borderRadius:8,padding:4}}>
481
+ {["generate","library","history"].map(t=>(
482
+ <div key={t} className={`tab${tab===t?" active":""}`}
483
+ onClick={()=>setTab(t)} style={{flex:1,textAlign:"center"}}>
484
+ {t==="generate"?"⚡ توليد":t==="library"?"📚 مكتبة الأصوات":"🕘 السجل"}
485
+ </div>
486
+ ))}
487
+ </div>
488
+
489
+ {tab==="generate" && (
490
+ <div style={{display:"flex",flexDirection:"column",gap:14}}>
491
+ <div className="card" style={{padding:16}}>
492
+ <div style={{display:"flex",justifyContent:"space-between",
493
+ alignItems:"center",marginBottom:10}}>
494
+ <div className="tag">النص المراد تحويله</div>
495
+ <select value={lang} onChange={e=>setLang(e.target.value)}>
496
+ {Object.entries(languages).map(([k,v])=>(
497
+ <option key={k} value={k}>{v}</option>
498
+ ))}
499
+ </select>
500
+ </div>
501
+ <textarea
502
+ dir={isRTL?"rtl":"ltr"}
503
+ rows={5}
504
+ placeholder={isRTL?"أدخل النص هنا…":"Enter text here…"}
505
+ value={text}
506
+ onChange={e=>setText(e.target.value)}
507
+ />
508
+ <div style={{textAlign:"left",fontSize:11,color:"var(--muted)",marginTop:4,
509
+ fontFamily:"var(--mono)"}}>
510
+ {text.length} chars
511
+ </div>
512
+ </div>
513
+
514
+ <div className="card" style={{padding:16}}>
515
+ <div className="tag" style={{marginBottom:10}}>المرجع الصوتي</div>
516
+ <div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:12}}>
517
+ <FileZone label="عينة 1 (مطلوبة)" file={file1} onFile={setFile1} />
518
+ <FileZone label="عينة 2 (اختيارية)" file={file2} onFile={setFile2} />
519
+ </div>
520
+ {voices.length>0 && (
521
+ <div style={{marginTop:12}}>
522
+ <div className="tag" style={{marginBottom:8}}>أو من المكتبة</div>
523
+ <div style={{display:"flex",flexWrap:"wrap",gap:8}}>
524
+ {voices.map(v=>(
525
+ <div key={v}
526
+ style={{padding:"6px 14px",borderRadius:20,cursor:"pointer",
527
+ fontSize:12,border:"1px solid",
528
+ borderColor:selVoice===v?"var(--amber)":"var(--border)",
529
+ color:selVoice===v?"var(--amber)":"var(--muted)",
530
+ background:selVoice===v?"rgba(245,166,35,.08)":"transparent",
531
+ transition:"all .2s"}}
532
+ onClick={()=>setSelVoice(selVoice===v?null:v)}>
533
+ {v}
534
+ </div>
535
+ ))}
536
+ </div>
537
+ </div>
538
+ )}
539
+ {file1 && (
540
+ <div style={{marginTop:12,display:"flex",gap:8,alignItems:"center"}}>
541
+ <input type="text"
542
+ placeholder="اسم الصوت للحفظ…"
543
+ value={saveName} onChange={e=>setSaveName(e.target.value)}
544
+ style={{flex:1,background:"var(--bg)",border:"1px solid var(--border)",
545
+ color:"var(--text)",borderRadius:6,padding:"6px 10px",
546
+ fontSize:12,fontFamily:"var(--sans)",outline:"none"}} />
547
+ <button className="btn-ghost" onClick={saveVoice}
548
+ style={{whiteSpace:"nowrap"}}>
549
+ حفظ في المكتبة
550
+ </button>
551
+ {saveStatus && <span style={{fontSize:12,color:"var(--green)"}}>{saveStatus}</span>}
552
+ </div>
553
+ )}
554
+ </div>
555
+
556
+ <div className="card" style={{padding:16}}>
557
+ <details>
558
+ <summary style={{cursor:"pointer",listStyle:"none",display:"flex",
559
+ justifyContent:"space-between",alignItems:"center",
560
+ userSelect:"none"}}>
561
+ <div className="tag">المعاملات المتقدمة</div>
562
+ <span style={{fontSize:11,color:"var(--muted)"}}>انقر للتوسيع ▾</span>
563
+ </summary>
564
+ <div style={{marginTop:14,display:"flex",flexDirection:"column",gap:14}}>
565
+ <Slider label="temperature" min={0.05} max={1.0} step={0.05}
566
+ value={temperature} onChange={setTemp}
567
+ hint="التنوع العاطفي في الصوت" />
568
+ <Slider label="speed" min={0.5} max={2.0} step={0.05}
569
+ value={speed} onChange={setSpeed}
570
+ hint="سرعة الكلام" />
571
+ <Slider label="top_k" min={1} max={100} step={1}
572
+ value={topK} onChange={setTopK}
573
+ hint="أعلى k احتمالات" />
574
+ <Slider label="top_p" min={0.1} max={1.0} step={0.05}
575
+ value={topP} onChange={setTopP}
576
+ hint="أعلى p احتمالات" />
577
+ <Slider label="rep_penalty" min={1.0} max={10.0} step={0.5}
578
+ value={repPenalty} onChange={setRepPenalty}
579
+ hint="عقوبة التكرار" />
580
+ <div style={{display:"flex",alignItems:"center",gap:10,paddingTop:4}}>
581
+ <label style={{display:"flex",alignItems:"center",gap:8,
582
+ cursor:"pointer",fontSize:12,color:"var(--muted)"}}>
583
+ <input type="checkbox" checked={splitText}
584
+ onChange={e=>setSplitText(e.target.checked)}
585
+ style={{accentColor:"var(--amber)",width:14,height:14}} />
586
+ تقسيم النص التلقائي (للنصوص الطويلة)
587
+ </label>
588
+ </div>
589
+ </div>
590
+ </details>
591
+ </div>
592
+
593
+ {statusMsg && (
594
+ <div style={{padding:"10px 14px",borderRadius:8,fontSize:13,
595
+ background:"rgba(255,82,82,.08)",border:"1px solid rgba(255,82,82,.3)",
596
+ color:"var(--red)"}}>
597
+ {statusMsg}
598
+ </div>
599
+ )}
600
+
601
+ <button className="btn-primary" onClick={generate}
602
+ disabled={status==="running"}>
603
+ {status==="running"
604
+ ? <span style={{display:"flex",alignItems:"center",justifyContent:"center",gap:10}}>
605
+ <WaveAnim/> جاري التوليد…
606
+ </span>
607
+ : "⚡ توليد الصوت"}
608
+ </button>
609
+
610
+ {audioUrl && (
611
+ <div className="card" style={{padding:16}}>
612
+ <div style={{display:"flex",justifyContent:"space-between",
613
+ alignItems:"center",marginBottom:12}}>
614
+ <div style={{display:"flex",alignItems:"center",gap:8}}>
615
+ <span className="badge green">✓ تم التوليد</span>
616
+ </div>
617
+ <a href={audioUrl} download={audioFilename}
618
+ style={{textDecoration:"none"}}>
619
+ <button className="btn-ghost">⬇ تحميل</button>
620
+ </a>
621
+ </div>
622
+ <audio src={audioUrl} controls autoPlay />
623
+ </div>
624
+ )}
625
+ </div>
626
+ )}
627
+
628
+ {tab==="library" && (
629
+ <div style={{display:"flex",flexDirection:"column",gap:12}}>
630
+ <div className="card" style={{padding:16}}>
631
+ <div className="tag" style={{marginBottom:8}}>إضافة صوت جديد للمكتبة</div>
632
+ <div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:12,marginBottom:12}}>
633
+ <FileZone label="عينة 1" file={file1} onFile={setFile1} />
634
+ <FileZone label="عينة 2 (اختياري)" file={file2} onFile={setFile2} />
635
+ </div>
636
+ <div style={{display:"flex",gap:8}}>
637
+ <input type="text"
638
+ placeholder="اسم الصوت…"
639
+ value={saveName} onChange={e=>setSaveName(e.target.value)}
640
+ style={{flex:1,background:"var(--bg)",border:"1px solid var(--border)",
641
+ color:"var(--text)",borderRadius:6,padding:"8px 12px",
642
+ fontSize:13,fontFamily:"var(--sans)",outline:"none"}} />
643
+ <button className="btn-primary" style={{width:"auto",padding:"8px 20px"}}
644
+ onClick={saveVoice}>
645
+ حفظ
646
+ </button>
647
+ </div>
648
+ {saveStatus && <div style={{marginTop:8,fontSize:12,
649
+ color:"var(--green)"}}>{saveStatus}</div>}
650
+ </div>
651
+
652
+ {voices.length===0
653
+ ? <div style={{textAlign:"center",color:"var(--muted)",
654
+ padding:"40px 0",fontSize:14}}>
655
+ لا توجد أصوات محفوظة بعد.
656
+ </div>
657
+ : voices.map(v=>(
658
+ <div key={v} className="voice-card"
659
+ onClick={()=>{setSelVoice(selVoice===v?null:v);setTab("generate");}}>
660
+ <div>
661
+ <div style={{fontSize:14,fontWeight:600}}>{v}</div>
662
+ <div style={{fontSize:11,color:"var(--muted)",marginTop:2,
663
+ fontFamily:"var(--mono)"}}>
664
+ {selVoice===v?"● محدد":"انقر للاستخدام"}
665
+ </div>
666
+ </div>
667
+ <button className="btn-ghost"
668
+ onClick={e=>{e.stopPropagation();deleteVoice(v);}}>
669
+ حذف
670
+ </button>
671
+ </div>
672
+ ))}
673
+ </div>
674
+ )}
675
+
676
+ {tab==="history" && (
677
+ <div style={{display:"flex",flexDirection:"column",gap:10}}>
678
+ {history.length===0
679
+ ? <div style={{textAlign:"center",color:"var(--muted)",
680
+ padding:"40px 0",fontSize:14}}>
681
+ السجل فارغ.
682
+ </div>
683
+ : [...history].reverse().map((h,i)=>(
684
+ <div key={i} className="history-row">
685
+ <div style={{flex:1,minWidth:0}}>
686
+ <div style={{fontSize:12,color:"var(--text)",
687
+ overflow:"hidden",textOverflow:"ellipsis",
688
+ whiteSpace:"nowrap",direction:"rtl"}}>
689
+ {h.text}
690
+ </div>
691
+ <div style={{display:"flex",gap:8,marginTop:4,flexWrap:"wrap"}}>
692
+ <span className="badge">{h.language}</span>
693
+ <span style={{fontSize:10,color:"var(--muted)",
694
+ fontFamily:"var(--mono)"}}>
695
+ {new Date(h.ts*1000).toLocaleString("ar-EG")}
696
+ </span>
697
+ </div>
698
+ </div>
699
+ <div style={{display:"flex",gap:6,flexShrink:0}}>
700
+ <button className="btn-ghost"
701
+ onClick={()=>{setAudioUrl(`/audio/${h.filename}`);
702
+ setAudioFilename(h.filename);
703
+ setTab("generate");
704
+ setStatus("done");}}>
705
+
706
+ </button>
707
+ <a href={`/audio/${h.filename}`} download={h.filename}
708
+ style={{textDecoration:"none"}}>
709
+ <button className="btn-ghost">⬇</button>
710
+ </a>
711
+ </div>
712
+ </div>
713
+ ))}
714
+ </div>
715
+ )}
716
+
717
+ <div style={{marginTop:32,textAlign:"center",fontSize:11,
718
+ color:"var(--muted)",fontFamily:"var(--mono)"}}>
719
+ XTTS v2 · Coqui TTS · {device.toUpperCase()} inference
720
+ </div>
721
+ </div>
722
+ );
723
+ }
724
+
725
+ ReactDOM.createRoot(document.getElementById("root")).render(<App/>);
726
+ </script>
727
+ </body>
728
+ </html>
729
+ """
730
+
731
+ # ══════════════════════════════════════════════════════════════════════
732
+ # Routes
733
+ # ══════════════════════════════════════════════════════════════════════
734
+
735
+ @app.get("/", response_class=HTMLResponse)
736
+ async def ui():
737
+ page = HTML \
738
+ .replace("LANGUAGES_JSON", json.dumps(LANGUAGES, ensure_ascii=False)) \
739
+ .replace('device="{device}"', f'device="{device}"') \
740
+ .replace("{device}", device)
741
+ page = page.replace(
742
+ 'const { useState, useEffect, useRef, useCallback } = React;',
743
+ f'const {{ useState, useEffect, useRef, useCallback }} = React;\n'
744
+ f'const device = "{device}";'
745
+ )
746
+ return page
747
+
748
+ @app.post("/generate")
749
+ async def generate(
750
+ text: str = Form(...),
751
+ language: str = Form("ar"),
752
+ temperature: float = Form(0.75),
753
+ speed: float = Form(1.0),
754
+ top_k: int = Form(50),
755
+ top_p: float = Form(0.85),
756
+ repetition_penalty: float = Form(5.0),
757
+ enable_text_splitting: bool = Form(True),
758
+ voice_name: str = Form(None),
759
+ files: list[UploadFile] = File(default=[]),
760
+ ):
761
+ if not text.strip():
762
+ raise HTTPException(400, "النص فارغ.")
763
+
764
+ ref_paths = []
765
+ tmp_files = []
766
+
767
+ for f in files:
768
+ path = f"/tmp/ref_{uuid.uuid4().hex}_{f.filename}"
769
+ with open(path, "wb") as buf:
770
+ shutil.copyfileobj(f.file, buf)
771
+ ref_paths.append(path)
772
+ tmp_files.append(path)
773
+
774
+ if voice_name:
775
+ lib_dir = os.path.join(VOICE_LIB, voice_name)
776
+ if os.path.isdir(lib_dir):
777
+ ref_paths += [
778
+ os.path.join(lib_dir, fn)
779
+ for fn in os.listdir(lib_dir)
780
+ if fn.lower().endswith((".wav",".mp3",".flac",".ogg"))
781
+ ]
782
+
783
+ if not ref_paths:
784
+ raise HTTPException(400, "يجب تحديد عينة صوتية مرجعية.")
785
+
786
+ out_name = f"gen_{uuid.uuid4().hex[:8]}.wav"
787
+ out_path = os.path.join(OUTPUT_DIR, out_name)
788
+
789
+ try:
790
+ xtts_engine.tts_to_file(
791
+ text=text,
792
+ speaker_wav=ref_paths,
793
+ language=language,
794
+ file_path=out_path,
795
+ temperature=float(temperature),
796
+ speed=float(speed),
797
+ top_k=int(top_k),
798
+ top_p=float(top_p),
799
+ repetition_penalty=float(repetition_penalty),
800
+ enable_text_splitting=bool(enable_text_splitting),
801
+ )
802
+ finally:
803
+ for p in tmp_files:
804
+ try: os.remove(p)
805
+ except: pass
806
+
807
+ hist = load_history()
808
+ hist.append({
809
+ "filename": out_name,
810
+ "text": text[:120],
811
+ "language": language,
812
+ "ts": int(time.time()),
813
+ "params": dict(temperature=temperature, speed=speed,
814
+ top_k=top_k, top_p=top_p,
815
+ repetition_penalty=repetition_penalty),
816
+ })
817
+ save_history(hist)
818
+
819
+ # Sync after generating and saving history
820
+ background_sync()
821
+
822
+ return {"filename": out_name}
823
+
824
+ @app.get("/audio/{filename}")
825
+ def get_audio(filename: str):
826
+ path = os.path.join(OUTPUT_DIR, filename)
827
+ if not os.path.exists(path):
828
+ raise HTTPException(404, "File not found.")
829
+ return FileResponse(path, media_type="audio/wav")
830
+
831
+ @app.get("/history")
832
+ def get_history():
833
+ return JSONResponse(load_history())
834
+
835
+ @app.get("/voices")
836
+ def list_voices():
837
+ if not os.path.isdir(VOICE_LIB):
838
+ return []
839
+ return [d for d in os.listdir(VOICE_LIB)
840
+ if os.path.isdir(os.path.join(VOICE_LIB, d))]
841
+
842
+ @app.post("/voices/save")
843
+ async def save_voice(
844
+ name: str = Form(...),
845
+ file: UploadFile = File(...),
846
+ file2: UploadFile = File(default=None),
847
+ ):
848
+ safe = name.strip().replace("/", "_").replace("..", "_")
849
+ lib_dir = os.path.join(VOICE_LIB, safe)
850
+ os.makedirs(lib_dir, exist_ok=True)
851
+ for f in ([file, file2] if file2 else [file]):
852
+ dest = os.path.join(lib_dir, f.filename)
853
+ with open(dest, "wb") as buf:
854
+ shutil.copyfileobj(f.file, buf)
855
+
856
+ # Sync after saving a new voice
857
+ background_sync()
858
+
859
+ return {"name": safe}
860
+
861
+ @app.delete("/voices/{name}")
862
+ def delete_voice(name: str):
863
+ lib_dir = os.path.join(VOICE_LIB, name)
864
+ if os.path.isdir(lib_dir):
865
+ shutil.rmtree(lib_dir)
866
+ # Sync after deleting a voice
867
+ background_sync()
868
+ return {"deleted": name}
869
+
870
+ # ══════════════════════════════════════════════════════════════════════
871
+ # Launch
872
+ # ══════════════════════════════════════════════════════════════════════
873
+ def _run():
874
+ nest_asyncio.apply()
875
+ # Hugging Face Spaces Default Port is 7860
876
+ uvicorn.run(app, host="0.0.0.0", port=7860, log_level="warning")
877
+
878
+ if __name__ == "__main__":
879
+ print("[*] Starting XTTS Studio on Hugging Face Spaces...")
880
+ _run()