from fastapi import APIRouter, HTTPException, Request from fastapi.responses import FileResponse, Response from pydantic import BaseModel, Field from typing import Optional, Literal import os import logging from app.config import settings router = APIRouter() logger = logging.getLogger(__name__) class OpenAISpeechRequest(BaseModel): model: str = Field(..., description="The ID of the model to use (e.g., 'kokoro', 'tts-1')") input: str = Field(..., description="The text to generate audio for") voice: str = Field(..., description="The voice to use") response_format: Optional[Literal['mp3', 'opus', 'aac', 'flac', 'wav', 'pcm']] = Field('mp3', description="The format to return audio in") speed: Optional[float] = Field(1.0, description="The speed of the generated audio (0.25 to 4.0)") @router.post("/v1/audio/speech") async def openai_speech_endpoint(req: OpenAISpeechRequest, request: Request): """ OpenAI-compatible speech endpoint. Allows this API to be used as a drop-in replacement for OpenAI TTS. """ # 1. Resolve Engine and Model # Strategy: # - If 'model' matches an active engine name exactly (e.g., 'kokoro'), use it. # - If 'model' is 'tts-1' or 'tts-1-hd', use the first available/active engine (or a specific default if we had one). # - If 'model' contains a separator (e.g. 'kokoro:en-us'), split it. engine_name = req.model.lower() model_id = None # Check for engine:model format if ":" in engine_name: engine_name, model_id = engine_name.split(":", 1) elif "-" in engine_name and engine_name not in request.app.ENGINE_REGISTRY: # Try splitting by hyphen if direct match fails (e.g. kokoro-en-us -> engine: kokoro?? No, ambiguous). # Let's stick to checking availability. pass # Handle standard OpenAI model names -> Map to preferred local engine if engine_name in ["tts-1", "tts-1-hd"]: # Pick the first active engine as default, preferring 'kokoro' or 'xtts' if active active_engines = list(request.app.ENGINE_REGISTRY.keys()) if not active_engines: raise HTTPException(status_code=503, detail="No active TTS engines available.") if "kokoro" in active_engines: engine_name = "kokoro" elif "xtts" in active_engines: engine_name = "xtts" else: engine_name = active_engines[0] # Check engine availability engine = request.app.ENGINE_REGISTRY.get(engine_name) if not engine: raise HTTPException(status_code=404, detail=f"Model/Engine '{req.model}' not found. Available: {list(request.app.ENGINE_REGISTRY.keys())}") # 2. Map 'voice' to 'speaker' # Some engines are strict, others fuzzy. We pass it through. speaker_id = req.voice # 3. Map 'response_format' to 'fmt' fmt = req.response_format if fmt == "pcm": # We don't natively support raw PCM in all engines yet, usually wav is closest or we need ffmpeg raw # For now, let's treat pcm as wav or raise error. # OpenAI PCM is usually 16-bit little-endian raw. # Let's fallback to wav for now if engine doesn't support pcm explicitly. fmt = "wav" # 4. Synthesize try: # We rely on the engine's synthesize method. # Note: speed is not currently supported by our BaseEngine interface. # We are ignoring req.speed for now. output_path = await engine.synthesize( text=req.input, speaker=speaker_id, model=model_id, # Might be None, engine uses default fmt=fmt ) if not os.path.exists(output_path): raise RuntimeError("Synthesis finished but output file is missing.") except Exception as e: logger.error(f"OpenAI API Synthesis failed: {e}") raise HTTPException(status_code=500, detail=str(e)) # 5. Return binary stream # OpenAI returns the binary content with correct Content-Type. media_type_map = { "mp3": "audio/mpeg", "opus": "audio/opus", "aac": "audio/aac", "flac": "audio/flac", "wav": "audio/wav", "pcm": "audio/pcm" # Not standard MIME, but commonly used } media_type = media_type_map.get(fmt, "application/octet-stream") # We use FileResponse to stream the file efficiently # We might want to add a background task to clean up the file after sending, # but our Engine implementations often cache or handle temp files. # The current 'synthesize' implementations in this project seem to return paths to # temp files (Kokoro) or cached files (main.py logic). # Since this endpoint bypasses main.py's caching logic, we might be leaking temp files # if the engine creates unique temp files every time. # KokoroEngine: cleans up internal temps but returns a final temp file. It expects caller to handle it? # Inspecting Kokoro: "temp_files_to_cleanup.remove(output_other_path) -> return output_other_path". # So Kokoro leaves the final file for the caller. # We should delete the file after sending. FileResponse has a background task for this? # No, we need to pass a background task to Starlette's Response. from starlette.background import BackgroundTask def cleanup_file(path: str): try: if os.path.exists(path): os.remove(path) logger.debug(f"Cleaned up OpenAI API temp file: {path}") except Exception as e: logger.warning(f"Failed to cleanup temp file {path}: {e}") return FileResponse( path=output_path, media_type=media_type, background=BackgroundTask(cleanup_file, output_path) )