Files
audio-engine-hub/app/routers/openai_compatible.py
stephan fff0252d52 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.
2025-12-09 12:45:17 +01:00

138 lines
5.7 KiB
Python

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)
)