# 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"])