xxparthparekhxx commited on
Commit
64801d5
·
1 Parent(s): 19633f7

added the app

Browse files
Files changed (6) hide show
  1. .gitignore +1 -0
  2. Dockerfile +42 -0
  3. __pycache__/main.cpython-310.pyc +0 -0
  4. main.py +226 -0
  5. requirements.txt +17 -0
  6. static/index.html +1670 -0
.gitignore ADDED
@@ -0,0 +1 @@
 
 
1
+ .venv
Dockerfile ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ # ── System dependencies ────────────────────────────────────────────────────────
4
+ RUN apt-get update && apt-get install -y --no-install-recommends \
5
+ git \
6
+ git-lfs \
7
+ curl \
8
+ ffmpeg \
9
+ espeak-ng \
10
+ libsndfile1 \
11
+ build-essential \
12
+ && git lfs install \
13
+ && rm -rf /var/lib/apt/lists/*
14
+
15
+ # ── Create app user (HuggingFace Spaces runs as non-root) ─────────────────────
16
+ RUN useradd -m -u 1000 appuser
17
+ WORKDIR /app
18
+
19
+ # ── Python dependencies ────────────────────────────────────────────────────────
20
+ COPY requirements.txt .
21
+ RUN pip install --no-cache-dir --upgrade pip \
22
+ && pip install --no-cache-dir -r requirements.txt
23
+
24
+ # ── App files ─────────────────────────────────────────────────────────────────
25
+ COPY --chown=appuser:appuser main.py .
26
+ COPY --chown=appuser:appuser static/ ./static/
27
+
28
+ # ── Runtime directories ────────────────────────────────────────────────────────
29
+ RUN mkdir -p /app/audio_output && chown -R appuser:appuser /app
30
+
31
+ # ── Switch to non-root user ────────────────────────────────────────────────────
32
+ USER appuser
33
+
34
+ # ── Cache model weights at build time ─────────────────────────────────────────
35
+ # This bakes the model into the image so cold starts are fast.
36
+ RUN python -c "from kokoro import KModel; KModel(repo_id='hexgrad/Kokoro-82M')" || true
37
+
38
+ # ── Expose port (HuggingFace expects 7860) ────────────────────────────────────
39
+ EXPOSE 7860
40
+
41
+ # ── Start server ───────────────────────────────────────────────────────────────
42
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
__pycache__/main.cpython-310.pyc ADDED
Binary file (7.44 kB). View file
 
main.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import time
3
+ import asyncio
4
+ from pathlib import Path
5
+ from typing import Optional
6
+ import numpy as np
7
+ import soundfile as sf
8
+ import torch
9
+ from fastapi import FastAPI, HTTPException, BackgroundTasks
10
+ from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
11
+ from fastapi.staticfiles import StaticFiles
12
+ from fastapi.middleware.cors import CORSMiddleware
13
+ from pydantic import BaseModel
14
+ import logging
15
+ import io
16
+
17
+ logging.basicConfig(level=logging.INFO)
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # ── App Setup ──────────────────────────────────────────────────────────────────
21
+ app = FastAPI(
22
+ title="Kokoro TTS API",
23
+ description="Text-to-Speech API powered by Kokoro-82M",
24
+ version="1.0.0",
25
+ )
26
+
27
+ app.add_middleware(
28
+ CORSMiddleware,
29
+ allow_origins=["*"],
30
+ allow_methods=["*"],
31
+ allow_headers=["*"],
32
+ )
33
+
34
+ # ── Directories ────────────────────────────────────────────────────────────────
35
+ STATIC_DIR = Path("static")
36
+ STATIC_DIR.mkdir(exist_ok=True)
37
+
38
+ app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
39
+
40
+ # ── Model Loading ──────────────────────────────────────────────────────────────
41
+ CUDA_AVAILABLE = torch.cuda.is_available()
42
+ device = "cuda" if CUDA_AVAILABLE else "cpu"
43
+ logger.info(f"Device: {device}")
44
+
45
+ model = None
46
+ pipelines = {}
47
+
48
+ def load_model():
49
+ global model, pipelines
50
+ try:
51
+ from kokoro import KPipeline, KModel
52
+ logger.info("Loading Kokoro model...")
53
+ model = KModel(repo_id="hexgrad/Kokoro-82M").to(device).eval()
54
+
55
+ lang_codes = ["a", "b", "e", "f", "h", "i", "j", "p", "z"]
56
+ for code in lang_codes:
57
+ try:
58
+ pipelines[code] = KPipeline(lang_code=code, model=False)
59
+ except Exception as e:
60
+ logger.warning(f"Pipeline '{code}' failed: {e}")
61
+
62
+ if "a" in pipelines:
63
+ pipelines["a"].g2p.lexicon.golds["kokoro"] = "kˈOkəɹO"
64
+ if "b" in pipelines:
65
+ pipelines["b"].g2p.lexicon.golds["kokoro"] = "kˈQkəɹQ"
66
+
67
+ logger.info(f"Model loaded. Pipelines: {list(pipelines.keys())}")
68
+ except Exception as e:
69
+ logger.error(f"Model load failed: {e}")
70
+
71
+ # Load on startup
72
+ @app.on_event("startup")
73
+ async def startup_event():
74
+ loop = asyncio.get_event_loop()
75
+ await loop.run_in_executor(None, load_model)
76
+
77
+ # ── Voice Registry ─────────────────────────────────────────────────────────────
78
+ VOICES = {
79
+ # American English
80
+ "af_heart": {"label": "Heart", "lang": "en-US", "gender": "female", "flag": "🇺🇸", "code": "a"},
81
+ "af_bella": {"label": "Bella", "lang": "en-US", "gender": "female", "flag": "🇺🇸", "code": "a"},
82
+ "af_nicole": {"label": "Nicole", "lang": "en-US", "gender": "female", "flag": "🇺🇸", "code": "a"},
83
+ "af_aoede": {"label": "Aoede", "lang": "en-US", "gender": "female", "flag": "🇺🇸", "code": "a"},
84
+ "af_kore": {"label": "Kore", "lang": "en-US", "gender": "female", "flag": "🇺🇸", "code": "a"},
85
+ "af_sarah": {"label": "Sarah", "lang": "en-US", "gender": "female", "flag": "🇺🇸", "code": "a"},
86
+ "af_nova": {"label": "Nova", "lang": "en-US", "gender": "female", "flag": "🇺🇸", "code": "a"},
87
+ "af_sky": {"label": "Sky", "lang": "en-US", "gender": "female", "flag": "🇺🇸", "code": "a"},
88
+ "af_river": {"label": "River", "lang": "en-US", "gender": "female", "flag": "🇺🇸", "code": "a"},
89
+ "am_michael": {"label": "Michael", "lang": "en-US", "gender": "male", "flag": "🇺🇸", "code": "a"},
90
+ "am_fenrir": {"label": "Fenrir", "lang": "en-US", "gender": "male", "flag": "🇺🇸", "code": "a"},
91
+ "am_puck": {"label": "Puck", "lang": "en-US", "gender": "male", "flag": "🇺🇸", "code": "a"},
92
+ "am_echo": {"label": "Echo", "lang": "en-US", "gender": "male", "flag": "🇺🇸", "code": "a"},
93
+ "am_eric": {"label": "Eric", "lang": "en-US", "gender": "male", "flag": "🇺🇸", "code": "a"},
94
+ "am_liam": {"label": "Liam", "lang": "en-US", "gender": "male", "flag": "🇺🇸", "code": "a"},
95
+ "am_adam": {"label": "Adam", "lang": "en-US", "gender": "male", "flag": "🇺🇸", "code": "a"},
96
+ # British English
97
+ "bf_emma": {"label": "Emma", "lang": "en-GB", "gender": "female", "flag": "🇬🇧", "code": "b"},
98
+ "bf_isabella": {"label": "Isabella","lang": "en-GB", "gender": "female", "flag": "🇬🇧", "code": "b"},
99
+ "bf_alice": {"label": "Alice", "lang": "en-GB", "gender": "female", "flag": "🇬🇧", "code": "b"},
100
+ "bf_lily": {"label": "Lily", "lang": "en-GB", "gender": "female", "flag": "🇬🇧", "code": "b"},
101
+ "bm_george": {"label": "George", "lang": "en-GB", "gender": "male", "flag": "🇬🇧", "code": "b"},
102
+ "bm_fable": {"label": "Fable", "lang": "en-GB", "gender": "male", "flag": "🇬🇧", "code": "b"},
103
+ "bm_lewis": {"label": "Lewis", "lang": "en-GB", "gender": "male", "flag": "🇬🇧", "code": "b"},
104
+ "bm_daniel": {"label": "Daniel", "lang": "en-GB", "gender": "male", "flag": "🇬🇧", "code": "b"},
105
+ # Spanish
106
+ "ef_dora": {"label": "Dora", "lang": "es", "gender": "female", "flag": "🇪🇸", "code": "e"},
107
+ "em_alex": {"label": "Alex", "lang": "es", "gender": "male", "flag": "🇪🇸", "code": "e"},
108
+ # French
109
+ "ff_siwis": {"label": "Siwis", "lang": "fr", "gender": "female", "flag": "🇫🇷", "code": "f"},
110
+ # Hindi
111
+ "hf_alpha": {"label": "Alpha", "lang": "hi", "gender": "female", "flag": "🇮🇳", "code": "h"},
112
+ "hf_beta": {"label": "Beta", "lang": "hi", "gender": "female", "flag": "🇮🇳", "code": "h"},
113
+ "hm_omega": {"label": "Omega", "lang": "hi", "gender": "male", "flag": "🇮🇳", "code": "h"},
114
+ "hm_psi": {"label": "Psi", "lang": "hi", "gender": "male", "flag": "🇮🇳", "code": "h"},
115
+ # Italian
116
+ "if_sara": {"label": "Sara", "lang": "it", "gender": "female", "flag": "🇮🇹", "code": "i"},
117
+ "im_nicola": {"label": "Nicola", "lang": "it", "gender": "male", "flag": "🇮🇹", "code": "i"},
118
+ # Japanese
119
+ "jf_alpha": {"label": "Alpha", "lang": "ja", "gender": "female", "flag": "🇯🇵", "code": "j"},
120
+ "jf_gongitsune":{"label": "Gongitsune","lang": "ja", "gender": "female", "flag": "🇯🇵", "code": "j"},
121
+ "jf_nezumi": {"label": "Nezumi", "lang": "ja", "gender": "female", "flag": "🇯🇵", "code": "j"},
122
+ "jm_kumo": {"label": "Kumo", "lang": "ja", "gender": "male", "flag": "🇯🇵", "code": "j"},
123
+ # Portuguese
124
+ "pf_dora": {"label": "Dora", "lang": "pt", "gender": "female", "flag": "🇧🇷", "code": "p"},
125
+ "pm_alex": {"label": "Alex", "lang": "pt", "gender": "male", "flag": "🇧🇷", "code": "p"},
126
+ # Chinese
127
+ "zf_xiaobei": {"label": "Xiaobei", "lang": "zh", "gender": "female", "flag": "🇨🇳", "code": "z"},
128
+ "zf_xiaoxiao": {"label": "Xiaoxiao", "lang": "zh", "gender": "female", "flag": "🇨🇳", "code": "z"},
129
+ "zm_yunjian": {"label": "Yunjian", "lang": "zh", "gender": "male", "flag": "🇨🇳", "code": "z"},
130
+ "zm_yunxi": {"label": "Yunxi", "lang": "zh", "gender": "male", "flag": "🇨🇳", "code": "z"},
131
+ }
132
+
133
+ # ── Pydantic Models ────────────────────────────────────────────────────────────
134
+ class TTSRequest(BaseModel):
135
+ text: str
136
+ voice: str = "af_heart"
137
+ speed: float = 1.0
138
+ output_format: str = "wav" # "wav" or "mp3"
139
+
140
+
141
+ # ── Helper ─────────────────────────────────────────────────────────────────────
142
+ def _synthesize_to_bytes(text: str, voice: str, speed: float, output_format: str) -> tuple:
143
+ if model is None:
144
+ raise RuntimeError("Model not loaded yet")
145
+ voice_info = VOICES.get(voice)
146
+ if not voice_info:
147
+ raise ValueError(f"Unknown voice: {voice}")
148
+ pipeline = pipelines.get(voice_info["code"])
149
+ if not pipeline:
150
+ raise ValueError(f"No pipeline for lang code: {voice_info['code']}")
151
+ voice_pack = pipeline.load_voice(voice)
152
+ all_audio = []
153
+ for _, ps, _ in pipeline(text, voice, speed, split_pattern=r"\n+"):
154
+ ref_s = voice_pack[len(ps) - 1].to(device)
155
+ all_audio.append(model(ps, ref_s, speed).cpu().numpy())
156
+ if not all_audio:
157
+ raise RuntimeError("No audio generated")
158
+ final_audio = np.concatenate(all_audio)
159
+ duration = len(final_audio) / 24000
160
+ buf = io.BytesIO()
161
+ sf.write(buf, final_audio, 24000, format="WAV")
162
+ return buf.getvalue(), duration
163
+
164
+ # ── Routes ─────────────────────────────────────────────────────────────────────
165
+
166
+ @app.get("/")
167
+ async def root():
168
+ return FileResponse("static/index.html")
169
+
170
+ @app.get("/health")
171
+ async def health():
172
+ return {
173
+ "status": "ok",
174
+ "model_loaded": model is not None,
175
+ "device": device,
176
+ "cuda": CUDA_AVAILABLE,
177
+ "pipelines": list(pipelines.keys()),
178
+ }
179
+
180
+ @app.get("/voices")
181
+ async def list_voices():
182
+ available = {
183
+ k: v for k, v in VOICES.items() if v["code"] in pipelines
184
+ }
185
+ # Group by language
186
+ grouped = {}
187
+ for vid, info in available.items():
188
+ lang = info["lang"]
189
+ if lang not in grouped:
190
+ grouped[lang] = []
191
+ grouped[lang].append({"id": vid, **info})
192
+ return {"voices": available, "grouped": grouped, "total": len(available)}
193
+
194
+ @app.post("/tts")
195
+ async def text_to_speech(request: TTSRequest):
196
+ if not request.text.strip():
197
+ raise HTTPException(400, "text cannot be empty")
198
+ if request.voice not in VOICES:
199
+ raise HTTPException(400, f"Unknown voice. GET /voices for list.")
200
+ if not 0.5 <= request.speed <= 2.0:
201
+ raise HTTPException(400, "speed must be between 0.5 and 2.0")
202
+ if request.output_format not in ("wav", "mp3"):
203
+ raise HTTPException(400, "output_format must be wav or mp3")
204
+ if model is None:
205
+ raise HTTPException(503, "Model is still loading, please retry in a moment")
206
+
207
+ try:
208
+ loop = asyncio.get_event_loop()
209
+ audio_bytes, duration = await loop.run_in_executor(
210
+ None,
211
+ lambda: _synthesize_to_bytes(request.text, request.voice, request.speed, request.output_format),
212
+ )
213
+ except Exception as e:
214
+ logger.error(f"TTS error: {e}")
215
+ raise HTTPException(500, str(e))
216
+
217
+ fmt = request.output_format
218
+ return StreamingResponse(
219
+ io.BytesIO(audio_bytes),
220
+ media_type="audio/mpeg" if fmt == "mp3" else "audio/wav",
221
+ headers={
222
+ "Content-Disposition": f'attachment; filename="kokoro_{request.voice}.{fmt}"',
223
+ "X-Duration-Seconds": str(round(duration, 2)),
224
+ },
225
+ )
226
+
requirements.txt ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Core
2
+ fastapi==0.115.5
3
+ uvicorn[standard]==0.32.1
4
+ python-multipart==0.0.12
5
+
6
+ # TTS
7
+ kokoro>=0.9.4
8
+ torch>=2.1.0
9
+ soundfile>=0.12.1
10
+ numpy>=1.26.0
11
+
12
+ # Hugging Face
13
+ huggingface-hub>=0.26.0
14
+ transformers>=4.45.0
15
+
16
+ # Audio extras (for mp3 export)
17
+ pydub>=0.25.1
static/index.html ADDED
@@ -0,0 +1,1670 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>Kokoro TTS</title>
8
+ <link
9
+ href="https://fonts.googleapis.com/css2?family=Syne:wght@400;600;700;800&family=DM+Mono:wght@300;400;500&display=swap"
10
+ rel="stylesheet" />
11
+ <style>
12
+ :root {
13
+ --bg: #0a0a0f;
14
+ --surface: #111118;
15
+ --surface2: #1a1a24;
16
+ --surface3: #22222f;
17
+ --border: #2a2a3a;
18
+ --border2: #333345;
19
+ --accent: #7c6af7;
20
+ --accent2: #a78bfa;
21
+ --accent-glow: rgba(124, 106, 247, 0.25);
22
+ --green: #34d399;
23
+ --red: #f87171;
24
+ --yellow: #fbbf24;
25
+ --text: #e8e8f0;
26
+ --text-dim: #888898;
27
+ --text-muted: #555565;
28
+ --radius: 12px;
29
+ --radius-sm: 8px;
30
+ }
31
+
32
+ *,
33
+ *::before,
34
+ *::after {
35
+ box-sizing: border-box;
36
+ margin: 0;
37
+ padding: 0;
38
+ }
39
+
40
+ html {
41
+ background: var(--bg);
42
+ }
43
+
44
+ body {
45
+ font-family: 'DM Mono', monospace;
46
+ background: var(--bg);
47
+ color: var(--text);
48
+ min-height: 100vh;
49
+ display: grid;
50
+ grid-template-rows: auto 1fr auto;
51
+ overflow-x: hidden;
52
+ }
53
+
54
+ /* Subtle noise texture */
55
+ body::before {
56
+ content: '';
57
+ position: fixed;
58
+ inset: 0;
59
+ background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E");
60
+ pointer-events: none;
61
+ z-index: 0;
62
+ }
63
+
64
+ /* ── Header ── */
65
+ header {
66
+ position: relative;
67
+ z-index: 10;
68
+ padding: 24px 40px;
69
+ border-bottom: 1px solid var(--border);
70
+ display: flex;
71
+ align-items: center;
72
+ justify-content: space-between;
73
+ background: rgba(10, 10, 15, 0.8);
74
+ backdrop-filter: blur(12px);
75
+ }
76
+
77
+ .logo {
78
+ font-family: 'Syne', sans-serif;
79
+ font-size: 22px;
80
+ font-weight: 800;
81
+ letter-spacing: -0.5px;
82
+ display: flex;
83
+ align-items: center;
84
+ gap: 10px;
85
+ }
86
+
87
+ .logo-icon {
88
+ width: 32px;
89
+ height: 32px;
90
+ background: linear-gradient(135deg, var(--accent), #c084fc);
91
+ border-radius: 8px;
92
+ display: grid;
93
+ place-items: center;
94
+ font-size: 16px;
95
+ }
96
+
97
+ .logo-text span {
98
+ color: var(--accent2);
99
+ }
100
+
101
+ .status-badge {
102
+ display: flex;
103
+ align-items: center;
104
+ gap: 8px;
105
+ padding: 6px 14px;
106
+ background: var(--surface2);
107
+ border: 1px solid var(--border);
108
+ border-radius: 100px;
109
+ font-size: 11px;
110
+ color: var(--text-dim);
111
+ font-family: 'DM Mono', monospace;
112
+ }
113
+
114
+ .status-dot {
115
+ width: 7px;
116
+ height: 7px;
117
+ border-radius: 50%;
118
+ background: var(--text-muted);
119
+ transition: background 0.3s, box-shadow 0.3s;
120
+ }
121
+
122
+ .status-dot.ready {
123
+ background: var(--green);
124
+ box-shadow: 0 0 8px var(--green);
125
+ }
126
+
127
+ .status-dot.loading {
128
+ background: var(--yellow);
129
+ animation: pulse 1s infinite;
130
+ }
131
+
132
+ .status-dot.error {
133
+ background: var(--red);
134
+ }
135
+
136
+ @keyframes pulse {
137
+
138
+ 0%,
139
+ 100% {
140
+ opacity: 1
141
+ }
142
+
143
+ 50% {
144
+ opacity: 0.4
145
+ }
146
+ }
147
+
148
+ /* ── Tabs ── */
149
+ .tabs {
150
+ display: flex;
151
+ gap: 2px;
152
+ padding: 0 40px;
153
+ border-bottom: 1px solid var(--border);
154
+ background: var(--surface);
155
+ position: relative;
156
+ z-index: 9;
157
+ }
158
+
159
+ .tab-btn {
160
+ font-family: 'Syne', sans-serif;
161
+ font-size: 13px;
162
+ font-weight: 600;
163
+ letter-spacing: 0.5px;
164
+ padding: 14px 20px;
165
+ border: none;
166
+ background: transparent;
167
+ color: var(--text-muted);
168
+ cursor: pointer;
169
+ position: relative;
170
+ transition: color 0.2s;
171
+ display: flex;
172
+ align-items: center;
173
+ gap: 7px;
174
+ }
175
+
176
+ .tab-btn:hover {
177
+ color: var(--text-dim);
178
+ }
179
+
180
+ .tab-btn.active {
181
+ color: var(--accent2);
182
+ }
183
+
184
+ .tab-btn.active::after {
185
+ content: '';
186
+ position: absolute;
187
+ bottom: -1px;
188
+ left: 0;
189
+ right: 0;
190
+ height: 2px;
191
+ background: var(--accent);
192
+ border-radius: 2px 2px 0 0;
193
+ }
194
+
195
+ /* ── Main Layout ── */
196
+ main {
197
+ position: relative;
198
+ z-index: 5;
199
+ max-width: 1200px;
200
+ width: 100%;
201
+ margin: 0 auto;
202
+ padding: 32px 40px;
203
+ }
204
+
205
+ .tab-panel {
206
+ display: none;
207
+ }
208
+
209
+ .tab-panel.active {
210
+ display: block;
211
+ }
212
+
213
+ /* ── Generate Panel ── */
214
+ .generate-grid {
215
+ display: grid;
216
+ grid-template-columns: 1fr 420px;
217
+ gap: 24px;
218
+ align-items: start;
219
+ }
220
+
221
+ /* ── Card ── */
222
+ .card {
223
+ background: var(--surface);
224
+ border: 1px solid var(--border);
225
+ border-radius: var(--radius);
226
+ overflow: hidden;
227
+ }
228
+
229
+ .card-header {
230
+ padding: 16px 20px;
231
+ border-bottom: 1px solid var(--border);
232
+ display: flex;
233
+ align-items: center;
234
+ justify-content: space-between;
235
+ }
236
+
237
+ .card-title {
238
+ font-family: 'Syne', sans-serif;
239
+ font-size: 12px;
240
+ font-weight: 700;
241
+ letter-spacing: 1.5px;
242
+ text-transform: uppercase;
243
+ color: var(--text-dim);
244
+ }
245
+
246
+ .card-body {
247
+ padding: 20px;
248
+ }
249
+
250
+ /* ── Textarea ── */
251
+ textarea {
252
+ width: 100%;
253
+ min-height: 200px;
254
+ background: var(--surface2);
255
+ border: 1px solid var(--border);
256
+ border-radius: var(--radius-sm);
257
+ color: var(--text);
258
+ font-family: 'DM Mono', monospace;
259
+ font-size: 13px;
260
+ line-height: 1.7;
261
+ padding: 14px 16px;
262
+ resize: vertical;
263
+ transition: border-color 0.2s, box-shadow 0.2s;
264
+ outline: none;
265
+ }
266
+
267
+ textarea:focus {
268
+ border-color: var(--accent);
269
+ box-shadow: 0 0 0 3px var(--accent-glow);
270
+ }
271
+
272
+ .char-count {
273
+ font-size: 11px;
274
+ color: var(--text-muted);
275
+ text-align: right;
276
+ margin-top: 6px;
277
+ }
278
+
279
+ .char-count.warn {
280
+ color: var(--yellow);
281
+ }
282
+
283
+ .char-count.danger {
284
+ color: var(--red);
285
+ }
286
+
287
+ /* ── Examples ── */
288
+ .examples-section {
289
+ margin-top: 16px;
290
+ }
291
+
292
+ .examples-label {
293
+ font-size: 11px;
294
+ color: var(--text-muted);
295
+ letter-spacing: 1px;
296
+ text-transform: uppercase;
297
+ margin-bottom: 10px;
298
+ }
299
+
300
+ .examples-grid {
301
+ display: flex;
302
+ flex-wrap: wrap;
303
+ gap: 8px;
304
+ }
305
+
306
+ .example-chip {
307
+ font-family: 'DM Mono', monospace;
308
+ font-size: 11px;
309
+ padding: 5px 12px;
310
+ background: var(--surface2);
311
+ border: 1px solid var(--border);
312
+ border-radius: 100px;
313
+ color: var(--text-dim);
314
+ cursor: pointer;
315
+ transition: all 0.18s;
316
+ white-space: nowrap;
317
+ }
318
+
319
+ .example-chip:hover {
320
+ border-color: var(--accent);
321
+ color: var(--accent2);
322
+ background: rgba(124, 106, 247, 0.08);
323
+ }
324
+
325
+ /* ── Voice Selector ── */
326
+ .voice-search {
327
+ width: 100%;
328
+ background: var(--surface2);
329
+ border: 1px solid var(--border);
330
+ border-radius: var(--radius-sm);
331
+ color: var(--text);
332
+ font-family: 'DM Mono', monospace;
333
+ font-size: 12px;
334
+ padding: 9px 14px;
335
+ outline: none;
336
+ transition: border-color 0.2s;
337
+ margin-bottom: 12px;
338
+ }
339
+
340
+ .voice-search:focus {
341
+ border-color: var(--accent);
342
+ }
343
+
344
+ .voice-list {
345
+ max-height: 220px;
346
+ overflow-y: auto;
347
+ display: flex;
348
+ flex-direction: column;
349
+ gap: 4px;
350
+ }
351
+
352
+ .voice-list::-webkit-scrollbar {
353
+ width: 4px;
354
+ }
355
+
356
+ .voice-list::-webkit-scrollbar-track {
357
+ background: transparent;
358
+ }
359
+
360
+ .voice-list::-webkit-scrollbar-thumb {
361
+ background: var(--border2);
362
+ border-radius: 4px;
363
+ }
364
+
365
+ .voice-item {
366
+ display: flex;
367
+ align-items: center;
368
+ gap: 10px;
369
+ padding: 9px 12px;
370
+ border-radius: var(--radius-sm);
371
+ cursor: pointer;
372
+ border: 1px solid transparent;
373
+ transition: all 0.15s;
374
+ background: transparent;
375
+ }
376
+
377
+ .voice-item:hover {
378
+ background: var(--surface2);
379
+ border-color: var(--border);
380
+ }
381
+
382
+ .voice-item.selected {
383
+ background: rgba(124, 106, 247, 0.12);
384
+ border-color: var(--accent);
385
+ }
386
+
387
+ .voice-flag {
388
+ font-size: 16px;
389
+ line-height: 1;
390
+ }
391
+
392
+ .voice-info {
393
+ flex: 1;
394
+ }
395
+
396
+ .voice-name {
397
+ font-family: 'Syne', sans-serif;
398
+ font-size: 13px;
399
+ font-weight: 600;
400
+ color: var(--text);
401
+ }
402
+
403
+ .voice-meta {
404
+ font-size: 10px;
405
+ color: var(--text-muted);
406
+ margin-top: 1px;
407
+ }
408
+
409
+ .voice-gender {
410
+ font-size: 10px;
411
+ padding: 2px 8px;
412
+ border-radius: 100px;
413
+ font-family: 'DM Mono', monospace;
414
+ }
415
+
416
+ .voice-gender.female {
417
+ background: rgba(251, 113, 133, 0.15);
418
+ color: #fb7185;
419
+ border: 1px solid rgba(251, 113, 133, 0.2);
420
+ }
421
+
422
+ .voice-gender.male {
423
+ background: rgba(96, 165, 250, 0.15);
424
+ color: #60a5fa;
425
+ border: 1px solid rgba(96, 165, 250, 0.2);
426
+ }
427
+
428
+ /* ── Controls ── */
429
+ .control-row {
430
+ display: flex;
431
+ align-items: center;
432
+ justify-content: space-between;
433
+ gap: 12px;
434
+ margin-top: 16px;
435
+ }
436
+
437
+ .control-group {
438
+ display: flex;
439
+ flex-direction: column;
440
+ gap: 6px;
441
+ flex: 1;
442
+ }
443
+
444
+ .control-label {
445
+ font-size: 10px;
446
+ letter-spacing: 1.2px;
447
+ text-transform: uppercase;
448
+ color: var(--text-muted);
449
+ }
450
+
451
+ .control-value {
452
+ font-size: 12px;
453
+ color: var(--accent2);
454
+ font-weight: 500;
455
+ }
456
+
457
+ input[type=range] {
458
+ width: 100%;
459
+ appearance: none;
460
+ height: 4px;
461
+ background: var(--surface3);
462
+ border-radius: 4px;
463
+ outline: none;
464
+ cursor: pointer;
465
+ }
466
+
467
+ input[type=range]::-webkit-slider-thumb {
468
+ appearance: none;
469
+ width: 14px;
470
+ height: 14px;
471
+ background: var(--accent);
472
+ border-radius: 50%;
473
+ box-shadow: 0 0 6px var(--accent-glow);
474
+ cursor: pointer;
475
+ }
476
+
477
+ .format-toggle {
478
+ display: flex;
479
+ gap: 4px;
480
+ background: var(--surface2);
481
+ border: 1px solid var(--border);
482
+ border-radius: var(--radius-sm);
483
+ padding: 3px;
484
+ }
485
+
486
+ .format-btn {
487
+ font-family: 'DM Mono', monospace;
488
+ font-size: 11px;
489
+ padding: 5px 14px;
490
+ border: none;
491
+ border-radius: 6px;
492
+ cursor: pointer;
493
+ background: transparent;
494
+ color: var(--text-dim);
495
+ transition: all 0.15s;
496
+ }
497
+
498
+ .format-btn.active {
499
+ background: var(--accent);
500
+ color: #fff;
501
+ box-shadow: 0 2px 8px var(--accent-glow);
502
+ }
503
+
504
+ /* ── Generate Button ── */
505
+ .generate-btn {
506
+ width: 100%;
507
+ margin-top: 20px;
508
+ padding: 14px;
509
+ font-family: 'Syne', sans-serif;
510
+ font-size: 14px;
511
+ font-weight: 700;
512
+ letter-spacing: 0.5px;
513
+ border: none;
514
+ border-radius: var(--radius-sm);
515
+ background: linear-gradient(135deg, var(--accent) 0%, #a855f7 100%);
516
+ color: #fff;
517
+ cursor: pointer;
518
+ position: relative;
519
+ overflow: hidden;
520
+ transition: opacity 0.2s, transform 0.1s;
521
+ box-shadow: 0 4px 20px rgba(124, 106, 247, 0.35);
522
+ }
523
+
524
+ .generate-btn:hover {
525
+ opacity: 0.9;
526
+ transform: translateY(-1px);
527
+ }
528
+
529
+ .generate-btn:active {
530
+ transform: translateY(0);
531
+ }
532
+
533
+ .generate-btn:disabled {
534
+ opacity: 0.4;
535
+ cursor: not-allowed;
536
+ transform: none;
537
+ }
538
+
539
+ .generate-btn .spinner {
540
+ display: none;
541
+ width: 16px;
542
+ height: 16px;
543
+ border: 2px solid rgba(255, 255, 255, 0.3);
544
+ border-top-color: #fff;
545
+ border-radius: 50%;
546
+ animation: spin 0.7s linear infinite;
547
+ margin-right: 8px;
548
+ }
549
+
550
+ .generate-btn.loading .spinner {
551
+ display: inline-block;
552
+ }
553
+
554
+ .generate-btn.loading .btn-text::before {
555
+ content: 'Generating...';
556
+ }
557
+
558
+ .generate-btn:not(.loading) .btn-text::before {
559
+ content: '⚡ Generate Audio';
560
+ }
561
+
562
+ @keyframes spin {
563
+ to {
564
+ transform: rotate(360deg);
565
+ }
566
+ }
567
+
568
+ /* ── Output Panel ── */
569
+ .output-card {
570
+ position: sticky;
571
+ top: 24px;
572
+ }
573
+
574
+ .waveform-placeholder {
575
+ height: 80px;
576
+ background: var(--surface2);
577
+ border-radius: var(--radius-sm);
578
+ display: flex;
579
+ align-items: center;
580
+ justify-content: center;
581
+ color: var(--text-muted);
582
+ font-size: 12px;
583
+ border: 1px dashed var(--border);
584
+ margin-bottom: 16px;
585
+ position: relative;
586
+ overflow: hidden;
587
+ }
588
+
589
+ .waveform-bars {
590
+ display: flex;
591
+ align-items: center;
592
+ gap: 3px;
593
+ height: 50px;
594
+ }
595
+
596
+ .wbar {
597
+ width: 3px;
598
+ background: var(--accent);
599
+ border-radius: 2px;
600
+ opacity: 0.4;
601
+ }
602
+
603
+ audio {
604
+ width: 100%;
605
+ border-radius: var(--radius-sm);
606
+ accent-color: var(--accent);
607
+ background: var(--surface2);
608
+ }
609
+
610
+ /* Custom audio player */
611
+ .audio-player {
612
+ display: none;
613
+ flex-direction: column;
614
+ gap: 12px;
615
+ }
616
+
617
+ .audio-player.visible {
618
+ display: flex;
619
+ }
620
+
621
+ .player-controls {
622
+ display: flex;
623
+ align-items: center;
624
+ gap: 12px;
625
+ }
626
+
627
+ .play-btn {
628
+ width: 42px;
629
+ height: 42px;
630
+ border-radius: 50%;
631
+ background: var(--accent);
632
+ border: none;
633
+ cursor: pointer;
634
+ display: grid;
635
+ place-items: center;
636
+ color: #fff;
637
+ font-size: 16px;
638
+ flex-shrink: 0;
639
+ transition: all 0.15s;
640
+ box-shadow: 0 2px 12px var(--accent-glow);
641
+ }
642
+
643
+ .play-btn:hover {
644
+ transform: scale(1.08);
645
+ }
646
+
647
+ .progress-wrap {
648
+ flex: 1;
649
+ display: flex;
650
+ flex-direction: column;
651
+ gap: 4px;
652
+ }
653
+
654
+ .progress-bar-bg {
655
+ height: 4px;
656
+ background: var(--surface3);
657
+ border-radius: 4px;
658
+ cursor: pointer;
659
+ position: relative;
660
+ }
661
+
662
+ .progress-bar-fill {
663
+ height: 100%;
664
+ background: var(--accent);
665
+ border-radius: 4px;
666
+ width: 0%;
667
+ transition: width 0.1s;
668
+ position: relative;
669
+ }
670
+
671
+ .progress-bar-fill::after {
672
+ content: '';
673
+ position: absolute;
674
+ right: -5px;
675
+ top: -4px;
676
+ width: 12px;
677
+ height: 12px;
678
+ background: var(--accent2);
679
+ border-radius: 50%;
680
+ box-shadow: 0 0 6px var(--accent-glow);
681
+ }
682
+
683
+ .time-row {
684
+ display: flex;
685
+ justify-content: space-between;
686
+ font-size: 10px;
687
+ color: var(--text-muted);
688
+ }
689
+
690
+ .download-row {
691
+ display: flex;
692
+ gap: 8px;
693
+ }
694
+
695
+ .action-btn {
696
+ flex: 1;
697
+ padding: 8px;
698
+ font-family: 'DM Mono', monospace;
699
+ font-size: 11px;
700
+ border-radius: var(--radius-sm);
701
+ border: 1px solid var(--border);
702
+ background: var(--surface2);
703
+ color: var(--text-dim);
704
+ cursor: pointer;
705
+ transition: all 0.15s;
706
+ display: flex;
707
+ align-items: center;
708
+ justify-content: center;
709
+ gap: 6px;
710
+ }
711
+
712
+ .action-btn:hover {
713
+ border-color: var(--accent);
714
+ color: var(--accent2);
715
+ background: rgba(124, 106, 247, 0.08);
716
+ }
717
+
718
+ /* Meta badges */
719
+ .meta-row {
720
+ display: flex;
721
+ gap: 8px;
722
+ flex-wrap: wrap;
723
+ }
724
+
725
+ .meta-badge {
726
+ font-size: 10px;
727
+ padding: 3px 10px;
728
+ background: var(--surface3);
729
+ border: 1px solid var(--border);
730
+ border-radius: 100px;
731
+ color: var(--text-dim);
732
+ font-family: 'DM Mono', monospace;
733
+ }
734
+
735
+ /* ── History Panel ── */
736
+ .history-toolbar {
737
+ display: flex;
738
+ align-items: center;
739
+ justify-content: space-between;
740
+ margin-bottom: 20px;
741
+ }
742
+
743
+ .hist-btn {
744
+ font-family: 'DM Mono', monospace;
745
+ font-size: 12px;
746
+ padding: 8px 16px;
747
+ border-radius: var(--radius-sm);
748
+ border: 1px solid var(--border);
749
+ background: var(--surface2);
750
+ color: var(--text-dim);
751
+ cursor: pointer;
752
+ transition: all 0.15s;
753
+ display: flex;
754
+ align-items: center;
755
+ gap: 6px;
756
+ }
757
+
758
+ .hist-btn:hover {
759
+ border-color: var(--accent);
760
+ color: var(--accent2);
761
+ }
762
+
763
+ .hist-btn.danger:hover {
764
+ border-color: var(--red);
765
+ color: var(--red);
766
+ }
767
+
768
+ .hist-list {
769
+ display: flex;
770
+ flex-direction: column;
771
+ gap: 6px;
772
+ }
773
+
774
+ .hist-item {
775
+ display: flex;
776
+ align-items: center;
777
+ gap: 14px;
778
+ padding: 14px 16px;
779
+ background: var(--surface);
780
+ border: 1px solid var(--border);
781
+ border-radius: var(--radius-sm);
782
+ cursor: pointer;
783
+ transition: all 0.15s;
784
+ }
785
+
786
+ .hist-item:hover {
787
+ border-color: var(--border2);
788
+ background: var(--surface2);
789
+ }
790
+
791
+ .hist-item.selected {
792
+ border-color: var(--accent);
793
+ background: rgba(124, 106, 247, 0.06);
794
+ }
795
+
796
+ .hist-icon {
797
+ width: 36px;
798
+ height: 36px;
799
+ background: var(--surface2);
800
+ border: 1px solid var(--border);
801
+ border-radius: 8px;
802
+ display: grid;
803
+ place-items: center;
804
+ font-size: 16px;
805
+ flex-shrink: 0;
806
+ }
807
+
808
+ .hist-info {
809
+ flex: 1;
810
+ min-width: 0;
811
+ }
812
+
813
+ .hist-name {
814
+ font-size: 13px;
815
+ color: var(--text);
816
+ white-space: nowrap;
817
+ overflow: hidden;
818
+ text-overflow: ellipsis;
819
+ }
820
+
821
+ .hist-sub {
822
+ font-size: 11px;
823
+ color: var(--text-muted);
824
+ margin-top: 2px;
825
+ }
826
+
827
+ .hist-actions {
828
+ display: flex;
829
+ gap: 6px;
830
+ }
831
+
832
+ .hist-action-btn {
833
+ width: 30px;
834
+ height: 30px;
835
+ border-radius: 6px;
836
+ border: 1px solid var(--border);
837
+ background: var(--surface2);
838
+ color: var(--text-dim);
839
+ cursor: pointer;
840
+ display: grid;
841
+ place-items: center;
842
+ font-size: 13px;
843
+ transition: all 0.15s;
844
+ }
845
+
846
+ .hist-action-btn:hover {
847
+ border-color: var(--accent);
848
+ color: var(--accent2);
849
+ }
850
+
851
+ .hist-action-btn.del:hover {
852
+ border-color: var(--red);
853
+ color: var(--red);
854
+ }
855
+
856
+ .hist-preview {
857
+ margin-top: 16px;
858
+ padding: 16px;
859
+ background: var(--surface);
860
+ border: 1px solid var(--border);
861
+ border-radius: var(--radius);
862
+ display: none;
863
+ }
864
+
865
+ .hist-preview.visible {
866
+ display: block;
867
+ }
868
+
869
+ /* ── API Docs ── */
870
+ .api-grid {
871
+ display: grid;
872
+ grid-template-columns: 1fr 1fr;
873
+ gap: 20px;
874
+ }
875
+
876
+ .endpoint-card {
877
+ background: var(--surface);
878
+ border: 1px solid var(--border);
879
+ border-radius: var(--radius);
880
+ overflow: hidden;
881
+ }
882
+
883
+ .endpoint-header {
884
+ padding: 14px 18px;
885
+ border-bottom: 1px solid var(--border);
886
+ display: flex;
887
+ align-items: center;
888
+ gap: 10px;
889
+ }
890
+
891
+ .method-badge {
892
+ font-family: 'DM Mono', monospace;
893
+ font-size: 11px;
894
+ font-weight: 500;
895
+ padding: 3px 10px;
896
+ border-radius: 6px;
897
+ }
898
+
899
+ .method-badge.POST {
900
+ background: rgba(52, 211, 153, 0.15);
901
+ color: var(--green);
902
+ border: 1px solid rgba(52, 211, 153, 0.25);
903
+ }
904
+
905
+ .method-badge.GET {
906
+ background: rgba(96, 165, 250, 0.15);
907
+ color: #60a5fa;
908
+ border: 1px solid rgba(96, 165, 250, 0.25);
909
+ }
910
+
911
+ .method-badge.DEL {
912
+ background: rgba(248, 113, 113, 0.15);
913
+ color: var(--red);
914
+ border: 1px solid rgba(248, 113, 113, 0.25);
915
+ }
916
+
917
+ .endpoint-path {
918
+ font-family: 'DM Mono', monospace;
919
+ font-size: 13px;
920
+ color: var(--text);
921
+ }
922
+
923
+ .endpoint-body {
924
+ padding: 16px 18px;
925
+ }
926
+
927
+ pre {
928
+ background: var(--surface2);
929
+ border: 1px solid var(--border);
930
+ border-radius: var(--radius-sm);
931
+ padding: 14px;
932
+ font-family: 'DM Mono', monospace;
933
+ font-size: 11px;
934
+ color: var(--text-dim);
935
+ overflow-x: auto;
936
+ line-height: 1.6;
937
+ }
938
+
939
+ .code-key {
940
+ color: #a78bfa;
941
+ }
942
+
943
+ .code-str {
944
+ color: #34d399;
945
+ }
946
+
947
+ .code-num {
948
+ color: #fbbf24;
949
+ }
950
+
951
+ .code-comment {
952
+ color: var(--text-muted);
953
+ }
954
+
955
+ /* ── Empty State ── */
956
+ .empty-state {
957
+ text-align: center;
958
+ padding: 60px 20px;
959
+ color: var(--text-muted);
960
+ }
961
+
962
+ .empty-icon {
963
+ font-size: 40px;
964
+ margin-bottom: 12px;
965
+ opacity: 0.5;
966
+ }
967
+
968
+ .empty-title {
969
+ font-family: 'Syne', sans-serif;
970
+ font-size: 15px;
971
+ color: var(--text-dim);
972
+ margin-bottom: 6px;
973
+ }
974
+
975
+ .empty-sub {
976
+ font-size: 12px;
977
+ }
978
+
979
+ /* ── Toast ── */
980
+ .toast-container {
981
+ position: fixed;
982
+ bottom: 24px;
983
+ right: 24px;
984
+ z-index: 1000;
985
+ display: flex;
986
+ flex-direction: column;
987
+ gap: 8px;
988
+ }
989
+
990
+ .toast {
991
+ padding: 12px 18px;
992
+ background: var(--surface2);
993
+ border: 1px solid var(--border);
994
+ border-radius: var(--radius-sm);
995
+ font-size: 13px;
996
+ color: var(--text);
997
+ backdrop-filter: blur(8px);
998
+ animation: slideIn 0.3s ease;
999
+ display: flex;
1000
+ align-items: center;
1001
+ gap: 8px;
1002
+ max-width: 320px;
1003
+ }
1004
+
1005
+ .toast.success {
1006
+ border-left: 3px solid var(--green);
1007
+ }
1008
+
1009
+ .toast.error {
1010
+ border-left: 3px solid var(--red);
1011
+ }
1012
+
1013
+ @keyframes slideIn {
1014
+ from {
1015
+ transform: translateX(20px);
1016
+ opacity: 0;
1017
+ }
1018
+
1019
+ to {
1020
+ transform: translateX(0);
1021
+ opacity: 1;
1022
+ }
1023
+ }
1024
+
1025
+ @keyframes slideOut {
1026
+ from {
1027
+ opacity: 1;
1028
+ }
1029
+
1030
+ to {
1031
+ opacity: 0;
1032
+ transform: translateX(20px);
1033
+ }
1034
+ }
1035
+
1036
+ /* ── Footer ── */
1037
+ footer {
1038
+ position: relative;
1039
+ z-index: 10;
1040
+ padding: 16px 40px;
1041
+ border-top: 1px solid var(--border);
1042
+ display: flex;
1043
+ align-items: center;
1044
+ justify-content: space-between;
1045
+ font-size: 11px;
1046
+ color: var(--text-muted);
1047
+ }
1048
+
1049
+ footer a {
1050
+ color: var(--accent2);
1051
+ text-decoration: none;
1052
+ }
1053
+
1054
+ /* ── Responsive ── */
1055
+ @media (max-width: 900px) {
1056
+
1057
+ header,
1058
+ .tabs,
1059
+ main,
1060
+ footer {
1061
+ padding-left: 20px;
1062
+ padding-right: 20px;
1063
+ }
1064
+
1065
+ .generate-grid {
1066
+ grid-template-columns: 1fr;
1067
+ }
1068
+
1069
+ .api-grid {
1070
+ grid-template-columns: 1fr;
1071
+ }
1072
+
1073
+ .output-card {
1074
+ position: static;
1075
+ }
1076
+ }
1077
+ </style>
1078
+ </head>
1079
+
1080
+ <body>
1081
+
1082
+ <!-- ── Header ── -->
1083
+ <header>
1084
+ <div class="logo">
1085
+ <div class="logo-icon">🔊</div>
1086
+ <span>Kokoro <span>TTS</span></span>
1087
+ </div>
1088
+ <div class="status-badge">
1089
+ <div class="status-dot loading" id="statusDot"></div>
1090
+ <span id="statusText">Loading model…</span>
1091
+ </div>
1092
+ </header>
1093
+
1094
+ <!-- ── Tabs ── -->
1095
+ <div class="tabs">
1096
+ <button class="tab-btn active" onclick="switchTab('generate', this)">
1097
+ <span>⚡</span> Generate
1098
+ </button>
1099
+ <button class="tab-btn" onclick="switchTab('api', this)">
1100
+ <span>📡</span> API Docs
1101
+ </button>
1102
+ </div>
1103
+
1104
+ <!-- ── Main ── -->
1105
+ <main>
1106
+
1107
+ <!-- Generate Tab -->
1108
+ <div class="tab-panel active" id="tab-generate">
1109
+ <div class="generate-grid">
1110
+
1111
+ <!-- Left: Input -->
1112
+ <div>
1113
+ <div class="card">
1114
+ <div class="card-header">
1115
+ <span class="card-title">Input Text</span>
1116
+ <span class="char-count" id="charCount">0 / 5000</span>
1117
+ </div>
1118
+ <div class="card-body">
1119
+ <textarea id="textInput" placeholder="Enter text to synthesize…"
1120
+ oninput="updateCharCount()"></textarea>
1121
+ </div>
1122
+ </div>
1123
+
1124
+ <!-- Examples -->
1125
+ <div class="examples-section">
1126
+ <div class="examples-label">Quick Examples</div>
1127
+ <div class="examples-grid" id="examplesGrid"></div>
1128
+ </div>
1129
+ </div>
1130
+
1131
+ <!-- Right: Controls + Output -->
1132
+ <div>
1133
+ <!-- Voice Selector -->
1134
+ <div class="card" style="margin-bottom:16px">
1135
+ <div class="card-header">
1136
+ <span class="card-title">Voice</span>
1137
+ <span id="selectedVoiceLabel" style="font-size:12px;color:var(--accent2)">—</span>
1138
+ </div>
1139
+ <div class="card-body" style="padding-bottom:12px">
1140
+ <input class="voice-search" type="text" placeholder="Search voices…"
1141
+ oninput="filterVoices(this.value)" />
1142
+ <div class="voice-list" id="voiceList">
1143
+ <div class="empty-state" style="padding:20px">
1144
+ <div class="empty-sub">Loading voices…</div>
1145
+ </div>
1146
+ </div>
1147
+ </div>
1148
+ </div>
1149
+
1150
+ <!-- Settings -->
1151
+ <div class="card" style="margin-bottom:16px">
1152
+ <div class="card-header">
1153
+ <span class="card-title">Settings</span>
1154
+ </div>
1155
+ <div class="card-body">
1156
+ <div class="control-row">
1157
+ <div class="control-group">
1158
+ <div style="display:flex;justify-content:space-between">
1159
+ <span class="control-label">Speed</span>
1160
+ <span class="control-value" id="speedVal">1.0×</span>
1161
+ </div>
1162
+ <input type="range" id="speedSlider" min="0.5" max="2.0" step="0.1" value="1.0"
1163
+ oninput="document.getElementById('speedVal').textContent = parseFloat(this.value).toFixed(1) + '×'" />
1164
+ </div>
1165
+ <div style="display:flex;flex-direction:column;gap:6px;margin-left:20px">
1166
+ <span class="control-label">Format</span>
1167
+ <div class="format-toggle">
1168
+ <button class="format-btn active" id="fmtWav"
1169
+ onclick="setFormat('wav')">WAV</button>
1170
+ <button class="format-btn" id="fmtMp3" onclick="setFormat('mp3')">MP3</button>
1171
+ </div>
1172
+ </div>
1173
+ </div>
1174
+ </div>
1175
+ </div>
1176
+
1177
+ <!-- Generate Button -->
1178
+ <button class="generate-btn" id="generateBtn" onclick="generateAudio()">
1179
+ <span class="spinner" id="btnSpinner"></span>
1180
+ <span class="btn-text" id="btnText"></span>
1181
+ </button>
1182
+
1183
+ <!-- Output -->
1184
+ <div class="card output-card" style="margin-top:16px">
1185
+ <div class="card-header">
1186
+ <span class="card-title">Output</span>
1187
+ <div class="meta-row" id="metaRow"></div>
1188
+ </div>
1189
+ <div class="card-body">
1190
+ <div class="waveform-placeholder" id="waveformPlaceholder">
1191
+ <div id="waveformBars" class="waveform-bars" style="display:none"></div>
1192
+ <span id="waveformHint" style="font-size:12px">Generate audio to see output</span>
1193
+ </div>
1194
+
1195
+ <div class="audio-player" id="audioPlayer">
1196
+ <audio id="audioEl" preload="auto"></audio>
1197
+ <div class="player-controls">
1198
+ <button class="play-btn" id="playBtn" onclick="togglePlay()">▶</button>
1199
+ <div class="progress-wrap">
1200
+ <div class="progress-bar-bg" id="progressBg" onclick="seekAudio(event)">
1201
+ <div class="progress-bar-fill" id="progressFill"></div>
1202
+ </div>
1203
+ <div class="time-row">
1204
+ <span id="timeEl">0:00</span>
1205
+ <span id="durationEl">0:00</span>
1206
+ </div>
1207
+ </div>
1208
+ </div>
1209
+ <div class="download-row">
1210
+ <button class="action-btn" id="downloadBtn" onclick="downloadAudio()">⬇
1211
+ Download</button>
1212
+ </div>
1213
+ </div>
1214
+ </div>
1215
+ </div>
1216
+ </div>
1217
+ </div>
1218
+ </div>
1219
+
1220
+
1221
+
1222
+ <!-- API Tab -->
1223
+ <div class="tab-panel" id="tab-api">
1224
+ <div style="display:flex;justify-content:flex-end;margin-bottom:20px;gap:8px">
1225
+ <a href="/docs" target="_blank" class="hist-btn" style="text-decoration:none">
1226
+ <span>📋</span> Swagger UI
1227
+ </a>
1228
+ </div>
1229
+ <div class="api-grid">
1230
+
1231
+ <div class="endpoint-card">
1232
+ <div class="endpoint-header">
1233
+ <span class="method-badge POST">POST</span>
1234
+ <span class="endpoint-path">/tts</span>
1235
+ </div>
1236
+ <div class="endpoint-body">
1237
+ <p style="font-size:12px;color:var(--text-dim);margin-bottom:12px">Synthesize text to audio.
1238
+ Returns raw audio bytes.</p>
1239
+ <pre><span class="code-comment">// Request body</span>
1240
+ {
1241
+ <span class="code-key">"text"</span>: <span class="code-str">"Hello world!"</span>,
1242
+ <span class="code-key">"voice"</span>: <span class="code-str">"af_heart"</span>,
1243
+ <span class="code-key">"speed"</span>: <span class="code-num">1.0</span>,
1244
+ <span class="code-key">"output_format"</span>: <span class="code-str">"wav"</span>
1245
+ }
1246
+
1247
+ <span class="code-comment">// Response: audio/wav or audio/mpeg stream</span>
1248
+ <span class="code-comment">// Headers:</span>
1249
+ <span class="code-comment">// X-Duration-Seconds: 3.45</span>
1250
+ <span class="code-comment">// Content-Disposition: attachment; filename="kokoro_af_heart.wav"</span></pre>
1251
+ </div>
1252
+ </div>
1253
+
1254
+ <div class="endpoint-card">
1255
+ <div class="endpoint-header">
1256
+ <span class="method-badge GET">GET</span>
1257
+ <span class="endpoint-path">/voices</span>
1258
+ </div>
1259
+ <div class="endpoint-body">
1260
+ <p style="font-size:12px;color:var(--text-dim);margin-bottom:12px">List all available voices.
1261
+ </p>
1262
+ <pre><span class="code-comment">// Response</span>
1263
+ {
1264
+ <span class="code-key">"voices"</span>: {
1265
+ <span class="code-key">"af_heart"</span>: {
1266
+ <span class="code-key">"label"</span>: <span class="code-str">"Heart"</span>,
1267
+ <span class="code-key">"lang"</span>: <span class="code-str">"en-US"</span>,
1268
+ <span class="code-key">"gender"</span>: <span class="code-str">"female"</span>,
1269
+ <span class="code-key">"flag"</span>: <span class="code-str">"🇺🇸"</span>
1270
+ },
1271
+ ...
1272
+ },
1273
+ <span class="code-key">"total"</span>: <span class="code-num">42</span>
1274
+ }</pre>
1275
+ </div>
1276
+ </div>
1277
+
1278
+ <div class="endpoint-card">
1279
+ <div class="endpoint-header">
1280
+ <span class="method-badge GET">GET</span>
1281
+ <span class="endpoint-path">/health</span>
1282
+ </div>
1283
+ <div class="endpoint-body">
1284
+ <p style="font-size:12px;color:var(--text-dim);margin-bottom:12px">Model and device status.</p>
1285
+ <pre><span class="code-comment">// Response</span>
1286
+ {
1287
+ <span class="code-key">"status"</span>: <span class="code-str">"ok"</span>,
1288
+ <span class="code-key">"model_loaded"</span>: <span class="code-num">true</span>,
1289
+ <span class="code-key">"device"</span>: <span class="code-str">"cuda"</span>,
1290
+ <span class="code-key">"cuda"</span>: <span class="code-num">true</span>,
1291
+ <span class="code-key">"pipelines"</span>: <span class="code-str">["a","b","e","f","h","i","j","p","z"]</span>
1292
+ }</pre>
1293
+ </div>
1294
+ </div>
1295
+
1296
+ </div>
1297
+
1298
+ <div style="margin-top:24px">
1299
+ <div class="card">
1300
+ <div class="card-header"><span class="card-title">Quick Start (Python)</span></div>
1301
+ <div class="card-body">
1302
+ <pre><span class="code-comment"># pip install requests</span>
1303
+ import requests
1304
+
1305
+ resp = requests.post(<span class="code-str">"http://localhost:7860/tts"</span>, json={
1306
+ <span class="code-str">"text"</span>: <span class="code-str">"Hello from Kokoro TTS!"</span>,
1307
+ <span class="code-str">"voice"</span>: <span class="code-str">"af_heart"</span>,
1308
+ <span class="code-str">"speed"</span>: <span class="code-num">1.0</span>,
1309
+ <span class="code-str">"output_format"</span>: <span class="code-str">"wav"</span>
1310
+ })
1311
+
1312
+ with open(<span class="code-str">"output.wav"</span>, <span class="code-str">"wb"</span>) as f:
1313
+ f.write(resp.content)
1314
+
1315
+ duration = resp.headers.get(<span class="code-str">"X-Duration-Seconds"</span>)
1316
+ print(<span class="code-str">f"Duration: {duration}s"</span>)</pre>
1317
+ </div>
1318
+ </div>
1319
+ </div>
1320
+ </div>
1321
+
1322
+ </main>
1323
+
1324
+ <!-- Footer -->
1325
+ <footer>
1326
+ <span>Kokoro TTS · Powered by <a href="https://huggingface.co/hexgrad/Kokoro-82M"
1327
+ target="_blank">hexgrad/Kokoro-82M</a></span>
1328
+ <span id="deviceInfo">—</span>
1329
+ </footer>
1330
+
1331
+ <!-- Toast Container -->
1332
+ <div class="toast-container" id="toastContainer"></div>
1333
+
1334
+ <script>
1335
+ // ── State ───────────────────────────��──────────────────────────────────────────
1336
+ let allVoices = {};
1337
+ let selectedVoice = 'af_heart';
1338
+ let selectedFormat = 'wav';
1339
+ let currentAudioUrl = null;
1340
+ let selectedHistFile = null;
1341
+
1342
+ // ── Examples ──────────────────────────────────────────────────────────────────
1343
+ const EXAMPLES = [
1344
+ { label: "🎙️ Welcome", text: "Welcome to Kokoro TTS! A powerful text-to-speech system running locally on your machine." },
1345
+ { label: "📢 Announcement", text: "Attention please! The meeting has been rescheduled to 3 PM in conference room B." },
1346
+ { label: "🤖 Tech Demo", text: "This system uses a lightweight 82 million parameter model to generate high-quality speech in multiple languages." },
1347
+ { label: "🌍 Multilingual", text: "Bonjour tout le monde! Hola amigos! नमस्ते दुनिया!" },
1348
+ { label: "📖 Narration", text: "In a distant galaxy, beyond the reach of ordinary telescopes, a civilization had mastered the art of turning thought into sound." },
1349
+ { label: "💼 Business", text: "Q3 results exceeded expectations, with revenue growing 24% year-over-year. The board has approved a special dividend." },
1350
+ { label: "🔢 Numbers", text: "The event starts at 9:30 AM on January 15th, 2025. Please contact us at extension 4200 for more information." },
1351
+ { label: "😄 Casual", text: "Hey! What's up? Just wanted to let you know that the pizza arrived and it's absolutely amazing!" },
1352
+ ];
1353
+
1354
+ function buildExamples() {
1355
+ const grid = document.getElementById('examplesGrid');
1356
+ grid.innerHTML = '';
1357
+ EXAMPLES.forEach((e, i) => {
1358
+ const btn = document.createElement('button');
1359
+ btn.className = 'example-chip';
1360
+ btn.textContent = e.label;
1361
+ btn.onclick = () => setExample(e.text);
1362
+ grid.appendChild(btn);
1363
+ });
1364
+ }
1365
+
1366
+ function setExample(text) {
1367
+ document.getElementById('textInput').value = text;
1368
+ updateCharCount();
1369
+ }
1370
+
1371
+ // ── Char Count ─────────────────────────────────────────────────────────────────
1372
+ function updateCharCount() {
1373
+ const len = document.getElementById('textInput').value.length;
1374
+ const el = document.getElementById('charCount');
1375
+ el.textContent = `${len} / 5000`;
1376
+ el.className = 'char-count' + (len > 4500 ? ' danger' : len > 4000 ? ' warn' : '');
1377
+ }
1378
+
1379
+ // ── Tab Switch ─────────────────────────────────────────────────────────────────
1380
+ function switchTab(name, btn) {
1381
+ document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
1382
+ document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
1383
+ document.getElementById(`tab-${name}`).classList.add('active');
1384
+ btn.classList.add('active');
1385
+ if (name === 'history') loadHistory();
1386
+ }
1387
+
1388
+ // ── Format Toggle ──────────────────────────────────────────────────────────────
1389
+ function setFormat(fmt) {
1390
+ selectedFormat = fmt;
1391
+ document.getElementById('fmtWav').classList.toggle('active', fmt === 'wav');
1392
+ document.getElementById('fmtMp3').classList.toggle('active', fmt === 'mp3');
1393
+ }
1394
+
1395
+ // ── Voices ─────────────────────────────────────────────────────────────────────
1396
+ async function loadVoices() {
1397
+ try {
1398
+ const res = await fetch('/voices');
1399
+ const data = await res.json();
1400
+ allVoices = data.voices;
1401
+ renderVoices(Object.entries(allVoices));
1402
+ if (allVoices[selectedVoice]) {
1403
+ updateSelectedVoiceLabel(selectedVoice);
1404
+ } else {
1405
+ selectedVoice = Object.keys(allVoices)[0];
1406
+ updateSelectedVoiceLabel(selectedVoice);
1407
+ }
1408
+ } catch (e) {
1409
+ console.error('Voice load failed:', e);
1410
+ }
1411
+ }
1412
+
1413
+ function renderVoices(entries) {
1414
+ const list = document.getElementById('voiceList');
1415
+ if (!entries.length) {
1416
+ list.innerHTML = '<div style="padding:16px;font-size:12px;color:var(--text-muted);text-align:center">No voices found</div>';
1417
+ return;
1418
+ }
1419
+ list.innerHTML = entries.map(([id, info]) => `
1420
+ <div class="voice-item ${id === selectedVoice ? 'selected' : ''}" onclick="selectVoice('${id}')" id="vi_${id}">
1421
+ <span class="voice-flag">${info.flag}</span>
1422
+ <div class="voice-info">
1423
+ <div class="voice-name">${info.label}</div>
1424
+ <div class="voice-meta">${info.lang}</div>
1425
+ </div>
1426
+ <span class="voice-gender ${info.gender}">${info.gender}</span>
1427
+ </div>
1428
+ `).join('');
1429
+ }
1430
+
1431
+ function filterVoices(q) {
1432
+ const lower = q.toLowerCase();
1433
+ const filtered = Object.entries(allVoices).filter(([id, info]) =>
1434
+ info.label.toLowerCase().includes(lower) ||
1435
+ info.lang.toLowerCase().includes(lower) ||
1436
+ id.toLowerCase().includes(lower)
1437
+ );
1438
+ renderVoices(filtered);
1439
+ }
1440
+
1441
+ function selectVoice(id) {
1442
+ selectedVoice = id;
1443
+ document.querySelectorAll('.voice-item').forEach(el => el.classList.remove('selected'));
1444
+ const el = document.getElementById(`vi_${id}`);
1445
+ if (el) el.classList.add('selected');
1446
+ updateSelectedVoiceLabel(id);
1447
+ }
1448
+
1449
+ function updateSelectedVoiceLabel(id) {
1450
+ const info = allVoices[id];
1451
+ if (info) {
1452
+ document.getElementById('selectedVoiceLabel').textContent = `${info.flag} ${info.label}`;
1453
+ }
1454
+ }
1455
+
1456
+ let currentBlob = null;
1457
+ let currentBlobUrl = null;
1458
+
1459
+ async function generateAudio() {
1460
+ const text = document.getElementById('textInput').value.trim();
1461
+ if (!text) { toast('Please enter some text', 'error'); return; }
1462
+
1463
+ const btn = document.getElementById('generateBtn');
1464
+ btn.classList.add('loading');
1465
+ btn.disabled = true;
1466
+
1467
+ try {
1468
+ const resp = await fetch('/tts', {
1469
+ method: 'POST',
1470
+ headers: { 'Content-Type': 'application/json' },
1471
+ body: JSON.stringify({
1472
+ text,
1473
+ voice: selectedVoice,
1474
+ speed: parseFloat(document.getElementById('speedSlider').value),
1475
+ output_format: selectedFormat,
1476
+ })
1477
+ });
1478
+
1479
+ if (!resp.ok) {
1480
+ const err = await resp.json();
1481
+ throw new Error(err.detail || 'Generation failed');
1482
+ }
1483
+
1484
+ const duration = resp.headers.get('X-Duration-Seconds');
1485
+ currentBlob = await resp.blob();
1486
+
1487
+ // Revoke previous blob URL to free memory
1488
+ if (currentBlobUrl) URL.revokeObjectURL(currentBlobUrl);
1489
+ currentBlobUrl = URL.createObjectURL(currentBlob);
1490
+
1491
+ showAudioOutput(currentBlobUrl, duration);
1492
+ toast(`Audio generated · ${duration}s`, 'success');
1493
+ } catch (e) {
1494
+ toast(e.message, 'error');
1495
+ } finally {
1496
+ btn.classList.remove('loading');
1497
+ btn.disabled = false;
1498
+ }
1499
+ }
1500
+
1501
+ function downloadAudio() {
1502
+ if (!currentBlobUrl) return;
1503
+ const a = document.createElement('a');
1504
+ a.href = currentBlobUrl;
1505
+ a.download = `kokoro_${selectedVoice}.${selectedFormat}`;
1506
+ a.click();
1507
+ }
1508
+
1509
+ function showAudioOutput(audioUrl, duration) {
1510
+ const audioEl = document.getElementById('audioEl');
1511
+ audioEl.src = audioUrl;
1512
+ audioEl.load();
1513
+
1514
+ // Waveform bars
1515
+ const bars = document.getElementById('waveformBars');
1516
+ const hint = document.getElementById('waveformHint');
1517
+ bars.innerHTML = '';
1518
+ for (let i = 0; i < 40; i++) {
1519
+ const bar = document.createElement('div');
1520
+ bar.className = 'wbar';
1521
+ bar.style.height = (8 + Math.random() * 40) + 'px';
1522
+ bar.style.opacity = 0.3 + Math.random() * 0.6;
1523
+ bars.appendChild(bar);
1524
+ }
1525
+ bars.style.display = 'flex';
1526
+ hint.style.display = 'none';
1527
+
1528
+ const voiceInfo = allVoices[selectedVoice] || {};
1529
+ document.getElementById('metaRow').innerHTML = `
1530
+ <span class="meta-badge">${voiceInfo.flag || ''} ${voiceInfo.label || selectedVoice}</span>
1531
+ <span class="meta-badge">${duration}s</span>
1532
+ <span class="meta-badge">${selectedFormat.toUpperCase()}</span>
1533
+ `;
1534
+
1535
+ document.getElementById('audioPlayer').classList.add('visible');
1536
+
1537
+ audioEl.ontimeupdate = () => {
1538
+ const pct = (audioEl.currentTime / (audioEl.duration || 1)) * 100;
1539
+ document.getElementById('progressFill').style.width = pct + '%';
1540
+ document.getElementById('timeEl').textContent = fmtTime(audioEl.currentTime);
1541
+ };
1542
+ audioEl.onloadedmetadata = () => {
1543
+ document.getElementById('durationEl').textContent = fmtTime(audioEl.duration);
1544
+ };
1545
+ audioEl.onended = () => {
1546
+ document.getElementById('playBtn').textContent = '▶';
1547
+ };
1548
+ }
1549
+ function togglePlay() {
1550
+ const audio = document.getElementById('audioEl');
1551
+ const btn = document.getElementById('playBtn');
1552
+ if (audio.paused) { audio.play(); btn.textContent = '⏸'; }
1553
+ else { audio.pause(); btn.textContent = '▶'; }
1554
+ }
1555
+
1556
+ function seekAudio(e) {
1557
+ const audio = document.getElementById('audioEl');
1558
+ const bg = document.getElementById('progressBg');
1559
+ const rect = bg.getBoundingClientRect();
1560
+ const pct = (e.clientX - rect.left) / rect.width;
1561
+ audio.currentTime = pct * audio.duration;
1562
+ }
1563
+
1564
+ function fmtTime(s) {
1565
+ if (!s || isNaN(s)) return '0:00';
1566
+ const m = Math.floor(s / 60), sec = Math.floor(s % 60);
1567
+ return `${m}:${sec.toString().padStart(2, '0')}`;
1568
+ }
1569
+
1570
+ function copyAudioUrl() {
1571
+ if (!currentAudioUrl) return;
1572
+ navigator.clipboard.writeText(location.origin + currentAudioUrl);
1573
+ toast('URL copied!', 'success');
1574
+ }
1575
+
1576
+ // ── History ────────────────────────────────────────────────────────────────────
1577
+ async function loadHistory() {
1578
+ try {
1579
+ const res = await fetch('/history');
1580
+ const data = await res.json();
1581
+ renderHistory(data.files);
1582
+ } catch (e) {
1583
+ console.error(e);
1584
+ }
1585
+ }
1586
+
1587
+
1588
+
1589
+
1590
+ function selectHist(filename, url) {
1591
+ selectedHistFile = filename;
1592
+ const audio = document.getElementById('histAudio');
1593
+ audio.src = url;
1594
+ document.getElementById('histPreview').classList.add('visible');
1595
+ document.getElementById('deleteBtn').style.display = 'flex';
1596
+ loadHistory();
1597
+ }
1598
+
1599
+ function histDownload(url, filename) {
1600
+ const a = document.createElement('a');
1601
+ a.href = url; a.download = filename; a.click();
1602
+ }
1603
+
1604
+ async function deleteFile(filename) {
1605
+ try {
1606
+ await fetch(`/history/${filename}`, { method: 'DELETE' });
1607
+ toast('Deleted', 'success');
1608
+ if (selectedHistFile === filename) {
1609
+ selectedHistFile = null;
1610
+ document.getElementById('histPreview').classList.remove('visible');
1611
+ document.getElementById('deleteBtn').style.display = 'none';
1612
+ }
1613
+ loadHistory();
1614
+ } catch (e) { toast('Delete failed', 'error'); }
1615
+ }
1616
+
1617
+ function deleteSelected() {
1618
+ if (selectedHistFile) deleteFile(selectedHistFile);
1619
+ }
1620
+
1621
+ // ── Health Check ──────────────────────────────────────────────────────────────
1622
+ async function checkHealth() {
1623
+ try {
1624
+ const res = await fetch('/health');
1625
+ const data = await res.json();
1626
+ const dot = document.getElementById('statusDot');
1627
+ const txt = document.getElementById('statusText');
1628
+ const dev = document.getElementById('deviceInfo');
1629
+
1630
+ if (data.model_loaded) {
1631
+ dot.className = 'status-dot ready';
1632
+ txt.textContent = 'Ready';
1633
+ } else {
1634
+ dot.className = 'status-dot loading';
1635
+ txt.textContent = 'Loading…';
1636
+ setTimeout(checkHealth, 3000);
1637
+ }
1638
+ dev.textContent = `${data.device.toUpperCase()} · ${data.pipelines.length} langs`;
1639
+ } catch (e) {
1640
+ document.getElementById('statusDot').className = 'status-dot error';
1641
+ document.getElementById('statusText').textContent = 'Offline';
1642
+ setTimeout(checkHealth, 5000);
1643
+ }
1644
+ }
1645
+
1646
+ // ── Toast ──────────────────────────────────────────────────────────────────────
1647
+ function toast(msg, type = 'success') {
1648
+ const c = document.getElementById('toastContainer');
1649
+ const el = document.createElement('div');
1650
+ el.className = `toast ${type}`;
1651
+ el.innerHTML = `<span>${type === 'success' ? '✓' : '✕'}</span> ${msg}`;
1652
+ c.appendChild(el);
1653
+ setTimeout(() => {
1654
+ el.style.animation = 'slideOut 0.3s ease forwards';
1655
+ setTimeout(() => el.remove(), 300);
1656
+ }, 3000);
1657
+ }
1658
+
1659
+ // ── Init ──────────────────────────────────────────────────────────────────────
1660
+ buildExamples();
1661
+ loadVoices();
1662
+ checkHealth();
1663
+
1664
+ // Default example text
1665
+ document.getElementById('textInput').value = "Hello! Welcome to Kokoro TTS. This is a powerful, open-source text-to-speech system running locally.";
1666
+ updateCharCount();
1667
+ </script>
1668
+ </body>
1669
+
1670
+ </html>