- 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.
138 lines
5.7 KiB
Python
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)
|
|
)
|