orpheus-fastapi/app.py
2025-04-02 14:49:52 -04:00

382 lines
13 KiB
Python

# Orpheus-FASTAPI by Lex-au
# https://github.com/Lex-au/Orpheus-FastAPI
# Description: Main FastAPI server for Orpheus Text-to-Speech
import os
import time
import asyncio
from datetime import datetime
from typing import List, Optional
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from dotenv import load_dotenv
# Function to ensure .env file exists
def ensure_env_file_exists():
"""Create a default .env file if one doesn't exist"""
if not os.path.exists(".env") and os.path.exists(".env.example"):
try:
# Copy .env.example to .env
with open(".env.example", "r") as example_file:
with open(".env", "w") as env_file:
env_file.write(example_file.read())
print("✅ Created default configuration file at .env")
except Exception as e:
print(f"⚠️ Error creating default .env file: {e}")
# Ensure .env file exists before loading environment variables
ensure_env_file_exists()
# Load environment variables from .env file
load_dotenv(override=True)
from fastapi import FastAPI, Request, Form, HTTPException, Depends, Security
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
import json
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"
)
# We'll use FastAPI's built-in startup complete mechanism
# The log message "INFO: Application startup complete." indicates
# that the application is ready
# 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 key authentication
security = HTTPBearer(auto_error=False)
# Get API key from environment
API_KEY = os.environ.get("ORPHEUS_API_KEY")
# Function to verify API key
async def verify_api_key(credentials: HTTPAuthorizationCredentials = Security(security)):
"""
Verify the API key from the Authorization header.
If no API key is configured, authentication is skipped.
"""
# If no API key is configured, skip authentication
if not API_KEY:
return True
# If API key is configured but no credentials provided, raise 401
if not credentials:
raise HTTPException(
status_code=401,
detail="Missing API key. Please provide an API key in the Authorization header."
)
# Verify the API key
if credentials.credentials != API_KEY:
raise HTTPException(status_code=401, detail="Invalid API key")
# 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, authorized: bool = Depends(verify_api_key)):
"""
Generate speech from text using the Orpheus TTS model.
Compatible with OpenAI's /v1/audio/speech endpoint.
For longer texts (>1000 characters), batched generation is used
to improve reliability and avoid truncation issues.
"""
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"
# Check if we should use batched generation
use_batching = len(request.input) > 1000
if use_batching:
print(f"Using batched generation for long text ({len(request.input)} characters)")
# Generate speech with automatic batching for long texts
start = time.time()
generate_speech_from_api(
prompt=request.input,
voice=request.voice,
output_file=output_path,
use_batching=use_batching,
max_batch_chars=1000 # Process in ~1000 character chunks (roughly 1 paragraph)
)
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, authorized: bool = Depends(verify_api_key)):
"""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"
# Check if we should use batched generation for longer texts
use_batching = len(text) > 1000
if use_batching:
print(f"Using batched generation for long text ({len(text)} characters)")
# Generate speech with batching for longer texts
start = time.time()
generate_speech_from_api(
prompt=text,
voice=voice,
output_file=output_path,
use_batching=use_batching,
max_batch_chars=1000
)
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"""
# Get current config for the Web UI
config = get_current_config()
return templates.TemplateResponse(
"tts.html",
{"request": request, "voices": AVAILABLE_VOICES, "config": config}
)
@app.get("/get_config")
async def get_config():
"""Get current configuration from .env file or defaults"""
config = get_current_config()
return JSONResponse(content=config)
@app.post("/save_config")
async def save_config(request: Request):
"""Save configuration to .env file"""
data = await request.json()
# Convert values to proper types
for key, value in data.items():
if key in ["ORPHEUS_MAX_TOKENS", "ORPHEUS_API_TIMEOUT", "ORPHEUS_PORT", "ORPHEUS_SAMPLE_RATE"]:
try:
data[key] = str(int(value))
except (ValueError, TypeError):
pass
elif key in ["ORPHEUS_TEMPERATURE", "ORPHEUS_TOP_P"]: # Removed ORPHEUS_REPETITION_PENALTY since it's hardcoded now
try:
data[key] = str(float(value))
except (ValueError, TypeError):
pass
# Write configuration to .env file
with open(".env", "w") as f:
for key, value in data.items():
f.write(f"{key}={value}\n")
return JSONResponse(content={"status": "ok", "message": "Configuration saved successfully. Restart server to apply changes."})
@app.post("/restart_server")
async def restart_server():
"""Restart the server by touching a file that triggers Uvicorn's reload"""
import threading
def touch_restart_file():
# Wait a moment to let the response get back to the client
time.sleep(0.5)
# Create or update restart.flag file to trigger reload
restart_file = "restart.flag"
with open(restart_file, "w") as f:
f.write(str(time.time()))
print("🔄 Restart flag created, server will reload momentarily...")
# Start the touch operation in a separate thread
threading.Thread(target=touch_restart_file, daemon=True).start()
# Return success response
return JSONResponse(content={"status": "ok", "message": "Server is restarting. Please wait a moment..."})
def get_current_config():
"""Read current configuration from .env.example and .env files"""
# Default config from .env.example
default_config = {}
if os.path.exists(".env.example"):
with open(".env.example", "r") as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, value = line.split("=", 1)
default_config[key] = value
# Current config from .env
current_config = {}
if os.path.exists(".env"):
with open(".env", "r") as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, value = line.split("=", 1)
current_config[key] = value
# Merge configs, with current taking precedence
config = {**default_config, **current_config}
# Add current environment variables
for key in config:
env_value = os.environ.get(key)
if env_value is not None:
config[key] = env_value
return config
@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"
# Check if we should use batched generation for longer texts
use_batching = len(text) > 1000
if use_batching:
print(f"Using batched generation for long text from web form ({len(text)} characters)")
# Generate speech with batching for longer texts
start = time.time()
generate_speech_from_api(
prompt=text,
voice=voice,
output_file=output_path,
use_batching=use_batching,
max_batch_chars=1000
)
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
# Check for required settings
required_settings = ["ORPHEUS_HOST", "ORPHEUS_PORT"]
missing_settings = [s for s in required_settings if s not in os.environ]
if missing_settings:
print(f"⚠️ Missing environment variable(s): {', '.join(missing_settings)}")
print(" Using fallback values for server startup.")
# Get host and port from environment variables with better error handling
try:
host = os.environ.get("ORPHEUS_HOST")
if not host:
print("⚠️ ORPHEUS_HOST not set, using 0.0.0.0 as fallback")
host = "0.0.0.0"
except Exception:
print("⚠️ Error reading ORPHEUS_HOST, using 0.0.0.0 as fallback")
host = "0.0.0.0"
try:
port = int(os.environ.get("ORPHEUS_PORT", "5005"))
except (ValueError, TypeError):
print("⚠️ Invalid ORPHEUS_PORT value, using 5005 as fallback")
port = 5005
print(f"🔥 Starting Orpheus-FASTAPI Server on {host}:{port}")
print(f"💬 Web UI available at http://{host if host != '0.0.0.0' else 'localhost'}:{port}")
print(f"📖 API docs available at http://{host if host != '0.0.0.0' else 'localhost'}:{port}/docs")
# Read current API_URL for user information
api_url = os.environ.get("ORPHEUS_API_URL")
if not api_url:
print("⚠️ ORPHEUS_API_URL not set. Please configure in .env file before generating speech.")
else:
print(f"🔗 Using LLM inference server at: {api_url}")
# Include restart.flag in the reload_dirs to monitor it for changes
extra_files = ["restart.flag"] if os.path.exists("restart.flag") else []
# Start with reload enabled to allow automatic restart when restart.flag changes
uvicorn.run("app:app", host=host, port=port, reload=True, reload_dirs=["."], reload_includes=["*.py", "*.html", "restart.flag"])