""" cantrell-kokoro-engine — StoryVoice TTS Backend Docker Space, Python 3.11, FastAPI only Supports voice blending, sentence-level silence padding, pronunciation map """ import io import re import time import numpy as np import soundfile as sf from fastapi import FastAPI, HTTPException from fastapi.responses import Response from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel from kokoro import KPipeline import uvicorn app = FastAPI() app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], ) # ── Pipeline cache ──────────────────────────────────────────────────────────── _pipelines = {} def get_pipeline(lang_code): if lang_code not in _pipelines: _pipelines[lang_code] = KPipeline(lang_code=lang_code) return _pipelines[lang_code] def lang_for_voice(voice_id: str) -> str: # Use first voice in a blend to determine lang first = voice_id.split('+')[0].strip().split(':')[0].strip() return "b" if first.startswith("b") else "a" # ── Pronunciation map ───────────────────────────────────────────────────────── PRONUNCIATION = { "Ybor": "Eebore", "ybor": "eebore", "breathed": "breethd", "Breathed": "Breethd", "Sanae": "Suh-nay", "sanae": "suh-nay", "Nae": "Nay", "nae": "nay", "Keymoni": "Keymoney", "keymoni": "keymoney", } def apply_pronunciation(text: str) -> str: for word, replacement in PRONUNCIATION.items(): text = text.replace(word, replacement) return text # ── Voice blending ──────────────────────────────────────────────────────────── # Blend format: "af_aoede:0.50 + af_sky:0.30 + af_nicole:0.20" # Single voice: "af_heart" or "af_heart:1.0" def parse_blend(voice_str: str): """Parse blend string into list of (voice_id, weight) tuples.""" parts = [p.strip() for p in voice_str.split('+')] blend = [] for part in parts: if ':' in part: vid, w = part.rsplit(':', 1) blend.append((vid.strip(), float(w.strip()))) else: blend.append((part.strip(), 1.0)) # Normalize weights total = sum(w for _, w in blend) return [(v, w / total) for v, w in blend] def blend_voices(blend_list): """Create a blended voice tensor from a list of (voice_id, weight) tuples.""" import torch blended = None for voice_id, weight in blend_list: # KPipeline loads voice pack internally; access via pipeline's voice method lang = "b" if voice_id.startswith("b") else "a" pipeline = get_pipeline(lang) pack = pipeline.load_voice(voice_id) if blended is None: blended = pack * weight else: blended = blended + pack * weight return blended # ── Named presets ───────────────────────────────────────────────────────────── PRESETS = { "male_narrator": "am_adam:0.60 + am_michael:0.30 + am_onyx:0.10", "female_narrator": "af_aoede:0.50 + af_sky:0.30 + af_nicole:0.20", } # ── Voice registry ──────────────────────────────────────────────────────────── VOICES = [ {"voice_id": "af_alloy", "display_name": "Alloy", "gender": "female", "accent": "american"}, {"voice_id": "af_aoede", "display_name": "Aoede", "gender": "female", "accent": "american"}, {"voice_id": "af_bella", "display_name": "Bella", "gender": "female", "accent": "american"}, {"voice_id": "af_heart", "display_name": "Heart", "gender": "female", "accent": "american"}, {"voice_id": "af_jessica", "display_name": "Jessica", "gender": "female", "accent": "american"}, {"voice_id": "af_kore", "display_name": "Kore", "gender": "female", "accent": "american"}, {"voice_id": "af_nicole", "display_name": "Nicole", "gender": "female", "accent": "american"}, {"voice_id": "af_nova", "display_name": "Nova", "gender": "female", "accent": "american"}, {"voice_id": "af_river", "display_name": "River", "gender": "female", "accent": "american"}, {"voice_id": "af_sarah", "display_name": "Sarah", "gender": "female", "accent": "american"}, {"voice_id": "af_sky", "display_name": "Sky", "gender": "female", "accent": "american"}, {"voice_id": "am_adam", "display_name": "Adam", "gender": "male", "accent": "american"}, {"voice_id": "am_echo", "display_name": "Echo", "gender": "male", "accent": "american"}, {"voice_id": "am_eric", "display_name": "Eric", "gender": "male", "accent": "american"}, {"voice_id": "am_fenrir", "display_name": "Fenrir", "gender": "male", "accent": "american"}, {"voice_id": "am_liam", "display_name": "Liam", "gender": "male", "accent": "american"}, {"voice_id": "am_michael", "display_name": "Michael", "gender": "male", "accent": "american"}, {"voice_id": "am_onyx", "display_name": "Onyx", "gender": "male", "accent": "american"}, {"voice_id": "am_puck", "display_name": "Puck", "gender": "male", "accent": "american"}, {"voice_id": "am_santa", "display_name": "Santa", "gender": "male", "accent": "american"}, {"voice_id": "bf_alice", "display_name": "Alice", "gender": "female", "accent": "british"}, {"voice_id": "bf_emma", "display_name": "Emma", "gender": "female", "accent": "british"}, {"voice_id": "bf_isabella", "display_name": "Isabella", "gender": "female", "accent": "british"}, {"voice_id": "bf_lily", "display_name": "Lily", "gender": "female", "accent": "british"}, {"voice_id": "bm_daniel", "display_name": "Daniel", "gender": "male", "accent": "british"}, {"voice_id": "bm_fable", "display_name": "Fable", "gender": "male", "accent": "british"}, {"voice_id": "bm_george", "display_name": "George", "gender": "male", "accent": "british"}, {"voice_id": "bm_lewis", "display_name": "Lewis", "gender": "male", "accent": "british"}, # Named presets shown as selectable voices {"voice_id": "male_narrator", "display_name": "Male Narrator (Blend)", "gender": "male", "accent": "american"}, {"voice_id": "female_narrator", "display_name": "Female Narrator (Blend)", "gender": "female", "accent": "american"}, ] VOICE_MAP = {v["voice_id"]: v for v in VOICES} SAMPLE_RATE = 24000 def resolve_voice(voice_id: str) -> str: voice_id = voice_id.replace(".mp3", "").strip() # Check presets first if voice_id in PRESETS: return PRESETS[voice_id] if voice_id in VOICE_MAP: return voice_id matched = next( (v["voice_id"] for v in VOICES if v["display_name"].lower() == voice_id.lower()), None ) return matched or "af_heart" def make_silence(ms: int) -> np.ndarray: return np.zeros(int(SAMPLE_RATE * ms / 1000), dtype=np.float32) def split_sentences(text: str) -> list: sentences = re.split(r'(?<=[.!?])\s+', text.strip()) return [s.strip() for s in sentences if s.strip()] def generate_sentence(pipeline, sentence: str, voice, speed: float) -> np.ndarray: chunks = [] for _, _, audio in pipeline(sentence, voice=voice, speed=speed): if audio is not None and len(audio) > 0: chunks.append(audio) if not chunks: return np.array([], dtype=np.float32) return np.concatenate(chunks) if len(chunks) > 1 else chunks[0] def generate_audio(text: str, voice_id: str, speed: float = 1.0) -> bytes: text = apply_pronunciation(text) sentences = split_sentences(text) # Resolve preset to blend string voice_str = PRESETS.get(voice_id, voice_id) lang = lang_for_voice(voice_str) pipeline = get_pipeline(lang) # Determine if blending needed is_blend = '+' in voice_str or ':' in voice_str if is_blend: blend_list = parse_blend(voice_str) try: voice = blend_voices(blend_list) except Exception: # Fallback to first voice if blending fails voice = blend_list[0][0] else: voice = voice_str.split(':')[0].strip() segments = [] for i, sentence in enumerate(sentences): if not sentence: continue audio = generate_sentence(pipeline, sentence, voice, speed) if len(audio) > 0: segments.append(audio) if i < len(sentences) - 1: pause_ms = 250 if sentence.endswith(('!', '?')) else 150 segments.append(make_silence(pause_ms)) if not segments: raise ValueError("No audio generated") combined = np.concatenate(segments) buf = io.BytesIO() sf.write(buf, combined, SAMPLE_RATE, format="mp3") buf.seek(0) return buf.read() # ── Routes ──────────────────────────────────────────────────────────────────── @app.get("/") def index(): html = """ StoryVoice™ TTS Engine

StoryVoice™ TTS Engine

Cantrell Creatives — Preview
""" return Response(content=html, media_type="text/html") @app.get("/health") def health(): return {"status": "ok", "engine": "kokoro-82m", "voices": len(VOICES), "presets": list(PRESETS.keys()), "timestamp": int(time.time())} @app.get("/voices") def voices(): return VOICES @app.get("/presets") def presets(): return PRESETS class GenerateRequest(BaseModel): text: str voice_id: str = "af_heart" speed: float = 1.0 @app.post("/generate") def generate(req: GenerateRequest): if not req.text.strip(): raise HTTPException(status_code=400, detail="text is required") vid = resolve_voice(req.voice_id) spd = max(0.5, min(2.0, req.speed)) mp3 = generate_audio(req.text.strip(), vid, spd) return Response(content=mp3, media_type="audio/mpeg") class PreviewRequest(BaseModel): text: str voice_id: str = "af_heart" speed: float = 0.9 @app.post("/tts-preview") def tts_preview(req: PreviewRequest): return generate(GenerateRequest(text=req.text, voice_id=req.voice_id, speed=req.speed)) if __name__ == "__main__": uvicorn.run(app, host="0.0.0.0", port=7860)