Files changed (1) hide show
  1. server.py +118 -47
server.py CHANGED
@@ -1,6 +1,7 @@
1
  """
2
  cantrell-kokoro-engine β€” StoryVoice TTS Backend
3
  Docker Space, Python 3.11, FastAPI only
 
4
  """
5
 
6
  import io
@@ -9,7 +10,7 @@ import time
9
  import numpy as np
10
  import soundfile as sf
11
  from fastapi import FastAPI, HTTPException
12
- from fastapi.responses import Response, JSONResponse
13
  from fastapi.middleware.cors import CORSMiddleware
14
  from pydantic import BaseModel
15
  from kokoro import KPipeline
@@ -33,7 +34,9 @@ def get_pipeline(lang_code):
33
  return _pipelines[lang_code]
34
 
35
  def lang_for_voice(voice_id: str) -> str:
36
- return "b" if voice_id.startswith("b") else "a"
 
 
37
 
38
  # ── Pronunciation map ─────────────────────────────────────────────────────────
39
  PRONUNCIATION = {
@@ -41,6 +44,12 @@ PRONUNCIATION = {
41
  "ybor": "eebore",
42
  "breathed": "breethd",
43
  "Breathed": "Breethd",
 
 
 
 
 
 
44
  }
45
 
46
  def apply_pronunciation(text: str) -> str:
@@ -48,48 +57,44 @@ def apply_pronunciation(text: str) -> str:
48
  text = text.replace(word, replacement)
49
  return text
50
 
51
- # ── Sentence splitter ─────────────────────────────────────────────────────────
52
- def split_sentences(text: str) -> list:
53
- sentences = re.split(r'(?<=[.!?])\s+', text.strip())
54
- return [s.strip() for s in sentences if s.strip()]
55
-
56
- # ── Audio helpers ─────────────────────────────────────────────────────────────
57
- SAMPLE_RATE = 24000
58
 
59
- def make_silence(ms: int) -> np.ndarray:
60
- return np.zeros(int(SAMPLE_RATE * ms / 1000), dtype=np.float32)
 
 
 
 
 
 
 
 
 
 
 
61
 
62
- def generate_sentence(pipeline, sentence: str, voice_id: str, speed: float) -> np.ndarray:
63
- chunks = []
64
- for _, _, audio in pipeline(sentence, voice=voice_id, speed=speed):
65
- if audio is not None and len(audio) > 0:
66
- chunks.append(audio)
67
- if not chunks:
68
- return np.array([], dtype=np.float32)
69
- return np.concatenate(chunks) if len(chunks) > 1 else chunks[0]
 
 
 
 
 
 
70
 
71
- def generate_audio(text: str, voice_id: str, speed: float = 1.0) -> bytes:
72
- text = apply_pronunciation(text)
73
- sentences = split_sentences(text)
74
- lang = lang_for_voice(voice_id)
75
- pipeline = get_pipeline(lang)
76
- segments = []
77
- for i, sentence in enumerate(sentences):
78
- if not sentence:
79
- continue
80
- audio = generate_sentence(pipeline, sentence, voice_id, speed)
81
- if len(audio) > 0:
82
- segments.append(audio)
83
- if i < len(sentences) - 1:
84
- pause_ms = 250 if sentence.endswith(('!', '?')) else 150
85
- segments.append(make_silence(pause_ms))
86
- if not segments:
87
- raise ValueError("No audio generated")
88
- combined = np.concatenate(segments)
89
- buf = io.BytesIO()
90
- sf.write(buf, combined, SAMPLE_RATE, format="mp3")
91
- buf.seek(0)
92
- return buf.read()
93
 
94
  # ── Voice registry ────────────────────────────────────────────────────────────
95
  VOICES = [
@@ -121,12 +126,20 @@ VOICES = [
121
  {"voice_id": "bm_fable", "display_name": "Fable", "gender": "male", "accent": "british"},
122
  {"voice_id": "bm_george", "display_name": "George", "gender": "male", "accent": "british"},
123
  {"voice_id": "bm_lewis", "display_name": "Lewis", "gender": "male", "accent": "british"},
 
 
 
124
  ]
125
 
126
  VOICE_MAP = {v["voice_id"]: v for v in VOICES}
127
 
 
 
128
  def resolve_voice(voice_id: str) -> str:
129
  voice_id = voice_id.replace(".mp3", "").strip()
 
 
 
130
  if voice_id in VOICE_MAP:
131
  return voice_id
132
  matched = next(
@@ -135,8 +148,65 @@ def resolve_voice(voice_id: str) -> str:
135
  )
136
  return matched or "af_heart"
137
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  # ── Routes ────────────────────────────────────────────────────────────────────
139
- @app.get("/", response_class=Response)
140
  def index():
141
  html = """<!DOCTYPE html>
142
  <html>
@@ -183,9 +253,7 @@ def index():
183
  <div class="status" id="status"></div>
184
  </div>
185
  <script>
186
- var VOICES = [];
187
  fetch('/voices').then(r=>r.json()).then(function(data){
188
- VOICES = data;
189
  var sel = document.getElementById('voice');
190
  data.forEach(function(v){
191
  var o = document.createElement('option');
@@ -232,15 +300,18 @@ function generate(){
232
  </html>"""
233
  return Response(content=html, media_type="text/html")
234
 
235
-
236
  @app.get("/health")
237
  def health():
238
- return {"status": "ok", "engine": "kokoro-82m", "voices": len(VOICES), "timestamp": int(time.time())}
239
 
240
  @app.get("/voices")
241
  def voices():
242
  return VOICES
243
 
 
 
 
 
244
  class GenerateRequest(BaseModel):
245
  text: str
246
  voice_id: str = "af_heart"
@@ -265,4 +336,4 @@ def tts_preview(req: PreviewRequest):
265
  return generate(GenerateRequest(text=req.text, voice_id=req.voice_id, speed=req.speed))
266
 
267
  if __name__ == "__main__":
268
- uvicorn.run("server:app", host="0.0.0.0", port=7860)
 
1
  """
2
  cantrell-kokoro-engine β€” StoryVoice TTS Backend
3
  Docker Space, Python 3.11, FastAPI only
4
+ Supports voice blending, sentence-level silence padding, pronunciation map
5
  """
6
 
7
  import io
 
10
  import numpy as np
11
  import soundfile as sf
12
  from fastapi import FastAPI, HTTPException
13
+ from fastapi.responses import Response
14
  from fastapi.middleware.cors import CORSMiddleware
15
  from pydantic import BaseModel
16
  from kokoro import KPipeline
 
34
  return _pipelines[lang_code]
35
 
36
  def lang_for_voice(voice_id: str) -> str:
37
+ # Use first voice in a blend to determine lang
38
+ first = voice_id.split('+')[0].strip().split(':')[0].strip()
39
+ return "b" if first.startswith("b") else "a"
40
 
41
  # ── Pronunciation map ─────────────────────────────────────────────────────────
42
  PRONUNCIATION = {
 
44
  "ybor": "eebore",
45
  "breathed": "breethd",
46
  "Breathed": "Breethd",
47
+ "Sanae": "Suh-nay",
48
+ "sanae": "suh-nay",
49
+ "Nae": "Nay",
50
+ "nae": "nay",
51
+ "Keymoni": "Keymoney",
52
+ "keymoni": "keymoney",
53
  }
54
 
55
  def apply_pronunciation(text: str) -> str:
 
57
  text = text.replace(word, replacement)
58
  return text
59
 
60
+ # ── Voice blending ────────────────────────────────────────────────────────────
61
+ # Blend format: "af_aoede:0.50 + af_sky:0.30 + af_nicole:0.20"
62
+ # Single voice: "af_heart" or "af_heart:1.0"
 
 
 
 
63
 
64
+ def parse_blend(voice_str: str):
65
+ """Parse blend string into list of (voice_id, weight) tuples."""
66
+ parts = [p.strip() for p in voice_str.split('+')]
67
+ blend = []
68
+ for part in parts:
69
+ if ':' in part:
70
+ vid, w = part.rsplit(':', 1)
71
+ blend.append((vid.strip(), float(w.strip())))
72
+ else:
73
+ blend.append((part.strip(), 1.0))
74
+ # Normalize weights
75
+ total = sum(w for _, w in blend)
76
+ return [(v, w / total) for v, w in blend]
77
 
78
+ def blend_voices(blend_list):
79
+ """Create a blended voice tensor from a list of (voice_id, weight) tuples."""
80
+ import torch
81
+ blended = None
82
+ for voice_id, weight in blend_list:
83
+ # KPipeline loads voice pack internally; access via pipeline's voice method
84
+ lang = "b" if voice_id.startswith("b") else "a"
85
+ pipeline = get_pipeline(lang)
86
+ pack = pipeline.load_voice(voice_id)
87
+ if blended is None:
88
+ blended = pack * weight
89
+ else:
90
+ blended = blended + pack * weight
91
+ return blended
92
 
93
+ # ── Named presets ─────────────────────────────────────────────────────────��───
94
+ PRESETS = {
95
+ "male_narrator": "am_adam:0.60 + am_michael:0.30 + am_onyx:0.10",
96
+ "female_narrator": "af_aoede:0.50 + af_sky:0.30 + af_nicole:0.20",
97
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
 
99
  # ── Voice registry ────────────────────────────────────────────────────────────
100
  VOICES = [
 
126
  {"voice_id": "bm_fable", "display_name": "Fable", "gender": "male", "accent": "british"},
127
  {"voice_id": "bm_george", "display_name": "George", "gender": "male", "accent": "british"},
128
  {"voice_id": "bm_lewis", "display_name": "Lewis", "gender": "male", "accent": "british"},
129
+ # Named presets shown as selectable voices
130
+ {"voice_id": "male_narrator", "display_name": "Male Narrator (Blend)", "gender": "male", "accent": "american"},
131
+ {"voice_id": "female_narrator", "display_name": "Female Narrator (Blend)", "gender": "female", "accent": "american"},
132
  ]
133
 
134
  VOICE_MAP = {v["voice_id"]: v for v in VOICES}
135
 
136
+ SAMPLE_RATE = 24000
137
+
138
  def resolve_voice(voice_id: str) -> str:
139
  voice_id = voice_id.replace(".mp3", "").strip()
140
+ # Check presets first
141
+ if voice_id in PRESETS:
142
+ return PRESETS[voice_id]
143
  if voice_id in VOICE_MAP:
144
  return voice_id
145
  matched = next(
 
148
  )
149
  return matched or "af_heart"
150
 
151
+ def make_silence(ms: int) -> np.ndarray:
152
+ return np.zeros(int(SAMPLE_RATE * ms / 1000), dtype=np.float32)
153
+
154
+ def split_sentences(text: str) -> list:
155
+ sentences = re.split(r'(?<=[.!?])\s+', text.strip())
156
+ return [s.strip() for s in sentences if s.strip()]
157
+
158
+ def generate_sentence(pipeline, sentence: str, voice, speed: float) -> np.ndarray:
159
+ chunks = []
160
+ for _, _, audio in pipeline(sentence, voice=voice, speed=speed):
161
+ if audio is not None and len(audio) > 0:
162
+ chunks.append(audio)
163
+ if not chunks:
164
+ return np.array([], dtype=np.float32)
165
+ return np.concatenate(chunks) if len(chunks) > 1 else chunks[0]
166
+
167
+ def generate_audio(text: str, voice_id: str, speed: float = 1.0) -> bytes:
168
+ text = apply_pronunciation(text)
169
+ sentences = split_sentences(text)
170
+
171
+ # Resolve preset to blend string
172
+ voice_str = PRESETS.get(voice_id, voice_id)
173
+ lang = lang_for_voice(voice_str)
174
+ pipeline = get_pipeline(lang)
175
+
176
+ # Determine if blending needed
177
+ is_blend = '+' in voice_str or ':' in voice_str
178
+ if is_blend:
179
+ blend_list = parse_blend(voice_str)
180
+ try:
181
+ voice = blend_voices(blend_list)
182
+ except Exception:
183
+ # Fallback to first voice if blending fails
184
+ voice = blend_list[0][0]
185
+ else:
186
+ voice = voice_str.split(':')[0].strip()
187
+
188
+ segments = []
189
+ for i, sentence in enumerate(sentences):
190
+ if not sentence:
191
+ continue
192
+ audio = generate_sentence(pipeline, sentence, voice, speed)
193
+ if len(audio) > 0:
194
+ segments.append(audio)
195
+ if i < len(sentences) - 1:
196
+ pause_ms = 250 if sentence.endswith(('!', '?')) else 150
197
+ segments.append(make_silence(pause_ms))
198
+
199
+ if not segments:
200
+ raise ValueError("No audio generated")
201
+
202
+ combined = np.concatenate(segments)
203
+ buf = io.BytesIO()
204
+ sf.write(buf, combined, SAMPLE_RATE, format="mp3")
205
+ buf.seek(0)
206
+ return buf.read()
207
+
208
  # ── Routes ────────────────────────────────────────────────────────────────────
209
+ @app.get("/")
210
  def index():
211
  html = """<!DOCTYPE html>
212
  <html>
 
253
  <div class="status" id="status"></div>
254
  </div>
255
  <script>
 
256
  fetch('/voices').then(r=>r.json()).then(function(data){
 
257
  var sel = document.getElementById('voice');
258
  data.forEach(function(v){
259
  var o = document.createElement('option');
 
300
  </html>"""
301
  return Response(content=html, media_type="text/html")
302
 
 
303
  @app.get("/health")
304
  def health():
305
+ return {"status": "ok", "engine": "kokoro-82m", "voices": len(VOICES), "presets": list(PRESETS.keys()), "timestamp": int(time.time())}
306
 
307
  @app.get("/voices")
308
  def voices():
309
  return VOICES
310
 
311
+ @app.get("/presets")
312
+ def presets():
313
+ return PRESETS
314
+
315
  class GenerateRequest(BaseModel):
316
  text: str
317
  voice_id: str = "af_heart"
 
336
  return generate(GenerateRequest(text=req.text, voice_id=req.voice_id, speed=req.speed))
337
 
338
  if __name__ == "__main__":
339
+ uvicorn.run(app, host="0.0.0.0", port=7860)