tts_providers/supertonic_tts.py
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env python3
"""
Supertonic TTS wrapper for OpenVoiceUI.
This module provides a clean interface to the Supertonic Text-to-Speech engine,
wrapping the helper.py functionality for use in Flask applications.
Author: OpenVoiceUI
Date: 2026-02-11
"""
import os
import sys
import logging
from io import BytesIO
from typing import Optional
import numpy as np
import soundfile as sf
# Add the Supertonic helper.py directory to the path
SUPERTONIC_HELPER_PATH = os.environ.get("SUPERTONIC_HELPER_PATH", os.path.expanduser("~/supertonic/py"))
if SUPERTONIC_HELPER_PATH not in sys.path:
sys.path.insert(0, SUPERTONIC_HELPER_PATH)
try:
from helper import (
load_text_to_speech,
load_voice_style,
Style,
)
except ImportError as e:
logging.error(f"Failed to import Supertonic helper: {e}")
logging.error(f"Make sure {SUPERTONIC_HELPER_PATH}/helper.py exists")
raise
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
class SupertonicTTS:
"""
Wrapper class for Supertonic Text-to-Speech engine.
This class provides a simple interface for generating speech from text
using the Supertonic ONNX models. It handles model loading, voice style
management, and audio generation.
Example:
>>> tts = SupertonicTTS(
... onnx_dir="~/supertonic/assets/onnx",
... voice_style_path="~/supertonic/assets/voice_styles/M1.json"
... )
>>> audio_bytes = tts.generate_speech("Hello world, this is a test")
>>> # audio_bytes contains WAV format audio data
"""
# Default paths (use SUPERTONIC_MODEL_PATH env var or ~/supertonic)
DEFAULT_ONNX_DIR = os.environ.get("SUPERTONIC_ONNX_DIR", os.path.expanduser("~/supertonic/assets/onnx"))
DEFAULT_VOICE_STYLES_DIR = os.environ.get("SUPERTONIC_VOICE_STYLES_DIR", os.path.expanduser("~/supertonic/assets/voice_styles"))
# Available voice styles
AVAILABLE_VOICE_STYLES = {
'M1': 'M1.json', # Male voice 1
'M2': 'M2.json', # Male voice 2
'F1': 'F1.json', # Female voice 1
'F2': 'F2.json', # Female voice 2
}
def __init__(
self,
onnx_dir: Optional[str] = None,
voice_style_path: Optional[str] = None,
voice_style_name: str = 'M1',
use_gpu: bool = False
):
"""
Initialize the Supertonic TTS engine.
Args:
onnx_dir: Path to the ONNX models directory. If None, uses DEFAULT_ONNX_DIR.
voice_style_path: Full path to the voice style JSON file. If None,
constructs path from voice_style_name.
voice_style_name: Name of the voice style (M1, M2, F1, F2). Used only
if voice_style_path is None.
use_gpu: Whether to use GPU for inference. Default is False (CPU only).
Raises:
FileNotFoundError: If onnx_dir or voice_style file doesn't exist.
RuntimeError: If model loading fails.
"""
# Set paths
self.onnx_dir = onnx_dir or self.DEFAULT_ONNX_DIR
self.voice_style_name = voice_style_name
# Validate onnx directory
if not os.path.exists(self.onnx_dir):
raise FileNotFoundError(
f"ONNX models directory not found: {self.onnx_dir}"
)
logger.info(f"Using ONNX models from: {self.onnx_dir}")
# Set voice style path
if voice_style_path:
self.voice_style_path = voice_style_path
else:
# Construct path from voice style name
if voice_style_name not in self.AVAILABLE_VOICE_STYLES:
raise ValueError(
f"Invalid voice_style_name: {voice_style_name}. "
f"Available: {list(self.AVAILABLE_VOICE_STYLES.keys())}"
)
voice_style_file = self.AVAILABLE_VOICE_STYLES[voice_style_name]
self.voice_style_path = os.path.join(
self.DEFAULT_VOICE_STYLES_DIR, voice_style_file
)
# Validate voice style file
if not os.path.exists(self.voice_style_path):
raise FileNotFoundError(
f"Voice style file not found: {self.voice_style_path}"
)
logger.info(f"Using voice style: {self.voice_style_path}")
# Initialize models
try:
logger.info("Loading Supertonic TTS models...")
self.text_to_speech = load_text_to_speech(self.onnx_dir, use_gpu=use_gpu)
self.style = load_voice_style([self.voice_style_path], verbose=True)
self.sample_rate = self.text_to_speech.sample_rate
logger.info(f"TTS models loaded successfully (sample rate: {self.sample_rate}Hz)")
except Exception as e:
logger.error(f"Failed to load TTS models: {e}")
raise RuntimeError(f"TTS model loading failed: {e}")
def generate_speech(
self,
text: str,
lang: str = 'en',
speed: float = 1.0,
total_step: int = 15
) -> bytes:
"""
Generate speech from text.
Args:
text: The text to synthesize into speech.
lang: Language code ('en', 'ko', 'es', 'pt', 'fr'). Default is 'en'.
speed: Speech speed multiplier. Higher values = faster speech.
Recommended range: 0.8 to 1.3. Default is 1.05.
total_step: Number of denoising steps for generation. More steps =
better quality but slower. Recommended range: 3-10.
Default is 5 (good balance).
Returns:
bytes: Raw WAV audio data (can be written directly to file or sent
via HTTP with Content-Type: audio/wav).
Raises:
ValueError: If lang is not supported or parameters are invalid.
RuntimeError: If speech generation fails.
Example:
>>> audio = tts.generate_speech("Hello world", lang='en', speed=1.05)
>>> with open('output.wav', 'wb') as f:
... f.write(audio)
"""
# Validate inputs
if not text or not text.strip():
raise ValueError("Text cannot be empty")
supported_langs = ['en', 'ko', 'es', 'pt', 'fr']
if lang not in supported_langs:
raise ValueError(
f"Unsupported language: {lang}. Supported: {supported_langs}"
)
if speed <= 0 or speed > 3:
raise ValueError(f"Invalid speed: {speed}. Must be between 0 and 3")
if total_step < 1 or total_step > 50:
raise ValueError(f"Invalid total_step: {total_step}. Must be between 1 and 50")
logger.info(f"Generating speech: '{text[:50]}...' (lang={lang}, speed={speed}, steps={total_step})")
# Maximum character length per chunk to stay under ONNX token limit (~1000 tokens)
MAX_CHUNK_LENGTH = 500
def split_text_into_chunks(text: str, max_length: int) -> list:
"""Split text into chunks at sentence boundaries."""
if len(text) <= max_length:
return [text]
chunks = []
# Split on sentence boundaries
sentence_endings = ['. ', '! ', '? ', '\n']
current_chunk = ""
# Split by sentences first
sentences = [text]
for ending in sentence_endings:
new_sentences = []
for s in sentences:
parts = s.split(ending)
for i, part in enumerate(parts):
if i < len(parts) - 1:
new_sentences.append(part + ending.strip())
elif part.strip():
new_sentences.append(part)
sentences = new_sentences if new_sentences else sentences
# Combine sentences into chunks up to max_length
for sentence in sentences:
sentence = sentence.strip()
if not sentence:
continue
if len(current_chunk) + len(sentence) + 1 <= max_length:
current_chunk += (" " if current_chunk else "") + sentence
else:
if current_chunk:
chunks.append(current_chunk.strip())
# If single sentence is too long, just use it (will be truncated by tokenizer)
if len(sentence) > max_length:
chunks.append(sentence)
current_chunk = ""
else:
current_chunk = sentence
if current_chunk.strip():
chunks.append(current_chunk.strip())
return chunks
try:
chunks = split_text_into_chunks(text, MAX_CHUNK_LENGTH)
logger.info(f"Text split into {len(chunks)} chunk(s)")
all_audio_chunks = []
for i, chunk in enumerate(chunks):
logger.info(f"Processing chunk {i+1}/{len(chunks)}: '{chunk[:30]}...'")
# Generate speech using the Supertonic TextToSpeech instance
wav, duration = self.text_to_speech(
text=chunk,
lang=lang,
style=self.style,
total_step=total_step,
speed=speed
)
# Extract the audio data (first batch item, trim to actual duration)
audio_data = wav[0, :int(self.sample_rate * duration[0].item())]
all_audio_chunks.append(audio_data)
# Concatenate all audio chunks
if len(all_audio_chunks) == 1:
final_audio = all_audio_chunks[0]
else:
final_audio = np.concatenate(all_audio_chunks)
# Write to BytesIO buffer to get raw bytes
buffer = BytesIO()
sf.write(buffer, final_audio, self.sample_rate, format='WAV')
audio_bytes = buffer.getvalue()
total_duration = len(final_audio) / self.sample_rate
logger.info(f"Generated {len(audio_bytes)} bytes of audio ({total_duration:.2f}s)")
return audio_bytes
except Exception as e:
logger.error(f"Speech generation failed: {e}")
raise RuntimeError(f"Failed to generate speech: {e}")
def set_voice_style(self, voice_style_name: str) -> None:
"""
Change the voice style.
Args:
voice_style_name: Name of the new voice style (M1, M2, F1, F2).
Raises:
ValueError: If voice_style_name is not available.
FileNotFoundError: If the voice style file doesn't exist.
RuntimeError: If loading the new style fails.
"""
if voice_style_name == self.voice_style_name:
logger.info(f"Already using voice style: {voice_style_name}")
return
if voice_style_name not in self.AVAILABLE_VOICE_STYLES:
raise ValueError(
f"Invalid voice_style_name: {voice_style_name}. "
f"Available: {list(self.AVAILABLE_VOICE_STYLES.keys())}"
)
voice_style_file = self.AVAILABLE_VOICE_STYLES[voice_style_name]
new_voice_style_path = os.path.join(
self.DEFAULT_VOICE_STYLES_DIR, voice_style_file
)
if not os.path.exists(new_voice_style_path):
raise FileNotFoundError(
f"Voice style file not found: {new_voice_style_path}"
)
try:
self.style = load_voice_style([new_voice_style_path], verbose=True)
self.voice_style_name = voice_style_name
self.voice_style_path = new_voice_style_path
logger.info(f"Voice style changed to: {voice_style_name}")
except Exception as e:
logger.error(f"Failed to load voice style: {e}")
raise RuntimeError(f"Failed to load voice style: {e}")
# Singleton instance for use in Flask app
_tts_instance: Optional[SupertonicTTS] = None
def get_tts_instance() -> Optional[SupertonicTTS]:
"""
Get the global TTS instance (singleton).
Returns:
The global SupertonicTTS instance, or None if not initialized.
This is useful for Flask apps where you want to initialize TTS once
at startup and reuse the instance across requests.
"""
global _tts_instance
return _tts_instance
def initialize_tts(
onnx_dir: Optional[str] = None,
voice_style_name: str = 'M1',
use_gpu: bool = False
) -> Optional[SupertonicTTS]:
"""
Initialize the global TTS instance.
Args:
onnx_dir: Path to ONNX models directory.
voice_style_name: Default voice style to use.
use_gpu: Whether to use GPU for inference.
Returns:
The initialized SupertonicTTS instance, or None if initialization fails.
"""
global _tts_instance
try:
_tts_instance = SupertonicTTS(
onnx_dir=onnx_dir,
voice_style_name=voice_style_name,
use_gpu=use_gpu
)
logger.info("Global TTS instance initialized")
return _tts_instance
except Exception as e:
logger.error(f"Failed to initialize TTS: {e}")
_tts_instance = None
return None
if __name__ == "__main__":
# Simple test when run directly
print("Supertonic TTS Wrapper - Direct Test")
print("=" * 50)
try:
# Initialize TTS
tts = SupertonicTTS(
onnx_dir=os.environ.get("SUPERTONIC_ONNX_DIR", os.path.expanduser("~/supertonic/assets/onnx")),
voice_style_name="M1"
)
# Generate speech
test_text = "Hello world, this is a test of the Supertonic TTS system."
audio = tts.generate_speech(test_text, lang='en', speed=1.05)
# Save to file
output_path = "/tmp/supertonic_test_output.wav"
with open(output_path, 'wb') as f:
f.write(audio)
print(f"Success! Audio saved to: {output_path}")
print(f"Generated {len(audio)} bytes of audio data")
except Exception as e:
print(f"Error: {e}")
import traceback
traceback.print_exc()