github-actions[bot]
Deploy from GitHub Actions Commit: 906c86dafcf143b9a7fd117b165c0ca00f2f4e65
4432e46
Raw
History Blame Contribute Delete
6.51 kB
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)