From 9e0f13f32ae4fb8560a968344a45cd3eff961ab6 Mon Sep 17 00:00:00 2001 From: will wade Date: Sun, 24 Aug 2025 21:03:45 +0100 Subject: [PATCH] docs: mention UpliftAI engine --- README.md | 9 ++ examples/test-upliftai.py | 19 +++ tests/load_credentials.py | 1 + tests/test_generate_audiofile.py | 5 + tests/test_tts_engines.py | 21 +++ tts_wrapper/engines/__init__.py | 1 + tts_wrapper/engines/upliftai/__init__.py | 5 + tts_wrapper/engines/upliftai/client.py | 183 +++++++++++++++++++++++ 8 files changed, 244 insertions(+) create mode 100644 examples/test-upliftai.py create mode 100644 tts_wrapper/engines/upliftai/__init__.py create mode 100644 tts_wrapper/engines/upliftai/client.py diff --git a/README.md b/README.md index 0039f0bb..dafb24fd 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ _TTS-Wrapper_ simplifies using text-to-speech APIs by providing a unified interf - Wit.Ai - eSpeak-NG - Play.HT +- UpliftAI - OpenAI - AVSynth (macOS only) - SAPI (Windows only) @@ -58,6 +59,7 @@ _TTS-Wrapper_ simplifies using text-to-speech APIs by providing a unified interf | Watson | Linux/MacOS/Windows| Online | Yes | Yes | Yes | Yes | Full | | ElevenLabs | Linux/MacOS/Windows| Online | No* | Yes | Yes | Yes | Full | | Play.HT | Linux/MacOS/Windows| Online | No* | No** | Yes | Yes | Basic | +| UpliftAI | Linux/MacOS/Windows| Online | No* | No** | Yes | Yes | Basic | | OpenAI | Linux/MacOS/Windows| Online | No | No | Yes | Yes | Basic | | Wit.Ai | Linux/MacOS/Windows| Online | No* | No** | Yes | Yes | Basic | | eSpeak | Linux/MacOS | Offline | Yes | No** | Yes | Yes | Basic | @@ -254,6 +256,13 @@ client = PlayHTClient(credentials=('api_key', 'user_id')) - **Note**: Play.HT does not support SSML, but we automatically strip SSML tags if present. +#### UpliftAI + +```python +from tts_wrapper import UpliftAIClient +client = UpliftAIClient(api_key="api_key") +``` + #### UWP ```python diff --git a/examples/test-upliftai.py b/examples/test-upliftai.py new file mode 100644 index 00000000..0defe61c --- /dev/null +++ b/examples/test-upliftai.py @@ -0,0 +1,19 @@ +"""Example usage of the UpliftAI engine.""" + +import os + +from tts_wrapper import UpliftAIClient + + +def main() -> None: + api_key = os.getenv("UPLIFTAI_KEY") + if not api_key: + raise RuntimeError("UPLIFTAI_KEY environment variable is not set") + + client = UpliftAIClient(api_key=api_key) + text = "Testing the UpliftAI text to speech engine" + client.speak_streamed(text) + + +if __name__ == "__main__": + main() diff --git a/tests/load_credentials.py b/tests/load_credentials.py index 9c47b5a3..5ebdb5f1 100644 --- a/tests/load_credentials.py +++ b/tests/load_credentials.py @@ -13,6 +13,7 @@ "elevenlabs": ["ELEVENLABS_API_KEY"], "witai": ["WITAI_TOKEN"], "playht": ["PLAYHT_API_KEY", "PLAYHT_USER_ID"], + "upliftai": ["UPLIFTAI_KEY"], } diff --git a/tests/test_generate_audiofile.py b/tests/test_generate_audiofile.py index 882c4f2e..069c442e 100644 --- a/tests/test_generate_audiofile.py +++ b/tests/test_generate_audiofile.py @@ -13,6 +13,7 @@ GoogleClient, GoogleTransClient, MicrosoftClient, + UpliftAIClient, PollyClient, SherpaOnnxClient, WatsonClient, @@ -48,6 +49,10 @@ "client": WitAiClient, "credential_keys": ["WITAI_TOKEN"], }, + "upliftai": { + "client": UpliftAIClient, + "credential_keys": ["UPLIFTAI_KEY"], + }, "googletrans": { "client_lambda": lambda: GoogleTransClient("en-co.uk"), }, diff --git a/tests/test_tts_engines.py b/tests/test_tts_engines.py index c4734311..e6fc2670 100644 --- a/tests/test_tts_engines.py +++ b/tests/test_tts_engines.py @@ -15,6 +15,7 @@ PlayHTClient, PollyClient, SherpaOnnxClient, + UpliftAIClient, WatsonClient, WitAiClient, eSpeakClient, @@ -37,6 +38,7 @@ "espeak": eSpeakClient, "playht": PlayHTClient, "openai": OpenAIClient, + "upliftai": UpliftAIClient, } # Add AVSynth only on macOS @@ -126,6 +128,8 @@ def check_credentials(service): f"ElevenLabs API key: {elevenlabs_api_key[:5]}...{elevenlabs_api_key[-5:] if elevenlabs_api_key else ''}" ) client = ElevenLabsClient(credentials=elevenlabs_api_key) + elif service == "upliftai": + client = UpliftAIClient(api_key=os.getenv("UPLIFTAI_KEY")) elif service == "witai": client = WitAiClient(credentials=os.getenv("WITAI_API_KEY")) elif service == "googletrans": @@ -199,6 +203,8 @@ def create_tts_client(service): return PlayHTClient( credentials=(os.getenv("PLAYHT_API_KEY"), os.getenv("PLAYHT_USER_ID")) ) + if service == "upliftai": + return UpliftAIClient(api_key=os.getenv("UPLIFTAI_KEY")) if service == "avsynth" and sys.platform == "darwin": return AVSynthClient() if service == "openai": @@ -232,6 +238,8 @@ def test_synth_to_bytes(service): audio_bytes = client.synth_to_bytes(text) assert isinstance(audio_bytes, bytes) assert len(audio_bytes) > 0 + if service == "upliftai": + assert not audio_bytes.startswith(b"RIFF") except Exception as e: pytest.fail(f"Synthesis failed with error: {e}") @@ -247,6 +255,8 @@ def test_synth_to_bytes(service): audio_bytes = client.synth_to_bytes(ssml_text) assert isinstance(audio_bytes, bytes) assert len(audio_bytes) > 0 + if service == "upliftai": + assert not audio_bytes.startswith(b"RIFF") except (AttributeError, NotImplementedError): # Skip SSML test for engines that don't support it pass @@ -254,6 +264,17 @@ def test_synth_to_bytes(service): pytest.fail(f"SSML synthesis failed with error: {e}") +@pytest.mark.synthetic +def test_upliftai_streaming_strips_wav_header(): + service = "upliftai" + if not check_credentials(service): + pytest.skip("UpliftAI TTS credentials are invalid or unavailable") + client = create_tts_client(service) + stream = client.synth_to_bytestream("Streaming header test") + first_chunk = next(stream) + assert not first_chunk.startswith(b"RIFF") + + @pytest.mark.synthetic @pytest.mark.parametrize("service", TTS_CLIENTS.keys()) def test_playback_with_callbacks(service): diff --git a/tts_wrapper/engines/__init__.py b/tts_wrapper/engines/__init__.py index 8de32973..921b984a 100644 --- a/tts_wrapper/engines/__init__.py +++ b/tts_wrapper/engines/__init__.py @@ -7,6 +7,7 @@ from .googletrans import * from .microsoft import * from .openai import * +from .upliftai import * from .pico import * from .playht import * from .polly import * diff --git a/tts_wrapper/engines/upliftai/__init__.py b/tts_wrapper/engines/upliftai/__init__.py new file mode 100644 index 00000000..0503492f --- /dev/null +++ b/tts_wrapper/engines/upliftai/__init__.py @@ -0,0 +1,5 @@ +"""UpliftAI TTS engine for tts-wrapper.""" + +from .client import UpliftAIClient + +__all__ = ["UpliftAIClient"] diff --git a/tts_wrapper/engines/upliftai/client.py b/tts_wrapper/engines/upliftai/client.py new file mode 100644 index 00000000..71685798 --- /dev/null +++ b/tts_wrapper/engines/upliftai/client.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import logging +import os +from pathlib import Path +from typing import TYPE_CHECKING, Any, Callable + +import requests + +from tts_wrapper.tts import AbstractTTS + +if TYPE_CHECKING: + from collections.abc import Generator + + +logger = logging.getLogger(__name__) +RIFF_HEADER = b"RIFF" + + +class UpliftAIClient(AbstractTTS): + """Client for the UpliftAI text-to-speech API.""" + + BASE_URL = "https://api.upliftai.org/v1/synthesis/text-to-speech" + STREAM_URL = f"{BASE_URL}/stream" + DEFAULT_VOICE = "v_8eelc901" # Info/Education Urdu + + def __init__(self, api_key: str | None = None) -> None: + super().__init__() + self.api_key = api_key or os.getenv("UPLIFTAI_KEY") + if not self.api_key: + msg = "UpliftAI API key is required. Set UPLIFTAI_KEY or pass api_key." + raise ValueError(msg) + + self.headers = {"Authorization": self.api_key, "Content-Type": "application/json"} + self.audio_rate = 22050 + self.voice_id = self.DEFAULT_VOICE + + def set_voice(self, voice_id: str, lang: str | None = None) -> None: + """Set the voice for synthesis.""" + self.voice_id = voice_id + if lang: + self.lang = lang + + def synth_to_bytes(self, text: Any, voice_id: str | None = None) -> bytes: + """Synthesize text to audio bytes using the non-streaming endpoint.""" + voice = voice_id or self.voice_id or self.DEFAULT_VOICE + payload = { + "voiceId": voice, + "text": str(text), + "outputFormat": "WAV_22050_16", + } + response = requests.post(self.BASE_URL, json=payload, headers=self.headers, timeout=30) + response.raise_for_status() + audio_bytes = response.content + if audio_bytes[:4] == RIFF_HEADER: + audio_bytes = self._strip_wav_header(audio_bytes) + return audio_bytes + + def synth_to_bytestream( + self, text: Any, voice_id: str | None = None + ) -> Generator[bytes, None, None]: + """Stream synthesized audio chunks from the API.""" + voice = voice_id or self.voice_id or self.DEFAULT_VOICE + payload = { + "voiceId": voice, + "text": str(text), + "outputFormat": "WAV_22050_16", + } + with requests.post( + self.STREAM_URL, json=payload, headers=self.headers, stream=True, timeout=30 + ) as response: + response.raise_for_status() + header_buffer = b"" + header_skipped = False + for chunk in response.iter_content(chunk_size=4096): + if not chunk: + continue + if not header_skipped: + header_buffer += chunk + if len(header_buffer) <= 44: + continue + data = header_buffer[44:] + header_skipped = True + if not data: + continue + yield data + continue + yield chunk + + def synth( + self, + text: Any, + output_file: str | Path, + output_format: str = "wav", + voice_id: str | None = None, + ) -> None: + """Synthesize text to a file.""" + audio_bytes = self.synth_to_bytes(text, voice_id) + with Path(output_file).open("wb") as f: + f.write(audio_bytes) + + def _get_voices(self) -> list[dict[str, Any]]: + """Return the list of available voices. + + The UpliftAI service does not provide a voices endpoint, + so the voices are hardcoded. + """ + return [ + { + "id": "v_meklc281", + "name": "Info/Education V2", + "gender": "neutral", + "language_codes": ["ur"], + }, + { + "id": "v_8eelc901", + "name": "Info/Education", + "gender": "neutral", + "language_codes": ["ur"], + }, + { + "id": "v_30s70t3a", + "name": "Nostalgic News", + "gender": "neutral", + "language_codes": ["ur"], + }, + { + "id": "v_yypgzenx", + "name": "Dada Jee", + "gender": "male", + "language_codes": ["ur"], + }, + { + "id": "v_kwmp7zxt", + "name": "Gen Z", + "gender": "neutral", + "language_codes": ["ur"], + }, + {"id": "v_sd0kl3m9", "name": "Female", "gender": "female", "language_codes": ["sd"]}, + { + "id": "v_sd6mn4p2", + "name": "Male Calm", + "gender": "male", + "language_codes": ["sd"], + }, + { + "id": "v_sd9qr7x5", + "name": "Male News", + "gender": "male", + "language_codes": ["sd"], + }, + { + "id": "v_bl0ab8c4", + "name": "Balochi Male", + "gender": "male", + "language_codes": ["bal"], + }, + { + "id": "v_bl1de2f7", + "name": "Balochi Female", + "gender": "female", + "language_codes": ["bal"], + }, + ] + + def check_credentials(self) -> bool: # pragma: no cover - network call + """Verify that the API key works by making a small request.""" + try: + payload = { + "voiceId": self.voice_id or self.DEFAULT_VOICE, + "text": "ping", + "outputFormat": "WAV_22050_16", + } + response = requests.post(self.BASE_URL, json=payload, headers=self.headers, timeout=10) + response.raise_for_status() + return True + except Exception: + logger.debug("UpliftAI credential check failed", exc_info=True) + return False + + def connect(self, event_name: str, callback: Callable) -> None: + """Connect a callback function to an event.""" + super().connect(event_name, callback)