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:
parent
9b5caadb8f
commit
d8b9a06b45
1 changed files with 71 additions and 4 deletions
75
speech.py
75
speech.py
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue