Ctrl K
ComunidadGuías…

tts_providers/supertonic_provider.py

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""
Supertonic TTS Provider for OpenVoiceUI.

This provider wraps the existing supertonic_tts.py module, implementing
the TTSProvider interface for seamless integration with the TTS provider system.

Supertonic is a local ONNX-based Text-to-Speech engine that supports multiple
voice styles (M1-M5 for male, F1-F5 for female voices) and multiple languages.

Author: OpenVoiceUI
Date: 2026-02-11
"""

import logging
import os
from typing import Dict, List, Any, Optional

from .base_provider import TTSProvider

# Configure logging
logger = logging.getLogger(__name__)

# ── API mode (preferred) ───────────────────────────────────────────────────────
# When SUPERTONIC_API_URL is set, all synthesis calls go to the shared
# supertonic-tts microservice (loaded once, serves all users).
# Falls back to local ONNX loading if the env var is not set.
_API_URL = os.environ.get("SUPERTONIC_API_URL", "").rstrip("/")

# ── Local mode (fallback) ──────────────────────────────────────────────────────
import sys
from pathlib import Path

_project_root = Path(__file__).parent.parent
if str(_project_root) not in sys.path:
    sys.path.insert(0, str(_project_root))

try:
    from supertonic_tts import SupertonicTTS
except ImportError as e:
    SupertonicTTS = None
    _import_error = str(e)


class SupertonicProvider(TTSProvider):
    """
    TTS Provider for Supertonic ONNX-based Text-to-Speech engine.

    This provider offers high-quality local TTS with multiple voice styles.
    It runs entirely offline after initial model loading, making it ideal
    for applications that need low latency and privacy.

    Key Behavior:
        This provider REINITIALIZES SupertonicTTS with the requested voice
        for each generate_speech() call, matching the behavior of server.py
        (lines ~3089-3094). This ensures proper voice switching without
        state management issues, at the cost of slightly higher latency
        on first use of each voice.

    Voice Styles:
        - M1-M5: Male voices with different characteristics
        - F1-F5: Female voices with different characteristics

    Languages:
        - en (English)
        - ko (Korean)
        - es (Spanish)
        - pt (Portuguese)
        - fr (French)

    Example:
        >>> from tts_providers import SupertonicProvider
        >>> provider = SupertonicProvider()
        >>> audio = provider.generate_speech("Hello world", voice='M1')
        >>> with open('output.wav', 'wb') as f:
        ...     f.write(audio)

    Configuration:
        >>> provider = SupertonicProvider(
        ...     onnx_dir="/path/to/onnx",
        ...     voice_styles_dir="/path/to/voice_styles",
        ...     default_voice="M1"
        ... )
    """

    # Default paths — override via SUPERTONIC_MODEL_PATH env var
    DEFAULT_ONNX_DIR = os.getenv('SUPERTONIC_MODEL_PATH', '/opt/supertonic/assets/onnx')
    DEFAULT_VOICE_STYLES_DIR = None  # derived from onnx_dir if not set

    # Available voice styles (expanded from base implementation)
    AVAILABLE_VOICES = [
        'M1', 'M2', 'M3', 'M4', 'M5',  # Male voices
        'F1', 'F2', 'F3', 'F4', 'F5'   # Female voices
    ]

    # Supported languages
    SUPPORTED_LANGUAGES = ['en', 'ko', 'es', 'pt', 'fr']

    # Provider metadata
    PROVIDER_NAME = "Supertonic"
    PROVIDER_VERSION = "1.0.0"
    PROVIDER_DESCRIPTION = "Local ONNX-based TTS with multiple voice styles"

    def __init__(
        self,
        onnx_dir: Optional[str] = None,
        voice_styles_dir: Optional[str] = None,
        default_voice: str = 'F3',
        use_gpu: bool = False
    ):
        """
        Initialize the Supertonic TTS Provider.

        Args:
            onnx_dir: Path to ONNX models directory. If None, uses DEFAULT_ONNX_DIR.
            voice_styles_dir: Path to voice styles JSON files directory.
                            If None, uses DEFAULT_VOICE_STYLES_DIR.
            default_voice: Default voice to use (M1-M5, F1-F5). Default is 'M1'.
            use_gpu: Whether to use GPU for inference. Default is False (CPU only).

        Raises:
            ValueError: If SupertonicTTS module is not available.
            FileNotFoundError: If required directories don't exist.
            RuntimeError: If initialization fails.

        Example:
            >>> provider = SupertonicProvider(
            ...     onnx_dir="/custom/path/to/onnx",
            ...     default_voice="F1"
            ... )
        """
        super().__init__()

        self._status = 'inactive'
        self._init_error = None
        self._tts_cache: Dict[str, SupertonicTTS] = {}
        self.default_voice = default_voice
        self.use_gpu = use_gpu

        # ── API mode ──────────────────────────────────────────────────────────
        # Preferred: call the shared supertonic-tts microservice.
        # Models are loaded once system-wide; no per-process ONNX loading.
        #
        # LAZY (TTS-3): __init__ must NEVER make a blocking network call — with the
        # provider singleton this runs once, but a readiness/health probe or a
        # cold first-sentence must not eat a 3s health GET (and then, on local
        # fallback, a multi-second in-process ONNX load). If SUPERTONIC_API_URL is
        # set we assume API mode and let the FIRST generate reveal an outage (the
        # tts.py fallback chain handles a down service in <1s). No health GET here.
        if _API_URL:
            self._use_api = True
            self._api_url = _API_URL
            self.onnx_dir = onnx_dir or self.DEFAULT_ONNX_DIR
            self.voice_styles_dir = ""
            self._status = 'active'
            logger.info(f"SupertonicProvider: API mode → {_API_URL} (lazy — no init health GET)")
            return

        self._use_api = False

        # ── Local mode (fallback) ─────────────────────────────────────────────
        if SupertonicTTS is None:
            self._status = 'error'
            self._init_error = "supertonic_tts module not found. Set SUPERTONIC_API_URL or SUPERTONIC_HELPER_PATH."
            self.onnx_dir = onnx_dir or self.DEFAULT_ONNX_DIR
            self.voice_styles_dir = voice_styles_dir or self.DEFAULT_ONNX_DIR.replace('/onnx', '/voice_styles')
            return

        self.onnx_dir = onnx_dir or self.DEFAULT_ONNX_DIR
        self.voice_styles_dir = (
            voice_styles_dir
            or (self.DEFAULT_VOICE_STYLES_DIR if self.DEFAULT_VOICE_STYLES_DIR
                else os.path.join(os.path.dirname(self.onnx_dir), 'voice_styles'))
        )

        if not os.path.exists(self.onnx_dir):
            self._status = 'error'
            self._init_error = f"ONNX directory not found: {self.onnx_dir}. Set SUPERTONIC_MODEL_PATH in .env."
            logger.warning(f"SupertonicProvider: {self._init_error}")
            return

        if not os.path.exists(self.voice_styles_dir):
            self._status = 'error'
            self._init_error = f"Voice styles directory not found: {self.voice_styles_dir}"
            logger.warning(f"SupertonicProvider: {self._init_error}")
            return

        # LAZY (TTS-3): dirs exist → mark active but DO NOT load ONNX models here.
        # The first generate_speech() lazily creates (and caches) the SupertonicTTS
        # instance via _create_tts_instance(), so __init__ stays fast/non-blocking.
        self._status = 'active'
        logger.info(f"SupertonicProvider: local mode ready (voice '{default_voice}', ONNX loads on first use)")

    def _get_voice_style_path(self, voice: str) -> str:
        """
        Get the full path to a voice style JSON file.

        Args:
            voice: Voice identifier (e.g., 'M1', 'F2').

        Returns:
            Full path to the voice style JSON file.

        Raises:
            ValueError: If voice is not available.
            FileNotFoundError: If voice style file doesn't exist.
        """
        if voice not in self.AVAILABLE_VOICES:
            raise ValueError(
                f"Invalid voice: {voice}. Available: {self.AVAILABLE_VOICES}"
            )

        voice_path = os.path.join(self.voice_styles_dir, f"{voice}.json")

        if not os.path.exists(voice_path):
            raise FileNotFoundError(
                f"Voice style file not found: {voice_path}"
            )

        return voice_path

    def _create_tts_instance(self, voice: str) -> SupertonicTTS:
        """
        Get or create a TTS instance for the specified voice.

        Uses caching to avoid reloading ONNX models for every call.
        Instances are cached by voice name and reused.

        Args:
            voice: Voice identifier.

        Returns:
            SupertonicTTS instance for the specified voice.

        Raises:
            RuntimeError: If TTS instance creation fails.
        """
        # Check cache first
        if voice in self._tts_cache:
            logger.debug(f"Reusing cached TTS instance for voice '{voice}'")
            return self._tts_cache[voice]

        voice_style_path = self._get_voice_style_path(voice)

        try:
            tts_instance = SupertonicTTS(
                onnx_dir=self.onnx_dir,
                voice_style_path=voice_style_path,
                voice_style_name=voice,
                use_gpu=self.use_gpu
            )
            # Cache the instance for reuse
            self._tts_cache[voice] = tts_instance
            logger.debug(f"Created and cached new TTS instance for voice '{voice}'")
            return tts_instance
        except Exception as e:
            logger.error(f"Failed to create TTS instance for voice '{voice}': {e}")
            raise RuntimeError(f"TTS instance creation failed: {e}")

    def generate_speech(
        self,
        text: str,
        voice: Optional[str] = None,
        lang: str = 'en',
        speed: float = 1.0,
        total_step: int = 15,
        **options
    ) -> bytes:
        """
        Generate speech from text using Supertonic TTS.

        Args:
            text: The text to synthesize. Must not be empty.
            voice: Voice identifier (M1-M5, F1-F5). If None, uses default_voice.
            lang: Language code ('en', 'ko', 'es', 'pt', 'fr'). Default is 'en'.
            speed: Speech speed multiplier. Higher = faster.
                   Recommended range: 0.8 to 1.3. Default is 1.05.
            total_step: Number of denoising steps. More = better quality but slower.
                       Recommended range: 3-10. Default is 5.
            **options: Additional options (currently not used, reserved for future).

        Returns:
            bytes: WAV audio data ready to write to file or send via HTTP.

        Raises:
            ValueError: If text is empty, or voice/lang/speed/total_step invalid.
            RuntimeError: If speech generation fails.

        Example:
            >>> audio = provider.generate_speech(
            ...     text="Hello world!",
            ...     voice="M1",
            ...     lang="en",
            ...     speed=1.1,
            ...     total_step=6
            ... )
        """
        # Use default voice if not specified
        if voice is None:
            voice = self.default_voice

        # Validate inputs
        self.validate_text(text)

        if voice not in self.AVAILABLE_VOICES:
            raise ValueError(
                f"Invalid voice: {voice}. Available: {self.AVAILABLE_VOICES}"
            )

        if lang not in self.SUPPORTED_LANGUAGES:
            raise ValueError(
                f"Unsupported language: {lang}. Supported: {self.SUPPORTED_LANGUAGES}"
            )

        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]}...' "
            f"(voice={voice}, lang={lang}, speed={speed}, steps={total_step})"
        )

        try:
            # ── API mode: call shared supertonic-tts service ──────────────────
            if getattr(self, '_use_api', False):
                import requests
                resp = requests.post(
                    f"{self._api_url}/tts",
                    json={"text": text, "voice": voice, "speed": speed,
                          "steps": total_step, "lang": lang},
                    timeout=60,
                )
                if not resp.ok:
                    raise RuntimeError(f"Supertonic API error {resp.status_code}: {resp.text[:200]}")
                audio_bytes = resp.content
                logger.info(f"API: {len(audio_bytes)} bytes for voice '{voice}'")
                # JamBot Books: shared supertonic-tts container call (cost 0, local).
                try:
                    from services.jambot_books_hook import record_provider_call
                    record_provider_call('supertonic', endpoint='/tts', op='tts-api',
                                         units=str(len(text)), status=resp.status_code,
                                         model='supertonic')
                except Exception:
                    pass
                return audio_bytes

            # ── Local mode: load ONNX in-process ─────────────────────────────
            tts = self._create_tts_instance(voice)
            audio_bytes = tts.generate_speech(
                text=text, lang=lang, speed=speed, total_step=total_step
            )
            logger.info(f"Local: {len(audio_bytes)} bytes for voice '{voice}'")
            # JamBot Books: in-process ONNX supertonic (cost 0, local).
            try:
                from services.jambot_books_hook import record_provider_call
                record_provider_call('supertonic', endpoint='/local', op='tts-local',
                                     units=str(len(text)), status=200, model='supertonic')
            except Exception:
                pass
            return audio_bytes

        except Exception as e:
            logger.error(f"Speech generation failed: {e}")
            raise RuntimeError(f"Failed to generate speech: {e}")

    def list_voices(self) -> List[str]:
        """
        List all available voice styles.

        Returns:
            List of voice identifiers that can be used with generate_speech().

        Example:
            >>> provider.list_voices()
            ['M1', 'M2', 'M3', 'M4', 'M5', 'F1', 'F2', 'F3', 'F4', 'F5']
        """
        return self.AVAILABLE_VOICES.copy()

    def get_info(self) -> Dict[str, Any]:
        """
        Get provider metadata and status matching providers_config.json format.

        Returns:
            Dict containing complete metadata including:
                - 'name': Provider name
                - 'provider_id': Unique provider identifier
                - 'status': 'active', 'inactive', or 'error'
                - 'description': Human-readable description
                - 'quality': Audio quality rating
                - 'latency': Expected latency category
                - 'cost_per_minute': Cost per minute of audio
                - 'voices': List of all available voice identifiers
                - 'features': List of provider features
                - 'requires_api_key': Whether API key is required
                - 'languages': List of supported language codes
                - 'max_characters': Max text length per request
                - 'notes': Additional notes about the provider
                - 'documentation_url': Link to documentation
                - 'default_voice': Default voice identifier
                - 'capabilities': Dict of feature flags
                - 'onnx_dir': ONNX models directory path
                - 'voice_styles_dir': Voice styles directory path
                - 'error': Error message if status is 'error'

        Example:
            >>> info = provider.get_info()
            >>> print(f"{info['name']}: {info['status']}")
            Supertonic TTS: active
            >>> info['languages']
            ['en', 'ko', 'es', 'pt', 'fr']
        """
        onnx_dir = getattr(self, 'onnx_dir', self.DEFAULT_ONNX_DIR)
        return {
            'name': 'Supertonic TTS',
            'provider_id': 'supertonic',
            'status': self._status,
            'description': 'Local ONNX-based TTS engine with multiple voice styles',
            'quality': 'high',
            'latency': 'very-fast',
            'cost_per_minute': 0.0,
            'voices': self.AVAILABLE_VOICES.copy(),
            'features': [
                'multi-language',
                'local-processing',
                'open-source',
                'no-api-key-required',
                'onnx-based',
                'voice-style-switching',
                'offline-capable',
            ],
            'requires_api_key': False,
            'languages': self.SUPPORTED_LANGUAGES.copy(),
            'max_characters': 10000,
            'notes': (
                'Free, fast, local inference. Requires local ONNX models. '
                f'Set SUPERTONIC_MODEL_PATH in .env. Current path: {onnx_dir}'
            ),
            'documentation_url': 'https://github.com/playht/supertonic',
            'default_voice': self.default_voice,
            'capabilities': {
                'streaming': False,
                'ssml': False,
                'custom_voices': True,
                'offline': True,
                'gpu_support': True,
            },
            'onnx_dir': self.onnx_dir,
            'voice_styles_dir': self.voice_styles_dir,
            'error': self._init_error if self._status == 'error' else None,
            'requires_microphone': False,
            'requires_websocket': False,
            'mode': 'tts-only',
        }

    def is_available(self) -> bool:
        """
        Check if the provider is ready to generate speech.

        Returns:
            True if provider is active and can generate speech, False otherwise.
        """
        return self._status == 'active'

    def get_default_voice(self) -> str:
        """
        Get the default voice identifier.

        Returns:
            The default voice identifier (e.g., 'M1').
        """
        return self.default_voice

    def set_default_voice(self, voice: str) -> None:
        """
        Change the default voice.

        Args:
            voice: New default voice identifier (must be in AVAILABLE_VOICES).

        Raises:
            ValueError: If voice is not available.
            RuntimeError: If voice initialization fails.
        """
        if voice not in self.AVAILABLE_VOICES:
            raise ValueError(
                f"Invalid voice: {voice}. Available: {self.AVAILABLE_VOICES}"
            )

        try:
            # Test initialization with the new default voice
            self._create_tts_instance(voice)
            self.default_voice = voice
            logger.info(f"Default voice changed to '{voice}'")
        except Exception as e:
            raise RuntimeError(f"Failed to set default voice: {e}")

    def get_supported_languages(self) -> List[str]:
        """
        Get list of supported language codes.

        Returns:
            List of supported language codes.
        """
        return self.SUPPORTED_LANGUAGES.copy()

    def clear_cache(self) -> None:
        """
        Clear the TTS instance cache.

        Removes all cached TTS instances, forcing new instances to be
        created on the next generate_speech() call.
        """
        self._tts_cache.clear()
        logger.debug("TTS instance cache cleared")

    def preload_voice(self, voice: str) -> None:
        """
        Preload a TTS instance for a specific voice.

        Creates and caches the TTS instance so it's ready for immediate use.

        Args:
            voice: Voice identifier to preload.

        Raises:
            ValueError: If voice is not available.
            RuntimeError: If preloading fails.
        """
        if voice not in self.AVAILABLE_VOICES:
            raise ValueError(
                f"Invalid voice: {voice}. Available: {self.AVAILABLE_VOICES}"
            )

        try:
            # Create and cache the TTS instance
            self._create_tts_instance(voice)
            logger.info(f"Voice '{voice}' preloaded and cached")
        except Exception as e:
            raise RuntimeError(f"Failed to preload voice '{voice}': {e}")

    def preload_all_voices(self) -> Dict[str, bool]:
        """
        Test initialization for all available voices.

        Note: This only tests that each voice can be initialized. Since the
        provider reinitializes for each call, this is a validation check.

        Returns:
            Dict mapping voice identifiers to success status.

        Example:
            >>> results = provider.preload_all_voices()
            >>> print(results)
            {'M1': True, 'M2': True, 'F1': True, 'F2': False}
        """
        results = {}
        for voice in self.AVAILABLE_VOICES:
            try:
                self.preload_voice(voice)
                results[voice] = True
            except Exception as e:
                logger.error(f"Failed to preload voice '{voice}': {e}")
                results[voice] = False
        return results


__all__ = ['SupertonicProvider']