aliSaac510 commited on
Commit
d07649b
·
1 Parent(s): dfb9f05

Deploy EG Autonomous TTS Studio app

Browse files
Files changed (8) hide show
  1. Dockerfile +21 -0
  2. README.md +113 -8
  3. app.py +476 -0
  4. requirements.txt +3 -0
  5. static/app.css +446 -0
  6. static/app.js +510 -0
  7. templates/index.html +249 -0
  8. tts_engine.py +107 -0
Dockerfile ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1 \
4
+ PYTHONUNBUFFERED=1 \
5
+ PORT=7860
6
+
7
+ WORKDIR /app
8
+
9
+ COPY requirements.txt ./
10
+ RUN pip install --no-cache-dir -r requirements.txt
11
+
12
+ COPY tts_engine.py ./tts_engine.py
13
+ COPY app.py ./app.py
14
+ COPY templates ./templates
15
+ COPY static ./static
16
+
17
+ RUN mkdir -p /app/generated
18
+
19
+ EXPOSE 7860
20
+
21
+ CMD ["sh", "-c", "uvicorn app:app --host 0.0.0.0 --port ${PORT:-7860}"]
README.md CHANGED
@@ -1,12 +1,117 @@
1
  ---
2
- title: Eg Autonomous Tts Studio
3
- emoji: 👁
4
- colorFrom: yellow
5
- colorTo: yellow
6
- sdk: gradio
7
- sdk_version: 6.13.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: EG Autonomous TTS Studio
3
+ emoji: "??"
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: docker
7
+ app_port: 7860
 
8
  pinned: false
9
+ license: mit
10
+ short_description: Enterprise text-to-speech studio with Translate URL and Edge neural voices.
11
  ---
12
 
