diegosouzacdv commited on
Commit
dd46e93
·
verified ·
1 Parent(s): 6246e07

Streaming /tts/stream route + ONNX engine (misaki G2P), torch fallback

Browse files
Files changed (3) hide show
  1. README.md +11 -11
  2. app.py +250 -69
  3. requirements.txt +10 -6
README.md CHANGED
@@ -1,11 +1,11 @@
1
- ---
2
- title: Kokoro TTS
3
- emoji: 🗣️
4
- colorFrom: indigo
5
- colorTo: purple
6
- sdk: gradio
7
- sdk_version: 6.6.0
8
- app_file: app.py
9
- pinned: false
10
- python_version: "3.10"
11
- ---
 
1
+ ---
2
+ title: Kokoro TTS
3
+ emoji: 🗣️
4
+ colorFrom: indigo
5
+ colorTo: purple
6
+ sdk: gradio
7
+ sdk_version: 6.6.0
8
+ app_file: app.py
9
+ pinned: false
10
+ python_version: "3.10"
11
+ ---
app.py CHANGED
@@ -1,69 +1,250 @@
1
- import gradio as gr
2
- from kokoro import KPipeline
3
- import numpy as np
4
-
5
- pipelines = {
6
- "a": KPipeline(lang_code="a"),
7
- "b": KPipeline(lang_code="b"),
8
- "p": KPipeline(lang_code="p"),
9
- }
10
-
11
- def get_pipeline_for_voice(voice_id: str) -> KPipeline:
12
- if voice_id.startswith(("af_", "am_")):
13
- return pipelines["a"]
14
- elif voice_id.startswith(("bf_", "bm_")):
15
- return pipelines["b"]
16
- elif voice_id.startswith(("pf_", "pm_")):
17
- return pipelines["p"]
18
- return pipelines["a"]
19
-
20
- def generate_tts(text: str, voice_id: str, speed: float):
21
- try:
22
- text = (text or "").strip()
23
- if not text:
24
- return None
25
- MAX_CHARS = 600
26
- if len(text) > MAX_CHARS:
27
- text = text[:MAX_CHARS]
28
- split_pattern = None if len(text) < 80 else r"(?<=[.!?…])\s+"
29
- pipeline = get_pipeline_for_voice(voice_id)
30
- generator = pipeline(text, voice=voice_id, speed=speed, split_pattern=split_pattern)
31
- segments = []
32
- for _gs, _ps, audio in generator:
33
- seg = np.array(audio, dtype=np.float32)
34
- if seg.ndim > 1:
35
- seg = seg.mean(axis=-1)
36
- if seg.size > 0:
37
- segments.append(seg)
38
- if not segments:
39
- return None
40
- return (24000, np.concatenate(segments).astype(np.float32))
41
- except Exception as e:
42
- print(f"[TTS] Erro: {e}")
43
- return None
44
-
45
- VOICES = [
46
- "af_heart", "af_alloy", "af_aoede", "af_bella", "af_jessica",
47
- "af_kore", "af_nicole", "af_nova", "af_river", "af_sarah", "af_sky",
48
- "am_adam", "am_echo", "am_eric", "am_fenrir", "am_liam",
49
- "am_michael", "am_onyx", "am_orion", "am_puck",
50
- "bf_alice", "bf_emma", "bf_isabella",
51
- "bm_george", "bm_lewis",
52
- "pf_dora", "pm_alex", "pm_santa",
53
- ]
54
-
55
- demo = gr.Interface(
56
- fn=generate_tts,
57
- inputs=[
58
- gr.Textbox(label="Texto", lines=3, placeholder="Digite o texto aqui..."),
59
- gr.Dropdown(choices=VOICES, value="af_heart", label="Voz"),
60
- gr.Slider(minimum=0.5, maximum=2.0, value=1.0, step=0.1, label="Velocidade"),
61
- ],
62
- outputs=gr.Audio(label="Audio Gerado"),
63
- title="Kokoro-82M TTS",
64
- description="Ingles Americano / Britanico e Portugues Brasileiro.",
65
- cache_examples=False,
66
- )
67
-
68
- if __name__ == "__main__":
69
- demo.launch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Kokoro-82M TTS Space: Gradio UI + a streaming HTTP endpoint.
2
+
3
+ - Gradio UI and its API (/generate_tts) are unchanged, so existing clients
4
+ (gradio_client.predict) keep working.
5
+ - POST /tts/stream returns raw PCM16 mono audio WHILE it is being generated:
6
+ the text is cut into pieces (the first sentence at its first clause, so the
7
+ first audio is short), each piece is synthesized and sent as soon as it is
8
+ ready. No Gradio queue, no WAV file to download, resampled to 16 kHz here.
9
+
10
+ Engine (KOKORO_ENGINE): "onnx" (default) runs the same Kokoro-82M v1.0
11
+ weights through ONNX Runtime (kokoro-onnx), with phonemes from Kokoro's own
12
+ G2P (misaki) so pronunciation is unchanged; "torch" is the original
13
+ PyTorch KPipeline. If ONNX can't start, it falls back to torch.
14
+
15
+ The Space runs on 2 shared vCPUs: one synthesis at a time (a lock per piece,
16
+ so concurrent requests interleave piece by piece instead of waiting for a
17
+ whole reply).
18
+ """
19
+ import os
20
+ import re
21
+ import threading
22
+ import time
23
+ import urllib.request
24
+
25
+ import gradio as gr
26
+ import numpy as np
27
+ import torch
28
+ import uvicorn
29
+ from fastapi import FastAPI, HTTPException, Request
30
+ from fastapi.responses import JSONResponse, StreamingResponse
31
+ from kokoro import KPipeline
32
+ from scipy.signal import resample_poly
33
+
34
+ THREADS = max(1, os.cpu_count() or 1)
35
+ torch.set_num_threads(THREADS)
36
+
37
+ # Optional shared secret (Space secret TTS_API_KEY): when set, /tts/stream
38
+ # requires the X-API-Key header. The Gradio UI stays public as before.
39
+ API_KEY = os.getenv("TTS_API_KEY", "").strip()
40
+ MAX_CHARS = 600
41
+ FIRST_PIECE_MAX = 60 # first sentence longer than this is cut at its first clause
42
+ SAMPLE_RATE = 24000 # Kokoro output
43
+
44
+ _LANGS = {"a": ("af_", "am_"), "b": ("bf_", "bm_"), "p": ("pf_", "pm_")}
45
+ _pipelines: dict = {}
46
+ _pipelines_lock = threading.Lock()
47
+ _synth_lock = threading.Lock()
48
+
49
+ # ---------------------------------------------------------------- engine
50
+
51
+ ONNX_RELEASE = "https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0"
52
+ ONNX_FILES = ("kokoro-v1.0.onnx", "voices-v1.0.bin")
53
+ MODEL_DIR = os.getenv("KOKORO_MODEL_DIR", os.path.join(os.path.dirname(os.path.abspath(__file__)), "models"))
54
+ ENGINE = os.getenv("KOKORO_ENGINE", "onnx").strip().lower()
55
+ _onnx = None # kokoro_onnx.Kokoro when the ONNX engine is active
56
+
57
+
58
+ def _start_onnx():
59
+ import onnxruntime as ort
60
+ from kokoro_onnx import Kokoro
61
+
62
+ os.makedirs(MODEL_DIR, exist_ok=True)
63
+ for name in ONNX_FILES:
64
+ path = os.path.join(MODEL_DIR, name)
65
+ if not os.path.exists(path) or os.path.getsize(path) == 0:
66
+ t0 = time.perf_counter()
67
+ urllib.request.urlretrieve(f"{ONNX_RELEASE}/{name}", path + ".part")
68
+ os.replace(path + ".part", path)
69
+ print(f"[TTS] downloaded {name} in {time.perf_counter() - t0:.1f}s")
70
+ opts = ort.SessionOptions()
71
+ opts.intra_op_num_threads = THREADS
72
+ opts.inter_op_num_threads = 1
73
+ opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
74
+ session = ort.InferenceSession(
75
+ os.path.join(MODEL_DIR, ONNX_FILES[0]), sess_options=opts, providers=["CPUExecutionProvider"]
76
+ )
77
+ return Kokoro.from_session(session, os.path.join(MODEL_DIR, ONNX_FILES[1]))
78
+
79
+
80
+ if ENGINE == "onnx":
81
+ try:
82
+ _onnx = _start_onnx()
83
+ print(f"[TTS] engine: onnx ({THREADS} threads)")
84
+ except Exception as e:
85
+ print(f"[TTS] onnx failed ({e}); falling back to torch")
86
+ ENGINE = "torch"
87
+ if ENGINE != "onnx":
88
+ print(f"[TTS] engine: torch ({THREADS} threads)")
89
+
90
+
91
+ def get_pipeline_for_voice(voice_id: str) -> KPipeline:
92
+ """American English is loaded at start-up; British / Portuguese on first
93
+ use. With ONNX the pipeline is G2P only (model=False)."""
94
+ lang = next((code for code, prefixes in _LANGS.items() if voice_id.startswith(prefixes)), "a")
95
+ with _pipelines_lock:
96
+ if lang not in _pipelines:
97
+ _pipelines[lang] = KPipeline(lang_code=lang, model=_onnx is None)
98
+ return _pipelines[lang]
99
+
100
+
101
+ get_pipeline_for_voice("af_heart")
102
+
103
+
104
+ def _synth(text: str, voice_id: str, speed: float) -> np.ndarray:
105
+ """Float32 mono audio at 24 kHz for one piece of text."""
106
+ pipeline = get_pipeline_for_voice(voice_id)
107
+ segments = []
108
+ with _synth_lock:
109
+ if _onnx is not None:
110
+ for result in pipeline(text, voice=voice_id, split_pattern=None):
111
+ if result.phonemes:
112
+ audio, _sr = _onnx.create(result.phonemes, voice=voice_id, speed=speed, is_phonemes=True)
113
+ segments.append(np.asarray(audio, dtype=np.float32))
114
+ else:
115
+ for _gs, _ps, audio in pipeline(text, voice=voice_id, speed=speed, split_pattern=None):
116
+ seg = np.asarray(audio, dtype=np.float32)
117
+ if seg.ndim > 1:
118
+ seg = seg.mean(axis=-1)
119
+ if seg.size:
120
+ segments.append(seg)
121
+ return np.concatenate(segments) if segments else np.zeros(0, dtype=np.float32)
122
+
123
+
124
+ _SENTENCE = re.compile(r"(?<=[.!?…])\s+")
125
+ _CLAUSE = re.compile(r"(?<=[,;:])\s+")
126
+
127
+
128
+ def split_for_streaming(text: str) -> list:
129
+ """Sentences, with the first one cut at its first clause when long, so
130
+ the first audio arrives after synthesizing only a few words."""
131
+ sentences = [s.strip() for s in _SENTENCE.split(text) if s.strip()]
132
+ if not sentences:
133
+ return []
134
+ first = sentences[0]
135
+ if len(first) > FIRST_PIECE_MAX:
136
+ parts = _CLAUSE.split(first, maxsplit=1)
137
+ if len(parts) == 2 and len(parts[0]) >= 12 and len(parts[1]) >= 12:
138
+ return [parts[0], parts[1]] + sentences[1:]
139
+ return sentences
140
+
141
+
142
+ def _to_pcm16(audio: np.ndarray, rate: int) -> bytes:
143
+ if rate == 16000:
144
+ audio = resample_poly(audio, 2, 3) # 24 kHz -> 16 kHz
145
+ return (np.clip(audio, -1.0, 1.0) * 32767).astype(np.int16).tobytes()
146
+
147
+
148
+ # ---------------------------------------------------------------- Gradio UI
149
+
150
+
151
+ def generate_tts(text: str, voice_id: str, speed: float):
152
+ try:
153
+ text = (text or "").strip()[:MAX_CHARS]
154
+ if not text:
155
+ return None
156
+ audio = np.concatenate([_synth(p, voice_id, speed) for p in split_for_streaming(text)])
157
+ return (SAMPLE_RATE, audio) if audio.size else None
158
+ except Exception as e:
159
+ print(f"[TTS] Erro: {e}")
160
+ return None
161
+
162
+
163
+ VOICES = [
164
+ "af_heart", "af_alloy", "af_aoede", "af_bella", "af_jessica",
165
+ "af_kore", "af_nicole", "af_nova", "af_river", "af_sarah", "af_sky",
166
+ "am_adam", "am_echo", "am_eric", "am_fenrir", "am_liam",
167
+ "am_michael", "am_onyx", "am_orion", "am_puck",
168
+ "bf_alice", "bf_emma", "bf_isabella",
169
+ "bm_george", "bm_lewis",
170
+ "pf_dora", "pm_alex", "pm_santa",
171
+ ]
172
+
173
+ demo = gr.Interface(
174
+ fn=generate_tts,
175
+ inputs=[
176
+ gr.Textbox(label="Texto", lines=3, placeholder="Digite o texto aqui..."),
177
+ gr.Dropdown(choices=VOICES, value="af_heart", label="Voz"),
178
+ gr.Slider(minimum=0.5, maximum=2.0, value=1.0, step=0.1, label="Velocidade"),
179
+ ],
180
+ outputs=gr.Audio(label="Audio Gerado"),
181
+ title="Kokoro-82M TTS",
182
+ description="Ingles Americano / Britanico e Portugues Brasileiro.",
183
+ )
184
+
185
+ # ---------------------------------------------------------------- HTTP API
186
+
187
+ app = FastAPI()
188
+
189
+
190
+ @app.get("/tts/health")
191
+ def tts_health():
192
+ return {"ok": True, "engine": ENGINE, "threads": THREADS, "voices": len(VOICES), "loaded": sorted(_pipelines)}
193
+
194
+
195
+ @app.post("/tts/stream")
196
+ async def tts_stream(request: Request):
197
+ """Body: {"text", "voice"="af_heart", "speed"=1.0, "sample_rate"=16000}.
198
+ Response: raw PCM16 mono little-endian, streamed piece by piece."""
199
+ if API_KEY and request.headers.get("x-api-key") != API_KEY:
200
+ raise HTTPException(status_code=401, detail="invalid api key")
201
+ try:
202
+ body = await request.json()
203
+ except Exception:
204
+ raise HTTPException(status_code=400, detail="json body required")
205
+ text = str(body.get("text") or "").strip()[:MAX_CHARS]
206
+ voice = str(body.get("voice") or "af_heart")
207
+ if voice not in VOICES:
208
+ raise HTTPException(status_code=400, detail="unknown voice")
209
+ try:
210
+ speed = min(2.0, max(0.5, float(body.get("speed") or 1.0)))
211
+ rate = int(body.get("sample_rate") or 16000)
212
+ except (TypeError, ValueError):
213
+ raise HTTPException(status_code=400, detail="invalid speed or sample_rate")
214
+ if rate not in (16000, 24000):
215
+ raise HTTPException(status_code=400, detail="sample_rate must be 16000 or 24000")
216
+ if not text:
217
+ return JSONResponse({"detail": "empty text"}, status_code=400)
218
+
219
+ pieces = split_for_streaming(text)
220
+
221
+ def generate():
222
+ t0 = time.perf_counter()
223
+ for i, piece in enumerate(pieces):
224
+ pcm = _to_pcm16(_synth(piece, voice, speed), rate)
225
+ if i == 0:
226
+ print(f"[TTS stream] first piece in {time.perf_counter() - t0:.2f}s ({len(piece)} chars)")
227
+ yield pcm
228
+
229
+ return StreamingResponse(
230
+ generate(), # sync generator: Starlette runs it in a worker thread
231
+ media_type="application/octet-stream",
232
+ headers={"X-Sample-Rate": str(rate), "X-Pieces": str(len(pieces)), "Cache-Control": "no-store"},
233
+ )
234
+
235
+
236
+ app = gr.mount_gradio_app(app, demo, path="/")
237
+
238
+
239
+ def _warmup():
240
+ try:
241
+ _synth("Hello there.", "af_heart", 1.0)
242
+ print("[TTS] warm-up done")
243
+ except Exception as e:
244
+ print(f"[TTS] warm-up failed: {e}")
245
+
246
+
247
+ threading.Thread(target=_warmup, daemon=True).start()
248
+
249
+ if __name__ == "__main__":
250
+ uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", "7860")))
requirements.txt CHANGED
@@ -1,6 +1,10 @@
1
- kokoro>=0.9.4
2
- soundfile>=0.12.1
3
- numpy>=1.24.0
4
- gradio>=4.0.0
5
- torch
6
- scipy
 
 
 
 
 
1
+ kokoro>=0.9.4
2
+ kokoro-onnx>=0.4.9
3
+ onnxruntime>=1.20
4
+ soundfile>=0.12.1
5
+ numpy>=1.24.0
6
+ gradio>=4.0.0
7
+ torch
8
+ scipy
9
+ fastapi
10
+ uvicorn