tiktokprogrammes commited on
Commit
555431e
·
verified ·
1 Parent(s): 7287001

Upload 3 files

Browse files
Files changed (3) hide show
  1. Dockerfile +2 -4
  2. app.py +105 -98
  3. requirements.txt +1 -3
Dockerfile CHANGED
@@ -8,10 +8,8 @@ RUN apt-get update && apt-get install -y \
8
 
9
  WORKDIR /app
10
 
11
- # Install CPU-only torch v2.1.2 has torch.package.PackageImporter (removed in 2.3+)
12
- RUN pip install --no-cache-dir torch==2.1.2 --index-url https://download.pytorch.org/whl/cpu
13
-
14
- # Install remaining Python deps
15
  COPY requirements.txt .
16
  RUN pip install --no-cache-dir -r requirements.txt
17
 
 
8
 
9
  WORKDIR /app
10
 
11
+ # Install Python deps (includes torch==2.1.2 needed for Silero v4 PackageImporter,
12
+ # removed in torch 2.3+. PyPI torch wheels are CPU-only by default.)
 
 
13
  COPY requirements.txt .
14
  RUN pip install --no-cache-dir -r requirements.txt
15
 
app.py CHANGED
@@ -31,9 +31,7 @@ PIPER_READY = False
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"),
@@ -45,12 +43,8 @@ PIPER_VOICES = {
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
  }
@@ -169,19 +163,10 @@ def download_piper_dynamic(voice_code: str) -> tuple:
169
  return onnx_path, json_path
170
 
171
 
172
- def synthesize_piper(text: str, voice_code: str, speed: float = 1.0) -> bytes:
173
- if not PIPER_READY:
174
- raise Exception("Piper is not available on this system")
175
- # Pehle PIPER_VOICES dict mein check karo, nahi mila to dynamic download
176
- if voice_code in PIPER_VOICES:
177
- onnx_path, json_path = download_piper_model(voice_code)
178
- else:
179
- # Dynamic voice — direct HuggingFace se download
180
- onnx_path, json_path = download_piper_dynamic(voice_code)
181
  with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as out_f:
182
  out_path = out_f.name
183
  try:
