diff --git a/QUICKSTART_API.md b/QUICKSTART_API.md new file mode 100644 index 0000000..4b1bf2a --- /dev/null +++ b/QUICKSTART_API.md @@ -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! 🎡** diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000..cebb131 --- /dev/null +++ b/api/README.md @@ -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 diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..8576eb8 --- /dev/null +++ b/api/__init__.py @@ -0,0 +1,5 @@ +""" +Qwen3-TTS OpenAI-like API package. +""" + +__version__ = "0.1.0" diff --git a/api/gradio_demo.py b/api/gradio_demo.py new file mode 100644 index 0000000..0c31be6 --- /dev/null +++ b/api/gradio_demo.py @@ -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, + ) diff --git a/api/main.py b/api/main.py new file mode 100644 index 0000000..b11b751 --- /dev/null +++ b/api/main.py @@ -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", + ) diff --git a/api/models.py b/api/models.py new file mode 100644 index 0000000..35ecc0c --- /dev/null +++ b/api/models.py @@ -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") diff --git a/api/utils.py b/api/utils.py new file mode 100644 index 0000000..ab6bf1d --- /dev/null +++ b/api/utils.py @@ -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 diff --git a/assets/voice_cloning/README.md b/assets/voice_cloning/README.md new file mode 100644 index 0000000..a28e2d8 --- /dev/null +++ b/assets/voice_cloning/README.md @@ -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. diff --git a/kuklina-1.wav b/kuklina-1.wav deleted file mode 100644 index 0e62f4b..0000000 Binary files a/kuklina-1.wav and /dev/null differ diff --git a/pyproject.toml b/pyproject.toml index eb8c063..045d947 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,16 @@ dependencies = [ "sox", "onnxruntime", "einops", + "fastapi", + "uvicorn[standard]", + "requests", +] + +[project.optional-dependencies] +api = [ + "fastapi", + "uvicorn[standard]", + "requests", ] [project.urls] diff --git a/start_api.py b/start_api.py new file mode 100644 index 0000000..464ca5b --- /dev/null +++ b/start_api.py @@ -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()