import os import io import wave import math import logging import threading from fastapi import FastAPI, HTTPException from fastapi.responses import StreamingResponse from pydantic import BaseModel logging.basicConfig(level=logging.INFO) logger = logging.getLogger("tts_worker") app = FastAPI(title="PhiloMind Open Source TTS Worker", version="1.0.0") class TTSRequest(BaseModel): text: str voice: str = "af_bella" # Default Kokoro female voice # Global lock for thread-safe TTS generation tts_lock = threading.Lock() # Global placeholder for the Kokoro model instance kokoro_model = None def download_kokoro_assets(): """Helper function to download model files dynamically if they aren't bundled""" import urllib.request model_dir = "/app/models" os.makedirs(model_dir, exist_ok=True) model_path = os.path.join(model_dir, "kokoro-v0_19.onnx") voices_path = os.path.join(model_dir, "voices.json") # Kokoro ONNX model and voices links (lightweight assets) if not os.path.exists(model_path): logger.info("Downloading Kokoro-82M ONNX model weights...") try: urllib.request.urlretrieve( "https://github.com/thewh1teagle/kokoro-onnx/releases/download/v0.1.0/kokoro-v0_19.onnx", model_path ) logger.info("Kokoro ONNX weights downloaded successfully.") except Exception as e: logger.error(f"Failed to download weights: {e}") if not os.path.exists(voices_path): logger.info("Downloading voice definitions...") try: urllib.request.urlretrieve( "https://github.com/thewh1teagle/kokoro-onnx/releases/download/v0.1.0/voices.json", voices_path ) logger.info("Voice definitions downloaded successfully.") except Exception as e: logger.error(f"Failed to download voices: {e}") def init_kokoro(): global kokoro_model try: from kokoro_onnx import KokoroOnnx download_kokoro_assets() model_path = "/app/models/kokoro-v0_19.onnx" voices_path = "/app/models/voices.json" if os.path.exists(model_path) and os.path.exists(voices_path): kokoro_model = KokoroOnnx(model_path, voices_path) logger.info("Kokoro ONNX model loaded successfully!") else: logger.warning("Kokoro asset files missing. Falling back to synthetic audio generator.") except Exception as e: logger.error(f"Could not initialize Kokoro TTS engine: {e}. Using fallback generator.") @app.on_event("startup") def startup_event(): # Attempt to load model during startup asynchronously init_kokoro() def generate_fallback_wav(text: str) -> io.BytesIO: """ Produces a clean synthetic carrier tone containing modulation representing speech length. Ensures backend always receives valid audio even if internet or model files fail. """ logger.info("Generating professional fallback synthetic wave...") sample_rate = 24000 # Simulate pacing (approx 150 words per minute -> 2.5 words per second) words = len(text.split()) duration = max(1.0, words / 2.5) num_samples = int(sample_rate * duration) wav_io = io.BytesIO() with wave.open(wav_io, 'wb') as wav_file: wav_file.setnchannels(1) wav_file.setsampwidth(2) # 16-bit wav_file.setframerate(sample_rate) # Write simple frequency modulated sine waves to simulate vocal formants for i in range(num_samples): t = i / sample_rate # Basic fundamental frequency at 120Hz, modulated gently f0 = 120 + 10 * math.sin(2 * math.pi * 1.5 * t) # Add second harmonic val = math.sin(2 * math.pi * f0 * t) * 0.5 + math.sin(2 * math.pi * (2 * f0) * t) * 0.25 # Apply amplitude envelope (fade in, fade out, word boundaries) envelope = math.sin(math.pi * t / duration) if words > 1: # Add rapid syllabic pulsing envelope *= (0.7 + 0.3 * math.sin(2 * math.pi * 4 * t)) sample = int(val * envelope * 32767) wav_file.writeframesraw(sample.to_bytes(2, byteorder='little', signed=True)) wav_io.seek(0) return wav_io @app.get("/health") def health_check(): return { "status": "healthy", "engine": "Kokoro-82M" if kokoro_model is not None else "Fallback Generator", "model_loaded": kokoro_model is not None } @app.post("/api/tts/synthesize") def synthesize_speech(request: TTSRequest): if not request.text.strip(): raise HTTPException(status_code=400, detail="Text cannot be empty.") if len(request.text) > 2000: raise HTTPException(status_code=400, detail="Text length exceeds limit of 2000 characters.") try: if kokoro_model is not None: # Render using Kokoro-ONNX engine logger.info(f"Synthesizing text with Kokoro ONNX: {request.text[:40]}...") # voice must be loadable with tts_lock: samples, sample_rate = kokoro_model.create(request.text, voice=request.voice, speed=1.0, phonemes=None) # Write float array samples to WAV format wav_io = io.BytesIO() with wave.open(wav_io, 'wb') as wav_file: wav_file.setnchannels(1) wav_file.setsampwidth(2) # 16-bit PCM wav_file.setframerate(sample_rate) # convert float to 16bit int import numpy as np audio_data = (samples * 32767).astype(np.int16) wav_file.writeframes(audio_data.tobytes()) wav_io.seek(0) return StreamingResponse(wav_io, media_type="audio/wav") else: # Fallback wav_io = generate_fallback_wav(request.text) return StreamingResponse(wav_io, media_type="audio/wav") except Exception as e: logger.error(f"TTS synthesis error: {e}") # Return fallback WAV rather than crashing wav_io = generate_fallback_wav(request.text) return StreamingResponse(wav_io, media_type="audio/wav") if __name__ == "__main__": import uvicorn port = int(os.getenv("PORT", 8000)) uvicorn.run("main:app", host="0.0.0.0", port=port, reload=True)