184
- length_scale = 1.0 / max(0.25, min(4.0, speed))
185
  cmd = [
186
  PIPER_BIN,
187
  "--model", onnx_path,
@@ -204,6 +189,68 @@ def synthesize_piper(text: str, voice_code: str, speed: float = 1.0) -> bytes:
204
  os.unlink(out_path)
205
 
206
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
  def split_text(text: str, max_chars: int = 1400) -> list:
208
  """Text ko chunklara bol"""
209
  text = text.strip()
@@ -258,6 +305,7 @@ SILERO_READY = False
258
  SILERO_MODELS_DIR = "/tmp/silero_models"
259
  SILERO_SAMPLE_RATE = 48000
260
  SILERO_MODELS = {}
 
261
 
262
  SILERO_MODEL_URLS = [
263
  "https://models.silero.ai/models/tts/ru/v4_ru.pt",
@@ -313,11 +361,8 @@ threading.Thread(target=setup_silero, daemon=True).start()
313
 
314
 
315
  def synthesize_silero(text: str, voice_code: str) -> bytes:
316
- """Silero TTS — code: silero:ru_xenia. v4 model via PackageImporter."""
317
- global SILERO_READY
318
- if not SILERO_READY:
319
- if "ru" not in SILERO_MODELS:
320
- raise Exception("Silero TTS not available. Ensure torch 2.1+ is installed and model downloaded.")
321
  import numpy as np, scipy.io.wavfile as wav
322
  try:
323
  import torch
@@ -331,20 +376,23 @@ def synthesize_silero(text: str, voice_code: str) -> bytes:
331
  # Validate speaker — only real v4 speakers allowed
332
  valid_speakers = set(SILERO_SPEAKERS_RU)
333
  if speaker not in valid_speakers:
334
- raise Exception(f"Invalid Silero speaker '{speaker}'. Valid speakers: {', '.join(valid_speakers)}")
335
 
 
336
  if lang not in SILERO_MODELS:
337
- model_path = download_silero_model()
338
- if not model_path:
339
- raise Exception("Silero v4 model could not be downloaded.")
340
- import torch
341
- model = torch.package.PackageImporter(model_path).load_pickle("tts_models", "model")
342
- SILERO_MODELS[lang] = model
343
- SILERO_READY = True
 
 
344
 
345
  model = SILERO_MODELS[lang]
346
  audio = model.apply_tts(text=text, speaker=speaker, sample_rate=SILERO_SAMPLE_RATE)
347
- audio_np = audio.numpy() if hasattr(audio, 'numpy') else audio.cpu().detach().numpy()
348
 
349
  buf = io.BytesIO()
350
  wav.write(buf, SILERO_SAMPLE_RATE, (audio_np * 32767).astype(np.int16))
@@ -398,16 +446,28 @@ async def synthesize_edge(
398
  rate: str = "+0%",
399
  volume: str = "+0%",
400
  pitch: str = "+0Hz",
 
 
401
  ) -> bytes:
402
  import edge_tts
403
  chunks = split_text(text)
404
  audio_parts = []
 
 
 
 
 
 
 
 
 
 
405
  for chunk in chunks:
406
  final_data = None
407
  for attempt in range(3):
408
- data = bytearray() # M2 FIX: Reset buffer on each retry
409
  try:
410
- comm = edge_tts.Communicate(chunk, voice, rate=rate, volume=volume, pitch=pitch)
411
  async for packet in comm.stream():
412
  if packet["type"] == "audio" and packet.get("data"):
413
  data.extend(packet["data"])
@@ -424,39 +484,9 @@ async def synthesize_edge(
424
  return _ffmpeg_concat_mp3(audio_parts)
425
 
426
 
427
- def synthesize_gtts(text: str, lang: str) -> bytes:
428
- from gtts import gTTS
429
- chunks = split_text(text, max_chars=4200)
430
- buf = io.BytesIO()
431
- if len(chunks) == 1:
432
- gTTS(text=chunks[0], lang=lang, slow=False).write_to_fp(buf)
433
- buf.seek(0)
434
- return buf.read()
435
- # Multiple chunks — write to temp files and merge via ffmpeg
436
- parts = []
437
- for chunk in chunks:
438
- tmp = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False)
439
- gTTS(text=chunk, lang=lang, slow=False).save(tmp.name)
440
- tmp.close()
441
- parts.append(tmp.name)
442
- # Read all chunk bytes
443
- chunk_bytes = [open(p, "rb").read() for p in parts]
444
- combined = _ffmpeg_concat_mp3(chunk_bytes)
445
- for p in parts:
446
- try:
447
- os.unlink(p)
448
- except Exception:
449
- pass
450
- return combined
451
-
452
-
453
- # ══════════════════════════════════════════════════════════════════
454
- # ROUTES
455
- # ══════════════════════════════════════════════════════════════════
456
-
457
  @app.get("/")
458
  def root():
459
- return {"status": "VoiceCraft TTS Server ", "engines": ["edge", "gtts", "piper", "silero"]}
460
 
461
 
462
  @app.get("/health")
@@ -467,14 +497,14 @@ def health():
467
  "piper_ready": PIPER_READY,
468
  "silero_ready": SILERO_READY,
469
  "silero_speakers": SILERO_SPEAKERS_RU,
470
- "engines": ["edge", "gtts", "piper", "silero"],
471
  }
472
 
473
 
474
  @app.get("/all_voices")
475
  async def all_voices_list():
476
- """All voices — Edge (400+) + Piper (900+) + gTTS (60+)"""
477
- result = {"edge": {}, "piper": {}, "gtts": {}, "silero": {}}
478
 
479
  # Edge TTS — 400+ voices (complete list, clean naming)
480
  try:
@@ -519,6 +549,8 @@ async def all_voices_list():
519
  lang_code = dash_parts[0].replace("_", "-")
520
  voice = dash_parts[1].replace("_", " ").title()
521
  quality = dash_parts[2]
 
 
522
  quality_map = {"high": " +", "medium": "", "low": " -", "x_low": " --"}
523
  qs = quality_map.get(quality, " -")
524
  display = f"{voice} [{lang_code}]{qs}"
@@ -531,27 +563,6 @@ async def all_voices_list():
531
  for key in PIPER_VOICES:
532
  result["piper"][key] = key # voice code as string, not tuple
533
 
534
- # gTTS — 60+ languages (complete)
535
- GTTS_LANGS = {
536
- "af":"Afrikaans","am":"Amharic","ar":"Arabic","bg":"Bulgarian","bn":"Bengali",
537
- "bs":"Bosnian","ca":"Catalan","cs":"Czech","cy":"Welsh","da":"Danish",
538
- "de":"German","el":"Greek","en":"English","es":"Spanish","et":"Estonian",
539
- "eu":"Basque","fi":"Finnish","fr":"French","fr-CA":"French (Canada)","gl":"Galician",
540
- "gu":"Gujarati","ha":"Hausa","hi":"Hindi","hr":"Croatian","hu":"Hungarian",
541
- "id":"Indonesian","is":"Icelandic","it":"Italian","iw":"Hebrew","ja":"Japanese",
542
- "jw":"Javanese","km":"Khmer","kn":"Kannada","ko":"Korean","la":"Latin",
543
- "lt":"Lithuanian","lv":"Latvian","ml":"Malayalam","mr":"Marathi","ms":"Malay",
544
- "my":"Myanmar","ne":"Nepali","nl":"Dutch","no":"Norwegian","pa":"Punjabi",
545
- "pl":"Polish","pt":"Portuguese","pt-PT":"Portuguese (Portugal)","ro":"Romanian",
546
- "ru":"Russian","si":"Sinhala","sk":"Slovak","sq":"Albanian","sr":"Serbian",
547
- "su":"Sundanese","sv":"Swedish","sw":"Swahili","ta":"Tamil","te":"Telugu",
548
- "th":"Thai","tl":"Filipino","tr":"Turkish","uk":"Ukrainian","ur":"Urdu",
549
- "vi":"Vietnamese","yue":"Cantonese","zh":"Chinese","zh-CN":"Chinese (Simplified)",
550
- "zh-TW":"Chinese (Traditional)"
551
- }
552
- for code, name in GTTS_LANGS.items():
553
- result["gtts"][f"{name} [{code}]"] = f"google:{code}"
554
-
555
  # Silero v4 — Russian (12 speakers)
556
  silero_ru_speakers = {
557
  "xenia": "Xenia", "eugene": "Eugene", "baya": "Baya", "kseniya": "Kseniya",
@@ -561,20 +572,22 @@ async def all_voices_list():
561
  for speaker_code, display_name in silero_ru_speakers.items():
562
  result["silero"][f"{display_name} \u2022 Russian [RU]"] = f"silero:ru_{speaker_code}"
563
 
564
- total = len(result["edge"]) + len(result["piper"]) + len(result["gtts"]) + len(result.get("silero", {}))
565
  return {"voices": result, "total": total}
566
 
567
 
568
  @app.post("/tts")
569
  async def tts_endpoint(
570
  request: Request,
571
- engine: str = Form(...), # "edge" | "gtts" | "piper"
572
  text: str = Form(...),
573
- voice: str = Form("en-US-AvaNeural"), # edge voice code OR gtts lang OR piper code
574
  rate: str = Form("+0%"), # edge only
575
  volume: str = Form("+0%"), # edge only
576
  pitch: str = Form("+0Hz"), # edge only
577
  speed: float = Form(1.0), # piper only
 
 
578
  ):
579
  verify_token(request)
580
 
@@ -585,16 +598,10 @@ async def tts_endpoint(
585
 
586
  try:
587
  if engine == "edge":
588
- audio = await synthesize_edge(text, voice, rate=rate, volume=volume, pitch=pitch)
589
  media = "audio/mpeg"
590
  fname = "tts_edge.mp3"
591
 
592
- elif engine == "gtts":
593
- lang = voice if ":" not in voice else voice.split(":", 1)[1]
594
- audio = synthesize_gtts(text, lang)
595
- media = "audio/mpeg"
596
- fname = "tts_gtts.mp3"
597
-
598
  elif engine == "piper":
599
  audio = synthesize_piper(text, voice, speed=speed)
600
  media = "audio/wav"
@@ -618,5 +625,5 @@ async def tts_endpoint(
618
  logging.error(f"TTS error: {e}", exc_info=True)
619
  return JSONResponse(
620
  status_code=500,
621
- content={"error": "Synthesis failed. Please try again."},
622
  )
 
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-joe-medium": ("en_US-joe-medium.onnx", "en_US-joe-medium.onnx.json"),
 
35
  "piper:en_US-lessac-medium": ("en_US-lessac-medium.onnx", "en_US-lessac-medium.onnx.json"),
36
  "piper:en_US-ryan-high": ("en_US-ryan-high.onnx", "en_US-ryan-high.onnx.json"),
37
  "piper:en_GB-alan-medium": ("en_GB-alan-medium.onnx", "en_GB-alan-medium.onnx.json"),
 
43
  # Other languages
44
  "piper:de_DE-thorsten-medium": ("de_DE-thorsten-medium.onnx", "de_DE-thorsten-medium.onnx.json"),
45
  "piper:fr_FR-upmc-medium": ("fr_FR-upmc-medium.onnx", "fr_FR-upmc-medium.onnx.json"),
 
46
  "piper:ru_RU-irina-medium": ("ru_RU-irina-medium.onnx", "ru_RU-irina-medium.onnx.json"),
 
47
  "piper:tr_TR-dfki-medium": ("tr_TR-dfki-medium.onnx", "tr_TR-dfki-medium.onnx.json"),
 
 
48
  "piper:pt_BR-faber-medium": ("pt_BR-faber-medium.onnx", "pt_BR-faber-medium.onnx.json"),
49
  "piper:nl_NL-mls-medium": ("nl_NL-mls-medium.onnx", "nl_NL-mls-medium.onnx.json"),
50
  }
 
163
  return onnx_path, json_path
164
 
165
 
166
+ def _piper_synth_chunk(text: str, onnx_path: str, json_path: str, length_scale: float) -> bytes:
 
 
 
 
 
 
 
 
167
  with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as out_f:
168
  out_path = out_f.name
169
  try:
 
170
  cmd = [
171
  PIPER_BIN,
172
  "--model", onnx_path,
 
189
  os.unlink(out_path)
190
 
191
 
192
+ def _ffmpeg_concat_wav(parts: list) -> bytes:
193
+ """Merge multiple WAV byte chunks using ffmpeg concat (same codec)."""
194
+ if len(parts) == 1:
195
+ return parts[0]
196
+ tmp_files, concat_list, out_path = [], None, None
197
+ try:
198
+ for data in parts:
199
+ fd, path = tempfile.mkstemp(suffix=".wav")
200
+ os.close(fd)
201
+ with open(path, "wb") as f:
202
+ f.write(data)
203
+ tmp_files.append(path)
204
+ fd, concat_list = tempfile.mkstemp(suffix=".txt")
205
+ os.close(fd)
206
+ with open(concat_list, "w") as f:
207
+ for p in tmp_files:
208
+ f.write(f"file '{p}'\n")
209
+ fd, out_path = tempfile.mkstemp(suffix=".wav")
210
+ os.close(fd)
211
+ subprocess.run(["ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
212
+ "-f", "concat", "-safe", "0", "-i", concat_list,
213
+ "-c", "copy", out_path], check=True, timeout=60)
214
+ with open(out_path, "rb") as f:
215
+ return f.read()
216
+ except Exception:
217
+ return b"".join(parts)
218
+ finally:
219
+ for p in tmp_files:
220
+ try: os.unlink(p)
221
+ except Exception: pass
222
+ for x in (concat_list, out_path):
223
+ if x:
224
+ try: os.unlink(x)
225
+ except Exception: pass
226
+
227
+
228
+ def synthesize_piper(text: str, voice_code: str, speed: float = 1.0) -> bytes:
229
+ if not PIPER_READY:
230
+ raise Exception("Piper is not available on this system")
231
+ # Pehle PIPER_VOICES dict mein check karo, nahi mila to dynamic download
232
+ if voice_code in PIPER_VOICES:
233
+ onnx_path, json_path = download_piper_model(voice_code)
234
+ else:
235
+ # Dynamic voice — direct HuggingFace se download
236
+ onnx_path, json_path = download_piper_dynamic(voice_code)
237
+ length_scale = 1.0 / max(0.25, min(4.0, speed))
238
+ # Lambi text ko chunk karo (Piper stdin limit + timeout avoid karne ke liye)
239
+ if len(text) > 1400:
240
+ chunks = split_text(text, max_chars=1400)
241
+ else:
242
+ chunks = [text]
243
+ if len(chunks) == 1:
244
+ return _piper_synth_chunk(chunks[0], onnx_path, json_path, length_scale)
245
+ parts = []
246
+ for ch in chunks:
247
+ if ch.strip():
248
+ parts.append(_piper_synth_chunk(ch, onnx_path, json_path, length_scale))
249
+ if not parts:
250
+ raise Exception("Piper: no audio generated")
251
+ return _ffmpeg_concat_wav(parts)
252
+
253
+
254
  def split_text(text: str, max_chars: int = 1400) -> list:
255
  """Text ko chunklara bol"""
256
  text = text.strip()
 
305
  SILERO_MODELS_DIR = "/tmp/silero_models"
306
  SILERO_SAMPLE_RATE = 48000
307
  SILERO_MODELS = {}
308
+ SILERO_LOAD_LOCK = threading.Lock()
309
 
310
  SILERO_MODEL_URLS = [
311
  "https://models.silero.ai/models/tts/ru/v4_ru.pt",
 
361
 
362
 
363
  def synthesize_silero(text: str, voice_code: str) -> bytes:
364
+ """Silero TTS — code: silero:ru_xenia. v4 model via torch.package.
365
+ Model lazily load hota hai (self-heal) agar startup thread fail hua ho."""
 
 
 
366
  import numpy as np, scipy.io.wavfile as wav
367
  try:
368
  import torch
 
376
  # Validate speaker — only real v4 speakers allowed
377
  valid_speakers = set(SILERO_SPEAKERS_RU)
378
  if speaker not in valid_speakers:
379
+ raise Exception(f"Invalid Silero speaker '{speaker}'. Valid: {', '.join(valid_speakers)}")
380
 
381
+ # Model on-demand load (self-heals if startup thread failed / was slow)
382
  if lang not in SILERO_MODELS:
383
+ with SILERO_LOAD_LOCK:
384
+ if lang not in SILERO_MODELS:
385
+ model_path = download_silero_model()
386
+ if not model_path:
387
+ raise Exception("Silero v4 model could not be downloaded (check network / model URL).")
388
+ model = torch.package.PackageImporter(model_path).load_pickle("tts_models", "model")
389
+ SILERO_MODELS[lang] = model
390
+ global SILERO_READY
391
+ SILERO_READY = True
392
 
393
  model = SILERO_MODELS[lang]
394
  audio = model.apply_tts(text=text, speaker=speaker, sample_rate=SILERO_SAMPLE_RATE)
395
+ audio_np = audio.numpy() if hasattr(audio, "numpy") else audio.cpu().detach().numpy()
396
 
397
  buf = io.BytesIO()
398
  wav.write(buf, SILERO_SAMPLE_RATE, (audio_np * 32767).astype(np.int16))
 
446
  rate: str = "+0%",
447
  volume: str = "+0%",
448
  pitch: str = "+0Hz",
449
+ style: str = None,
450
+ styledegree: str = None,
451
  ) -> bytes:
452
  import edge_tts
453
  chunks = split_text(text)
454
  audio_parts = []
455
+ kwargs = {"rate": rate, "volume": volume, "pitch": pitch}
456
+ if style and style != "Default" and style != "General":
457
+ kwargs["style"] = style
458
+ if styledegree is not None:
459
+ try:
460
+ sd = float(styledegree)
461
+ if 0.0 <= sd <= 2.0:
462
+ kwargs["styledegree"] = styledegree
463
+ except Exception:
464
+ pass
465
  for chunk in chunks:
466
  final_data = None
467
  for attempt in range(3):
468
+ data = bytearray()
469
  try:
470
+ comm = edge_tts.Communicate(chunk, voice, **kwargs)
471
  async for packet in comm.stream():
472
  if packet["type"] == "audio" and packet.get("data"):
473
  data.extend(packet["data"])
 
484
  return _ffmpeg_concat_mp3(audio_parts)
485
 
486
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
487
  @app.get("/")
488
  def root():
489
+ return {"status": "VoiceCraft TTS Server OK", "engines": ["edge", "piper", "silero"]}
490
 
491
 
492
  @app.get("/health")
 
497
  "piper_ready": PIPER_READY,
498
  "silero_ready": SILERO_READY,
499
  "silero_speakers": SILERO_SPEAKERS_RU,
500
+ "engines": ["edge", "piper", "silero"],
501
  }
502
 
503
 
504
  @app.get("/all_voices")
505
  async def all_voices_list():
506
+ """All voices — Edge (400+) + Piper (900+) + Silero (12 RU)"""
507
+ result = {"edge": {}, "piper": {}, "silero": {}}
508
 
509
  # Edge TTS — 400+ voices (complete list, clean naming)
510
  try:
 
549
  lang_code = dash_parts[0].replace("_", "-")
550
  voice = dash_parts[1].replace("_", " ").title()
551
  quality = dash_parts[2]
552
+ if quality in ("low", "x_low"):
553
+ continue
554
  quality_map = {"high": " +", "medium": "", "low": " -", "x_low": " --"}
555
  qs = quality_map.get(quality, " -")
556
  display = f"{voice} [{lang_code}]{qs}"
 
563
  for key in PIPER_VOICES:
564
  result["piper"][key] = key # voice code as string, not tuple
565
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
566
  # Silero v4 — Russian (12 speakers)
567
  silero_ru_speakers = {
568
  "xenia": "Xenia", "eugene": "Eugene", "baya": "Baya", "kseniya": "Kseniya",
 
572
  for speaker_code, display_name in silero_ru_speakers.items():
573
  result["silero"][f"{display_name} \u2022 Russian [RU]"] = f"silero:ru_{speaker_code}"
574
 
575
+ total = len(result["edge"]) + len(result["piper"]) + len(result.get("silero", {}))
576
  return {"voices": result, "total": total}
577
 
578
 
579
  @app.post("/tts")
580
  async def tts_endpoint(
581
  request: Request,
582
+ engine: str = Form(...), # "edge" | "piper" | "silero"
583
  text: str = Form(...),
584
+ voice: str = Form("en-US-AvaNeural"), # edge voice code OR piper/silero code
585
  rate: str = Form("+0%"), # edge only
586
  volume: str = Form("+0%"), # edge only
587
  pitch: str = Form("+0Hz"), # edge only
588
  speed: float = Form(1.0), # piper only
589
+ style: str = Form(None), # edge style (emotion)
590
+ styledegree: str = Form(None), # edge style degree 0-2
591
  ):
592
  verify_token(request)
593
 
 
598
 
599
  try:
600
  if engine == "edge":
601
+ audio = await synthesize_edge(text, voice, rate=rate, volume=volume, pitch=pitch, style=style, styledegree=styledegree)
602
  media = "audio/mpeg"
603
  fname = "tts_edge.mp3"
604
 
 
 
 
 
 
 
605
  elif engine == "piper":
606
  audio = synthesize_piper(text, voice, speed=speed)
607
  media = "audio/wav"
 
625
  logging.error(f"TTS error: {e}", exc_info=True)
626
  return JSONResponse(
627
  status_code=500,
628
+ content={"error": f"Synthesis failed: {str(e)[:240]}"},
629
  )
requirements.txt CHANGED
@@ -1,10 +1,8 @@
1
  fastapi
2
  uvicorn
3
  edge-tts
4
- gTTS
5
  python-multipart
6
  requests
7
  numpy<2
8
  scipy
9
- omegaconf
10
- soundfile
 
1
  fastapi
2
  uvicorn
3
  edge-tts
 
4
  python-multipart
5
  requests
6
  numpy<2
7
  scipy
8
+ torch==2.1.2