Add voice-based model auto-detection and voice discovery endpoint

Features:
- Optional model parameter in /v1/audio/speech - auto-detects from voice name
- Voice-to-model cache loaded at startup for fast O(1) lookups
- First-match strategy for duplicate voice names across models
- New /v1/voices endpoint with extended voice info (engine, sample_rate, voice count)
- /v1/models kept OpenAI-compatible (minimal fields)

Implementation:
- speech.py:274: Made model parameter Optional[str] = None
- speech.py:253-260: Added detect_model_from_voice() using cached mapping
- speech.py:42: Added voice_to_model_cache global dict
- speech.py:723-732: Cache initialization at startup (227 voices)
- speech.py:336-383: New /v1/voices endpoint with voice lists and metadata
- speech.py:401-408: Auto-detection logic when model is None

Tested:
- bm_george auto-detected to tts-1-kokoro (unique voice)
- alloy auto-detected to tts-1 (first match of duplicate)
- /v1/models returns OpenAI-compatible minimal format
- /v1/voices returns extended info for all 4 models

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Russell Ballestrini 2025-11-09 14:45:06 -05:00
parent 9b5caadb8f
commit d8b9a06b45

View file

@ -16,6 +16,7 @@ from fastapi.responses import StreamingResponse
from loguru import logger
from openedai import OpenAIStub, BadRequestError, ServiceUnavailableError
from pydantic import BaseModel
from typing import Optional
import uvicorn
@contextlib.asynccontextmanager
@ -38,6 +39,9 @@ kokoro_pipeline = None
kokoro_lang = None
args = None
# Voice-to-model lookup cache (loaded at startup)
voice_to_model_cache = {}
def unload_model():
import torch, gc
global xtts
@ -246,6 +250,15 @@ def preprocess(raw_input):
#logger.debug(f"preprocess: after: {[raw_input]}")
return raw_input
# Auto-detect which model a voice belongs to (uses cached mapping)
def detect_model_from_voice(voice: str) -> str:
"""Find which model supports a given voice name.
Returns the first model that has this voice, or None if not found.
Uses voice_to_model_cache populated at startup for fast lookups.
"""
global voice_to_model_cache
return voice_to_model_cache.get(voice, None)
# Read voice map on demand so it can be changed without restarting the server
def map_voice_to_speaker(voice: str, model: str):
default_exists('config/voice_to_speaker.yaml')
@ -258,7 +271,7 @@ def map_voice_to_speaker(voice: str, model: str):
raise BadRequestError(f"Error loading voice: {voice}, KeyError: {e}", param='voice')
class GenerateSpeechRequest(BaseModel):
model: str = "tts-1" # or "tts-1-hd"
model: Optional[str] = None # Auto-detected from voice if not provided
input: str
voice: str = "alloy" # alloy, echo, fable, onyx, nova, and shimmer
response_format: str = "mp3" # mp3, opus, aac, flac
@ -288,7 +301,41 @@ def build_ffmpeg_args(response_format, input_format, sample_rate):
@app.get("/v1/models")
async def list_models():
"""List all available TTS models and their supported voices"""
"""List all available TTS models (OpenAI-compatible format)"""
# Return minimal OpenAI-compatible model list (no extra fields)
return {
"object": "list",
"data": [
{
"id": "tts-1",
"object": "model",
"created": 1700000000,
"owned_by": "uncloseai"
},
{
"id": "tts-1-hd",
"object": "model",
"created": 1700000000,
"owned_by": "uncloseai"
},
{
"id": "tts-1-silero",
"object": "model",
"created": 1700000000,
"owned_by": "uncloseai"
},
{
"id": "tts-1-kokoro",
"object": "model",
"created": 1700000000,
"owned_by": "uncloseai"
}
]
}
@app.get("/v1/voices")
async def list_voices():
"""List all available voices with model mapping and metadata (extended endpoint)"""
default_exists('config/voice_to_speaker.yaml')
with open('config/voice_to_speaker.yaml', 'r', encoding='utf8') as file:
@ -300,11 +347,11 @@ async def list_models():
if isinstance(voices, dict):
voice_list = list(voices.keys())
# Add model metadata
# Add model metadata with extended info
model_info = {
"id": model_id,
"object": "model",
"created": 1700000000, # Static timestamp
"created": 1700000000,
"owned_by": "uncloseai",
"voices": voice_list,
"voice_count": len(voice_list)
@ -351,6 +398,15 @@ async def generate_speech(request: GenerateSpeechRequest):
response_format = request.response_format.lower()
speed = request.speed
# Auto-detect model from voice if model not provided
if model is None:
detected_model = detect_model_from_voice(voice)
if detected_model:
logger.info(f"Auto-detected model '{detected_model}' for voice '{voice}'")
model = detected_model
else:
raise BadRequestError(f"Voice '{voice}' not found in any model. Please specify a model.", param='voice')
# Set the Content-Type header based on the requested format
if response_format == "mp3":
media_type = "audio/mpeg"
@ -665,6 +721,17 @@ if __name__ == "__main__":
default_exists('config/pre_process_map.yaml')
default_exists('config/voice_to_speaker.yaml')
# Build voice-to-model cache for fast lookups
with open('config/voice_to_speaker.yaml', 'r', encoding='utf8') as file:
voice_map = yaml.safe_load(file)
for model_id, voices in voice_map.items():
if isinstance(voices, dict):
for voice_name in voices.keys():
# First match wins (for duplicate voice names across models)
if voice_name not in voice_to_model_cache:
voice_to_model_cache[voice_name] = model_id
print(f"Voice-to-model cache initialized with {len(voice_to_model_cache)} voices")
logger.remove()
logger.add(sink=sys.stderr, level=args.log_level)