Spaces:
Sleeping
Sleeping
github-actions[bot]
Deploy from GitHub Actions Commit: 906c86dafcf143b9a7fd117b165c0ca00f2f4e65
4432e46 | 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.") | |
| 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 | |
| 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 | |
| } | |
| 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) | |