feat: Add OpenAI-compatible TTS endpoint and engines

- Implements POST /v1/audio/speech endpoint (OpenAI API compatible).
- Integrates Kokoro and XTTS engines (including dependencies and implementations).
- Updates main application to register new engines and router.
- Adds unit tests for OpenAI compatibility.
- Updates requirements.txt for new engines.
This commit is contained in:
2025-12-09 12:45:17 +01:00
parent c06fd677dc
commit fff0252d52
9 changed files with 985 additions and 3 deletions

0
app/routers/__init__.py Normal file
View File

View File

@ -0,0 +1,137 @@
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)
)