Added initial API code and documentation.

This commit is contained in:
Nighthawk
2026-02-18 01:38:09 -05:00
parent b8db6ff1ce
commit b0588a0c93
11 changed files with 1778 additions and 0 deletions
+275
View File
@@ -0,0 +1,275 @@
# Qwen3-TTS API - Quick Start Guide
Get the API and Gradio demo running in minutes!
## Prerequisites
- Python 3.9+
- CUDA 11.8+ (optional, for GPU acceleration)
- 8GB+ RAM (16GB+ recommended)
## Installation
### 1. Clone and Install
```bash
# Navigate to the repository
cd Qwen3-TTS-streaming
# Install the package with API dependencies
pip install -e .
# Or just the API extras:
pip install -e ".[api]"
```
### 2. Start the API and Demo
**Option A: Using the startup script (Recommended)**
```bash
python start_api.py
```
**Option B: Start services separately**
Terminal 1 - Start API:
```bash
python -m api.main
# or
uvicorn api.main:app --host 0.0.0.0 --port 8000
```
Terminal 2 - Start Gradio Demo:
```bash
python -m api.gradio_demo
```
### 3. Access the Services
- **Gradio Demo**: http://localhost:7860
- **API Server**: http://localhost:8000
- **API Documentation**: http://localhost:8000/docs
## Voice Cloning Setup (Optional)
To use voice cloning, place reference files in `assets/voice_cloning/`:
```bash
# Create reference files
mkdir -p assets/voice_cloning
# Copy or create your reference audio (must be WAV format)
cp your_voice.wav assets/voice_cloning/ref_audio.wav
# Create reference text file with the transcription
echo "Your reference text transcription here..." > assets/voice_cloning/ref_text.txt
```
The API will automatically load these files on startup.
## First Test
### Using the Gradio Demo (Easy)
1. Open http://localhost:7860 in your browser
2. Enter text to synthesize
3. Select language and options
4. Click "Generate Speech 🎵"
### Using cURL (Command Line)
```bash
curl -X POST "http://localhost:8000/v1/audio/speech" \
-H "Content-Type: application/json" \
-d '{
"text": "Hello, this is a test.",
"language": "English"
}'
```
### Using Python
```python
import requests
import base64
from io import BytesIO
import soundfile as sf
# Make request
response = requests.post(
"http://localhost:8000/v1/audio/speech",
json={
"text": "Hello, world!",
"language": "English"
}
)
# Decode and save audio
data = response.json()
audio_bytes = base64.b64decode(data["audio_base64"])
audio, sr = sf.read(BytesIO(audio_bytes))
sf.write("output.wav", audio, sr)
print(f"✅ Saved audio: {data['duration']:.2f}s at {sr} Hz")
```
## API Endpoints
### Health & Models
```
GET /v1/health # Check API status
GET /v1/models # List available models
```
### Text-to-Speech
```
POST /v1/audio/speech # Generate audio
POST /v1/audio/speech/stream # Stream audio (real-time)
```
## Streaming Audio
The streaming endpoint returns WAV frames with length prefixes for real-time audio:
```python
import requests
response = requests.post(
"http://localhost:8000/v1/audio/speech/stream",
json={
"text": "This is a streaming test.",
"language": "English",
"stream_options": {
"emit_every_frames": 8,
"decode_window_frames": 80,
"overlap_samples": 512
}
},
stream=True
)
# Process streaming chunks
for chunk in response.iter_content(chunk_size=65536):
# Parse: [frame_length: 4 bytes][frame_data: frame_length bytes]
frame_len = int.from_bytes(chunk[:4], byteorder='big')
frame_data = chunk[4:4 + frame_len]
# Play or process frame_data...
```
## Voice Cloning Usage
Once reference files are in `assets/voice_cloning/`:
```python
response = requests.post(
"http://localhost:8000/v1/audio/speech",
json={
"text": "New text in cloned voice",
"language": "English",
"voice_clone_mode": "reference_audio" # Enable voice cloning
}
)
```
## Supported Languages
- English
- Russian
- Chinese (Simplified)
- Japanese
- Korean
- Auto (automatic detection)
## Performance Tips
### GPU Usage
The API automatically uses CUDA if available:
- Monitor GPU: `nvidia-smi`
- Requires: CUDA 11.8+ and cuDNN
### Faster Streaming
Adjust streaming options:
```python
"stream_options": {
"emit_every_frames": 4, # Lower = faster first chunk
"decode_window_frames": 40, # Lower = faster, less quality
"overlap_samples": 512
}
```
### Lower Memory Usage
```bash
# Use CPU only
python start_api.py --device cpu
# Or manually in code (api/main.py):
model_state.device = "cpu"
```
## Troubleshooting
### API won't start
```
ModuleNotFoundError: No module named 'fastapi'
→ pip install fastapi uvicorn
```
### GPU out of memory
```
CUDA out of memory
→ Use CPU: python start_api.py --device cpu
→ Or reduce batch size/model size
```
### Voice cloning not working
```
→ Check files exist: assets/voice_cloning/ref_audio.wav
→ Check files exist: assets/voice_cloning/ref_text.txt
→ Ensure ref_text matches the audio content
→ Check API logs for error details
```
### Slow first response
```
→ First request loads model (~30-60 seconds)
→ Subsequent requests are fast (2-5 seconds)
→ Streaming gives first chunk in ~1-2 seconds
```
## Interactive Documentation
Visit `http://localhost:8000/docs` for:
- **Swagger UI**: Full API documentation with "Try it out"
- **ReDoc**: Alternative documentation view
- **API schema**: OpenAPI 3.0.2 specification
## Next Steps
1. **Integrate with your app**
- Use the Python client code above
- Or use any HTTP client in your language
2. **Deploy to production**
- Use Gunicorn: `gunicorn -w 4 api.main:app`
- Or Docker (guidelines in main API README)
3. **Customize the API**
- Edit `api/models.py` for request/response schemas
- Edit `api/main.py` for endpoint logic
- Add more models to `api/models.py` enum
## More Information
- **API Documentation**: See `api/README.md`
- **Voice Cloning Setup**: See `assets/voice_cloning/README.md`
- **Examples**: Check `examples/` directory
## Support
For issues or questions:
1. Check logs in the terminal
2. Visit `/docs` endpoint for API details
3. Review the comprehensive `api/README.md`
4. Check `assets/voice_cloning/README.md` for voice cloning issues
---
**Happy synthesizing! 🎵**
+338
View File
@@ -0,0 +1,338 @@
# Qwen3-TTS OpenAI-like API
A FastAPI-based OpenAI-compatible API for Qwen3 Text-to-Speech with streaming support and voice cloning capabilities.
## Features
- 🎵 **Real-time Streaming** - Stream audio chunks as they're generated
- 🎭 **Voice Cloning** - Clone voices using reference audio
- 🌍 **Multi-language Support** - English, Russian, Chinese, Japanese, Korean, and more
- 📡 **OpenAI-like API** - Familiar API structure for easy integration
- 🚀 **High Performance** - Optimized streaming with CUDA support
- 📚 **Interactive Docs** - Swagger UI at `/docs`
## Installation
### Prerequisites
- Python >= 3.9
- CUDA 11.8+ (for GPU support) or CPU mode
- 8GB+ VRAM recommended for GPU
### Setup
1. **Install dependencies**
```bash
pip install fastapi uvicorn gradio requests soundfile librosa numpy torch transformers
```
2. **Install Qwen3-TTS package**
```bash
pip install -e .
```
## Quick Start
### Starting the API Server
```bash
# Run the API server
python -m api.main
# Or with uvicorn directly
uvicorn api.main:app --host 0.0.0.0 --port 8000
```
The API will be available at `http://localhost:8000`
### Running the Gradio Demo
In a separate terminal:
```bash
python -m api.gradio_demo
```
The demo will be available at `http://localhost:7860`
## API Endpoints
### Health Check
```
GET /v1/health
```
Check API status and model readiness.
**Response:**
```json
{
"status": "healthy",
"model": "Qwen/Qwen3-TTS-12Hz-1.7B-Base",
"device": "cuda",
"ready": true
}
```
### List Models
```
GET /v1/models
```
Get available TTS models.
**Response:**
```json
{
"object": "list",
"data": [
{
"id": "Qwen/Qwen3-TTS-12Hz-1.7B-Base",
"object": "model",
"owned_by": "Alibaba",
"supported_languages": ["English", "Russian", "Chinese", "Japanese", "Korean"],
"supports_streaming": true,
"supports_voice_clone": true
}
]
}
```
### Text-to-Speech (Standard)
```
POST /v1/audio/speech
```
Generate audio from text.
**Request:**
```json
{
"text": "Hello, how are you?",
"language": "English",
"model": "Qwen/Qwen3-TTS-12Hz-1.7B-Base",
"voice_clone_mode": "disabled",
"speed": 1.0,
"pitch": 1.0
}
```
**Response:**
```json
{
"id": "req_abc123def456",
"object": "audio",
"created": 1708284000,
"model": "Qwen/Qwen3-TTS-12Hz-1.7B-Base",
"audio_base64": "UklGRi...",
"duration": 2.5,
"sample_rate": 24000,
"language": "English"
}
```
### Text-to-Speech (Streaming)
```
POST /v1/audio/speech/stream
```
Stream audio chunks as they're generated.
**Request:**
```json
{
"text": "Hello, this is a streaming test.",
"language": "English",
"voice_clone_mode": "disabled",
"stream_options": {
"emit_every_frames": 8,
"decode_window_frames": 80,
"overlap_samples": 512
}
}
```
**Response:**
- Binary stream of WAV frame chunks
- Each chunk is prefixed with a 4-byte length (big-endian)
- Format: `[length (4 bytes)][WAV frame data]...`
## Voice Cloning
### Setup
Voice cloning requires two files in `assets/voice_cloning/`:
1. **`ref_audio.wav`** - Reference audio file
- Format: WAV
- Duration: 5-30 seconds
- Clear, natural speech
2. **`ref_text.txt`** - Transcription of reference audio
- Plain text UTF-8
- Must exactly match the audio content
### Using Voice Cloning
```bash
# 1. Place reference files
cp your_reference.wav assets/voice_cloning/ref_audio.wav
echo "Reference text here..." > assets/voice_cloning/ref_text.txt
# 2. Start the API (it will load voice cloning automatically)
python -m api.main
# 3. Use voice cloning in requests
curl -X POST "http://localhost:8000/v1/audio/speech" \
-H "Content-Type: application/json" \
-d '{
"text": "New text to synthesize",
"language": "English",
"voice_clone_mode": "reference_audio"
}'
```
## Python Integration
### Using the API from Python
```python
import requests
import base64
from io import BytesIO
import soundfile as sf
# Generate speech
response = requests.post(
"http://localhost:8000/v1/audio/speech",
json={
"text": "Hello, world!",
"language": "English",
"voice_clone_mode": "disabled"
}
)
data = response.json()
audio_bytes = base64.b64decode(data["audio_base64"])
audio, sr = sf.read(BytesIO(audio_bytes))
# Save to file
sf.write("output.wav", audio, sr)
```
### Streaming Example
```python
import requests
response = requests.post(
"http://localhost:8000/v1/audio/speech/stream",
json={
"text": "This is a streaming test.",
"language": "English",
},
stream=True
)
chunks = []
for chunk in response.iter_content(chunk_size=65536):
# Parse frame length (4 bytes)
frame_len = int.from_bytes(chunk[:4], byteorder='big')
# Extract WAV frame data
frame_data = chunk[4:4 + frame_len]
chunks.append(frame_data)
```
## Configuration
### Model Selection
Currently supports:
- `Qwen/Qwen3-TTS-12Hz-1.7B-Base` (default)
More models can be added to `TTSModel` enum in `api/models.py`.
### Device Selection
The API automatically selects the best available device:
- CUDA GPU (if available and with sufficient VRAM)
- CPU (fallback)
To force a specific device, modify `api/main.py`:
```python
# In ModelState.__init__
self.device = "cpu" # Force CPU mode
```
### Streaming Options
Adjust streaming performance in `api/models.py`:
```python
class StreamOptions(BaseModel):
emit_every_frames: int = 8 # Lower = more chunks, lower latency
decode_window_frames: int = 80 # Higher = better quality, higher latency
overlap_samples: int = 512 # Overlap for smooth transitions
```
## Performance Tips
1. **GPU Optimization**
- Use CUDA 11.8+ for better performance
- `flash_attention_2` is automatically enabled when available
2. **Streaming Optimization**
- Lower `emit_every_frames` for faster first chunk
- Increase `decode_window_frames` for better audio quality
3. **Production Deployment**
- Use a production ASGI server like Gunicorn or Hypercorn
- Enable response caching for identical requests
- Monitor GPU memory usage
## Troubleshooting
### API won't start
```
error: No module named 'transformers'
→ pip install transformers accelerate
```
### Model loading fails
```
error: CUDA out of memory
→ Use CPU mode: modify device to "cpu" in api/main.py
→ Or reduce batch size
```
### Voice cloning not working
- Verify `ref_audio.wav` and `ref_text.txt` exist in `assets/voice_cloning/`
- Check file permissions
- Ensure reference text matches audio content exactly
### Slow responses
- Check GPU/CPU utilization
- Reduce `decode_window_frames` for faster (but lower quality) generation
- Ensure sufficient system memory available
## API Documentation
Once the server is running, visit:
- **Interactive Docs (Swagger UI)**: `http://localhost:8000/docs`
- **ReDoc Documentation**: `http://localhost:8000/redoc`
## Examples
See the `examples/` directory for more usage examples:
- `test_streaming.py` - Streaming generation example
- `test_model_12hz_base.py` - Base model usage
- `test_model_12hz_custom_voice.py` - Voice cloning example
## License
This API is licensed under the Apache 2.0 License. See LICENSE file for details.
## Contributing
Contributions are welcome! Please feel free to submit pull requests.
## Support
For issues, questions, or suggestions:
1. Check the troubleshooting section
2. Review the API documentation at `/docs`
3. Check logs for detailed error messages
+5
View File
@@ -0,0 +1,5 @@
"""
Qwen3-TTS OpenAI-like API package.
"""
__version__ = "0.1.0"
+344
View File
@@ -0,0 +1,344 @@
"""
Gradio demo for Qwen3-TTS API.
Provides a user-friendly interface for testing the streaming TTS API.
"""
import requests
import base64
import io
import time
from pathlib import Path
import gradio as gr
import soundfile as sf
import numpy as np
class TTSAPIClient:
"""Client for interacting with Qwen3-TTS API."""
def __init__(self, api_url: str = "http://localhost:8000"):
self.api_url = api_url
def health_check(self) -> dict:
"""Check API health status."""
try:
response = requests.get(f"{self.api_url}/v1/health", timeout=5)
return response.json() if response.status_code == 200 else {"status": "unavailable"}
except Exception as e:
return {"status": "error", "message": str(e)}
def list_models(self) -> list:
"""Get list of available models."""
try:
response = requests.get(f"{self.api_url}/v1/models", timeout=5)
if response.status_code == 200:
data = response.json()
return [m["id"] for m in data.get("data", [])]
return []
except Exception as e:
return f"Error: {str(e)}"
def generate_speech(
self,
text: str,
language: str = "English",
voice_clone: bool = False,
) -> tuple:
"""
Generate speech using TTS API.
Returns:
Tuple of (audio_data, sample_rate) or (None, error_message)
"""
try:
payload = {
"text": text,
"language": language,
"voice_clone_mode": "reference_audio" if voice_clone else "disabled",
}
response = requests.post(
f"{self.api_url}/v1/audio/speech",
json=payload,
timeout=60,
)
if response.status_code == 200:
data = response.json()
# Decode base64 audio
audio_bytes = base64.b64decode(data["audio_base64"])
audio, sr = sf.read(io.BytesIO(audio_bytes), dtype="float32")
return (sr, audio), None
else:
error = response.json().get("error", "Unknown error")
return None, f"Error: {error}"
except requests.exceptions.Timeout:
return None, "Request timeout - model may be loading or busy"
except Exception as e:
return None, f"Error: {str(e)}"
def stream_speech(
self,
text: str,
language: str = "English",
voice_clone: bool = False,
) -> tuple:
"""
Stream speech from TTS API with chunks.
Yields audio chunks to stream in near real-time.
"""
try:
payload = {
"text": text,
"language": language,
"voice_clone_mode": "reference_audio" if voice_clone else "disabled",
"stream_options": {
"emit_every_frames": 8,
"decode_window_frames": 80,
"overlap_samples": 512,
}
}
response = requests.post(
f"{self.api_url}/v1/audio/speech/stream",
json=payload,
stream=True,
timeout=120,
)
if response.status_code != 200:
error = response.json().get("error", "Unknown error")
return None, f"Error: {error}"
chunks = []
sample_rate = None
chunk_count = 0
# Read streaming chunks with length prefix (4 bytes big-endian)
buffer = b""
for chunk in response.iter_content(chunk_size=65536):
if chunk:
buffer += chunk
# Process complete frames
while len(buffer) >= 4:
frame_len = int.from_bytes(buffer[:4], byteorder='big')
if len(buffer) < 4 + frame_len:
break # Need more data
frame_data = buffer[4:4 + frame_len]
buffer = buffer[4 + frame_len:]
try:
audio, sample_rate = sf.read(
io.BytesIO(frame_data),
dtype="float32"
)
chunks.append(audio)
chunk_count += 1
except Exception as e:
print(f"Error decoding chunk: {e}")
if chunks and sample_rate:
final_audio = np.concatenate(chunks)
return (sample_rate, final_audio), None
else:
return None, "No audio data received"
except requests.exceptions.Timeout:
return None, "Request timeout - stream took too long"
except Exception as e:
return None, f"Streaming error: {str(e)}"
# Initialize API client
client = TTSAPIClient()
def check_api_status():
"""Check API health and return status message."""
health = client.health_check()
if health.get("status") == "healthy":
return f"✅ API Ready\nModel: {health.get('model', 'Unknown')}\nDevice: {health.get('device', 'Unknown')}"
else:
return f"❌ API Unavailable\nMake sure the API server is running on http://localhost:8000"
def synthesize_speech(text: str, language: str, use_streaming: bool, use_voice_clone: bool) -> tuple:
"""
Synthesize speech using the selected options.
Returns:
Tuple of (audio_output, status_message)
"""
if not text.strip():
return None, "❌ Error: Please enter some text"
# Check API status
health = client.health_check()
if health.get("status") != "healthy":
return None, "❌ Error: API is not available. Please start the API server."
status_update = f"🔄 Generating speech ({len(text)} characters)...\n"
status_update += f"Language: {language}\n"
status_update += f"Voice Clone: {'Yes' if use_voice_clone else 'No'}\n"
status_update += f"Streaming: {'Yes' if use_streaming else 'No'}\n\n"
start_time = time.time()
if use_streaming:
result, error = client.stream_speech(text, language, use_voice_clone)
else:
result, error = client.generate_speech(text, language, use_voice_clone)
elapsed = time.time() - start_time
if error:
return None, f"{error}"
if result:
sample_rate, audio = result
duration = len(audio) / sample_rate
status_update += f"✅ Success!\n"
status_update += f"Duration: {duration:.2f}s\n"
status_update += f"Generation time: {elapsed:.2f}s\n"
status_update += f"Sample rate: {sample_rate} Hz\n"
if use_streaming:
status_update += f"(Streaming optimized)\n"
return (sample_rate, audio), status_update
return None, "❌ Error: No audio generated"
def create_demo():
"""Create and return the Gradio demo interface."""
with gr.Blocks(
title="Qwen3-TTS API Demo",
theme=gr.themes.Soft(),
) as demo:
gr.Markdown("""
# 🎙️ Qwen3-TTS Streaming API Demo
This demo showcases the **Qwen3-TTS OpenAI-like API** with support for:
- 🎵 Real-time streaming audio generation
- 🎭 Voice cloning (when reference audio is provided)
- 🌍 Multiple language support
**Note:** Make sure the API server is running at `http://localhost:8000`
""")
# API Status Section
with gr.Group():
gr.Markdown("### API Status")
status_button = gr.Button("Check API Status", variant="primary")
status_output = gr.Textbox(
label="Status",
interactive=False,
lines=3,
)
status_button.click(
fn=check_api_status,
outputs=status_output,
)
# Main Input Section
with gr.Group():
gr.Markdown("### Text to Speech Settings")
with gr.Row():
text_input = gr.Textbox(
label="Text to Synthesize",
placeholder="Enter the text you want to convert to speech...",
lines=4,
scale=3,
)
with gr.Column(scale=1):
language_select = gr.Dropdown(
choices=["English", "Russian", "Chinese", "Japanese", "Korean", "Auto"],
value="English",
label="Language",
)
use_streaming = gr.Checkbox(
label="Use Streaming",
value=True,
info="Stream audio for faster first chunk",
)
use_voice_clone = gr.Checkbox(
label="Voice Cloning",
value=False,
info="Use reference voice (if available)",
)
# Synthesis Button
with gr.Row():
synthesize_button = gr.Button(
"Generate Speech 🎵",
variant="primary",
size="lg",
)
# Output Section
with gr.Group():
gr.Markdown("### Audio Output")
with gr.Row():
audio_output = gr.Audio(
label="Generated Audio",
type="numpy",
interactive=False,
)
status_output_gen = gr.Textbox(
label="Generation Status",
interactive=False,
lines=6,
)
# Connect synthesis button
synthesize_button.click(
fn=synthesize_speech,
inputs=[text_input, language_select, use_streaming, use_voice_clone],
outputs=[audio_output, status_output_gen],
)
# Info Section
with gr.Group():
gr.Markdown("""
### ️ Information
**Voice Cloning:**
- To enable voice cloning, place `ref_audio.wav` and `ref_text.txt` in `assets/voice_cloning/`
- `ref_audio.wav`: Reference audio file in WAV format
- `ref_text.txt`: Transcription of the reference audio
**API Endpoints:**
- `GET /v1/health` - Health check
- `GET /v1/models` - List available models
- `POST /v1/audio/speech` - Generate speech
- `POST /v1/audio/speech/stream` - Stream speech
**Documentation:** Visit `http://localhost:8000/docs` for API documentation
""")
return demo
if __name__ == "__main__":
demo = create_demo()
demo.launch(
server_name="0.0.0.0",
server_port=7860,
share=False,
show_error=True,
)
+340
View File
@@ -0,0 +1,340 @@
"""
Main FastAPI application for Qwen3-TTS OpenAI-like API.
Provides streaming and standard TTS endpoints.
"""
import io
import os
import torch
import logging
import soundfile as sf
from contextlib import asynccontextmanager
from pathlib import Path
from typing import AsyncGenerator, Optional
from fastapi import FastAPI, Request, HTTPException, BackgroundTasks
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
)
from .utils import (
generate_request_id, get_unix_timestamp, audio_to_base64,
get_audio_duration, load_voice_clone_files, concatenate_audio_chunks
)
# 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
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)
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
}
)
# ============== Root Endpoint ==============
@app.get("/")
async def root():
"""Root endpoint with API information."""
return {
"name": "Qwen3-TTS API",
"version": "0.1.0",
"description": "OpenAI-like API for Qwen3 Text-to-Speech streaming",
"documentation": "/docs",
"endpoints": {
"health": "/v1/health",
"models": "/v1/models",
"speech": "/v1/audio/speech",
"stream": "/v1/audio/speech/stream",
}
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(
app,
host="0.0.0.0",
port=8000,
log_level="info",
)
+86
View File
@@ -0,0 +1,86 @@
"""
Pydantic models for API requests and responses.
Inspired by OpenAI's API but tailored for Qwen3-TTS.
"""
from typing import List, Optional, Dict, Any
from enum import Enum
from pydantic import BaseModel, Field
class TTSModel(str, Enum):
"""Available TTS models."""
BASE = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
# Add more model variants as needed
class VoiceCloneMode(str, Enum):
"""Voice cloning modes."""
DISABLED = "disabled"
REFERENCE_AUDIO = "reference_audio"
CUSTOM = "custom"
class StreamOptions(BaseModel):
"""Streaming configuration options."""
emit_every_frames: int = Field(default=8, ge=1, le=100, description="Emit chunks every N frames")
decode_window_frames: int = Field(default=80, ge=1, le=256, description="Decode window size in frames")
overlap_samples: int = Field(default=512, ge=0, le=2048, description="Overlap samples for smooth transitions")
class TTSRequest(BaseModel):
"""Base TTS request model."""
text: str = Field(..., min_length=1, max_length=10000, description="Text to synthesize")
model: TTSModel = Field(default=TTSModel.BASE, description="Model to use")
language: str = Field(default="English", description="Language for TTS")
voice_clone_mode: VoiceCloneMode = Field(default=VoiceCloneMode.DISABLED, description="Voice cloning mode")
speed: float = Field(default=1.0, ge=0.5, le=2.0, description="Speech speed multiplier")
pitch: float = Field(default=1.0, ge=0.5, le=2.0, description="Pitch adjustment")
class StreamingTTSRequest(TTSRequest):
"""Request model for streaming TTS."""
stream_options: StreamOptions = Field(default_factory=StreamOptions, description="Streaming configuration")
class TTSResponse(BaseModel):
"""Response model for TTS requests."""
id: str = Field(..., description="Request ID")
object: str = Field(default="audio", description="Object type")
created: int = Field(..., description="Unix timestamp of creation")
model: str = Field(..., description="Model used")
audio_base64: str = Field(..., description="Audio data in base64 format")
duration: float = Field(..., description="Duration in seconds")
sample_rate: int = Field(default=24000, description="Sample rate in Hz")
language: str = Field(..., description="Language used")
class ErrorResponse(BaseModel):
"""Error response model."""
error: str = Field(..., description="Error message")
code: str = Field(..., description="Error code")
details: Optional[Dict[str, Any]] = Field(default=None, description="Additional error details")
class HealthResponse(BaseModel):
"""Health check response."""
status: str = Field(default="healthy", description="Health status")
model: str = Field(..., description="Current model")
device: str = Field(..., description="Computation device")
ready: bool = Field(default=True, description="Ready for requests")
class ModelInfoResponse(BaseModel):
"""Model information response."""
id: str = Field(..., description="Model ID")
object: str = Field(default="model", description="Object type")
owned_by: str = Field(default="Alibaba", description="Model owner")
supported_languages: List[str] = Field(..., description="Supported languages")
supports_streaming: bool = Field(default=True, description="Supports streaming")
supports_voice_clone: bool = Field(default=True, description="Supports voice cloning")
class ModelsListResponse(BaseModel):
"""List of available models."""
object: str = Field(default="list", description="Object type")
data: List[ModelInfoResponse] = Field(..., description="List of models")
+140
View File
@@ -0,0 +1,140 @@
"""
Utility functions for audio processing and API operations.
"""
import base64
import io
import os
import uuid
from datetime import datetime
from pathlib import Path
from typing import Tuple, Optional
import numpy as np
import soundfile as sf
def generate_request_id() -> str:
"""Generate a unique request ID."""
return f"req_{uuid.uuid4().hex[:12]}"
def get_unix_timestamp() -> int:
"""Get current Unix timestamp."""
return int(datetime.utcnow().timestamp())
def audio_to_base64(audio: np.ndarray, sample_rate: int) -> str:
"""
Convert numpy audio array to base64-encoded WAV string.
Args:
audio: Audio waveform as numpy array
sample_rate: Sample rate in Hz
Returns:
Base64-encoded audio data
"""
with io.BytesIO() as wav_buffer:
sf.write(wav_buffer, audio, sample_rate, format='WAV')
wav_bytes = wav_buffer.getvalue()
return base64.b64encode(wav_bytes).decode('utf-8')
def base64_to_audio(audio_base64: str) -> Tuple[np.ndarray, int]:
"""
Convert base64-encoded audio to numpy array.
Args:
audio_base64: Base64-encoded audio data
Returns:
Tuple of (audio waveform, sample_rate)
"""
wav_bytes = base64.b64decode(audio_base64)
audio, sr = sf.read(io.BytesIO(wav_bytes), dtype='float32')
return audio, int(sr)
def get_audio_duration(audio: np.ndarray, sample_rate: int) -> float:
"""
Calculate audio duration in seconds.
Args:
audio: Audio waveform as numpy array
sample_rate: Sample rate in Hz
Returns:
Duration in seconds
"""
return float(len(audio) / sample_rate)
def load_voice_clone_files(voice_clone_dir: str) -> Tuple[Optional[str], Optional[str]]:
"""
Load ref_audio.wav and ref_text.txt from voice cloning directory.
Args:
voice_clone_dir: Path to voice cloning directory
Returns:
Tuple of (ref_audio_path, ref_text) or (None, None) if files not found
"""
voice_clone_path = Path(voice_clone_dir)
ref_audio_path = voice_clone_path / "ref_audio.wav"
ref_text_path = voice_clone_path / "ref_text.txt"
if not ref_audio_path.exists() or not ref_text_path.exists():
return None, None
with open(ref_text_path, 'r', encoding='utf-8') as f:
ref_text = f.read().strip()
return str(ref_audio_path), ref_text
def validate_audio_file(file_path: str) -> Tuple[bool, str]:
"""
Validate that the audio file exists and is readable.
Args:
file_path: Path to audio file
Returns:
Tuple of (is_valid, message)
"""
path = Path(file_path)
if not path.exists():
return False, f"File not found: {file_path}"
if not path.is_file():
return False, f"Path is not a file: {file_path}"
if path.suffix.lower() not in ['.wav', '.mp3', '.flac', '.ogg']:
return False, f"Unsupported audio format: {path.suffix}"
try:
sf.read(file_path, frames=1)
return True, "Valid audio file"
except Exception as e:
return False, f"Error reading audio file: {str(e)}"
def concatenate_audio_chunks(chunks: list, sample_rate: int) -> Tuple[np.ndarray, int]:
"""
Concatenate multiple audio chunks into a single array.
Args:
chunks: List of audio chunks as numpy arrays
sample_rate: Sample rate in Hz
Returns:
Tuple of (concatenated audio, sample_rate)
"""
if not chunks:
return np.array([]), sample_rate
return np.concatenate(chunks), sample_rate
+99
View File
@@ -0,0 +1,99 @@
# Voice Cloning Assets
This directory contains the reference audio and text files needed for voice cloning functionality.
## Files Required
### `ref_audio.wav`
- **Format**: WAV (Waveform Audio File Format)
- **Sample Rate**: 24kHz or higher (will be automatically resampled if needed)
- **Duration**: 5-30 seconds recommended
- **Content**: Clear speech sample with natural pronunciation and emotion
- **Language**: Should match the languages you plan to synthesize
### `ref_text.txt`
- **Format**: Plain text UTF-8
- **Content**: Exact transcription of the reference audio
- **Purpose**: Used for in-context learning (ICL) mode to match prosody and emotion
- **Length**: Should match the duration of `ref_audio.wav`
## Setup Instructions
1. **Prepare Your Reference Audio**
- Record or find a clear audio sample with good quality
- Ensure the audio is in WAV format
- Recommended duration: 10-20 seconds
2. **Create Reference Text**
- Transcribe the audio content accurately
- Include punctuation and capitalization
- Save as `ref_text.txt` in UTF-8 encoding
3. **Place Files in This Directory**
```
assets/voice_cloning/
├── ref_audio.wav
└── ref_text.txt
```
4. **Verify Setup**
- Check the API health endpoint: `GET /v1/health`
- If voice cloning is available, the API will automatically load the files
- The Gradio demo will show "Voice Cloning: Yes" when ready
## Example Reference Text
For a Russian speaker:
```
Это брат Кэти, моей одноклассницы. А что у тебя с рукой?
И почему ты голая? У него ведь куча наград по боевым искусствам.
```
For an English speaker:
```
Good one. Okay, fine, I'm just gonna leave this sock monkey here. Goodbye.
```
## Voice Cloning Modes
The API supports two voice cloning modes:
1. **ICL Mode (In-Context Learning)** - Default
- Uses both the reference audio codes and speaker embedding
- Requires accurate `ref_text.txt`
- More expressive but slightly slower
- Best for preserving voice characteristics and emotion
2. **X-Vector Only Mode**
- Uses only the speaker embedding
- Does not require `ref_text.txt`
- Faster generation
- Good for voice similarity without exact prosody matching
## Troubleshooting
### Voice cloning not available
- Check that both `ref_audio.wav` and `ref_text.txt` exist in this directory
- Verify file names are exact (case-sensitive on Linux/Mac)
- Check that the audio file is readable and valid
### Poor voice quality
- Use a clearer reference audio sample
- Ensure the reference text exactly matches the audio
- Try a different reference speaker
### API errors with voice cloning
- Check the API logs for specific error messages
- Verify the audio file is not corrupted
- Ensure sufficient system memory (model requires VRAM/RAM)
## Supported Languages
The Qwen3-TTS model supports voice cloning for multiple languages:
- English
- Russian
- Chinese (Simplified & Traditional)
- Japanese
- Korean
Choose appropriate reference audio for your target language.
BIN
View File
Binary file not shown.
+10
View File
@@ -29,6 +29,16 @@ dependencies = [
"sox",
"onnxruntime",
"einops",
"fastapi",
"uvicorn[standard]",
"requests",
]
[project.optional-dependencies]
api = [
"fastapi",
"uvicorn[standard]",
"requests",
]
[project.urls]
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env python
"""
Startup script for Qwen3-TTS API and Gradio demo.
Runs both the FastAPI backend and Gradio frontend.
"""
import sys
import subprocess
import time
import webbrowser
from pathlib import Path
import argparse
def main():
parser = argparse.ArgumentParser(
description="Start Qwen3-TTS API and optional Gradio demo"
)
parser.add_argument(
"--no-demo",
action="store_true",
help="Run only the API server without Gradio demo"
)
parser.add_argument(
"--host",
default="0.0.0.0",
help="API host (default: 0.0.0.0)"
)
parser.add_argument(
"--port",
type=int,
default=8000,
help="API port (default: 8000)"
)
parser.add_argument(
"--demo-port",
type=int,
default=7860,
help="Gradio demo port (default: 7860)"
)
parser.add_argument(
"--no-browser",
action="store_true",
help="Don't open browser automatically"
)
args = parser.parse_args()
print("""
╔════════════════════════════════════════════════════════════════╗
║ Qwen3-TTS OpenAI-like API Server ║
║ Starting API and Gradio Demo... ║
╚════════════════════════════════════════════════════════════════╝
""")
# Start API server
print(f"\n📡 Starting API server on http://{args.host}:{args.port}")
print(" Docs: http://localhost:{}/docs".format(args.port))
api_cmd = [
sys.executable,
"-m",
"uvicorn",
"api.main:app",
"--host", args.host,
"--port", str(args.port),
"--log-level", "info",
]
api_process = subprocess.Popen(api_cmd)
# Give API time to start
time.sleep(5)
if not args.no_demo:
# Start Gradio demo in separate process
print(f"\n🎨 Starting Gradio demo on http://localhost:{args.demo_port}")
demo_cmd = [
sys.executable,
"-m",
"api.gradio_demo",
]
# Note: Gradio will bind to 0.0.0.0:7860 by default
# Modify api/gradio_demo.py if you need to change this
demo_process = subprocess.Popen(demo_cmd)
# Open browser if requested
if not args.no_browser:
time.sleep(3)
try:
webbrowser.open(f"http://localhost:{args.demo_port}")
except Exception as e:
print(f"Could not open browser: {e}")
print("""
╔════════════════════════════════════════════════════════════════╗
║ ✅ Services started successfully! ║
║ ║
║ API Server: http://localhost:{}
║ API Docs: http://localhost:{}/docs ║
║ Gradio Demo: http://localhost:{}
║ ║
║ Press Ctrl+C to stop all services ║
╚════════════════════════════════════════════════════════════════╝
""".format(args.port, args.port, args.demo_port))
try:
if not args.no_demo:
# Wait for both processes
api_process.wait()
demo_process.wait()
else:
# Wait for API process
api_process.wait()
except KeyboardInterrupt:
print("\n\n🛑 Shutting down...")
api_process.terminate()
if not args.no_demo:
demo_process.terminate()
# Wait for graceful shutdown
try:
api_process.wait(timeout=5)
except subprocess.TimeoutExpired:
api_process.kill()
if not args.no_demo:
try:
demo_process.wait(timeout=5)
except subprocess.TimeoutExpired:
demo_process.kill()
print("👋 Services stopped.")
sys.exit(0)
if __name__ == "__main__":
main()