Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 | Confidence: High

The feature matrix accurately represents UpliftAI's capabilities based on the implementation. However, the notes about SSML and word boundary handling should be verified against the actual UpliftAI API documentation to ensure accuracy, as these are important differentiation points for users.

| 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 |
Expand Down Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions examples/test-upliftai.py
Original file line number Diff line number Diff line change
@@ -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()
1 change: 1 addition & 0 deletions tests/load_credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"elevenlabs": ["ELEVENLABS_API_KEY"],
"witai": ["WITAI_TOKEN"],
"playht": ["PLAYHT_API_KEY", "PLAYHT_USER_ID"],
"upliftai": ["UPLIFTAI_KEY"],
}


Expand Down
5 changes: 5 additions & 0 deletions tests/test_generate_audiofile.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
GoogleClient,
GoogleTransClient,
MicrosoftClient,
UpliftAIClient,
PollyClient,
SherpaOnnxClient,
WatsonClient,
Expand Down Expand Up @@ -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"),
},
Expand Down
21 changes: 21 additions & 0 deletions tests/test_tts_engines.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
PlayHTClient,
PollyClient,
SherpaOnnxClient,
UpliftAIClient,
WatsonClient,
WitAiClient,
eSpeakClient,
Expand All @@ -37,6 +38,7 @@
"espeak": eSpeakClient,
"playht": PlayHTClient,
"openai": OpenAIClient,
"upliftai": UpliftAIClient,
}

# Add AVSynth only on macOS
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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":

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 | Confidence: High

The test validates WAV header stripping for UpliftAI, but the current implementation will fail due to the missing _strip_wav_header method. This test provides crucial validation that should be maintained once the header stripping is properly implemented.

assert not audio_bytes.startswith(b"RIFF")
except Exception as e:
pytest.fail(f"Synthesis failed with error: {e}")

Expand All @@ -247,13 +255,26 @@ 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
except Exception as e:
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):
Expand Down
1 change: 1 addition & 0 deletions tts_wrapper/engines/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 *
Expand Down
5 changes: 5 additions & 0 deletions tts_wrapper/engines/upliftai/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""UpliftAI TTS engine for tts-wrapper."""

from .client import UpliftAIClient

__all__ = ["UpliftAIClient"]
183 changes: 183 additions & 0 deletions tts_wrapper/engines/upliftai/client.py
Original file line number Diff line number Diff line change
@@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 | Confidence: High

The code attempts to call self._strip_wav_header() which is not defined anywhere in the class. This will cause an AttributeError at runtime when processing WAV audio from UpliftAI. The related test test_synth_to_bytes explicitly checks for RIFF header stripping, confirming this is required functionality. Without this method, all UpliftAI audio synthesis will fail.

Suggested change
response = requests.post(self.BASE_URL, json=payload, headers=self.headers, timeout=30)
if audio_bytes.startswith(RIFF_HEADER):
audio_bytes = audio_bytes[44:] # Remove 44-byte WAV header

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""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 | Confidence: Medium

The WAV header stripping logic assumes a fixed 44-byte header, which is fragile. While this matches the current UpliftAI API, WAV headers can vary in length. The related test test_upliftai_streaming_strips_wav_header validates this assumption but doesn't guarantee future compatibility. A more robust approach would parse the WAV header structure to find the actual data start position.

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]]:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 | Confidence: High

Hardcoding voice metadata creates a maintenance burden and potential synchronization issues if UpliftAI updates their voice offerings. The related test context shows this data is critical for voice selection functionality. Consider implementing a periodic background update mechanism or providing a clear way to update these values without code changes.

"""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)
Loading