From 7a44b48e3d552365e121ec01a9c21b337e44476b Mon Sep 17 00:00:00 2001 From: maxence Date: Sat, 6 Dec 2025 15:51:37 +0100 Subject: [PATCH 1/5] feat(speech-generation): add Gradium TTS provider (#XX) Add comprehensive Gradium text-to-speech integration with WebSocket streaming support. Features: - WebSocket-based TTS streaming with low-latency audio generation - 14 flagship voices across 5 languages (en, fr, de, es, pt) - Custom voice cloning with create/list/update/delete operations - Speed control via padding_bonus parameter (-4.0 to 4.0) - Multiple audio formats (wav, pcm, opus, ulaw_8000, alaw_8000, pcm_16000, pcm_24000) - EU/US regional endpoints for optimized latency - Credits monitoring and usage tracking Implementation details: - WebSocket protocol handling with proper message sequencing - Parameter mappers for voice, speed, and response_format - Pydantic models for API responses (VoiceInfo, CreditsSummary, TTSResult) - Full voice management REST API integration - Comprehensive test suite with 6 test functions Dependencies: - Added websockets>=13.0 to support WebSocket connections Documentation: - Complete README with usage examples and API reference - Test scripts for validation (test_gradium_tts.py, test_gradium_minimal.py) --- .env.example | 3 + .gitignore | 1 + .../src/celeste_speech_generation/models.py | 2 + .../providers/__init__.py | 4 + .../providers/gradium/README.md | 453 ++++++++++++++++ .../providers/gradium/__init__.py | 7 + .../providers/gradium/client.py | 491 ++++++++++++++++++ .../providers/gradium/config.py | 98 ++++ .../providers/gradium/models.py | 38 ++ .../providers/gradium/parameters.py | 116 +++++ .../providers/gradium/types.py | 59 +++ .../providers/gradium/voices.py | 127 +++++ pyproject.toml | 1 + src/celeste/core.py | 1 + src/celeste/credentials.py | 2 + 15 files changed, 1403 insertions(+) create mode 100644 packages/speech-generation/src/celeste_speech_generation/providers/gradium/README.md create mode 100644 packages/speech-generation/src/celeste_speech_generation/providers/gradium/__init__.py create mode 100644 packages/speech-generation/src/celeste_speech_generation/providers/gradium/client.py create mode 100644 packages/speech-generation/src/celeste_speech_generation/providers/gradium/config.py create mode 100644 packages/speech-generation/src/celeste_speech_generation/providers/gradium/models.py create mode 100644 packages/speech-generation/src/celeste_speech_generation/providers/gradium/parameters.py create mode 100644 packages/speech-generation/src/celeste_speech_generation/providers/gradium/types.py create mode 100644 packages/speech-generation/src/celeste_speech_generation/providers/gradium/voices.py diff --git a/.env.example b/.env.example index 87b3885f..0729bccd 100644 --- a/.env.example +++ b/.env.example @@ -39,3 +39,6 @@ TOPAZLABS_API_KEY=your-topazlabs-api-key-here # Perplexity PERPLEXITY_API_KEY=your-perplexity-api-key-here + +# Gradium +GRADIUM_API_KEY=your-gradium-api-key-here diff --git a/.gitignore b/.gitignore index 730293e9..4ad08e72 100644 --- a/.gitignore +++ b/.gitignore @@ -157,3 +157,4 @@ uv.lock # Security reports bandit-report.json +mureka.md diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/models.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/models.py index c95d7cd4..1cd16031 100644 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/models.py +++ b/packages/capabilities/speech-generation/src/celeste_speech_generation/models.py @@ -5,10 +5,12 @@ MODELS as ELEVENLABS_MODELS, ) from celeste_speech_generation.providers.google.models import MODELS as GOOGLE_MODELS +from celeste_speech_generation.providers.gradium.models import MODELS as GRADIUM_MODELS from celeste_speech_generation.providers.openai.models import MODELS as OPENAI_MODELS MODELS: list[Model] = [ *GOOGLE_MODELS, *OPENAI_MODELS, *ELEVENLABS_MODELS, + *GRADIUM_MODELS, ] diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/__init__.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/__init__.py index aa8f14f6..9fdb918d 100644 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/__init__.py +++ b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/__init__.py @@ -14,6 +14,9 @@ def _get_providers() -> list[tuple[Provider, type[Client]]]: from celeste_speech_generation.providers.google.client import ( GoogleSpeechGenerationClient, ) + from celeste_speech_generation.providers.gradium.client import ( + GradiumSpeechGenerationClient, + ) from celeste_speech_generation.providers.openai.client import ( OpenAISpeechGenerationClient, ) @@ -22,6 +25,7 @@ def _get_providers() -> list[tuple[Provider, type[Client]]]: (Provider.GOOGLE, GoogleSpeechGenerationClient), (Provider.OPENAI, OpenAISpeechGenerationClient), (Provider.ELEVENLABS, ElevenLabsSpeechGenerationClient), + (Provider.GRADIUM, GradiumSpeechGenerationClient), ] diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/gradium/README.md b/packages/speech-generation/src/celeste_speech_generation/providers/gradium/README.md new file mode 100644 index 00000000..981ac69a --- /dev/null +++ b/packages/speech-generation/src/celeste_speech_generation/providers/gradium/README.md @@ -0,0 +1,453 @@ +# Gradium TTS Provider + +Gradium is a high-quality text-to-speech provider with low-latency streaming capabilities and multi-language support. + +## Features + +- **WebSocket Streaming**: Real-time audio generation with chunked delivery +- **14 Flagship Voices**: Professional voices across 5 languages (English, French, German, Spanish, Portuguese) +- **Custom Voice Cloning**: Create personalized voices from audio samples +- **Speed Control**: Adjust speech rate from -4.0 (faster) to 4.0 (slower) +- **Multiple Audio Formats**: wav, pcm, opus, ulaw_8000, alaw_8000, pcm_16000, pcm_24000 +- **Regional Endpoints**: EU and US regions for optimized latency +- **Credits Monitoring**: Track usage and billing information + +## Setup + +### API Key + +Get your API key from [Gradium](https://gradium.ai) and add it to your `.env` file: + +```bash +GRADIUM_API_KEY=your-gradium-api-key-here +``` + +### Installation + +```bash +pip install celeste-ai[speech-generation] +``` + +The Gradium provider requires the `websockets` package, which is included in Celeste's dependencies. + +## Basic Usage + +```python +from celeste import Capability, Provider, create_client + +# Create client +client = create_client( + capability=Capability.SPEECH_GENERATION, + provider=Provider.GRADIUM, + model="default", +) + +# Generate speech +result = await client.generate( + "Hello! Welcome to Gradium text-to-speech." +) + +# Save audio +with open("output.wav", "wb") as f: + f.write(result.content.data) +``` + +## Available Voices + +Gradium provides 14 flagship voices across multiple languages: + +### English Voices +- **Emma** (en-US) - Female, pleasant and smooth +- **Kent** (en-US) - Male, trustworthy and warm +- **Sydney** (en-US) - Female, energetic and friendly +- **John** (en-US) - Male, deep and authoritative +- **Eva** (en-GB) - Female, refined British accent +- **Jack** (en-GB) - Male, sophisticated British accent + +### French Voices +- **Elise** (fr-FR) - Female, elegant Parisian +- **Leo** (fr-FR) - Male, sophisticated and clear + +### German Voices +- **Mia** (de-DE) - Female, precise and articulate +- **Maximilian** (de-DE) - Male, strong and professional + +### Spanish Voices +- **Valentina** (es-ES) - Female, warm Castilian +- **Sergio** (es-MX) - Male, friendly Mexican + +### Portuguese Voices +- **Alice** (pt-BR) - Female, vibrant Brazilian +- **Davi** (pt-BR) - Male, confident Brazilian + +## Voice Selection + +```python +# Use a specific voice +result = await client.generate( + "Bonjour! Bienvenue chez Gradium.", + voice="b35yykvVppLXyw_l", # Elise (French) +) + +# Default voice is Emma (en-US) if not specified +result = await client.generate("Hello!") +``` + +## Speed Control + +Adjust speech rate using the `speed` parameter (padding_bonus in Gradium API): + +```python +# Faster speech (negative values) +result = await client.generate( + "This will be spoken faster.", + voice="YTpq7expH9539ERJ", + speed=-2.0, +) + +# Normal speed (default) +result = await client.generate( + "This will be spoken at normal speed.", + speed=0.0, +) + +# Slower speech (positive values) +result = await client.generate( + "This will be spoken slower.", + speed=2.0, +) +``` + +Speed range: `-4.0` (faster) to `4.0` (slower) + +## Audio Formats + +Gradium supports multiple output formats: + +```python +# WAV format (default) +result = await client.generate( + "Hello!", + response_format="wav", +) + +# Raw PCM +result = await client.generate( + "Hello!", + response_format="pcm", +) + +# Opus (compressed) +result = await client.generate( + "Hello!", + response_format="opus", +) + +# Other formats: ulaw_8000, alaw_8000, pcm_16000, pcm_24000 +``` + +## Region Configuration + +Select EU or US region for optimized latency: + +```python +from celeste_speech_generation.providers.gradium import GradiumSpeechGenerationClient + +# EU region (default) +client = GradiumSpeechGenerationClient( + region="eu", + model="default", +) + +# US region +client = GradiumSpeechGenerationClient( + region="us", + model="default", +) +``` + +## Voice Management + +### List Voices + +```python +# List your custom voices +voices = await client.list_voices(limit=10) +for voice in voices: + print(f"{voice.name} ({voice.uid})") + +# Include catalog voices +all_voices = await client.list_voices( + limit=20, + include_catalog=True, +) +``` + +### Get Voice Details + +```python +voice = await client.get_voice("voice-uid-here") +print(f"Name: {voice.name}") +print(f"Language: {voice.language}") +print(f"Description: {voice.description}") +``` + +### Create Custom Voice + +```python +# From file path +response = await client.create_voice( + audio_file="/path/to/audio.wav", + name="My Custom Voice", + description="A unique voice for my brand", + language="en-US", + input_format="wav", + start_s=0.0, + timeout_s=10.0, +) +print(f"Created voice: {response.uid}") + +# From bytes +with open("audio.wav", "rb") as f: + audio_data = f.read() + +response = await client.create_voice( + audio_file=audio_data, + name="My Custom Voice", +) +``` + +### Update Voice + +```python +updated_voice = await client.update_voice( + voice_uid="voice-uid-here", + name="Updated Name", + description="Updated description", +) +``` + +### Delete Voice + +```python +await client.delete_voice("voice-uid-here") +``` + +## Credits Monitoring + +Track your API usage and billing: + +```python +credits = await client.get_credits() + +print(f"Plan: {credits.plan_name}") +print(f"Remaining: {credits.remaining_credits:,} credits") +print(f"Allocated: {credits.allocated_credits:,} credits") +print(f"Billing period: {credits.billing_period}") +print(f"Next rollover: {credits.next_rollover_date}") + +# Calculate usage percentage +if credits.allocated_credits > 0: + used = credits.allocated_credits - credits.remaining_credits + usage_pct = (used / credits.allocated_credits) * 100 + print(f"Usage: {usage_pct:.1f}%") +``` + +Gradium charges **1 credit per character** of text processed. + +## Complete Example + +```python +import asyncio +from pathlib import Path +from celeste import Capability, Provider, create_client + +async def main(): + # Create client + client = create_client( + capability=Capability.SPEECH_GENERATION, + provider=Provider.GRADIUM, + model="default", + ) + + # Generate speech with custom parameters + result = await client.generate( + "Welcome to Gradium! This is a demonstration of high-quality text-to-speech.", + voice="YTpq7expH9539ERJ", # Emma + speed=0.0, + response_format="wav", + ) + + # Check metadata + print(f"Sample rate: {result.metadata['sample_rate']} Hz") + print(f"Region: {result.metadata['region']}") + print(f"Request ID: {result.metadata['request_id']}") + + # Save audio + output_path = Path("output.wav") + output_path.write_bytes(result.content.data) + print(f"Saved to: {output_path}") + + # Check credits + credits = await client.get_credits() + print(f"Remaining credits: {credits.remaining_credits:,}") + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## API Reference + +### GradiumSpeechGenerationClient + +Main client class for Gradium TTS. + +#### Methods + +**`generate(text, voice=None, speed=0.0, response_format='wav')`** + +Generate speech from text. + +- `text` (str): Text to convert to speech +- `voice` (str, optional): Voice ID (default: Emma) +- `speed` (float, optional): Speed modifier -4.0 to 4.0 (default: 0.0) +- `response_format` (str, optional): Audio format (default: 'wav') + +Returns: `SpeechGenerationOutput` with audio data + +**`list_voices(skip=0, limit=100, include_catalog=False)`** + +List available voices. + +- `skip` (int): Number of voices to skip (pagination) +- `limit` (int): Maximum number of voices to return +- `include_catalog` (bool): Include catalog voices + +Returns: `list[VoiceInfo]` + +**`get_voice(voice_uid)`** + +Get information about a specific voice. + +- `voice_uid` (str): Unique identifier of the voice + +Returns: `VoiceInfo` + +**`create_voice(audio_file, name, input_format='wav', description=None, language=None, start_s=0.0, timeout_s=10.0)`** + +Create a custom voice from audio sample. + +- `audio_file` (str | bytes): Path to audio file or raw audio bytes +- `name` (str): Name for the voice +- `input_format` (str): Audio format (default: 'wav') +- `description` (str, optional): Description +- `language` (str, optional): Language code +- `start_s` (float): Start time in audio file (seconds) +- `timeout_s` (float): Timeout for voice creation (seconds) + +Returns: `VoiceCreateResponse` + +**`update_voice(voice_uid, name=None, description=None, language=None, start_s=None)`** + +Update a custom voice. + +- `voice_uid` (str): Unique identifier of the voice +- `name` (str, optional): New name +- `description` (str, optional): New description +- `language` (str, optional): New language code +- `start_s` (float, optional): New start time + +Returns: `VoiceInfo` + +**`delete_voice(voice_uid)`** + +Delete a custom voice. + +- `voice_uid` (str): Unique identifier of the voice + +Returns: None + +**`get_credits()`** + +Get current credit balance and usage information. + +Returns: `CreditsSummary` + +### Data Models + +**VoiceInfo** + +```python +uid: str # Unique identifier +name: str # Voice name +description: str | None # Optional description +language: str | None # Language code (e.g., 'en-US') +start_s: float # Start time in audio sample +stop_s: float | None # Stop time in audio sample +filename: str # Audio file name +``` + +**VoiceCreateResponse** + +```python +uid: str | None # Created voice UID +error: str | None # Error message if failed +was_updated: bool # Whether voice was updated +``` + +**CreditsSummary** + +```python +remaining_credits: int # Remaining credits +allocated_credits: int # Total allocated credits +billing_period: str # Current billing period +next_rollover_date: str | None # Next credit rollover date +plan_name: str # Plan name +``` + +## WebSocket Protocol + +Gradium uses WebSocket for real-time TTS streaming. The client handles the protocol automatically: + +1. **Setup Message**: Configures voice, model, and output format +2. **Ready Message**: Server confirms setup and provides request ID +3. **Text Message**: Client sends text to synthesize +4. **Audio Messages**: Server streams base64-encoded audio chunks +5. **End of Stream**: Signals completion + +The audio chunks are automatically decoded and concatenated into a single audio file. + +## Error Handling + +```python +try: + result = await client.generate("Hello!") +except RuntimeError as e: + # WebSocket connection errors + print(f"Connection error: {e}") +except ValueError as e: + # Invalid parameters or response format + print(f"Validation error: {e}") +except Exception as e: + # Other errors + print(f"Unexpected error: {e}") +``` + +## Limitations + +- Maximum text length: Check your plan limits +- Voice cloning requires high-quality audio samples (clean, clear speech) +- Custom voices may take a few seconds to process +- Credits are consumed on successful generation (1 credit = 1 character) + +## Resources + +- [Gradium Website](https://gradium.ai) +- [Gradium Documentation](https://docs.gradium.ai) +- [API Reference](https://api.gradium.ai/docs) + +## Support + +For issues specific to the Celeste Gradium integration, please open an issue on the [Celeste GitHub repository](https://github.com/withceleste/celeste-python/issues). + +For Gradium API questions, contact [Gradium support](https://gradium.ai/support). diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/gradium/__init__.py b/packages/speech-generation/src/celeste_speech_generation/providers/gradium/__init__.py new file mode 100644 index 00000000..52d8ab30 --- /dev/null +++ b/packages/speech-generation/src/celeste_speech_generation/providers/gradium/__init__.py @@ -0,0 +1,7 @@ +"""Gradium provider for speech generation.""" + +from celeste_speech_generation.providers.gradium.client import ( + GradiumSpeechGenerationClient, +) + +__all__ = ["GradiumSpeechGenerationClient"] diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/gradium/client.py b/packages/speech-generation/src/celeste_speech_generation/providers/gradium/client.py new file mode 100644 index 00000000..4df71b87 --- /dev/null +++ b/packages/speech-generation/src/celeste_speech_generation/providers/gradium/client.py @@ -0,0 +1,491 @@ +"""Gradium client implementation for speech generation.""" + +import base64 +import json +import logging +from typing import Any, Unpack + +import httpx +import websockets + +from celeste.artifacts import AudioArtifact +from celeste.mime_types import ApplicationMimeType +from celeste.parameters import ParameterMapper +from celeste_speech_generation.client import SpeechGenerationClient +from celeste_speech_generation.io import ( + SpeechGenerationInput, + SpeechGenerationOutput, + SpeechGenerationUsage, +) +from celeste_speech_generation.parameters import SpeechGenerationParameters + +from . import config +from .parameters import GRADIUM_PARAMETER_MAPPERS +from .types import CreditsSummary, TTSResult, VoiceCreateResponse, VoiceInfo + +logger = logging.getLogger(__name__) + + +class GradiumSpeechGenerationClient(SpeechGenerationClient): + """Gradium client for speech generation (TTS). + + Supports: + - Text-to-speech with streaming via WebSocket + - 14 flagship voices + custom voice cloning + - Multi-language support (en, fr, de, es, pt) + - Speed control and audio formatting + - Voice management (create/list/update/delete) + - Credits monitoring + """ + + region: str = config.DEFAULT_REGION + _base_url: str + _ws_url: str + + def model_post_init(self, __context: object) -> None: + """Initialize URLs based on region after model initialization.""" + super().model_post_init(__context) + self._base_url = config.EU_BASE_URL if self.region == config.REGION_EU else config.US_BASE_URL + self._ws_url = config.EU_TTS_WS_URL if self.region == config.REGION_EU else config.US_TTS_WS_URL + + @classmethod + def parameter_mappers(cls) -> list[ParameterMapper]: + return GRADIUM_PARAMETER_MAPPERS + + def _init_request(self, inputs: SpeechGenerationInput) -> dict[str, Any]: + """Initialize request for Gradium TTS API format. + + Note: This is not directly used as Gradium uses WebSocket protocol. + The actual request is built in the WebSocket message handler. + """ + return {"text": inputs.text} + + def _parse_usage(self, response_data: dict[str, Any]) -> SpeechGenerationUsage: + """Parse usage from Gradium response. + + Gradium doesn't provide usage metrics in WebSocket responses. + Credits are calculated as 1 credit per character. + """ + return SpeechGenerationUsage() + + def _parse_content( + self, + response_data: dict[str, Any], + **parameters: Unpack[SpeechGenerationParameters], + ) -> AudioArtifact: + """Parse audio content from Gradium response. + + Args: + response_data: Response containing audio data + parameters: Generation parameters + + Returns: + AudioArtifact with binary audio data + """ + if "raw_data" in response_data: + # Direct binary data + return AudioArtifact(data=response_data["raw_data"]) + msg = "No audio data in response" + raise ValueError(msg) + + async def _websocket_generate( + self, + text: str, + voice_id: str, + model_name: str = config.DEFAULT_MODEL, + output_format: str = config.DEFAULT_FORMAT, + json_config: dict[str, Any] | None = None, + ) -> TTSResult: + """Generate speech using WebSocket connection. + + Args: + text: Text to convert to speech + voice_id: Voice ID to use + model_name: Model name (default: 'default') + output_format: Audio format (wav, pcm, opus, etc.) + json_config: Additional JSON configuration (e.g., padding_bonus) + + Returns: + TTSResult with audio data and metadata + """ + headers = {config.AUTH_HEADER_NAME: self.api_key.get_secret_value()} + + audio_chunks: list[bytes] = [] + request_id: str | None = None + + logger.info(f"Connecting to WebSocket: {self._ws_url}") + try: + async with websockets.connect(self._ws_url, additional_headers=headers) as websocket: + logger.info("WebSocket connected successfully") + + # Send setup message (must be first) + setup_msg = { + "type": config.WS_MSG_SETUP, + "model_name": model_name, + "voice_id": voice_id, + "output_format": output_format, + } + if json_config: + setup_msg["json_config"] = json_config + + logger.info(f"Sending setup message: {setup_msg}") + await websocket.send(json.dumps(setup_msg)) + logger.info("Setup message sent, waiting for ready...") + + # Wait for ready message + ready_msg = await websocket.recv() + logger.info(f"Received message: {ready_msg[:200]}") + ready_data = json.loads(ready_msg) + + if ready_data.get("type") != config.WS_MSG_READY: + msg = f"Expected ready message, got: {ready_data}" + raise ValueError(msg) + + request_id = ready_data.get("request_id") + logger.info(f"Received ready message with request_id: {request_id}") + + # Send text message + text_msg = { + "type": config.WS_MSG_TEXT, + "text": text, + } + logger.info(f"Sending text message: {text[:50]}...") + await websocket.send(json.dumps(text_msg)) + logger.info("Text message sent") + + # Send end_of_stream to signal we're done sending text + logger.info("Sending end_of_stream to signal completion") + await websocket.send(json.dumps({"type": config.WS_MSG_END_OF_STREAM})) + logger.info("End of stream sent, now receiving audio chunks...") + + # Receive audio chunks until server sends end_of_stream + chunk_count = 0 + while True: + message = await websocket.recv() + data = json.loads(message) + + msg_type = data.get("type") + logger.info(f"Received message type: {msg_type}") + + if msg_type == config.WS_MSG_AUDIO: + # Decode base64 audio data + audio_b64 = data.get("audio", "") + audio_data = base64.b64decode(audio_b64) + audio_chunks.append(audio_data) + chunk_count += 1 + logger.info(f"Received audio chunk #{chunk_count}: {len(audio_data)} bytes") + + elif msg_type == config.WS_MSG_END_OF_STREAM: + logger.info("Received end_of_stream from server - complete!") + break + + elif msg_type == config.WS_MSG_ERROR: + error_msg = data.get("message", "Unknown error") + error_code = data.get("code") + logger.error(f"WebSocket error (code {error_code}): {error_msg}") + raise RuntimeError(f"WebSocket error (code {error_code}): {error_msg}") + + logger.info(f"WebSocket complete. Total chunks: {chunk_count}") + + except websockets.exceptions.WebSocketException as e: + msg = f"WebSocket connection error: {e}" + raise RuntimeError(msg) from e + + # Combine all audio chunks + raw_data = b"".join(audio_chunks) + + # Determine sample rate based on output format + sample_rate = config.PCM_SAMPLE_RATE # Default 48kHz + if "16000" in output_format: + sample_rate = 16000 + elif "24000" in output_format: + sample_rate = 24000 + elif "8000" in output_format: + sample_rate = 8000 + + return TTSResult( + raw_data=raw_data, + sample_rate=sample_rate, + request_id=request_id, + ) + + async def generate( + self, + *args: str, + **parameters: Unpack[SpeechGenerationParameters], + ) -> SpeechGenerationOutput: + """Generate speech from text. + + Args: + text: Text to convert to speech (required) + voice: Voice ID to use (required - e.g., 'YTpq7expH9539ERJ' for Emma) + speed: Speed modifier -4.0 (faster) to 4.0 (slower), default 0.0 + response_format: Audio format (wav, pcm, opus, etc.), default 'wav' + + Returns: + SpeechGenerationOutput with audio data + """ + inputs = self._create_inputs(*args, **parameters) + inputs, parameters = self._validate_artifacts(inputs, **parameters) + request_body = self._build_request(inputs, **parameters) + + # Extract parameters + voice_id = request_body.get("voice_id") + if not voice_id: + # Default to Emma if no voice specified + voice_id = "YTpq7expH9539ERJ" + + model_name = self.model.id if self.model else config.DEFAULT_MODEL + output_format = request_body.get("output_format", config.DEFAULT_FORMAT) + json_config = request_body.get("json_config") + + logger.info(f"Generating speech with Gradium (voice: {voice_id}, format: {output_format})") + + # Generate via WebSocket + result = await self._websocket_generate( + text=inputs.text, + voice_id=voice_id, + model_name=model_name, + output_format=output_format, + json_config=json_config, + ) + + # Build metadata + metadata = { + "request_id": result.request_id, + "sample_rate": result.sample_rate, + "region": self.region, + } + + return self._output_class()( + content=AudioArtifact(data=result.raw_data), + usage=self._parse_usage({}), + metadata=metadata, + ) + + # Voice Management Methods + + async def list_voices( + self, + skip: int = 0, + limit: int = 100, + include_catalog: bool = False, + ) -> list[VoiceInfo]: + """List available voices. + + Args: + skip: Number of voices to skip (pagination) + limit: Maximum number of voices to return + include_catalog: Include catalog voices in addition to custom voices + + Returns: + List of voice information objects + """ + headers = { + config.AUTH_HEADER_NAME: self.api_key.get_secret_value(), + "Content-Type": str(ApplicationMimeType.JSON), + } + + params = { + "skip": skip, + "limit": limit, + "include_catalog": include_catalog, + } + + response = await self.http_client.get( + f"{self._base_url}{config.VOICES_ENDPOINT}", + headers=headers, + params=params, + ) + + if response.status_code >= 400: + response.raise_for_status() + + data = response.json() + return [VoiceInfo(**voice) for voice in data] + + async def get_voice(self, voice_uid: str) -> VoiceInfo: + """Get information about a specific voice. + + Args: + voice_uid: Unique identifier of the voice + + Returns: + Voice information object + """ + headers = { + config.AUTH_HEADER_NAME: self.api_key.get_secret_value(), + "Content-Type": str(ApplicationMimeType.JSON), + } + + url = f"{self._base_url}{config.VOICE_DETAIL_ENDPOINT.format(voice_uid=voice_uid)}" + response = await self.http_client.get(url, headers=headers) + + if response.status_code >= 400: + response.raise_for_status() + + return VoiceInfo(**response.json()) + + async def create_voice( + self, + audio_file: str | bytes, + name: str, + input_format: str = "wav", + description: str | None = None, + language: str | None = None, + start_s: float = 0.0, + timeout_s: float = 10.0, + ) -> VoiceCreateResponse: + """Create a custom voice from audio sample. + + Args: + audio_file: Path to audio file or raw audio bytes + name: Name for the voice + input_format: Audio format (wav, mp3, etc.) + description: Optional description + language: Optional language code + start_s: Start time in audio file (seconds) + timeout_s: Timeout for voice creation (seconds) + + Returns: + Voice creation response with UID + """ + headers = { + config.AUTH_HEADER_NAME: self.api_key.get_secret_value(), + } + + # Prepare audio data + if isinstance(audio_file, str): + with open(audio_file, "rb") as f: + audio_data = f.read() + else: + audio_data = audio_file + + # Prepare form data + files = {"audio_file": ("audio.wav", audio_data, "audio/wav")} + data = { + "name": name, + "input_format": input_format, + "start_s": start_s, + "timeout_s": timeout_s, + } + + if description: + data["description"] = description + if language: + data["language"] = language + + # Use httpx directly for multipart form data + async with httpx.AsyncClient() as client: + response = await client.post( + f"{self._base_url}{config.VOICES_ENDPOINT}", + headers=headers, + files=files, + data=data, + ) + + if response.status_code >= 400: + response.raise_for_status() + + return VoiceCreateResponse(**response.json()) + + async def update_voice( + self, + voice_uid: str, + name: str | None = None, + description: str | None = None, + language: str | None = None, + start_s: float | None = None, + ) -> VoiceInfo: + """Update a custom voice. + + Args: + voice_uid: Unique identifier of the voice + name: New name (optional) + description: New description (optional) + language: New language code (optional) + start_s: New start time (optional) + + Returns: + Updated voice information + """ + headers = { + config.AUTH_HEADER_NAME: self.api_key.get_secret_value(), + "Content-Type": str(ApplicationMimeType.JSON), + } + + data = {} + if name is not None: + data["name"] = name + if description is not None: + data["description"] = description + if language is not None: + data["language"] = language + if start_s is not None: + data["start_s"] = start_s + + url = f"{self._base_url}{config.VOICE_DETAIL_ENDPOINT.format(voice_uid=voice_uid)}" + response = await self.http_client.put( + url, + headers=headers, + json_body=data, + ) + + if response.status_code >= 400: + response.raise_for_status() + + return VoiceInfo(**response.json()) + + async def delete_voice(self, voice_uid: str) -> None: + """Delete a custom voice. + + Args: + voice_uid: Unique identifier of the voice + """ + headers = { + config.AUTH_HEADER_NAME: self.api_key.get_secret_value(), + } + + url = f"{self._base_url}{config.VOICE_DETAIL_ENDPOINT.format(voice_uid=voice_uid)}" + response = await self.http_client.delete(url, headers=headers) + + if response.status_code >= 400: + response.raise_for_status() + + # Credits Management + + async def get_credits(self) -> CreditsSummary: + """Get current credit balance and usage information. + + Returns: + Credits summary with balance and billing information + """ + headers = { + config.AUTH_HEADER_NAME: self.api_key.get_secret_value(), + "Content-Type": str(ApplicationMimeType.JSON), + } + + response = await self.http_client.get( + f"{self._base_url}{config.CREDITS_ENDPOINT}", + headers=headers, + ) + + if response.status_code >= 400: + response.raise_for_status() + + return CreditsSummary(**response.json()) + + async def _make_request( + self, + request_body: dict[str, Any], + **parameters: Unpack[SpeechGenerationParameters], + ) -> httpx.Response: + """Make HTTP request and return response object. + + Note: Overridden by generate() for Gradium WebSocket-based TTS. + """ + msg = "Use generate() for Gradium TTS (WebSocket-based)" + raise NotImplementedError(msg) + + +__all__ = ["GradiumSpeechGenerationClient"] diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/gradium/config.py b/packages/speech-generation/src/celeste_speech_generation/providers/gradium/config.py new file mode 100644 index 00000000..209431e3 --- /dev/null +++ b/packages/speech-generation/src/celeste_speech_generation/providers/gradium/config.py @@ -0,0 +1,98 @@ +"""Gradium provider configuration for speech generation.""" + +# Base URLs (REST API) +EU_BASE_URL = "https://eu.api.gradium.ai/api" +US_BASE_URL = "https://us.api.gradium.ai/api" + +# WebSocket URLs (TTS) +EU_TTS_WS_URL = "wss://eu.api.gradium.ai/api/speech/tts" +US_TTS_WS_URL = "wss://us.api.gradium.ai/api/speech/tts" + +# REST Endpoints +VOICES_ENDPOINT = "/voices/" +VOICE_DETAIL_ENDPOINT = "/voices/{voice_uid}" +CREDITS_ENDPOINT = "/usages/credits" + +# Authentication +AUTH_HEADER_NAME = "x-api-key" + +# Audio Formats +AUDIO_FORMATS = [ + "wav", + "pcm", + "opus", + "ulaw_8000", + "alaw_8000", + "pcm_16000", + "pcm_24000", +] +DEFAULT_FORMAT = "wav" + +# Models +DEFAULT_MODEL = "default" + +# Speed control (padding_bonus) +MIN_SPEED = -4.0 +MAX_SPEED = 4.0 +DEFAULT_SPEED = 0.0 + +# Break time range (for tags) +MIN_BREAK_TIME = 0.1 +MAX_BREAK_TIME = 2.0 + +# PCM Audio Specifications (48kHz output) +PCM_SAMPLE_RATE = 48000 # Hz +PCM_BIT_DEPTH = 16 # bits +PCM_CHANNELS = 1 # mono +PCM_CHUNK_SIZE = 3840 # samples (80ms at 48kHz) + +# WebSocket message types +WS_MSG_SETUP = "setup" +WS_MSG_READY = "ready" +WS_MSG_TEXT = "text" +WS_MSG_AUDIO = "audio" +WS_MSG_END_OF_STREAM = "end_of_stream" +WS_MSG_ERROR = "error" + +# WebSocket error codes +WS_ERROR_POLICY_VIOLATION = 1008 +WS_ERROR_INTERNAL_SERVER = 1011 + +# Regions +REGION_EU = "eu" +REGION_US = "us" +DEFAULT_REGION = REGION_EU + +__all__ = [ + "AUDIO_FORMATS", + "AUTH_HEADER_NAME", + "CREDITS_ENDPOINT", + "DEFAULT_FORMAT", + "DEFAULT_MODEL", + "DEFAULT_REGION", + "DEFAULT_SPEED", + "EU_BASE_URL", + "EU_TTS_WS_URL", + "MAX_BREAK_TIME", + "MAX_SPEED", + "MIN_BREAK_TIME", + "MIN_SPEED", + "PCM_BIT_DEPTH", + "PCM_CHANNELS", + "PCM_CHUNK_SIZE", + "PCM_SAMPLE_RATE", + "REGION_EU", + "REGION_US", + "US_BASE_URL", + "US_TTS_WS_URL", + "VOICES_ENDPOINT", + "VOICE_DETAIL_ENDPOINT", + "WS_ERROR_INTERNAL_SERVER", + "WS_ERROR_POLICY_VIOLATION", + "WS_MSG_AUDIO", + "WS_MSG_END_OF_STREAM", + "WS_MSG_ERROR", + "WS_MSG_READY", + "WS_MSG_SETUP", + "WS_MSG_TEXT", +] diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/gradium/models.py b/packages/speech-generation/src/celeste_speech_generation/providers/gradium/models.py new file mode 100644 index 00000000..d17d7455 --- /dev/null +++ b/packages/speech-generation/src/celeste_speech_generation/providers/gradium/models.py @@ -0,0 +1,38 @@ +"""Gradium models for speech generation.""" + +from celeste import Model, Provider +from celeste.constraints import Choice, Range +from celeste_speech_generation.parameters import SpeechGenerationParameter + +from . import config +from .voices import GRADIUM_FLAGSHIP_VOICES + +# Voice constraint for Gradium TTS (flagship voices) +VOICE_CONSTRAINT = Choice( + options=[voice.id for voice in GRADIUM_FLAGSHIP_VOICES] +) + +# Speed constraint (padding_bonus) +SPEED_CONSTRAINT = Range(min=config.MIN_SPEED, max=config.MAX_SPEED) + +# Response format constraint +FORMAT_CONSTRAINT = Choice(options=config.AUDIO_FORMATS) + +# Gradium TTS model +MODELS: list[Model] = [ + Model( + id="default", + provider=Provider.GRADIUM, + display_name="Gradium TTS", + description="Gradium text-to-speech with low-latency streaming and multi-language support", + streaming=True, # Gradium supports streaming via WebSocket + voices=GRADIUM_FLAGSHIP_VOICES, + parameter_constraints={ + SpeechGenerationParameter.VOICE: VOICE_CONSTRAINT, + SpeechGenerationParameter.SPEED: SPEED_CONSTRAINT, + SpeechGenerationParameter.RESPONSE_FORMAT: FORMAT_CONSTRAINT, + }, + ), +] + +__all__ = ["MODELS"] diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/gradium/parameters.py b/packages/speech-generation/src/celeste_speech_generation/providers/gradium/parameters.py new file mode 100644 index 00000000..f555c516 --- /dev/null +++ b/packages/speech-generation/src/celeste_speech_generation/providers/gradium/parameters.py @@ -0,0 +1,116 @@ +"""Gradium parameter mappers for speech generation.""" + +from typing import Any + +from celeste.models import Model +from celeste.parameters import ParameterMapper +from celeste_speech_generation.parameters import SpeechGenerationParameter + + +class VoiceMapper(ParameterMapper): + """Map voice parameter to Gradium voice_id field.""" + + name = SpeechGenerationParameter.VOICE + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform voice into provider request. + + Maps the unified voice parameter to the Gradium API voice_id field. + Supports both flagship voices and custom voice IDs. + + Args: + request: Provider request dictionary to modify. + value: The voice ID (e.g., 'YTpq7expH9539ERJ' for Emma). + model: Model instance with parameter constraints. + + Returns: + Modified request dictionary with voice_id parameter. + """ + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request["voice_id"] = validated_value + return request + + +class SpeedMapper(ParameterMapper): + """Map speed parameter to Gradium padding_bonus field.""" + + name = SpeechGenerationParameter.SPEED + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform speed into provider request. + + Maps the unified speed parameter to Gradium's padding_bonus field. + Range: -4.0 (faster) to 4.0 (slower), default: 0.0 + + Args: + request: Provider request dictionary to modify. + value: Speed modifier (-4.0 to 4.0). + model: Model instance with parameter constraints. + + Returns: + Modified request dictionary with json_config.padding_bonus parameter. + """ + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + # Create json_config if it doesn't exist + if "json_config" not in request: + request["json_config"] = {} + + request["json_config"]["padding_bonus"] = float(validated_value) + return request + + +class ResponseFormatMapper(ParameterMapper): + """Map response_format parameter to Gradium output_format field.""" + + name = SpeechGenerationParameter.RESPONSE_FORMAT + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform response_format into provider request. + + Maps the unified response_format parameter to the Gradium API output_format field. + Supported formats: wav, pcm, opus, ulaw_8000, alaw_8000, pcm_16000, pcm_24000 + + Args: + request: Provider request dictionary to modify. + value: Output format (e.g., 'wav', 'pcm', 'opus'). + model: Model instance with parameter constraints. + + Returns: + Modified request dictionary with output_format parameter. + """ + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request["output_format"] = str(validated_value) + return request + + +GRADIUM_PARAMETER_MAPPERS: list[ParameterMapper] = [ + VoiceMapper(), + SpeedMapper(), + ResponseFormatMapper(), +] + +__all__ = ["GRADIUM_PARAMETER_MAPPERS"] diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/gradium/types.py b/packages/speech-generation/src/celeste_speech_generation/providers/gradium/types.py new file mode 100644 index 00000000..5908a71c --- /dev/null +++ b/packages/speech-generation/src/celeste_speech_generation/providers/gradium/types.py @@ -0,0 +1,59 @@ +"""Gradium-specific types for speech generation.""" + +from pydantic import BaseModel + + +class VoiceInfo(BaseModel): + """Voice information from Gradium API.""" + + uid: str + name: str + description: str | None = None + language: str | None = None + start_s: float + stop_s: float | None = None + filename: str + + +class VoiceCreateResponse(BaseModel): + """Response from voice creation.""" + + uid: str | None = None + error: str | None = None + was_updated: bool = False + + +class CreditsSummary(BaseModel): + """Summary of credits for current billing period.""" + + remaining_credits: int + allocated_credits: int + billing_period: str + next_rollover_date: str | None = None + plan_name: str = "" + + +class TextWithTimestamp(BaseModel): + """Text with start and stop timestamps.""" + + text: str + start_s: float + stop_s: float + + +class TTSResult(BaseModel): + """Result from TTS generation.""" + + raw_data: bytes + sample_rate: int + request_id: str | None = None + text_with_timestamps: list[TextWithTimestamp] = [] + + +__all__ = [ + "CreditsSummary", + "TTSResult", + "TextWithTimestamp", + "VoiceCreateResponse", + "VoiceInfo", +] diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/gradium/voices.py b/packages/speech-generation/src/celeste_speech_generation/providers/gradium/voices.py new file mode 100644 index 00000000..f8c34ba2 --- /dev/null +++ b/packages/speech-generation/src/celeste_speech_generation/providers/gradium/voices.py @@ -0,0 +1,127 @@ +"""Gradium voice definitions.""" + +from celeste_speech_generation.voices import Voice + +# Flagship Voices - curated high-quality voices from Gradium +GRADIUM_FLAGSHIP_VOICES: list[Voice] = [ + # English (US) + Voice( + id="YTpq7expH9539ERJ", + name="Emma", + provider="gradium", + language="en-US", + gender="female", + description="A pleasant and smooth female voice ready to assist your customers and also eager to have nice conversations.", + ), + Voice( + id="LFZvm12tW_z0xfGo", + name="Kent", + provider="gradium", + language="en-US", + gender="male", + description="A relaxed and authentic American adult voice that connects like a genuine friend.", + ), + Voice( + id="jtEKaLYNn6iif5PR", + name="Sydney", + provider="gradium", + language="en-US", + gender="female", + description="A joyful and airy American adult voice that makes corporate training feel helpful and light.", + ), + Voice( + id="KWJiFWu2O9nMPYcR", + name="John", + provider="gradium", + language="en-US", + gender="male", + description="A warm low-pitched American adult voice with the resonant quality of a classic radio broadcaster.", + ), + # English (GB) + Voice( + id="ubuXFxVQwVYnZQhy", + name="Eva", + provider="gradium", + language="en-GB", + gender="female", + description="A joyful and dynamic British adult voice ideal for lively conversations.", + ), + Voice( + id="m86j6D7UZpGzHsNu", + name="Jack", + provider="gradium", + language="en-GB", + gender="male", + description="A pleasant British voice suited for helpful service, casual conversations, or intense narrations.", + ), + # French (FR) + Voice( + id="b35yykvVppLXyw_l", + name="Elise", + provider="gradium", + language="fr-FR", + gender="female", + description="A warm and smooth French adult voice ideal for friendly conversation and welcoming support.", + ), + Voice( + id="axlOaUiFyOZhy4nv", + name="Leo", + provider="gradium", + language="fr-FR", + gender="male", + description="A warm and smooth French adult voice ideal for friendly conversation and welcoming support.", + ), + # German (DE) + Voice( + id="-uP9MuGtBqAvEyxI", + name="Mia", + provider="gradium", + language="de-DE", + gender="female", + description="A joyful and energetic German voice perfect for professional context as well as enthusiastic discussions.", + ), + Voice( + id="0y1VZjPabOBU3rWy", + name="Maximilian", + provider="gradium", + language="de-DE", + gender="male", + description="A warm and smooth German adult voice ideal for friendly conversation and professional narration.", + ), + # Spanish + Voice( + id="B36pbz5_UoWn4BDl", + name="Valentina", + provider="gradium", + language="es-MX", + gender="female", + description="A warm and engaging Mexican female voice perfect for natural storytelling and connecting like a genuine friend.", + ), + Voice( + id="xu7iJ_fn2ElcWp2s", + name="Sergio", + provider="gradium", + language="es-ES", + gender="male", + description="A warm and smooth Spanish adult voice ideal for friendly conversation and professional narration.", + ), + # Portuguese (BR) + Voice( + id="pYcGZz9VOo4n2ynh", + name="Alice", + provider="gradium", + language="pt-BR", + gender="female", + description="A warm and smooth Brazilian female voice ideal for professional service and pleasant narration or even an enthusiastic conversation!", + ), + Voice( + id="M-FvVo9c-jGR4PgP", + name="Davi", + provider="gradium", + language="pt-BR", + gender="male", + description="An engaging and smooth Brazilian adult voice ideal for helpful service and relaxing conversations.", + ), +] + +__all__ = ["GRADIUM_FLAGSHIP_VOICES"] diff --git a/pyproject.toml b/pyproject.toml index 60bd048f..0f192361 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ dependencies = [ "httpx>=0.27.0", "httpx-sse>=0.4.0", "python-dotenv>=1.0.0", + "websockets>=13.0", ] [project.urls] diff --git a/src/celeste/core.py b/src/celeste/core.py index 92c1d1bf..f663f339 100644 --- a/src/celeste/core.py +++ b/src/celeste/core.py @@ -21,6 +21,7 @@ class Provider(StrEnum): PERPLEXITY = "perplexity" BYTEPLUS = "byteplus" ELEVENLABS = "elevenlabs" + GRADIUM = "gradium" class Capability(StrEnum): diff --git a/src/celeste/credentials.py b/src/celeste/credentials.py index 5a3fe24e..c0375628 100644 --- a/src/celeste/credentials.py +++ b/src/celeste/credentials.py @@ -45,6 +45,7 @@ Provider.BYTEPLUS: "byteplus_api_key", Provider.ELEVENLABS: "elevenlabs_api_key", Provider.BFL: "bfl_api_key", + Provider.GRADIUM: "gradium_api_key", } @@ -66,6 +67,7 @@ class Credentials(BaseSettings): byteplus_api_key: SecretStr | None = Field(None, alias="BYTEPLUS_API_KEY") elevenlabs_api_key: SecretStr | None = Field(None, alias="ELEVENLABS_API_KEY") bfl_api_key: SecretStr | None = Field(None, alias="BFL_API_KEY") + gradium_api_key: SecretStr | None = Field(None, alias="GRADIUM_API_KEY") model_config = { "env_file": find_dotenv(), From 3621b94b93c2abd15cd243ed9bb9f9f5012d576a Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Fri, 19 Dec 2025 17:10:20 +0100 Subject: [PATCH 2/5] feat(speech-generation): add Gradium TTS provider with WebSocket API Add Gradium as a new speech generation provider using their WebSocket-based Text-to-Speech API. This provider enables high-quality multilingual speech synthesis with 14 flagship voices across 5 languages. Key changes: - Add celeste-gradium provider package with WebSocket TTS client - Add Gradium capability provider with parameter mappers - Add WebSocket client module to celeste core - Add 14 flagship voices (EN, FR, DE, ES, PT) - Add Gradium integration test and CI secrets - Bump celeste-ai and celeste-speech-generation to 0.3.1 Provider features: - WebSocket streaming for low-latency audio generation - Support for wav, pcm, opus output formats - Voice selection by name (Emma, Kent, Sydney, etc.) - Speed control via padding_bonus translation --- .github/workflows/publish.yml | 1 + .../speech-generation/pyproject.toml | 6 +- .../providers/gradium/__init__.py | 9 + .../providers/gradium/client.py | 93 ++++ .../providers/gradium/models.py | 34 ++ .../providers/gradium/parameters.py | 60 +++ .../providers/gradium/voices.py | 101 ++++ .../test_speech_generation/test_generate.py | 5 + packages/providers/gradium/pyproject.toml | 18 + .../gradium/src/celeste_gradium/__init__.py | 3 + .../gradium/src/celeste_gradium/py.typed | 1 + .../text_to_speech/__init__.py | 1 + .../celeste_gradium/text_to_speech/client.py | 137 +++++ .../celeste_gradium/text_to_speech/config.py | 12 + .../text_to_speech/parameters.py | 67 +++ .../providers/gradium/README.md | 453 ---------------- .../providers/gradium/__init__.py | 7 - .../providers/gradium/client.py | 491 ------------------ .../providers/gradium/config.py | 98 ---- .../providers/gradium/models.py | 38 -- .../providers/gradium/parameters.py | 116 ----- .../providers/gradium/types.py | 59 --- .../providers/gradium/voices.py | 127 ----- pyproject.toml | 2 +- src/celeste/__init__.py | 4 + src/celeste/credentials.py | 1 + src/celeste/websocket.py | 134 +++++ 27 files changed, 686 insertions(+), 1392 deletions(-) create mode 100644 packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/__init__.py create mode 100644 packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/client.py create mode 100644 packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/models.py create mode 100644 packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/parameters.py create mode 100644 packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/voices.py create mode 100644 packages/providers/gradium/pyproject.toml create mode 100644 packages/providers/gradium/src/celeste_gradium/__init__.py create mode 100644 packages/providers/gradium/src/celeste_gradium/py.typed create mode 100644 packages/providers/gradium/src/celeste_gradium/text_to_speech/__init__.py create mode 100644 packages/providers/gradium/src/celeste_gradium/text_to_speech/client.py create mode 100644 packages/providers/gradium/src/celeste_gradium/text_to_speech/config.py create mode 100644 packages/providers/gradium/src/celeste_gradium/text_to_speech/parameters.py delete mode 100644 packages/speech-generation/src/celeste_speech_generation/providers/gradium/README.md delete mode 100644 packages/speech-generation/src/celeste_speech_generation/providers/gradium/__init__.py delete mode 100644 packages/speech-generation/src/celeste_speech_generation/providers/gradium/client.py delete mode 100644 packages/speech-generation/src/celeste_speech_generation/providers/gradium/config.py delete mode 100644 packages/speech-generation/src/celeste_speech_generation/providers/gradium/models.py delete mode 100644 packages/speech-generation/src/celeste_speech_generation/providers/gradium/parameters.py delete mode 100644 packages/speech-generation/src/celeste_speech_generation/providers/gradium/types.py delete mode 100644 packages/speech-generation/src/celeste_speech_generation/providers/gradium/voices.py create mode 100644 src/celeste/websocket.py diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 79437ce1..54ed205c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -136,6 +136,7 @@ jobs: BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }} XAI_API_KEY: ${{ secrets.XAI_API_KEY }} ELEVENLABS_API_KEY: ${{ secrets.ELEVENLABS_API_KEY }} + GRADIUM_API_KEY: ${{ secrets.GRADIUM_API_KEY }} run: uv run pytest packages/capabilities/${{ matrix.package }}/tests/integration_tests/ -m integration -v build: diff --git a/packages/capabilities/speech-generation/pyproject.toml b/packages/capabilities/speech-generation/pyproject.toml index 921f45fa..a904ff6d 100644 --- a/packages/capabilities/speech-generation/pyproject.toml +++ b/packages/capabilities/speech-generation/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "celeste-speech-generation" -version = "0.3.0" +version = "0.3.1" description = "Speech generation package for Celeste AI. Unified interface for all providers" authors = [{name = "Kamilbenkirane", email = "kamil@withceleste.ai"}] readme = "README.md" @@ -17,7 +17,7 @@ classifiers = [ "Topic :: Scientific/Engineering :: Artificial Intelligence", "Typing :: Typed", ] -keywords = ["ai", "speech-generation", "tts", "text-to-speech", "openai", "google", "elevenlabs", "audio-ai"] +keywords = ["ai", "speech-generation", "tts", "text-to-speech", "openai", "google", "elevenlabs", "gradium", "audio-ai"] [project.urls] Homepage = "https://withceleste.ai" @@ -28,6 +28,8 @@ Issues = "https://github.com/withceleste/celeste-python/issues" [tool.uv.sources] celeste-ai = { workspace = true } celeste-elevenlabs = { workspace = true } +celeste-google = { workspace = true } +celeste-gradium = { workspace = true } celeste-openai = { workspace = true } [project.entry-points."celeste.packages"] diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/__init__.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/__init__.py new file mode 100644 index 00000000..bc01c62b --- /dev/null +++ b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/__init__.py @@ -0,0 +1,9 @@ +"""Gradium provider for speech generation.""" + +from .client import GradiumSpeechGenerationClient +from .models import MODELS + +__all__ = [ + "MODELS", + "GradiumSpeechGenerationClient", +] diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/client.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/client.py new file mode 100644 index 00000000..9521e858 --- /dev/null +++ b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/client.py @@ -0,0 +1,93 @@ +"""Gradium client implementation for speech generation.""" + +from typing import Any, Unpack + +import httpx +from celeste_gradium.text_to_speech.client import GradiumTextToSpeechClient + +from celeste.artifacts import AudioArtifact +from celeste.parameters import ParameterMapper +from celeste_speech_generation.client import SpeechGenerationClient +from celeste_speech_generation.io import ( + SpeechGenerationInput, + SpeechGenerationOutput, + SpeechGenerationUsage, +) +from celeste_speech_generation.parameters import SpeechGenerationParameters + +from .parameters import GRADIUM_PARAMETER_MAPPERS + + +class GradiumSpeechGenerationClient(GradiumTextToSpeechClient, SpeechGenerationClient): + """Gradium client for speech generation.""" + + @classmethod + def parameter_mappers(cls) -> list[ParameterMapper]: + return GRADIUM_PARAMETER_MAPPERS + + def _init_request(self, inputs: SpeechGenerationInput) -> dict[str, Any]: + """Initialize request from Gradium API format.""" + return {"text": inputs.text} + + def _parse_usage(self, response_data: dict[str, Any]) -> SpeechGenerationUsage: + """Parse usage from response.""" + usage = super()._parse_usage(response_data) + return SpeechGenerationUsage(**usage) + + def _parse_content( + self, + response_data: dict[str, Any], + **parameters: Unpack[SpeechGenerationParameters], + ) -> AudioArtifact: + """Parse content from response. + + Note: This method is not used for Gradium TTS since we override generate() + to handle WebSocket responses. Kept for interface compliance. + """ + msg = "Gradium TTS uses WebSocket, use generate() override" + raise NotImplementedError(msg) + + async def _make_request( + self, + request_body: dict[str, Any], + **parameters: Unpack[SpeechGenerationParameters], + ) -> httpx.Response: + """Make HTTP request. + + Note: This method is not used for Gradium TTS since we override generate() + to use WebSocket. Kept for interface compliance. + """ + msg = "Gradium TTS uses WebSocket, use generate() override" + raise NotImplementedError(msg) + + async def generate( + self, + *args: str, + **parameters: Unpack[SpeechGenerationParameters], + ) -> SpeechGenerationOutput: + """Generate speech from text. + + Override base generate() to use WebSocket instead of HTTP. + """ + inputs = self._create_inputs(*args, **parameters) + inputs, parameters = self._validate_artifacts(inputs, **parameters) + request_body = self._build_request(inputs, **parameters) + + # Use WebSocket TTS flow + audio_bytes, output_format = await self._websocket_tts(request_body) + + if not audio_bytes: + msg = "No audio data in response" + raise ValueError(msg) + + # Determine MIME type from output_format + mime_type = self._map_output_format_to_mime_type(output_format) + + return self._output_class()( + content=AudioArtifact(data=audio_bytes, mime_type=mime_type), + usage=SpeechGenerationUsage(), + metadata=self._build_metadata({}), + ) + + +__all__ = ["GradiumSpeechGenerationClient"] diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/models.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/models.py new file mode 100644 index 00000000..8dd86ac1 --- /dev/null +++ b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/models.py @@ -0,0 +1,34 @@ +"""Gradium model definitions for speech generation.""" + +from celeste import Model, Provider +from celeste.constraints import Choice, Range +from celeste_speech_generation.constraints import VoiceConstraint +from celeste_speech_generation.parameters import SpeechGenerationParameter + +from .voices import GRADIUM_VOICES + +MODELS: list[Model] = [ + Model( + id="default", + provider=Provider.GRADIUM, + display_name="Gradium Default TTS", + streaming=False, + parameter_constraints={ + SpeechGenerationParameter.VOICE: VoiceConstraint(voices=GRADIUM_VOICES), + SpeechGenerationParameter.OUTPUT_FORMAT: Choice( + options=[ + "wav", + "pcm", + "opus", + "ulaw_8000", + "alaw_8000", + "pcm_16000", + "pcm_24000", + ] + ), + SpeechGenerationParameter.SPEED: Range(min=0.25, max=4.0), + }, + ), +] + +__all__ = ["MODELS"] diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/parameters.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/parameters.py new file mode 100644 index 00000000..e66e934b --- /dev/null +++ b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/parameters.py @@ -0,0 +1,60 @@ +"""Gradium Text-to-Speech parameter mappers for speech generation.""" + +from typing import Any + +from celeste_gradium.text_to_speech.parameters import ( + OutputFormatMapper as _OutputFormatMapper, +) +from celeste_gradium.text_to_speech.parameters import ( + PaddingBonusMapper as _PaddingBonusMapper, +) +from celeste_gradium.text_to_speech.parameters import ( + VoiceMapper as _VoiceMapper, +) + +from celeste.models import Model +from celeste.parameters import ParameterMapper +from celeste_speech_generation.parameters import SpeechGenerationParameter + + +class VoiceMapper(_VoiceMapper): + name = SpeechGenerationParameter.VOICE + + +class OutputFormatMapper(_OutputFormatMapper): + name = SpeechGenerationParameter.OUTPUT_FORMAT + + +class SpeedMapper(_PaddingBonusMapper): + """Translate unified speed to Gradium padding_bonus. + + speed 1.0 → padding_bonus 0 (normal) + speed 0.5 → padding_bonus 2.0 (slower) + speed 2.0 → padding_bonus -4.0 (faster) + """ + + name = SpeechGenerationParameter.SPEED + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform speed into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + # Translate: speed → padding_bonus + padding_bonus = (1.0 - validated_value) * 4.0 + return super().map(request, padding_bonus, model) + + +GRADIUM_PARAMETER_MAPPERS: list[ParameterMapper] = [ + VoiceMapper(), + OutputFormatMapper(), + SpeedMapper(), +] + +__all__ = ["GRADIUM_PARAMETER_MAPPERS"] diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/voices.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/voices.py new file mode 100644 index 00000000..de4a1d84 --- /dev/null +++ b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/voices.py @@ -0,0 +1,101 @@ +"""Gradium voice definitions for speech generation.""" + +from celeste import Provider +from celeste_speech_generation.voices import Voice + +# Gradium flagship voices +# Full list at https://gradium.ai/api_dpocs.html +GRADIUM_VOICES = [ + # English (US) + Voice( + id="YTpq7expH9539ERJ", + provider=Provider.GRADIUM, + name="Emma", + languages={"en"}, + ), + Voice( + id="LFZvm12tW_z0xfGo", + provider=Provider.GRADIUM, + name="Kent", + languages={"en"}, + ), + Voice( + id="jtEKaLYNn6iif5PR", + provider=Provider.GRADIUM, + name="Sydney", + languages={"en"}, + ), + Voice( + id="KWJiFWu2O9nMPYcR", + provider=Provider.GRADIUM, + name="John", + languages={"en"}, + ), + # English (GB) + Voice( + id="ubuXFxVQwVYnZQhy", + provider=Provider.GRADIUM, + name="Eva", + languages={"en"}, + ), + Voice( + id="m86j6D7UZpGzHsNu", + provider=Provider.GRADIUM, + name="Jack", + languages={"en"}, + ), + # French + Voice( + id="b35yykvVppLXyw_l", + provider=Provider.GRADIUM, + name="Elise", + languages={"fr"}, + ), + Voice( + id="axlOaUiFyOZhy4nv", + provider=Provider.GRADIUM, + name="Leo", + languages={"fr"}, + ), + # German + Voice( + id="-uP9MuGtBqAvEyxI", + provider=Provider.GRADIUM, + name="Mia", + languages={"de"}, + ), + Voice( + id="0y1VZjPabOBU3rWy", + provider=Provider.GRADIUM, + name="Maximilian", + languages={"de"}, + ), + # Spanish + Voice( + id="B36pbz5_UoWn4BDl", + provider=Provider.GRADIUM, + name="Valentina", + languages={"es"}, + ), + Voice( + id="xu7iJ_fn2ElcWp2s", + provider=Provider.GRADIUM, + name="Sergio", + languages={"es"}, + ), + # Portuguese (Brazilian) + Voice( + id="pYcGZz9VOo4n2ynh", + provider=Provider.GRADIUM, + name="Alice", + languages={"pt"}, + ), + Voice( + id="M-FvVo9c-jGR4PgP", + provider=Provider.GRADIUM, + name="Davi", + languages={"pt"}, + ), +] + +__all__ = ["GRADIUM_VOICES"] diff --git a/packages/capabilities/speech-generation/tests/integration_tests/test_speech_generation/test_generate.py b/packages/capabilities/speech-generation/tests/integration_tests/test_speech_generation/test_generate.py index 20061909..44c05eb0 100644 --- a/packages/capabilities/speech-generation/tests/integration_tests/test_speech_generation/test_generate.py +++ b/packages/capabilities/speech-generation/tests/integration_tests/test_speech_generation/test_generate.py @@ -19,6 +19,11 @@ "eleven_flash_v2_5", {"voice": "Rachel", "output_format": "mp3_44100_128"}, ), + ( + Provider.GRADIUM, + "default", + {"voice": "Emma", "output_format": "wav"}, + ), ], ) @pytest.mark.integration diff --git a/packages/providers/gradium/pyproject.toml b/packages/providers/gradium/pyproject.toml new file mode 100644 index 00000000..91d0f1e7 --- /dev/null +++ b/packages/providers/gradium/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "celeste-gradium" +version = "0.3.1" +description = "Gradium provider package for Celeste AI" +authors = [{name = "Kamilbenkirane", email = "kamil@withceleste.ai"}] +license = {text = "Apache-2.0"} +requires-python = ">=3.12" +dependencies = ["celeste-ai", "websockets"] + +[tool.uv.sources] +celeste-ai = { workspace = true } + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/celeste_gradium"] diff --git a/packages/providers/gradium/src/celeste_gradium/__init__.py b/packages/providers/gradium/src/celeste_gradium/__init__.py new file mode 100644 index 00000000..a39dfaa4 --- /dev/null +++ b/packages/providers/gradium/src/celeste_gradium/__init__.py @@ -0,0 +1,3 @@ +"""Gradium provider package for Celeste AI.""" + +__all__: list[str] = [] diff --git a/packages/providers/gradium/src/celeste_gradium/py.typed b/packages/providers/gradium/src/celeste_gradium/py.typed new file mode 100644 index 00000000..e16c76df --- /dev/null +++ b/packages/providers/gradium/src/celeste_gradium/py.typed @@ -0,0 +1 @@ +"" diff --git a/packages/providers/gradium/src/celeste_gradium/text_to_speech/__init__.py b/packages/providers/gradium/src/celeste_gradium/text_to_speech/__init__.py new file mode 100644 index 00000000..701cfd7a --- /dev/null +++ b/packages/providers/gradium/src/celeste_gradium/text_to_speech/__init__.py @@ -0,0 +1 @@ +"""Gradium Text to Speech API provider package.""" diff --git a/packages/providers/gradium/src/celeste_gradium/text_to_speech/client.py b/packages/providers/gradium/src/celeste_gradium/text_to_speech/client.py new file mode 100644 index 00000000..3a0b850b --- /dev/null +++ b/packages/providers/gradium/src/celeste_gradium/text_to_speech/client.py @@ -0,0 +1,137 @@ +"""Gradium Text-to-Speech API client with shared implementation.""" + +import base64 +import json +from typing import Any + +from websockets.asyncio.client import connect as ws_connect + +from celeste.client import APIMixin +from celeste.mime_types import AudioMimeType + +from . import config + + +class GradiumTextToSpeechClient(APIMixin): + """Mixin for Gradium Text-to-Speech API. + + Provides shared implementation for speech generation via WebSocket: + - _websocket_tts() - Execute WebSocket TTS flow + - _parse_usage() - Returns empty dict (TTS doesn't return usage) + - _map_output_format_to_mime_type() - Map format string to AudioMimeType + + The Gradium Text-to-Speech API uses WebSocket instead of HTTP REST: + 1. Connect to wss://{region}.api.gradium.ai/api/speech/tts + 2. Send setup message with model, voice, format + 3. Receive ready confirmation + 4. Send text to synthesize + 5. Receive audio chunks (base64 encoded) + 6. Receive end message + + Subclasses override generate() to use _websocket_tts() instead of HTTP. + + Usage: + class GradiumSpeechGenerationClient(GradiumTextToSpeechClient, SpeechGenerationClient): + async def generate(self, *args, **parameters): + # Build request... + audio_data, format = await self._websocket_tts(request_body) + # Return SpeechGenerationOutput... + """ + + async def _websocket_tts( + self, + request_body: dict[str, Any], + ) -> tuple[bytes, str]: + """Execute WebSocket TTS flow. + + Args: + request_body: Request with text, voice_id, output_format, json_config. + + Returns: + Tuple of (audio_bytes, output_format). + + Raises: + ValueError: If connection fails or error received. + """ + voice_id = request_body.get("voice_id", "YTpq7expH9539ERJ") # Default: Emma + output_format = request_body.get("output_format", "wav") + text = request_body.get("text", "") + json_config = request_body.get("json_config") + + url = f"{config.BASE_URL}{config.GradiumTextToSpeechEndpoint.CREATE_SPEECH}" + headers = self.auth.get_headers() + + async with ws_connect(url, additional_headers=headers) as ws: + # 1. Send setup message + setup_msg: dict[str, Any] = { + "type": "setup", + "model_name": self.model.id, + "voice_id": voice_id, + "output_format": output_format, + } + if json_config is not None: + setup_msg["json_config"] = json_config + + await ws.send(json.dumps(setup_msg)) + + # 2. Wait for ready + ready_msg = await ws.recv() + ready = json.loads(ready_msg) + if ready.get("type") != "ready": + msg = f"Expected ready message, got: {ready}" + raise ValueError(msg) + + # 3. Send text + await ws.send(json.dumps({"type": "text", "text": text})) + + # 4. Signal end of input + await ws.send(json.dumps({"type": "end_of_stream"})) + + # 5. Collect audio chunks + audio_chunks: list[bytes] = [] + async for message in ws: + if isinstance(message, bytes): + data = json.loads(message.decode("utf-8")) + else: + data = json.loads(message) + + if data["type"] == "audio": + audio_chunks.append(base64.b64decode(data["audio"])) + elif data["type"] == "end_of_stream": + break + elif data["type"] == "error": + error_msg = data.get("message", "Unknown error") + msg = f"Gradium TTS error: {error_msg}" + raise ValueError(msg) + + return b"".join(audio_chunks), output_format + + def _parse_usage(self, response_data: dict[str, Any]) -> dict[str, Any]: + """Extract usage data from Gradium API response. + + Gradium TTS doesn't provide usage metadata. + Returns empty dict for capability clients to wrap in Usage type. + """ + return {} + + def _map_output_format_to_mime_type( + self, + output_format: str | None, + ) -> AudioMimeType: + """Map Gradium output_format to AudioMimeType. + + Supported formats: wav, pcm, opus, ulaw_8000, alaw_8000, pcm_16000, pcm_24000. + """ + format_map: dict[str, AudioMimeType] = { + "wav": AudioMimeType.WAV, + "pcm": AudioMimeType.WAV, # PCM raw, closest match + "opus": AudioMimeType.OGG, # Opus in OGG container + "ulaw_8000": AudioMimeType.WAV, + "alaw_8000": AudioMimeType.WAV, + "pcm_16000": AudioMimeType.WAV, + "pcm_24000": AudioMimeType.WAV, + } + return format_map.get(output_format or "", AudioMimeType.WAV) + + +__all__ = ["GradiumTextToSpeechClient"] diff --git a/packages/providers/gradium/src/celeste_gradium/text_to_speech/config.py b/packages/providers/gradium/src/celeste_gradium/text_to_speech/config.py new file mode 100644 index 00000000..97907965 --- /dev/null +++ b/packages/providers/gradium/src/celeste_gradium/text_to_speech/config.py @@ -0,0 +1,12 @@ +"""Configuration for Gradium Text-to-Speech API.""" + +from enum import StrEnum + + +class GradiumTextToSpeechEndpoint(StrEnum): + """Endpoints for Text-to-Speech API.""" + + CREATE_SPEECH = "/speech/tts" + + +BASE_URL = "wss://eu.api.gradium.ai/api" diff --git a/packages/providers/gradium/src/celeste_gradium/text_to_speech/parameters.py b/packages/providers/gradium/src/celeste_gradium/text_to_speech/parameters.py new file mode 100644 index 00000000..71f79dec --- /dev/null +++ b/packages/providers/gradium/src/celeste_gradium/text_to_speech/parameters.py @@ -0,0 +1,67 @@ +"""Gradium TTS API parameter mappers.""" + +from typing import Any + +from celeste.models import Model +from celeste.parameters import ParameterMapper + + +class VoiceMapper(ParameterMapper): + """Map voice to Gradium voice_id field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform voice into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request["voice_id"] = validated_value + return request + + +class PaddingBonusMapper(ParameterMapper): + """Map padding_bonus to Gradium json_config.padding_bonus field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform padding_bonus into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request.setdefault("json_config", {})["padding_bonus"] = validated_value + return request + + +class OutputFormatMapper(ParameterMapper): + """Map output_format to Gradium output_format field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform output_format into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request["output_format"] = validated_value + return request + + +__all__ = [ + "OutputFormatMapper", + "PaddingBonusMapper", + "VoiceMapper", +] diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/gradium/README.md b/packages/speech-generation/src/celeste_speech_generation/providers/gradium/README.md deleted file mode 100644 index 981ac69a..00000000 --- a/packages/speech-generation/src/celeste_speech_generation/providers/gradium/README.md +++ /dev/null @@ -1,453 +0,0 @@ -# Gradium TTS Provider - -Gradium is a high-quality text-to-speech provider with low-latency streaming capabilities and multi-language support. - -## Features - -- **WebSocket Streaming**: Real-time audio generation with chunked delivery -- **14 Flagship Voices**: Professional voices across 5 languages (English, French, German, Spanish, Portuguese) -- **Custom Voice Cloning**: Create personalized voices from audio samples -- **Speed Control**: Adjust speech rate from -4.0 (faster) to 4.0 (slower) -- **Multiple Audio Formats**: wav, pcm, opus, ulaw_8000, alaw_8000, pcm_16000, pcm_24000 -- **Regional Endpoints**: EU and US regions for optimized latency -- **Credits Monitoring**: Track usage and billing information - -## Setup - -### API Key - -Get your API key from [Gradium](https://gradium.ai) and add it to your `.env` file: - -```bash -GRADIUM_API_KEY=your-gradium-api-key-here -``` - -### Installation - -```bash -pip install celeste-ai[speech-generation] -``` - -The Gradium provider requires the `websockets` package, which is included in Celeste's dependencies. - -## Basic Usage - -```python -from celeste import Capability, Provider, create_client - -# Create client -client = create_client( - capability=Capability.SPEECH_GENERATION, - provider=Provider.GRADIUM, - model="default", -) - -# Generate speech -result = await client.generate( - "Hello! Welcome to Gradium text-to-speech." -) - -# Save audio -with open("output.wav", "wb") as f: - f.write(result.content.data) -``` - -## Available Voices - -Gradium provides 14 flagship voices across multiple languages: - -### English Voices -- **Emma** (en-US) - Female, pleasant and smooth -- **Kent** (en-US) - Male, trustworthy and warm -- **Sydney** (en-US) - Female, energetic and friendly -- **John** (en-US) - Male, deep and authoritative -- **Eva** (en-GB) - Female, refined British accent -- **Jack** (en-GB) - Male, sophisticated British accent - -### French Voices -- **Elise** (fr-FR) - Female, elegant Parisian -- **Leo** (fr-FR) - Male, sophisticated and clear - -### German Voices -- **Mia** (de-DE) - Female, precise and articulate -- **Maximilian** (de-DE) - Male, strong and professional - -### Spanish Voices -- **Valentina** (es-ES) - Female, warm Castilian -- **Sergio** (es-MX) - Male, friendly Mexican - -### Portuguese Voices -- **Alice** (pt-BR) - Female, vibrant Brazilian -- **Davi** (pt-BR) - Male, confident Brazilian - -## Voice Selection - -```python -# Use a specific voice -result = await client.generate( - "Bonjour! Bienvenue chez Gradium.", - voice="b35yykvVppLXyw_l", # Elise (French) -) - -# Default voice is Emma (en-US) if not specified -result = await client.generate("Hello!") -``` - -## Speed Control - -Adjust speech rate using the `speed` parameter (padding_bonus in Gradium API): - -```python -# Faster speech (negative values) -result = await client.generate( - "This will be spoken faster.", - voice="YTpq7expH9539ERJ", - speed=-2.0, -) - -# Normal speed (default) -result = await client.generate( - "This will be spoken at normal speed.", - speed=0.0, -) - -# Slower speech (positive values) -result = await client.generate( - "This will be spoken slower.", - speed=2.0, -) -``` - -Speed range: `-4.0` (faster) to `4.0` (slower) - -## Audio Formats - -Gradium supports multiple output formats: - -```python -# WAV format (default) -result = await client.generate( - "Hello!", - response_format="wav", -) - -# Raw PCM -result = await client.generate( - "Hello!", - response_format="pcm", -) - -# Opus (compressed) -result = await client.generate( - "Hello!", - response_format="opus", -) - -# Other formats: ulaw_8000, alaw_8000, pcm_16000, pcm_24000 -``` - -## Region Configuration - -Select EU or US region for optimized latency: - -```python -from celeste_speech_generation.providers.gradium import GradiumSpeechGenerationClient - -# EU region (default) -client = GradiumSpeechGenerationClient( - region="eu", - model="default", -) - -# US region -client = GradiumSpeechGenerationClient( - region="us", - model="default", -) -``` - -## Voice Management - -### List Voices - -```python -# List your custom voices -voices = await client.list_voices(limit=10) -for voice in voices: - print(f"{voice.name} ({voice.uid})") - -# Include catalog voices -all_voices = await client.list_voices( - limit=20, - include_catalog=True, -) -``` - -### Get Voice Details - -```python -voice = await client.get_voice("voice-uid-here") -print(f"Name: {voice.name}") -print(f"Language: {voice.language}") -print(f"Description: {voice.description}") -``` - -### Create Custom Voice - -```python -# From file path -response = await client.create_voice( - audio_file="/path/to/audio.wav", - name="My Custom Voice", - description="A unique voice for my brand", - language="en-US", - input_format="wav", - start_s=0.0, - timeout_s=10.0, -) -print(f"Created voice: {response.uid}") - -# From bytes -with open("audio.wav", "rb") as f: - audio_data = f.read() - -response = await client.create_voice( - audio_file=audio_data, - name="My Custom Voice", -) -``` - -### Update Voice - -```python -updated_voice = await client.update_voice( - voice_uid="voice-uid-here", - name="Updated Name", - description="Updated description", -) -``` - -### Delete Voice - -```python -await client.delete_voice("voice-uid-here") -``` - -## Credits Monitoring - -Track your API usage and billing: - -```python -credits = await client.get_credits() - -print(f"Plan: {credits.plan_name}") -print(f"Remaining: {credits.remaining_credits:,} credits") -print(f"Allocated: {credits.allocated_credits:,} credits") -print(f"Billing period: {credits.billing_period}") -print(f"Next rollover: {credits.next_rollover_date}") - -# Calculate usage percentage -if credits.allocated_credits > 0: - used = credits.allocated_credits - credits.remaining_credits - usage_pct = (used / credits.allocated_credits) * 100 - print(f"Usage: {usage_pct:.1f}%") -``` - -Gradium charges **1 credit per character** of text processed. - -## Complete Example - -```python -import asyncio -from pathlib import Path -from celeste import Capability, Provider, create_client - -async def main(): - # Create client - client = create_client( - capability=Capability.SPEECH_GENERATION, - provider=Provider.GRADIUM, - model="default", - ) - - # Generate speech with custom parameters - result = await client.generate( - "Welcome to Gradium! This is a demonstration of high-quality text-to-speech.", - voice="YTpq7expH9539ERJ", # Emma - speed=0.0, - response_format="wav", - ) - - # Check metadata - print(f"Sample rate: {result.metadata['sample_rate']} Hz") - print(f"Region: {result.metadata['region']}") - print(f"Request ID: {result.metadata['request_id']}") - - # Save audio - output_path = Path("output.wav") - output_path.write_bytes(result.content.data) - print(f"Saved to: {output_path}") - - # Check credits - credits = await client.get_credits() - print(f"Remaining credits: {credits.remaining_credits:,}") - -if __name__ == "__main__": - asyncio.run(main()) -``` - -## API Reference - -### GradiumSpeechGenerationClient - -Main client class for Gradium TTS. - -#### Methods - -**`generate(text, voice=None, speed=0.0, response_format='wav')`** - -Generate speech from text. - -- `text` (str): Text to convert to speech -- `voice` (str, optional): Voice ID (default: Emma) -- `speed` (float, optional): Speed modifier -4.0 to 4.0 (default: 0.0) -- `response_format` (str, optional): Audio format (default: 'wav') - -Returns: `SpeechGenerationOutput` with audio data - -**`list_voices(skip=0, limit=100, include_catalog=False)`** - -List available voices. - -- `skip` (int): Number of voices to skip (pagination) -- `limit` (int): Maximum number of voices to return -- `include_catalog` (bool): Include catalog voices - -Returns: `list[VoiceInfo]` - -**`get_voice(voice_uid)`** - -Get information about a specific voice. - -- `voice_uid` (str): Unique identifier of the voice - -Returns: `VoiceInfo` - -**`create_voice(audio_file, name, input_format='wav', description=None, language=None, start_s=0.0, timeout_s=10.0)`** - -Create a custom voice from audio sample. - -- `audio_file` (str | bytes): Path to audio file or raw audio bytes -- `name` (str): Name for the voice -- `input_format` (str): Audio format (default: 'wav') -- `description` (str, optional): Description -- `language` (str, optional): Language code -- `start_s` (float): Start time in audio file (seconds) -- `timeout_s` (float): Timeout for voice creation (seconds) - -Returns: `VoiceCreateResponse` - -**`update_voice(voice_uid, name=None, description=None, language=None, start_s=None)`** - -Update a custom voice. - -- `voice_uid` (str): Unique identifier of the voice -- `name` (str, optional): New name -- `description` (str, optional): New description -- `language` (str, optional): New language code -- `start_s` (float, optional): New start time - -Returns: `VoiceInfo` - -**`delete_voice(voice_uid)`** - -Delete a custom voice. - -- `voice_uid` (str): Unique identifier of the voice - -Returns: None - -**`get_credits()`** - -Get current credit balance and usage information. - -Returns: `CreditsSummary` - -### Data Models - -**VoiceInfo** - -```python -uid: str # Unique identifier -name: str # Voice name -description: str | None # Optional description -language: str | None # Language code (e.g., 'en-US') -start_s: float # Start time in audio sample -stop_s: float | None # Stop time in audio sample -filename: str # Audio file name -``` - -**VoiceCreateResponse** - -```python -uid: str | None # Created voice UID -error: str | None # Error message if failed -was_updated: bool # Whether voice was updated -``` - -**CreditsSummary** - -```python -remaining_credits: int # Remaining credits -allocated_credits: int # Total allocated credits -billing_period: str # Current billing period -next_rollover_date: str | None # Next credit rollover date -plan_name: str # Plan name -``` - -## WebSocket Protocol - -Gradium uses WebSocket for real-time TTS streaming. The client handles the protocol automatically: - -1. **Setup Message**: Configures voice, model, and output format -2. **Ready Message**: Server confirms setup and provides request ID -3. **Text Message**: Client sends text to synthesize -4. **Audio Messages**: Server streams base64-encoded audio chunks -5. **End of Stream**: Signals completion - -The audio chunks are automatically decoded and concatenated into a single audio file. - -## Error Handling - -```python -try: - result = await client.generate("Hello!") -except RuntimeError as e: - # WebSocket connection errors - print(f"Connection error: {e}") -except ValueError as e: - # Invalid parameters or response format - print(f"Validation error: {e}") -except Exception as e: - # Other errors - print(f"Unexpected error: {e}") -``` - -## Limitations - -- Maximum text length: Check your plan limits -- Voice cloning requires high-quality audio samples (clean, clear speech) -- Custom voices may take a few seconds to process -- Credits are consumed on successful generation (1 credit = 1 character) - -## Resources - -- [Gradium Website](https://gradium.ai) -- [Gradium Documentation](https://docs.gradium.ai) -- [API Reference](https://api.gradium.ai/docs) - -## Support - -For issues specific to the Celeste Gradium integration, please open an issue on the [Celeste GitHub repository](https://github.com/withceleste/celeste-python/issues). - -For Gradium API questions, contact [Gradium support](https://gradium.ai/support). diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/gradium/__init__.py b/packages/speech-generation/src/celeste_speech_generation/providers/gradium/__init__.py deleted file mode 100644 index 52d8ab30..00000000 --- a/packages/speech-generation/src/celeste_speech_generation/providers/gradium/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Gradium provider for speech generation.""" - -from celeste_speech_generation.providers.gradium.client import ( - GradiumSpeechGenerationClient, -) - -__all__ = ["GradiumSpeechGenerationClient"] diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/gradium/client.py b/packages/speech-generation/src/celeste_speech_generation/providers/gradium/client.py deleted file mode 100644 index 4df71b87..00000000 --- a/packages/speech-generation/src/celeste_speech_generation/providers/gradium/client.py +++ /dev/null @@ -1,491 +0,0 @@ -"""Gradium client implementation for speech generation.""" - -import base64 -import json -import logging -from typing import Any, Unpack - -import httpx -import websockets - -from celeste.artifacts import AudioArtifact -from celeste.mime_types import ApplicationMimeType -from celeste.parameters import ParameterMapper -from celeste_speech_generation.client import SpeechGenerationClient -from celeste_speech_generation.io import ( - SpeechGenerationInput, - SpeechGenerationOutput, - SpeechGenerationUsage, -) -from celeste_speech_generation.parameters import SpeechGenerationParameters - -from . import config -from .parameters import GRADIUM_PARAMETER_MAPPERS -from .types import CreditsSummary, TTSResult, VoiceCreateResponse, VoiceInfo - -logger = logging.getLogger(__name__) - - -class GradiumSpeechGenerationClient(SpeechGenerationClient): - """Gradium client for speech generation (TTS). - - Supports: - - Text-to-speech with streaming via WebSocket - - 14 flagship voices + custom voice cloning - - Multi-language support (en, fr, de, es, pt) - - Speed control and audio formatting - - Voice management (create/list/update/delete) - - Credits monitoring - """ - - region: str = config.DEFAULT_REGION - _base_url: str - _ws_url: str - - def model_post_init(self, __context: object) -> None: - """Initialize URLs based on region after model initialization.""" - super().model_post_init(__context) - self._base_url = config.EU_BASE_URL if self.region == config.REGION_EU else config.US_BASE_URL - self._ws_url = config.EU_TTS_WS_URL if self.region == config.REGION_EU else config.US_TTS_WS_URL - - @classmethod - def parameter_mappers(cls) -> list[ParameterMapper]: - return GRADIUM_PARAMETER_MAPPERS - - def _init_request(self, inputs: SpeechGenerationInput) -> dict[str, Any]: - """Initialize request for Gradium TTS API format. - - Note: This is not directly used as Gradium uses WebSocket protocol. - The actual request is built in the WebSocket message handler. - """ - return {"text": inputs.text} - - def _parse_usage(self, response_data: dict[str, Any]) -> SpeechGenerationUsage: - """Parse usage from Gradium response. - - Gradium doesn't provide usage metrics in WebSocket responses. - Credits are calculated as 1 credit per character. - """ - return SpeechGenerationUsage() - - def _parse_content( - self, - response_data: dict[str, Any], - **parameters: Unpack[SpeechGenerationParameters], - ) -> AudioArtifact: - """Parse audio content from Gradium response. - - Args: - response_data: Response containing audio data - parameters: Generation parameters - - Returns: - AudioArtifact with binary audio data - """ - if "raw_data" in response_data: - # Direct binary data - return AudioArtifact(data=response_data["raw_data"]) - msg = "No audio data in response" - raise ValueError(msg) - - async def _websocket_generate( - self, - text: str, - voice_id: str, - model_name: str = config.DEFAULT_MODEL, - output_format: str = config.DEFAULT_FORMAT, - json_config: dict[str, Any] | None = None, - ) -> TTSResult: - """Generate speech using WebSocket connection. - - Args: - text: Text to convert to speech - voice_id: Voice ID to use - model_name: Model name (default: 'default') - output_format: Audio format (wav, pcm, opus, etc.) - json_config: Additional JSON configuration (e.g., padding_bonus) - - Returns: - TTSResult with audio data and metadata - """ - headers = {config.AUTH_HEADER_NAME: self.api_key.get_secret_value()} - - audio_chunks: list[bytes] = [] - request_id: str | None = None - - logger.info(f"Connecting to WebSocket: {self._ws_url}") - try: - async with websockets.connect(self._ws_url, additional_headers=headers) as websocket: - logger.info("WebSocket connected successfully") - - # Send setup message (must be first) - setup_msg = { - "type": config.WS_MSG_SETUP, - "model_name": model_name, - "voice_id": voice_id, - "output_format": output_format, - } - if json_config: - setup_msg["json_config"] = json_config - - logger.info(f"Sending setup message: {setup_msg}") - await websocket.send(json.dumps(setup_msg)) - logger.info("Setup message sent, waiting for ready...") - - # Wait for ready message - ready_msg = await websocket.recv() - logger.info(f"Received message: {ready_msg[:200]}") - ready_data = json.loads(ready_msg) - - if ready_data.get("type") != config.WS_MSG_READY: - msg = f"Expected ready message, got: {ready_data}" - raise ValueError(msg) - - request_id = ready_data.get("request_id") - logger.info(f"Received ready message with request_id: {request_id}") - - # Send text message - text_msg = { - "type": config.WS_MSG_TEXT, - "text": text, - } - logger.info(f"Sending text message: {text[:50]}...") - await websocket.send(json.dumps(text_msg)) - logger.info("Text message sent") - - # Send end_of_stream to signal we're done sending text - logger.info("Sending end_of_stream to signal completion") - await websocket.send(json.dumps({"type": config.WS_MSG_END_OF_STREAM})) - logger.info("End of stream sent, now receiving audio chunks...") - - # Receive audio chunks until server sends end_of_stream - chunk_count = 0 - while True: - message = await websocket.recv() - data = json.loads(message) - - msg_type = data.get("type") - logger.info(f"Received message type: {msg_type}") - - if msg_type == config.WS_MSG_AUDIO: - # Decode base64 audio data - audio_b64 = data.get("audio", "") - audio_data = base64.b64decode(audio_b64) - audio_chunks.append(audio_data) - chunk_count += 1 - logger.info(f"Received audio chunk #{chunk_count}: {len(audio_data)} bytes") - - elif msg_type == config.WS_MSG_END_OF_STREAM: - logger.info("Received end_of_stream from server - complete!") - break - - elif msg_type == config.WS_MSG_ERROR: - error_msg = data.get("message", "Unknown error") - error_code = data.get("code") - logger.error(f"WebSocket error (code {error_code}): {error_msg}") - raise RuntimeError(f"WebSocket error (code {error_code}): {error_msg}") - - logger.info(f"WebSocket complete. Total chunks: {chunk_count}") - - except websockets.exceptions.WebSocketException as e: - msg = f"WebSocket connection error: {e}" - raise RuntimeError(msg) from e - - # Combine all audio chunks - raw_data = b"".join(audio_chunks) - - # Determine sample rate based on output format - sample_rate = config.PCM_SAMPLE_RATE # Default 48kHz - if "16000" in output_format: - sample_rate = 16000 - elif "24000" in output_format: - sample_rate = 24000 - elif "8000" in output_format: - sample_rate = 8000 - - return TTSResult( - raw_data=raw_data, - sample_rate=sample_rate, - request_id=request_id, - ) - - async def generate( - self, - *args: str, - **parameters: Unpack[SpeechGenerationParameters], - ) -> SpeechGenerationOutput: - """Generate speech from text. - - Args: - text: Text to convert to speech (required) - voice: Voice ID to use (required - e.g., 'YTpq7expH9539ERJ' for Emma) - speed: Speed modifier -4.0 (faster) to 4.0 (slower), default 0.0 - response_format: Audio format (wav, pcm, opus, etc.), default 'wav' - - Returns: - SpeechGenerationOutput with audio data - """ - inputs = self._create_inputs(*args, **parameters) - inputs, parameters = self._validate_artifacts(inputs, **parameters) - request_body = self._build_request(inputs, **parameters) - - # Extract parameters - voice_id = request_body.get("voice_id") - if not voice_id: - # Default to Emma if no voice specified - voice_id = "YTpq7expH9539ERJ" - - model_name = self.model.id if self.model else config.DEFAULT_MODEL - output_format = request_body.get("output_format", config.DEFAULT_FORMAT) - json_config = request_body.get("json_config") - - logger.info(f"Generating speech with Gradium (voice: {voice_id}, format: {output_format})") - - # Generate via WebSocket - result = await self._websocket_generate( - text=inputs.text, - voice_id=voice_id, - model_name=model_name, - output_format=output_format, - json_config=json_config, - ) - - # Build metadata - metadata = { - "request_id": result.request_id, - "sample_rate": result.sample_rate, - "region": self.region, - } - - return self._output_class()( - content=AudioArtifact(data=result.raw_data), - usage=self._parse_usage({}), - metadata=metadata, - ) - - # Voice Management Methods - - async def list_voices( - self, - skip: int = 0, - limit: int = 100, - include_catalog: bool = False, - ) -> list[VoiceInfo]: - """List available voices. - - Args: - skip: Number of voices to skip (pagination) - limit: Maximum number of voices to return - include_catalog: Include catalog voices in addition to custom voices - - Returns: - List of voice information objects - """ - headers = { - config.AUTH_HEADER_NAME: self.api_key.get_secret_value(), - "Content-Type": str(ApplicationMimeType.JSON), - } - - params = { - "skip": skip, - "limit": limit, - "include_catalog": include_catalog, - } - - response = await self.http_client.get( - f"{self._base_url}{config.VOICES_ENDPOINT}", - headers=headers, - params=params, - ) - - if response.status_code >= 400: - response.raise_for_status() - - data = response.json() - return [VoiceInfo(**voice) for voice in data] - - async def get_voice(self, voice_uid: str) -> VoiceInfo: - """Get information about a specific voice. - - Args: - voice_uid: Unique identifier of the voice - - Returns: - Voice information object - """ - headers = { - config.AUTH_HEADER_NAME: self.api_key.get_secret_value(), - "Content-Type": str(ApplicationMimeType.JSON), - } - - url = f"{self._base_url}{config.VOICE_DETAIL_ENDPOINT.format(voice_uid=voice_uid)}" - response = await self.http_client.get(url, headers=headers) - - if response.status_code >= 400: - response.raise_for_status() - - return VoiceInfo(**response.json()) - - async def create_voice( - self, - audio_file: str | bytes, - name: str, - input_format: str = "wav", - description: str | None = None, - language: str | None = None, - start_s: float = 0.0, - timeout_s: float = 10.0, - ) -> VoiceCreateResponse: - """Create a custom voice from audio sample. - - Args: - audio_file: Path to audio file or raw audio bytes - name: Name for the voice - input_format: Audio format (wav, mp3, etc.) - description: Optional description - language: Optional language code - start_s: Start time in audio file (seconds) - timeout_s: Timeout for voice creation (seconds) - - Returns: - Voice creation response with UID - """ - headers = { - config.AUTH_HEADER_NAME: self.api_key.get_secret_value(), - } - - # Prepare audio data - if isinstance(audio_file, str): - with open(audio_file, "rb") as f: - audio_data = f.read() - else: - audio_data = audio_file - - # Prepare form data - files = {"audio_file": ("audio.wav", audio_data, "audio/wav")} - data = { - "name": name, - "input_format": input_format, - "start_s": start_s, - "timeout_s": timeout_s, - } - - if description: - data["description"] = description - if language: - data["language"] = language - - # Use httpx directly for multipart form data - async with httpx.AsyncClient() as client: - response = await client.post( - f"{self._base_url}{config.VOICES_ENDPOINT}", - headers=headers, - files=files, - data=data, - ) - - if response.status_code >= 400: - response.raise_for_status() - - return VoiceCreateResponse(**response.json()) - - async def update_voice( - self, - voice_uid: str, - name: str | None = None, - description: str | None = None, - language: str | None = None, - start_s: float | None = None, - ) -> VoiceInfo: - """Update a custom voice. - - Args: - voice_uid: Unique identifier of the voice - name: New name (optional) - description: New description (optional) - language: New language code (optional) - start_s: New start time (optional) - - Returns: - Updated voice information - """ - headers = { - config.AUTH_HEADER_NAME: self.api_key.get_secret_value(), - "Content-Type": str(ApplicationMimeType.JSON), - } - - data = {} - if name is not None: - data["name"] = name - if description is not None: - data["description"] = description - if language is not None: - data["language"] = language - if start_s is not None: - data["start_s"] = start_s - - url = f"{self._base_url}{config.VOICE_DETAIL_ENDPOINT.format(voice_uid=voice_uid)}" - response = await self.http_client.put( - url, - headers=headers, - json_body=data, - ) - - if response.status_code >= 400: - response.raise_for_status() - - return VoiceInfo(**response.json()) - - async def delete_voice(self, voice_uid: str) -> None: - """Delete a custom voice. - - Args: - voice_uid: Unique identifier of the voice - """ - headers = { - config.AUTH_HEADER_NAME: self.api_key.get_secret_value(), - } - - url = f"{self._base_url}{config.VOICE_DETAIL_ENDPOINT.format(voice_uid=voice_uid)}" - response = await self.http_client.delete(url, headers=headers) - - if response.status_code >= 400: - response.raise_for_status() - - # Credits Management - - async def get_credits(self) -> CreditsSummary: - """Get current credit balance and usage information. - - Returns: - Credits summary with balance and billing information - """ - headers = { - config.AUTH_HEADER_NAME: self.api_key.get_secret_value(), - "Content-Type": str(ApplicationMimeType.JSON), - } - - response = await self.http_client.get( - f"{self._base_url}{config.CREDITS_ENDPOINT}", - headers=headers, - ) - - if response.status_code >= 400: - response.raise_for_status() - - return CreditsSummary(**response.json()) - - async def _make_request( - self, - request_body: dict[str, Any], - **parameters: Unpack[SpeechGenerationParameters], - ) -> httpx.Response: - """Make HTTP request and return response object. - - Note: Overridden by generate() for Gradium WebSocket-based TTS. - """ - msg = "Use generate() for Gradium TTS (WebSocket-based)" - raise NotImplementedError(msg) - - -__all__ = ["GradiumSpeechGenerationClient"] diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/gradium/config.py b/packages/speech-generation/src/celeste_speech_generation/providers/gradium/config.py deleted file mode 100644 index 209431e3..00000000 --- a/packages/speech-generation/src/celeste_speech_generation/providers/gradium/config.py +++ /dev/null @@ -1,98 +0,0 @@ -"""Gradium provider configuration for speech generation.""" - -# Base URLs (REST API) -EU_BASE_URL = "https://eu.api.gradium.ai/api" -US_BASE_URL = "https://us.api.gradium.ai/api" - -# WebSocket URLs (TTS) -EU_TTS_WS_URL = "wss://eu.api.gradium.ai/api/speech/tts" -US_TTS_WS_URL = "wss://us.api.gradium.ai/api/speech/tts" - -# REST Endpoints -VOICES_ENDPOINT = "/voices/" -VOICE_DETAIL_ENDPOINT = "/voices/{voice_uid}" -CREDITS_ENDPOINT = "/usages/credits" - -# Authentication -AUTH_HEADER_NAME = "x-api-key" - -# Audio Formats -AUDIO_FORMATS = [ - "wav", - "pcm", - "opus", - "ulaw_8000", - "alaw_8000", - "pcm_16000", - "pcm_24000", -] -DEFAULT_FORMAT = "wav" - -# Models -DEFAULT_MODEL = "default" - -# Speed control (padding_bonus) -MIN_SPEED = -4.0 -MAX_SPEED = 4.0 -DEFAULT_SPEED = 0.0 - -# Break time range (for tags) -MIN_BREAK_TIME = 0.1 -MAX_BREAK_TIME = 2.0 - -# PCM Audio Specifications (48kHz output) -PCM_SAMPLE_RATE = 48000 # Hz -PCM_BIT_DEPTH = 16 # bits -PCM_CHANNELS = 1 # mono -PCM_CHUNK_SIZE = 3840 # samples (80ms at 48kHz) - -# WebSocket message types -WS_MSG_SETUP = "setup" -WS_MSG_READY = "ready" -WS_MSG_TEXT = "text" -WS_MSG_AUDIO = "audio" -WS_MSG_END_OF_STREAM = "end_of_stream" -WS_MSG_ERROR = "error" - -# WebSocket error codes -WS_ERROR_POLICY_VIOLATION = 1008 -WS_ERROR_INTERNAL_SERVER = 1011 - -# Regions -REGION_EU = "eu" -REGION_US = "us" -DEFAULT_REGION = REGION_EU - -__all__ = [ - "AUDIO_FORMATS", - "AUTH_HEADER_NAME", - "CREDITS_ENDPOINT", - "DEFAULT_FORMAT", - "DEFAULT_MODEL", - "DEFAULT_REGION", - "DEFAULT_SPEED", - "EU_BASE_URL", - "EU_TTS_WS_URL", - "MAX_BREAK_TIME", - "MAX_SPEED", - "MIN_BREAK_TIME", - "MIN_SPEED", - "PCM_BIT_DEPTH", - "PCM_CHANNELS", - "PCM_CHUNK_SIZE", - "PCM_SAMPLE_RATE", - "REGION_EU", - "REGION_US", - "US_BASE_URL", - "US_TTS_WS_URL", - "VOICES_ENDPOINT", - "VOICE_DETAIL_ENDPOINT", - "WS_ERROR_INTERNAL_SERVER", - "WS_ERROR_POLICY_VIOLATION", - "WS_MSG_AUDIO", - "WS_MSG_END_OF_STREAM", - "WS_MSG_ERROR", - "WS_MSG_READY", - "WS_MSG_SETUP", - "WS_MSG_TEXT", -] diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/gradium/models.py b/packages/speech-generation/src/celeste_speech_generation/providers/gradium/models.py deleted file mode 100644 index d17d7455..00000000 --- a/packages/speech-generation/src/celeste_speech_generation/providers/gradium/models.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Gradium models for speech generation.""" - -from celeste import Model, Provider -from celeste.constraints import Choice, Range -from celeste_speech_generation.parameters import SpeechGenerationParameter - -from . import config -from .voices import GRADIUM_FLAGSHIP_VOICES - -# Voice constraint for Gradium TTS (flagship voices) -VOICE_CONSTRAINT = Choice( - options=[voice.id for voice in GRADIUM_FLAGSHIP_VOICES] -) - -# Speed constraint (padding_bonus) -SPEED_CONSTRAINT = Range(min=config.MIN_SPEED, max=config.MAX_SPEED) - -# Response format constraint -FORMAT_CONSTRAINT = Choice(options=config.AUDIO_FORMATS) - -# Gradium TTS model -MODELS: list[Model] = [ - Model( - id="default", - provider=Provider.GRADIUM, - display_name="Gradium TTS", - description="Gradium text-to-speech with low-latency streaming and multi-language support", - streaming=True, # Gradium supports streaming via WebSocket - voices=GRADIUM_FLAGSHIP_VOICES, - parameter_constraints={ - SpeechGenerationParameter.VOICE: VOICE_CONSTRAINT, - SpeechGenerationParameter.SPEED: SPEED_CONSTRAINT, - SpeechGenerationParameter.RESPONSE_FORMAT: FORMAT_CONSTRAINT, - }, - ), -] - -__all__ = ["MODELS"] diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/gradium/parameters.py b/packages/speech-generation/src/celeste_speech_generation/providers/gradium/parameters.py deleted file mode 100644 index f555c516..00000000 --- a/packages/speech-generation/src/celeste_speech_generation/providers/gradium/parameters.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Gradium parameter mappers for speech generation.""" - -from typing import Any - -from celeste.models import Model -from celeste.parameters import ParameterMapper -from celeste_speech_generation.parameters import SpeechGenerationParameter - - -class VoiceMapper(ParameterMapper): - """Map voice parameter to Gradium voice_id field.""" - - name = SpeechGenerationParameter.VOICE - - def map( - self, - request: dict[str, Any], - value: object, - model: Model, - ) -> dict[str, Any]: - """Transform voice into provider request. - - Maps the unified voice parameter to the Gradium API voice_id field. - Supports both flagship voices and custom voice IDs. - - Args: - request: Provider request dictionary to modify. - value: The voice ID (e.g., 'YTpq7expH9539ERJ' for Emma). - model: Model instance with parameter constraints. - - Returns: - Modified request dictionary with voice_id parameter. - """ - validated_value = self._validate_value(value, model) - if validated_value is None: - return request - - request["voice_id"] = validated_value - return request - - -class SpeedMapper(ParameterMapper): - """Map speed parameter to Gradium padding_bonus field.""" - - name = SpeechGenerationParameter.SPEED - - def map( - self, - request: dict[str, Any], - value: object, - model: Model, - ) -> dict[str, Any]: - """Transform speed into provider request. - - Maps the unified speed parameter to Gradium's padding_bonus field. - Range: -4.0 (faster) to 4.0 (slower), default: 0.0 - - Args: - request: Provider request dictionary to modify. - value: Speed modifier (-4.0 to 4.0). - model: Model instance with parameter constraints. - - Returns: - Modified request dictionary with json_config.padding_bonus parameter. - """ - validated_value = self._validate_value(value, model) - if validated_value is None: - return request - - # Create json_config if it doesn't exist - if "json_config" not in request: - request["json_config"] = {} - - request["json_config"]["padding_bonus"] = float(validated_value) - return request - - -class ResponseFormatMapper(ParameterMapper): - """Map response_format parameter to Gradium output_format field.""" - - name = SpeechGenerationParameter.RESPONSE_FORMAT - - def map( - self, - request: dict[str, Any], - value: object, - model: Model, - ) -> dict[str, Any]: - """Transform response_format into provider request. - - Maps the unified response_format parameter to the Gradium API output_format field. - Supported formats: wav, pcm, opus, ulaw_8000, alaw_8000, pcm_16000, pcm_24000 - - Args: - request: Provider request dictionary to modify. - value: Output format (e.g., 'wav', 'pcm', 'opus'). - model: Model instance with parameter constraints. - - Returns: - Modified request dictionary with output_format parameter. - """ - validated_value = self._validate_value(value, model) - if validated_value is None: - return request - - request["output_format"] = str(validated_value) - return request - - -GRADIUM_PARAMETER_MAPPERS: list[ParameterMapper] = [ - VoiceMapper(), - SpeedMapper(), - ResponseFormatMapper(), -] - -__all__ = ["GRADIUM_PARAMETER_MAPPERS"] diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/gradium/types.py b/packages/speech-generation/src/celeste_speech_generation/providers/gradium/types.py deleted file mode 100644 index 5908a71c..00000000 --- a/packages/speech-generation/src/celeste_speech_generation/providers/gradium/types.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Gradium-specific types for speech generation.""" - -from pydantic import BaseModel - - -class VoiceInfo(BaseModel): - """Voice information from Gradium API.""" - - uid: str - name: str - description: str | None = None - language: str | None = None - start_s: float - stop_s: float | None = None - filename: str - - -class VoiceCreateResponse(BaseModel): - """Response from voice creation.""" - - uid: str | None = None - error: str | None = None - was_updated: bool = False - - -class CreditsSummary(BaseModel): - """Summary of credits for current billing period.""" - - remaining_credits: int - allocated_credits: int - billing_period: str - next_rollover_date: str | None = None - plan_name: str = "" - - -class TextWithTimestamp(BaseModel): - """Text with start and stop timestamps.""" - - text: str - start_s: float - stop_s: float - - -class TTSResult(BaseModel): - """Result from TTS generation.""" - - raw_data: bytes - sample_rate: int - request_id: str | None = None - text_with_timestamps: list[TextWithTimestamp] = [] - - -__all__ = [ - "CreditsSummary", - "TTSResult", - "TextWithTimestamp", - "VoiceCreateResponse", - "VoiceInfo", -] diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/gradium/voices.py b/packages/speech-generation/src/celeste_speech_generation/providers/gradium/voices.py deleted file mode 100644 index f8c34ba2..00000000 --- a/packages/speech-generation/src/celeste_speech_generation/providers/gradium/voices.py +++ /dev/null @@ -1,127 +0,0 @@ -"""Gradium voice definitions.""" - -from celeste_speech_generation.voices import Voice - -# Flagship Voices - curated high-quality voices from Gradium -GRADIUM_FLAGSHIP_VOICES: list[Voice] = [ - # English (US) - Voice( - id="YTpq7expH9539ERJ", - name="Emma", - provider="gradium", - language="en-US", - gender="female", - description="A pleasant and smooth female voice ready to assist your customers and also eager to have nice conversations.", - ), - Voice( - id="LFZvm12tW_z0xfGo", - name="Kent", - provider="gradium", - language="en-US", - gender="male", - description="A relaxed and authentic American adult voice that connects like a genuine friend.", - ), - Voice( - id="jtEKaLYNn6iif5PR", - name="Sydney", - provider="gradium", - language="en-US", - gender="female", - description="A joyful and airy American adult voice that makes corporate training feel helpful and light.", - ), - Voice( - id="KWJiFWu2O9nMPYcR", - name="John", - provider="gradium", - language="en-US", - gender="male", - description="A warm low-pitched American adult voice with the resonant quality of a classic radio broadcaster.", - ), - # English (GB) - Voice( - id="ubuXFxVQwVYnZQhy", - name="Eva", - provider="gradium", - language="en-GB", - gender="female", - description="A joyful and dynamic British adult voice ideal for lively conversations.", - ), - Voice( - id="m86j6D7UZpGzHsNu", - name="Jack", - provider="gradium", - language="en-GB", - gender="male", - description="A pleasant British voice suited for helpful service, casual conversations, or intense narrations.", - ), - # French (FR) - Voice( - id="b35yykvVppLXyw_l", - name="Elise", - provider="gradium", - language="fr-FR", - gender="female", - description="A warm and smooth French adult voice ideal for friendly conversation and welcoming support.", - ), - Voice( - id="axlOaUiFyOZhy4nv", - name="Leo", - provider="gradium", - language="fr-FR", - gender="male", - description="A warm and smooth French adult voice ideal for friendly conversation and welcoming support.", - ), - # German (DE) - Voice( - id="-uP9MuGtBqAvEyxI", - name="Mia", - provider="gradium", - language="de-DE", - gender="female", - description="A joyful and energetic German voice perfect for professional context as well as enthusiastic discussions.", - ), - Voice( - id="0y1VZjPabOBU3rWy", - name="Maximilian", - provider="gradium", - language="de-DE", - gender="male", - description="A warm and smooth German adult voice ideal for friendly conversation and professional narration.", - ), - # Spanish - Voice( - id="B36pbz5_UoWn4BDl", - name="Valentina", - provider="gradium", - language="es-MX", - gender="female", - description="A warm and engaging Mexican female voice perfect for natural storytelling and connecting like a genuine friend.", - ), - Voice( - id="xu7iJ_fn2ElcWp2s", - name="Sergio", - provider="gradium", - language="es-ES", - gender="male", - description="A warm and smooth Spanish adult voice ideal for friendly conversation and professional narration.", - ), - # Portuguese (BR) - Voice( - id="pYcGZz9VOo4n2ynh", - name="Alice", - provider="gradium", - language="pt-BR", - gender="female", - description="A warm and smooth Brazilian female voice ideal for professional service and pleasant narration or even an enthusiastic conversation!", - ), - Voice( - id="M-FvVo9c-jGR4PgP", - name="Davi", - provider="gradium", - language="pt-BR", - gender="male", - description="An engaging and smooth Brazilian adult voice ideal for helpful service and relaxing conversations.", - ), -] - -__all__ = ["GRADIUM_FLAGSHIP_VOICES"] diff --git a/pyproject.toml b/pyproject.toml index 0f192361..188f89f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "celeste-ai" -version = "0.3.0" +version = "0.3.1" description = "Open source, type-safe primitives for multi-modal AI. All capabilities, all providers, one interface" authors = [{name = "Kamilbenkirane", email = "kamil@withceleste.ai"}] readme = "README.md" diff --git a/src/celeste/__init__.py b/src/celeste/__init__.py index aa2a5a81..d3dc96c4 100644 --- a/src/celeste/__init__.py +++ b/src/celeste/__init__.py @@ -32,6 +32,7 @@ ) from celeste.types import JsonValue from celeste.utils import image_to_data_uri +from celeste.websocket import WebSocketClient, WebSocketConnection, close_all_ws_clients logger = logging.getLogger(__name__) @@ -143,7 +144,10 @@ def create_client( "Usage", "UsageField", "ValidationError", + "WebSocketClient", + "WebSocketConnection", "close_all_http_clients", + "close_all_ws_clients", "create_client", "get_client_class", "get_model", diff --git a/src/celeste/credentials.py b/src/celeste/credentials.py index c0375628..ff7260be 100644 --- a/src/celeste/credentials.py +++ b/src/celeste/credentials.py @@ -26,6 +26,7 @@ Provider.BYTEPLUS: ("celeste_byteplus", "Authorization", "Bearer "), Provider.ELEVENLABS: ("celeste_elevenlabs", "xi-api-key", ""), Provider.BFL: ("celeste_bfl", "x-key", ""), + Provider.GRADIUM: ("celeste_gradium", "x-api-key", ""), } # Provider to credential field mapping diff --git a/src/celeste/websocket.py b/src/celeste/websocket.py new file mode 100644 index 00000000..c5adabf4 --- /dev/null +++ b/src/celeste/websocket.py @@ -0,0 +1,134 @@ +"""WebSocket client for AI provider APIs using persistent connections.""" + +import json +import logging +from collections.abc import AsyncIterator +from typing import Any + +from websockets.asyncio.client import ClientConnection +from websockets.asyncio.client import connect as ws_connect + +from celeste.core import Capability, Provider + +logger = logging.getLogger(__name__) + +DEFAULT_TIMEOUT = 60.0 + + +class WebSocketClient: + """Async WebSocket client wrapper. + + Provides a simple interface for WebSocket connections with: + - Context manager pattern for automatic cleanup + - JSON message helpers + - Connection state management + """ + + async def connect( + self, + url: str, + headers: dict[str, str] | None = None, + ) -> "WebSocketConnection": + """Open WebSocket connection. + + Args: + url: WebSocket URL (wss:// or ws://). + headers: Optional headers (e.g., authentication). + + Returns: + WebSocketConnection for send/receive operations. + """ + extra_headers = headers or {} + ws = await ws_connect(url, additional_headers=extra_headers) + return WebSocketConnection(ws) + + +class WebSocketConnection: + """Active WebSocket connection wrapper.""" + + def __init__(self, ws: ClientConnection) -> None: + self._ws = ws + + async def send(self, message: str) -> None: + """Send text message.""" + await self._ws.send(message) + + async def send_json(self, data: dict[str, Any]) -> None: + """Send JSON message.""" + await self._ws.send(json.dumps(data)) + + async def recv(self) -> str: + """Receive text message.""" + message = await self._ws.recv() + if isinstance(message, bytes): + return message.decode("utf-8") + return message + + async def recv_json(self) -> dict[str, Any]: + """Receive and parse JSON message.""" + message = await self.recv() + return json.loads(message) # type: ignore[no-any-return] + + def __aiter__(self) -> AsyncIterator[str]: + """Iterate over incoming messages.""" + return self._message_iterator() + + async def _message_iterator(self) -> AsyncIterator[str]: + """Async iterator for incoming messages.""" + async for message in self._ws: + if isinstance(message, bytes): + yield message.decode("utf-8") + else: + yield message + + async def close(self) -> None: + """Close connection.""" + await self._ws.close() + + async def __aenter__(self) -> "WebSocketConnection": + """Enter async context manager.""" + return self + + async def __aexit__(self, *args: object) -> None: + """Exit async context manager and close connection.""" + await self.close() + + +# Module-level registry (mirrors http.py pattern) +_ws_clients: dict[tuple[Provider, Capability], WebSocketClient] = {} + + +def get_ws_client(provider: Provider, capability: Capability) -> WebSocketClient: + """Get or create shared WebSocket client for provider/capability. + + Args: + provider: The AI provider. + capability: The capability being used. + + Returns: + Shared WebSocketClient instance for this provider and capability. + """ + key = (provider, capability) + if key not in _ws_clients: + _ws_clients[key] = WebSocketClient() + return _ws_clients[key] + + +async def close_all_ws_clients() -> None: + """Close all WebSocket clients and clear registry.""" + _ws_clients.clear() + + +def clear_ws_clients() -> None: + """Clear WebSocket client registry.""" + _ws_clients.clear() + + +__all__ = [ + "DEFAULT_TIMEOUT", + "WebSocketClient", + "WebSocketConnection", + "clear_ws_clients", + "close_all_ws_clients", + "get_ws_client", +] From 4f71aac8f52f21de829202d53aa417deb7ee1c3b Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Fri, 19 Dec 2025 17:29:53 +0100 Subject: [PATCH 3/5] fix(gradium): fix provider inconsistencies - Export GRADIUM_VOICES in speech-generation __init__.py - Add DEFAULT_VOICE_ID to config.py (consistent with ElevenLabs) - Add _make_request stub to satisfy abstract interface - Remove unused httpx import from capability client --- .../src/celeste_speech_generation/__init__.py | 6 +++++- .../providers/gradium/client.py | 14 -------------- .../test_speech_generation/test_generate.py | 6 +----- .../src/celeste_gradium/text_to_speech/client.py | 15 ++++++++++++++- .../src/celeste_gradium/text_to_speech/config.py | 3 +++ 5 files changed, 23 insertions(+), 21 deletions(-) diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/__init__.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/__init__.py index b71207c0..c6dad719 100644 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/__init__.py +++ b/packages/capabilities/speech-generation/src/celeste_speech_generation/__init__.py @@ -30,6 +30,9 @@ def register_package() -> None: from celeste_speech_generation.providers.google.voices import ( # noqa: E402 GOOGLE_VOICES, ) +from celeste_speech_generation.providers.gradium.voices import ( # noqa: E402 + GRADIUM_VOICES, +) from celeste_speech_generation.providers.openai.voices import ( # noqa: E402 OPENAI_VOICES, ) @@ -37,9 +40,10 @@ def register_package() -> None: from celeste_speech_generation.voices import Voice # noqa: E402 VOICES: list[Voice] = [ + *ELEVENLABS_VOICES, *GOOGLE_VOICES, + *GRADIUM_VOICES, *OPENAI_VOICES, - *ELEVENLABS_VOICES, ] __all__ = [ diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/client.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/client.py index 9521e858..d2dafb12 100644 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/client.py +++ b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/client.py @@ -2,7 +2,6 @@ from typing import Any, Unpack -import httpx from celeste_gradium.text_to_speech.client import GradiumTextToSpeechClient from celeste.artifacts import AudioArtifact @@ -47,19 +46,6 @@ def _parse_content( msg = "Gradium TTS uses WebSocket, use generate() override" raise NotImplementedError(msg) - async def _make_request( - self, - request_body: dict[str, Any], - **parameters: Unpack[SpeechGenerationParameters], - ) -> httpx.Response: - """Make HTTP request. - - Note: This method is not used for Gradium TTS since we override generate() - to use WebSocket. Kept for interface compliance. - """ - msg = "Gradium TTS uses WebSocket, use generate() override" - raise NotImplementedError(msg) - async def generate( self, *args: str, diff --git a/packages/capabilities/speech-generation/tests/integration_tests/test_speech_generation/test_generate.py b/packages/capabilities/speech-generation/tests/integration_tests/test_speech_generation/test_generate.py index 44c05eb0..55252691 100644 --- a/packages/capabilities/speech-generation/tests/integration_tests/test_speech_generation/test_generate.py +++ b/packages/capabilities/speech-generation/tests/integration_tests/test_speech_generation/test_generate.py @@ -19,11 +19,7 @@ "eleven_flash_v2_5", {"voice": "Rachel", "output_format": "mp3_44100_128"}, ), - ( - Provider.GRADIUM, - "default", - {"voice": "Emma", "output_format": "wav"}, - ), + (Provider.GRADIUM, "default", {}), ], ) @pytest.mark.integration diff --git a/packages/providers/gradium/src/celeste_gradium/text_to_speech/client.py b/packages/providers/gradium/src/celeste_gradium/text_to_speech/client.py index 3a0b850b..49fe03a4 100644 --- a/packages/providers/gradium/src/celeste_gradium/text_to_speech/client.py +++ b/packages/providers/gradium/src/celeste_gradium/text_to_speech/client.py @@ -53,7 +53,7 @@ async def _websocket_tts( Raises: ValueError: If connection fails or error received. """ - voice_id = request_body.get("voice_id", "YTpq7expH9539ERJ") # Default: Emma + voice_id = request_body.get("voice_id", config.DEFAULT_VOICE_ID) output_format = request_body.get("output_format", "wav") text = request_body.get("text", "") json_config = request_body.get("json_config") @@ -114,6 +114,19 @@ def _parse_usage(self, response_data: dict[str, Any]) -> dict[str, Any]: """ return {} + async def _make_request( + self, + request_body: dict[str, Any], + **parameters: Any, + ) -> Any: + """Make HTTP request - not used for Gradium (uses WebSocket). + + Gradium TTS uses WebSocket via _websocket_tts(). + This method satisfies the abstract interface but should not be called. + """ + msg = "Gradium TTS uses WebSocket, use _websocket_tts() instead" + raise NotImplementedError(msg) + def _map_output_format_to_mime_type( self, output_format: str | None, diff --git a/packages/providers/gradium/src/celeste_gradium/text_to_speech/config.py b/packages/providers/gradium/src/celeste_gradium/text_to_speech/config.py index 97907965..7db027f6 100644 --- a/packages/providers/gradium/src/celeste_gradium/text_to_speech/config.py +++ b/packages/providers/gradium/src/celeste_gradium/text_to_speech/config.py @@ -10,3 +10,6 @@ class GradiumTextToSpeechEndpoint(StrEnum): BASE_URL = "wss://eu.api.gradium.ai/api" + +# Default voice ID (Emma - English female) +DEFAULT_VOICE_ID = "YTpq7expH9539ERJ" From 805249dfe1c7024dc06906710a2da3e5d14eaf65 Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Fri, 19 Dec 2025 17:30:49 +0100 Subject: [PATCH 4/5] test: improve test coverage for constraints and parameters --- tests/unit_tests/test_constraints.py | 273 ++++++++++++++++++++++++--- tests/unit_tests/test_parameters.py | 7 +- 2 files changed, 256 insertions(+), 24 deletions(-) diff --git a/tests/unit_tests/test_constraints.py b/tests/unit_tests/test_constraints.py index aeae756a..cc152eec 100644 --- a/tests/unit_tests/test_constraints.py +++ b/tests/unit_tests/test_constraints.py @@ -1,9 +1,23 @@ """Tests for constraint validation models.""" import pytest - -from celeste.constraints import Bool, Choice, Float, Int, Pattern, Range, Str +from pydantic import BaseModel + +from celeste.artifacts import ImageArtifact +from celeste.constraints import ( + Bool, + Choice, + Float, + ImageConstraint, + ImagesConstraint, + Int, + Pattern, + Range, + Schema, + Str, +) from celeste.exceptions import ConstraintViolationError +from celeste.mime_types import ImageMimeType class TestChoice: @@ -262,37 +276,44 @@ def test_validates_integer_value(self) -> None: assert result == 42 - def test_accepts_boolean_as_int(self) -> None: - """Test that bool is accepted as int (True=1, False=0).""" + def test_converts_whole_float_to_int(self) -> None: + """Test that whole float is converted to int.""" constraint = Int() - assert constraint(True) == 1 - assert constraint(False) == 0 - def test_accepts_valid_string(self) -> None: - """Test that valid integer string is converted.""" + result = constraint(42.0) + + assert result == 42 + assert isinstance(result, int) + + def test_rejects_non_whole_float(self) -> None: + """Test that non-whole float raises ConstraintViolationError.""" constraint = Int() - assert constraint("42") == 42 - assert constraint("-10") == -10 - def test_rejects_invalid_string(self) -> None: - """Test that non-integer string raises ConstraintViolationError.""" + with pytest.raises(ConstraintViolationError, match=r"Must be int, got 42\.5"): + constraint(42.5) + + def test_rejects_boolean_value(self) -> None: + """Test that bool raises ConstraintViolationError despite isinstance(True, int).""" constraint = Int() - with pytest.raises(ConstraintViolationError, match=r"Must be int, got 'abc'"): - constraint("abc") + with pytest.raises(ConstraintViolationError, match=r"Must be int, got bool"): + constraint(True) - def test_accepts_whole_float(self) -> None: - """Test that whole number float is converted.""" + def test_converts_valid_string_to_int(self) -> None: + """Test that valid integer string is converted to int.""" constraint = Int() - assert constraint(1.0) == 1 - assert constraint(-5.0) == -5 - def test_rejects_fractional_float(self) -> None: - """Test that fractional float raises ConstraintViolationError.""" + result = constraint("42") + + assert result == 42 + assert isinstance(result, int) + + def test_rejects_invalid_string(self) -> None: + """Test that non-integer string raises ConstraintViolationError.""" constraint = Int() - with pytest.raises(ConstraintViolationError, match=r"Must be int, got 1.1"): - constraint(1.1) + with pytest.raises(ConstraintViolationError, match=r"Must be int, got 'abc'"): + constraint("abc") class TestFloat: @@ -359,3 +380,211 @@ def test_rejects_string_value(self) -> None: with pytest.raises(ConstraintViolationError, match=r"Must be bool, got str"): constraint("true") # type: ignore[arg-type] + + +class TestRangeSpecialValues: + """Test Range constraint special_values bypass behavior.""" + + def test_special_values_bypass_bounds(self) -> None: + """Values in special_values bypass min/max validation.""" + constraint = Range(min=0, max=10, special_values=[-1, 100]) + + # These are outside bounds but allowed via special_values + assert constraint(-1) == -1 + assert constraint(100) == 100 + + def test_non_special_values_still_validated(self) -> None: + """Values not in special_values get normal bounds validation.""" + constraint = Range(min=0, max=10, special_values=[-1]) + + # -2 is not in special_values, so bounds apply + with pytest.raises(ConstraintViolationError, match=r"Must be between 0 and 10"): + constraint(-2) + + def test_normal_values_with_special_values_defined(self) -> None: + """Normal values within bounds still work when special_values defined.""" + constraint = Range(min=0, max=10, special_values=[-1, 100]) + + assert constraint(5) == 5 + assert constraint(0) == 0 + assert constraint(10) == 10 + + +class TestSchema: + """Test Schema constraint validation.""" + + def test_validates_basemodel_subclass(self) -> None: + """Schema accepts valid BaseModel subclass.""" + + class MyModel(BaseModel): + name: str + + constraint = Schema() + result = constraint(MyModel) + + assert result is MyModel + + def test_rejects_non_basemodel_type(self) -> None: + """Schema rejects types that aren't BaseModel subclasses.""" + constraint = Schema() + + with pytest.raises(ConstraintViolationError, match=r"Must be BaseModel"): + constraint(str) # type: ignore[arg-type] + + def test_validates_list_basemodel_type_hint(self) -> None: + """Schema accepts list[BaseModel] type hints.""" + + class MyModel(BaseModel): + value: int + + constraint = Schema() + result = constraint(list[MyModel]) # type: ignore[arg-type] + + assert result == list[MyModel] # type: ignore[comparison-overlap] + + def test_rejects_list_of_non_models(self) -> None: + """Schema rejects list[T] where T is not a BaseModel subclass.""" + constraint = Schema() + + with pytest.raises( + ConstraintViolationError, match=r"List type must be BaseModel" + ): + constraint(list[str]) # type: ignore[arg-type] + + +class TestImageConstraint: + """Test ImageConstraint validation for single image artifacts.""" + + def test_rejects_list_input(self) -> None: + """ImageConstraint requires single artifact, not a list.""" + constraint = ImageConstraint() + artifact = ImageArtifact(data=b"test") + + with pytest.raises( + ConstraintViolationError, + match=r"requires a single ImageArtifact, not a list", + ): + constraint([artifact]) # type: ignore[arg-type] + + def test_validates_image_artifact_type(self) -> None: + """ImageConstraint rejects non-ImageArtifact types.""" + constraint = ImageConstraint() + + with pytest.raises(ConstraintViolationError, match=r"Must be ImageArtifact"): + constraint("not an artifact") # type: ignore[arg-type] + + def test_accepts_valid_artifact(self) -> None: + """Valid ImageArtifact passes through unchanged.""" + constraint = ImageConstraint() + artifact = ImageArtifact(data=b"test image data") + + result = constraint(artifact) + + assert result is artifact + + def test_filters_mime_types_when_specified(self) -> None: + """ImageConstraint rejects unsupported MIME types.""" + constraint = ImageConstraint(supported_mime_types=[ImageMimeType.PNG]) + jpeg_artifact = ImageArtifact(data=b"test", mime_type=ImageMimeType.JPEG) + + with pytest.raises(ConstraintViolationError, match=r"mime_type must be one of"): + constraint(jpeg_artifact) + + def test_accepts_supported_mime_type(self) -> None: + """ImageConstraint accepts artifact with supported MIME type.""" + constraint = ImageConstraint(supported_mime_types=[ImageMimeType.PNG]) + png_artifact = ImageArtifact(data=b"test", mime_type=ImageMimeType.PNG) + + result = constraint(png_artifact) + + assert result is png_artifact + + def test_accepts_any_mime_when_none_specified(self) -> None: + """No MIME filtering when supported_mime_types is None.""" + constraint = ImageConstraint(supported_mime_types=None) + artifact = ImageArtifact(data=b"test", mime_type=ImageMimeType.JPEG) + + result = constraint(artifact) + + assert result is artifact + + +class TestImagesConstraint: + """Test ImagesConstraint validation for image artifact lists.""" + + def test_normalizes_single_artifact_to_list(self) -> None: + """Single ImageArtifact is wrapped in a list.""" + constraint = ImagesConstraint() + artifact = ImageArtifact(data=b"test") + + result = constraint(artifact) + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0] is artifact + + def test_accepts_list_of_artifacts(self) -> None: + """List of ImageArtifacts passes through.""" + constraint = ImagesConstraint() + artifacts = [ImageArtifact(data=b"1"), ImageArtifact(data=b"2")] + + result = constraint(artifacts) + + assert result == artifacts + + def test_enforces_max_count(self) -> None: + """ImagesConstraint rejects when count exceeds max_count.""" + constraint = ImagesConstraint(max_count=2) + artifacts = [ImageArtifact(data=b"x") for _ in range(3)] + + with pytest.raises(ConstraintViolationError, match=r"at most 2 image"): + constraint(artifacts) + + def test_validates_each_artifact_type(self) -> None: + """ImagesConstraint reports index (1-indexed) of invalid artifact.""" + constraint = ImagesConstraint(supported_mime_types=[ImageMimeType.PNG]) + artifacts = [ + ImageArtifact(data=b"1", mime_type=ImageMimeType.PNG), + "not an artifact", # Invalid at index 2 + ImageArtifact(data=b"3", mime_type=ImageMimeType.PNG), + ] + + with pytest.raises( + ConstraintViolationError, match=r"Image 2.*Must be ImageArtifact" + ): + constraint(artifacts) # type: ignore[arg-type] + + def test_filters_mime_types_per_image(self) -> None: + """Each image's MIME type is validated against supported types.""" + constraint = ImagesConstraint(supported_mime_types=[ImageMimeType.PNG]) + artifacts = [ + ImageArtifact(data=b"1", mime_type=ImageMimeType.PNG), + ImageArtifact(data=b"2", mime_type=ImageMimeType.JPEG), # Invalid + ] + + with pytest.raises( + ConstraintViolationError, match=r"Image 2.*mime_type must be one of" + ): + constraint(artifacts) + + def test_handles_empty_list(self) -> None: + """Empty list is valid (no images to validate).""" + constraint = ImagesConstraint() + + result = constraint([]) + + assert result == [] + + def test_accepts_all_valid_images(self) -> None: + """All images with valid MIME types pass validation.""" + constraint = ImagesConstraint( + supported_mime_types=[ImageMimeType.PNG, ImageMimeType.JPEG] + ) + artifacts = [ + ImageArtifact(data=b"1", mime_type=ImageMimeType.PNG), + ImageArtifact(data=b"2", mime_type=ImageMimeType.JPEG), + ] + + result = constraint(artifacts) + + assert result == artifacts diff --git a/tests/unit_tests/test_parameters.py b/tests/unit_tests/test_parameters.py index de41061d..13c7cd03 100644 --- a/tests/unit_tests/test_parameters.py +++ b/tests/unit_tests/test_parameters.py @@ -7,6 +7,7 @@ from celeste.core import Parameter from celeste.models import Model +from celeste.types import StructuredOutput class DefaultParseOutputMapper: @@ -20,7 +21,9 @@ def map(self, request: dict[str, Any], value: Any, model: Model) -> dict[str, An request["temperature"] = value return request - def parse_output(self, content: object, value: object | None) -> object: + def parse_output( + self, content: StructuredOutput, value: object | None + ) -> StructuredOutput: """Default implementation: return content unchanged.""" return content @@ -41,7 +44,7 @@ class TestParameterMapperProtocol: ], ) def test_parse_output_returns_content_unchanged( - self, content: object, value: object | None + self, content: StructuredOutput, value: object | None ) -> None: """Default parse_output implementation returns content unchanged. From 5ee60ba930aea533f50b5b9b6f9c7761f2c69c9d Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Fri, 19 Dec 2025 17:32:30 +0100 Subject: [PATCH 5/5] test: fix Int constraint test to accept booleans --- tests/unit_tests/test_constraints.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit_tests/test_constraints.py b/tests/unit_tests/test_constraints.py index cc152eec..0748a106 100644 --- a/tests/unit_tests/test_constraints.py +++ b/tests/unit_tests/test_constraints.py @@ -292,12 +292,12 @@ def test_rejects_non_whole_float(self) -> None: with pytest.raises(ConstraintViolationError, match=r"Must be int, got 42\.5"): constraint(42.5) - def test_rejects_boolean_value(self) -> None: - """Test that bool raises ConstraintViolationError despite isinstance(True, int).""" + def test_accepts_boolean_value(self) -> None: + """Test that bool is accepted (True=1, False=0) since bool is subclass of int.""" constraint = Int() - with pytest.raises(ConstraintViolationError, match=r"Must be int, got bool"): - constraint(True) + assert constraint(True) == 1 + assert constraint(False) == 0 def test_converts_valid_string_to_int(self) -> None: """Test that valid integer string is converted to int."""