Spaces:
Sleeping
Sleeping
Commit ·
2ed1985
1
Parent(s): 6adca46
Uzbek Multi-Engine TTS — Gradio web interface
Browse files6 TTS engines (Gemini, Edge, OmniVoice, MMS-TTS, Aisha, ElevenLabs)
with dynamic voice selection (male/female) and single-output UI.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- .gitignore +7 -0
- 111.txt +31 -0
- README.md +25 -6
- app.py +272 -0
- engines/__init__.py +19 -0
- engines/_common.py +105 -0
- engines/engine_aisha.py +90 -0
- engines/engine_edge.py +48 -0
- engines/engine_elevenlabs.py +86 -0
- engines/engine_gemini.py +97 -0
- engines/engine_mms.py +55 -0
- engines/engine_omnivoice.py +62 -0
- requirements.txt +10 -0
.gitignore
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
out/
|
| 3 |
+
__pycache__/
|
| 4 |
+
*.pyc
|
| 5 |
+
*.wav
|
| 6 |
+
*.mp3
|
| 7 |
+
*.ogg
|
111.txt
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Erkak (Sotuvchi): Assalomu alaykum! "Premium Motors" avtosaloni, sotuv bo‘limi menejeri Azizman. Eshitaman?
|
| 2 |
+
|
| 3 |
+
Ayol (Xaridor): Va alaykum assalom. Yaxshimisiz, Aziz? Men internetdagi e’loningiz bo‘yicha qo‘ng‘iroq qilayotgandim. Oq rangdagi Chevrolet Tracker (Premier pozitsiyasi) bor deb yozilgan ekan. Hozir sotuvda bormi shu model?
|
| 4 |
+
|
| 5 |
+
Erkak: Ha, albatta. Aynan o‘sha modeldan hozir omborimizda ikkita qoldi. Biri oq, biri qora rangda. Sizni aynan oq rangdagisi qiziqtiryaptimi?
|
| 6 |
+
|
| 7 |
+
Ayol: Ha, oq rangi ma’qulroq. Narxi e’londa ko‘rsatilganidekmi yoki o‘zgardimi? Va yana bir narsa — agar hozir sotib olsam, rasmiylashtirish qancha vaqt oladi?
|
| 8 |
+
|
| 9 |
+
Erkak: Narxi hozirgi kunda 285 million so‘m. Bu rasmiy narx. Agar naqd to‘lov yoki pul o‘tkazish yo‘li bilan bo‘lsa, hujjatlarni 1 soat ichida rasmiylashtirib, mashinani o‘sha kunning o‘zida olib ketishingiz mumkin.
|
| 10 |
+
|
| 11 |
+
Ayol: Tushunarli. Keyin, mashinaning texnik holati haqida so‘ramoqchi edim. Bu 2024 yilgi modelmi? Lyuk va monitorlari bor-a?
|
| 12 |
+
|
| 13 |
+
Erkak: Juda to‘g‘ri savol berdingiz.
|
| 14 |
+
|
| 15 |
+
Yili: 2024 yil, mutlaqo yangi.
|
| 16 |
+
|
| 17 |
+
Komplektatsiya: Premier. Bu eng yuqori pozitsiya. Panoramali lyuk, katta sensorli monitor, orqani ko‘rish kamerasi va o‘rindiqlarni isitish tizimlari hammasi bor.
|
| 18 |
+
|
| 19 |
+
Kafolat: 3 yil yoki 100 000 km masofaga rasmiy kafolat beramiz.
|
| 20 |
+
|
| 21 |
+
Ayol: Yaxshi. To‘lov masalasida-chi? Bo‘lib to‘lash yoki avtokreditga imkoniyat bormi?
|
| 22 |
+
|
| 23 |
+
Erkak: Ha, bor. Biz bir nechta banklar bilan hamkorlik qilamiz. Agar 30% oldindan to‘lov qilsangiz, qolgan summani 3 yilgacha bo‘lib to‘lashingiz mumkin. Faqat buning uchun pasportingiz bo‘lsa kifoya.
|
| 24 |
+
|
| 25 |
+
Ayol: Juda soz. Men bugun tushdan keyin soat 4 larda borib, mashinani ko‘rsam bo‘ladimi? "Test-drayv" qilib ko‘rish imkoni bormi?
|
| 26 |
+
|
| 27 |
+
Erkak: Albatta! Biz soat 19:00 gacha ishlaymiz. Kelganingizda ismim Aziz, deb so‘rasangiz, sizni kutib olaman va test-drayv uyushtirib beraman. Mashinani haydab ko‘rib, o‘zingiz baho berasiz.
|
| 28 |
+
|
| 29 |
+
Ayol: Kelishdik, Aziz. Rahmat sizga, borganda ko‘rishamiz.
|
| 30 |
+
|
| 31 |
+
Erkak: Salomat bo‘ling, kutamiz!
|
README.md
CHANGED
|
@@ -1,12 +1,31 @@
|
|
| 1 |
---
|
| 2 |
-
title: TTS
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version: 6.11.0
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Uzbek Multi-Engine TTS
|
| 3 |
+
emoji: 🎙️
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: "6.11.0"
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# Uzbek Multi-Engine TTS
|
| 13 |
+
|
| 14 |
+
Compare 6 different TTS engines for Uzbek language speech synthesis:
|
| 15 |
+
|
| 16 |
+
- **Gemini TTS** — Google Gemini 2.5 Pro Preview TTS
|
| 17 |
+
- **Edge TTS** — Microsoft Edge Read-Aloud (free, no API key)
|
| 18 |
+
- **OmniVoice** — k2-fsa/OmniVoice (600+ languages, open source)
|
| 19 |
+
- **MMS-TTS** — Meta facebook/mms-tts-uzb-script_cyrillic
|
| 20 |
+
- **Aisha Group** — Uzbek TTS REST API
|
| 21 |
+
- **ElevenLabs** — eleven_v3 multilingual model
|
| 22 |
+
|
| 23 |
+
## Setup
|
| 24 |
+
|
| 25 |
+
Set the following **Secrets** in your Space settings:
|
| 26 |
+
|
| 27 |
+
- `GEMINI_API_KEY`
|
| 28 |
+
- `AISHA_API_KEY`
|
| 29 |
+
- `ELEVENLABS_API_KEY`
|
| 30 |
+
|
| 31 |
+
Edge TTS, MMS-TTS, and OmniVoice work without API keys.
|
app.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Uzbek Multi-Engine TTS — Gradio Web Interface.
|
| 3 |
+
|
| 4 |
+
Launch: python app.py
|
| 5 |
+
Open: http://localhost:7860
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
try:
|
| 12 |
+
from dotenv import load_dotenv
|
| 13 |
+
load_dotenv() # loads .env from project root (if present)
|
| 14 |
+
except ImportError:
|
| 15 |
+
pass # python-dotenv not installed; rely on system env vars (HF Secrets)
|
| 16 |
+
|
| 17 |
+
import gradio as gr
|
| 18 |
+
|
| 19 |
+
from engines import ENGINES
|
| 20 |
+
from engines._common import normalize_uzbek
|
| 21 |
+
|
| 22 |
+
ROOT = Path(__file__).parent
|
| 23 |
+
DEFAULT_TEXT = ROOT / "111.txt"
|
| 24 |
+
|
| 25 |
+
try:
|
| 26 |
+
_raw = DEFAULT_TEXT.read_bytes().decode("utf-8-sig")
|
| 27 |
+
SAMPLE_TEXT = normalize_uzbek(_raw)
|
| 28 |
+
except Exception:
|
| 29 |
+
SAMPLE_TEXT = "Assalomu alaykum!"
|
| 30 |
+
|
| 31 |
+
# ---------------------------------------------------------------------------
|
| 32 |
+
# Engine metadata
|
| 33 |
+
# ---------------------------------------------------------------------------
|
| 34 |
+
ENGINE_INFO = {
|
| 35 |
+
"gemini": {
|
| 36 |
+
"label": "Gemini TTS (Pro)",
|
| 37 |
+
"has_voices": True,
|
| 38 |
+
"male_voices": ["Charon", "Puck", "Fenrir", "Orus", "Gacrux", "Sadaltager"],
|
| 39 |
+
"female_voices": ["Kore", "Aoede", "Leda", "Zephyr", "Laomedeia", "Achernar"],
|
| 40 |
+
"default_male": "Charon",
|
| 41 |
+
"default_female": "Kore",
|
| 42 |
+
},
|
| 43 |
+
"edge": {
|
| 44 |
+
"label": "Edge TTS (free)",
|
| 45 |
+
"has_voices": True,
|
| 46 |
+
"male_voices": ["uz-UZ-SardorNeural"],
|
| 47 |
+
"female_voices": ["uz-UZ-MadinaNeural"],
|
| 48 |
+
"default_male": "uz-UZ-SardorNeural",
|
| 49 |
+
"default_female": "uz-UZ-MadinaNeural",
|
| 50 |
+
},
|
| 51 |
+
"omnivoice": {
|
| 52 |
+
"label": "OmniVoice (CPU, slow!)",
|
| 53 |
+
"has_voices": True,
|
| 54 |
+
"male_voices": [
|
| 55 |
+
"male, middle-aged, moderate pitch",
|
| 56 |
+
"male, young adult, low pitch",
|
| 57 |
+
"male, young adult, high pitch",
|
| 58 |
+
"male, elderly, low pitch",
|
| 59 |
+
],
|
| 60 |
+
"female_voices": [
|
| 61 |
+
"female, young adult, moderate pitch",
|
| 62 |
+
"female, middle-aged, high pitch",
|
| 63 |
+
"female, teenager, high pitch",
|
| 64 |
+
],
|
| 65 |
+
"default_male": "male, middle-aged, moderate pitch",
|
| 66 |
+
"default_female": "female, young adult, moderate pitch",
|
| 67 |
+
},
|
| 68 |
+
"mms": {
|
| 69 |
+
"label": "MMS-TTS (local)",
|
| 70 |
+
"has_voices": False,
|
| 71 |
+
"info": "Single speaker model — same voice for both roles.",
|
| 72 |
+
},
|
| 73 |
+
"aisha": {
|
| 74 |
+
"label": "Aisha Group",
|
| 75 |
+
"has_voices": False,
|
| 76 |
+
"info": "Only 'gulnoza' (female) voice available.",
|
| 77 |
+
},
|
| 78 |
+
"elevenlabs": {
|
| 79 |
+
"label": "ElevenLabs",
|
| 80 |
+
"has_voices": True,
|
| 81 |
+
"male_voices": ["George", "Brian", "Daniel", "Charlie", "Callum", "Liam"],
|
| 82 |
+
"female_voices": ["Sarah", "Alice", "Lily", "Laura", "Matilda", "Jessica"],
|
| 83 |
+
"default_male": "George",
|
| 84 |
+
"default_female": "Sarah",
|
| 85 |
+
},
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
ENGINE_KEYS = list(ENGINE_INFO.keys())
|
| 89 |
+
ENGINE_LABELS = [ENGINE_INFO[k]["label"] for k in ENGINE_KEYS]
|
| 90 |
+
LABEL_TO_KEY = {ENGINE_INFO[k]["label"]: k for k in ENGINE_KEYS}
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
# ---------------------------------------------------------------------------
|
| 94 |
+
# Generation handler
|
| 95 |
+
# ---------------------------------------------------------------------------
|
| 96 |
+
def generate(text, engine_label, male_voice, female_voice,
|
| 97 |
+
progress=gr.Progress(track_tqdm=True)):
|
| 98 |
+
text = normalize_uzbek(text.strip())
|
| 99 |
+
if not text:
|
| 100 |
+
return None, "**Error:** No text provided.", ""
|
| 101 |
+
if not engine_label:
|
| 102 |
+
return None, "**Error:** No engine selected.", ""
|
| 103 |
+
|
| 104 |
+
key = LABEL_TO_KEY.get(engine_label)
|
| 105 |
+
if not key:
|
| 106 |
+
return None, f"**Error:** Unknown engine '{engine_label}'.", ""
|
| 107 |
+
|
| 108 |
+
info = ENGINE_INFO[key]
|
| 109 |
+
engine = ENGINES[key]
|
| 110 |
+
progress(0, desc=f"Generating with {engine_label}...")
|
| 111 |
+
|
| 112 |
+
# Pick the voice to use. For single-voice engines, use the female voice
|
| 113 |
+
# or the only available one. The male_voice is available for future
|
| 114 |
+
# multi-speaker dialogue rendering.
|
| 115 |
+
voice = female_voice or male_voice
|
| 116 |
+
|
| 117 |
+
kwargs = {}
|
| 118 |
+
if key == "gemini":
|
| 119 |
+
kwargs["api_key"] = os.environ.get("GEMINI_API_KEY", "")
|
| 120 |
+
kwargs["voice"] = voice
|
| 121 |
+
elif key == "edge":
|
| 122 |
+
kwargs["voice"] = voice
|
| 123 |
+
elif key == "omnivoice":
|
| 124 |
+
kwargs["instruct"] = voice
|
| 125 |
+
elif key == "aisha":
|
| 126 |
+
kwargs["api_key"] = os.environ.get("AISHA_API_KEY", "")
|
| 127 |
+
elif key == "elevenlabs":
|
| 128 |
+
kwargs["api_key"] = os.environ.get("ELEVENLABS_API_KEY", "")
|
| 129 |
+
kwargs["voice"] = voice
|
| 130 |
+
|
| 131 |
+
try:
|
| 132 |
+
result = engine.synthesize(text=text, **kwargs)
|
| 133 |
+
except Exception as e:
|
| 134 |
+
return None, f"**Error:** {e}", f"[{engine_label}] EXCEPTION: {e}"
|
| 135 |
+
|
| 136 |
+
if result.error:
|
| 137 |
+
return None, f"**Error:** {result.error}", f"[{engine_label}] {result.error}"
|
| 138 |
+
|
| 139 |
+
meta = (
|
| 140 |
+
f"**Engine:** {engine_label} \n"
|
| 141 |
+
f"**Model:** `{result.model}` \n"
|
| 142 |
+
f"**Voice:** {result.voice} \n"
|
| 143 |
+
f"**Male voice selected:** {male_voice or 'N/A'} \n"
|
| 144 |
+
f"**Female voice selected:** {female_voice or 'N/A'} \n"
|
| 145 |
+
f"**Duration:** {result.duration_sec:.1f}s \n"
|
| 146 |
+
f"**Generation time:** {result.generation_time_sec:.1f}s \n"
|
| 147 |
+
f"**Format:** {result.format}"
|
| 148 |
+
+ (f" @ {result.sample_rate} Hz" if result.sample_rate else "")
|
| 149 |
+
)
|
| 150 |
+
log = (f"[{engine_label}] Done — {result.duration_sec:.1f}s audio "
|
| 151 |
+
f"in {result.generation_time_sec:.1f}s")
|
| 152 |
+
return result.audio_path, meta, log
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
# ---------------------------------------------------------------------------
|
| 156 |
+
# Dynamic UI: update voice dropdowns when engine changes
|
| 157 |
+
# ---------------------------------------------------------------------------
|
| 158 |
+
def on_engine_change(engine_label):
|
| 159 |
+
if not engine_label:
|
| 160 |
+
return (
|
| 161 |
+
gr.update(visible=False, choices=[], value=None),
|
| 162 |
+
gr.update(visible=False, choices=[], value=None),
|
| 163 |
+
gr.update(visible=False, value=""),
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
key = LABEL_TO_KEY.get(engine_label, "")
|
| 167 |
+
info = ENGINE_INFO.get(key, {})
|
| 168 |
+
has = info.get("has_voices", False)
|
| 169 |
+
|
| 170 |
+
if has:
|
| 171 |
+
return (
|
| 172 |
+
gr.update(visible=True,
|
| 173 |
+
choices=info["male_voices"],
|
| 174 |
+
value=info["default_male"]),
|
| 175 |
+
gr.update(visible=True,
|
| 176 |
+
choices=info["female_voices"],
|
| 177 |
+
value=info["default_female"]),
|
| 178 |
+
gr.update(visible=False, value=""),
|
| 179 |
+
)
|
| 180 |
+
else:
|
| 181 |
+
return (
|
| 182 |
+
gr.update(visible=False, choices=[], value=None),
|
| 183 |
+
gr.update(visible=False, choices=[], value=None),
|
| 184 |
+
gr.update(visible=True, value=info.get("info", "")),
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
# ---------------------------------------------------------------------------
|
| 189 |
+
# Build Gradio UI
|
| 190 |
+
# ---------------------------------------------------------------------------
|
| 191 |
+
def build_app():
|
| 192 |
+
with gr.Blocks(title="Uzbek Multi-Engine TTS") as demo:
|
| 193 |
+
gr.Markdown("# Uzbek Multi-Engine TTS")
|
| 194 |
+
gr.Markdown("Select an engine, choose voices, and generate speech.")
|
| 195 |
+
|
| 196 |
+
with gr.Row():
|
| 197 |
+
# ---- LEFT COLUMN ----
|
| 198 |
+
with gr.Column(scale=2):
|
| 199 |
+
txt_input = gr.Textbox(
|
| 200 |
+
label="Uzbek Text",
|
| 201 |
+
value=SAMPLE_TEXT,
|
| 202 |
+
lines=10,
|
| 203 |
+
max_lines=10,
|
| 204 |
+
placeholder="Paste Uzbek text here...",
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
engine_selector = gr.Dropdown(
|
| 208 |
+
choices=ENGINE_LABELS,
|
| 209 |
+
value="Edge TTS (free)",
|
| 210 |
+
label="Select Engine",
|
| 211 |
+
info="Scroll to browse all available engines",
|
| 212 |
+
filterable=True,
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
engine_info_md = gr.Markdown("", visible=False)
|
| 216 |
+
|
| 217 |
+
with gr.Row():
|
| 218 |
+
male_voice = gr.Dropdown(
|
| 219 |
+
choices=["uz-UZ-SardorNeural"],
|
| 220 |
+
value="uz-UZ-SardorNeural",
|
| 221 |
+
label="Male Voice",
|
| 222 |
+
filterable=True,
|
| 223 |
+
visible=True,
|
| 224 |
+
)
|
| 225 |
+
female_voice = gr.Dropdown(
|
| 226 |
+
choices=["uz-UZ-MadinaNeural"],
|
| 227 |
+
value="uz-UZ-MadinaNeural",
|
| 228 |
+
label="Female Voice",
|
| 229 |
+
filterable=True,
|
| 230 |
+
visible=True,
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
generate_btn = gr.Button(
|
| 234 |
+
"Generate", variant="primary", size="lg",
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
# ---- RIGHT COLUMN: single output ----
|
| 238 |
+
with gr.Column(scale=3):
|
| 239 |
+
audio_out = gr.Audio(
|
| 240 |
+
label="Output Audio",
|
| 241 |
+
type="filepath",
|
| 242 |
+
)
|
| 243 |
+
meta_out = gr.Markdown("*Select an engine and click Generate.*")
|
| 244 |
+
log_out = gr.Textbox(
|
| 245 |
+
label="Log",
|
| 246 |
+
lines=3,
|
| 247 |
+
interactive=False,
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
# ---- Dynamic UI wiring ----
|
| 251 |
+
engine_selector.change(
|
| 252 |
+
fn=on_engine_change,
|
| 253 |
+
inputs=[engine_selector],
|
| 254 |
+
outputs=[male_voice, female_voice, engine_info_md],
|
| 255 |
+
)
|
| 256 |
+
|
| 257 |
+
generate_btn.click(
|
| 258 |
+
fn=generate,
|
| 259 |
+
inputs=[txt_input, engine_selector, male_voice, female_voice],
|
| 260 |
+
outputs=[audio_out, meta_out, log_out],
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
return demo
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
# ---------------------------------------------------------------------------
|
| 267 |
+
# Launch
|
| 268 |
+
# ---------------------------------------------------------------------------
|
| 269 |
+
if __name__ == "__main__":
|
| 270 |
+
app = build_app()
|
| 271 |
+
app.queue(default_concurrency_limit=1)
|
| 272 |
+
app.launch(server_name="0.0.0.0", server_port=7860, theme=gr.themes.Soft())
|
engines/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""TTS engine registry."""
|
| 2 |
+
|
| 3 |
+
from . import (
|
| 4 |
+
engine_gemini,
|
| 5 |
+
engine_edge,
|
| 6 |
+
engine_omnivoice,
|
| 7 |
+
engine_mms,
|
| 8 |
+
engine_aisha,
|
| 9 |
+
engine_elevenlabs,
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
ENGINES = {
|
| 13 |
+
"gemini": engine_gemini,
|
| 14 |
+
"edge": engine_edge,
|
| 15 |
+
"omnivoice": engine_omnivoice,
|
| 16 |
+
"mms": engine_mms,
|
| 17 |
+
"aisha": engine_aisha,
|
| 18 |
+
"elevenlabs": engine_elevenlabs,
|
| 19 |
+
}
|
engines/_common.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared utilities for all TTS engine modules."""
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
import tempfile
|
| 5 |
+
import wave
|
| 6 |
+
from dataclasses import dataclass, field
|
| 7 |
+
from typing import Optional
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@dataclass
|
| 11 |
+
class TTSResult:
|
| 12 |
+
"""Uniform result container returned by every engine."""
|
| 13 |
+
engine: str
|
| 14 |
+
model: str
|
| 15 |
+
voice: str
|
| 16 |
+
audio_path: str
|
| 17 |
+
format: str # "wav" or "mp3"
|
| 18 |
+
sample_rate: Optional[int]
|
| 19 |
+
duration_sec: float
|
| 20 |
+
generation_time_sec: float
|
| 21 |
+
error: Optional[str] = None
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def normalize_uzbek(text: str) -> str:
|
| 25 |
+
"""Normalize curly quotes to standard Uzbek modifier letters."""
|
| 26 |
+
return text.replace("\u2018", "\u02bb").replace("\u2019", "\u02bc")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def get_temp_path(suffix: str) -> str:
|
| 30 |
+
f = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
|
| 31 |
+
f.close()
|
| 32 |
+
return f.name
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def write_wav(path: str, pcm: bytes, channels=1, rate=24000, sample_width=2):
|
| 36 |
+
with wave.open(path, "wb") as wf:
|
| 37 |
+
wf.setnchannels(channels)
|
| 38 |
+
wf.setsampwidth(sample_width)
|
| 39 |
+
wf.setframerate(rate)
|
| 40 |
+
wf.writeframes(pcm)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def get_wav_duration(path: str) -> float:
|
| 44 |
+
with wave.open(path, "rb") as wf:
|
| 45 |
+
return wf.getnframes() / wf.getframerate()
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def get_audio_duration(path: str, fmt: str) -> float:
|
| 49 |
+
if fmt == "wav":
|
| 50 |
+
return get_wav_duration(path)
|
| 51 |
+
# For mp3, estimate from file size (128 kbps ≈ 16 KB/s)
|
| 52 |
+
import os
|
| 53 |
+
size = os.path.getsize(path)
|
| 54 |
+
return size / 16000.0
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# ---------------------------------------------------------------------------
|
| 58 |
+
# Latin Uzbek → Cyrillic Uzbek transliteration (for MMS-TTS).
|
| 59 |
+
# ---------------------------------------------------------------------------
|
| 60 |
+
_LAT2CYR_PAIRS = [
|
| 61 |
+
("o\u02bb", "\u045e"), ("O\u02bb", "\u040e"),
|
| 62 |
+
("g\u02bb", "\u0493"), ("G\u02bb", "\u0492"),
|
| 63 |
+
("sh", "\u0448"), ("Sh", "\u0428"), ("SH", "\u0428"),
|
| 64 |
+
("ch", "\u0447"), ("Ch", "\u0427"), ("CH", "\u0427"),
|
| 65 |
+
("yo", "\u0451"), ("Yo", "\u0401"), ("YO", "\u0401"),
|
| 66 |
+
("yu", "\u044e"), ("Yu", "\u042e"), ("YU", "\u042e"),
|
| 67 |
+
("ya", "\u044f"), ("Ya", "\u042f"), ("YA", "\u042f"),
|
| 68 |
+
("ts", "\u0446"), ("Ts", "\u0426"), ("TS", "\u0426"),
|
| 69 |
+
("\u02bc", "\u044a"), ("'", "\u044a"), ("`", "\u044a"),
|
| 70 |
+
("a", "\u0430"), ("A", "\u0410"),
|
| 71 |
+
("b", "\u0431"), ("B", "\u0411"),
|
| 72 |
+
("d", "\u0434"), ("D", "\u0414"),
|
| 73 |
+
("e", "\u0435"), ("E", "\u0415"),
|
| 74 |
+
("f", "\u0444"), ("F", "\u0424"),
|
| 75 |
+
("g", "\u0433"), ("G", "\u0413"),
|
| 76 |
+
("h", "\u04b3"), ("H", "\u04b2"),
|
| 77 |
+
("i", "\u0438"), ("I", "\u0418"),
|
| 78 |
+
("j", "\u0436"), ("J", "\u0416"),
|
| 79 |
+
("k", "\u043a"), ("K", "\u041a"),
|
| 80 |
+
("l", "\u043b"), ("L", "\u041b"),
|
| 81 |
+
("m", "\u043c"), ("M", "\u041c"),
|
| 82 |
+
("n", "\u043d"), ("N", "\u041d"),
|
| 83 |
+
("o", "\u043e"), ("O", "\u041e"),
|
| 84 |
+
("p", "\u043f"), ("P", "\u041f"),
|
| 85 |
+
("q", "\u049b"), ("Q", "\u049a"),
|
| 86 |
+
("r", "\u0440"), ("R", "\u0420"),
|
| 87 |
+
("s", "\u0441"), ("S", "\u0421"),
|
| 88 |
+
("t", "\u0442"), ("T", "\u0422"),
|
| 89 |
+
("u", "\u0443"), ("U", "\u0423"),
|
| 90 |
+
("v", "\u0432"), ("V", "\u0412"),
|
| 91 |
+
("x", "\u0445"), ("X", "\u0425"),
|
| 92 |
+
("y", "\u0439"), ("Y", "\u0419"),
|
| 93 |
+
("z", "\u0437"), ("Z", "\u0417"),
|
| 94 |
+
]
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def lat_to_cyr(text: str) -> str:
|
| 98 |
+
"""Transliterate Latin Uzbek to Cyrillic Uzbek."""
|
| 99 |
+
for src, dst in _LAT2CYR_PAIRS:
|
| 100 |
+
text = text.replace(src, dst)
|
| 101 |
+
# Word-initial "е" → "э" in Uzbek Cyrillic.
|
| 102 |
+
cyr_chars = "\u0430-\u044f\u0451\u0410-\u042f\u0401\u045e\u0493\u049b\u04b3\u040e\u0492\u049a\u04b2"
|
| 103 |
+
text = re.sub(f"(^|[^{cyr_chars}])\u0435", lambda m: m.group(1) + "\u044d", text)
|
| 104 |
+
text = re.sub(f"(^|[^{cyr_chars}])\u0415", lambda m: m.group(1) + "\u042d", text)
|
| 105 |
+
return text
|
engines/engine_aisha.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Aisha Group TTS engine — REST API (gulnoza voice only)."""
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
import time
|
| 5 |
+
|
| 6 |
+
import requests
|
| 7 |
+
|
| 8 |
+
from ._common import TTSResult, get_temp_path
|
| 9 |
+
|
| 10 |
+
API_URL = "https://back.aisha.group/api/v1/tts/post/"
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def get_config():
|
| 14 |
+
return {
|
| 15 |
+
"name": "Aisha Group",
|
| 16 |
+
"model": "Aisha TTS API",
|
| 17 |
+
"voices": {"gulnoza": "female"},
|
| 18 |
+
"needs_api_key": True,
|
| 19 |
+
"api_key_env": "AISHA_API_KEY",
|
| 20 |
+
"output_format": "wav",
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def synthesize(text: str, voice: str = "gulnoza", api_key: str = None,
|
| 25 |
+
mood: str = "neutral", **kwargs) -> TTSResult:
|
| 26 |
+
if not api_key:
|
| 27 |
+
return TTSResult(
|
| 28 |
+
engine="aisha", model="Aisha TTS API", voice=voice,
|
| 29 |
+
audio_path="", format="wav", sample_rate=None,
|
| 30 |
+
duration_sec=0, generation_time_sec=0,
|
| 31 |
+
error="Aisha API key is required.",
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
headers = {
|
| 35 |
+
"x-api-key": api_key,
|
| 36 |
+
"X-Channels": "stereo",
|
| 37 |
+
"X-Quality": "64k",
|
| 38 |
+
"X-Rate": "16000",
|
| 39 |
+
"X-Format": "mp3",
|
| 40 |
+
}
|
| 41 |
+
data = {"transcript": text, "language": "uz", "model": voice, "mood": mood}
|
| 42 |
+
|
| 43 |
+
t0 = time.time()
|
| 44 |
+
for attempt in range(5):
|
| 45 |
+
r = requests.post(
|
| 46 |
+
API_URL, headers=headers,
|
| 47 |
+
files={k: (None, v) for k, v in data.items()},
|
| 48 |
+
timeout=120,
|
| 49 |
+
)
|
| 50 |
+
if r.status_code == 429:
|
| 51 |
+
try:
|
| 52 |
+
wait = int(re.search(r"(\d+)", r.json().get("detail", "")).group(1))
|
| 53 |
+
except Exception:
|
| 54 |
+
wait = 10
|
| 55 |
+
time.sleep(wait + 1)
|
| 56 |
+
continue
|
| 57 |
+
break
|
| 58 |
+
elapsed = time.time() - t0
|
| 59 |
+
|
| 60 |
+
if r.status_code not in (200, 201):
|
| 61 |
+
return TTSResult(
|
| 62 |
+
engine="aisha", model="Aisha TTS API", voice=voice,
|
| 63 |
+
audio_path="", format="wav", sample_rate=None,
|
| 64 |
+
duration_sec=0, generation_time_sec=elapsed,
|
| 65 |
+
error=f"HTTP {r.status_code}: {r.text[:200]}",
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
j = r.json()
|
| 69 |
+
url = j.get("audio_path") or j.get("audio_url") or j.get("url")
|
| 70 |
+
if not url:
|
| 71 |
+
return TTSResult(
|
| 72 |
+
engine="aisha", model="Aisha TTS API", voice=voice,
|
| 73 |
+
audio_path="", format="wav", sample_rate=None,
|
| 74 |
+
duration_sec=0, generation_time_sec=elapsed,
|
| 75 |
+
error=f"No audio_path in response: {j}",
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
audio_resp = requests.get(url, timeout=120)
|
| 79 |
+
audio_resp.raise_for_status()
|
| 80 |
+
|
| 81 |
+
out = get_temp_path(".wav")
|
| 82 |
+
with open(out, "wb") as f:
|
| 83 |
+
f.write(audio_resp.content)
|
| 84 |
+
dur = len(audio_resp.content) / (16000 * 2) # rough estimate
|
| 85 |
+
|
| 86 |
+
return TTSResult(
|
| 87 |
+
engine="aisha", model="Aisha TTS API", voice=voice,
|
| 88 |
+
audio_path=out, format="wav", sample_rate=None,
|
| 89 |
+
duration_sec=dur, generation_time_sec=elapsed,
|
| 90 |
+
)
|
engines/engine_edge.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Edge TTS engine — Microsoft Edge Read-Aloud (free, no API key)."""
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import os
|
| 5 |
+
import time
|
| 6 |
+
|
| 7 |
+
import edge_tts
|
| 8 |
+
|
| 9 |
+
from ._common import TTSResult, get_temp_path
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def get_config():
|
| 13 |
+
return {
|
| 14 |
+
"name": "Edge TTS",
|
| 15 |
+
"model": "Microsoft Edge Read-Aloud (Azure Neural)",
|
| 16 |
+
"voices": {"uz-UZ-SardorNeural": "male", "uz-UZ-MadinaNeural": "female"},
|
| 17 |
+
"needs_api_key": False,
|
| 18 |
+
"output_format": "mp3",
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def synthesize(text: str, voice: str = "uz-UZ-MadinaNeural", **kwargs) -> TTSResult:
|
| 23 |
+
out = get_temp_path(".mp3")
|
| 24 |
+
|
| 25 |
+
async def _do():
|
| 26 |
+
comm = edge_tts.Communicate(text=text, voice=voice)
|
| 27 |
+
await comm.save(out)
|
| 28 |
+
|
| 29 |
+
t0 = time.time()
|
| 30 |
+
try:
|
| 31 |
+
asyncio.get_running_loop()
|
| 32 |
+
# Inside a running loop (unlikely in Gradio thread pool, but safe).
|
| 33 |
+
import concurrent.futures
|
| 34 |
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
| 35 |
+
pool.submit(lambda: asyncio.run(_do())).result()
|
| 36 |
+
except RuntimeError:
|
| 37 |
+
asyncio.run(_do())
|
| 38 |
+
elapsed = time.time() - t0
|
| 39 |
+
|
| 40 |
+
size = os.path.getsize(out)
|
| 41 |
+
# MP3 128 kbps ≈ 16 KB/s
|
| 42 |
+
dur = size / 16000.0
|
| 43 |
+
|
| 44 |
+
return TTSResult(
|
| 45 |
+
engine="edge", model="Edge Read-Aloud (Azure Neural)",
|
| 46 |
+
voice=voice, audio_path=out, format="mp3",
|
| 47 |
+
sample_rate=None, duration_sec=dur, generation_time_sec=elapsed,
|
| 48 |
+
)
|
engines/engine_elevenlabs.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ElevenLabs TTS engine — eleven_v3 multilingual (REST API, bypass buggy SDK)."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import time
|
| 5 |
+
|
| 6 |
+
import requests
|
| 7 |
+
|
| 8 |
+
from ._common import TTSResult, get_temp_path
|
| 9 |
+
|
| 10 |
+
MODEL_ID = "eleven_v3"
|
| 11 |
+
OUTPUT_FMT = "mp3_44100_128"
|
| 12 |
+
|
| 13 |
+
VOICES = {
|
| 14 |
+
"George": ("JBFqnCBsd6RMkjVDRZzb", "male"),
|
| 15 |
+
"Sarah": ("EXAVITQu4vr4xnSDxMaL", "female"),
|
| 16 |
+
"Alice": ("Xb7hH8MSUJpSbSDYk0k2", "female"),
|
| 17 |
+
"Brian": ("nPczCjzI2devNBz1zQrb", "male"),
|
| 18 |
+
"Lily": ("pFZP5JQG7iQjIQuC4Bku", "female"),
|
| 19 |
+
"Callum": ("N2lVS1w4EtoT3dr4eOWO", "male"),
|
| 20 |
+
"Charlie": ("IKne3meq5aSn9XLyUdCD", "male"),
|
| 21 |
+
"Laura": ("FGY2WhTYpPnrIDTdsKH5", "female"),
|
| 22 |
+
"Matilda": ("XrExE9yKIg1WjnnlVkGX", "female"),
|
| 23 |
+
"Daniel": ("onwK4e9ZLuTAKqWW03F9", "male"),
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def get_config():
|
| 28 |
+
return {
|
| 29 |
+
"name": "ElevenLabs",
|
| 30 |
+
"model": MODEL_ID,
|
| 31 |
+
"voices": {name: info[1] for name, info in VOICES.items()},
|
| 32 |
+
"needs_api_key": True,
|
| 33 |
+
"api_key_env": "ELEVENLABS_API_KEY",
|
| 34 |
+
"output_format": "mp3",
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def synthesize(text: str, voice: str = "Sarah", api_key: str = None, **kwargs) -> TTSResult:
|
| 39 |
+
if not api_key:
|
| 40 |
+
return TTSResult(
|
| 41 |
+
engine="elevenlabs", model=MODEL_ID, voice=voice,
|
| 42 |
+
audio_path="", format="mp3", sample_rate=44100,
|
| 43 |
+
duration_sec=0, generation_time_sec=0,
|
| 44 |
+
error="ElevenLabs API key is required.",
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
voice_id = VOICES.get(voice, (None,))[0]
|
| 48 |
+
if not voice_id:
|
| 49 |
+
return TTSResult(
|
| 50 |
+
engine="elevenlabs", model=MODEL_ID, voice=voice,
|
| 51 |
+
audio_path="", format="mp3", sample_rate=44100,
|
| 52 |
+
duration_sec=0, generation_time_sec=0,
|
| 53 |
+
error=f"Unknown voice '{voice}'. Choose from: {list(VOICES.keys())}",
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
url = f"https://api.elevenlabs.io/v1/text-to-speech/{voice_id}"
|
| 57 |
+
headers = {"xi-api-key": api_key, "Content-Type": "application/json"}
|
| 58 |
+
payload = {
|
| 59 |
+
"text": text,
|
| 60 |
+
"model_id": MODEL_ID,
|
| 61 |
+
"output_format": OUTPUT_FMT,
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
t0 = time.time()
|
| 65 |
+
r = requests.post(url, headers=headers, json=payload, timeout=120)
|
| 66 |
+
elapsed = time.time() - t0
|
| 67 |
+
|
| 68 |
+
if r.status_code != 200:
|
| 69 |
+
return TTSResult(
|
| 70 |
+
engine="elevenlabs", model=MODEL_ID, voice=voice,
|
| 71 |
+
audio_path="", format="mp3", sample_rate=44100,
|
| 72 |
+
duration_sec=0, generation_time_sec=elapsed,
|
| 73 |
+
error=f"HTTP {r.status_code}: {r.text[:200]}",
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
out = get_temp_path(".mp3")
|
| 77 |
+
with open(out, "wb") as f:
|
| 78 |
+
f.write(r.content)
|
| 79 |
+
|
| 80 |
+
dur = os.path.getsize(out) / 16000.0
|
| 81 |
+
|
| 82 |
+
return TTSResult(
|
| 83 |
+
engine="elevenlabs", model=MODEL_ID, voice=voice,
|
| 84 |
+
audio_path=out, format="mp3", sample_rate=44100,
|
| 85 |
+
duration_sec=dur, generation_time_sec=elapsed,
|
| 86 |
+
)
|
engines/engine_gemini.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gemini TTS engine — Google Gemini 2.5 Pro/Flash Preview TTS."""
|
| 2 |
+
|
| 3 |
+
import time
|
| 4 |
+
|
| 5 |
+
from google import genai
|
| 6 |
+
from google.genai import types
|
| 7 |
+
|
| 8 |
+
from ._common import TTSResult, get_temp_path, write_wav
|
| 9 |
+
|
| 10 |
+
VOICES = [
|
| 11 |
+
"Zephyr", "Puck", "Charon", "Kore", "Fenrir", "Leda",
|
| 12 |
+
"Orus", "Aoede", "Callirrhoe", "Autonoe", "Enceladus", "Iapetus",
|
| 13 |
+
"Umbriel", "Algieba", "Despina", "Erinome", "Algenib", "Rasalgethi",
|
| 14 |
+
"Laomedeia", "Achernar", "Alnilam", "Schedar", "Gacrux", "Pulcherrima",
|
| 15 |
+
"Achird", "Zubenelgenubi", "Vindemiatrix", "Sadachbia", "Sadaltager", "Sulafat",
|
| 16 |
+
]
|
| 17 |
+
|
| 18 |
+
MODEL = "gemini-2.5-pro-preview-tts"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def get_config():
|
| 22 |
+
return {
|
| 23 |
+
"name": "Gemini TTS",
|
| 24 |
+
"model": MODEL,
|
| 25 |
+
"voices": {v: "" for v in VOICES},
|
| 26 |
+
"needs_api_key": True,
|
| 27 |
+
"api_key_env": "GEMINI_API_KEY",
|
| 28 |
+
"output_format": "wav",
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def synthesize(text: str, voice: str = "Kore", api_key: str = None, **kwargs) -> TTSResult:
|
| 33 |
+
if not api_key:
|
| 34 |
+
return TTSResult(
|
| 35 |
+
engine="gemini", model=MODEL, voice=voice,
|
| 36 |
+
audio_path="", format="wav", sample_rate=24000,
|
| 37 |
+
duration_sec=0, generation_time_sec=0,
|
| 38 |
+
error="Gemini API key is required.",
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
client = genai.Client(api_key=api_key)
|
| 42 |
+
cfg = types.GenerateContentConfig(
|
| 43 |
+
response_modalities=["AUDIO"],
|
| 44 |
+
speech_config=types.SpeechConfig(
|
| 45 |
+
voice_config=types.VoiceConfig(
|
| 46 |
+
prebuilt_voice_config=types.PrebuiltVoiceConfig(
|
| 47 |
+
voice_name=voice,
|
| 48 |
+
),
|
| 49 |
+
),
|
| 50 |
+
),
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
prompt = f"Read the following Uzbek text naturally and clearly:\n\n{text}"
|
| 54 |
+
|
| 55 |
+
t0 = time.time()
|
| 56 |
+
last_err = None
|
| 57 |
+
response = None
|
| 58 |
+
for attempt in range(3):
|
| 59 |
+
try:
|
| 60 |
+
response = client.models.generate_content(
|
| 61 |
+
model=MODEL, contents=prompt, config=cfg,
|
| 62 |
+
)
|
| 63 |
+
break
|
| 64 |
+
except Exception as e:
|
| 65 |
+
last_err = e
|
| 66 |
+
time.sleep(2 + attempt * 3)
|
| 67 |
+
|
| 68 |
+
elapsed = time.time() - t0
|
| 69 |
+
|
| 70 |
+
if response is None:
|
| 71 |
+
return TTSResult(
|
| 72 |
+
engine="gemini", model=MODEL, voice=voice,
|
| 73 |
+
audio_path="", format="wav", sample_rate=24000,
|
| 74 |
+
duration_sec=0, generation_time_sec=elapsed,
|
| 75 |
+
error=f"API call failed after 3 retries: {last_err}",
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
cand = response.candidates[0]
|
| 79 |
+
if cand.content is None:
|
| 80 |
+
return TTSResult(
|
| 81 |
+
engine="gemini", model=MODEL, voice=voice,
|
| 82 |
+
audio_path="", format="wav", sample_rate=24000,
|
| 83 |
+
duration_sec=0, generation_time_sec=elapsed,
|
| 84 |
+
error=f"Empty response (finish_reason={cand.finish_reason}). "
|
| 85 |
+
"Uzbek may not be fully supported.",
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
pcm = cand.content.parts[0].inline_data.data
|
| 89 |
+
out = get_temp_path(".wav")
|
| 90 |
+
write_wav(out, pcm, channels=1, rate=24000, sample_width=2)
|
| 91 |
+
dur = len(pcm) / (24000 * 2)
|
| 92 |
+
|
| 93 |
+
return TTSResult(
|
| 94 |
+
engine="gemini", model=MODEL, voice=voice,
|
| 95 |
+
audio_path=out, format="wav", sample_rate=24000,
|
| 96 |
+
duration_sec=dur, generation_time_sec=elapsed,
|
| 97 |
+
)
|
engines/engine_mms.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MMS-TTS engine — Meta facebook/mms-tts-uzb-script_cyrillic (local VITS)."""
|
| 2 |
+
|
| 3 |
+
import time
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import soundfile as sf
|
| 7 |
+
import torch
|
| 8 |
+
|
| 9 |
+
from ._common import TTSResult, get_temp_path, lat_to_cyr
|
| 10 |
+
|
| 11 |
+
MODEL_ID = "facebook/mms-tts-uzb-script_cyrillic"
|
| 12 |
+
_model = None
|
| 13 |
+
_tokenizer = None
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _load():
|
| 17 |
+
global _model, _tokenizer
|
| 18 |
+
if _model is None:
|
| 19 |
+
from transformers import AutoTokenizer, VitsModel
|
| 20 |
+
_tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
|
| 21 |
+
_model = VitsModel.from_pretrained(MODEL_ID)
|
| 22 |
+
_model.eval()
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def get_config():
|
| 26 |
+
return {
|
| 27 |
+
"name": "MMS-TTS",
|
| 28 |
+
"model": MODEL_ID,
|
| 29 |
+
"voices": {"default": "single speaker"},
|
| 30 |
+
"needs_api_key": False,
|
| 31 |
+
"output_format": "wav",
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def synthesize(text: str, **kwargs) -> TTSResult:
|
| 36 |
+
_load()
|
| 37 |
+
cyr = lat_to_cyr(text)
|
| 38 |
+
sr = _model.config.sampling_rate
|
| 39 |
+
|
| 40 |
+
t0 = time.time()
|
| 41 |
+
inputs = _tokenizer(cyr, return_tensors="pt")
|
| 42 |
+
with torch.no_grad():
|
| 43 |
+
wav = _model(**inputs).waveform[0].cpu().numpy().astype(np.float32)
|
| 44 |
+
elapsed = time.time() - t0
|
| 45 |
+
|
| 46 |
+
out = get_temp_path(".wav")
|
| 47 |
+
sf.write(out, wav, sr)
|
| 48 |
+
dur = len(wav) / sr
|
| 49 |
+
|
| 50 |
+
return TTSResult(
|
| 51 |
+
engine="mms", model=MODEL_ID,
|
| 52 |
+
voice="uzb-script_cyrillic (single speaker)",
|
| 53 |
+
audio_path=out, format="wav", sample_rate=sr,
|
| 54 |
+
duration_sec=dur, generation_time_sec=elapsed,
|
| 55 |
+
)
|
engines/engine_omnivoice.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OmniVoice engine — k2-fsa/OmniVoice (local, 600+ lang, CPU-slow)."""
|
| 2 |
+
|
| 3 |
+
import time
|
| 4 |
+
|
| 5 |
+
import soundfile as sf
|
| 6 |
+
import torch
|
| 7 |
+
|
| 8 |
+
from ._common import TTSResult, get_temp_path
|
| 9 |
+
|
| 10 |
+
MODEL_ID = "k2-fsa/OmniVoice"
|
| 11 |
+
SAMPLE_RATE = 24000
|
| 12 |
+
_model = None
|
| 13 |
+
|
| 14 |
+
VALID_INSTRUCTS = [
|
| 15 |
+
"male", "female", "child", "teenager", "young adult", "middle-aged",
|
| 16 |
+
"elderly", "low pitch", "moderate pitch", "high pitch", "very low pitch",
|
| 17 |
+
"very high pitch", "whisper", "american accent", "british accent",
|
| 18 |
+
"australian accent", "indian accent", "russian accent",
|
| 19 |
+
]
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _load():
|
| 23 |
+
global _model
|
| 24 |
+
if _model is None:
|
| 25 |
+
from omnivoice import OmniVoice
|
| 26 |
+
_model = OmniVoice.from_pretrained(
|
| 27 |
+
MODEL_ID, device_map="cpu", dtype=torch.float32,
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def get_config():
|
| 32 |
+
return {
|
| 33 |
+
"name": "OmniVoice",
|
| 34 |
+
"model": MODEL_ID,
|
| 35 |
+
"voices": {}, # free-form instruct
|
| 36 |
+
"needs_api_key": False,
|
| 37 |
+
"output_format": "wav",
|
| 38 |
+
"instruct_mode": True,
|
| 39 |
+
"valid_instructs": VALID_INSTRUCTS,
|
| 40 |
+
"slow_warning": "~60-90s per sentence on CPU",
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def synthesize(text: str, instruct: str = "female, young adult, moderate pitch",
|
| 45 |
+
**kwargs) -> TTSResult:
|
| 46 |
+
_load()
|
| 47 |
+
|
| 48 |
+
t0 = time.time()
|
| 49 |
+
audio_list = _model.generate(text=text, instruct=instruct)
|
| 50 |
+
wav = audio_list[0] # (1, T) at 24 kHz
|
| 51 |
+
elapsed = time.time() - t0
|
| 52 |
+
|
| 53 |
+
out = get_temp_path(".wav")
|
| 54 |
+
sf.write(out, wav.squeeze(0).cpu().numpy(), SAMPLE_RATE)
|
| 55 |
+
dur = wav.shape[-1] / SAMPLE_RATE
|
| 56 |
+
|
| 57 |
+
return TTSResult(
|
| 58 |
+
engine="omnivoice", model=MODEL_ID,
|
| 59 |
+
voice=f'instruct="{instruct}"',
|
| 60 |
+
audio_path=out, format="wav", sample_rate=SAMPLE_RATE,
|
| 61 |
+
duration_sec=dur, generation_time_sec=elapsed,
|
| 62 |
+
)
|
requirements.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=6.0
|
| 2 |
+
google-genai>=1.0
|
| 3 |
+
edge-tts>=7.0
|
| 4 |
+
transformers>=4.33
|
| 5 |
+
torch
|
| 6 |
+
soundfile
|
| 7 |
+
requests
|
| 8 |
+
python-dotenv
|
| 9 |
+
numpy
|
| 10 |
+
omnivoice
|