Ctrl K
ComunidadGuías…

tts_providers/supertonic_tts.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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()