mirror of
https://github.com/Nighthawk42/Qwen3-TTS-streaming.git
synced 2026-08-30 08:42:26 +00:00
897 lines
29 KiB
Python
897 lines
29 KiB
Python
"""
|
|
Main FastAPI application for Qwen3-TTS OpenAI-like API.
|
|
Provides streaming and standard TTS endpoints.
|
|
"""
|
|
|
|
import io
|
|
import os
|
|
import json
|
|
import torch
|
|
import logging
|
|
import soundfile as sf
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
from typing import AsyncGenerator, Optional, Dict, List
|
|
from datetime import datetime, timedelta
|
|
from collections import defaultdict
|
|
import uuid
|
|
|
|
from fastapi import FastAPI, Request, HTTPException, BackgroundTasks, UploadFile, File
|
|
from fastapi.responses import JSONResponse, StreamingResponse
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
from qwen_tts import Qwen3TTSModel
|
|
|
|
from .models import (
|
|
TTSRequest, StreamingTTSRequest, TTSResponse, ErrorResponse,
|
|
HealthResponse, ModelInfoResponse, ModelsListResponse, VoiceCloneMode,
|
|
VoiceResponse, VoicesListResponse, TextValidationRequest, TextValidationResponse,
|
|
CreateBatchRequest, BatchJobResponse, BatchResultsResponse, BatchResultItem, BatchJobStatus,
|
|
UsageResponse, QuotaResponse, AudioFormat, ConvertAudioRequest, ConvertAudioResponse,
|
|
ModelConfigResponse
|
|
)
|
|
from .utils import (
|
|
generate_request_id, get_unix_timestamp, audio_to_base64,
|
|
get_audio_duration, load_voice_clone_files, concatenate_audio_chunks,
|
|
base64_to_audio
|
|
)
|
|
|
|
# Configure logging
|
|
logging.basicConfig(level=logging.INFO)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ============== Storage Classes ==============
|
|
|
|
class VoiceStorage:
|
|
"""Storage for custom voice clones."""
|
|
def __init__(self):
|
|
self.voices: Dict[str, Dict] = {}
|
|
self.storage_dir = Path(__file__).parent.parent / "assets" / "custom_voices"
|
|
self.storage_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
def create_voice(self, name: str, language: str, audio_data: bytes, ref_text: str) -> str:
|
|
"""Create and store a custom voice."""
|
|
voice_id = f"voice_{uuid.uuid4().hex[:12]}"
|
|
voice_info = {
|
|
"id": voice_id,
|
|
"name": name,
|
|
"language": language,
|
|
"created_at": get_unix_timestamp(),
|
|
}
|
|
|
|
# Save voice files
|
|
voice_dir = self.storage_dir / voice_id
|
|
voice_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
with open(voice_dir / "audio.wav", "wb") as f:
|
|
f.write(audio_data)
|
|
|
|
with open(voice_dir / "text.txt", "w", encoding="utf-8") as f:
|
|
f.write(ref_text)
|
|
|
|
with open(voice_dir / "metadata.json", "w") as f:
|
|
json.dump(voice_info, f)
|
|
|
|
self.voices[voice_id] = voice_info
|
|
return voice_id
|
|
|
|
def get_voice(self, voice_id: str) -> Optional[Dict]:
|
|
"""Get voice information."""
|
|
return self.voices.get(voice_id)
|
|
|
|
def list_voices(self) -> List[Dict]:
|
|
"""List all custom voices."""
|
|
return list(self.voices.values())
|
|
|
|
def delete_voice(self, voice_id: str) -> bool:
|
|
"""Delete a custom voice."""
|
|
if voice_id not in self.voices:
|
|
return False
|
|
|
|
del self.voices[voice_id]
|
|
voice_dir = self.storage_dir / voice_id
|
|
if voice_dir.exists():
|
|
import shutil
|
|
shutil.rmtree(voice_dir)
|
|
return True
|
|
|
|
def get_voice_prompt(self, voice_id: str, model) -> Optional[any]:
|
|
"""Load voice clone prompt from storage."""
|
|
if voice_id not in self.voices:
|
|
return None
|
|
|
|
voice_dir = self.storage_dir / voice_id
|
|
audio_path = voice_dir / "audio.wav"
|
|
text_path = voice_dir / "text.txt"
|
|
|
|
if not audio_path.exists() or not text_path.exists():
|
|
return None
|
|
|
|
with open(text_path, "r", encoding="utf-8") as f:
|
|
ref_text = f.read().strip()
|
|
|
|
try:
|
|
return model.create_voice_clone_prompt(
|
|
ref_audio=str(audio_path),
|
|
ref_text=ref_text,
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Error creating voice prompt: {e}")
|
|
return None
|
|
|
|
|
|
class BatchJobStorage:
|
|
"""Storage for batch jobs."""
|
|
def __init__(self):
|
|
self.jobs: Dict[str, Dict] = {}
|
|
self.storage_dir = Path(__file__).parent.parent / "assets" / "batch_jobs"
|
|
self.storage_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
def create_job(self, items: list, model: str) -> str:
|
|
"""Create a new batch job."""
|
|
job_id = f"batch_{uuid.uuid4().hex[:12]}"
|
|
timestamp = get_unix_timestamp()
|
|
|
|
job_info = {
|
|
"id": job_id,
|
|
"status": BatchJobStatus.PENDING.value,
|
|
"created_at": timestamp,
|
|
"updated_at": timestamp,
|
|
"model": model,
|
|
"items": items,
|
|
"results": {},
|
|
"request_counts": {
|
|
"total": len(items),
|
|
"processing": 0,
|
|
"completed": 0,
|
|
"failed": 0,
|
|
}
|
|
}
|
|
|
|
self.jobs[job_id] = job_info
|
|
|
|
# Save to disk
|
|
job_dir = self.storage_dir / job_id
|
|
job_dir.mkdir(parents=True, exist_ok=True)
|
|
with open(job_dir / "job.json", "w") as f:
|
|
json.dump(job_info, f, default=str)
|
|
|
|
return job_id
|
|
|
|
def get_job(self, job_id: str) -> Optional[Dict]:
|
|
"""Get job information."""
|
|
return self.jobs.get(job_id)
|
|
|
|
def update_job_status(self, job_id: str, status: str) -> bool:
|
|
"""Update job status."""
|
|
if job_id not in self.jobs:
|
|
return False
|
|
|
|
self.jobs[job_id]["status"] = status
|
|
self.jobs[job_id]["updated_at"] = get_unix_timestamp()
|
|
return True
|
|
|
|
def add_result(self, job_id: str, index: int, result: Dict) -> bool:
|
|
"""Add a result to the job."""
|
|
if job_id not in self.jobs:
|
|
return False
|
|
|
|
self.jobs[job_id]["results"][str(index)] = result
|
|
return True
|
|
|
|
|
|
class UsageTracker:
|
|
"""Track API usage."""
|
|
def __init__(self):
|
|
self.requests_made = 0
|
|
self.audio_seconds_generated = 0.0
|
|
self.requests_by_model: Dict[str, int] = defaultdict(int)
|
|
self.requests_by_language: Dict[str, int] = defaultdict(int)
|
|
self.request_times: List[int] = []
|
|
|
|
def record_request(self, model: str, language: str, duration: float = 0.0):
|
|
"""Record a TTS request."""
|
|
self.requests_made += 1
|
|
self.audio_seconds_generated += duration
|
|
self.requests_by_model[model] += 1
|
|
self.requests_by_language[language] += 1
|
|
self.request_times.append(get_unix_timestamp())
|
|
|
|
# Keep only last hour of requests for rate limiting
|
|
cutoff = get_unix_timestamp() - 3600
|
|
self.request_times = [t for t in self.request_times if t > cutoff]
|
|
|
|
def get_usage(self) -> Dict:
|
|
"""Get usage statistics."""
|
|
return {
|
|
"requests_made": self.requests_made,
|
|
"audio_generated_seconds": self.audio_seconds_generated,
|
|
"audio_generated_minutes": self.audio_seconds_generated / 60.0,
|
|
"requests_by_model": dict(self.requests_by_model),
|
|
"requests_by_language": dict(self.requests_by_language),
|
|
}
|
|
|
|
def get_remaining_requests(self, limit_per_minute: int = 60) -> int:
|
|
"""Get remaining requests in current minute."""
|
|
cutoff = get_unix_timestamp() - 60
|
|
recent = len([t for t in self.request_times if t > cutoff])
|
|
return max(0, limit_per_minute - recent)
|
|
|
|
|
|
# Configure logging
|
|
logging.basicConfig(level=logging.INFO)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Global model state
|
|
class ModelState:
|
|
"""Global state for the TTS model."""
|
|
def __init__(self):
|
|
self.model: Optional[Qwen3TTSModel] = None
|
|
self.voice_clone_prompt = None
|
|
self.device = "cuda" if torch.cuda.is_available() else "cpu"
|
|
self.model_name = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
|
|
self.ready = False
|
|
|
|
# Storage instances
|
|
self.voice_storage = VoiceStorage()
|
|
self.batch_storage = BatchJobStorage()
|
|
self.usage_tracker = UsageTracker()
|
|
|
|
model_state = ModelState()
|
|
|
|
|
|
async def load_model():
|
|
"""Load the TTS model asynchronously."""
|
|
try:
|
|
logger.info(f"Loading model: {model_state.model_name} on device: {model_state.device}")
|
|
|
|
# Determine device_map and dtype based on device
|
|
if model_state.device == "cuda":
|
|
device_map = "cuda:0"
|
|
dtype = torch.bfloat16
|
|
attn_impl = "flash_attention_2"
|
|
else:
|
|
device_map = "cpu"
|
|
dtype = torch.float32
|
|
attn_impl = "eager"
|
|
|
|
model_state.model = Qwen3TTSModel.from_pretrained(
|
|
model_state.model_name,
|
|
device_map=device_map,
|
|
dtype=dtype,
|
|
attn_implementation=attn_impl,
|
|
)
|
|
|
|
# Enable streaming optimizations
|
|
model_state.model.enable_streaming_optimizations(
|
|
decode_window_frames=80,
|
|
use_compile=False, # Set to True if your system supports it
|
|
)
|
|
|
|
# Load voice cloning reference audio if available
|
|
voice_clone_dir = Path(__file__).parent.parent / "assets" / "voice_cloning"
|
|
if voice_clone_dir.exists():
|
|
ref_audio_path, ref_text = load_voice_clone_files(str(voice_clone_dir))
|
|
if ref_audio_path and ref_text:
|
|
logger.info(f"Creating voice clone prompt from: {ref_audio_path}")
|
|
try:
|
|
model_state.voice_clone_prompt = model_state.model.create_voice_clone_prompt(
|
|
ref_audio=ref_audio_path,
|
|
ref_text=ref_text,
|
|
)
|
|
logger.info("Voice clone prompt created successfully")
|
|
except Exception as e:
|
|
logger.warning(f"Failed to create voice clone prompt: {e}")
|
|
else:
|
|
logger.info("Voice cloning files (ref_audio.wav, ref_text.txt) not found in assets/voice_cloning")
|
|
|
|
model_state.ready = True
|
|
logger.info("Model loaded and ready")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to load model: {e}")
|
|
model_state.ready = False
|
|
raise
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
"""Lifespan context manager for FastAPI app."""
|
|
# Startup
|
|
await load_model()
|
|
yield
|
|
# Shutdown
|
|
logger.info("Shutting down")
|
|
|
|
|
|
# Create FastAPI app
|
|
app = FastAPI(
|
|
title="Qwen3-TTS API",
|
|
description="OpenAI-like API for Qwen3 Text-to-Speech streaming",
|
|
version="0.1.0",
|
|
lifespan=lifespan,
|
|
)
|
|
|
|
# Add CORS middleware
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"],
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
|
|
# ============== Error Handlers ==============
|
|
|
|
@app.exception_handler(HTTPException)
|
|
async def http_exception_handler(request: Request, exc: HTTPException):
|
|
"""Handle HTTP exceptions."""
|
|
return JSONResponse(
|
|
status_code=exc.status_code,
|
|
content=ErrorResponse(
|
|
error=exc.detail,
|
|
code=f"http_{exc.status_code}",
|
|
).dict(),
|
|
)
|
|
|
|
|
|
@app.exception_handler(ValueError)
|
|
async def value_error_handler(request: Request, exc: ValueError):
|
|
"""Handle value errors."""
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content=ErrorResponse(
|
|
error=str(exc),
|
|
code="validation_error",
|
|
).dict(),
|
|
)
|
|
|
|
|
|
# ============== Health Check Endpoint ==============
|
|
|
|
@app.get("/v1/health", response_model=HealthResponse)
|
|
async def health_check():
|
|
"""Check API health and model readiness."""
|
|
return HealthResponse(
|
|
status="healthy" if model_state.ready else "unavailable",
|
|
model=model_state.model_name,
|
|
device=model_state.device,
|
|
ready=model_state.ready,
|
|
)
|
|
|
|
|
|
# ============== Models Endpoint ==============
|
|
|
|
@app.get("/v1/models", response_model=ModelsListResponse)
|
|
async def list_models():
|
|
"""List available TTS models."""
|
|
models = [
|
|
ModelInfoResponse(
|
|
id="Qwen/Qwen3-TTS-12Hz-1.7B-Base",
|
|
supported_languages=["English", "Russian", "Chinese", "Japanese", "Korean"],
|
|
supports_streaming=True,
|
|
supports_voice_clone=True if model_state.voice_clone_prompt else False,
|
|
),
|
|
]
|
|
return ModelsListResponse(data=models)
|
|
|
|
|
|
# ============== Standard TTS Endpoint ==============
|
|
|
|
@app.post("/v1/audio/speech", response_model=TTSResponse)
|
|
async def text_to_speech(request: TTSRequest):
|
|
"""
|
|
Convert text to speech.
|
|
|
|
Returns audio as base64-encoded WAV in response.
|
|
"""
|
|
if not model_state.ready:
|
|
raise HTTPException(status_code=503, detail="Model not ready")
|
|
|
|
request_id = generate_request_id()
|
|
logger.info(f"[{request_id}] TTS request: {request.text[:50]}...")
|
|
|
|
try:
|
|
# Prepare voice clone prompt if mode is enabled
|
|
voice_clone_prompt = None
|
|
if request.voice_clone_mode == VoiceCloneMode.REFERENCE_AUDIO:
|
|
if model_state.voice_clone_prompt is None:
|
|
raise ValueError("Voice cloning not available: no reference audio loaded")
|
|
voice_clone_prompt = model_state.voice_clone_prompt
|
|
|
|
# Generate speech (voice_clone_prompt can be None for base model)
|
|
wavs, sample_rate = model_state.model.generate_voice_clone(
|
|
text=request.text,
|
|
language=request.language,
|
|
voice_clone_prompt=voice_clone_prompt,
|
|
)
|
|
|
|
audio = wavs[0]
|
|
duration = get_audio_duration(audio, sample_rate)
|
|
audio_base64 = audio_to_base64(audio, sample_rate)
|
|
|
|
# Track usage
|
|
model_state.usage_tracker.record_request(
|
|
model=str(request.model),
|
|
language=request.language,
|
|
duration=duration,
|
|
)
|
|
|
|
logger.info(f"[{request_id}] TTS complete: {duration:.2f}s audio generated")
|
|
|
|
return TTSResponse(
|
|
id=request_id,
|
|
created=get_unix_timestamp(),
|
|
model=request.model,
|
|
audio_base64=audio_base64,
|
|
duration=duration,
|
|
sample_rate=sample_rate,
|
|
language=request.language,
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"[{request_id}] Error during TTS: {e}")
|
|
raise HTTPException(status_code=500, detail=f"TTS generation failed: {str(e)}")
|
|
|
|
|
|
# ============== Streaming TTS Endpoint ==============
|
|
|
|
async def stream_audio_chunks(request: StreamingTTSRequest) -> AsyncGenerator[bytes, None]:
|
|
"""
|
|
Generator for streaming audio chunks as WAV frames.
|
|
Uses chunked encoding to stream audio as it's being generated.
|
|
"""
|
|
request_id = generate_request_id()
|
|
logger.info(f"[{request_id}] Streaming TTS request: {request.text[:50]}...")
|
|
|
|
try:
|
|
# Prepare voice clone prompt if mode is enabled
|
|
voice_clone_prompt = None
|
|
if request.voice_clone_mode == VoiceCloneMode.REFERENCE_AUDIO:
|
|
if model_state.voice_clone_prompt is None:
|
|
logger.warning(f"[{request_id}] Voice cloning not available, using base model")
|
|
else:
|
|
voice_clone_prompt = model_state.voice_clone_prompt
|
|
|
|
# Stream generation (voice_clone_prompt can be None for base model)
|
|
chunk_count = 0
|
|
first_chunk_time = None
|
|
import time
|
|
start_time = time.time()
|
|
|
|
stream_gen = model_state.model.stream_generate_voice_clone(
|
|
text=request.text,
|
|
language=request.language,
|
|
voice_clone_prompt=voice_clone_prompt,
|
|
emit_every_frames=request.stream_options.emit_every_frames,
|
|
decode_window_frames=request.stream_options.decode_window_frames,
|
|
overlap_samples=request.stream_options.overlap_samples,
|
|
)
|
|
|
|
for chunk, sample_rate in stream_gen:
|
|
chunk_count += 1
|
|
|
|
if first_chunk_time is None:
|
|
first_chunk_time = time.time() - start_time
|
|
logger.info(f"[{request_id}] First chunk in {first_chunk_time:.2f}s")
|
|
|
|
# Convert chunk to WAV bytes
|
|
with io.BytesIO() as wav_buffer:
|
|
sf.write(wav_buffer, chunk, sample_rate, format='WAV')
|
|
wav_bytes = wav_buffer.getvalue()
|
|
|
|
# Write chunk size as 4 bytes (big-endian)
|
|
yield len(wav_bytes).to_bytes(4, byteorder='big')
|
|
yield wav_bytes
|
|
|
|
total_time = time.time() - start_time
|
|
logger.info(f"[{request_id}] Streaming complete: {chunk_count} chunks in {total_time:.2f}s")
|
|
|
|
except Exception as e:
|
|
logger.error(f"[{request_id}] Error during streaming: {e}")
|
|
raise
|
|
|
|
|
|
@app.post("/v1/audio/speech/stream")
|
|
async def stream_text_to_speech(request: StreamingTTSRequest):
|
|
"""
|
|
Stream text-to-speech audio chunks.
|
|
|
|
Returns audio chunks with frame length prefixes for streaming consumption.
|
|
"""
|
|
if not model_state.ready:
|
|
raise HTTPException(status_code=503, detail="Model not ready")
|
|
|
|
return StreamingResponse(
|
|
stream_audio_chunks(request),
|
|
media_type="application/octet-stream",
|
|
headers={
|
|
"Content-Disposition": "attachment; filename=audio_stream.bin",
|
|
"X-Accel-Buffering": "no", # Disable proxy buffering
|
|
}
|
|
)
|
|
|
|
|
|
# ============== Voice Management Endpoints ==============
|
|
|
|
@app.post("/v1/voices/upload", response_model=VoiceResponse)
|
|
async def upload_voice(
|
|
name: str,
|
|
language: str,
|
|
audio: UploadFile = File(...),
|
|
ref_text: str = None,
|
|
):
|
|
"""
|
|
Upload and register a custom voice clone.
|
|
|
|
Args:
|
|
name: Name for the voice
|
|
language: Language of the voice
|
|
audio: WAV audio file
|
|
ref_text: Reference transcription text
|
|
"""
|
|
if not model_state.ready:
|
|
raise HTTPException(status_code=503, detail="Model not ready")
|
|
|
|
if not ref_text:
|
|
raise HTTPException(status_code=400, detail="ref_text is required")
|
|
|
|
try:
|
|
# Read audio file
|
|
audio_data = await audio.read()
|
|
|
|
# Validate audio
|
|
try:
|
|
voice_audio, sr = sf.read(io.BytesIO(audio_data), dtype="float32")
|
|
except Exception as e:
|
|
raise HTTPException(status_code=400, detail=f"Invalid audio file: {str(e)}")
|
|
|
|
# Create and store voice
|
|
voice_id = model_state.voice_storage.create_voice(
|
|
name=name,
|
|
language=language,
|
|
audio_data=audio_data,
|
|
ref_text=ref_text,
|
|
)
|
|
|
|
voice = model_state.voice_storage.get_voice(voice_id)
|
|
logger.info(f"Voice created: {voice_id} ({name})")
|
|
|
|
return VoiceResponse(
|
|
id=voice["id"],
|
|
name=voice["name"],
|
|
language=voice["language"],
|
|
created_at=voice["created_at"],
|
|
)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error uploading voice: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Failed to upload voice: {str(e)}")
|
|
|
|
|
|
@app.get("/v1/voices", response_model=VoicesListResponse)
|
|
async def list_voices():
|
|
"""List all custom voices."""
|
|
voices = model_state.voice_storage.list_voices()
|
|
return VoicesListResponse(
|
|
data=[
|
|
VoiceResponse(
|
|
id=v["id"],
|
|
name=v["name"],
|
|
language=v["language"],
|
|
created_at=v["created_at"],
|
|
)
|
|
for v in voices
|
|
]
|
|
)
|
|
|
|
|
|
@app.delete("/v1/voices/{voice_id}")
|
|
async def delete_voice(voice_id: str):
|
|
"""Delete a custom voice."""
|
|
if not model_state.voice_storage.delete_voice(voice_id):
|
|
raise HTTPException(status_code=404, detail="Voice not found")
|
|
|
|
return {"object": "voice.deleted", "id": voice_id}
|
|
|
|
|
|
# ============== Text Validation Endpoint ==============
|
|
|
|
@app.post("/v1/text/validate", response_model=TextValidationResponse)
|
|
async def validate_text(request: TextValidationRequest):
|
|
"""
|
|
Validate text for TTS synthesis.
|
|
|
|
Checks character count, language compatibility, and estimates duration.
|
|
"""
|
|
text = request.text.strip()
|
|
|
|
if not text:
|
|
raise HTTPException(status_code=400, detail="Text cannot be empty")
|
|
|
|
if len(text) > 10000:
|
|
raise HTTPException(status_code=400, detail="Text exceeds maximum length of 10000 characters")
|
|
|
|
warnings = []
|
|
|
|
# Estimate duration (rough: ~150 words per minute, avg 5 chars per word)
|
|
estimated_words = len(text) / 5
|
|
estimated_seconds = estimated_words / 2.5 # ~150 wpm
|
|
|
|
# Check for potential issues
|
|
if len(text) < 3:
|
|
warnings.append("Text is very short, may result in poor quality")
|
|
|
|
if len(text) > 5000:
|
|
warnings.append("Text is very long, generation may take several minutes")
|
|
|
|
# Check for unsupported characters (basic check)
|
|
if "😀" in text or "🎵" in text:
|
|
warnings.append("Text contains emoji which may be handled differently")
|
|
|
|
return TextValidationResponse(
|
|
valid=True,
|
|
language=request.language,
|
|
character_count=len(text),
|
|
estimated_duration=max(0.5, estimated_seconds),
|
|
warnings=warnings,
|
|
)
|
|
|
|
|
|
# ============== Batch Processing Endpoints ==============
|
|
|
|
@app.post("/v1/batch/create", response_model=BatchJobResponse)
|
|
async def create_batch_job(request: CreateBatchRequest):
|
|
"""
|
|
Create a batch job for processing multiple texts.
|
|
|
|
Batch processing is asynchronous. Use /v1/batch/{job_id} to check status.
|
|
"""
|
|
if not model_state.ready:
|
|
raise HTTPException(status_code=503, detail="Model not ready")
|
|
|
|
if len(request.items) > 100:
|
|
raise HTTPException(status_code=400, detail="Maximum batch size is 100 items")
|
|
|
|
job_id = model_state.batch_storage.create_job(
|
|
items=[item.dict() for item in request.items],
|
|
model=str(request.model),
|
|
)
|
|
|
|
job = model_state.batch_storage.get_job(job_id)
|
|
logger.info(f"Batch job created: {job_id} with {len(request.items)} items")
|
|
|
|
return BatchJobResponse(
|
|
id=job["id"],
|
|
status=BatchJobStatus(job["status"]),
|
|
created_at=job["created_at"],
|
|
updated_at=job["updated_at"],
|
|
request_counts=job["request_counts"],
|
|
)
|
|
|
|
|
|
@app.get("/v1/batch/{job_id}", response_model=BatchJobResponse)
|
|
async def get_batch_job(job_id: str):
|
|
"""Check the status of a batch job."""
|
|
job = model_state.batch_storage.get_job(job_id)
|
|
|
|
if not job:
|
|
raise HTTPException(status_code=404, detail="Batch job not found")
|
|
|
|
return BatchJobResponse(
|
|
id=job["id"],
|
|
status=BatchJobStatus(job["status"]),
|
|
created_at=job["created_at"],
|
|
updated_at=job["updated_at"],
|
|
request_counts=job["request_counts"],
|
|
output_file_id=job.get("output_file_id"),
|
|
)
|
|
|
|
|
|
@app.get("/v1/batch/{job_id}/results", response_model=BatchResultsResponse)
|
|
async def get_batch_results(job_id: str):
|
|
"""Get results from a completed batch job."""
|
|
job = model_state.batch_storage.get_job(job_id)
|
|
|
|
if not job:
|
|
raise HTTPException(status_code=404, detail="Batch job not found")
|
|
|
|
if job["status"] == BatchJobStatus.PENDING.value:
|
|
raise HTTPException(status_code=400, detail="Batch job is still pending")
|
|
|
|
results = [
|
|
BatchResultItem(
|
|
index=int(idx),
|
|
status="success" if "audio_base64" in result else "error",
|
|
error=result.get("error"),
|
|
audio_base64=result.get("audio_base64"),
|
|
duration=result.get("duration"),
|
|
)
|
|
for idx, result in job["results"].items()
|
|
]
|
|
|
|
return BatchResultsResponse(
|
|
job_id=job_id,
|
|
status=BatchJobStatus(job["status"]),
|
|
data=results,
|
|
)
|
|
|
|
|
|
# ============== Usage & Quota Endpoints ==============
|
|
|
|
@app.get("/v1/usage", response_model=UsageResponse)
|
|
async def get_usage():
|
|
"""Get API usage statistics."""
|
|
usage = model_state.usage_tracker.get_usage()
|
|
|
|
return UsageResponse(
|
|
requests_made=usage["requests_made"],
|
|
audio_generated_seconds=usage["audio_generated_seconds"],
|
|
audio_generated_minutes=usage["audio_generated_minutes"],
|
|
requests_by_model=usage["requests_by_model"],
|
|
requests_by_language=usage["requests_by_language"],
|
|
)
|
|
|
|
|
|
@app.get("/v1/quota", response_model=QuotaResponse)
|
|
async def get_quota():
|
|
"""Get current quota and rate limits."""
|
|
remaining = model_state.usage_tracker.get_remaining_requests(limit_per_minute=60)
|
|
|
|
return QuotaResponse(
|
|
requests_per_minute=60,
|
|
max_text_length=10000,
|
|
max_batch_size=100,
|
|
concurrent_requests=4,
|
|
remaining_requests=remaining,
|
|
)
|
|
|
|
|
|
# ============== Audio Conversion Endpoint ==============
|
|
|
|
@app.post("/v1/audio/convert", response_model=ConvertAudioResponse)
|
|
async def convert_audio(request: ConvertAudioRequest):
|
|
"""
|
|
Convert audio format or sample rate.
|
|
|
|
Supports: WAV, MP3, OGG, FLAC
|
|
"""
|
|
try:
|
|
from .utils import base64_to_audio
|
|
|
|
# Decode audio
|
|
audio, sr = base64_to_audio(request.audio_base64)
|
|
|
|
# Resample if needed
|
|
target_sr = request.target_sample_rate or sr
|
|
if target_sr != sr:
|
|
import librosa
|
|
audio = librosa.resample(audio, orig_sr=sr, target_sr=target_sr)
|
|
sr = target_sr
|
|
|
|
# Convert format (for now, we'll output WAV internally and encode)
|
|
# In production, you'd use pydub or ffmpeg for actual format conversion
|
|
output_base64 = audio_to_base64(audio, sr)
|
|
|
|
request_id = generate_request_id()
|
|
|
|
return ConvertAudioResponse(
|
|
id=request_id,
|
|
created=get_unix_timestamp(),
|
|
format=request.target_format.value,
|
|
sample_rate=sr,
|
|
audio_base64=output_base64,
|
|
duration=get_audio_duration(audio, sr),
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error converting audio: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Audio conversion failed: {str(e)}")
|
|
|
|
|
|
# ============== Model Configuration Endpoint ==============
|
|
|
|
@app.get("/v1/models/{model_id}/config", response_model=ModelConfigResponse)
|
|
async def get_model_config(model_id: str):
|
|
"""Get detailed configuration for a model."""
|
|
if model_id != "Qwen/Qwen3-TTS-12Hz-1.7B-Base":
|
|
raise HTTPException(status_code=404, detail="Model not found")
|
|
|
|
return ModelConfigResponse(
|
|
id=model_id,
|
|
size="1.7B",
|
|
tokenizer_type="Qwen3TTSTokenizer",
|
|
languages=["English", "Russian", "Chinese", "Japanese", "Korean"],
|
|
max_text_length=10000,
|
|
output_sample_rate=24000,
|
|
streaming_supported=True,
|
|
voice_cloning_supported=True,
|
|
recommended_parameters={
|
|
"temperature": 0.9,
|
|
"top_p": 1.0,
|
|
"top_k": 50,
|
|
"do_sample": True,
|
|
"repetition_penalty": 1.05,
|
|
"emit_every_frames": 8,
|
|
"decode_window_frames": 80,
|
|
},
|
|
)
|
|
|
|
|
|
@app.get("/v1/models/{model_id}/languages")
|
|
async def get_model_languages(model_id: str):
|
|
"""Get supported languages for a model."""
|
|
if model_id != "Qwen/Qwen3-TTS-12Hz-1.7B-Base":
|
|
raise HTTPException(status_code=404, detail="Model not found")
|
|
|
|
return {
|
|
"object": "list",
|
|
"model": model_id,
|
|
"languages": [
|
|
{"code": "en", "name": "English"},
|
|
{"code": "ru", "name": "Russian"},
|
|
{"code": "zh", "name": "Chinese"},
|
|
{"code": "ja", "name": "Japanese"},
|
|
{"code": "ko", "name": "Korean"},
|
|
{"code": "auto", "name": "Auto-detect"},
|
|
]
|
|
}
|
|
|
|
|
|
# ============== Root Endpoint ==============
|
|
|
|
@app.get("/")
|
|
async def root():
|
|
"""Root endpoint with API information."""
|
|
return {
|
|
"name": "Qwen3-TTS API",
|
|
"version": "0.2.0",
|
|
"description": "OpenAI-like API for Qwen3 Text-to-Speech streaming",
|
|
"documentation": "/docs",
|
|
"endpoints": {
|
|
"health": "/v1/health",
|
|
"models": {
|
|
"list": "/v1/models",
|
|
"config": "/v1/models/{model_id}/config",
|
|
"languages": "/v1/models/{model_id}/languages",
|
|
},
|
|
"speech": {
|
|
"generate": "/v1/audio/speech",
|
|
"stream": "/v1/audio/speech/stream",
|
|
"convert": "/v1/audio/convert",
|
|
},
|
|
"voices": {
|
|
"upload": "POST /v1/voices/upload",
|
|
"list": "/v1/voices",
|
|
"delete": "DELETE /v1/voices/{voice_id}",
|
|
},
|
|
"text": {
|
|
"validate": "POST /v1/text/validate",
|
|
},
|
|
"batch": {
|
|
"create": "POST /v1/batch/create",
|
|
"status": "/v1/batch/{job_id}",
|
|
"results": "/v1/batch/{job_id}/results",
|
|
},
|
|
"usage": {
|
|
"statistics": "/v1/usage",
|
|
"quota": "/v1/quota",
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
|
|
uvicorn.run(
|
|
app,
|
|
host="0.0.0.0",
|
|
port=8000,
|
|
log_level="info",
|
|
)
|