Spaces:
Sleeping
Sleeping
Update server.py
#1
by mssonya72 - opened
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
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
# ββ
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
return [s.strip() for s in sentences if s.strip()]
|
| 55 |
-
|
| 56 |
-
# ββ Audio helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 57 |
-
SAMPLE_RATE = 24000
|
| 58 |
|
| 59 |
-
def
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
-
def
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 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("/"
|
| 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(
|
|
|
|
| 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)
|