tiktokprogrammes commited on
Commit
aad679d
·
verified ·
1 Parent(s): c95ca0e

Upload 3 files

Browse files
Files changed (3) hide show
  1. Dockerfile +26 -0
  2. app.py +594 -0
  3. requirements.txt +10 -0
Dockerfile ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ # System tools
4
+ RUN apt-get update && apt-get install -y \
5
+ ffmpeg \
6
+ wget \
7
+ curl \
8
+ build-essential \
9
+ && rm -rf /var/lib/apt/lists/*
10
+
11
+ WORKDIR /app
12
+
13
+ # Install CPU-only torch — v2.1.2 has torch.package.PackageImporter (removed in 2.3+)
14
+ RUN pip install --no-cache-dir torch==2.1.2 --index-url https://download.pytorch.org/whl/cpu
15
+
16
+ # Install remaining Python deps
17
+ COPY requirements.txt .
18
+ RUN pip install --no-cache-dir -r requirements.txt
19
+
20
+ # Copy app
21
+ COPY app.py .
22
+
23
+ # HuggingFace Spaces runs on port 7860
24
+ EXPOSE 7860
25
+
26
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
app.py ADDED
@@ -0,0 +1,594 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, io, asyncio, tempfile, threading, re, time, subprocess, shutil, logging
2
+ from fastapi import FastAPI, Form, Request, HTTPException
3
+ from fastapi.responses import StreamingResponse, JSONResponse
4
+ from fastapi.middleware.gzip import GZipMiddleware
5
+
6
+ app = FastAPI()
7
+ app.add_middleware(GZipMiddleware, minimum_size=1000)
8
+
9
+ # ══════════════════════════════════════════════════════════════════
10
+ # SECURITY — Token check
11
+ # Set API_SECRET environment variable in HuggingFace Space settings
12
+ # ══════════════════════════════════════════════════════════════════
13
+ API_SECRET = os.environ.get("API_SECRET", "")
14
+
15
+ def verify_token(request: Request):
16
+ if not API_SECRET:
17
+ logging.warning("⚠️ API_SECRET not set — rejecting request")
18
+ raise HTTPException(status_code=503, detail="Server not configured")
19
+ token = request.headers.get("X-API-Token", "")
20
+ if token != API_SECRET:
21
+ raise HTTPException(status_code=403, detail="Unauthorized")
22
+
23
+ # ══════════════════════════════════════════════════════════════════
24
+ # PIPER TTS SETUP — Auto download on first run
25
+ # ══════════════════════════════════════════════════════════════════
26
+ PIPER_DIR = "/tmp/piper"
27
+ PIPER_BIN = os.path.join(PIPER_DIR, "piper")
28
+ PIPER_MODELS_DIR = "/tmp/piper_models"
29
+ PIPER_READY = False
30
+
31
+ PIPER_VOICES = {
32
+ # English
33
+ "piper:en_US-amy-medium": ("en_US-amy-medium.onnx", "en_US-amy-medium.onnx.json"),
34
+ "piper:en_US-danny-low": ("en_US-danny-low.onnx", "en_US-danny-low.onnx.json"),
35
+ "piper:en_US-joe-medium": ("en_US-joe-medium.onnx", "en_US-joe-medium.onnx.json"),
36
+ "piper:en_US-kathleen-low": ("en_US-kathleen-low.onnx", "en_US-kathleen-low.onnx.json"),
37
+ "piper:en_US-lessac-medium": ("en_US-lessac-medium.onnx", "en_US-lessac-medium.onnx.json"),
38
+ "piper:en_US-ryan-high": ("en_US-ryan-high.onnx", "en_US-ryan-high.onnx.json"),
39
+ "piper:en_GB-alan-medium": ("en_GB-alan-medium.onnx", "en_GB-alan-medium.onnx.json"),
40
+ "piper:en_GB-alba-medium": ("en_GB-alba-medium.onnx", "en_GB-alba-medium.onnx.json"),
41
+ # Urdu / Hindi / Arabic
42
+ "piper:ur_PK-fasih-medium": ("ur_PK-fasih-medium.onnx", "ur_PK-fasih-medium.onnx.json"),
43
+ "piper:hi_IN-pratham-medium": ("hi_IN-pratham-medium.onnx", "hi_IN-pratham-medium.onnx.json"),
44
+ "piper:ar_JO-kareem-medium": ("ar_JO-kareem-medium.onnx", "ar_JO-kareem-medium.onnx.json"),
45
+ # Other languages
46
+ "piper:de_DE-thorsten-medium": ("de_DE-thorsten-medium.onnx", "de_DE-thorsten-medium.onnx.json"),
47
+ "piper:fr_FR-upmc-medium": ("fr_FR-upmc-medium.onnx", "fr_FR-upmc-medium.onnx.json"),
48
+ "piper:es_ES-mls_10246-low": ("es_ES-mls_10246-low.onnx", "es_ES-mls_10246-low.onnx.json"),
49
+ "piper:ru_RU-irina-medium": ("ru_RU-irina-medium.onnx", "ru_RU-irina-medium.onnx.json"),
50
+ "piper:zh_CN-huayan-x_low": ("zh_CN-huayan-x_low.onnx", "zh_CN-huayan-x_low.onnx.json"),
51
+ "piper:tr_TR-dfki-medium": ("tr_TR-dfki-medium.onnx", "tr_TR-dfki-medium.onnx.json"),
52
+ "piper:pl_PL-mls_6892-low": ("pl_PL-mls_6892-low.onnx", "pl_PL-mls_6892-low.onnx.json"),
53
+ "piper:it_IT-riccardo-x_low": ("it_IT-riccardo-x_low.onnx", "it_IT-riccardo-x_low.onnx.json"),
54
+ "piper:pt_BR-faber-medium": ("pt_BR-faber-medium.onnx", "pt_BR-faber-medium.onnx.json"),
55
+ "piper:nl_NL-mls-medium": ("nl_NL-mls-medium.onnx", "nl_NL-mls-medium.onnx.json"),
56
+ }
57
+
58
+ PIPER_BASE_URL = "https://huggingface.co/rhasspy/piper-voices/resolve/main"
59
+
60
+ def setup_piper():
61
+ global PIPER_READY
62
+ try:
63
+ import platform
64
+ os.makedirs(PIPER_DIR, exist_ok=True)
65
+ os.makedirs(PIPER_MODELS_DIR, exist_ok=True)
66
+
67
+ system = platform.system().lower()
68
+ arch = platform.machine().lower()
69
+
70
+ if system == "linux" and "x86" in arch:
71
+ piper_url = "https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_linux_x86_64.tar.gz"
72
+ elif system == "linux" and "aarch" in arch:
73
+ piper_url = "https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_linux_aarch64.tar.gz"
74
+ else:
75
+ print(f"⚠️ Piper: unsupported platform {system}/{arch}, Piper disabled")
76
+ return
77
+
78
+ if not os.path.exists(PIPER_BIN):
79
+ print("📥 Piper binary indiriliyor...")
80
+ import urllib.request
81
+ tar_path = "/tmp/piper.tar.gz"
82
+ urllib.request.urlretrieve(piper_url, tar_path)
83
+ import tarfile
84
+ with tarfile.open(tar_path, "r:gz") as tf:
85
+ tf.extractall("/tmp/piper_extract")
86
+ extracted = "/tmp/piper_extract/piper"
87
+ if os.path.isdir(extracted):
88
+ for item in os.listdir(extracted):
89
+ shutil.move(os.path.join(extracted, item), os.path.join(PIPER_DIR, item))
90
+ else:
91
+ shutil.move(extracted, PIPER_BIN)
92
+ os.chmod(PIPER_BIN, 0o755)
93
+ print("✅ Piper binary ready")
94
+
95
+ PIPER_READY = True
96
+ print("✅ Piper TTS ready")
97
+ except Exception as e:
98
+ print(f"⚠️ Piper setup failed (non-critical): {e}")
99
+ PIPER_READY = False
100
+
101
+ threading.Thread(target=setup_piper, daemon=True).start()
102
+
103
+
104
+ def download_piper_model(voice_code: str) -> tuple:
105
+ """Model yoksa indir, path tuple dondur (onnx, json)"""
106
+ if voice_code not in PIPER_VOICES:
107
+ raise ValueError(f"Unknown Piper voice: {voice_code}")
108
+ onnx_file, json_file = PIPER_VOICES[voice_code]
109
+ onnx_path = os.path.join(PIPER_MODELS_DIR, onnx_file)
110
+ json_path = os.path.join(PIPER_MODELS_DIR, json_file)
111
+
112
+ import urllib.request
113
+ lang_prefix = onnx_file.split("-")[0].replace("_", "/") # en_US → en/en_US
114
+ lang_dir = lang_prefix.split("/")[0]
115
+
116
+ for fname, fpath in [(onnx_file, onnx_path), (json_file, json_path)]:
117
+ if not os.path.exists(fpath):
118
+ url = f"{PIPER_BASE_URL}/{lang_dir}/{lang_prefix}/{fname}"
119
+ print(f"📥 Downloading Piper model: {fname}")
120
+ try:
121
+ urllib.request.urlretrieve(url, fpath)
122
+ except Exception:
123
+ # Fallback URL pattern
124
+ url2 = f"{PIPER_BASE_URL}/{lang_dir}/{onnx_file.rsplit('-',1)[0]}/{fname}"
125
+ urllib.request.urlretrieve(url2, fpath)
126
+ return onnx_path, json_path
127
+
128
+
129
+ def download_piper_dynamic(voice_code: str) -> tuple:
130
+ """Dynamic Piper voice download — code format: piper:ar_JO-kareem-low"""
131
+ model_name = voice_code.replace("piper:", "") # ar_JO-kareem-low
132
+ onnx_file = f"{model_name}.onnx"
133
+ json_file = f"{model_name}.onnx.json"
134
+ onnx_path = os.path.join(PIPER_MODELS_DIR, onnx_file)
135
+ json_path = os.path.join(PIPER_MODELS_DIR, json_file)
136
+
137
+ if os.path.exists(onnx_path) and os.path.exists(json_path):
138
+ return onnx_path, json_path
139
+
140
+ import urllib.request
141
+ # Model format: lang_code-voice_name-quality (e.g., ar_JO-kareem-low)
142
+ # HF path: ar/ar_JO/kareem/low/ar_JO-kareem-low.onnx
143
+ dash_parts = model_name.rsplit("-", 2) # Split from right: ["ar_JO", "kareem", "low"]
144
+ if len(dash_parts) >= 3:
145
+ lang_code = dash_parts[0] # ar_JO
146
+ voice = dash_parts[1] # kareem
147
+ quality = dash_parts[2] # low
148
+ lang = lang_code.split("_")[0] # ar
149
+ hf_path = f"{lang}/{lang_code}/{voice}/{quality}"
150
+ else:
151
+ hf_path = f"{model_name}/{model_name}"
152
+
153
+ for fname, fpath in [(onnx_file, onnx_path), (json_file, json_path)]:
154
+ if not os.path.exists(fpath):
155
+ url = f"{PIPER_BASE_URL}/{hf_path}/{fname}"
156
+ print(f"📥 Downloading dynamic Piper model: {fname}")
157
+ try:
158
+ urllib.request.urlretrieve(url, fpath)
159
+ except Exception as e:
160
+ print(f"⚠️ Dynamic Piper download failed: {e}")
161
+ raise
162
+ return onnx_path, json_path
163
+
164
+
165
+ def synthesize_piper(text: str, voice_code: str, speed: float = 1.0) -> bytes:
166
+ if not PIPER_READY:
167
+ raise Exception("Piper is not available on this system")
168
+ # Pehle PIPER_VOICES dict mein check karo, nahi mila to dynamic download
169
+ if voice_code in PIPER_VOICES:
170
+ onnx_path, json_path = download_piper_model(voice_code)
171
+ else:
172
+ # Dynamic voice — direct HuggingFace se download
173
+ onnx_path, json_path = download_piper_dynamic(voice_code)
174
+ with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as out_f:
175
+ out_path = out_f.name
176
+ try:
177
+ length_scale = 1.0 / max(0.25, min(4.0, speed))
178
+ cmd = [
179
+ PIPER_BIN,
180
+ "--model", onnx_path,
181
+ "--config", json_path,
182
+ "--output_file", out_path,
183
+ "--length_scale", str(round(length_scale, 2)),
184
+ ]
185
+ result = subprocess.run(
186
+ cmd,
187
+ input=text.encode("utf-8"),
188
+ capture_output=True,
189
+ timeout=120,
190
+ )
191
+ if result.returncode != 0:
192
+ raise Exception(f"Piper error: {result.stderr.decode()[:200]}")
193
+ with open(out_path, "rb") as f:
194
+ return f.read()
195
+ finally:
196
+ if os.path.exists(out_path):
197
+ os.unlink(out_path)
198
+
199
+
200
+ def split_text(text: str, max_chars: int = 1400) -> list:
201
+ """Text ko chunklara bol"""
202
+ text = text.strip()
203
+ if not text:
204
+ return []
205
+ sentence_re = re.compile(
206
+ r'(?:(?<=[.!?\u0964\u06D4\u061F\u2026])\s+)|(?<=[\u3002\uff01\uff1f])'
207
+ )
208
+ chunks, current = [], ""
209
+ for para in re.split(r'\n+', text):
210
+ para = para.strip()
211
+ if not para:
212
+ if current:
213
+ chunks.append(current)
214
+ current = ""
215
+ continue
216
+ for sentence in sentence_re.split(para):
217
+ sentence = sentence.strip()
218
+ if not sentence:
219
+ continue
220
+ if len(sentence) > max_chars:
221
+ words, buf = sentence.split(), ""
222
+ for word in words:
223
+ add = (" " if buf else "") + word
224
+ if len(buf) + len(add) <= max_chars:
225
+ buf += add
226
+ else:
227
+ if buf:
228
+ chunks.append(buf)
229
+ buf = word
230
+ if buf:
231
+ chunks.append(buf)
232
+ elif len(current) + len(sentence) + 1 <= max_chars:
233
+ current = (current + " " + sentence).strip()
234
+ else:
235
+ if current:
236
+ chunks.append(current)
237
+ current = sentence
238
+ if current:
239
+ chunks.append(current)
240
+ current = ""
241
+ if current:
242
+ chunks.append(current)
243
+ return [c for c in chunks if c.strip()]
244
+
245
+
246
+ # ══════════════════════════════════════════════════════════════════
247
+ # SILERO TTS — v4 model (48kHz, 12 Russian speakers)
248
+ # Loaded via torch.package.PackageImporter (requires torch < 2.3)
249
+ # ══════════════════════════════════════════════════════════════════
250
+ SILERO_READY = False
251
+ SILERO_MODELS_DIR = "/tmp/silero_models"
252
+ SILERO_SAMPLE_RATE = 48000
253
+ SILERO_MODELS = {}
254
+
255
+ SILERO_MODEL_URLS = [
256
+ "https://models.silero.ai/models/tts/ru/v4_ru.pt",
257
+ "https://huggingface.co/Derur/silero-models/resolve/main/tts/ru/ru_v4/v4_ru.pt",
258
+ ]
259
+
260
+ SILERO_SPEAKERS_RU = [
261
+ "xenia", "eugene", "baya", "kseniya", "aidar", "random",
262
+ ]
263
+
264
+
265
+ def download_silero_model() -> str:
266
+ """Download Silero v4 Russian model. Returns path on success."""
267
+ import urllib.request
268
+ os.makedirs(SILERO_MODELS_DIR, exist_ok=True)
269
+ model_path = os.path.join(SILERO_MODELS_DIR, "v4_ru.pt")
270
+ if os.path.exists(model_path) and os.path.getsize(model_path) > 100000:
271
+ return model_path
272
+ for url in SILERO_MODEL_URLS:
273
+ try:
274
+ print(f"📥 Downloading Silero v4: {url[:80]}...")
275
+ urllib.request.urlretrieve(url, model_path)
276
+ if os.path.getsize(model_path) > 100000:
277
+ print(f"✅ Silero v4 downloaded ({os.path.getsize(model_path)//1024}KB)")
278
+ return model_path
279
+ os.remove(model_path)
280
+ except Exception as e:
281
+ print(f"⚠️ Download failed: {e}")
282
+ try:
283
+ os.remove(model_path)
284
+ except Exception:
285
+ pass
286
+ return ""
287
+
288
+
289
+ def setup_silero():
290
+ global SILERO_READY
291
+ try:
292
+ import torch
293
+ model_path = download_silero_model()
294
+ if model_path:
295
+ model = torch.package.PackageImporter(model_path).load_pickle("tts_models", "model")
296
+ SILERO_MODELS["ru"] = model
297
+ SILERO_READY = True
298
+ print("✅ Silero TTS ready (v4 Russian — 12 speakers)")
299
+ else:
300
+ print("❌ Silero TTS: model download failed")
301
+ except Exception as e:
302
+ print(f"❌ Silero setup failed: {e}")
303
+
304
+ threading.Thread(target=setup_silero, daemon=True).start()
305
+
306
+
307
+ def synthesize_silero(text: str, voice_code: str) -> bytes:
308
+ """Silero TTS — code: silero:ru_xenia. v4 model via PackageImporter."""
309
+ global SILERO_READY
310
+ import numpy as np, scipy.io.wavfile as wav
311
+
312
+ lang_speaker = voice_code.replace("silero:", "")
313
+ lang = lang_speaker.split("_")[0]
314
+ speaker = lang_speaker.split("_", 1)[1] if "_" in lang_speaker else lang_speaker
315
+
316
+ # Validate speaker — only real v4 speakers allowed
317
+ valid_speakers = ["xenia", "eugene", "baya", "kseniya", "aidar", "random"]
318
+ if speaker not in valid_speakers:
319
+ raise Exception(f"Invalid Silero speaker '{speaker}'. Valid speakers: {', '.join(valid_speakers)}")
320
+
321
+ if lang not in SILERO_MODELS:
322
+ model_path = download_silero_model()
323
+ if not model_path:
324
+ raise Exception("Silero v4 model could not be downloaded.")
325
+ import torch
326
+ model = torch.package.PackageImporter(model_path).load_pickle("tts_models", "model")
327
+ SILERO_MODELS[lang] = model
328
+ SILERO_READY = True
329
+
330
+ model = SILERO_MODELS[lang]
331
+ audio = model.apply_tts(text=text, speaker=speaker, sample_rate=SILERO_SAMPLE_RATE)
332
+ audio_np = audio.numpy() if hasattr(audio, 'numpy') else audio.cpu().detach().numpy()
333
+
334
+ buf = io.BytesIO()
335
+ wav.write(buf, SILERO_SAMPLE_RATE, (audio_np * 32767).astype(np.int16))
336
+ buf.seek(0)
337
+ return buf.read()
338
+
339
+
340
+ async def synthesize_edge(
341
+ text: str,
342
+ voice: str,
343
+ rate: str = "+0%",
344
+ volume: str = "+0%",
345
+ pitch: str = "+0Hz",
346
+ ) -> bytes:
347
+ import edge_tts
348
+ chunks = split_text(text)
349
+ audio_parts = []
350
+ for chunk in chunks:
351
+ final_data = None
352
+ for attempt in range(3):
353
+ data = bytearray() # M2 FIX: Reset buffer on each retry
354
+ try:
355
+ comm = edge_tts.Communicate(chunk, voice, rate=rate, volume=volume, pitch=pitch)
356
+ async for packet in comm.stream():
357
+ if packet["type"] == "audio" and packet.get("data"):
358
+ data.extend(packet["data"])
359
+ if data:
360
+ final_data = bytes(data)
361
+ break
362
+ except Exception as e:
363
+ if attempt == 2:
364
+ raise
365
+ await asyncio.sleep(1 + attempt)
366
+ audio_parts.append(final_data if final_data else b"")
367
+ if len(audio_parts) == 1:
368
+ return audio_parts[0]
369
+ return b"".join(audio_parts)
370
+
371
+
372
+ def synthesize_gtts(text: str, lang: str) -> bytes:
373
+ from gtts import gTTS
374
+ chunks = split_text(text, max_chars=4200)
375
+ buf = io.BytesIO()
376
+ if len(chunks) == 1:
377
+ gTTS(text=chunks[0], lang=lang, slow=False).write_to_fp(buf)
378
+ buf.seek(0)
379
+ return buf.read()
380
+ # Multiple chunks — write to temp files and merge
381
+ parts = []
382
+ for chunk in chunks:
383
+ tmp = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False)
384
+ gTTS(text=chunk, lang=lang, slow=False).save(tmp.name)
385
+ tmp.close()
386
+ parts.append(tmp.name)
387
+ combined = b"".join(open(p, "rb").read() for p in parts)
388
+ for p in parts:
389
+ try:
390
+ os.unlink(p)
391
+ except Exception:
392
+ pass
393
+ return combined
394
+
395
+
396
+ # ══════════════════════════════════════════════════════════════════
397
+ # ROUTES
398
+ # ══════════════════════════════════════════════════════════════════
399
+
400
+ @app.get("/")
401
+ def root():
402
+ return {"status": "VoiceCraft TTS Server ✅", "engines": ["edge", "gtts", "piper", "silero"]}
403
+
404
+
405
+ @app.get("/health")
406
+ @app.head("/health")
407
+ def health():
408
+ return {
409
+ "status": "ok",
410
+ "piper_ready": PIPER_READY,
411
+ "silero_ready": SILERO_READY,
412
+ "silero_speakers": SILERO_SPEAKERS_RU,
413
+ "engines": ["edge", "gtts", "piper", "silero"],
414
+ }
415
+
416
+
417
+ @app.get("/piper_voices")
418
+ def piper_voices_list():
419
+ return {"voices": list(PIPER_VOICES.keys())}
420
+
421
+
422
+ @app.get("/edge_voices")
423
+ async def edge_voices_list():
424
+ try:
425
+ import edge_tts
426
+ voices = await edge_tts.list_voices()
427
+ result = {}
428
+ for v in voices:
429
+ short = v.get("ShortName", "")
430
+ friendly = v.get("FriendlyName", "") or short
431
+ name = friendly
432
+ for remove in ["Microsoft Server Speech Text to Speech Voice", "Microsoft", "Online", "(Natural)"]:
433
+ name = name.replace(remove, "")
434
+ if "," in name:
435
+ name = name.split(",")[-1]
436
+ name = re.sub(r'\s*-\s*', ' - ', name)
437
+ name = re.sub(r'\s+', ' ', name)
438
+ name = name.strip(" -").strip()
439
+ region = "-".join(short.split("-")[:2])
440
+ result[f"{name} [{region}]"] = short
441
+ return {"voices": result}
442
+ except Exception as e:
443
+ return JSONResponse(status_code=500, content={"error": str(e)})
444
+
445
+
446
+ @app.get("/all_voices")
447
+ async def all_voices_list():
448
+ """All voices — Edge (400+) + Piper (900+) + gTTS (60+)"""
449
+ result = {"edge": {}, "piper": {}, "gtts": {}, "silero": {}}
450
+
451
+ # Edge TTS — 400+ voices (complete list, clean naming)
452
+ try:
453
+ import edge_tts
454
+ voices = await edge_tts.list_voices()
455
+ for v in voices:
456
+ short = v.get("ShortName", "")
457
+ friendly = v.get("FriendlyName", "") or short
458
+ name = friendly
459
+ for remove in ["Microsoft Server Speech Text to Speech Voice", "Microsoft", "Online", "(Natural)", "(Neural)", "(Standard)", "(Multilingual)", "(Expressive)"]:
460
+ name = name.replace(remove, "")
461
+ if "," in name:
462
+ name = name.split(",")[-1]
463
+ # Clean dash pattern: " - " or " - " → single " - "
464
+ name = re.sub(r'\s*-\s*', ' - ', name)
465
+ # Collapse all whitespace to single space
466
+ name = re.sub(r'\s+', ' ', name)
467
+ name = name.strip(" -").strip()
468
+ region = short.split("-")[0] + "-" + short.split("-")[1] if "-" in short else ""
469
+ result["edge"][f"{name} [{region}]"] = short
470
+ except Exception as e:
471
+ print(f"Edge voices error: {e}")
472
+
473
+ # Piper TTS — all voices (clean naming, no engine hints)
474
+ try:
475
+ import urllib.request, json as _json
476
+ api_url = "https://huggingface.co/api/models/rhasspy/piper-voices"
477
+ req = urllib.request.Request(api_url, headers={"User-Agent": "VoiceCraft/2.0"})
478
+ with urllib.request.urlopen(req, timeout=60) as resp:
479
+ data = _json.loads(resp.read())
480
+ siblings = data.get("siblings", [])
481
+ for s in siblings:
482
+ rfn = s.get("rfilename", s.get("rfn", ""))
483
+ if not rfn.endswith(".onnx") or ".json" in rfn or "samples" in rfn:
484
+ continue
485
+ parts = rfn.split("/")
486
+ if len(parts) < 2:
487
+ continue
488
+ model_name = parts[-1].replace(".onnx", "")
489
+ dash_parts = model_name.rsplit("-", 2)
490
+ if len(dash_parts) >= 3:
491
+ lang_code = dash_parts[0].replace("_", "-")
492
+ voice = dash_parts[1].replace("_", " ").title()
493
+ quality = dash_parts[2]
494
+ quality_map = {"high": " +", "medium": "", "low": " -", "x_low": " --"}
495
+ qs = quality_map.get(quality, " -")
496
+ display = f"{voice} [{lang_code}]{qs}"
497
+ else:
498
+ display = model_name.replace("_", " ").title()
499
+ full_code = f"piper:{model_name}"
500
+ result["piper"][display] = full_code
501
+ except Exception as e:
502
+ print(f"Piper dynamic fetch error: {e}")
503
+ for key, val in PIPER_VOICES.items():
504
+ result["piper"][key] = val
505
+
506
+ # gTTS — 60+ languages (complete)
507
+ GTTS_LANGS = {
508
+ "af":"Afrikaans","am":"Amharic","ar":"Arabic","bg":"Bulgarian","bn":"Bengali",
509
+ "bs":"Bosnian","ca":"Catalan","cs":"Czech","cy":"Welsh","da":"Danish",
510
+ "de":"German","el":"Greek","en":"English","es":"Spanish","et":"Estonian",
511
+ "eu":"Basque","fi":"Finnish","fr":"French","fr-CA":"French (Canada)","gl":"Galician",
512
+ "gu":"Gujarati","ha":"Hausa","hi":"Hindi","hr":"Croatian","hu":"Hungarian",
513
+ "id":"Indonesian","is":"Icelandic","it":"Italian","iw":"Hebrew","ja":"Japanese",
514
+ "jw":"Javanese","km":"Khmer","kn":"Kannada","ko":"Korean","la":"Latin",
515
+ "lt":"Lithuanian","lv":"Latvian","ml":"Malayalam","mr":"Marathi","ms":"Malay",
516
+ "my":"Myanmar","ne":"Nepali","nl":"Dutch","no":"Norwegian","pa":"Punjabi",
517
+ "pl":"Polish","pt":"Portuguese","pt-PT":"Portuguese (Portugal)","ro":"Romanian",
518
+ "ru":"Russian","si":"Sinhala","sk":"Slovak","sq":"Albanian","sr":"Serbian",
519
+ "su":"Sundanese","sv":"Swedish","sw":"Swahili","ta":"Tamil","te":"Telugu",
520
+ "th":"Thai","tl":"Filipino","tr":"Turkish","uk":"Ukrainian","ur":"Urdu",
521
+ "vi":"Vietnamese","yue":"Cantonese","zh":"Chinese","zh-CN":"Chinese (Simplified)",
522
+ "zh-TW":"Chinese (Traditional)"
523
+ }
524
+ for code, name in GTTS_LANGS.items():
525
+ result["gtts"][f"{name} [{code}]"] = f"google:{code}"
526
+
527
+ # Silero v4 — Russian (12 speakers)
528
+ silero_ru_speakers = {
529
+ "xenia": "Xenia", "eugene": "Eugene", "baya": "Baya", "kseniya": "Kseniya",
530
+ "aidar": "Aidar", "kolya": "Kolya", "mikhai": "Mikhai", "nikita": "Nikita",
531
+ "pavel": "Pavel", "tatiana": "Tatiana", "elena": "Elena", "irina": "Irina",
532
+ }
533
+ for speaker_code, display_name in silero_ru_speakers.items():
534
+ result["silero"][f"{display_name} \u2022 Russian [RU]"] = f"silero:ru_{speaker_code}"
535
+
536
+ total = len(result["edge"]) + len(result["piper"]) + len(result["gtts"]) + len(result.get("silero", {}))
537
+ return {"voices": result, "total": total}
538
+
539
+
540
+ @app.post("/tts")
541
+ async def tts_endpoint(
542
+ request: Request,
543
+ engine: str = Form(...), # "edge" | "gtts" | "piper"
544
+ text: str = Form(...),
545
+ voice: str = Form("en-US-AvaNeural"), # edge voice code OR gtts lang OR piper code
546
+ rate: str = Form("+0%"), # edge only
547
+ volume: str = Form("+0%"), # edge only
548
+ pitch: str = Form("+0Hz"), # edge only
549
+ speed: float = Form(1.0), # piper only
550
+ ):
551
+ verify_token(request)
552
+
553
+ if not text or not text.strip():
554
+ return JSONResponse(status_code=400, content={"error": "Text is empty"})
555
+
556
+ text = text.strip()
557
+
558
+ try:
559
+ if engine == "edge":
560
+ audio = await synthesize_edge(text, voice, rate=rate, volume=volume, pitch=pitch)
561
+ media = "audio/mpeg"
562
+ fname = "tts_edge.mp3"
563
+
564
+ elif engine == "gtts":
565
+ lang = voice if ":" not in voice else voice.split(":", 1)[1]
566
+ audio = synthesize_gtts(text, lang)
567
+ media = "audio/mpeg"
568
+ fname = "tts_gtts.mp3"
569
+
570
+ elif engine == "piper":
571
+ audio = synthesize_piper(text, voice, speed=speed)
572
+ media = "audio/wav"
573
+ fname = "tts_piper.wav"
574
+
575
+ elif engine == "silero":
576
+ audio = synthesize_silero(text, voice)
577
+ media = "audio/wav"
578
+ fname = "tts_silero.wav"
579
+
580
+ else:
581
+ return JSONResponse(status_code=400, content={"error": f"Unknown engine: {engine}"})
582
+
583
+ return StreamingResponse(
584
+ io.BytesIO(audio),
585
+ media_type=media,
586
+ headers={"Content-Disposition": f"attachment; filename={fname}"},
587
+ )
588
+
589
+ except Exception as e:
590
+ logging.error(f"TTS error: {e}", exc_info=True)
591
+ return JSONResponse(
592
+ status_code=500,
593
+ content={"error": f"Synthesis failed: {str(e)[:300]}"},
594
+ )
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ edge-tts
4
+ gTTS
5
+ python-multipart
6
+ requests
7
+ numpy<2
8
+ scipy
9
+ omegaconf
10
+ soundfile