mustafoyev202 Claude Opus 4.6 (1M context) commited on
Commit
2ed1985
·
1 Parent(s): 6adca46

Uzbek Multi-Engine TTS — Gradio web interface

Browse files

6 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 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: yellow
5
- colorTo: pink
6
  sdk: gradio
7
- sdk_version: 6.11.0
8
  app_file: app.py
9
  pinned: false
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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