tiktokprogrammes commited on
Commit
ff95843
Β·
verified Β·
1 Parent(s): 147c1f4

Upload 3 files

Browse files
Files changed (3) hide show
  1. Dockerfile +5 -4
  2. app.py +154 -27
  3. requirements.txt +0 -1
Dockerfile CHANGED
@@ -3,9 +3,9 @@ FROM python:3.10-slim
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
@@ -13,9 +13,10 @@ WORKDIR /app
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 .
 
3
  # System tools
4
  RUN apt-get update && apt-get install -y \
5
  ffmpeg \
6
+ libsndfile1 \
7
+ libnss3 libnspr4 libatk-bridge2.0-0 libdrm2 libxkbcommon0 libxcomposite1 \
8
+ libxdamage1 libxrandr2 libgbm1 libpango-1.0-0 libcairo2 libasound2 \
9
  && rm -rf /var/lib/apt/lists/*
10
 
11
  WORKDIR /app
 
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 + Playwright for vocalize.fm
17
  COPY requirements.txt .
18
  RUN pip install --no-cache-dir -r requirements.txt
19
+ RUN pip install --no-cache-dir playwright && playwright install chromium --with-deps
20
 
21
  # Copy app
22
  COPY app.py .
app.py CHANGED
@@ -1,4 +1,4 @@
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
@@ -17,7 +17,7 @@ def verify_token(request: Request):
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
  # ══════════════════════════════════════════════════════════════════
@@ -110,18 +110,22 @@ def download_piper_model(voice_code: str) -> tuple:
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
 
@@ -129,6 +133,9 @@ def download_piper_model(voice_code: str) -> tuple:
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)
@@ -307,14 +314,21 @@ threading.Thread(target=setup_silero, daemon=True).start()
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
 
@@ -337,6 +351,107 @@ def synthesize_silero(text: str, voice_code: str) -> bytes:
337
  return buf.read()
338
 
339
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
340
  async def synthesize_edge(
341
  text: str,
342
  voice: str,
@@ -366,7 +481,7 @@ async def synthesize_edge(
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:
@@ -377,14 +492,16 @@ def synthesize_gtts(text: str, lang: str) -> bytes:
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)
@@ -399,7 +516,7 @@ def synthesize_gtts(text: str, lang: str) -> bytes:
399
 
400
  @app.get("/")
401
  def root():
402
- return {"status": "VoiceCraft TTS Server βœ…", "engines": ["edge", "gtts", "piper", "silero"]}
403
 
404
 
405
  @app.get("/health")
@@ -410,7 +527,7 @@ def health():
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
 
@@ -440,13 +557,13 @@ async def edge_voices_list():
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:
@@ -500,8 +617,8 @@ async def all_voices_list():
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 = {
@@ -524,19 +641,24 @@ async def all_voices_list():
524
  for code, name in GTTS_LANGS.items():
525
  result["gtts"][f"{name} [{code}]"] = f"google:{code}"
526
 
527
- # Silero v4 β€” Russian (6 real speakers only β€” v4 model confirmed)
528
  silero_ru_speakers = {
529
- "xenia": "Xenia β€’ Russian β€’ Russian",
530
- "eugene": "Eugene β€’ Russian β€’ Russian",
531
- "baya": "Baya β€’ Russian β€’ Russian",
532
- "kseniya": "Kseniya β€’ Russian β€’ Russian",
533
- "aidar": "Aidar β€’ Russian β€’ Russian",
534
- "random": "Random β€’ Russian β€’ Russian",
535
  }
536
  for speaker_code, display_name in silero_ru_speakers.items():
537
- result["silero"][display_name] = f"silero:ru_{speaker_code}"
 
 
 
 
 
 
 
 
538
 
539
- total = len(result["edge"]) + len(result["piper"]) + len(result["gtts"]) + len(result.get("silero", {}))
540
  return {"voices": result, "total": total}
541
 
542
 
@@ -580,6 +702,11 @@ async def tts_endpoint(
580
  media = "audio/wav"
581
  fname = "tts_silero.wav"
582
 
 
 
 
 
 
583
  else:
584
  return JSONResponse(status_code=400, content={"error": f"Unknown engine: {engine}"})
585
 
@@ -593,5 +720,5 @@ async def tts_endpoint(
593
  logging.error(f"TTS error: {e}", exc_info=True)
594
  return JSONResponse(
595
  status_code=500,
596
- content={"error": f"Synthesis failed: {str(e)[:300]}"},
597
  )
 
1
+ import os, io, asyncio, tempfile, threading, re, time, subprocess, shutil, logging, secrets
2
  from fastapi import FastAPI, Form, Request, HTTPException
3
  from fastapi.responses import StreamingResponse, JSONResponse
4
  from fastapi.middleware.gzip import GZipMiddleware
 
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 not secrets.compare_digest(token, API_SECRET):
21
  raise HTTPException(status_code=403, detail="Unauthorized")
22
 
23
  # ══════════════════════════════════════════════════════════════════
 
110
  json_path = os.path.join(PIPER_MODELS_DIR, json_file)
111
 
112
  import urllib.request
113
+ # Build correct HF path: en/en_US/amy/medium/en_US-amy-medium.onnx
114
+ parts = onnx_file.rsplit("-", 2)
115
+ lang_code = parts[0] # en_US
116
+ voice = parts[1] # amy
117
+ quality = parts[2].replace(".onnx", "") # medium
118
+ lang_short = lang_code.split("_")[0] # en
119
+ hf_dir = f"{lang_short}/{lang_code}/{voice}/{quality}"
120
 
121
  for fname, fpath in [(onnx_file, onnx_path), (json_file, json_path)]:
122
  if not os.path.exists(fpath):
123
+ url = f"{PIPER_BASE_URL}/{hf_dir}/{fname}"
124
  print(f"πŸ“₯ Downloading Piper model: {fname}")
125
  try:
126
  urllib.request.urlretrieve(url, fpath)
127
  except Exception:
128
+ url2 = f"{PIPER_BASE_URL}/{lang_short}/{lang_code}/{fname}"
 
129
  urllib.request.urlretrieve(url2, fpath)
130
  return onnx_path, json_path
131
 
 
133
  def download_piper_dynamic(voice_code: str) -> tuple:
134
  """Dynamic Piper voice download β€” code format: piper:ar_JO-kareem-low"""
135
  model_name = voice_code.replace("piper:", "") # ar_JO-kareem-low
136
+ # Prevent path traversal
137
+ if ".." in model_name or "/" in model_name or "\\" in model_name:
138
+ raise ValueError(f"Invalid voice code: {voice_code}")
139
  onnx_file = f"{model_name}.onnx"
140
  json_file = f"{model_name}.onnx.json"
141
  onnx_path = os.path.join(PIPER_MODELS_DIR, onnx_file)
 
314
  def synthesize_silero(text: str, voice_code: str) -> bytes:
315
  """Silero TTS β€” code: silero:ru_xenia. v4 model via PackageImporter."""
316
  global SILERO_READY
317
+ if not SILERO_READY:
318
+ if "ru" not in SILERO_MODELS:
319
+ raise Exception("Silero TTS not available. Ensure torch 2.1+ is installed and model downloaded.")
320
  import numpy as np, scipy.io.wavfile as wav
321
+ try:
322
+ import torch
323
+ except ImportError:
324
+ raise Exception("Silero TTS requires torch. Install: pip install torch==2.1.2")
325
 
326
  lang_speaker = voice_code.replace("silero:", "")
327
  lang = lang_speaker.split("_")[0]
328
  speaker = lang_speaker.split("_", 1)[1] if "_" in lang_speaker else lang_speaker
329
 
330
  # Validate speaker β€” only real v4 speakers allowed
331
+ valid_speakers = set(SILERO_SPEAKERS_RU)
332
  if speaker not in valid_speakers:
333
  raise Exception(f"Invalid Silero speaker '{speaker}'. Valid speakers: {', '.join(valid_speakers)}")
334
 
 
351
  return buf.read()
352
 
353
 
354
+ # ══════════════════════════════════════════════════════════════════
355
+ # VOCALIZE.FM TTS β€” Free AI voices via Playwright browser automation
356
+ # ══════════════════════════════════════════════════════════════════
357
+ VOCALIZE_VOICES = {
358
+ "17889": {"name": "Marcus (ElevenLabs)", "gender": "male"},
359
+ "3228": {"name": "Adam (ElevenLabs)", "gender": "male"},
360
+ "26343": {"name": "Adam v2 (ElevenLabs)", "gender": "male"},
361
+ "1229": {"name": "Male 1 (TikTok)", "gender": "male"},
362
+ "rachel": {"name": "Rachel (ElevenLabs)", "gender": "female"},
363
+ "bella": {"name": "Bella (ElevenLabs)", "gender": "female"},
364
+ "elli": {"name": "Elli (ElevenLabs)", "gender": "female"},
365
+ }
366
+
367
+ VOCALIZE_BASE = "https://www.vocalize.fm"
368
+
369
+
370
+ async def synthesize_vocalize(text: str, voice_code: str) -> bytes:
371
+ """Vocalize.fm TTS using Playwright β€” code: vocalize:17889"""
372
+ voice_id = voice_code.replace("vocalize:", "")
373
+ if voice_id not in VOCALIZE_VOICES:
374
+ raise Exception(f"Unknown vocalize voice: {voice_id}")
375
+
376
+ try:
377
+ from playwright.async_api import async_playwright
378
+ except ImportError:
379
+ raise Exception("Vocalize.fm requires playwright. Run: pip install playwright && playwright install chromium")
380
+
381
+ async with async_playwright() as p:
382
+ browser = await p.chromium.launch(headless=True, args=["--no-sandbox", "--disable-dev-shm-usage"])
383
+ page = await browser.new_page()
384
+ try:
385
+ await page.goto(f"{VOCALIZE_BASE}/voices/{voice_id}", wait_until="networkidle", timeout=30000)
386
+ # Try TTS tab / text input
387
+ textareas = await page.query_selector_all("textarea")
388
+ if textareas:
389
+ await textareas[0].fill(text)
390
+ else:
391
+ await page.fill("input[type='text']", text)
392
+ # Click convert/generate button
393
+ buttons = await page.query_selector_all("button")
394
+ for btn in buttons:
395
+ btn_text = (await btn.inner_text()).lower()
396
+ if "convert" in btn_text or "generate" in btn_text or "speak" in btn_text:
397
+ await btn.click()
398
+ break
399
+ else:
400
+ await page.keyboard.press("Enter")
401
+ # Wait for audio element
402
+ await page.wait_for_selector("audio", timeout=60000)
403
+ await asyncio.sleep(2)
404
+ audio_url = await page.eval_on_selector("audio", "el => el.src")
405
+ if not audio_url:
406
+ raise Exception("No audio generated")
407
+ import requests as _r
408
+ resp = _r.get(audio_url, timeout=60, headers={"User-Agent": "VoiceCraft/3.0"})
409
+ resp.raise_for_status()
410
+ return resp.content
411
+ finally:
412
+ await browser.close()
413
+
414
+
415
+ def _ffmpeg_concat_mp3(parts: list) -> bytes:
416
+ """Merge multiple MP3 byte chunks using ffmpeg concat."""
417
+ if len(parts) == 1:
418
+ return parts[0]
419
+ tmp_files = []
420
+ concat_list = None
421
+ out_path = None
422
+ try:
423
+ for data in parts:
424
+ fd, path = tempfile.mkstemp(suffix=".mp3")
425
+ os.close(fd)
426
+ with open(path, "wb") as f:
427
+ f.write(data)
428
+ tmp_files.append(path)
429
+ fd, concat_list = tempfile.mkstemp(suffix=".txt")
430
+ os.close(fd)
431
+ with open(concat_list, "w") as f:
432
+ for p in tmp_files:
433
+ f.write(f"file '{p}'\n")
434
+ fd, out_path = tempfile.mkstemp(suffix=".mp3")
435
+ os.close(fd)
436
+ subprocess.run(["ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
437
+ "-f", "concat", "-safe", "0", "-i", concat_list,
438
+ "-c", "copy", out_path], check=True, timeout=60)
439
+ with open(out_path, "rb") as f:
440
+ return f.read()
441
+ except Exception:
442
+ return b"".join(parts)
443
+ finally:
444
+ for p in tmp_files:
445
+ try: os.unlink(p)
446
+ except Exception: pass
447
+ if concat_list:
448
+ try: os.unlink(concat_list)
449
+ except Exception: pass
450
+ if out_path:
451
+ try: os.unlink(out_path)
452
+ except Exception: pass
453
+
454
+
455
  async def synthesize_edge(
456
  text: str,
457
  voice: str,
 
481
  audio_parts.append(final_data if final_data else b"")
482
  if len(audio_parts) == 1:
483
  return audio_parts[0]
484
+ return _ffmpeg_concat_mp3(audio_parts)
485
 
486
 
487
  def synthesize_gtts(text: str, lang: str) -> bytes:
 
492
  gTTS(text=chunks[0], lang=lang, slow=False).write_to_fp(buf)
493
  buf.seek(0)
494
  return buf.read()
495
+ # Multiple chunks β€” write to temp files and merge via ffmpeg
496
  parts = []
497
  for chunk in chunks:
498
  tmp = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False)
499
  gTTS(text=chunk, lang=lang, slow=False).save(tmp.name)
500
  tmp.close()
501
  parts.append(tmp.name)
502
+ # Read all chunk bytes
503
+ chunk_bytes = [open(p, "rb").read() for p in parts]
504
+ combined = _ffmpeg_concat_mp3(chunk_bytes)
505
  for p in parts:
506
  try:
507
  os.unlink(p)
 
516
 
517
  @app.get("/")
518
  def root():
519
+ return {"status": "VoiceCraft TTS Server βœ…", "engines": ["edge", "gtts", "piper", "silero", "vocalize"]}
520
 
521
 
522
  @app.get("/health")
 
527
  "piper_ready": PIPER_READY,
528
  "silero_ready": SILERO_READY,
529
  "silero_speakers": SILERO_SPEAKERS_RU,
530
+ "engines": ["edge", "gtts", "piper", "silero", "vocalize"],
531
  }
532
 
533
 
 
557
  result[f"{name} [{region}]"] = short
558
  return {"voices": result}
559
  except Exception as e:
560
+ return JSONResponse(status_code=500, content={"error": "Failed to list voices"})
561
 
562
 
563
  @app.get("/all_voices")
564
  async def all_voices_list():
565
  """All voices β€” Edge (400+) + Piper (900+) + gTTS (60+)"""
566
+ result = {"edge": {}, "piper": {}, "gtts": {}, "silero": {}, "vocalize": {}}
567
 
568
  # Edge TTS β€” 400+ voices (complete list, clean naming)
569
  try:
 
617
  result["piper"][display] = full_code
618
  except Exception as e:
619
  print(f"Piper dynamic fetch error: {e}")
620
+ for key in PIPER_VOICES:
621
+ result["piper"][key] = key # voice code as string, not tuple
622
 
623
  # gTTS β€” 60+ languages (complete)
624
  GTTS_LANGS = {
 
641
  for code, name in GTTS_LANGS.items():
642
  result["gtts"][f"{name} [{code}]"] = f"google:{code}"
643
 
644
+ # Silero v4 β€” Russian (12 speakers)
645
  silero_ru_speakers = {
646
+ "xenia": "Xenia", "eugene": "Eugene", "baya": "Baya", "kseniya": "Kseniya",
647
+ "aidar": "Aidar", "kolya": "Kolya", "mikhai": "Mikhai", "nikita": "Nikita",
648
+ "pavel": "Pavel", "tatiana": "Tatiana", "elena": "Elena", "irina": "Irina",
 
 
 
649
  }
650
  for speaker_code, display_name in silero_ru_speakers.items():
651
+ result["silero"][f"{display_name} \u2022 Russian [RU]"] = f"silero:ru_{speaker_code}"
652
+
653
+ # Vocalize.fm voices
654
+ vocalize_names = {
655
+ "17889": "Marcus ⚑", "3228": "Adam (Pro)", "26343": "Adam v2",
656
+ "1229": "Male 1 (TikTok)", "rachel": "Rachel", "bella": "Bella", "elli": "Elli",
657
+ }
658
+ for vid, vname in vocalize_names.items():
659
+ result["vocalize"][f"{vname} \u2022 Vocalize.fm"] = f"vocalize:{vid}"
660
 
661
+ total = len(result["edge"]) + len(result["piper"]) + len(result["gtts"]) + len(result.get("silero", {})) + len(result["vocalize"])
662
  return {"voices": result, "total": total}
663
 
664
 
 
702
  media = "audio/wav"
703
  fname = "tts_silero.wav"
704
 
705
+ elif engine == "vocalize":
706
+ audio = await synthesize_vocalize(text, voice)
707
+ media = "audio/mpeg"
708
+ fname = "tts_vocalize.mp3"
709
+
710
  else:
711
  return JSONResponse(status_code=400, content={"error": f"Unknown engine: {engine}"})
712
 
 
720
  logging.error(f"TTS error: {e}", exc_info=True)
721
  return JSONResponse(
722
  status_code=500,
723
+ content={"error": "Synthesis failed. Please try again."},
724
  )
requirements.txt CHANGED
@@ -8,4 +8,3 @@ numpy<2
8
  scipy
9
  omegaconf
10
  soundfile
11
- torch
 
8
  scipy
9
  omegaconf
10
  soundfile