This major update brings comprehensive multilingual support to Orpheus-FASTAPI: • Added 16 new voice actors across 7 languages (French, German, Korean, Hindi, Mandarin, Spanish, Italian) • Released 6 language-specific optimized models for superior pronunciation and fluency • Enhanced UI with dynamic language selector and voice filtering • Updated Docker Compose workflow to simplify language-specific model deployment • Reorganized code architecture with language-aware voice mappings All voices support the same emotion tags and audio quality features as the original English voices, maintaining compatibility with existing applications while expanding international usability.
931 lines
38 KiB
Python
931 lines
38 KiB
Python
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
|
|
from dotenv import load_dotenv
|
|
|
|
# Helper to detect if running in Uvicorn's reloader
|
|
def is_reloader_process():
|
|
"""Check if the current process is a uvicorn reloader"""
|
|
return (sys.argv[0].endswith('_continuation.py') or
|
|
os.environ.get('UVICORN_STARTED') == 'true')
|
|
|
|
# Set a flag to avoid repeat messages
|
|
IS_RELOADER = is_reloader_process()
|
|
if not IS_RELOADER:
|
|
os.environ['UVICORN_STARTED'] = 'true'
|
|
|
|
# Load environment variables from .env file
|
|
load_dotenv()
|
|
|
|
# Detect hardware capabilities and display information
|
|
import torch
|
|
import psutil
|
|
|
|
# Detect if we're on a high-end system based on hardware capabilities
|
|
HIGH_END_GPU = False
|
|
if torch.cuda.is_available():
|
|
# Get GPU properties
|
|
props = torch.cuda.get_device_properties(0)
|
|
gpu_name = props.name
|
|
gpu_mem_gb = props.total_memory / (1024**3)
|
|
compute_capability = f"{props.major}.{props.minor}"
|
|
|
|
# Consider high-end if: large VRAM (≥16GB) OR high compute capability (≥8.0) OR large VRAM (≥12GB) with good CC (≥7.0)
|
|
HIGH_END_GPU = (gpu_mem_gb >= 16.0 or
|
|
props.major >= 8 or
|
|
(gpu_mem_gb >= 12.0 and props.major >= 7))
|
|
|
|
if HIGH_END_GPU:
|
|
if not IS_RELOADER:
|
|
print(f"🖥️ Hardware: High-end CUDA GPU detected")
|
|
print(f"📊 Device: {gpu_name}")
|
|
print(f"📊 VRAM: {gpu_mem_gb:.2f} GB")
|
|
print(f"📊 Compute Capability: {compute_capability}")
|
|
print("🚀 Using high-performance optimizations")
|
|
else:
|
|
if not IS_RELOADER:
|
|
print(f"🖥️ Hardware: CUDA GPU detected")
|
|
print(f"📊 Device: {gpu_name}")
|
|
print(f"📊 VRAM: {gpu_mem_gb:.2f} GB")
|
|
print(f"📊 Compute Capability: {compute_capability}")
|
|
print("🚀 Using GPU-optimized settings")
|
|
else:
|
|
# Get CPU info
|
|
cpu_cores = psutil.cpu_count(logical=False)
|
|
cpu_threads = psutil.cpu_count(logical=True)
|
|
ram_gb = psutil.virtual_memory().total / (1024**3)
|
|
|
|
if not IS_RELOADER:
|
|
print(f"🖥️ Hardware: CPU only (No CUDA GPU detected)")
|
|
print(f"📊 CPU: {cpu_cores} cores, {cpu_threads} threads")
|
|
print(f"📊 RAM: {ram_gb:.2f} GB")
|
|
print("⚙️ Using CPU-optimized settings")
|
|
|
|
# Load configuration from environment variables without hardcoded defaults
|
|
# Critical settings - will log errors if missing
|
|
required_settings = ["ORPHEUS_API_URL"]
|
|
missing_settings = [s for s in required_settings if s not in os.environ]
|
|
if missing_settings:
|
|
print(f"ERROR: Missing required environment variable(s): {', '.join(missing_settings)}")
|
|
print("Please set them in .env file or environment. See .env.example for defaults.")
|
|
|
|
# API connection settings
|
|
API_URL = os.environ.get("ORPHEUS_API_URL")
|
|
if not API_URL:
|
|
print("WARNING: ORPHEUS_API_URL not set. API calls will fail until configured.")
|
|
|
|
HEADERS = {
|
|
"Content-Type": "application/json"
|
|
}
|
|
|
|
# Request timeout settings
|
|
try:
|
|
REQUEST_TIMEOUT = int(os.environ.get("ORPHEUS_API_TIMEOUT", "120"))
|
|
except (ValueError, TypeError):
|
|
print("WARNING: Invalid ORPHEUS_API_TIMEOUT value, using 120 seconds as fallback")
|
|
REQUEST_TIMEOUT = 120
|
|
|
|
# Model generation parameters from environment variables
|
|
try:
|
|
MAX_TOKENS = int(os.environ.get("ORPHEUS_MAX_TOKENS", "8192"))
|
|
except (ValueError, TypeError):
|
|
print("WARNING: Invalid ORPHEUS_MAX_TOKENS value, using 8192 as fallback")
|
|
MAX_TOKENS = 8192
|
|
|
|
try:
|
|
TEMPERATURE = float(os.environ.get("ORPHEUS_TEMPERATURE", "0.6"))
|
|
except (ValueError, TypeError):
|
|
print("WARNING: Invalid ORPHEUS_TEMPERATURE value, using 0.6 as fallback")
|
|
TEMPERATURE = 0.6
|
|
|
|
try:
|
|
TOP_P = float(os.environ.get("ORPHEUS_TOP_P", "0.9"))
|
|
except (ValueError, TypeError):
|
|
print("WARNING: Invalid ORPHEUS_TOP_P value, using 0.9 as fallback")
|
|
TOP_P = 0.9
|
|
|
|
# Repetition penalty is hardcoded to 1.1 which is the only stable value for quality output
|
|
REPETITION_PENALTY = 1.1
|
|
|
|
try:
|
|
SAMPLE_RATE = int(os.environ.get("ORPHEUS_SAMPLE_RATE", "24000"))
|
|
except (ValueError, TypeError):
|
|
print("WARNING: Invalid ORPHEUS_SAMPLE_RATE value, using 24000 as fallback")
|
|
SAMPLE_RATE = 24000
|
|
|
|
# Print loaded configuration only in the main process, not in the reloader
|
|
if not IS_RELOADER:
|
|
print(f"Configuration loaded:")
|
|
print(f" API_URL: {API_URL}")
|
|
print(f" MAX_TOKENS: {MAX_TOKENS}")
|
|
print(f" TEMPERATURE: {TEMPERATURE}")
|
|
print(f" TOP_P: {TOP_P}")
|
|
print(f" REPETITION_PENALTY: {REPETITION_PENALTY}")
|
|
|
|
# Parallel processing settings
|
|
NUM_WORKERS = 4 if HIGH_END_GPU else 2
|
|
|
|
# Define voices by language
|
|
ENGLISH_VOICES = ["tara", "leah", "jess", "leo", "dan", "mia", "zac", "zoe"]
|
|
FRENCH_VOICES = ["pierre", "amelie", "marie"]
|
|
GERMAN_VOICES = ["jana", "thomas", "max"]
|
|
KOREAN_VOICES = ["유나", "준서"]
|
|
HINDI_VOICES = ["ऋतिका"]
|
|
MANDARIN_VOICES = ["长乐", "白芷"]
|
|
SPANISH_VOICES = ["javi", "sergio", "maria"]
|
|
ITALIAN_VOICES = ["pietro", "giulia", "carlo"]
|
|
|
|
# Combined list for API compatibility
|
|
AVAILABLE_VOICES = (
|
|
ENGLISH_VOICES +
|
|
FRENCH_VOICES +
|
|
GERMAN_VOICES +
|
|
KOREAN_VOICES +
|
|
HINDI_VOICES +
|
|
MANDARIN_VOICES +
|
|
SPANISH_VOICES +
|
|
ITALIAN_VOICES
|
|
)
|
|
DEFAULT_VOICE = "tara" # Best voice according to documentation
|
|
|
|
# Map voices to languages for the UI
|
|
VOICE_TO_LANGUAGE = {}
|
|
VOICE_TO_LANGUAGE.update({voice: "english" for voice in ENGLISH_VOICES})
|
|
VOICE_TO_LANGUAGE.update({voice: "french" for voice in FRENCH_VOICES})
|
|
VOICE_TO_LANGUAGE.update({voice: "german" for voice in GERMAN_VOICES})
|
|
VOICE_TO_LANGUAGE.update({voice: "korean" for voice in KOREAN_VOICES})
|
|
VOICE_TO_LANGUAGE.update({voice: "hindi" for voice in HINDI_VOICES})
|
|
VOICE_TO_LANGUAGE.update({voice: "mandarin" for voice in MANDARIN_VOICES})
|
|
VOICE_TO_LANGUAGE.update({voice: "spanish" for voice in SPANISH_VOICES})
|
|
VOICE_TO_LANGUAGE.update({voice: "italian" for voice in ITALIAN_VOICES})
|
|
|
|
# Languages list for the UI
|
|
AVAILABLE_LANGUAGES = ["english", "french", "german", "korean", "hindi", "mandarin", "spanish", "italian"]
|
|
|
|
# Import the unified token handling from speechpipe
|
|
from .speechpipe import turn_token_into_id, CUSTOM_TOKEN_PREFIX
|
|
|
|
# Special token IDs for Orpheus model
|
|
START_TOKEN_ID = 128259
|
|
END_TOKEN_IDS = [128009, 128260, 128261, 128257]
|
|
|
|
# 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 GPUs
|
|
if HIGH_END_GPU:
|
|
# Use more aggressive parameters for faster generation on high-end GPUs
|
|
print("Using optimized parameters for high-end GPU")
|
|
elif torch.cuda.is_available():
|
|
print("Using optimized parameters for GPU acceleration")
|
|
|
|
# Create the request payload (model field may not be required by some endpoints but included for compatibility)
|
|
payload = {
|
|
"prompt": formatted_prompt,
|
|
"max_tokens": max_tokens,
|
|
"temperature": temperature,
|
|
"top_p": top_p,
|
|
"repeat_penalty": repetition_penalty,
|
|
"stream": True # Always stream for better performance
|
|
}
|
|
|
|
# Add model field - this is ignored by many local inference servers for /v1/completions
|
|
# but included for compatibility with OpenAI API and some servers that may use it
|
|
model_name = os.environ.get("ORPHEUS_MODEL_NAME", "Orpheus-3b-FT-Q8_0.gguf")
|
|
payload["model"] = model_name
|
|
|
|
# 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_chunk = data['choices'][0].get('text', '')
|
|
for token_text in token_chunk.split('>'):
|
|
token_text = f'{token_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
|
|
|
|
# The turn_token_into_id function is now imported from speechpipe.py
|
|
# This eliminates duplicate code and ensures consistent behavior
|
|
|
|
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 with early first-chunk processing for lower latency."""
|
|
buffer = []
|
|
count = 0
|
|
|
|
# Use different thresholds for first chunk vs. subsequent chunks
|
|
first_chunk_processed = False
|
|
min_frames_first = 7 # Process after just 7 tokens for first chunk (ultra-low latency)
|
|
min_frames_subsequent = 28 # Default for reliability after first chunk (4 chunks of 7)
|
|
process_every = 7 # Process every 7 tokens (standard for Orpheus model)
|
|
|
|
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
|
|
|
|
# Different processing paths based on whether first chunk has been processed
|
|
if not first_chunk_processed:
|
|
# For first audio output, process as soon as we have enough tokens for one chunk
|
|
if count >= min_frames_first:
|
|
buffer_to_proc = buffer[-min_frames_first:]
|
|
|
|
# Process the first chunk for immediate audio feedback
|
|
print(f"Processing first audio chunk with {len(buffer_to_proc)} tokens")
|
|
audio_samples = convert_to_audio(buffer_to_proc, count)
|
|
if audio_samples is not None:
|
|
first_chunk_processed = True # Mark first chunk as processed
|
|
yield audio_samples
|
|
else:
|
|
# For subsequent chunks, use standard processing with larger batch
|
|
if count % process_every == 0 and count >= min_frames_subsequent:
|
|
# Use simple slice operation - reliable and correct
|
|
buffer_to_proc = buffer[-min_frames_subsequent:]
|
|
|
|
# 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
|
|
|
|
# Thread synchronization for proper completion detection
|
|
producer_done_event = threading.Event()
|
|
producer_started_event = threading.Event()
|
|
|
|
# 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 in the final batch
|
|
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
|
|
|
|
try:
|
|
# Signal that producer has started processing
|
|
producer_started_event.set()
|
|
|
|
async for audio_chunk in tokens_decoder(async_token_gen()):
|
|
# Process each audio chunk from the decoder
|
|
if audio_chunk:
|
|
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 - last_log_time
|
|
if elapsed > 0:
|
|
recent_chunks = chunk_count
|
|
chunks_per_sec = recent_chunks / elapsed
|
|
print(f"Audio generation rate: {chunks_per_sec:.2f} chunks/second")
|
|
last_log_time = current_time
|
|
# Reset chunk counter for next interval
|
|
chunk_count = 0
|
|
except Exception as e:
|
|
print(f"Error in token processing: {str(e)}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
finally:
|
|
# Always signal completion, even if there was an error
|
|
print("Producer completed - setting done event")
|
|
producer_done_event.set()
|
|
# Add sentinel to queue to signal end of stream
|
|
audio_queue.put(None)
|
|
|
|
def run_async():
|
|
"""Run the async producer in its own thread"""
|
|
asyncio.run(async_producer())
|
|
|
|
# Use a separate thread with higher priority for producer
|
|
thread = threading.Thread(target=run_async, name="TokenProcessor")
|
|
thread.daemon = True # Allow thread to be terminated when main thread exits
|
|
thread.start()
|
|
|
|
# Wait for producer to actually start before proceeding
|
|
# This avoids race conditions where we might try to read from an empty queue
|
|
# before the producer has had a chance to add anything
|
|
producer_started_event.wait(timeout=5.0)
|
|
|
|
# Optimized I/O approach for all systems
|
|
# This approach is simpler and more reliable than separate code paths
|
|
write_buffer = bytearray()
|
|
buffer_max_size = 1024 * 1024 # 1MB max buffer size (adjustable)
|
|
|
|
# Keep track of the last time we checked for completion
|
|
last_check_time = time.time()
|
|
check_interval = 1.0 # Check producer status every second
|
|
|
|
# Process audio chunks until we're done
|
|
while True:
|
|
try:
|
|
# Get the next audio chunk with a short timeout
|
|
# This allows us to periodically check status and handle other events
|
|
audio = audio_queue.get(timeout=0.1)
|
|
|
|
# None marker indicates end of stream
|
|
if audio is None:
|
|
print("Received end-of-stream marker")
|
|
break
|
|
|
|
# Store the audio segment for return value
|
|
audio_segments.append(audio)
|
|
|
|
# Write to file if needed
|
|
if wav_file:
|
|
write_buffer.extend(audio)
|
|
|
|
# Flush buffer if it's large enough
|
|
if len(write_buffer) >= buffer_max_size:
|
|
wav_file.writeframes(write_buffer)
|
|
write_buffer = bytearray() # Reset buffer
|
|
|
|
except queue.Empty:
|
|
# No data available right now
|
|
current_time = time.time()
|
|
|
|
# Periodically check if producer is done
|
|
if current_time - last_check_time > check_interval:
|
|
last_check_time = current_time
|
|
|
|
# If producer is done and queue is empty, we're finished
|
|
if producer_done_event.is_set() and audio_queue.empty():
|
|
print("Producer done and queue empty - finishing consumer")
|
|
break
|
|
|
|
# Flush buffer periodically even if not full
|
|
if wav_file and len(write_buffer) > 0:
|
|
wav_file.writeframes(write_buffer)
|
|
write_buffer = bytearray() # Reset buffer
|
|
|
|
# Extra safety check - ensure thread is done
|
|
if thread.is_alive():
|
|
print("Waiting for token processor thread to complete...")
|
|
thread.join(timeout=10.0)
|
|
if thread.is_alive():
|
|
print("WARNING: Token processor thread did not complete within timeout")
|
|
|
|
# Final flush of any remaining data
|
|
if wav_file and len(write_buffer) > 0:
|
|
print(f"Final buffer flush: {len(write_buffer)} bytes")
|
|
wav_file.writeframes(write_buffer)
|
|
|
|
# Close WAV file if opened
|
|
if wav_file:
|
|
wav_file.close()
|
|
if output_file:
|
|
print(f"Audio saved to {output_file}")
|
|
|
|
# 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}")
|
|
|
|
import re
|
|
import numpy as np
|
|
from io import BytesIO
|
|
import wave
|
|
|
|
def split_text_into_sentences(text):
|
|
"""Split text into sentences with a more reliable approach."""
|
|
# We'll use a simple approach that doesn't rely on variable-width lookbehinds
|
|
# which aren't supported in Python's regex engine
|
|
|
|
# First, split on common sentence ending punctuation
|
|
# This isn't perfect but works for most cases and avoids the regex error
|
|
parts = []
|
|
current_sentence = ""
|
|
|
|
for char in text:
|
|
current_sentence += char
|
|
|
|
# If we hit a sentence ending followed by a space, consider this a potential sentence end
|
|
if char in (' ', '\n', '\t') and len(current_sentence) > 1:
|
|
prev_char = current_sentence[-2]
|
|
if prev_char in ('.', '!', '?'):
|
|
# Check if this is likely a real sentence end and not an abbreviation
|
|
# (Simple heuristic: if there's a space before the period, it's likely a real sentence end)
|
|
if len(current_sentence) > 3 and current_sentence[-3] not in ('.', ' '):
|
|
parts.append(current_sentence.strip())
|
|
current_sentence = ""
|
|
|
|
# Add any remaining text
|
|
if current_sentence.strip():
|
|
parts.append(current_sentence.strip())
|
|
|
|
# Combine very short segments to avoid tiny audio files
|
|
min_chars = 20 # Minimum reasonable sentence length
|
|
combined_sentences = []
|
|
i = 0
|
|
|
|
while i < len(parts):
|
|
current = parts[i]
|
|
|
|
# If this is a short sentence and not the last one, combine with next
|
|
while i < len(parts) - 1 and len(current) < min_chars:
|
|
i += 1
|
|
current += " " + parts[i]
|
|
|
|
combined_sentences.append(current)
|
|
i += 1
|
|
|
|
return combined_sentences
|
|
|
|
def generate_speech_from_api(prompt, voice=DEFAULT_VOICE, output_file=None, temperature=TEMPERATURE,
|
|
top_p=TOP_P, max_tokens=MAX_TOKENS, repetition_penalty=None,
|
|
use_batching=True, max_batch_chars=1000):
|
|
"""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()
|
|
|
|
# For shorter text, use the standard non-batched approach
|
|
if not use_batching or len(prompt) < max_batch_chars:
|
|
# Note: we ignore any provided repetition_penalty and always use the hardcoded value
|
|
# This ensures consistent quality regardless of what might be passed in
|
|
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 # Always use hardcoded value
|
|
),
|
|
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
|
|
|
|
# For longer text, use sentence-based batching
|
|
print(f"Using sentence-based batching for text with {len(prompt)} characters")
|
|
|
|
# Split the text into sentences
|
|
sentences = split_text_into_sentences(prompt)
|
|
print(f"Split text into {len(sentences)} segments")
|
|
|
|
# Create batches by combining sentences up to max_batch_chars
|
|
batches = []
|
|
current_batch = ""
|
|
|
|
for sentence in sentences:
|
|
# If adding this sentence would exceed the batch size, start a new batch
|
|
if len(current_batch) + len(sentence) > max_batch_chars and current_batch:
|
|
batches.append(current_batch)
|
|
current_batch = sentence
|
|
else:
|
|
# Add separator space if needed
|
|
if current_batch:
|
|
current_batch += " "
|
|
current_batch += sentence
|
|
|
|
# Add the last batch if it's not empty
|
|
if current_batch:
|
|
batches.append(current_batch)
|
|
|
|
print(f"Created {len(batches)} batches for processing")
|
|
|
|
# Process each batch and collect audio segments
|
|
all_audio_segments = []
|
|
batch_temp_files = []
|
|
|
|
for i, batch in enumerate(batches):
|
|
print(f"Processing batch {i+1}/{len(batches)} ({len(batch)} characters)")
|
|
|
|
# Create a temporary file for this batch if an output file is requested
|
|
temp_output_file = None
|
|
if output_file:
|
|
temp_output_file = f"outputs/temp_batch_{i}_{int(time.time())}.wav"
|
|
batch_temp_files.append(temp_output_file)
|
|
|
|
# Generate speech for this batch
|
|
batch_segments = tokens_decoder_sync(
|
|
generate_tokens_from_api(
|
|
prompt=batch,
|
|
voice=voice,
|
|
temperature=temperature,
|
|
top_p=top_p,
|
|
max_tokens=max_tokens,
|
|
repetition_penalty=REPETITION_PENALTY
|
|
),
|
|
output_file=temp_output_file
|
|
)
|
|
|
|
# Add to our collection
|
|
all_audio_segments.extend(batch_segments)
|
|
|
|
# If an output file was requested, stitch together the temporary files
|
|
if output_file and batch_temp_files:
|
|
# Stitch together WAV files
|
|
stitch_wav_files(batch_temp_files, output_file)
|
|
|
|
# Clean up temporary files
|
|
for temp_file in batch_temp_files:
|
|
try:
|
|
os.remove(temp_file)
|
|
except Exception as e:
|
|
print(f"Warning: Could not remove temporary file {temp_file}: {e}")
|
|
|
|
# Report final performance metrics
|
|
end_time = time.time()
|
|
total_time = end_time - start_time
|
|
|
|
# Calculate combined duration
|
|
if all_audio_segments:
|
|
total_bytes = sum(len(segment) for segment in all_audio_segments)
|
|
duration = total_bytes / (2 * SAMPLE_RATE) # 2 bytes per sample at 24kHz
|
|
print(f"Generated {len(all_audio_segments)} audio segments")
|
|
print(f"Generated {duration:.2f} seconds of audio in {total_time:.2f} seconds")
|
|
print(f"Realtime factor: {duration/total_time:.2f}x")
|
|
|
|
print(f"Total speech generation completed in {total_time:.2f} seconds")
|
|
|
|
return all_audio_segments
|
|
|
|
def stitch_wav_files(input_files, output_file, crossfade_ms=50):
|
|
"""Stitch multiple WAV files together with crossfading for smooth transitions."""
|
|
if not input_files:
|
|
return
|
|
|
|
print(f"Stitching {len(input_files)} WAV files together with {crossfade_ms}ms crossfade")
|
|
|
|
# If only one file, just copy it
|
|
if len(input_files) == 1:
|
|
import shutil
|
|
shutil.copy(input_files[0], output_file)
|
|
return
|
|
|
|
# Convert crossfade_ms to samples
|
|
crossfade_samples = int(SAMPLE_RATE * crossfade_ms / 1000)
|
|
print(f"Using {crossfade_samples} samples for crossfade at {SAMPLE_RATE}Hz")
|
|
|
|
# Build the final audio in memory with crossfades
|
|
final_audio = np.array([], dtype=np.int16)
|
|
first_params = None
|
|
|
|
for i, input_file in enumerate(input_files):
|
|
try:
|
|
with wave.open(input_file, 'rb') as wav:
|
|
if first_params is None:
|
|
first_params = wav.getparams()
|
|
elif wav.getparams() != first_params:
|
|
print(f"Warning: WAV file {input_file} has different parameters")
|
|
|
|
frames = wav.readframes(wav.getnframes())
|
|
audio = np.frombuffer(frames, dtype=np.int16)
|
|
|
|
if i == 0:
|
|
# First segment - use as is
|
|
final_audio = audio
|
|
else:
|
|
# Apply crossfade with previous segment
|
|
if len(final_audio) >= crossfade_samples and len(audio) >= crossfade_samples:
|
|
# Create crossfade weights
|
|
fade_out = np.linspace(1.0, 0.0, crossfade_samples)
|
|
fade_in = np.linspace(0.0, 1.0, crossfade_samples)
|
|
|
|
# Apply crossfade
|
|
crossfade_region = (final_audio[-crossfade_samples:] * fade_out +
|
|
audio[:crossfade_samples] * fade_in).astype(np.int16)
|
|
|
|
# Combine: original without last crossfade_samples + crossfade + new without first crossfade_samples
|
|
final_audio = np.concatenate([final_audio[:-crossfade_samples],
|
|
crossfade_region,
|
|
audio[crossfade_samples:]])
|
|
else:
|
|
# One segment too short for crossfade, just append
|
|
print(f"Segment {i} too short for crossfade, concatenating directly")
|
|
final_audio = np.concatenate([final_audio, audio])
|
|
except Exception as e:
|
|
print(f"Error processing file {input_file}: {e}")
|
|
if i == 0:
|
|
raise # Critical failure if first file fails
|
|
|
|
# Write the final audio data to the output file
|
|
try:
|
|
with wave.open(output_file, 'wb') as output_wav:
|
|
if first_params is None:
|
|
raise ValueError("No valid WAV files were processed")
|
|
|
|
output_wav.setparams(first_params)
|
|
output_wav.writeframes(final_audio.tobytes())
|
|
|
|
print(f"Successfully stitched audio to {output_file} with crossfading")
|
|
except Exception as e:
|
|
print(f"Error writing output file {output_file}: {e}")
|
|
raise
|
|
|
|
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 (fixed at 1.1 for stable generation - parameter kept for compatibility)")
|
|
|
|
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()
|