mirror of
https://github.com/Nighthawk42/Qwen3-TTS-streaming.git
synced 2026-08-30 08:02:28 +00:00
Added initial API code and documentation.
This commit is contained in:
@@ -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
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
Qwen3-TTS OpenAI-like API package.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -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
@@ -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",
|
||||
)
|
||||
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
Binary file not shown.
@@ -29,6 +29,16 @@ dependencies = [
|
||||
"sox",
|
||||
"onnxruntime",
|
||||
"einops",
|
||||
"fastapi",
|
||||
"uvicorn[standard]",
|
||||
"requests",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
api = [
|
||||
"fastapi",
|
||||
"uvicorn[standard]",
|
||||
"requests",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
+141
@@ -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()
|
||||
Reference in New Issue
Block a user