13
+ # EG Autonomous TTS Studio
14
+
15
+ Production-ready Text-to-Speech Space powered by FastAPI, with a branded web interface and dual synthesis modes:
16
+
17
+ - `translate`: fast Google Translate TTS URL generation + proxy playback
18
+ - `edge`: high-quality MP3 generation via Microsoft Edge TTS voices
19
+
20
+ ## Features
21
+
22
+ - Professional EG AUTONOMOUS branded UI
23
+ - Instant audio playback in-browser
24
+ - Language and provider switching
25
+ - Voice catalog loading for Edge TTS
26
+ - Long-text chunking for Translate TTS
27
+ - JSON API output for direct app integration
28
+
29
+ ## Project Structure
30
+
31
+ - `app.py`: FastAPI application entrypoint
32
+ - `tts_engine.py`: Edge TTS engine helpers and CLI utilities
33
+ - `templates/index.html`: UI template
34
+ - `static/app.css`: branded styles
35
+ - `static/app.js`: frontend logic and API integration
36
+ - `Dockerfile`: Space runtime configuration
37
+ - `requirements.txt`: Python dependencies
38
+
39
+ ## API Endpoints
40
+
41
+ ### Health
42
+
43
+ `GET /health`
44
+
45
+ ### Bootstrap data
46
+
47
+ `GET /api/bootstrap`
48
+
49
+ Returns defaults, providers, and network information for the UI.
50
+
51
+ ### Translate URL
52
+
53
+ `GET /api/translate-url?text=Hello&tl=en`
54
+
55
+ Returns:
56
+ - `directUrl`
57
+ - `proxyUrl`
58
+ - `segments`
59
+
60
+ ### Translate audio proxy
61
+
62
+ `GET /api/translate-audio?text=Hello&tl=en`
63
+
64
+ Returns playable audio bytes for embedding in `<audio>`.
65
+
66
+ ### Voice catalog (Edge)
67
+
68
+ `GET /api/voices?filter=en-US`
69
+
70
+ Returns available voice options filtered by locale or name.
71
+
72
+ ### Synthesize
73
+
74
+ `POST /api/synthesize`
75
+
76
+ Translate mode:
77
+
78
+ ```json
79
+ {
80
+ "provider": "translate",
81
+ "text": "Hello from EG Autonomous",
82
+ "lang": "en",
83
+ "slow": false
84
+ }
85
+ ```
86
+
87
+ Edge mode:
88
+
89
+ ```json
90
+ {
91
+ "provider": "edge",
92
+ "text": "Hello from EG Autonomous",
93
+ "voice": "en-US-AriaNeural",
94
+ "rate": "+0%",
95
+ "volume": "+0%",
96
+ "pitch": "+0Hz"
97
+ }
98
+ ```
99
+
100
+ ## Local Development
101
+
102
+ ```bash
103
+ pip install -r requirements.txt
104
+ uvicorn app:app --host 0.0.0.0 --port 7860
105
+ ```
106
+
107
+ Open:
108
+
109
+ `http://127.0.0.1:7860`
110
+
111
+ ## Deployment Notes (Hugging Face Spaces)
112
+
113
+ - Space SDK: `docker`
114
+ - Port: `7860`
115
+ - Build source: repository `Dockerfile`
116
+
117
+ No runtime git clone is required. The repository itself is the deployment source.
app.py ADDED
@@ -0,0 +1,476 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ from pathlib import Path
5
+ import socket
6
+ import time
7
+ from urllib.parse import quote, urlencode
8
+ from urllib.request import Request, urlopen
9
+ from uuid import uuid4
10
+
11
+ from fastapi import FastAPI, HTTPException, Query, Request as FastApiRequest
12
+ from fastapi.middleware.cors import CORSMiddleware
13
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
14
+ from fastapi.staticfiles import StaticFiles
15
+ from pydantic import BaseModel, Field
16
+
17
+ from tts_engine import DEFAULT_TEXT, DEFAULT_VOICE, get_voices, synthesize_to_file
18
+
19
+
20
+ APP_DIR = Path(__file__).resolve().parent
21
+ GENERATED_DIR = APP_DIR / "generated"
22
+ STATIC_DIR = APP_DIR / "static"
23
+ TEMPLATE_DIR = APP_DIR / "templates"
24
+ INDEX_FILE = TEMPLATE_DIR / "index.html"
25
+
26
+ GENERATED_DIR.mkdir(parents=True, exist_ok=True)
27
+
28
+ MAX_TEXT_LENGTH = 3000
29
+ TRANSLATE_MAX_CHARS = 180
30
+ DEFAULT_TRANSLATE_LANG = "en"
31
+ VOICE_CACHE_TTL_SECONDS = 900
32
+
33
+ TRANSLATE_LANGUAGES = [
34
+ {"code": "en", "label": "English"},
35
+ {"code": "ar", "label": "Arabic"},
36
+ {"code": "vi", "label": "Vietnamese"},
37
+ {"code": "ja", "label": "Japanese"},
38
+ {"code": "ko", "label": "Korean"},
39
+ {"code": "zh-CN", "label": "Chinese (Simplified)"},
40
+ {"code": "fr", "label": "French"},
41
+ ]
42
+
43
+ VOICE_CACHE: dict[str, dict[str, object]] = {}
44
+
45
+ app = FastAPI(title="EG Autonomous TTS Studio", version="3.0.0")
46
+ app.add_middleware(
47
+ CORSMiddleware,
48
+ allow_origins=["*"],
49
+ allow_credentials=False,
50
+ allow_methods=["*"],
51
+ allow_headers=["*"],
52
+ )
53
+ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
54
+
55
+
56
+ class SynthesizeRequest(BaseModel):
57
+ provider: str = Field(default="translate", pattern="^(translate|edge)$")
58
+ text: str = Field(..., min_length=1, max_length=MAX_TEXT_LENGTH)
59
+ voice: str = Field(default=DEFAULT_VOICE, min_length=1)
60
+ rate: str = Field(default="+0%")
61
+ volume: str = Field(default="+0%")
62
+ pitch: str = Field(default="+0Hz")
63
+ lang: str = Field(default=DEFAULT_TRANSLATE_LANG, min_length=2, max_length=12)
64
+ slow: bool = Field(default=False)
65
+
66
+
67
+ @app.get("/", response_class=HTMLResponse)
68
+ async def index() -> HTMLResponse:
69
+ return HTMLResponse(INDEX_FILE.read_text(encoding="utf-8"))
70
+
71
+
72
+ @app.get("/health")
73
+ async def health() -> dict[str, str]:
74
+ return {"status": "ok"}
75
+
76
+
77
+ @app.get("/api/defaults")
78
+ async def get_defaults() -> dict[str, object]:
79
+ return {
80
+ "text": DEFAULT_TEXT,
81
+ "provider": "translate",
82
+ "voice": DEFAULT_VOICE,
83
+ "rate": "+0%",
84
+ "volume": "+0%",
85
+ "pitch": "+0Hz",
86
+ "lang": DEFAULT_TRANSLATE_LANG,
87
+ "slow": False,
88
+ "translateLanguages": TRANSLATE_LANGUAGES,
89
+ }
90
+
91
+
92
+ @app.get("/api/bootstrap")
93
+ async def get_bootstrap_data(request: FastApiRequest) -> dict[str, object]:
94
+ return {
95
+ "defaults": await get_defaults(),
96
+ "providers": (await get_providers())["items"],
97
+ "network": (await get_network_info(request)),
98
+ }
99
+
100
+
101
+ @app.get("/api/providers")
102
+ async def get_providers() -> dict[str, object]:
103
+ return {
104
+ "items": [
105
+ {
106
+ "id": "translate",
107
+ "displayName": "Google Translate TTS URL",
108
+ "description": "Generate direct and proxied translate_tts URLs for instant playback.",
109
+ },
110
+ {
111
+ "id": "edge",
112
+ "displayName": "Edge TTS Engine",
113
+ "description": "Generate MP3 audio with edge-tts through the internal synthesis engine.",
114
+ },
115
+ ]
116
+ }
117
+
118
+
119
+ @app.get("/api/network")
120
+ async def get_network_info(request: FastApiRequest) -> dict[str, object]:
121
+ host_header = request.headers.get("host", "")
122
+ current_host = host_header.split(":", 1)[0] if host_header else request.url.hostname or "127.0.0.1"
123
+ current_port = request.url.port or 7860
124
+ lan_urls = build_lan_urls_(current_port)
125
+
126
+ return {
127
+ "ok": True,
128
+ "host": current_host,
129
+ "port": current_port,
130
+ "localUrl": f"http://127.0.0.1:{current_port}",
131
+ "currentUrl": str(request.base_url).rstrip("/"),
132
+ "lanUrls": lan_urls,
133
+ "lanEnabledHint": f"python -m uvicorn app:app --host 0.0.0.0 --port {current_port}",
134
+ "isLocalRequest": current_host in {"127.0.0.1", "localhost", "0.0.0.0"},
135
+ }
136
+
137
+
138
+ @app.get("/api/voices")
139
+ async def list_voice_catalog(filter: str | None = None) -> dict[str, object]:
140
+ voices = await get_cached_voices_(filter)
141
+ return {
142
+ "items": [
143
+ {
144
+ "name": voice["ShortName"],
145
+ "locale": voice["Locale"],
146
+ "gender": voice["Gender"],
147
+ "friendlyName": voice.get("FriendlyName", voice["ShortName"]),
148
+ }
149
+ for voice in voices
150
+ ]
151
+ }
152
+
153
+
154
+ @app.get("/api/translate-url")
155
+ async def get_translate_url(
156
+ request: FastApiRequest,
157
+ text: str | None = Query(default=None),
158
+ q: str | None = Query(default=None),
159
+ lang: str | None = Query(default=None),
160
+ tl: str | None = Query(default=None),
161
+ slow: bool = Query(default=False),
162
+ ) -> JSONResponse:
163
+ normalized_text = normalize_text_(text or q or "")
164
+ if not normalized_text:
165
+ raise HTTPException(status_code=400, detail="Text is required.")
166
+
167
+ resolved_lang = normalize_lang_(lang or tl or DEFAULT_TRANSLATE_LANG)
168
+ segments = build_translate_segments_(normalized_text, resolved_lang, slow, request)
169
+
170
+ return JSONResponse(
171
+ {
172
+ "ok": True,
173
+ "provider": "translate",
174
+ "text": normalized_text,
175
+ "lang": resolved_lang,
176
+ "slow": slow,
177
+ "directUrl": segments[0]["directUrl"],
178
+ "proxyUrl": segments[0]["proxyUrl"],
179
+ "segments": segments,
180
+ "chunkCount": len(segments),
181
+ }
182
+ )
183
+
184
+
185
+ @app.get("/api/translate-audio")
186
+ async def proxy_translate_audio(
187
+ text: str | None = Query(default=None),
188
+ q: str | None = Query(default=None),
189
+ lang: str | None = Query(default=None),
190
+ tl: str | None = Query(default=None),
191
+ slow: bool = Query(default=False),
192
+ ) -> Response:
193
+ normalized_text = normalize_text_(text or q or "")
194
+ if not normalized_text:
195
+ raise HTTPException(status_code=400, detail="Text is required.")
196
+ if len(normalized_text) > TRANSLATE_MAX_CHARS:
197
+ raise HTTPException(
198
+ status_code=400,
199
+ detail="Text is too long for a single translate_tts request. Use /api/synthesize for segmented playback.",
200
+ )
201
+
202
+ resolved_lang = normalize_lang_(lang or tl or DEFAULT_TRANSLATE_LANG)
203
+ direct_url = build_translate_tts_url_(normalized_text, resolved_lang, slow)
204
+ audio_bytes, content_type = fetch_remote_audio_(direct_url)
205
+
206
+ return Response(
207
+ content=audio_bytes,
208
+ media_type=content_type or "audio/mpeg",
209
+ headers={"Cache-Control": "public, max-age=3600"},
210
+ )
211
+
212
+
213
+ @app.post("/api/synthesize")
214
+ async def synthesize_audio(payload: SynthesizeRequest, request: FastApiRequest) -> dict[str, object]:
215
+ text = normalize_text_(payload.text)
216
+ if not text:
217
+ raise HTTPException(status_code=400, detail="Text is required.")
218
+
219
+ if payload.provider == "translate":
220
+ segments = build_translate_segments_(text, normalize_lang_(payload.lang), payload.slow, request)
221
+ return {
222
+ "ok": True,
223
+ "provider": "translate",
224
+ "text": text,
225
+ "lang": normalize_lang_(payload.lang),
226
+ "slow": payload.slow,
227
+ "audioUrl": segments[0]["proxyUrl"],
228
+ "directUrl": segments[0]["directUrl"],
229
+ "proxyUrl": segments[0]["proxyUrl"],
230
+ "segments": segments,
231
+ "chunkCount": len(segments),
232
+ }
233
+
234
+ file_name = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S") + "-" + uuid4().hex + ".mp3"
235
+ output_path = GENERATED_DIR / file_name
236
+
237
+ await synthesize_to_file(
238
+ text=text,
239
+ output=output_path,
240
+ voice=payload.voice.strip(),
241
+ rate=payload.rate.strip(),
242
+ volume=payload.volume.strip(),
243
+ pitch=payload.pitch.strip(),
244
+ )
245
+
246
+ audio_url = str(request.url_for("get_audio_file", file_name=file_name))
247
+ return {
248
+ "ok": True,
249
+ "provider": "edge",
250
+ "audioUrl": audio_url,
251
+ "downloadUrl": audio_url,
252
+ "fileName": file_name,
253
+ "contentType": "audio/mpeg",
254
+ "voice": payload.voice.strip(),
255
+ "rate": payload.rate.strip(),
256
+ "volume": payload.volume.strip(),
257
+ "pitch": payload.pitch.strip(),
258
+ "textLength": len(text),
259
+ }
260
+
261
+
262
+ @app.get("/audio/{file_name}", name="get_audio_file")
263
+ async def get_audio_file(file_name: str) -> FileResponse:
264
+ if "/" in file_name or "\\" in file_name:
265
+ raise HTTPException(status_code=400, detail="Invalid file name.")
266
+
267
+ file_path = GENERATED_DIR / file_name
268
+ if not file_path.is_file():
269
+ raise HTTPException(status_code=404, detail="Audio file not found.")
270
+
271
+ return FileResponse(file_path, media_type="audio/mpeg", content_disposition_type="inline")
272
+
273
+
274
+ def build_translate_segments_(
275
+ text: str,
276
+ lang: str,
277
+ slow: bool,
278
+ request: FastApiRequest,
279
+ ) -> list[dict[str, object]]:
280
+ chunks = split_text_into_chunks_(text, TRANSLATE_MAX_CHARS)
281
+ segments: list[dict[str, object]] = []
282
+
283
+ for index, chunk in enumerate(chunks, start=1):
284
+ direct_url = build_translate_tts_url_(chunk, lang, slow)
285
+ proxy_url = str(
286
+ request.url_for("proxy_translate_audio")
287
+ .include_query_params(text=chunk, lang=lang, slow=str(slow).lower())
288
+ )
289
+ segments.append(
290
+ {
291
+ "index": index,
292
+ "text": chunk,
293
+ "directUrl": direct_url,
294
+ "proxyUrl": proxy_url,
295
+ }
296
+ )
297
+
298
+ return segments
299
+
300
+
301
+ def build_translate_tts_url_(text: str, lang: str, slow: bool) -> str:
302
+ params = {
303
+ "ie": "UTF-8",
304
+ "q": text,
305
+ "tl": lang,
306
+ "client": "tw-ob",
307
+ }
308
+ if slow:
309
+ params["ttsspeed"] = "0.24"
310
+
311
+ return "https://translate.google.com/translate_tts?" + urlencode(params, quote_via=quote)
312
+
313
+
314
+ def split_text_into_chunks_(text: str, max_length: int) -> list[str]:
315
+ normalized = normalize_text_(text)
316
+ if not normalized:
317
+ return []
318
+
319
+ paragraphs = normalized.split("\n")
320
+ chunks: list[str] = []
321
+
322
+ for paragraph in paragraphs:
323
+ sentences = split_sentences_(paragraph)
324
+ buffer = ""
325
+
326
+ for sentence in sentences:
327
+ part = sentence.strip()
328
+ if not part:
329
+ continue
330
+
331
+ if len(part) > max_length:
332
+ if buffer:
333
+ chunks.append(buffer)
334
+ buffer = ""
335
+ chunks.extend(split_long_part_(part, max_length))
336
+ continue
337
+
338
+ if not buffer:
339
+ buffer = part
340
+ continue
341
+
342
+ candidate = buffer + " " + part
343
+ if len(candidate) <= max_length:
344
+ buffer = candidate
345
+ continue
346
+
347
+ chunks.append(buffer)
348
+ buffer = part
349
+
350
+ if buffer:
351
+ chunks.append(buffer)
352
+
353
+ return chunks
354
+
355
+
356
+ def split_sentences_(text: str) -> list[str]:
357
+ sentences: list[str] = []
358
+ current = []
359
+ punctuation = {".", "!", "?", ";", ":"}
360
+
361
+ for char in text:
362
+ current.append(char)
363
+ if char in punctuation:
364
+ sentences.append("".join(current).strip())
365
+ current = []
366
+
367
+ if current:
368
+ sentences.append("".join(current).strip())
369
+
370
+ return [sentence for sentence in sentences if sentence]
371
+
372
+
373
+ def split_long_part_(text: str, max_length: int) -> list[str]:
374
+ words = [word for word in text.split() if word]
375
+ pieces: list[str] = []
376
+ buffer = ""
377
+
378
+ for word in words:
379
+ if len(word) > max_length:
380
+ if buffer:
381
+ pieces.append(buffer)
382
+ buffer = ""
383
+ for start in range(0, len(word), max_length):
384
+ pieces.append(word[start : start + max_length])
385
+ continue
386
+
387
+ candidate = word if not buffer else buffer + " " + word
388
+ if len(candidate) <= max_length:
389
+ buffer = candidate
390
+ continue
391
+
392
+ pieces.append(buffer)
393
+ buffer = word
394
+
395
+ if buffer:
396
+ pieces.append(buffer)
397
+
398
+ return pieces
399
+
400
+
401
+ def fetch_remote_audio_(url: str) -> tuple[bytes, str]:
402
+ request = Request(
403
+ url,
404
+ headers={
405
+ "User-Agent": "Mozilla/5.0",
406
+ "Referer": "https://translate.google.com/",
407
+ "Accept": "audio/mpeg,audio/*;q=0.9,*/*;q=0.8",
408
+ },
409
+ )
410
+
411
+ try:
412
+ with urlopen(request, timeout=20) as response:
413
+ content_type = response.headers.get_content_type() or "audio/mpeg"
414
+ return response.read(), content_type
415
+ except Exception as error:
416
+ raise HTTPException(status_code=502, detail=f"Could not fetch translate_tts audio: {error}") from error
417
+
418
+
419
+ def normalize_text_(value: str) -> str:
420
+ text = str(value or "").replace("\x00", "").replace("\r\n", "\n")
421
+ text = "\n".join(line.strip() for line in text.split("\n"))
422
+ text = text.strip()
423
+ return text[:MAX_TEXT_LENGTH]
424
+
425
+
426
+ def normalize_lang_(value: str) -> str:
427
+ lang = str(value or DEFAULT_TRANSLATE_LANG).strip()
428
+ if not lang:
429
+ return DEFAULT_TRANSLATE_LANG
430
+ return lang[:12]
431
+
432
+
433
+ async def get_cached_voices_(keyword: str | None) -> list[dict[str, object]]:
434
+ cache_key = (keyword or "").strip().lower()
435
+ cached = VOICE_CACHE.get(cache_key)
436
+ now = time.time()
437
+
438
+ if cached and now - float(cached["timestamp"]) < VOICE_CACHE_TTL_SECONDS:
439
+ return cached["voices"] # type: ignore[return-value]
440
+
441
+ voices = await get_voices(keyword)
442
+ VOICE_CACHE[cache_key] = {
443
+ "timestamp": now,
444
+ "voices": voices,
445
+ }
446
+ return voices
447
+
448
+
449
+ def build_lan_urls_(port: int) -> list[str]:
450
+ urls: list[str] = []
451
+ candidates = {"127.0.0.1"}
452
+
453
+ try:
454
+ hostname = socket.gethostname()
455
+ for result in socket.getaddrinfo(hostname, None, family=socket.AF_INET):
456
+ ip = result[4][0]
457
+ if ip:
458
+ candidates.add(ip)
459
+ except Exception:
460
+ pass
461
+
462
+ try:
463
+ udp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
464
+ udp_socket.connect(("8.8.8.8", 80))
465
+ candidates.add(udp_socket.getsockname()[0])
466
+ udp_socket.close()
467
+ except Exception:
468
+ pass
469
+
470
+ for ip in sorted(candidates):
471
+ if ip.startswith("127."):
472
+ continue
473
+ urls.append(f"http://{ip}:{port}")
474
+
475
+ return urls
476
+
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ edge-tts>=6.1,<8
2
+ fastapi>=0.111,<1
3
+ uvicorn[standard]>=0.30,<1
static/app.css ADDED
@@ -0,0 +1,446 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg-main: #020a1e;
3
+ --bg-secondary: #041634;
4
+ --panel: rgba(7, 26, 58, 0.72);
5
+ --panel-strong: rgba(10, 34, 73, 0.85);
6
+ --line: rgba(106, 183, 255, 0.24);
7
+ --text: #e9f4ff;
8
+ --muted: #9ebbd8;
9
+ --accent: #23c3ff;
10
+ --accent-strong: #005eff;
11
+ --success: #26e2c2;
12
+ --radius: 22px;
13
+ --shadow: 0 24px 60px rgba(0, 0, 0, 0.45);
14
+ }
15
+
16
+ * {
17
+ box-sizing: border-box;
18
+ }
19
+
20
+ html,
21
+ body {
22
+ margin: 0;
23
+ min-height: 100%;
24
+ }
25
+
26
+ body {
27
+ color: var(--text);
28
+ font-family: "Manrope", "Segoe UI", sans-serif;
29
+ background:
30
+ radial-gradient(circle at 20% -10%, rgba(35, 195, 255, 0.25), transparent 38%),
31
+ radial-gradient(circle at 95% 4%, rgba(0, 94, 255, 0.28), transparent 34%),
32
+ linear-gradient(150deg, var(--bg-main) 0%, var(--bg-secondary) 100%);
33
+ position: relative;
34
+ overflow-x: hidden;
35
+ }
36
+
37
+ body::before,
38
+ body::after {
39
+ content: "";
40
+ position: fixed;
41
+ inset: 0;
42
+ pointer-events: none;
43
+ z-index: 0;
44
+ }
45
+
46
+ body::before {
47
+ background:
48
+ linear-gradient(120deg, transparent 0%, rgba(35, 195, 255, 0.08) 35%, transparent 70%),
49
+ repeating-linear-gradient(
50
+ 0deg,
51
+ rgba(90, 152, 211, 0.05) 0,
52
+ rgba(90, 152, 211, 0.05) 1px,
53
+ transparent 1px,
54
+ transparent 28px
55
+ );
56
+ }
57
+
58
+ body::after {
59
+ background: radial-gradient(circle at 50% 100%, rgba(80, 172, 255, 0.18), transparent 45%);
60
+ }
61
+
62
+ .shell {
63
+ width: min(1200px, calc(100% - 30px));
64
+ margin: 0 auto;
65
+ padding: 28px 0 56px;
66
+ position: relative;
67
+ z-index: 1;
68
+ }
69
+
70
+ .hero {
71
+ margin-bottom: 24px;
72
+ }
73
+
74
+ .brand-banner {
75
+ position: relative;
76
+ border-radius: 24px;
77
+ overflow: hidden;
78
+ border: 1px solid rgba(126, 202, 255, 0.32);
79
+ box-shadow: var(--shadow);
80
+ min-height: 240px;
81
+ }
82
+
83
+ .brand-art {
84
+ width: 100%;
85
+ display: block;
86
+ object-fit: cover;
87
+ }
88
+
89
+ .brand-overlay {
90
+ position: absolute;
91
+ inset: 0;
92
+ background:
93
+ linear-gradient(to bottom, rgba(1, 10, 28, 0.08), rgba(1, 10, 28, 0.44)),
94
+ radial-gradient(circle at 50% 34%, rgba(216, 243, 255, 0.18), transparent 40%);
95
+ }
96
+
97
+ .hero-copy {
98
+ margin-top: 18px;
99
+ max-width: 760px;
100
+ }
101
+
102
+ .eyebrow,
103
+ .panel-kicker {
104
+ margin: 0 0 10px;
105
+ text-transform: uppercase;
106
+ letter-spacing: 0.18em;
107
+ color: #9fd8ff;
108
+ font-size: 0.74rem;
109
+ font-weight: 700;
110
+ }
111
+
112
+ .hero h1,
113
+ .panel-head h2,
114
+ .lower-panel h3 {
115
+ margin: 0;
116
+ font-family: "Orbitron", "Manrope", sans-serif;
117
+ }
118
+
119
+ .hero h1 {
120
+ font-size: clamp(2rem, 5.2vw, 3.6rem);
121
+ line-height: 1.07;
122
+ letter-spacing: 0.02em;
123
+ text-transform: uppercase;
124
+ text-shadow: 0 0 20px rgba(35, 195, 255, 0.4);
125
+ }
126
+
127
+ .intro {
128
+ margin: 14px 0 0;
129
+ color: var(--muted);
130
+ font-size: 1rem;
131
+ line-height: 1.76;
132
+ }
133
+
134
+ code {
135
+ padding: 0.14rem 0.42rem;
136
+ border-radius: 7px;
137
+ background: rgba(116, 180, 255, 0.16);
138
+ border: 1px solid rgba(116, 180, 255, 0.2);
139
+ }
140
+
141
+ .workspace {
142
+ display: grid;
143
+ grid-template-columns: 1.05fr 0.95fr;
144
+ gap: 20px;
145
+ align-items: start;
146
+ }
147
+
148
+ .panel {
149
+ border: 1px solid var(--line);
150
+ border-radius: var(--radius);
151
+ background: var(--panel);
152
+ box-shadow: var(--shadow);
153
+ padding: 22px;
154
+ backdrop-filter: blur(9px);
155
+ }
156
+
157
+ .lower-panel {
158
+ margin-top: 20px;
159
+ background: var(--panel-strong);
160
+ }
161
+
162
+ .panel-head {
163
+ display: flex;
164
+ justify-content: space-between;
165
+ gap: 16px;
166
+ align-items: flex-start;
167
+ margin-bottom: 16px;
168
+ }
169
+
170
+ .panel-head h2 {
171
+ font-size: 1.2rem;
172
+ letter-spacing: 0.02em;
173
+ text-transform: uppercase;
174
+ }
175
+
176
+ .status-pill,
177
+ .summary-badge {
178
+ display: inline-flex;
179
+ align-items: center;
180
+ min-height: 36px;
181
+ padding: 0 14px;
182
+ border-radius: 999px;
183
+ font-size: 0.84rem;
184
+ font-weight: 700;
185
+ }
186
+
187
+ .status-pill {
188
+ color: #002539;
189
+ background: linear-gradient(120deg, var(--success), #8dfff1);
190
+ }
191
+
192
+ .summary-badge {
193
+ color: #d0e9ff;
194
+ background: rgba(35, 195, 255, 0.18);
195
+ border: 1px solid rgba(142, 215, 255, 0.3);
196
+ }
197
+
198
+ .field {
199
+ display: grid;
200
+ gap: 9px;
201
+ margin-bottom: 16px;
202
+ }
203
+
204
+ .field span {
205
+ font-size: 0.8rem;
206
+ text-transform: uppercase;
207
+ letter-spacing: 0.1em;
208
+ font-weight: 700;
209
+ color: #a9d9ff;
210
+ }
211
+
212
+ .field-grid {
213
+ display: grid;
214
+ gap: 12px;
215
+ }
216
+
217
+ .field-grid-meta {
218
+ grid-template-columns: 1.1fr 1fr 1fr;
219
+ }
220
+
221
+ .field-grid-voices {
222
+ grid-template-columns: 0.84fr 1.16fr;
223
+ }
224
+
225
+ .field-grid-controls {
226
+ grid-template-columns: repeat(3, minmax(0, 1fr));
227
+ }
228
+
229
+ .checkbox-field {
230
+ align-content: end;
231
+ grid-auto-flow: column;
232
+ justify-content: start;
233
+ align-items: center;
234
+ gap: 9px;
235
+ }
236
+
237
+ .checkbox-field input {
238
+ width: 18px;
239
+ height: 18px;
240
+ margin: 0;
241
+ accent-color: var(--accent);
242
+ }
243
+
244
+ .checkbox-field span {
245
+ text-transform: none;
246
+ letter-spacing: 0;
247
+ font-size: 0.94rem;
248
+ font-weight: 600;
249
+ color: #d7edff;
250
+ }
251
+
252
+ textarea,
253
+ select,
254
+ input[type="text"] {
255
+ width: 100%;
256
+ border: 1px solid rgba(128, 195, 255, 0.36);
257
+ border-radius: 14px;
258
+ background: rgba(4, 20, 48, 0.74);
259
+ color: var(--text);
260
+ padding: 12px 13px;
261
+ font: inherit;
262
+ transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
263
+ }
264
+
265
+ textarea::placeholder,
266
+ input[type="text"]::placeholder {
267
+ color: #82a8cf;
268
+ }
269
+
270
+ textarea:focus,
271
+ select:focus,
272
+ input[type="text"]:focus {
273
+ outline: none;
274
+ border-color: rgba(93, 200, 255, 0.86);
275
+ box-shadow: 0 0 0 3px rgba(35, 195, 255, 0.2);
276
+ background: rgba(5, 27, 62, 0.92);
277
+ }
278
+
279
+ textarea {
280
+ min-height: 112px;
281
+ resize: vertical;
282
+ line-height: 1.62;
283
+ }
284
+
285
+ button,
286
+ .button-link {
287
+ appearance: none;
288
+ border: none;
289
+ cursor: pointer;
290
+ transition: transform 150ms ease, box-shadow 150ms ease, opacity 150ms ease;
291
+ }
292
+
293
+ button:hover,
294
+ .button-link:hover {
295
+ transform: translateY(-1px);
296
+ }
297
+
298
+ button:active,
299
+ .button-link:active {
300
+ transform: translateY(0);
301
+ }
302
+
303
+ .action-row {
304
+ display: flex;
305
+ flex-wrap: wrap;
306
+ gap: 10px;
307
+ }
308
+
309
+ .action-row button,
310
+ .button-link {
311
+ display: inline-flex;
312
+ align-items: center;
313
+ justify-content: center;
314
+ min-height: 46px;
315
+ padding: 0 20px;
316
+ border-radius: 999px;
317
+ font: inherit;
318
+ font-weight: 800;
319
+ text-decoration: none;
320
+ color: #041227;
321
+ background: linear-gradient(130deg, #97ecff, #1ac8ff 45%, #0f7eff);
322
+ box-shadow: 0 12px 24px rgba(20, 152, 255, 0.35);
323
+ }
324
+
325
+ .action-row button.secondary {
326
+ color: #cce7ff;
327
+ background: rgba(58, 129, 194, 0.22);
328
+ border: 1px solid rgba(137, 205, 255, 0.26);
329
+ box-shadow: none;
330
+ }
331
+
332
+ .compact-actions {
333
+ margin-bottom: 16px;
334
+ }
335
+
336
+ .microcopy,
337
+ .message-box,
338
+ .warnings-box,
339
+ .guide-summary,
340
+ .guide-copy,
341
+ .guide-note {
342
+ color: var(--muted);
343
+ line-height: 1.7;
344
+ }
345
+
346
+ .microcopy {
347
+ margin: 16px 0 0;
348
+ }
349
+
350
+ .message-box {
351
+ min-height: 24px;
352
+ margin: 0 0 14px;
353
+ }
354
+
355
+ .warnings-box .warning {
356
+ padding: 10px 13px;
357
+ border-radius: 14px;
358
+ background: rgba(9, 31, 67, 0.84);
359
+ border: 1px solid rgba(126, 200, 255, 0.3);
360
+ }
361
+
362
+ .warnings-box .warning + .warning {
363
+ margin-top: 10px;
364
+ }
365
+
366
+ #player {
367
+ width: 100%;
368
+ margin-top: 6px;
369
+ }
370
+
371
+ .player-actions {
372
+ margin-top: 14px;
373
+ margin-bottom: 14px;
374
+ }
375
+
376
+ .lower-grid {
377
+ display: grid;
378
+ grid-template-columns: 0.95fr 1.05fr;
379
+ gap: 20px;
380
+ }
381
+
382
+ .guide-card {
383
+ padding: 16px;
384
+ border-radius: 18px;
385
+ background: rgba(10, 35, 75, 0.74);
386
+ border: 1px solid rgba(128, 199, 255, 0.26);
387
+ }
388
+
389
+ .guide-card + .guide-card,
390
+ .guide-card + h3 {
391
+ margin-top: 14px;
392
+ }
393
+
394
+ .guide-label {
395
+ margin: 12px 0 8px;
396
+ font-size: 0.8rem;
397
+ text-transform: uppercase;
398
+ letter-spacing: 0.1em;
399
+ color: #95d6ff;
400
+ font-weight: 700;
401
+ }
402
+
403
+ .guide-steps,
404
+ .guide-response {
405
+ margin: 8px 0 0;
406
+ padding-left: 20px;
407
+ }
408
+
409
+ .guide-steps li,
410
+ .guide-response li {
411
+ line-height: 1.6;
412
+ margin-bottom: 8px;
413
+ }
414
+
415
+ .provider-panel.hidden,
416
+ .hidden {
417
+ display: none;
418
+ }
419
+
420
+ @media (max-width: 980px) {
421
+ .workspace,
422
+ .lower-grid,
423
+ .field-grid-meta,
424
+ .field-grid-voices,
425
+ .field-grid-controls {
426
+ grid-template-columns: 1fr;
427
+ }
428
+
429
+ .shell {
430
+ width: min(100% - 14px, 1200px);
431
+ padding-top: 14px;
432
+ }
433
+
434
+ .brand-banner {
435
+ min-height: 170px;
436
+ }
437
+
438
+ .brand-art {
439
+ min-height: 170px;
440
+ object-position: center;
441
+ }
442
+
443
+ .hero h1 {
444
+ font-size: clamp(1.7rem, 8vw, 2.5rem);
445
+ }
446
+ }
static/app.js ADDED
@@ -0,0 +1,510 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ (function () {
2
+ const state = {
3
+ defaults: null,
4
+ providers: [],
5
+ voices: [],
6
+ voicesLoaded: false,
7
+ activeVoiceFilter: "",
8
+ payload: null,
9
+ queueIndex: 0,
10
+ };
11
+
12
+ const elements = {
13
+ form: document.getElementById("tts-form"),
14
+ providerSelect: document.getElementById("provider-select"),
15
+ langSelect: document.getElementById("lang-select"),
16
+ slowInput: document.getElementById("slow-input"),
17
+ textInput: document.getElementById("text-input"),
18
+ edgeControls: document.getElementById("edge-controls"),
19
+ voiceFilterInput: document.getElementById("voice-filter-input"),
20
+ voiceSelect: document.getElementById("voice-select"),
21
+ loadVoicesButton: document.getElementById("load-voices-button"),
22
+ rateInput: document.getElementById("rate-input"),
23
+ volumeInput: document.getElementById("volume-input"),
24
+ pitchInput: document.getElementById("pitch-input"),
25
+ generateButton: document.getElementById("generate-button"),
26
+ sampleButton: document.getElementById("sample-button"),
27
+ statusPill: document.getElementById("status-pill"),
28
+ summaryBadge: document.getElementById("summary-badge"),
29
+ messageBox: document.getElementById("message-box"),
30
+ player: document.getElementById("player"),
31
+ openLink: document.getElementById("open-link"),
32
+ warningsBox: document.getElementById("warnings-box"),
33
+ directUrl: document.getElementById("direct-url"),
34
+ proxyUrl: document.getElementById("proxy-url"),
35
+ jsonOutput: document.getElementById("json-output"),
36
+ baseHtmlSnippet: document.getElementById("base-html-snippet"),
37
+ exampleGetUrl: document.getElementById("example-get-url"),
38
+ exampleAudioUrl: document.getElementById("example-audio-url"),
39
+ exampleVoicesUrl: document.getElementById("example-voices-url"),
40
+ audioTagSnippet: document.getElementById("audio-tag-snippet"),
41
+ fetchGetSnippet: document.getElementById("fetch-get-snippet"),
42
+ fetchPostSnippet: document.getElementById("fetch-post-snippet"),
43
+ translateResponseSnippet: document.getElementById("translate-response-snippet"),
44
+ edgeResponseSnippet: document.getElementById("edge-response-snippet"),
45
+ processSummary: document.getElementById("process-summary"),
46
+ };
47
+
48
+ bindEvents();
49
+ bootstrap();
50
+
51
+ function bindEvents() {
52
+ elements.form.addEventListener("submit", handleSubmit);
53
+ elements.providerSelect.addEventListener("change", syncProviderMode);
54
+ elements.loadVoicesButton.addEventListener("click", loadVoices);
55
+ elements.textInput.addEventListener("input", updateExamples);
56
+ elements.langSelect.addEventListener("change", updateExamples);
57
+ elements.providerSelect.addEventListener("change", updateExamples);
58
+ elements.slowInput.addEventListener("change", updateExamples);
59
+ elements.voiceSelect.addEventListener("change", updateExamples);
60
+ elements.rateInput.addEventListener("input", updateExamples);
61
+ elements.volumeInput.addEventListener("input", updateExamples);
62
+ elements.pitchInput.addEventListener("input", updateExamples);
63
+ elements.voiceFilterInput.addEventListener("input", function () {
64
+ const nextFilter = elements.voiceFilterInput.value.trim() || "en-US";
65
+ if (nextFilter !== state.activeVoiceFilter) {
66
+ state.voicesLoaded = false;
67
+ state.voices = [];
68
+ renderVoices();
69
+ }
70
+ });
71
+
72
+ elements.sampleButton.addEventListener("click", function () {
73
+ if (state.defaults) {
74
+ elements.textInput.value = state.defaults.text || "";
75
+ }
76
+ elements.textInput.focus();
77
+ updateExamples();
78
+ });
79
+
80
+ elements.player.addEventListener("ended", handlePlayerEnded);
81
+
82
+ document.querySelectorAll("[data-copy-target]").forEach(function (button) {
83
+ button.addEventListener("click", function () {
84
+ copyField(button.getAttribute("data-copy-target"));
85
+ });
86
+ });
87
+ }
88
+
89
+ async function bootstrap() {
90
+ setStatus("Loading");
91
+ setMessage("Loading configuration...");
92
+
93
+ try {
94
+ const bootstrapData = await fetchJson("/api/bootstrap");
95
+ state.defaults = bootstrapData.defaults || {};
96
+ state.providers = bootstrapData.providers || [];
97
+ renderLanguages();
98
+ applyDefaults();
99
+ setStatus("Ready");
100
+ setMessage("Enter text and click Generate audio.");
101
+ } catch (error) {
102
+ setStatus("Error");
103
+ setMessage(error.message || "Could not load configuration.");
104
+ }
105
+ }
106
+
107
+ function renderProviders() {
108
+ elements.providerSelect.innerHTML = "";
109
+ state.providers.forEach(function (provider) {
110
+ const option = document.createElement("option");
111
+ option.value = provider.id;
112
+ option.textContent = provider.displayName;
113
+ elements.providerSelect.appendChild(option);
114
+ });
115
+ }
116
+
117
+ function renderLanguages() {
118
+ elements.langSelect.innerHTML = "";
119
+ (state.defaults.translateLanguages || []).forEach(function (language) {
120
+ const option = document.createElement("option");
121
+ option.value = language.code;
122
+ option.textContent = language.label + " (" + language.code + ")";
123
+ elements.langSelect.appendChild(option);
124
+ });
125
+ }
126
+
127
+ function applyDefaults() {
128
+ renderProviders();
129
+ elements.providerSelect.value = state.defaults.provider || "translate";
130
+ elements.langSelect.value = state.defaults.lang || "en";
131
+ elements.textInput.value = state.defaults.text || "";
132
+ elements.voiceFilterInput.value = "en-US";
133
+ elements.rateInput.value = state.defaults.rate || "+0%";
134
+ elements.volumeInput.value = state.defaults.volume || "+0%";
135
+ elements.pitchInput.value = state.defaults.pitch || "+0Hz";
136
+ renderVoices();
137
+ syncProviderMode();
138
+ updateExamples();
139
+ }
140
+
141
+ async function loadVoices() {
142
+ if (state.voicesLoaded && state.activeVoiceFilter === (elements.voiceFilterInput.value.trim() || "en-US")) {
143
+ renderVoices();
144
+ return;
145
+ }
146
+
147
+ setMessage("Loading available voices...");
148
+ elements.loadVoicesButton.disabled = true;
149
+
150
+ try {
151
+ const filter = elements.voiceFilterInput.value.trim() || "en-US";
152
+ const response = await fetchJson("/api/voices?filter=" + encodeURIComponent(filter));
153
+ state.voices = response.items || [];
154
+ state.voicesLoaded = true;
155
+ state.activeVoiceFilter = filter;
156
+ renderVoices();
157
+ setMessage("Loaded " + state.voices.length + " voices.");
158
+ } catch (error) {
159
+ state.voices = [];
160
+ state.voicesLoaded = false;
161
+ renderVoices();
162
+ setMessage(error.message || "Could not load voices.");
163
+ } finally {
164
+ elements.loadVoicesButton.disabled = false;
165
+ }
166
+ }
167
+
168
+ function renderVoices() {
169
+ elements.voiceSelect.innerHTML = "";
170
+ if (!state.voices.length) {
171
+ const option = document.createElement("option");
172
+ option.value = state.defaults ? state.defaults.voice : "en-US-AriaNeural";
173
+ option.textContent = state.voicesLoaded ? option.value : "Load voices to choose";
174
+ elements.voiceSelect.appendChild(option);
175
+ return;
176
+ }
177
+
178
+ state.voices.forEach(function (voice, index) {
179
+ const option = document.createElement("option");
180
+ option.value = voice.name;
181
+ option.textContent = voice.name + " (" + voice.locale + ", " + voice.gender + ")";
182
+ if (index === 0 || voice.name === (state.defaults && state.defaults.voice)) {
183
+ option.selected = true;
184
+ }
185
+ elements.voiceSelect.appendChild(option);
186
+ });
187
+ }
188
+
189
+ function syncProviderMode() {
190
+ const provider = elements.providerSelect.value;
191
+ elements.edgeControls.classList.toggle("hidden", provider !== "edge");
192
+ updateExamples();
193
+ elements.generateButton.disabled = !provider;
194
+
195
+ if (provider === "edge" && !state.voicesLoaded) {
196
+ setMessage("Edge provider selected. Click Load voices when you are ready.");
197
+ }
198
+ }
199
+
200
+ async function handleSubmit(event) {
201
+ event.preventDefault();
202
+ const payload = collectPayload();
203
+
204
+ setBusy(true);
205
+ setStatus("Loading");
206
+ setMessage("Generating audio...");
207
+
208
+ try {
209
+ const response = await fetch("/api/synthesize", {
210
+ method: "POST",
211
+ headers: {
212
+ "Content-Type": "application/json",
213
+ },
214
+ body: JSON.stringify(payload),
215
+ });
216
+
217
+ const body = await response.json();
218
+ if (!response.ok) {
219
+ throw new Error(body.detail || "Could not generate audio.");
220
+ }
221
+
222
+ state.payload = body;
223
+ state.queueIndex = 0;
224
+ renderPayload();
225
+ setStatus("Ready");
226
+ setMessage(body.provider === "translate" ? "Translate URL generated successfully." : "Edge MP3 generated successfully.");
227
+ autoplayFirst();
228
+ } catch (error) {
229
+ setStatus("Error");
230
+ setMessage(error.message || "Could not generate audio.");
231
+ } finally {
232
+ setBusy(false);
233
+ }
234
+ }
235
+
236
+ function collectPayload() {
237
+ return {
238
+ provider: elements.providerSelect.value,
239
+ text: elements.textInput.value.trim(),
240
+ lang: elements.langSelect.value,
241
+ slow: elements.slowInput.checked,
242
+ voice: elements.voiceSelect.value,
243
+ rate: elements.rateInput.value.trim() || "+0%",
244
+ volume: elements.volumeInput.value.trim() || "+0%",
245
+ pitch: elements.pitchInput.value.trim() || "+0Hz",
246
+ };
247
+ }
248
+
249
+ function renderPayload() {
250
+ const payload = state.payload || {};
251
+ const segments = payload.segments || [];
252
+ const firstSegment = segments[0] || null;
253
+
254
+ const directUrl = payload.directUrl || (firstSegment ? firstSegment.directUrl : "");
255
+ const proxyUrl = payload.proxyUrl || payload.audioUrl || (firstSegment ? firstSegment.proxyUrl : "");
256
+
257
+ fillReadonly(elements.directUrl, directUrl);
258
+ fillReadonly(elements.proxyUrl, proxyUrl);
259
+ fillReadonly(elements.jsonOutput, JSON.stringify(payload, null, 2));
260
+
261
+ if (payload.provider === "translate") {
262
+ elements.player.src = proxyUrl || directUrl || "";
263
+ elements.summaryBadge.textContent = segments.length + (segments.length === 1 ? " segment" : " segments");
264
+ toggleOpenLink(proxyUrl || directUrl);
265
+ renderWarnings(
266
+ segments.length > 1
267
+ ? ["Long text was split into " + segments.length + " Translate segments."]
268
+ : ["Direct URL and Proxy URL are ready for playback."]
269
+ );
270
+ } else {
271
+ elements.player.src = payload.audioUrl || "";
272
+ elements.summaryBadge.textContent = "Edge MP3";
273
+ toggleOpenLink(payload.audioUrl || "");
274
+ renderWarnings(["MP3 was generated with edge-tts via the internal TTS engine."]);
275
+ }
276
+
277
+ if (elements.player.src) {
278
+ elements.player.load();
279
+ }
280
+
281
+ updateExamples();
282
+ }
283
+
284
+ function renderWarnings(items) {
285
+ elements.warningsBox.innerHTML = "";
286
+ (items || []).forEach(function (item) {
287
+ const warning = document.createElement("div");
288
+ warning.className = "warning";
289
+ warning.textContent = item;
290
+ elements.warningsBox.appendChild(warning);
291
+ });
292
+ }
293
+
294
+ function autoplayFirst() {
295
+ if (!elements.player.src) {
296
+ return;
297
+ }
298
+
299
+ const promise = elements.player.play();
300
+ if (promise && typeof promise.catch === "function") {
301
+ promise.catch(function () {
302
+ setMessage("Audio is ready. If autoplay is blocked, click Play manually.");
303
+ });
304
+ }
305
+ }
306
+
307
+ function handlePlayerEnded() {
308
+ const payload = state.payload || {};
309
+ const segments = payload.segments || [];
310
+
311
+ if (payload.provider !== "translate" || segments.length <= 1) {
312
+ return;
313
+ }
314
+
315
+ state.queueIndex += 1;
316
+ if (state.queueIndex >= segments.length) {
317
+ return;
318
+ }
319
+
320
+ const next = segments[state.queueIndex];
321
+ elements.player.src = next.proxyUrl || next.directUrl;
322
+ elements.player.load();
323
+ autoplayFirst();
324
+ }
325
+
326
+ function updateExamples() {
327
+ const payload = collectPayload();
328
+ const sampleText = payload.text || "Hello from EG Autonomous Studio";
329
+ const encodedText = encodeURIComponent(sampleText);
330
+ const encodedLang = encodeURIComponent(payload.lang || "en");
331
+ const encodedFilter = encodeURIComponent(elements.voiceFilterInput.value.trim() || "en-US");
332
+ const translateUrl = window.location.origin + "/api/translate-url?text=" + encodedText + "&tl=" + encodedLang;
333
+ const audioUrl = window.location.origin + "/api/translate-audio?text=" + encodedText + "&tl=" + encodedLang;
334
+ const voicesUrl = window.location.origin + "/api/voices?filter=" + encodedFilter;
335
+ const basePlayerId = "ttsPlayer";
336
+ const selectedVoice = elements.voiceSelect.value || "en-US-AriaNeural";
337
+
338
+ fillReadonly(elements.baseHtmlSnippet, '<audio id="' + basePlayerId + '" controls preload="none"></audio>');
339
+
340
+ fillReadonly(elements.exampleGetUrl, translateUrl);
341
+ fillReadonly(elements.exampleAudioUrl, audioUrl);
342
+ fillReadonly(elements.exampleVoicesUrl, voicesUrl);
343
+ fillReadonly(
344
+ elements.audioTagSnippet,
345
+ '<audio id="' + basePlayerId + '" controls preload="none" src="' + audioUrl + '"></audio>\n' +
346
+ "<script>\n" +
347
+ " const audioPlayer = document.getElementById(\"" + basePlayerId + "\");\n" +
348
+ " audioPlayer.play().catch(() => {\n" +
349
+ " console.log(\"Autoplay is blocked by the browser; user interaction is required.\");\n" +
350
+ " });\n" +
351
+ "<\\/script>"
352
+ );
353
+ fillReadonly(
354
+ elements.fetchGetSnippet,
355
+ "const audioPlayer = document.getElementById(\"" + basePlayerId + "\");\n\n" +
356
+ "// 1) Request playable URLs from the API\n" +
357
+ "const response = await fetch(\"" + translateUrl + "\");\n" +
358
+ "const data = await response.json();\n\n" +
359
+ "// 2) Prefer proxyUrl for stable inline playback\n" +
360
+ "audioPlayer.src = data.proxyUrl || data.directUrl;\n" +
361
+ "audioPlayer.load();\n\n" +
362
+ "// 3) Attempt autoplay, fallback to manual play\n" +
363
+ "await audioPlayer.play().catch(() => {\n" +
364
+ " console.log(\"Autoplay is blocked by the browser; user interaction is required.\");\n" +
365
+ "});"
366
+ );
367
+ fillReadonly(
368
+ elements.fetchPostSnippet,
369
+ "const audioPlayer = document.getElementById(\"" + basePlayerId + "\");\n\n" +
370
+ "// 1) Create MP3 with Edge TTS\n" +
371
+ "const response = await fetch(\"" + window.location.origin + "/api/synthesize\", {\n" +
372
+ " method: \"POST\",\n" +
373
+ " headers: { \"Content-Type\": \"application/json\" },\n" +
374
+ " body: JSON.stringify(" + JSON.stringify({
375
+ provider: "edge",
376
+ text: sampleText,
377
+ voice: selectedVoice,
378
+ rate: elements.rateInput.value.trim() || "+0%",
379
+ volume: elements.volumeInput.value.trim() || "+0%",
380
+ pitch: elements.pitchInput.value.trim() || "+0Hz",
381
+ }, null, 2) + ")\n" +
382
+ "});\n" +
383
+ "const data = await response.json();\n\n" +
384
+ "// 2) data.audioUrl is the generated MP3\n" +
385
+ "audioPlayer.src = data.audioUrl;\n" +
386
+ "audioPlayer.load();\n\n" +
387
+ "// 3) Attempt autoplay\n" +
388
+ "await audioPlayer.play().catch(() => {\n" +
389
+ " console.log(\"Autoplay is blocked by the browser; user interaction is required.\");\n" +
390
+ "});"
391
+ );
392
+
393
+ fillReadonly(
394
+ elements.translateResponseSnippet,
395
+ JSON.stringify(
396
+ {
397
+ ok: true,
398
+ provider: "translate",
399
+ text: sampleText,
400
+ lang: payload.lang || "en",
401
+ slow: payload.slow,
402
+ directUrl: "https://translate.google.com/translate_tts?ie=UTF-8&q=" + encodedText + "&tl=" + (payload.lang || "en") + "&client=tw-ob",
403
+ proxyUrl: audioUrl,
404
+ segments: [
405
+ {
406
+ index: 1,
407
+ text: sampleText,
408
+ directUrl: "https://translate.google.com/translate_tts?ie=UTF-8&q=" + encodedText + "&tl=" + (payload.lang || "en") + "&client=tw-ob",
409
+ proxyUrl: audioUrl,
410
+ },
411
+ ],
412
+ chunkCount: 1,
413
+ },
414
+ null,
415
+ 2
416
+ )
417
+ );
418
+
419
+ fillReadonly(
420
+ elements.edgeResponseSnippet,
421
+ JSON.stringify(
422
+ {
423
+ ok: true,
424
+ provider: "edge",
425
+ audioUrl: window.location.origin + "/audio/example.mp3",
426
+ contentType: "audio/mpeg",
427
+ voice: selectedVoice,
428
+ rate: elements.rateInput.value.trim() || "+0%",
429
+ volume: elements.volumeInput.value.trim() || "+0%",
430
+ pitch: elements.pitchInput.value.trim() || "+0Hz",
431
+ textLength: sampleText.length,
432
+ },
433
+ null,
434
+ 2
435
+ )
436
+ );
437
+
438
+ fillReadonly(
439
+ elements.processSummary,
440
+ "Translate URL mode:\n" +
441
+ "1) Call GET /api/translate-url to receive directUrl and proxyUrl.\n" +
442
+ "2) For immediate playback, use /api/translate-audio directly in <audio>.\n" +
443
+ "3) For JS control, set player.src = data.proxyUrl and call player.play().\n" +
444
+ "4) Browser autoplay policies may require first-click interaction.\n" +
445
+ "5) If text is long, iterate over the segments list for sequential playback.\n\n" +
446
+ "Edge neural mode:\n" +
447
+ "1) Call GET /api/voices?filter=en-US and choose a voice.\n" +
448
+ "2) Call POST /api/synthesize with provider=edge and voice parameters.\n" +
449
+ "3) Server runs the Edge TTS engine and returns audioUrl.\n" +
450
+ "4) Set audioUrl on your <audio> element, then load() and play().\n" +
451
+ "5) /audio/{file} is served inline for embedded web playback."
452
+ );
453
+ }
454
+
455
+ async function fetchJson(url) {
456
+ const response = await fetch(url);
457
+ const body = await response.json();
458
+ if (!response.ok) {
459
+ throw new Error(body.detail || "Request failed.");
460
+ }
461
+ return body;
462
+ }
463
+
464
+ function setBusy(isBusy) {
465
+ elements.generateButton.disabled = isBusy;
466
+ elements.generateButton.textContent = isBusy ? "Generating..." : "Generate audio";
467
+ }
468
+
469
+ function setStatus(value) {
470
+ elements.statusPill.textContent = value;
471
+ }
472
+
473
+ function setMessage(value) {
474
+ elements.messageBox.textContent = value;
475
+ }
476
+
477
+ function fillReadonly(node, value) {
478
+ node.value = value || "";
479
+ }
480
+
481
+ function toggleOpenLink(url) {
482
+ if (!url) {
483
+ elements.openLink.classList.add("hidden");
484
+ elements.openLink.href = "#";
485
+ return;
486
+ }
487
+
488
+ elements.openLink.classList.remove("hidden");
489
+ elements.openLink.href = url;
490
+ }
491
+
492
+ function copyField(targetId) {
493
+ const field = document.getElementById(targetId);
494
+ if (!field || !field.value) {
495
+ return;
496
+ }
497
+
498
+ if (navigator.clipboard && navigator.clipboard.writeText) {
499
+ navigator.clipboard.writeText(field.value).then(function () {
500
+ setMessage("Copied: " + targetId);
501
+ });
502
+ return;
503
+ }
504
+
505
+ field.focus();
506
+ field.select();
507
+ document.execCommand("copy");
508
+ setMessage("Copied: " + targetId);
509
+ }
510
+ })();
templates/index.html ADDED
@@ -0,0 +1,249 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>EG AUTONOMOUS | TTS Command Studio</title>
7
+ <meta name="description" content="Enterprise-grade text-to-speech dashboard aligned with EG AUTONOMOUS visual identity.">
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Orbitron:wght@500;600;700;800&display=swap" rel="stylesheet">
11
+ <link rel="stylesheet" href="/static/app.css">
12
+ </head>
13
+ <body>
14
+ <main class="shell">
15
+ <section class="hero hero-brand">
16
+ <div class="brand-banner">
17
+ <img class="brand-art" src="/static/eg-autonomous-brand.png" alt="EG AUTONOMOUS brand visual identity">
18
+ <div class="brand-overlay"></div>
19
+ </div>
20
+
21
+ <div class="hero-copy">
22
+ <p class="eyebrow">EG Autonomous AI Platform</p>
23
+ <h1>TTS Command Studio</h1>
24
+ <p class="intro">
25
+ Professional text-to-speech generation with production-ready endpoints, dual synthesis providers,
26
+ and fast API handoff for autonomous systems.
27
+ </p>
28
+ </div>
29
+ </section>
30
+
31
+ <section class="workspace">
32
+ <form id="tts-form" class="panel form-panel">
33
+ <div class="panel-head">
34
+ <div>
35
+ <p class="panel-kicker">Composer</p>
36
+ <h2>Create audio output</h2>
37
+ </div>
38
+ <span id="status-pill" class="status-pill" aria-live="polite">Ready</span>
39
+ </div>
40
+
41
+ <div class="field-grid field-grid-meta">
42
+ <label class="field">
43
+ <span>Provider</span>
44
+ <select id="provider-select"></select>
45
+ </label>
46
+
47
+ <label class="field">
48
+ <span>Language</span>
49
+ <select id="lang-select"></select>
50
+ </label>
51
+
52
+ <label class="field checkbox-field">
53
+ <input id="slow-input" type="checkbox">
54
+ <span>Slow translate voice</span>
55
+ </label>
56
+ </div>
57
+
58
+ <label class="field">
59
+ <span>Text</span>
60
+ <textarea id="text-input" rows="8" placeholder="Enter the text you want to convert to speech"></textarea>
61
+ </label>
62
+
63
+ <div id="edge-controls" class="provider-panel hidden">
64
+ <div class="field-grid field-grid-voices">
65
+ <label class="field">
66
+ <span>Voice filter</span>
67
+ <input id="voice-filter-input" type="text" placeholder="en-US">
68
+ </label>
69
+
70
+ <label class="field">
71
+ <span>Voice</span>
72
+ <select id="voice-select"></select>
73
+ </label>
74
+ </div>
75
+
76
+ <div class="action-row compact-actions">
77
+ <button id="load-voices-button" type="button" class="secondary">Load voices</button>
78
+ </div>
79
+
80
+ <div class="field-grid field-grid-controls">
81
+ <label class="field">
82
+ <span>Rate</span>
83
+ <input id="rate-input" type="text" placeholder="+0%">
84
+ </label>
85
+
86
+ <label class="field">
87
+ <span>Volume</span>
88
+ <input id="volume-input" type="text" placeholder="+0%">
89
+ </label>
90
+
91
+ <label class="field">
92
+ <span>Pitch</span>
93
+ <input id="pitch-input" type="text" placeholder="+0Hz">
94
+ </label>
95
+ </div>
96
+ </div>
97
+
98
+ <div class="action-row">
99
+ <button id="generate-button" type="submit">Generate audio</button>
100
+ <button id="sample-button" type="button" class="secondary">Use sample text</button>
101
+ </div>
102
+
103
+ <p class="microcopy">
104
+ Example direct URL format:
105
+ <code>https://translate.google.com/translate_tts?ie=UTF-8&q=Hello%20from%20EG%20AUTONOMOUS&tl=en&client=tw-ob</code>
106
+ </p>
107
+ </form>
108
+
109
+ <section class="panel output-panel">
110
+ <div class="panel-head">
111
+ <div>
112
+ <p class="panel-kicker">Playback</p>
113
+ <h2>Player and API response</h2>
114
+ </div>
115
+ <span id="summary-badge" class="summary-badge">Waiting</span>
116
+ </div>
117
+
118
+ <p id="message-box" class="message-box" aria-live="polite">Enter text and click Generate audio.</p>
119
+
120
+ <audio id="player" controls preload="none"></audio>
121
+
122
+ <div class="action-row player-actions">
123
+ <a id="open-link" class="button-link hidden" href="#" target="_blank" rel="noreferrer">Open audio file</a>
124
+ </div>
125
+
126
+ <div id="warnings-box" class="warnings-box"></div>
127
+
128
+ <label class="field">
129
+ <span>Direct URL</span>
130
+ <textarea id="direct-url" rows="3" readonly></textarea>
131
+ </label>
132
+
133
+ <label class="field">
134
+ <span>Proxy URL</span>
135
+ <textarea id="proxy-url" rows="3" readonly></textarea>
136
+ </label>
137
+
138
+ <label class="field">
139
+ <span>POST /api/synthesize response</span>
140
+ <textarea id="json-output" rows="10" readonly></textarea>
141
+ </label>
142
+ </section>
143
+ </section>
144
+
145
+ <section class="panel lower-panel">
146
+ <div class="panel-head">
147
+ <div>
148
+ <p class="panel-kicker">Developer Toolkit</p>
149
+ <h2>Ready-to-use API snippets</h2>
150
+ </div>
151
+ </div>
152
+
153
+ <div class="lower-grid">
154
+ <div>
155
+ <h3>Integration modes</h3>
156
+ <div class="guide-card">
157
+ <p class="guide-summary">
158
+ Choose the mode based on your product needs: fast URL playback with Translate or controllable MP3 generation with Edge voices.
159
+ </p>
160
+ <p class="guide-label">Translate URL mode</p>
161
+ <p class="guide-copy">
162
+ Best for quick playback where your app only needs an instantly playable URL and minimal setup complexity.
163
+ </p>
164
+ <ol class="guide-steps">
165
+ <li>Call <code>GET /api/translate-url</code> or <code>POST /api/synthesize</code> with <code>provider=translate</code>.</li>
166
+ <li>Read <code>directUrl</code> and <code>proxyUrl</code> from the response.</li>
167
+ <li>Set <code>proxyUrl</code> as your player source for stable inline playback.</li>
168
+ </ol>
169
+ </div>
170
+
171
+ <div class="guide-card">
172
+ <p class="guide-label">Edge neural mode</p>
173
+ <p class="guide-copy">
174
+ Best for voice quality and detailed control over voice, rate, volume, and pitch with reusable MP3 output.
175
+ </p>
176
+ <ol class="guide-steps">
177
+ <li>Call <code>GET /api/voices?filter=en-US</code> to fetch available voices.</li>
178
+ <li>Call <code>POST /api/synthesize</code> with <code>provider=edge</code> and selected voice parameters.</li>
179
+ <li>Use <code>audioUrl</code> in your player for production playback.</li>
180
+ </ol>
181
+ </div>
182
+ </div>
183
+
184
+ <div>
185
+ <h3>Copy-and-paste snippets</h3>
186
+ <label class="field">
187
+ <span>1. Base HTML audio player</span>
188
+ <textarea id="base-html-snippet" rows="4" readonly></textarea>
189
+ </label>
190
+
191
+ <label class="field">
192
+ <span>2. Translate: inline audio playback</span>
193
+ <textarea id="audio-tag-snippet" rows="6" readonly></textarea>
194
+ </label>
195
+
196
+ <label class="field">
197
+ <span>3. Translate: fetch and play</span>
198
+ <textarea id="fetch-get-snippet" rows="10" readonly></textarea>
199
+ </label>
200
+
201
+ <label class="field">
202
+ <span>4. Translate URL example</span>
203
+ <textarea id="example-get-url" rows="4" readonly></textarea>
204
+ </label>
205
+
206
+ <label class="field">
207
+ <span>5. Translate audio endpoint</span>
208
+ <textarea id="example-audio-url" rows="4" readonly></textarea>
209
+ </label>
210
+
211
+ <label class="field">
212
+ <span>6. Edge voices endpoint</span>
213
+ <textarea id="example-voices-url" rows="3" readonly></textarea>
214
+ </label>
215
+
216
+ <label class="field">
217
+ <span>7. Edge synthesize request</span>
218
+ <textarea id="fetch-post-snippet" rows="16" readonly></textarea>
219
+ </label>
220
+
221
+ <label class="field">
222
+ <span>8. Translate response sample</span>
223
+ <textarea id="translate-response-snippet" rows="10" readonly></textarea>
224
+ </label>
225
+
226
+ <label class="field">
227
+ <span>9. Edge response sample</span>
228
+ <textarea id="edge-response-snippet" rows="10" readonly></textarea>
229
+ </label>
230
+
231
+ <label class="field">
232
+ <span>10. End-to-end process summary</span>
233
+ <textarea id="process-summary" rows="10" readonly></textarea>
234
+ </label>
235
+
236
+ <div class="action-row">
237
+ <button type="button" data-copy-target="base-html-snippet">Copy HTML base</button>
238
+ <button type="button" data-copy-target="audio-tag-snippet" class="secondary">Copy translate audio</button>
239
+ <button type="button" data-copy-target="fetch-get-snippet" class="secondary">Copy translate fetch</button>
240
+ <button type="button" data-copy-target="fetch-post-snippet" class="secondary">Copy edge fetch</button>
241
+ </div>
242
+ </div>
243
+ </div>
244
+ </section>
245
+ </main>
246
+
247
+ <script src="/static/app.js" defer></script>
248
+ </body>
249
+ </html>
tts_engine.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import asyncio
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import edge_tts
7
+
8
+
9
+ DEFAULT_TEXT = "Hello. This is the default sample used to validate edge-tts output."
10
+ DEFAULT_VOICE = "en-US-AriaNeural"
11
+
12
+
13
+ def parse_args() -> argparse.Namespace:
14
+ parser = argparse.ArgumentParser(description="Edge TTS test utility.")
15
+ parser.add_argument("--text", help="Text content to synthesize.")
16
+ parser.add_argument("--file", type=Path, help="Load synthesis text from a file.")
17
+ parser.add_argument("--voice", default=DEFAULT_VOICE, help="Edge TTS voice name.")
18
+ parser.add_argument("--rate", default="+0%", help="Speech rate, for example: +10%%")
19
+ parser.add_argument("--volume", default="+0%", help="Speech volume, for example: +0%%")
20
+ parser.add_argument("--pitch", default="+0Hz", help="Speech pitch, for example: +0Hz")
21
+ parser.add_argument("--output", type=Path, default=Path("output.mp3"), help="Output audio file path.")
22
+ parser.add_argument("--list-voices", action="store_true", help="List available voices.")
23
+ parser.add_argument("--filter", help="Filter voices by keyword, for example: en-US")
24
+ return parser.parse_args()
25
+
26
+
27
+ def load_text(args: argparse.Namespace) -> str:
28
+ if args.text:
29
+ return args.text.strip()
30
+ if args.file:
31
+ return args.file.read_text(encoding="utf-8").strip()
32
+ return DEFAULT_TEXT
33
+
34
+
35
+ async def get_voices(keyword: str | None = None) -> list[dict[str, Any]]:
36
+ voices = await edge_tts.list_voices()
37
+ if keyword:
38
+ keyword = keyword.lower()
39
+ voices = [
40
+ voice
41
+ for voice in voices
42
+ if keyword in voice["ShortName"].lower() or keyword in voice["Locale"].lower()
43
+ ]
44
+
45
+ return voices
46
+
47
+
48
+ async def list_voices(keyword: str | None) -> None:
49
+ voices = await get_voices(keyword)
50
+
51
+ if not voices:
52
+ print("No matching voice was found.")
53
+ return
54
+
55
+ for voice in voices:
56
+ print(f'{voice["ShortName"]} | {voice["Locale"]} | {voice["Gender"]}')
57
+
58
+
59
+ async def synthesize_to_file(
60
+ *,
61
+ text: str,
62
+ output: Path,
63
+ voice: str = DEFAULT_VOICE,
64
+ rate: str = "+0%",
65
+ volume: str = "+0%",
66
+ pitch: str = "+0Hz",
67
+ ) -> Path:
68
+ if not text:
69
+ raise ValueError("Input text cannot be empty.")
70
+
71
+ output.parent.mkdir(parents=True, exist_ok=True)
72
+
73
+ communicate = edge_tts.Communicate(
74
+ text=text,
75
+ voice=voice,
76
+ rate=rate,
77
+ volume=volume,
78
+ pitch=pitch,
79
+ )
80
+ await communicate.save(str(output))
81
+ return output.resolve()
82
+
83
+
84
+ async def generate_tts(args: argparse.Namespace) -> None:
85
+ output_path = await synthesize_to_file(
86
+ text=load_text(args),
87
+ output=args.output,
88
+ voice=args.voice,
89
+ rate=args.rate,
90
+ volume=args.volume,
91
+ pitch=args.pitch,
92
+ )
93
+ print(f"Audio file generated: {output_path}")
94
+ print(f"Voice: {args.voice}")
95
+
96
+
97
+ async def async_main() -> None:
98
+ args = parse_args()
99
+ if args.list_voices:
100
+ await list_voices(args.filter)
101
+ return
102
+ await generate_tts(args)
103
+
104
+
105
+ if __name__ == "__main__":
106
+ asyncio.run(async_main())
107
+