Push repo

This commit is contained in:
Lex-au 2025-03-22 03:48:38 +11:00
parent 89c0969bd0
commit d1869cc13b
9 changed files with 1722 additions and 2 deletions

2
.gitattributes vendored
View file

@ -1,2 +0,0 @@
# Auto detect text files and perform LF normalization
* text=auto

220
README.md Normal file
View file

@ -0,0 +1,220 @@
# Orpheus-FASTAPI
[![GitHub](https://img.shields.io/github/license/Lex-au/Orpheus-FastAPI)](https://github.com/Lex-au/Orpheus-FastAPI/blob/main/LICENSE.txt)
High-performance Text-to-Speech server with OpenAI-compatible API, 8 voices, emotion tags, and modern web UI. Optimized for RTX GPUs.
[GitHub Repository](https://github.com/Lex-au/Orpheus-FastAPI)
## Features
- **OpenAI API Compatible**: Drop-in replacement for OpenAI's `/v1/audio/speech` endpoint
- **Modern Web Interface**: Clean, responsive UI with waveform visualization
- **High Performance**: Optimized for RTX GPUs with parallel processing
- **Multiple Voices**: 8 different voice options with different characteristics
- **Emotion Tags**: Support for laughter, sighs, and other emotional expressions
- **Long-form Audio**: Efficient generation of extended audio content in a single request
## Project Structure
```
Orpheus-FastAPI/
├── app.py # FastAPI server and endpoints
├── requirements.txt # Dependencies
├── static/ # Static assets (favicon, etc.)
├── outputs/ # Generated audio files
├── templates/ # HTML templates
│ └── tts.html # Web UI template
└── tts_engine/ # Core TTS functionality
├── __init__.py # Package exports
├── inference.py # Token generation and API handling
└── speechpipe.py # Audio conversion pipeline
```
## Setup
### Prerequisites
- Python 3.8+
- CUDA-compatible GPU (recommended: RTX series for best performance)
- Separate LLM inference server running the Orpheus model (e.g., LM Studio or llama.cpp server)
### Installation
1. Clone the repository:
```bash
git clone https://github.com/Lex-au/Orpheus-FastAPI.git
cd Orpheus-FastAPI
```
2. Create a Python virtual environment:
```bash
# Using venv (Python's built-in virtual environment)
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
# Or using conda
conda create -n orpheus-tts python=3.10
conda activate orpheus-tts
```
3. Install PyTorch with CUDA support:
```bash
pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu124
```
4. Install other dependencies:
```bash
pip3 install -r requirements.txt
```
5. Set up the required directories:
```bash
# Create directories for outputs and static files
mkdir -p outputs static
```
### Starting the Server
Run the FastAPI server:
```bash
python app.py
```
Or with specific host/port:
```bash
uvicorn app:app --host 0.0.0.0 --port 5005 --reload
```
Access:
- Web interface: http://localhost:5005/ (or http://127.0.0.1:5005/)
- API documentation: http://localhost:5005/docs (or http://127.0.0.1:5005/docs)
## API Usage
### OpenAI-Compatible Endpoint
The server provides an OpenAI-compatible API endpoint at `/v1/audio/speech`:
```bash
curl http://localhost:5005/v1/audio/speech \
-H "Content-Type: application/json" \
-d '{
"model": "orpheus",
"input": "Hello world! This is a test of the Orpheus TTS system.",
"voice": "tara",
"response_format": "wav",
"speed": 1.0
}' \
--output speech.wav
```
### Parameters
- `input` (required): The text to convert to speech
- `model` (optional): The model to use (default: "orpheus")
- `voice` (optional): Which voice to use (default: "tara")
- `response_format` (optional): Output format (currently only "wav" is supported)
- `speed` (optional): Speed factor (0.5 to 1.5, default: 1.0)
### Legacy API
Additionally, a simpler `/speak` endpoint is available:
```bash
curl -X POST http://localhost:5005/speak \
-H "Content-Type: application/json" \
-d '{
"text": "Hello world! This is a test.",
"voice": "tara"
}' \
-o output.wav
```
### Available Voices
- `tara`: Female, conversational, clear
- `leah`: Female, warm, gentle
- `jess`: Female, energetic, youthful
- `leo`: Male, authoritative, deep
- `dan`: Male, friendly, casual
- `mia`: Female, professional, articulate
- `zac`: Male, enthusiastic, dynamic
- `zoe`: Female, calm, soothing
### Emotion Tags
You can insert emotion tags into your text to add expressiveness:
- `<laugh>`: Add laughter
- `<sigh>`: Add a sigh
- `<chuckle>`: Add a chuckle
- `<cough>`: Add a cough sound
- `<sniffle>`: Add a sniffle sound
- `<groan>`: Add a groan
- `<yawn>`: Add a yawning sound
- `<gasp>`: Add a gasping sound
Example: "Well, that's interesting <laugh> I hadn't thought of that before."
## Technical Details
This server works as a frontend that connects to an external LLM inference server. It sends text prompts to the inference server, which generates tokens that are then converted to audio using the SNAC model. The system has been optimised for RTX 4090 GPUs with:
- Vectorised tensor operations
- Parallel processing with CUDA streams
- Efficient memory management
- Token and audio caching
- Optimised batch sizes
For best performance, adjust the API_URL in `tts_engine/inference.py` to point to your LLM inference server endpoint.
### Integration with OpenWebUI
You can easily integrate this TTS solution with [OpenWebUI](https://github.com/open-webui/open-webui) to add high-quality voice capabilities to your chatbot:
1. Start your Orpheus-FASTAPI server
2. In OpenWebUI, go to Admin Panel > Settings > Audio
3. Change TTS from Web API to OpenAI
4. Set APIBASE URL to your server address (e.g., `http://localhost:5005`)
5. API Key can be set to "not-needed"
6. Set TTS Voice to one of the available voices: `tara`, `leah`, `jess`, `leo`, `dan`, `mia`, `zac`, or `zoe`
7. Set TTS Model to `tts-1`
### External Inference Server
This application requires a separate LLM inference server running the Orpheus model. You can use:
- [GPUStack](https://github.com/gpustack/gpustack) - GPU optimised LLM inference server (My pick) - supports LAN/WAN tensor split parallelisation
- [LM Studio](https://lmstudio.ai/) - Load the GGUF model and start the local server
- [llama.cpp server](https://github.com/ggerganov/llama.cpp) - Run with the appropriate model parameters
- Any compatible OpenAI API-compatible server
Download the quantised model from [lex-au/Orpheus-3b-FT-Q8_0.gguf](https://huggingface.co/lex-au/Orpheus-3b-FT-Q8_0.gguf) and load it in your inference server.
The inference server should be configured to expose an API endpoint that this FastAPI application will connect to.
### Environment Variables
You can configure the system by setting environment variables:
- `ORPHEUS_API_URL`: URL of the LLM inference API (tts_engine/inference.py)
- `ORPHEUS_API_TIMEOUT`: Timeout in seconds for API requests (default: 120)
Make sure the `ORPHEUS_API_URL` points to your running inference server.
## Development
### Project Components
- **app.py**: FastAPI server that handles HTTP requests and serves the web UI
- **tts_engine/inference.py**: Handles token generation and API communication
- **tts_engine/speechpipe.py**: Converts token sequences to audio using the SNAC model
### Adding New Voices
To add new voices, update the `AVAILABLE_VOICES` list in `tts_engine/inference.py` and add corresponding descriptions in the HTML template.
## License
This project is licensed under the Apache License 2.0 - see the LICENSE.txt file for details.

166
app.py Normal file
View file

@ -0,0 +1,166 @@
import os
import time
from datetime import datetime
from typing import List, Optional
from fastapi import FastAPI, Request, Form, HTTPException, Depends
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
from tts_engine import generate_speech_from_api, AVAILABLE_VOICES, DEFAULT_VOICE
# Create FastAPI app
app = FastAPI(
title="Orpheus-FASTAPI",
description="High-performance Text-to-Speech server using Orpheus-FASTAPI",
version="1.0.0"
)
# Ensure directories exist
os.makedirs("outputs", exist_ok=True)
os.makedirs("static", exist_ok=True)
# Mount directories for serving files
app.mount("/outputs", StaticFiles(directory="outputs"), name="outputs")
app.mount("/static", StaticFiles(directory="static"), name="static")
# Setup templates
templates = Jinja2Templates(directory="templates")
# API models
class SpeechRequest(BaseModel):
input: str
model: str = "orpheus"
voice: str = DEFAULT_VOICE
response_format: str = "wav"
speed: float = 1.0
class APIResponse(BaseModel):
status: str
voice: str
output_file: str
generation_time: float
# OpenAI-compatible API endpoint
@app.post("/v1/audio/speech")
async def create_speech_api(request: SpeechRequest):
"""
Generate speech from text using the Orpheus TTS model.
Compatible with OpenAI's /v1/audio/speech endpoint.
"""
if not request.input:
raise HTTPException(status_code=400, detail="Missing input text")
# Generate unique filename
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
output_path = f"outputs/{request.voice}_{timestamp}.wav"
# Generate speech
start = time.time()
generate_speech_from_api(
prompt=request.input,
voice=request.voice,
output_file=output_path
)
end = time.time()
generation_time = round(end - start, 2)
# Return audio file
return FileResponse(
path=output_path,
media_type="audio/wav",
filename=f"{request.voice}_{timestamp}.wav"
)
# Legacy API endpoint for compatibility
@app.post("/speak")
async def speak(request: Request):
"""Legacy endpoint for compatibility with existing clients"""
data = await request.json()
text = data.get("text", "")
voice = data.get("voice", DEFAULT_VOICE)
if not text:
return JSONResponse(
status_code=400,
content={"error": "Missing 'text'"}
)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
output_path = f"outputs/{voice}_{timestamp}.wav"
# Generate speech
start = time.time()
generate_speech_from_api(prompt=text, voice=voice, output_file=output_path)
end = time.time()
generation_time = round(end - start, 2)
return JSONResponse(content={
"status": "ok",
"voice": voice,
"output_file": output_path,
"generation_time": generation_time
})
# Web UI routes
@app.get("/", response_class=HTMLResponse)
async def root(request: Request):
"""Redirect to web UI"""
return templates.TemplateResponse(
"tts.html",
{"request": request, "voices": AVAILABLE_VOICES}
)
@app.get("/web/", response_class=HTMLResponse)
async def web_ui(request: Request):
"""Main web UI for TTS generation"""
return templates.TemplateResponse(
"tts.html",
{"request": request, "voices": AVAILABLE_VOICES}
)
@app.post("/web/", response_class=HTMLResponse)
async def generate_from_web(
request: Request,
text: str = Form(...),
voice: str = Form(DEFAULT_VOICE)
):
"""Handle form submission from web UI"""
if not text:
return templates.TemplateResponse(
"tts.html",
{
"request": request,
"error": "Please enter some text.",
"voices": AVAILABLE_VOICES
}
)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
output_path = f"outputs/{voice}_{timestamp}.wav"
# Generate speech
start = time.time()
generate_speech_from_api(prompt=text, voice=voice, output_file=output_path)
end = time.time()
generation_time = round(end - start, 2)
return templates.TemplateResponse(
"tts.html",
{
"request": request,
"success": True,
"text": text,
"voice": voice,
"output_file": output_path,
"generation_time": generation_time,
"voices": AVAILABLE_VOICES
}
)
if __name__ == "__main__":
import uvicorn
print("🔥 Starting Orpheus-FASTAPI Server (CUDA)")
uvicorn.run("app:app", host="0.0.0.0", port=5005, reload=True)

15
requirements.txt Normal file
View file

@ -0,0 +1,15 @@
# Core dependencies
fastapi>=0.103.1
uvicorn>=0.23.2
jinja2>=3.1.2
pydantic>=2.3.0
numpy>=1.24.0
requests>=2.31.0
sounddevice>=0.4.6
python-multipart>=0.0.6
# SNAC is required for audio generation from tokens
snac>=0.3.0
# PyTorch - Note: Install PyTorch with CUDA 12.4 support separately:
# pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu124

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

488
templates/tts.html Normal file
View file

@ -0,0 +1,488 @@
<!DOCTYPE html>
<html lang="en" class="h-full bg-[#0f1729]">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Orpheus FASTAPI | Advanced Text-to-Speech</title>
<link rel="icon" href="/static/favicon.ico" type="image/x-icon">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
primary: {
50: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
300: '#7dd3fc',
400: '#38bdf8',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
},
purple: {
50: '#faf5ff',
100: '#f3e8ff',
200: '#e9d5ff',
300: '#d8b4fe',
400: '#c084fc',
500: '#a855f7',
600: '#9333ea',
700: '#7e22ce',
800: '#6b21a8',
900: '#581c87',
},
dark: {
50: '#f9fafb',
100: '#f3f4f6',
200: '#e5e7eb',
300: '#d1d5db',
400: '#9ca3af',
500: '#6b7280',
600: '#4b5563',
700: '#374151',
800: '#1f2937',
900: '#111827',
950: '#030712',
1000: '#0f1729'
}
}
}
}
}
</script>
<style type="text/tailwindcss">
@layer components {
.btn-primary {
@apply bg-primary-600 text-white px-4 py-2 rounded-md shadow-sm hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-dark-800 focus:ring-offset-2 transition-colors;
}
.voice-card {
@apply border border-dark-700 rounded-lg p-4 cursor-pointer transition-all hover:border-primary-400 hover:shadow-md bg-dark-800 text-dark-200;
}
.voice-card.active {
@apply border-primary-500 ring-2 ring-primary-500 bg-dark-700;
}
.audio-progress {
@apply h-2 w-full bg-dark-700 rounded-full overflow-hidden;
}
.audio-progress-bar {
@apply h-full bg-primary-500 transition-all duration-300;
}
}
</style>
<script src="https://cdn.jsdelivr.net/npm/wavesurfer.js@6/dist/wavesurfer.min.js"></script>
</head>
<body class="h-full">
<div class="min-h-full">
<!-- Navigation -->
<nav class="bg-gradient-to-r from-dark-900 to-purple-900 border-b border-purple-800 shadow-lg">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 items-center justify-between">
<div class="flex items-center">
<div class="flex-shrink-0">
<span class="text-white text-xl font-bold">Orpheus FASTAPI</span>
</div>
</div>
<div class="flex items-center space-x-4">
<a href="/docs" class="text-primary-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium">API Docs</a>
<a href="https://github.com/lex-au" target="_blank" class="text-primary-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium">GitHub</a>
</div>
</div>
</div>
</nav>
<!-- Main content -->
<main>
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<!-- Notification area -->
{% if error %}
<div class="mb-6 bg-red-50 border-l-4 border-red-400 p-4 rounded-md shadow-sm">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-red-700">{{ error }}</p>
</div>
</div>
</div>
{% endif %}
{% if success %}
<div class="mb-6 bg-green-50 border-l-4 border-green-400 p-4 rounded-md shadow-sm">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-green-700">Audio generated successfully in {{ generation_time }}s!</p>
</div>
</div>
</div>
{% endif %}
<!-- TTS form -->
<div class="bg-dark-800 shadow-lg rounded-lg overflow-hidden border border-dark-700">
<form id="tts-form" class="flex flex-col">
<div class="p-6">
<h2 class="text-lg font-medium text-white mb-4">Generate Speech</h2>
<!-- Text input -->
<div class="mb-6">
<label for="text" class="block text-sm font-medium text-white mb-1">Text to speak</label>
<div class="relative">
<textarea
name="text"
id="text"
rows="4"
class="block w-full rounded-md border-dark-600 bg-dark-700 text-white shadow-sm focus:border-primary-500 focus:ring-primary-500 focus:ring-offset-dark-800 sm:text-sm px-3 py-2"
placeholder="Enter text to convert to speech..."
required
>{{ text if text else "" }}</textarea>
<div class="absolute bottom-2 right-2 text-xs text-purple-300">
<span id="char-count">0</span> / 8192 characters
</div>
</div>
</div>
<!-- Voice selection -->
<div class="mb-6">
<label class="block text-sm font-medium text-white mb-2">Voice</label>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{% for voice_option in voices %}
<div class="voice-card {% if voice_option == DEFAULT_VOICE %}active{% endif %}" data-voice="{{ voice_option }}">
<input type="radio" name="voice" value="{{ voice_option }}" class="hidden" {% if voice_option == DEFAULT_VOICE %}checked{% endif %}>
<div class="flex items-center mb-2">
<span class="font-medium text-white">{{ voice_option|capitalize }}</span>
</div>
<div class="text-xs text-dark-300">
{% if voice_option == "tara" %}Female, conversational, clear
{% elif voice_option == "leah" %}Female, warm, gentle
{% elif voice_option == "jess" %}Female, energetic, youthful
{% elif voice_option == "leo" %}Male, authoritative, deep
{% elif voice_option == "dan" %}Male, friendly, casual
{% elif voice_option == "mia" %}Female, professional, articulate
{% elif voice_option == "zac" %}Male, enthusiastic, dynamic
{% elif voice_option == "zoe" %}Female, calm, soothing
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
<!-- Advanced options (can be expanded) -->
<div class="mb-6">
<details class="group">
<summary class="list-none flex cursor-pointer">
<span class="text-sm font-medium text-white">Advanced options</span>
<span class="ml-2 text-purple-300">
<svg class="group-open:rotate-180 h-5 w-5 transition-transform" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</span>
</summary>
<div class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="model" class="block text-sm font-medium text-white mb-1">Model</label>
<div class="relative">
<select id="model" name="model" class="block w-full rounded-md bg-dark-700 border-dark-600 text-white shadow-sm focus:border-primary-500 focus:ring-primary-500 focus:ring-offset-dark-800 focus:outline-none outline-none sm:text-sm pl-3 pr-10 py-2 appearance-none">
<option value="orpheus" selected>Orpheus 3B (0.1)</option>
</select>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-purple-300">
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</div>
</div>
</div>
<div>
<label for="speed" class="block text-sm font-medium text-white mb-1">Speed</label>
<input type="range" id="speed" name="speed" min="0.5" max="1.5" step="0.1" value="1.0"
class="mt-1 w-full h-2 bg-dark-600 rounded-lg appearance-none cursor-pointer">
<div class="flex justify-between text-xs text-purple-300 mt-1">
<span>Slower</span>
<span id="speed-value">1.0</span>
<span>Faster</span>
</div>
</div>
</div>
</details>
</div>
</div>
<div class="bg-dark-900 px-6 py-4 flex items-center justify-between">
<div class="text-sm text-purple-300">
<p>Supports emotion tags: <span class="font-mono text-xs">&lt;laugh&gt;</span>, <span class="font-mono text-xs">&lt;sigh&gt;</span>, etc.</p>
</div>
<button type="submit" id="generate-btn" class="btn-primary hover:bg-primary-600 active:bg-primary-800">
Generate Speech
</button>
</div>
</form>
</div>
<!-- Audio player container - will be populated by JavaScript -->
<div id="audio-player-container"></div>
<!-- Recent generations (could be expanded) -->
<div class="mt-8">
<h2 class="text-lg font-medium text-white mb-4">Tips & Tricks</h2>
<div class="bg-dark-800 shadow-lg rounded-lg overflow-hidden border border-dark-700">
<div class="p-6">
<ul class="list-disc pl-5 text-sm text-purple-300 space-y-2">
<li>Use <span class="font-mono text-xs">&lt;laugh&gt;</span> to add laughter to the speech</li>
<li>Use <span class="font-mono text-xs">&lt;sigh&gt;</span> for a sighing sound</li>
<li>Other supported tags: <span class="font-mono text-xs">&lt;chuckle&gt;</span>, <span class="font-mono text-xs">&lt;cough&gt;</span>, <span class="font-mono text-xs">&lt;sniffle&gt;</span>, <span class="font-mono text-xs">&lt;groan&gt;</span>, <span class="font-mono text-xs">&lt;yawn&gt;</span>, <span class="font-mono text-xs">&lt;gasp&gt;</span></li>
<li>For longer audio, the system can generate up to 2 minutes of speech in a single request</li>
<li>For API access, use the <code class="font-mono text-xs bg-dark-600 text-primary-300 p-1 rounded">/v1/audio/speech</code> endpoint (OpenAI compatible)</li>
</ul>
</div>
</div>
</div>
</div>
</main>
<!-- Footer -->
<footer class="bg-dark-900 border-t border-dark-700 py-6">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex justify-center">
<span class="text-purple-300 text-sm">Powered by <a href="https://fastapi.tiangolo.com/" target="_blank" class="text-primary-400 hover:text-primary-300">FASTAPI</a></span>
</div>
</div>
</footer>
</div>
<!-- Loading spinner template (hidden by default) -->
<div id="loading-overlay" class="hidden fixed inset-0 bg-dark-900 bg-opacity-75 flex items-center justify-center z-50">
<div class="bg-dark-800 p-6 rounded-lg shadow-lg flex flex-col items-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-500 mb-4"></div>
<p class="text-white text-lg">Generating audio...</p>
</div>
</div>
<!-- Audio player template (used for dynamic insertion) -->
<template id="audio-player-template">
<div class="mt-8 bg-dark-800 shadow-lg rounded-lg overflow-hidden border border-dark-700">
<div class="p-6">
<h2 class="text-lg font-medium text-white mb-4">Generated Audio</h2>
<div class="mb-6">
<div id="waveform" class="w-full h-24"></div>
</div>
<div class="flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center space-x-4">
<button id="play-btn" class="inline-flex items-center px-4 py-2 border border-primary-700 rounded-md shadow-sm text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-dark-800 focus:ring-offset-2">
<svg class="h-5 w-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
</svg>
Play
</button>
<a id="download-link" href="#" download class="inline-flex items-center px-4 py-2 border border-dark-600 rounded-md shadow-sm text-sm font-medium text-white bg-dark-700 hover:bg-dark-600 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-dark-800 focus:ring-offset-2">
<svg class="h-5 w-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
Download
</a>
</div>
<div class="text-sm text-purple-300">
Voice: <span id="voice-name" class="font-medium"></span>
Duration: <span id="audio-duration" class="font-medium">--:--</span>
Generated in: <span id="generation-time" class="font-medium"></span>s
</div>
</div>
</div>
</div>
</template>
<!-- JavaScript for interactivity -->
<script>
document.addEventListener('DOMContentLoaded', function() {
// Global variables
let wavesurfer;
// Character counter
const textArea = document.getElementById('text');
const charCount = document.getElementById('char-count');
textArea.addEventListener('input', function() {
charCount.textContent = textArea.value.length;
});
// Initialize char count
charCount.textContent = textArea.value.length;
// Voice selection
const voiceCards = document.querySelectorAll('.voice-card');
voiceCards.forEach(card => {
card.addEventListener('click', function() {
// Unselect all cards
voiceCards.forEach(c => c.classList.remove('active'));
// Select this card
this.classList.add('active');
// Check the radio button
const radio = this.querySelector('input[type="radio"]');
radio.checked = true;
});
});
// Speed slider
const speedSlider = document.getElementById('speed');
const speedValue = document.getElementById('speed-value');
speedSlider.addEventListener('input', function() {
speedValue.textContent = speedSlider.value;
});
// No preview buttons in this version
// Function to initialize WaveSurfer
function initWaveSurfer(audioPath) {
// If wavesurfer already exists, destroy it to prevent memory leaks
if (wavesurfer) {
wavesurfer.destroy();
}
// Create new wavesurfer instance
wavesurfer = WaveSurfer.create({
container: '#waveform',
waveColor: '#38bdf8',
progressColor: '#0284c7',
cursorColor: '#0ea5e9',
barWidth: 3,
barRadius: 3,
cursorWidth: 1,
height: 80,
barGap: 2,
responsive: true
});
wavesurfer.load('/' + audioPath);
wavesurfer.on('ready', function() {
const duration = wavesurfer.getDuration();
const minutes = Math.floor(duration / 60);
const seconds = Math.floor(duration % 60);
document.getElementById('audio-duration').textContent =
`${minutes}:${seconds < 10 ? '0' + seconds : seconds}`;
});
// Play button
const playBtn = document.getElementById('play-btn');
playBtn.addEventListener('click', function() {
wavesurfer.playPause();
if (wavesurfer.isPlaying()) {
playBtn.innerHTML = `
<svg class="h-5 w-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 00-1 1v2a1 1 0 001 1h6a1 1 0 001-1V9a1 1 0 00-1-1H7z" clip-rule="evenodd" />
</svg>
Pause
`;
} else {
playBtn.innerHTML = `
<svg class="h-5 w-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
</svg>
Play
`;
}
});
}
// Function to create and add the audio player to the DOM
function createAudioPlayer(response) {
const container = document.getElementById('audio-player-container');
const template = document.getElementById('audio-player-template');
// Clone the template
const audioPlayer = template.content.cloneNode(true);
// Clear previous player if exists
container.innerHTML = '';
// Add the new player
container.appendChild(audioPlayer);
// Set values
document.getElementById('voice-name').textContent = response.voice.charAt(0).toUpperCase() + response.voice.slice(1);
document.getElementById('generation-time').textContent = response.generation_time;
document.getElementById('download-link').href = '/' + response.output_file;
// Initialize waveform
initWaveSurfer(response.output_file);
}
// Form submission handler
const form = document.getElementById('tts-form');
form.addEventListener('submit', async function(event) {
// Prevent default form submission
event.preventDefault();
// Get form data
const text = document.getElementById('text').value;
const voice = document.querySelector('input[name="voice"]:checked').value;
if (!text.trim()) {
alert('Please enter some text to generate speech');
return;
}
// Show loading overlay
document.getElementById('loading-overlay').classList.remove('hidden');
try {
// Make API request to /speak endpoint
const response = await fetch('/speak', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
text: text,
voice: voice
})
});
if (!response.ok) {
throw new Error('Failed to generate speech');
}
const data = await response.json();
// Create audio player with response
createAudioPlayer(data);
} catch (error) {
console.error('Error generating speech:', error);
alert('An error occurred while generating speech. Please try again.');
} finally {
// Hide loading overlay
document.getElementById('loading-overlay').classList.add('hidden');
}
});
// Initialize audio player if output file exists from server-side rendering
{% if output_file %}
createAudioPlayer({
voice: "{{ voice if voice else DEFAULT_VOICE }}",
output_file: "{{ output_file }}",
generation_time: "{{ generation_time }}"
});
{% endif %}
});
</script>
</body>
</html>

15
tts_engine/__init__.py Normal file
View file

@ -0,0 +1,15 @@
"""
TTS Engine package for Orpheus text-to-speech system.
This package contains the core components for audio generation:
- inference.py: Token generation and API handling
- speechpipe.py: Audio conversion pipeline
"""
# Make key components available at package level
from .inference import (
generate_speech_from_api,
AVAILABLE_VOICES,
DEFAULT_VOICE,
list_available_voices
)

561
tts_engine/inference.py Normal file
View file

@ -0,0 +1,561 @@
import os
import sys
import requests
import json
import time
import wave
import numpy as np
import sounddevice as sd
import argparse
import threading
import queue
import asyncio
from concurrent.futures import ThreadPoolExecutor
from typing import List, Dict, Any, Optional, Generator, Union, Tuple
# Detect if we're on a high-end system like RTX 4090
import torch
HIGH_END_GPU = False
if torch.cuda.is_available():
gpu_name = torch.cuda.get_device_name(0).lower()
if any(x in gpu_name for x in ['4090', '3090', 'a100', 'h100']):
HIGH_END_GPU = True
print(f"High-end GPU detected: {torch.cuda.get_device_name(0)}")
print("Enabling high-performance optimizations")
# Orpheus-FASTAPI settings - make configurable for different endpoints
API_URL = os.environ.get("ORPHEUS_API_URL", "http://your-server-ip:port/v1/completions or v1/chat/completions")
HEADERS = {
"Content-Type": "application/json"
}
# Better timeout handling for API requests
REQUEST_TIMEOUT = int(os.environ.get("ORPHEUS_API_TIMEOUT", "120")) # 120 seconds default for long generations
# Model parameters - optimized defaults for high-end GPUs
MAX_TOKENS = 8192 if HIGH_END_GPU else 1200 # Significantly increased for RTX 4090 to allow ~1.5-2 minutes of audio
TEMPERATURE = 0.6
TOP_P = 0.9
REPETITION_PENALTY = 1.1
SAMPLE_RATE = 24000 # SNAC model uses 24kHz
# Parallel processing settings
NUM_WORKERS = 4 if HIGH_END_GPU else 2
# Available voices based on the Orpheus-TTS repository
AVAILABLE_VOICES = ["tara", "leah", "jess", "leo", "dan", "mia", "zac", "zoe"]
DEFAULT_VOICE = "tara" # Best voice according to documentation
# Special token IDs for Orpheus model
START_TOKEN_ID = 128259
END_TOKEN_IDS = [128009, 128260, 128261, 128257]
CUSTOM_TOKEN_PREFIX = "<custom_token_"
# Performance monitoring
class PerformanceMonitor:
"""Track and report performance metrics"""
def __init__(self):
self.start_time = time.time()
self.token_count = 0
self.audio_chunks = 0
self.last_report_time = time.time()
self.report_interval = 2.0 # seconds
def add_tokens(self, count: int = 1) -> None:
self.token_count += count
self._check_report()
def add_audio_chunk(self) -> None:
self.audio_chunks += 1
self._check_report()
def _check_report(self) -> None:
current_time = time.time()
if current_time - self.last_report_time >= self.report_interval:
self.report()
self.last_report_time = current_time
def report(self) -> None:
elapsed = time.time() - self.start_time
if elapsed < 0.001:
return
tokens_per_sec = self.token_count / elapsed
chunks_per_sec = self.audio_chunks / elapsed
# Estimate audio duration based on audio chunks (each chunk is ~0.085s of audio)
est_duration = self.audio_chunks * 0.085
print(f"Progress: {tokens_per_sec:.1f} tokens/sec, est. {est_duration:.1f}s audio generated, {self.token_count} tokens, {self.audio_chunks} chunks in {elapsed:.1f}s")
# Create global performance monitor
perf_monitor = PerformanceMonitor()
def format_prompt(prompt: str, voice: str = DEFAULT_VOICE) -> str:
"""Format prompt for Orpheus model with voice prefix and special tokens."""
# Validate voice and provide fallback
if voice not in AVAILABLE_VOICES:
print(f"Warning: Voice '{voice}' not recognized. Using '{DEFAULT_VOICE}' instead.")
voice = DEFAULT_VOICE
# Format similar to how engine_class.py does it with special tokens
formatted_prompt = f"{voice}: {prompt}"
# Add special token markers for the Orpheus-FASTAPI
special_start = "<|audio|>" # Using the additional_special_token from config
special_end = "<|eot_id|>" # Using the eos_token from config
return f"{special_start}{formatted_prompt}{special_end}"
def generate_tokens_from_api(prompt: str, voice: str = DEFAULT_VOICE, temperature: float = TEMPERATURE,
top_p: float = TOP_P, max_tokens: int = MAX_TOKENS,
repetition_penalty: float = REPETITION_PENALTY) -> Generator[str, None, None]:
"""Generate tokens from text using OpenAI-compatible API with optimized streaming and retry logic."""
start_time = time.time()
formatted_prompt = format_prompt(prompt, voice)
print(f"Generating speech for: {formatted_prompt}")
# Optimize the token generation for high-end GPUs
if HIGH_END_GPU:
# Use more aggressive parameters for faster generation on high-end GPUs
print("Using optimized parameters for high-end GPU")
# Create the request payload
payload = {
"model": "orpheus-3b-0.1-ft-q4_k_m", # Model name can be anything, endpoint will use loaded model
"prompt": formatted_prompt,
"max_tokens": max_tokens,
"temperature": temperature,
"top_p": top_p,
"repeat_penalty": repetition_penalty,
"stream": True # Always stream for better performance
}
# Session for connection pooling and retry logic
session = requests.Session()
retry_count = 0
max_retries = 3
while retry_count < max_retries:
try:
# Make the API request with streaming and timeout
response = session.post(
API_URL,
headers=HEADERS,
json=payload,
stream=True,
timeout=REQUEST_TIMEOUT
)
if response.status_code != 200:
print(f"Error: API request failed with status code {response.status_code}")
print(f"Error details: {response.text}")
# Retry on server errors (5xx) but not on client errors (4xx)
if response.status_code >= 500:
retry_count += 1
wait_time = 2 ** retry_count # Exponential backoff
print(f"Retrying in {wait_time} seconds...")
time.sleep(wait_time)
continue
return
# Process the streamed response with better buffering
buffer = ""
token_counter = 0
# Iterate through the response to get tokens
for line in response.iter_lines():
if line:
line_str = line.decode('utf-8')
if line_str.startswith('data: '):
data_str = line_str[6:] # Remove the 'data: ' prefix
if data_str.strip() == '[DONE]':
break
try:
data = json.loads(data_str)
if 'choices' in data and len(data['choices']) > 0:
token_text = data['choices'][0].get('text', '')
token_counter += 1
perf_monitor.add_tokens()
if token_text:
yield token_text
except json.JSONDecodeError as e:
print(f"Error decoding JSON: {e}")
continue
# Generation completed successfully
generation_time = time.time() - start_time
tokens_per_second = token_counter / generation_time if generation_time > 0 else 0
print(f"Token generation complete: {token_counter} tokens in {generation_time:.2f}s ({tokens_per_second:.1f} tokens/sec)")
return
except requests.exceptions.Timeout:
print(f"Request timed out after {REQUEST_TIMEOUT} seconds")
retry_count += 1
if retry_count < max_retries:
wait_time = 2 ** retry_count
print(f"Retrying in {wait_time} seconds... (attempt {retry_count+1}/{max_retries})")
time.sleep(wait_time)
else:
print("Max retries reached. Token generation failed.")
return
except requests.exceptions.ConnectionError:
print(f"Connection error to API at {API_URL}")
retry_count += 1
if retry_count < max_retries:
wait_time = 2 ** retry_count
print(f"Retrying in {wait_time} seconds... (attempt {retry_count+1}/{max_retries})")
time.sleep(wait_time)
else:
print("Max retries reached. Token generation failed.")
return
# Token ID cache to avoid repeated processing
token_id_cache = {}
MAX_CACHE_SIZE = 10000
def turn_token_into_id(token_string: str, index: int) -> Optional[int]:
"""Optimized token-to-ID conversion with caching."""
# Check cache first (significant speedup for repeated tokens)
cache_key = (token_string, index % 7)
if cache_key in token_id_cache:
return token_id_cache[cache_key]
# Early rejection for obvious non-matches
if CUSTOM_TOKEN_PREFIX not in token_string:
return None
# Process token
token_string = token_string.strip()
last_token_start = token_string.rfind(CUSTOM_TOKEN_PREFIX)
if last_token_start == -1:
return None
last_token = token_string[last_token_start:]
if not (last_token.startswith(CUSTOM_TOKEN_PREFIX) and last_token.endswith(">")):
return None
try:
number_str = last_token[14:-1]
token_id = int(number_str) - 10 - ((index % 7) * 4096)
# Cache the result if it's valid
if len(token_id_cache) < MAX_CACHE_SIZE:
token_id_cache[cache_key] = token_id
return token_id
except (ValueError, IndexError):
return None
def convert_to_audio(multiframe: List[int], count: int) -> Optional[bytes]:
"""Convert token frames to audio with performance monitoring."""
# Import here to avoid circular imports
from .speechpipe import convert_to_audio as orpheus_convert_to_audio
start_time = time.time()
result = orpheus_convert_to_audio(multiframe, count)
if result is not None:
perf_monitor.add_audio_chunk()
return result
async def tokens_decoder(token_gen) -> Generator[bytes, None, None]:
"""Simplified token decoder without complex ring buffer to ensure reliable output."""
buffer = []
count = 0
# Use conservative batch parameters to ensure output quality
min_frames = 28 # Default for reliability (4 chunks of 7)
process_every = 7 # Process every 7 tokens (standard for Orpheus)
start_time = time.time()
last_log_time = start_time
token_count = 0
async for token_text in token_gen:
token = turn_token_into_id(token_text, count)
if token is not None and token > 0:
# Add to buffer using simple append (reliable method)
buffer.append(token)
count += 1
token_count += 1
# Log throughput periodically
current_time = time.time()
if current_time - last_log_time > 5.0: # Every 5 seconds
elapsed = current_time - start_time
if elapsed > 0:
print(f"Token processing rate: {token_count/elapsed:.1f} tokens/second")
last_log_time = current_time
# Process in standard batches for Orpheus model
if count % process_every == 0 and count >= min_frames:
# Use simple slice operation - reliable and correct
buffer_to_proc = buffer[-min_frames:]
# Debug output to help diagnose issues
if count % 28 == 0:
print(f"Processing buffer with {len(buffer_to_proc)} tokens, total collected: {len(buffer)}")
# Process the tokens
audio_samples = convert_to_audio(buffer_to_proc, count)
if audio_samples is not None:
yield audio_samples
def tokens_decoder_sync(syn_token_gen, output_file=None):
"""Optimized synchronous wrapper with parallel processing and efficient file I/O."""
# Use a larger queue for high-end systems
queue_size = 100 if HIGH_END_GPU else 50
audio_queue = queue.Queue(maxsize=queue_size)
audio_segments = []
# If output_file is provided, prepare WAV file with buffered I/O
wav_file = None
if output_file:
# Create directory if it doesn't exist
os.makedirs(os.path.dirname(os.path.abspath(output_file)), exist_ok=True)
wav_file = wave.open(output_file, "wb")
wav_file.setnchannels(1)
wav_file.setsampwidth(2)
wav_file.setframerate(SAMPLE_RATE)
# Batch processing of tokens for improved throughput
batch_size = 32 if HIGH_END_GPU else 16
# Convert the synchronous token generator into an async generator with batching
async def async_token_gen():
batch = []
for token in syn_token_gen:
batch.append(token)
if len(batch) >= batch_size:
for t in batch:
yield t
batch = []
# Process any remaining tokens
for t in batch:
yield t
async def async_producer():
# Track performance with more granular metrics
start_time = time.time()
chunk_count = 0
last_log_time = start_time
async for audio_chunk in tokens_decoder(async_token_gen()):
audio_queue.put(audio_chunk)
chunk_count += 1
# Log performance periodically
current_time = time.time()
if current_time - last_log_time >= 3.0: # Every 3 seconds
elapsed = current_time - start_time
if elapsed > 0:
chunks_per_sec = chunk_count / elapsed
print(f"Audio generation rate: {chunks_per_sec:.2f} chunks/second")
last_log_time = current_time
# Signal completion
audio_queue.put(None)
def run_async():
asyncio.run(async_producer())
# Use a separate thread with higher priority for producer
thread = threading.Thread(target=run_async)
thread.daemon = True # Allow thread to be terminated when main thread exits
thread.start()
# For high-end GPUs, use a ThreadPoolExecutor for parallel file I/O
if HIGH_END_GPU and wav_file:
# Buffer for collecting chunks before writing
write_buffer = []
buffer_size = 10 # Write every 10 chunks
def write_chunks_to_file(chunks, file):
for chunk in chunks:
file.writeframes(chunk)
with ThreadPoolExecutor(max_workers=2) as executor:
future = None
while True:
audio = audio_queue.get()
if audio is None:
# Write any remaining buffered chunks
if write_buffer and wav_file:
if future:
future.result() # Wait for previous write to complete
write_chunks_to_file(write_buffer, wav_file)
break
audio_segments.append(audio)
if wav_file:
write_buffer.append(audio)
if len(write_buffer) >= buffer_size:
if future:
future.result() # Wait for previous write to complete
# Write in a separate thread to avoid blocking
chunks_to_write = write_buffer
write_buffer = []
future = executor.submit(write_chunks_to_file, chunks_to_write, wav_file)
else:
# Simpler direct approach for lower-end systems
while True:
audio = audio_queue.get()
if audio is None:
break
audio_segments.append(audio)
# Write to WAV file if provided
if wav_file:
wav_file.writeframes(audio)
# Close WAV file if opened
if wav_file:
wav_file.close()
thread.join()
# Calculate and print detailed performance metrics
if audio_segments:
total_bytes = sum(len(segment) for segment in audio_segments)
duration = total_bytes / (2 * SAMPLE_RATE) # 2 bytes per sample at 24kHz
total_time = time.time() - perf_monitor.start_time
realtime_factor = duration / total_time if total_time > 0 else 0
print(f"Generated {len(audio_segments)} audio segments")
print(f"Generated {duration:.2f} seconds of audio in {total_time:.2f} seconds")
print(f"Realtime factor: {realtime_factor:.2f}x")
if realtime_factor < 1.0:
print("⚠️ Warning: Generation is slower than realtime")
else:
print(f"✓ Generation is {realtime_factor:.1f}x faster than realtime")
return audio_segments
def stream_audio(audio_buffer):
"""Stream audio buffer to output device with error handling."""
if audio_buffer is None or len(audio_buffer) == 0:
return
try:
# Convert bytes to NumPy array (16-bit PCM)
audio_data = np.frombuffer(audio_buffer, dtype=np.int16)
# Normalize to float in range [-1, 1] for playback
audio_float = audio_data.astype(np.float32) / 32767.0
# Play the audio with proper device selection and error handling
sd.play(audio_float, SAMPLE_RATE)
sd.wait()
except Exception as e:
print(f"Audio playback error: {e}")
def generate_speech_from_api(prompt, voice=DEFAULT_VOICE, output_file=None, temperature=TEMPERATURE,
top_p=TOP_P, max_tokens=MAX_TOKENS, repetition_penalty=REPETITION_PENALTY):
"""Generate speech from text using Orpheus model with performance optimizations."""
print(f"Starting speech generation for '{prompt[:50]}{'...' if len(prompt) > 50 else ''}'")
print(f"Using voice: {voice}, GPU acceleration: {'Yes (High-end)' if HIGH_END_GPU else 'Yes' if torch.cuda.is_available() else 'No'}")
# Reset performance monitor
global perf_monitor
perf_monitor = PerformanceMonitor()
start_time = time.time()
# Generate speech with optimized settings
result = tokens_decoder_sync(
generate_tokens_from_api(
prompt=prompt,
voice=voice,
temperature=temperature,
top_p=top_p,
max_tokens=max_tokens,
repetition_penalty=repetition_penalty
),
output_file=output_file
)
# Report final performance metrics
end_time = time.time()
total_time = end_time - start_time
print(f"Total speech generation completed in {total_time:.2f} seconds")
return result
def list_available_voices():
"""List all available voices with the recommended one marked."""
print("Available voices (in order of conversational realism):")
for i, voice in enumerate(AVAILABLE_VOICES):
marker = "" if voice == DEFAULT_VOICE else " "
print(f"{marker} {voice}")
print(f"\nDefault voice: {DEFAULT_VOICE}")
print("\nAvailable emotion tags:")
print("<laugh>, <chuckle>, <sigh>, <cough>, <sniffle>, <groan>, <yawn>, <gasp>")
def main():
# Parse command line arguments
parser = argparse.ArgumentParser(description="Orpheus Text-to-Speech using Orpheus-FASTAPI")
parser.add_argument("--text", type=str, help="Text to convert to speech")
parser.add_argument("--voice", type=str, default=DEFAULT_VOICE, help=f"Voice to use (default: {DEFAULT_VOICE})")
parser.add_argument("--output", type=str, help="Output WAV file path")
parser.add_argument("--list-voices", action="store_true", help="List available voices")
parser.add_argument("--temperature", type=float, default=TEMPERATURE, help="Temperature for generation")
parser.add_argument("--top_p", type=float, default=TOP_P, help="Top-p sampling parameter")
parser.add_argument("--repetition_penalty", type=float, default=REPETITION_PENALTY,
help="Repetition penalty (>=1.1 required for stable generation)")
args = parser.parse_args()
if args.list_voices:
list_available_voices()
return
# Use text from command line or prompt user
prompt = args.text
if not prompt:
if len(sys.argv) > 1 and sys.argv[1] not in ("--voice", "--output", "--temperature", "--top_p", "--repetition_penalty"):
prompt = " ".join([arg for arg in sys.argv[1:] if not arg.startswith("--")])
else:
prompt = input("Enter text to synthesize: ")
if not prompt:
prompt = "Hello, I am Orpheus, an AI assistant with emotional speech capabilities."
# Default output file if none provided
output_file = args.output
if not output_file:
# Create outputs directory if it doesn't exist
os.makedirs("outputs", exist_ok=True)
# Generate a filename based on the voice and a timestamp
timestamp = time.strftime("%Y%m%d_%H%M%S")
output_file = f"outputs/{args.voice}_{timestamp}.wav"
print(f"No output file specified. Saving to {output_file}")
# Generate speech
start_time = time.time()
audio_segments = generate_speech_from_api(
prompt=prompt,
voice=args.voice,
temperature=args.temperature,
top_p=args.top_p,
repetition_penalty=args.repetition_penalty,
output_file=output_file
)
end_time = time.time()
print(f"Speech generation completed in {end_time - start_time:.2f} seconds")
print(f"Audio saved to {output_file}")
if __name__ == "__main__":
main()

257
tts_engine/speechpipe.py Normal file
View file

@ -0,0 +1,257 @@
from snac import SNAC
import numpy as np
import torch
import asyncio
import threading
import queue
import time
# Try to enable torch.compile if PyTorch 2.0+ is available
TORCH_COMPILE_AVAILABLE = False
try:
if hasattr(torch, 'compile'):
TORCH_COMPILE_AVAILABLE = True
print("PyTorch 2.0+ detected, torch.compile is available")
except:
pass
# Try to enable CUDA graphs if available
CUDA_GRAPHS_AVAILABLE = False
try:
if torch.cuda.is_available() and hasattr(torch.cuda, 'make_graphed_callables'):
CUDA_GRAPHS_AVAILABLE = True
print("CUDA graphs support is available")
except:
pass
model = SNAC.from_pretrained("hubertsiuzdak/snac_24khz").eval()
# Check if CUDA is available and set device accordingly
snac_device = "cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu"
print(f"Using device: {snac_device}")
model = model.to(snac_device)
# Disable torch.compile as it requires Triton which isn't installed
# We'll use regular PyTorch optimization techniques instead
print("Using standard PyTorch optimizations (torch.compile disabled)")
# Prepare CUDA streams for parallel processing if available
cuda_stream = None
if snac_device == "cuda":
cuda_stream = torch.cuda.Stream()
print("Using CUDA stream for parallel processing")
def convert_to_audio(multiframe, count):
"""
Optimized version of convert_to_audio that eliminates inefficient tensor operations
and reduces CPU-GPU transfers for much faster inference on high-end GPUs.
"""
if len(multiframe) < 7:
return None
num_frames = len(multiframe) // 7
frame = multiframe[:num_frames*7]
# Pre-allocate tensors instead of incrementally building them
codes_0 = torch.zeros(num_frames, dtype=torch.int32, device=snac_device)
codes_1 = torch.zeros(num_frames * 2, dtype=torch.int32, device=snac_device)
codes_2 = torch.zeros(num_frames * 4, dtype=torch.int32, device=snac_device)
# Use vectorized operations where possible
frame_tensor = torch.tensor(frame, dtype=torch.int32, device=snac_device)
# Direct indexing is much faster than concatenation in a loop
for j in range(num_frames):
idx = j * 7
# Code 0 - single value per frame
codes_0[j] = frame_tensor[idx]
# Code 1 - two values per frame
codes_1[j*2] = frame_tensor[idx+1]
codes_1[j*2+1] = frame_tensor[idx+4]
# Code 2 - four values per frame
codes_2[j*4] = frame_tensor[idx+2]
codes_2[j*4+1] = frame_tensor[idx+3]
codes_2[j*4+2] = frame_tensor[idx+5]
codes_2[j*4+3] = frame_tensor[idx+6]
# Reshape codes into expected format
codes = [
codes_0.unsqueeze(0),
codes_1.unsqueeze(0),
codes_2.unsqueeze(0)
]
# Check tokens are in valid range
if (torch.any(codes[0] < 0) or torch.any(codes[0] > 4096) or
torch.any(codes[1] < 0) or torch.any(codes[1] > 4096) or
torch.any(codes[2] < 0) or torch.any(codes[2] > 4096)):
return None
# Use CUDA stream for parallel processing if available
stream_ctx = torch.cuda.stream(cuda_stream) if cuda_stream is not None else torch.no_grad()
with stream_ctx, torch.inference_mode():
# Decode the audio
audio_hat = model.decode(codes)
# Extract the relevant slice and efficiently convert to bytes
# Keep data on GPU as long as possible
audio_slice = audio_hat[:, :, 2048:4096]
# Process on GPU if possible, with minimal data transfer
if snac_device == "cuda":
# Scale directly on GPU
audio_int16_tensor = (audio_slice * 32767).to(torch.int16)
# Only transfer the final result to CPU
audio_bytes = audio_int16_tensor.cpu().numpy().tobytes()
else:
# For non-CUDA devices, fall back to the original approach
detached_audio = audio_slice.detach().cpu()
audio_np = detached_audio.numpy()
audio_int16 = (audio_np * 32767).astype(np.int16)
audio_bytes = audio_int16.tobytes()
return audio_bytes
def turn_token_into_id(token_string, index):
"""Optimized token-to-id conversion with early returns and minimal string operations"""
token_string = token_string.strip()
# Early return for obvious mismatches
if "<custom_token_" not in token_string:
return None
# Find the last token in the string
last_token_start = token_string.rfind("<custom_token_")
if last_token_start == -1:
return None
# Check if the token ends properly
if not token_string.endswith(">"):
return None
try:
# Extract and convert the number directly
number_str = token_string[last_token_start+14:-1]
return int(number_str) - 10 - ((index % 7) * 4096)
except (ValueError, IndexError):
return None
# Cache for frequently processed tokens to avoid redundant computation
token_cache = {}
MAX_CACHE_SIZE = 1000 # Limit cache size to prevent memory bloat
async def tokens_decoder(token_gen):
"""Optimized token decoder with caching and conservative batch processing to ensure correct output"""
buffer = []
count = 0
# Start with conservative parameters to ensure we get audio output
min_frames_required = 28 # Default minimum frames (4 chunks of 7)
process_every_n = 7 # Process every 7 tokens
start_time = time.time()
token_count = 0
async for token_sim in token_gen:
token_count += 1
# Check cache first to avoid redundant computation
cache_key = (token_sim, count % 7)
if cache_key in token_cache:
token = token_cache[cache_key]
else:
token = turn_token_into_id(token_sim, count)
# Add to cache if valid token
if token is not None and len(token_cache) < MAX_CACHE_SIZE:
token_cache[cache_key] = token
if token is not None and token > 0:
buffer.append(token)
count += 1
# Process in larger batches for better GPU utilization
if count % process_every_n == 0 and count >= min_frames_required:
buffer_to_proc = buffer[-min_frames_required:]
audio_samples = convert_to_audio(buffer_to_proc, count)
if audio_samples is not None:
# Log processing rate occasionally
if count % 140 == 0: # Log every 20 chunks (assuming process_every_n=7)
elapsed = time.time() - start_time
tokens_per_sec = token_count / elapsed if elapsed > 0 else 0
print(f"Processing speed: {tokens_per_sec:.1f} tokens/sec, buffer size: {len(buffer)}")
yield audio_samples
# ------------------ Synchronous Tokens Decoder Wrapper ------------------ #
def tokens_decoder_sync(syn_token_gen):
"""Optimized synchronous decoder with larger queue and parallel processing"""
# Use a larger queue for RTX 4090 to maximize GPU utilization
max_queue_size = 32 if snac_device == "cuda" else 8
audio_queue = queue.Queue(maxsize=max_queue_size)
# Collect tokens in batches for higher throughput
batch_size = 16 if snac_device == "cuda" else 4
# Convert the synchronous token generator into an async generator with batching
async def async_token_gen():
token_batch = []
for token in syn_token_gen:
token_batch.append(token)
# Process in batches for efficiency
if len(token_batch) >= batch_size:
for t in token_batch:
yield t
token_batch = []
# Process any remaining tokens
for t in token_batch:
yield t
async def async_producer():
# Start timer for performance logging
start_time = time.time()
chunk_count = 0
# Process audio chunks from the token decoder
async for audio_chunk in tokens_decoder(async_token_gen()):
audio_queue.put(audio_chunk)
chunk_count += 1
# Log performance stats periodically
if chunk_count % 10 == 0:
elapsed = time.time() - start_time
print(f"Generated {chunk_count} chunks in {elapsed:.2f}s ({chunk_count/elapsed:.2f} chunks/sec)")
# Signal completion
audio_queue.put(None) # Sentinel
def run_async():
asyncio.run(async_producer())
# Use a higher priority thread for RTX 4090 to ensure it stays fed with work
thread = threading.Thread(target=run_async)
thread.daemon = True # Allow the thread to be terminated when the main thread exits
thread.start()
# Use larger buffer for final audio assembly
buffer_size = 5
audio_buffer = []
while True:
audio = audio_queue.get()
if audio is None:
break
audio_buffer.append(audio)
# Yield buffered audio chunks for smoother playback
if len(audio_buffer) >= buffer_size:
for chunk in audio_buffer:
yield chunk
audio_buffer = []
# Yield any remaining audio in the buffer
for chunk in audio_buffer:
yield chunk
thread.join()