diff --git a/.DS_Store b/.DS_Store
new file mode 100644
index 0000000..24e4bf2
Binary files /dev/null and b/.DS_Store differ
diff --git a/.env.example b/.env.example
index 11ad5da..12888bf 100644
--- a/.env.example
+++ b/.env.example
@@ -14,6 +14,10 @@ ORPHEUS_TOP_P=0.9
ORPHEUS_SAMPLE_RATE=24000
ORPHEUS_MODEL_NAME=Orpheus-3b-FT-Q8_0.gguf # Model name sent to inference server (Q2_K, Q4_K_M, or Q8_0 variants)
+# Audio processing parameters
+ORPHEUS_MAX_BATCH_CHARS=600 # Maximum characters per batch for sentence splitting (100-2000)
+ORPHEUS_CROSSFADE_MS=30 # Crossfade duration between audio batches in milliseconds (10-200)
+
# Web UI settings (keep in mind that the web UI is not secure and should not be exposed to the internet)
ORPHEUS_PORT=5005
ORPHEUS_HOST=0.0.0.0
diff --git a/.gitignore b/.gitignore
index f6f9952..710bb3c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,4 +3,11 @@ __pycache__/
.venv/
venv/
models/
-*.gguf
\ No newline at end of file
+*.gguf
+outputs/
+output_long*.txt
+*.txt
+jinja
+*.zsh
+*.bak
+*.sav
diff --git a/README.md b/README.md
index 867c46e..83261b3 100644
--- a/README.md
+++ b/README.md
@@ -401,6 +401,46 @@ Note: Repetition penalty is hardcoded to 1.1 and cannot be changed through envir
Make sure the `ORPHEUS_API_URL` points to your running inference server.
+## Apple Silicon Optimization
+
+This project includes special optimizations for Apple Silicon (M1/M2/M3) devices:
+
+### Metal Performance Shaders (MPS)
+
+The application automatically uses Apple's Metal Performance Shaders (MPS) backend when running on Apple Silicon. This provides significant performance improvements over CPU-only execution.
+
+### CoreML Neural Engine Acceleration
+
+For even better performance, you can enable Neural Engine acceleration via CoreML:
+
+1. Install the required package:
+ ```bash
+ pip install coremltools
+ ```
+
+2. Export the model to CoreML format (one-time setup):
+ ```bash
+ python export_coreml.py
+ ```
+
+3. Enable CoreML acceleration:
+ ```bash
+ export ORPHEUS_USE_COREML=1
+ ```
+
+This optional step can provide an additional 30-50% speedup by offloading computation to the Neural Engine, which is specifically designed for machine learning workloads.
+
+### Memory Optimization
+
+The application automatically detects high-memory Apple Silicon devices (M1 Pro/Max/Ultra, M2 Pro/Max/Ultra, M3 Pro/Max/Ultra) and uses more aggressive memory optimization strategies, including:
+
+- Parallel batch processing
+- Larger audio buffers
+- Pre-allocation for crossfading
+- Optimized tensor operations
+
+These optimizations are particularly effective on devices with 32GB+ of unified memory.
+
## Development
### Project Components
diff --git a/app.py b/app.py
index 1ca2108..6d41c13 100644
--- a/app.py
+++ b/app.py
@@ -51,7 +51,16 @@ from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
import json
-from tts_engine import generate_speech_from_api, AVAILABLE_VOICES, DEFAULT_VOICE, VOICE_TO_LANGUAGE, AVAILABLE_LANGUAGES
+# Import from tts_engine with fallback values
+try:
+ from tts_engine import generate_speech_from_api, AVAILABLE_VOICES, DEFAULT_VOICE, VOICE_TO_LANGUAGE, AVAILABLE_LANGUAGES, MAX_BATCH_CHARS
+except ImportError as e:
+ # If MAX_BATCH_CHARS is missing, import what we can and define a fallback value
+ print(f"Warning: Import error - {e}")
+ from tts_engine import generate_speech_from_api, AVAILABLE_VOICES, DEFAULT_VOICE, VOICE_TO_LANGUAGE, AVAILABLE_LANGUAGES
+ # Use a fallback value - this should match the value in inference.py
+ MAX_BATCH_CHARS = 600
+ print(f"Using fallback value for MAX_BATCH_CHARS: {MAX_BATCH_CHARS}")
# Create FastAPI app
app = FastAPI(
@@ -96,7 +105,7 @@ async def create_speech_api(request: SpeechRequest):
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
+ For longer texts (>MAX_BATCH_CHARS characters), batched generation is used
to improve reliability and avoid truncation issues.
"""
if not request.input:
@@ -107,7 +116,7 @@ async def create_speech_api(request: SpeechRequest):
output_path = f"outputs/{request.voice}_{timestamp}.wav"
# Check if we should use batched generation
- use_batching = len(request.input) > 1000
+ use_batching = len(request.input) > MAX_BATCH_CHARS
if use_batching:
print(f"Using batched generation for long text ({len(request.input)} characters)")
@@ -117,8 +126,7 @@ async def create_speech_api(request: SpeechRequest):
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)
+ use_batching=use_batching
)
end = time.time()
generation_time = round(end - start, 2)
@@ -160,7 +168,7 @@ async def speak(request: Request):
output_path = f"outputs/{voice}_{timestamp}.wav"
# Check if we should use batched generation for longer texts
- use_batching = len(text) > 1000
+ use_batching = len(text) > MAX_BATCH_CHARS
if use_batching:
print(f"Using batched generation for long text ({len(text)} characters)")
@@ -170,8 +178,7 @@ async def speak(request: Request):
prompt=text,
voice=voice,
output_file=output_path,
- use_batching=use_batching,
- max_batch_chars=1000
+ use_batching=use_batching
)
end = time.time()
generation_time = round(end - start, 2)
@@ -226,7 +233,7 @@ async def save_config(request: Request):
# 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"]:
+ if key in ["ORPHEUS_MAX_TOKENS", "ORPHEUS_API_TIMEOUT", "ORPHEUS_PORT", "ORPHEUS_SAMPLE_RATE", "ORPHEUS_MAX_BATCH_CHARS", "ORPHEUS_CROSSFADE_MS"]:
try:
data[key] = str(int(value))
except (ValueError, TypeError):
@@ -322,7 +329,7 @@ async def generate_from_web(
output_path = f"outputs/{voice}_{timestamp}.wav"
# Check if we should use batched generation for longer texts
- use_batching = len(text) > 1000
+ use_batching = len(text) > MAX_BATCH_CHARS
if use_batching:
print(f"Using batched generation for long text from web form ({len(text)} characters)")
@@ -332,8 +339,7 @@ async def generate_from_web(
prompt=text,
voice=voice,
output_file=output_path,
- use_batching=use_batching,
- max_batch_chars=1000
+ use_batching=use_batching
)
end = time.time()
generation_time = round(end - start, 2)
diff --git a/restart.flag b/restart.flag
new file mode 100644
index 0000000..e5ebbac
--- /dev/null
+++ b/restart.flag
@@ -0,0 +1 @@
+1745296574.27762
\ No newline at end of file
diff --git a/templates/tts.html b/templates/tts.html
index 3cf715c..3f8b705 100644
--- a/templates/tts.html
+++ b/templates/tts.html
@@ -183,6 +183,7 @@
{% if voice_option == "tara" %}Female, English, conversational, clear
+ {% elif voice_option == "kaya" %} Female, English, expressive, friendly
{% elif voice_option == "leah" %}Female, English, warm, gentle
{% elif voice_option == "jess" %}Female, English, energetic, youthful
{% elif voice_option == "leo" %}Male, English, authoritative, deep
@@ -330,6 +331,23 @@
class="block w-full rounded-md bg-dark-700 border-dark-600 text-white text-sm focus:border-primary-500 focus:ring-primary-500 focus:ring-offset-dark-800 px-3 py-2">
+
+
+
Max Batch Characters
+
+
Maximum characters per batch for sentence splitting (default: 600)
+
+
+
+
Crossfade Duration (ms)
+
+
Crossfade duration between audio batches in milliseconds (default: 30)
+
+
-
+
Play
diff --git a/tts_engine/__init__.py b/tts_engine/__init__.py
index 88bc054..59d1c22 100644
--- a/tts_engine/__init__.py
+++ b/tts_engine/__init__.py
@@ -4,14 +4,52 @@ 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
+- coreml_wrapper.py: Apple Silicon Neural Engine acceleration
"""
# Make key components available at package level
from .inference import (
generate_speech_from_api,
+ stream_audio,
AVAILABLE_VOICES,
DEFAULT_VOICE,
VOICE_TO_LANGUAGE,
AVAILABLE_LANGUAGES,
- list_available_voices
+ MAX_BATCH_CHARS,
+ CROSSFADE_MS,
+ list_available_voices,
+ API_URL,
+ HEADERS
)
+
+# Expose hardware detection flags
+from .speechpipe import (
+ APPLE_SILICON,
+ CUDA_AVAILABLE,
+ DEVICE
+)
+
+__all__ = [
+ # Core Functions
+ "generate_speech_from_api",
+ "stream_audio",
+ "list_available_voices",
+
+ # Speech Processing
+ "convert_to_audio",
+ "turn_token_into_id",
+ "reset_state",
+ "DEVICE",
+
+ # Constants & Settings
+ "AVAILABLE_VOICES",
+ "DEFAULT_VOICE",
+ "VOICE_TO_LANGUAGE",
+ "AVAILABLE_LANGUAGES",
+ "MAX_BATCH_CHARS",
+ "CROSSFADE_MS",
+
+ # Configuration (Example)
+ "API_URL",
+ "HEADERS",
+]
diff --git a/tts_engine/inference.mps b/tts_engine/inference.mps
new file mode 100644
index 0000000..a2dbdf9
--- /dev/null
+++ b/tts_engine/inference.mps
@@ -0,0 +1,1124 @@
+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
+HAS_MPS = False # new flag for MPS detection
+DEVICE_INFO = "CPU" # keep track of the primary compute device
+
+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:
+ DEVICE_INFO = "HIGH-end CUDA 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:
+ DEVICE_INFO = "CUDA GPU"
+ 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")
+#--- ADD MPS Detection block ---
+elif torch.backends.mps.is_available():
+ HAS_MPS = True
+ # Consider M-series chips with sufficient RAM as high-performance
+ ram_gb = psutil.virtual_memory().total / (1024**3)
+ # Let's consider M-series with >= 32GB RAM as "high performance" for TTS tasks
+ HIGH_PERFORMANCE_MPS = ram_gb >= 32.0
+ DEVICE_INFO = "Apple Silicon (MPS)"
+
+ if not IS_RELOADER:
+ print(f"🖥️ Hardware: Apple Silicon (MPS) detected")
+ print(f"📊 RAM: {ram_gb:.2f} GB")
+ if HIGH_PERFORMANCE_MPS:
+ print("🚀 Using high-performance MPS optimizations")
+ else:
+ print("⚙️ Using MPS-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 or MPS 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}")
+
+
+# Available voices based on the Orpheus-TTS repository
+AVAILABLE_VOICES = ["tara", "elise", "leah", "jess", "leo", "dan", "mia", "zac", "zoe"]
+DEFAULT_VOICE = "tara" # Best voice according to documentation
+
+# Import the unified token handling from speechpipe
+from .speechpipe import turn_token_into_id, token_id_cache, 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")
+ elif HAS_MPS: # Add check for MPS
+ print("Using optimized parameters for Apple Silicon (MPS) 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)
+# ideal_frames = 49 # Define ideal_frames here
+# process_every = 7 # Process every 7 tokens (standard for Orpheus model)
+#
+# start_time = time.time()
+# last_log_time = start_time
+# token_count = 0
+#
+# has_seen_audio_token = False # <<< New flag
+# spurious_text_detected = False # <<< New flag
+#
+# async for token_text in token_gen:
+# token = turn_token_into_id(token_text, count)
+# if token is not None and token > 0:
+# has_seen_audio_token = True # Mark that we are processing audio
+#
+# # 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
+#
+# # <<< START NEW LOGIC >>>
+# # Check for spurious text *after* having seen audio tokens
+# elif has_seen_audio_token and token is None and token_text.strip() and token_text.strip() != '>' and not token_text.startswith('>>
+#
+# # --- End-of-generation handling (Now runs after loop break) ---
+# print(f"End of token stream reached. Processing final buffer of length {len(buffer)}. Spurious text detected: {spurious_text_detected}")
+#
+# # --- START REVISED End-of-generation handling ---
+# if not spurious_text_detected:
+# print(f"End of token stream reached cleanly. Processing final buffer of length {len(buffer)}.")
+#
+# start_index = 0
+# final_chunks_processed = 0
+#
+# # Process all remaining complete chunks of size 'process_every'
+# while start_index + process_every <= len(buffer):
+# chunk_to_proc = buffer[start_index : start_index + process_every]
+# print(f"Processing final full chunk #{final_chunks_processed + 1} (indices {start_index}-{start_index + process_every - 1})")
+# # Note: Passing 'count' to convert_to_audio might be irrelevant if it only uses len(chunk)//7 internally.
+# # Check speechpipe.py if 'count' is actually needed there. Assuming it's not critical for slicing.
+# audio_samples = convert_to_audio(chunk_to_proc, count)
+# if audio_samples is not None:
+# yield audio_samples
+# final_chunks_processed += 1
+# start_index += process_every
+#
+# # Process the final partial chunk (if any) with padding
+# if start_index < len(buffer):
+# remainder_chunk = buffer[start_index:]
+# num_remaining = len(remainder_chunk)
+# print(f"Processing final partial chunk (indices {start_index}-end, length {num_remaining}) with padding")
+# last_token = remainder_chunk[-1]
+# padding_needed = process_every - num_remaining
+# padding = [last_token] * padding_needed
+# padded_buffer = remainder_chunk + padding
+#
+# # Pass the correctly sized padded buffer
+# audio_samples = convert_to_audio(padded_buffer, count) # Again, 'count' might be ignored by the function
+# if audio_samples is not None:
+# yield audio_samples
+# final_chunks_processed += 1
+# elif final_chunks_processed == 0 and len(buffer) > 0:
+# # This case handles buffers smaller than process_every initially
+# print(f"Processing final small buffer (length {len(buffer)} < {process_every}) with padding")
+# last_token = buffer[-1]
+# padding_needed = process_every - len(buffer)
+# padding = [last_token] * padding_needed
+# padded_buffer = buffer + padding
+# audio_samples = convert_to_audio(padded_buffer, count)
+# if audio_samples is not None:
+# yield audio_samples
+# final_chunks_processed += 1
+#
+# if final_chunks_processed > 0:
+# print(f"Processed {final_chunks_processed} final chunk(s).")
+# else:
+# print("No final audio chunks processed from remaining buffer.")
+#
+# else:
+# # This block executes if spurious text WAS detected
+# print("Skipping final buffer processing due to spurious text detection.")
+# # --- END REVISED End-of-generation handling ---
+
+# In inference.py
+
+# In inference.py -> tokens_decoder
+
+async def tokens_decoder(token_gen) -> Generator[bytes, None, None]:
+ """
+ Processes tokens using standard buffering, handles spurious text.
+ (Low-latency first chunk removed for compatibility with vocoder slice).
+ """
+ buffer = []
+ count = 0
+ process_every = 7
+ min_frames_process = 28 # Minimum tokens to have before processing a chunk
+ ideal_frames_process = 49 # Ideal number of tokens to send (using tail)
+
+ start_time = time.time()
+ token_count_perf = 0
+ last_log_time = start_time
+ has_seen_audio_token = False
+ spurious_text_detected = False
+
+ print("[Decoder] Initialized (No Low-Latency First Chunk).")
+
+ async for token_text in token_gen:
+ token = turn_token_into_id(token_text, count)
+
+ if token is not None and token > 0:
+ has_seen_audio_token = True
+ buffer.append(token)
+ count += 1
+ token_count_perf += 1
+
+ # --- START MODIFIED PROCESSING LOGIC ---
+ # Process only when buffer is sufficiently full
+ if len(buffer) >= min_frames_process:
+ # Check if we have enough for an ideal chunk from the tail
+ if len(buffer) >= ideal_frames_process:
+ buffer_to_proc = buffer[-ideal_frames_process:]
+ # print(f"[Decoder] Attempting ideal chunk ({ideal_frames_process} tokens from end of buffer len {len(buffer)})")
+ else:
+ # Otherwise, take the minimum required from the tail
+ buffer_to_proc = buffer[-min_frames_process:]
+ print(f"[Decoder] Attempting min chunk ({min_frames_process} tokens from end of buffer len {len(buffer)})")
+
+ audio_samples = convert_to_audio(buffer_to_proc, count)
+ if audio_samples is not None:
+ print(f"[Decoder] Yielding chunk ({len(audio_samples)} bytes)")
+ yield audio_samples
+ # *** Still don't remove from buffer if using tail slicing ***
+ else:
+ print(f"[Decoder] WARNING: Chunk conversion failed! Input tokens (last 10): {buffer_to_proc[-10:]}")
+ # --- END MODIFIED PROCESSING LOGIC ---
+
+
+ # --- Handle Spurious Text Detection (Same as before) ---
+ elif has_seen_audio_token and token is None and token_text.strip() and token_text.strip() != '>' and not token_text.startswith(' 5.0:
+ elapsed_interval = current_time - last_log_time
+ if elapsed_interval > 0:
+ rate = token_count_perf / elapsed_interval
+ print(f"Token processing rate: {rate:.1f} tokens/second (in last {elapsed_interval:.1f}s)")
+ last_log_time = current_time
+ token_count_perf = 0
+
+ # --- End-of-generation Handling ---
+ print(f"[Decoder] Exited main loop. Final unprocessed buffer len: {len(buffer)}. Spurious detected: {spurious_text_detected}")
+
+ # Process remaining buffer content only if stream ended cleanly
+ if not spurious_text_detected and len(buffer) > 0:
+ print(f"[Decoder] Processing final remaining buffer content (length {len(buffer)}).")
+
+ # How many full chunks remain after the initial chunk was removed?
+ # Note: This final processing logic needs refinement if the main loop doesn't remove processed tokens.
+ # Let's try the simpler approach first (like the original 'elif' logic) which relies on tail slicing.
+
+ final_buffer_processed = False
+ if len(buffer) >= ideal_frames:
+ print(f"[Decoder] Processing final ideal frame block ({ideal_frames} tokens from end)")
+ buffer_to_proc = buffer[-ideal_frames:]
+ audio_samples = convert_to_audio(buffer_to_proc, count)
+ if audio_samples is not None:
+ yield audio_samples
+ final_buffer_processed = True
+ elif len(buffer) >= min_frames_subsequent:
+ print(f"[Decoder] Processing final subsequent frame block ({min_frames_subsequent} tokens from end)")
+ buffer_to_proc = buffer[-min_frames_subsequent:]
+ audio_samples = convert_to_audio(buffer_to_proc, count)
+ if audio_samples is not None:
+ yield audio_samples
+ final_buffer_processed = True
+ elif len(buffer) >= process_every: # Must be >= 7 for convert_to_audio
+ print(f"[Decoder] Processing final padded frame block (>= {process_every} tokens from end)")
+ # Take the whole remaining buffer if it's less than min_frames_subsequent but >= process_every
+ remainder_chunk = buffer
+ num_remaining = len(remainder_chunk)
+ last_token = remainder_chunk[-1]
+ # Pad up to process_every size (or maybe min_frames_subsequent?) - Let's try process_every
+ padding_needed = process_every - num_remaining if num_remaining > 0 else 0
+ padding = [last_token] * padding_needed
+ padded_buffer = remainder_chunk + padding
+
+ if len(padded_buffer) >= process_every: # Ensure padding creates a valid chunk
+ audio_samples = convert_to_audio(padded_buffer, count)
+ if audio_samples is not None:
+ yield audio_samples
+ final_buffer_processed = True
+ else:
+ print(f"[Decoder] WARNING: Final padded chunk conversion failed!")
+ else:
+ print(f"[Decoder] WARNING: Final padded buffer too short ({len(padded_buffer)}), skipping.")
+
+
+ print(f"[Decoder] Final buffer processed flag: {final_buffer_processed}")
+
+ elif spurious_text_detected:
+ print("[Decoder] Skipped final buffer processing.")
+ else:
+ print("[Decoder] No final buffer processing needed (buffer empty).")
+
+ print(f"[Decoder] Finished. Total valid tokens received: {count}.") # Removed processed_count as it's complex with tail slicing
+
+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
+
+ #token_id_cache.clear()
+ #print("Cleared token ID cache.")
+
+ 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(", , , , , , , ")
+
+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()
diff --git a/tts_engine/inference.py b/tts_engine/inference.py
index b11817c..f2ce65d 100644
--- a/tts_engine/inference.py
+++ b/tts_engine/inference.py
@@ -10,9 +10,39 @@ import argparse
import threading
import queue
import asyncio
-from concurrent.futures import ThreadPoolExecutor
-from typing import List, Dict, Any, Optional, Generator, Union, Tuple
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from typing import List, Dict, Any, Optional, Generator, Union, Tuple, AsyncGenerator
from dotenv import load_dotenv
+import nltk
+import multiprocessing
+import logging
+
+logger = logging.getLogger(__name__)
+
+# Helper to ensure NLTK data is downloaded
+_nltk_punkt_downloaded = False
+def ensure_nltk_punkt():
+ global _nltk_punkt_downloaded
+ if not _nltk_punkt_downloaded:
+ try:
+ nltk.data.find('tokenizers/punkt')
+ except nltk.downloader.DownloadError:
+ print("NLTK 'punkt' tokenizer model not found. Downloading...")
+ try:
+ nltk.download('punkt', quiet=True)
+ print("'punkt' model downloaded successfully.")
+ except Exception as e:
+ print(f"Error downloading NLTK 'punkt' model: {e}")
+ print("Sentence tokenization might be less accurate.")
+ except LookupError: # Handle case where nltk_data path isn't configured
+ print("NLTK 'punkt' model not found. Downloading...")
+ try:
+ nltk.download('punkt', quiet=True)
+ print("'punkt' model downloaded successfully.")
+ except Exception as e:
+ print(f"Error downloading NLTK 'punkt' model: {e}")
+ print("Sentence tokenization might be less accurate.")
+ _nltk_punkt_downloaded = True
# Helper to detect if running in Uvicorn's reloader
def is_reloader_process():
@@ -32,9 +62,54 @@ load_dotenv()
import torch
import psutil
+# Device selection with support for Apple Silicon MPS
+# Define device globally for consistent use throughout
+DEVICE = "cpu" # Default to CPU, will be updated based on availability
+
# Detect if we're on a high-end system based on hardware capabilities
HIGH_END_GPU = False
-if torch.cuda.is_available():
+APPLE_SILICON = False
+
+# Check for Apple Silicon MPS support first
+if torch.backends.mps.is_available():
+ DEVICE = "mps"
+ APPLE_SILICON = True
+
+ # Get Apple Silicon details
+ import platform
+ import subprocess
+
+ # Get chip model and memory
+ chip_model = platform.processor()
+ try:
+ # Get memory info using sysctl
+ mem_cmd = subprocess.run(["sysctl", "hw.memsize"], capture_output=True, text=True)
+ if mem_cmd.returncode == 0:
+ mem_bytes = int(mem_cmd.stdout.split(':')[1].strip())
+ mem_gb = mem_bytes / (1024**3)
+ else:
+ mem_gb = psutil.virtual_memory().total / (1024**3)
+ except Exception:
+ mem_gb = psutil.virtual_memory().total / (1024**3)
+
+ # Detect high-end Apple Silicon (M1 Pro/Max/Ultra, M2 Pro/Max/Ultra, M3 Pro/Max/Ultra)
+ if "Pro" in chip_model or "Max" in chip_model or "Ultra" in chip_model or mem_gb >= 32:
+ HIGH_END_GPU = True
+ if not IS_RELOADER:
+ print(f"🍎 Hardware: High-end Apple Silicon detected")
+ print(f"📊 Chip: {chip_model}")
+ print(f"📊 RAM: {mem_gb:.2f} GB unified memory")
+ print("🚀 Using high-performance Apple Silicon optimizations")
+ else:
+ if not IS_RELOADER:
+ print(f"🍎 Hardware: Apple Silicon detected")
+ print(f"📊 Chip: {chip_model}")
+ print(f"📊 RAM: {mem_gb:.2f} GB unified memory")
+ print("🚀 Using Apple Silicon optimizations")
+
+# Then check for CUDA GPU
+elif torch.cuda.is_available():
+ DEVICE = "cuda"
# Get GPU properties
props = torch.cuda.get_device_properties(0)
gpu_name = props.name
@@ -67,7 +142,7 @@ else:
ram_gb = psutil.virtual_memory().total / (1024**3)
if not IS_RELOADER:
- print(f"🖥️ Hardware: CPU only (No CUDA GPU detected)")
+ print(f"🖥️ Hardware: CPU only (No GPU acceleration detected)")
print(f"📊 CPU: {cpu_cores} cores, {cpu_threads} threads")
print(f"📊 RAM: {ram_gb:.2f} GB")
print("⚙️ Using CPU-optimized settings")
@@ -134,10 +209,78 @@ if not IS_RELOADER:
print(f" REPETITION_PENALTY: {REPETITION_PENALTY}")
# Parallel processing settings
-NUM_WORKERS = 4 if HIGH_END_GPU else 2
+import multiprocessing
+
+# Determine optimal settings based on hardware
+CPU_CORES = multiprocessing.cpu_count()
+
+# For Apple Silicon, use more aggressive settings depending on the model
+if APPLE_SILICON:
+ # Optimize for Apple Silicon based on memory
+ ram_gb = psutil.virtual_memory().total / (1024**3)
+ if ram_gb >= 64: # High-memory M1 Max/Ultra, M2 Max/Ultra, M3 Max/Ultra (64GB+)
+ NUM_WORKERS = max(8, min(CPU_CORES-2, 12))
+ BATCH_SIZE = 64
+ AUDIO_QUEUE_SIZE = 200
+ elif ram_gb >= 32: # Mid-range models (32GB)
+ NUM_WORKERS = max(4, min(CPU_CORES-2, 8))
+ BATCH_SIZE = 48
+ AUDIO_QUEUE_SIZE = 150
+ else: # Base models
+ NUM_WORKERS = max(2, min(CPU_CORES-1, 4))
+ BATCH_SIZE = 32
+ AUDIO_QUEUE_SIZE = 100
+elif HIGH_END_GPU: # High-end CUDA GPU
+ NUM_WORKERS = 4
+ BATCH_SIZE = 32
+ AUDIO_QUEUE_SIZE = 100
+else: # Regular CUDA or CPU
+ NUM_WORKERS = 2
+ BATCH_SIZE = 16
+ AUDIO_QUEUE_SIZE = 50
+
+# Buffer size for audio processing
+if APPLE_SILICON and psutil.virtual_memory().total >= (64 * 1024**3): # 64GB+ RAM
+ BUFFER_SIZE_MB = 4 # 4MB buffer
+elif APPLE_SILICON or HIGH_END_GPU:
+ BUFFER_SIZE_MB = 2 # 2MB buffer
+else:
+ BUFFER_SIZE_MB = 1 # 1MB buffer
+
+BUFFER_MAX_SIZE = BUFFER_SIZE_MB * 1024 * 1024
+
+# Maximum number of characters per batch for NLTK sentence splitting
+try:
+ MAX_BATCH_CHARS = int(os.environ.get("ORPHEUS_MAX_BATCH_CHARS", "600"))
+ if MAX_BATCH_CHARS < 100 or MAX_BATCH_CHARS > 2000:
+ print(f"WARNING: Invalid ORPHEUS_MAX_BATCH_CHARS value ({MAX_BATCH_CHARS}), should be between 100-2000. Using 600 as fallback.")
+ MAX_BATCH_CHARS = 600
+except (ValueError, TypeError):
+ print("WARNING: Invalid ORPHEUS_MAX_BATCH_CHARS value, using 600 as fallback")
+ MAX_BATCH_CHARS = 600
+
+# Crossfade duration in milliseconds for stitching audio batches
+try:
+ CROSSFADE_MS = int(os.environ.get("ORPHEUS_CROSSFADE_MS", "30"))
+ if CROSSFADE_MS < 10 or CROSSFADE_MS > 200:
+ print(f"WARNING: Invalid ORPHEUS_CROSSFADE_MS value ({CROSSFADE_MS}), should be between 10-200. Using 30 as fallback.")
+ CROSSFADE_MS = 30
+except (ValueError, TypeError):
+ print("WARNING: Invalid ORPHEUS_CROSSFADE_MS value, using 30 as fallback")
+ CROSSFADE_MS = 30
+
+# Helper function to generate equal power fade curves using sine/cosine
+def generate_equal_power_fade_curves(num_samples):
+ """Generate fade-out and fade-in curves using sine/cosine for equal power crossfading."""
+ # Create a linear ramp from 0 to pi/2
+ ramp = np.linspace(0, np.pi/2, num_samples)
+ # Use sine for fade-out and cosine for fade-in to maintain equal power
+ fade_out = np.sin(ramp)
+ fade_in = np.cos(ramp)
+ return fade_out, fade_in
# Define voices by language
-ENGLISH_VOICES = ["tara", "leah", "jess", "leo", "dan", "mia", "zac", "zoe"]
+ENGLISH_VOICES = ["tara", "kaya", "leah", "jess", "leo", "dan", "mia", "zac", "zoe"]
FRENCH_VOICES = ["pierre", "amelie", "marie"]
GERMAN_VOICES = ["jana", "thomas", "max"]
KOREAN_VOICES = ["유나", "준서"]
@@ -215,56 +358,93 @@ class PerformanceMonitor:
# 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")
+ # 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()
+# --- Read System Prompt ---
+SYSTEM_PROMPT_CONTENT = None
+PAST_CONTEXT = None # For long texts, we are going to store the past context in this variable and pass it to the model as a system prompt.
+SYSTEM_PROMPT_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "System_Prompt.md")
+
+try:
+ if os.path.exists(SYSTEM_PROMPT_PATH):
+ with open(SYSTEM_PROMPT_PATH, 'r', encoding='utf-8') as f:
+ SYSTEM_PROMPT_CONTENT = f.read()
+ if not IS_RELOADER:
+ logger.info(f"Successfully loaded system prompt from {SYSTEM_PROMPT_PATH}")
+ else:
+ if not IS_RELOADER:
+ logger.warning(f"System prompt file not found at {SYSTEM_PROMPT_PATH}. Proceeding without system prompt.")
+except Exception as e:
+ if not IS_RELOADER:
+ logger.error(f"Error reading system prompt file {SYSTEM_PROMPT_PATH}: {e}")
+# --- End System Prompt Read ---
+
def format_prompt(prompt: str, voice: str = DEFAULT_VOICE) -> str:
- """Format prompt for Orpheus model with voice prefix and special tokens."""
+ """Format prompt for Orpheus model with voice prefix and special tokens.
+ Restored based on backup file.
+ """
# Validate voice and provide fallback
if voice not in AVAILABLE_VOICES:
- print(f"Warning: Voice '{voice}' not recognized. Using '{DEFAULT_VOICE}' instead.")
+ logger.warning(f"Voice '{voice}' not recognized. Using '{DEFAULT_VOICE}' instead.")
voice = DEFAULT_VOICE
- # Format similar to how engine_class.py does it with special tokens
+ # Format similar to original backup
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
+ # Add special token markers required by Orpheus
+ special_start = "<|audio|>"
+ special_end = "<|eot_id|>"
- return f"{special_start}{formatted_prompt}{special_end}"
+ return f"{special_start}{formatted_prompt}{special_end}" # Restored original format
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."""
+ repetition_penalty: float = REPETITION_PENALTY,
+ context_prompt: Optional[str] = None) -> Generator[str, None, None]:
+ """Generate tokens from text using OpenAI-compatible API (prompt format).
+ Uses the separate 'system_prompt' payload field for system/context info if available.
+ """
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)
+ # Format the main part of the user prompt (e.g., <|audio|>voice: text<|eot_id|>)
+ formatted_user_prompt = format_prompt(prompt, voice)
+ print(f"Generating speech for user prompt (voice: {voice}): {prompt[:80]}...")
+
+ # Combine system prompt and context prompt for the dedicated field
+ system_and_context = ""
+ if SYSTEM_PROMPT_CONTENT:
+ system_and_context += SYSTEM_PROMPT_CONTENT
+ print("Using system prompt.")
+ if context_prompt:
+ # Add clear separation if both system and context exist
+ if system_and_context:
+ system_and_context += "\n\n[Previous context:]\n"
+ else: # Only context exists
+ system_and_context += "[Previous context:]\n"
+ system_and_context += context_prompt
+ print("Using context prompt.")
+
+ # Create the request payload
payload = {
- "prompt": formatted_prompt,
+ "prompt": formatted_user_prompt, # Main user prompt here
"max_tokens": max_tokens,
"temperature": temperature,
"top_p": top_p,
"repeat_penalty": repetition_penalty,
- "stream": True # Always stream for better performance
+ "stream": True
}
- # 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
+ # Add the dedicated system_prompt field if we have content for it
+ if system_and_context:
+ payload["system"] = system_and_context
+ print(f"Sending system_prompt field: {system_and_context[:100]}...")
+
+ # Add model field - optional but good practice
model_name = os.environ.get("ORPHEUS_MODEL_NAME", "Orpheus-3b-FT-Q8_0.gguf")
- payload["model"] = model_name
+ if model_name:
+ payload["model"] = model_name
# Session for connection pooling and retry logic
session = requests.Session()
@@ -274,7 +454,7 @@ def generate_tokens_from_api(prompt: str, voice: str = DEFAULT_VOICE, temperatur
while retry_count < max_retries:
try:
- # Make the API request with streaming and timeout
+ # Make the API request
response = session.post(
API_URL,
headers=HEADERS,
@@ -284,72 +464,66 @@ def generate_tokens_from_api(prompt: str, voice: str = DEFAULT_VOICE, temperatur
)
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)
+ error_detail = response.text
+ logger.error(f"API Error ({response.status_code}): {error_detail}")
+ # Removed the messages vs prompt check as we now use prompt
if response.status_code >= 500:
retry_count += 1
- wait_time = 2 ** retry_count # Exponential backoff
- print(f"Retrying in {wait_time} seconds...")
+ wait_time = 2 ** retry_count
+ logger.info(f"Retrying in {wait_time} seconds... (attempt {retry_count+1}/{max_retries})")
time.sleep(wait_time)
continue
return
- # Process the streamed response with better buffering
- buffer = ""
+ # Process the streamed response (parsing logic remains the same, checking for 'text')
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
-
+ data_str = line_str[6:]
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}>'
+ choice = data['choices'][0]
+ token_chunk = choice.get('text', '') # Expecting 'text' for /v1/completions
+
+ if token_chunk and token_chunk.startswith(""):
token_counter += 1
perf_monitor.add_tokens()
+ yield token_chunk
- if token_text:
- yield token_text
except json.JSONDecodeError as e:
- print(f"Error decoding JSON: {e}")
+ logger.error(f"Error decoding JSON stream data: {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")
+ logger.warning(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})")
+ logger.info(f"Retrying in {wait_time} seconds... (attempt {retry_count+1}/{max_retries})")
time.sleep(wait_time)
else:
- print("Max retries reached. Token generation failed.")
+ logger.error("Max retries reached for timeout. Token generation failed.")
return
- except requests.exceptions.ConnectionError:
- print(f"Connection error to API at {API_URL}")
+ except requests.exceptions.RequestException as e:
+ logger.error(f"Request error connecting to API at {API_URL}: {e}")
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})")
+ logger.info(f"Retrying in {wait_time} seconds... (attempt {retry_count+1}/{max_retries})")
time.sleep(wait_time)
else:
- print("Max retries reached. Token generation failed.")
+ logger.error("Max retries reached for connection error. Token generation failed.")
return
# The turn_token_into_id function is now imported from speechpipe.py
@@ -367,7 +541,7 @@ def convert_to_audio(multiframe: List[int], count: int) -> Optional[bytes]:
return result
-async def tokens_decoder(token_gen) -> Generator[bytes, None, None]:
+async def tokens_decoder(token_gen) -> AsyncGenerator[bytes, None]:
"""Simplified token decoder with early first-chunk processing for lower latency."""
buffer = []
count = 0
@@ -391,12 +565,12 @@ async def tokens_decoder(token_gen) -> Generator[bytes, None, None]:
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
+ # 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:
@@ -417,8 +591,8 @@ async def tokens_decoder(token_gen) -> Generator[bytes, None, None]:
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)}")
+ # 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)
@@ -427,8 +601,8 @@ async def tokens_decoder(token_gen) -> Generator[bytes, None, None]:
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
+ # Use hardware-optimized queue size
+ queue_size = AUDIO_QUEUE_SIZE
audio_queue = queue.Queue(maxsize=queue_size)
audio_segments = []
@@ -442,8 +616,8 @@ def tokens_decoder_sync(syn_token_gen, output_file=None):
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
+ # Use optimized batch processing
+ batch_size = BATCH_SIZE
# Thread synchronization for proper completion detection
producer_done_event = threading.Event()
@@ -479,16 +653,16 @@ def tokens_decoder_sync(syn_token_gen, output_file=None):
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
+ # 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
@@ -514,10 +688,9 @@ def tokens_decoder_sync(syn_token_gen, output_file=None):
# 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
+ # Use hardware-optimized buffer size
write_buffer = bytearray()
- buffer_max_size = 1024 * 1024 # 1MB max buffer size (adjustable)
+ buffer_max_size = BUFFER_MAX_SIZE
# Keep track of the last time we checked for completion
last_check_time = time.time()
@@ -624,201 +797,512 @@ 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
+# Map for NLTK language names (add more as needed/supported by punkt)
+NLTK_LANG_MAP = {
+ "english": "english",
+ "french": "french",
+ "german": "german",
+ "spanish": "spanish",
+ "italian": "italian",
+ # Add other languages if punkt models exist for them
+}
+
+def split_text_into_sentences(text: str, language: str = "english", max_chars_per_segment: int = MAX_BATCH_CHARS) -> List[str]:
+ """Split text into sentences using NLTK for better accuracy."""
+ ensure_nltk_punkt() # Make sure model is available
+
+ nltk_lang = NLTK_LANG_MAP.get(language, "english") # Default to English if mapped lang not found
+ print(f"Splitting text into sentences using NLTK for language: {nltk_lang}")
- # First, split on common sentence ending punctuation
- # This isn't perfect but works for most cases and avoids the regex error
- parts = []
- current_sentence = ""
+ try:
+ # Attempt to use the specified language model
+ sentences = nltk.sent_tokenize(text, language=nltk_lang)
+ print(f"Successfully tokenized using NLTK for language: {nltk_lang}")
+ except Exception as e:
+ # Fallback to default English model if specific language model fails or isn't available
+ print(f"Warning: Could not use NLTK tokenizer for language '{nltk_lang}'. Falling back to English. Error: {e}")
+ try:
+ sentences = nltk.sent_tokenize(text)
+ except Exception as inner_e:
+ # If NLTK fails completely, fallback to a very basic split as a last resort
+ print(f"ERROR: NLTK sentence tokenization failed entirely: {inner_e}. Using basic fallback.")
+ sentences = text.split('. ') # Very basic fallback
+
+ # Filter out empty sentences and strip whitespace
+ sentences = [s.strip() for s in sentences if s.strip()]
- 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 = ""
+ # Create segments that respect the max_chars_per_segment limit
+ segments = []
+ current_segment = ""
- # 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]
+ for sentence in sentences:
+ # If a single sentence exceeds the limit, we need to split it
+ if len(sentence) > max_chars_per_segment:
+ # If we have a current segment, add it first
+ if current_segment:
+ segments.append(current_segment)
+ current_segment = ""
- combined_sentences.append(current)
- i += 1
+ # Split the long sentence into chunks
+ words = sentence.split()
+ current_chunk = ""
+
+ for word in words:
+ # If adding this word would exceed the limit, start a new chunk
+ if len(current_chunk) + len(word) + 1 > max_chars_per_segment:
+ if current_chunk:
+ segments.append(current_chunk)
+ current_chunk = word
+ else:
+ # Add word to current chunk with a space if needed
+ current_chunk = f"{current_chunk} {word}" if current_chunk else word
+
+ # Add the last chunk if it exists
+ if current_chunk:
+ segments.append(current_chunk)
+ else:
+ # Check if adding this sentence would exceed the limit
+ if current_segment:
+ potential_length = len(current_segment) + 1 + len(sentence)
+ if potential_length > max_chars_per_segment:
+ segments.append(current_segment)
+ current_segment = sentence
+ else:
+ current_segment = f"{current_segment} {sentence}"
+ else:
+ current_segment = sentence
- return combined_sentences
+ # Add the last segment if it exists
+ if current_segment:
+ segments.append(current_segment)
+
+ # Verify that no segment exceeds the limit
+ for i, segment in enumerate(segments):
+ if len(segment) > max_chars_per_segment:
+ print(f"Warning: Segment {i} exceeds max_chars_per_segment ({len(segment)} > {max_chars_per_segment})")
+ # Split the segment into smaller chunks
+ words = segment.split()
+ new_segments = []
+ current_chunk = ""
+
+ for word in words:
+ if len(current_chunk) + len(word) + 1 > max_chars_per_segment:
+ if current_chunk:
+ new_segments.append(current_chunk)
+ current_chunk = word
+ else:
+ current_chunk = f"{current_chunk} {word}" if current_chunk else word
+
+ if current_chunk:
+ new_segments.append(current_chunk)
+
+ # Replace the long segment with the new segments
+ segments[i:i+1] = new_segments
+
+ print(f"Split text into {len(segments)} segments with max length {max_chars_per_segment}")
+ return segments
+
+def cleanup_between_batches():
+ """Reset all state between batch processing."""
+ # Reset the performance monitor
+ global perf_monitor
+ perf_monitor = PerformanceMonitor()
+
+ # Import here to avoid circular imports
+ from .speechpipe import reset_state
+ # Reset the speechpipe state
+ reset_state()
+
+ # Force garbage collection
+ import gc
+ gc.collect()
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."""
+ use_batching=True, max_batch_chars=MAX_BATCH_CHARS):
+ """Generate speech from text using Orpheus model with performance optimizations and NLTK splitting."""
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'}")
+ print(f"Using voice: {voice}, GPU acceleration: {'Yes (High-end)' if HIGH_END_GPU else 'Yes' if torch.cuda.is_available() or torch.backends.mps.is_available() else 'No'}")
- # Reset performance monitor
+ # Reset performance monitor at start
global perf_monitor
perf_monitor = PerformanceMonitor()
start_time = time.time()
- # For shorter text, use the standard non-batched approach
+ all_audio_segments = [] # To store the final small chunks for return/streaming
+
+ # Determine language for splitting
+ generation_language = VOICE_TO_LANGUAGE.get(voice, "english")
+
+ # For shorter text or disabled batching, 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(
+ print("Processing text as a single batch.")
+ # Note: repetition_penalty is ignored (uses hardcoded 1.1)
+ all_audio_segments = 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
+ repetition_penalty=REPETITION_PENALTY # Fixed value
),
- output_file=output_file
+ output_file=output_file # Pass file handle if saving
)
- # 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)
+ pass # File writing is handled internally by tokens_decoder_sync
+ else:
+ pass # Segments are already in all_audio_segments
+
+ # For longer text, use sentence-based batching with NLTK and crossfading
+ else:
+ print(f"Using sentence-based batching for text with {len(prompt)} characters (limit: {max_batch_chars})")
- # 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
- )
+ # Split the text into segments using NLTK, passing the limit
+ segments = split_text_into_sentences(prompt, language=generation_language, max_chars_per_segment=max_batch_chars)
+ print(f"Split text into {len(segments)} segments using NLTK ({generation_language}).")
- # 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)
+ # Process each segment and collect audio segments
+ all_complete_batch_audio = [] # Store complete audio (bytes) for each batch
+ batch_temp_files = []
+ all_results = [(None, None, None, None)] * len(segments) # Pre-allocate results list
- # 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
+ # Determine if we should use parallel processing
+ use_parallel = APPLE_SILICON and len(segments) > 1 and psutil.virtual_memory().total >= (32 * 1024**3)
+
+ if use_parallel:
+ print(f"Using parallel processing with {NUM_WORKERS} workers for {len(segments)} segments")
+
+ # Define a function to process a single segment
+ def process_segment(segment_text, index, previous_segment_text):
+ print(f"Starting parallel processing of segment {index+1}/{len(segments)} ({len(segment_text)} characters)")
+
+ # Clean up state before processing
+ cleanup_between_batches()
+
+ # --- Prepare context from previous segment ---
+ context_for_next = ""
+ if previous_segment_text:
+ try:
+ # Extract last sentence as context
+ # Make sure NLTK data is available (should be loaded globally)
+ ensure_nltk_punkt()
+ prev_sentences = nltk.sent_tokenize(previous_segment_text)
+ if prev_sentences:
+ # Use only the last sentence as context
+ context_for_next = prev_sentences[-1].strip()
+ print(f"Segment {index+1}: Using last sentence of previous segment as context.")
+ except Exception as e:
+ logger.warning(f"Segment {index+1}: Could not extract context from previous segment: {e}")
+ # --- End context preparation ---
+
+ # Create a temporary file if needed
+ temp_file = None
+ if output_file:
+ temp_file = f"outputs/temp_batch_{index}_{int(time.time())}.wav"
+ # Note: Appending to batch_temp_files needs thread-safety or post-processing
+
+ # Process the segment, passing the context
+ segments_output = tokens_decoder_sync(
+ generate_tokens_from_api(
+ prompt=segment_text,
+ voice=voice,
+ temperature=temperature,
+ top_p=top_p,
+ max_tokens=max_tokens,
+ repetition_penalty=REPETITION_PENALTY,
+ context_prompt=context_for_next # Pass context here
+ ),
+ output_file=temp_file
+ )
+
+ # Combine the audio fragments
+ complete_audio = b"".join(segments_output)
+ print(f"Completed parallel processing of segment {index+1}")
+
+ return index, complete_audio, segments_output, temp_file
+
+ # Use ThreadPoolExecutor for parallel processing
+ with ThreadPoolExecutor(max_workers=NUM_WORKERS) as executor:
+ # Submit all segments for processing, passing previous segment text
+ future_to_segment = {
+ executor.submit(process_segment,
+ segment,
+ i,
+ segments[i-1] if i > 0 else None): # Pass previous segment
+ i for i, segment in enumerate(segments)
+ }
+
+ # Process segments as they complete
+ temp_batch_files_collector = {} # Collect temp files safely
+ for future in as_completed(future_to_segment):
+ try:
+ idx, complete_audio, segments_output, temp_file = future.result()
+ # Store the results in the pre-allocated list using the index
+ all_results[idx] = (idx, complete_audio, segments_output, temp_file)
+ if temp_file:
+ temp_batch_files_collector[idx] = temp_file
+ except Exception as e:
+ idx = future_to_segment[future] # Get index from map
+ print(f"Error processing segment {idx+1}: {e}")
+ # Store error indication if needed, e.g., all_results[idx] = (idx, None, None, None)
+
+ # Extract results in original order from the pre-allocated list
+ all_complete_batch_audio = [audio for idx, audio, _, _ in all_results if audio is not None]
+ batch_temp_files = [temp_batch_files_collector[i] for i in sorted(temp_batch_files_collector) if temp_batch_files_collector[i] is not None]
+
+ if not output_file:
+ # Flatten the segments in original order
+ temp_segments = []
+ for idx, _, segments_output, _ in all_results:
+ if segments_output is not None:
+ temp_segments.extend(segments_output)
+ all_audio_segments = temp_segments
+ else:
+ # Process segments sequentially (original logic, but add context passing)
+ for i, segment in enumerate(segments):
+ print(f"Processing segment {i+1}/{len(segments)} ({len(segment)} characters)")
+
+ # Clean up state between batches
+ cleanup_between_batches()
+
+ # --- Prepare context from previous segment ---
+ context_for_next = ""
+ if i > 0 and segments[i-1]: # Check if previous segment exists
+ try:
+ ensure_nltk_punkt()
+ prev_sentences = nltk.sent_tokenize(segments[i-1])
+ if prev_sentences:
+ context_for_next = prev_sentences[-1].strip()
+ print(f"Segment {i+1}: Using last sentence of previous segment as context.")
+ except Exception as e:
+ logger.warning(f"Segment {i+1}: Could not extract context from previous segment: {e}")
+ # --- End context preparation ---
+
+ # Create a temporary file ONLY if a final output file is requested
+ temp_output_file_for_batch = None
+ if output_file:
+ temp_output_file_for_batch = f"outputs/temp_batch_{i}_{int(time.time())}.wav"
+ batch_temp_files.append(temp_output_file_for_batch)
+
+ # Generate speech for this segment, passing context
+ batch_segments = tokens_decoder_sync(
+ generate_tokens_from_api(
+ prompt=segment,
+ voice=voice,
+ temperature=temperature,
+ top_p=top_p,
+ max_tokens=max_tokens,
+ repetition_penalty=REPETITION_PENALTY,
+ context_prompt=context_for_next # Pass context here
+ ),
+ output_file=temp_output_file_for_batch
+ )
+
+ # Combine the small segments from this batch into one bytes object
+ complete_batch_audio_bytes = b"".join(batch_segments)
+ all_complete_batch_audio.append(complete_batch_audio_bytes)
+
+ # Also keep track of small segments if not writing to file and no crossfading needed later
+ if not output_file:
+ all_audio_segments.extend(batch_segments)
+
+ # Explicitly clear segments list after processing all segments
+ segments = None
+ import gc
+ gc.collect()
+
+ # --- Post-Batch Processing ---
+ if output_file:
+ # If an output file was requested, stitch together the temporary batch files
+ if batch_temp_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 temp file {temp_file}: {e}")
+ # In file output mode, the final return value isn't the audio data itself
+ all_audio_segments = [] # Clear segments as data is in the file
+
+ elif len(all_complete_batch_audio) > 1:
+ # If NO output file AND more than one batch was processed, apply crossfading
+ print(f"Applying in-memory crossfade to {len(all_complete_batch_audio)} audio batches.")
+ try:
+ # Convert bytes to numpy arrays
+ batch_arrays = [np.frombuffer(b, dtype=np.int16) for b in all_complete_batch_audio if b] # Filter empty
+
+ if len(batch_arrays) > 1:
+ # Apply crossfading logic with optimizations for high-memory systems
+ if APPLE_SILICON and psutil.virtual_memory().total >= (64 * 1024**3):
+ # Pre-allocate the final array to avoid repeated concatenations
+ # First calculate total length of all arrays minus crossfade regions
+ crossfade_samples = int(SAMPLE_RATE * CROSSFADE_MS / 1000)
+ total_samples = sum(len(arr) for arr in batch_arrays)
+ # Subtract overlapping regions
+ total_samples -= crossfade_samples * (len(batch_arrays) - 1)
+
+ # Pre-allocate the final array
+ print(f"Pre-allocating array for {total_samples} samples")
+ final_audio_np = np.zeros(total_samples, dtype=np.int16)
+
+ # Fill the array with crossfaded audio
+ write_position = 0
+
+ for i, audio_np in enumerate(batch_arrays):
+ if i == 0:
+ # First segment - copy directly
+ final_audio_np[:len(audio_np)] = audio_np
+ write_position += len(audio_np) - crossfade_samples
+ else:
+ # For other segments, apply crossfade
+ # Generate equal power fade curves
+ fade_out, fade_in = generate_equal_power_fade_curves(crossfade_samples)
+
+ # Create crossfade region
+ prev_end = write_position
+ crossfade_region = (final_audio_np[prev_end:prev_end+crossfade_samples] * fade_out +
+ audio_np[:crossfade_samples] * fade_in).astype(np.int16)
+
+ # Write crossfade region
+ final_audio_np[prev_end:prev_end+crossfade_samples] = crossfade_region
+
+ # Write remainder of current segment
+ next_end = prev_end + crossfade_samples + len(audio_np) - crossfade_samples
+ final_audio_np[prev_end+crossfade_samples:next_end] = audio_np[crossfade_samples:]
+
+ # Update write position
+ write_position = next_end
+ else:
+ # Use standard approach for systems with less memory
+ final_audio_np = np.array([], dtype=np.int16)
+ # Use the constant for crossfade duration
+ crossfade_samples = int(SAMPLE_RATE * CROSSFADE_MS / 1000)
+ print(f"Applying {CROSSFADE_MS}ms crossfade ({crossfade_samples} samples)")
+
+ for i, audio_np in enumerate(batch_arrays):
+ if i == 0:
+ final_audio_np = audio_np
+ else:
+ # Apply crossfade
+ prev_audio_np = final_audio_np
+ current_audio_np = audio_np
+
+ if len(prev_audio_np) >= crossfade_samples and len(current_audio_np) >= crossfade_samples:
+ # Generate equal power fade curves
+ fade_out, fade_in = generate_equal_power_fade_curves(crossfade_samples)
+
+ crossfade_region = (prev_audio_np[-crossfade_samples:] * fade_out +
+ current_audio_np[:crossfade_samples] * fade_in).astype(np.int16)
+
+ final_audio_np = np.concatenate([
+ prev_audio_np[:-crossfade_samples],
+ crossfade_region,
+ current_audio_np[crossfade_samples:]
+ ])
+ else:
+ # Segments too short for crossfade, concatenate directly
+ print(f"Warning: Segments too short for crossfade between batch {i-1} and {i}. Concatenating.")
+ final_audio_np = np.concatenate([prev_audio_np, current_audio_np])
+
+ # Convert final numpy array back to bytes and wrap in a list
+ all_audio_segments = [final_audio_np.tobytes()]
+ print("In-memory crossfading complete.")
+
+ # Clear batch arrays to free memory
+ batch_arrays = None
+ all_complete_batch_audio = None
+ gc.collect()
+ else:
+ # Only one valid batch array, use the original segments
+ print("Only one batch generated after filtering, no crossfading needed.")
+ pass
+ except Exception as e:
+ print(f"ERROR during in-memory crossfading: {e}. Returning raw concatenated segments.")
+ # Fallback: return the original potentially discontinuous segments if crossfade fails
+ all_audio_segments = [b"".join(all_audio_segments)] # Combine all small chunks
+
+ # --- Final Reporting ---
end_time = time.time()
total_time = end_time - start_time
- # Calculate combined duration
+ # Calculate combined duration from the final segments to be returned
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")
+ # Handle if crossfading resulted in a single large chunk
+ if len(all_audio_segments) == 1:
+ total_bytes = len(all_audio_segments[0])
+ else: # Original chunked segments
+ total_bytes = sum(len(segment) for segment in all_audio_segments)
+ if total_bytes > 0:
+ duration = total_bytes / (2 * SAMPLE_RATE) # 2 bytes per sample
+ print(f"Generated {len(all_audio_segments)} final audio segment(s)") # Correctly reports 1 segment after crossfade
+ print(f"Generated {duration:.2f} seconds of audio in {total_time:.2f} seconds")
+ if total_time > 0:
+ realtime_factor = duration / total_time
+ 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")
+ else:
+ print("Generation time was negligible.")
+ else:
+ print("Warning: No audio data generated.")
+
print(f"Total speech generation completed in {total_time:.2f} seconds")
+ # Return the final audio segments (either original chunks or one combined chunk after crossfade)
return all_audio_segments
-def stitch_wav_files(input_files, output_file, crossfade_ms=50):
+def stitch_wav_files(input_files, output_file):
"""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")
+ 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)
+ print(f"Only one input file, copied directly to {output_file}")
return
- # Convert crossfade_ms to samples
- crossfade_samples = int(SAMPLE_RATE * crossfade_ms / 1000)
+ # Convert crossfade_ms to samples using the constant
+ 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
+ # Standard WAV parameters to enforce
+ standard_params = {
+ 'nchannels': 1,
+ 'sampwidth': 2,
+ 'framerate': SAMPLE_RATE
+ }
+
for i, input_file in enumerate(input_files):
try:
with wave.open(input_file, 'rb') as wav:
+ # Get current file parameters
+ current_params = wav.getparams()
+
+ # Check and standardize parameters
if first_params is None:
- first_params = wav.getparams()
- elif wav.getparams() != first_params:
- print(f"Warning: WAV file {input_file} has different parameters")
-
+ first_params = current_params
+ # Verify first file meets our standards
+ if (current_params.nchannels != standard_params['nchannels'] or
+ current_params.sampwidth != standard_params['sampwidth'] or
+ current_params.framerate != standard_params['framerate']):
+ print(f"Warning: First WAV file {input_file} has non-standard parameters. Converting to standard format.")
+ elif current_params != first_params:
+ print(f"Warning: WAV file {input_file} has different parameters. Converting to standard format.")
+
+ # Read frames and convert to numpy array
frames = wav.readframes(wav.getnframes())
audio = np.frombuffer(frames, dtype=np.int16)
@@ -828,9 +1312,8 @@ def stitch_wav_files(input_files, output_file, crossfade_ms=50):
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)
+ # Generate equal power fade curves
+ fade_out, fade_in = generate_equal_power_fade_curves(crossfade_samples)
# Apply crossfade
crossfade_region = (final_audio[-crossfade_samples:] * fade_out +
@@ -855,7 +1338,10 @@ def stitch_wav_files(input_files, output_file, crossfade_ms=50):
if first_params is None:
raise ValueError("No valid WAV files were processed")
- output_wav.setparams(first_params)
+ # Use standard parameters for output
+ output_wav.setnchannels(standard_params['nchannels'])
+ output_wav.setsampwidth(standard_params['sampwidth'])
+ output_wav.setframerate(standard_params['framerate'])
output_wav.writeframes(final_audio.tobytes())
print(f"Successfully stitched audio to {output_file} with crossfading")
diff --git a/tts_engine/speechpipe.py b/tts_engine/speechpipe.py
index 1a789d7..ce752a0 100644
--- a/tts_engine/speechpipe.py
+++ b/tts_engine/speechpipe.py
@@ -17,10 +17,32 @@ def is_reloader_process():
# Set a flag to avoid repeat messages
IS_RELOADER = is_reloader_process()
+# Detect hardware capabilities
+APPLE_SILICON = torch.backends.mps.is_available()
+CUDA_AVAILABLE = torch.cuda.is_available()
+
+# Set device for model processing
+if APPLE_SILICON:
+ DEVICE = "mps"
+ if not IS_RELOADER:
+ print("🍎 Using Apple Silicon MPS for speech generation")
+elif CUDA_AVAILABLE:
+ DEVICE = "cuda"
+ if not IS_RELOADER:
+ print("🖥️ Using CUDA for speech generation")
+else:
+ DEVICE = "cpu"
+ if not IS_RELOADER:
+ print("⚙️ Using CPU for speech generation")
+
+# Check if CoreML should be enabled
+# USE_COREML = APPLE_SILICON and os.environ.get("ORPHEUS_USE_COREML", "1") == "1"
+# CoreML logic removed
+
# Try to enable torch.compile if PyTorch 2.0+ is available
TORCH_COMPILE_AVAILABLE = False
try:
- if hasattr(torch, 'compile'):
+ if hasattr(torch, 'compile') and not APPLE_SILICON: # torch.compile not fully supported on MPS yet
TORCH_COMPILE_AVAILABLE = True
if not IS_RELOADER:
print("PyTorch 2.0+ detected, torch.compile is available")
@@ -30,29 +52,58 @@ except:
# Try to enable CUDA graphs if available
CUDA_GRAPHS_AVAILABLE = False
try:
- if torch.cuda.is_available() and hasattr(torch.cuda, 'make_graphed_callables'):
+ if CUDA_AVAILABLE and hasattr(torch.cuda, 'make_graphed_callables'):
CUDA_GRAPHS_AVAILABLE = True
if not IS_RELOADER:
print("CUDA graphs support is available")
except:
pass
-model = SNAC.from_pretrained("hubertsiuzdak/snac_24khz").eval()
+# Load the model with appropriate device placement
+base_model = SNAC.from_pretrained("hubertsiuzdak/snac_24khz").eval()
+base_model = base_model.to(DEVICE)
-# 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"
-if not IS_RELOADER:
- print(f"Using device: {snac_device}")
-model = model.to(snac_device)
+# Assign base_model directly as the model to use
+model = base_model
+
+# Check if CoreML wrapper should be used
+# if USE_COREML:
+# try:
+# from .coreml_wrapper import CoreMLWrapper
+# # Wrap the base model with CoreML for Apple Silicon
+# model = CoreMLWrapper(base_model, device=DEVICE)
+# if model.use_coreml and model.coreml_model is not None:
+# if not IS_RELOADER:
+# print("🧠 Using CoreML Neural Engine acceleration!")
+# else:
+# if not IS_RELOADER:
+# print("CoreML model not available, using MPS acceleration instead")
+# except ImportError:
+# # If CoreML wrapper is not available, use the base model
+# model = base_model
+# if not IS_RELOADER:
+# print("CoreML wrapper not available, using standard PyTorch backend")
+# else:
+# # Use base model directly
+# model = base_model
-# Disable torch.compile as it requires Triton which isn't installed
-# We'll use regular PyTorch optimization techniques instead
if not IS_RELOADER:
- print("Using standard PyTorch optimizations (torch.compile disabled)")
+ print(f"SNAC model loaded directly on {DEVICE} (CoreML export disabled)")
+
+# Disable torch.compile for MPS as it's not fully supported
+if APPLE_SILICON:
+ if not IS_RELOADER:
+ print("Using standard PyTorch optimizations for Apple Silicon")
+elif TORCH_COMPILE_AVAILABLE:
+ if not IS_RELOADER:
+ print("Using torch.compile for optimized performance")
+else:
+ if not IS_RELOADER:
+ print("Using standard PyTorch optimizations")
# Prepare CUDA streams for parallel processing if available
cuda_stream = None
-if snac_device == "cuda":
+if CUDA_AVAILABLE:
cuda_stream = torch.cuda.Stream()
if not IS_RELOADER:
print("Using CUDA stream for parallel processing")
@@ -60,8 +111,8 @@ if snac_device == "cuda":
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.
+ Optimized version of convert_to_audio that supports Apple Silicon MPS and
+ eliminates inefficient tensor operations for much faster inference.
"""
if len(multiframe) < 7:
return None
@@ -70,12 +121,12 @@ def convert_to_audio(multiframe, count):
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)
+ codes_0 = torch.zeros(num_frames, dtype=torch.int32, device=DEVICE)
+ codes_1 = torch.zeros(num_frames * 2, dtype=torch.int32, device=DEVICE)
+ codes_2 = torch.zeros(num_frames * 4, dtype=torch.int32, device=DEVICE)
# Use vectorized operations where possible
- frame_tensor = torch.tensor(frame, dtype=torch.int32, device=snac_device)
+ frame_tensor = torch.tensor(frame, dtype=torch.int32, device=DEVICE)
# Direct indexing is much faster than concatenation in a loop
for j in range(num_frames):
@@ -107,27 +158,33 @@ def convert_to_audio(multiframe, count):
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()
+ # Context manager depends on device type
+ if CUDA_AVAILABLE:
+ stream_ctx = torch.cuda.stream(cuda_stream)
+ else:
+ stream_ctx = torch.inference_mode()
- with stream_ctx, torch.inference_mode():
- # Decode the audio
- audio_hat = model.decode(codes)
+ with stream_ctx:
+ # Decode the audio using the base model directly
+ audio_hat = model.decode(codes) # model is now directly base_model
# 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":
+ # Process based on device type to minimize data transfers
+ if CUDA_AVAILABLE:
# 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()
+ elif APPLE_SILICON:
+ # For MPS, we need to go through CPU
+ audio_int16_tensor = (audio_slice * 32767).to(torch.int16)
+ audio_bytes = audio_int16_tensor.detach().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()
+ # For CPU, simpler pathway
+ audio_np = audio_slice.detach().numpy()
audio_int16 = (audio_np * 32767).astype(np.int16)
audio_bytes = audio_int16.tobytes()
@@ -189,7 +246,7 @@ async def tokens_decoder(token_gen):
"""Optimized token decoder with early first-chunk processing for lower latency"""
buffer = []
count = 0
-
+
# Track if first chunk has been processed
first_chunk_processed = False
@@ -288,15 +345,69 @@ async def tokens_decoder(token_gen):
audio_samples = convert_to_audio(padded_buffer, count)
if audio_samples is not None:
yield audio_samples
+
+def reset_state():
+ """Reset all state between batch processing."""
+ global token_id_cache, cuda_stream
+
+ # Clear the token ID cache
+ token_id_cache.clear()
+
+ # Reset CUDA stream if available
+ if CUDA_AVAILABLE and cuda_stream is not None:
+ cuda_stream.synchronize()
+ cuda_stream = torch.cuda.Stream()
+
+ # Clear CUDA cache
+ torch.cuda.empty_cache()
+
+ # For Apple Silicon, clear MPS cache if available
+ if APPLE_SILICON:
+ try:
+ # This is the proper way to clear MPS cache in PyTorch 2.0+
+ torch.mps.empty_cache()
+ except:
+ # Fallback for older PyTorch versions or if the operation fails
+ pass
+
+ # Reset CoreML state if needed
+ # if USE_COREML and isinstance(model, CoreMLWrapper) and model.use_coreml:
+ # try:
+ # # CoreML resources are generally managed by the OS
+ # # but we'll manually trigger garbage collection to be safe
+ # import gc
+ # gc.collect()
+ # except:
+ # pass
+
+ # Force garbage collection
+ import gc
+ gc.collect()
+
# ------------------ 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)
+ """Optimized synchronous decoder with hardware-specific optimizations"""
+ # Set queue size based on hardware
+ if APPLE_SILICON:
+ import psutil
+ ram_gb = psutil.virtual_memory().total / (1024**3)
+ if ram_gb >= 64: # High-memory Apple Silicon
+ max_queue_size = 64
+ batch_size = 32
+ elif ram_gb >= 32: # Mid-range Apple Silicon
+ max_queue_size = 48
+ batch_size = 24
+ else: # Base model
+ max_queue_size = 32
+ batch_size = 16
+ elif CUDA_AVAILABLE:
+ max_queue_size = 32
+ batch_size = 16
+ else: # CPU fallback
+ max_queue_size = 8
+ batch_size = 4
- # Collect tokens in batches for higher throughput
- batch_size = 16 if snac_device == "cuda" else 4
+ audio_queue = queue.Queue(maxsize=max_queue_size)
# Convert the synchronous token generator into an async generator with batching
async def async_token_gen():
@@ -340,13 +451,16 @@ def tokens_decoder_sync(syn_token_gen):
def run_async():
asyncio.run(async_producer())
- # Use a higher priority thread for RTX 4090 to ensure it stays fed with work
+ # Create thread with appropriate priority
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
+ # Use hardware-specific buffer sizes
+ if APPLE_SILICON:
+ buffer_size = 8 # Larger buffer for smoother playback on Apple Silicon
+ else:
+ buffer_size = 5
audio_buffer = []
while True: