From aa48f7d746aea1be3f760c8986bc65ed4959dfbb Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Thu, 4 Dec 2025 16:02:45 +0100 Subject: [PATCH 1/4] feat: add speech-generation package with OpenAI, Google, and ElevenLabs providers - Add complete speech-generation package with unified interface - Support for OpenAI TTS-1, Google Cloud TTS, and ElevenLabs models - Implement streaming support for ElevenLabs - Add voice constraints with name/ID mapping - Fix input content extraction pattern across all generation capabilities - Update root pyproject.toml: bump version to 0.2.13, add speech-generation to optional-dependencies - Add ELEVENLABS_API_KEY to GitHub workflows - Update CI/CD: add speech-generation to mypy checks, exclude conftest files - All tests passing, CI checks green Breaking changes: None (new package) --- .github/workflows/ci.yml | 2 +- .github/workflows/publish.yml | 1 + Makefile | 2 +- .../src/celeste_image_generation/client.py | 6 +- packages/speech-generation/README.md | 79 +++++++ packages/speech-generation/pyproject.toml | 39 ++++ .../src/celeste_speech_generation/__init__.py | 53 +++++ .../src/celeste_speech_generation/client.py | 71 +++++++ .../celeste_speech_generation/constraints.py | 42 ++++ .../src/celeste_speech_generation/io.py | 40 ++++ .../src/celeste_speech_generation/models.py | 14 ++ .../celeste_speech_generation/parameters.py | 23 ++ .../providers/__init__.py | 28 +++ .../providers/elevenlabs/__init__.py | 11 + .../providers/elevenlabs/client.py | 200 ++++++++++++++++++ .../providers/elevenlabs/config.py | 10 + .../providers/elevenlabs/models.py | 83 ++++++++ .../providers/elevenlabs/parameters.py | 124 +++++++++++ .../providers/elevenlabs/streaming.py | 44 ++++ .../providers/elevenlabs/voices.py | 70 ++++++ .../providers/google/__init__.py | 6 + .../providers/google/client.py | 123 +++++++++++ .../providers/google/config.py | 15 ++ .../providers/google/models.py | 31 +++ .../providers/google/parameters.py | 64 ++++++ .../providers/google/voices.py | 177 ++++++++++++++++ .../providers/openai/__init__.py | 6 + .../providers/openai/client.py | 122 +++++++++++ .../providers/openai/config.py | 9 + .../providers/openai/models.py | 61 ++++++ .../providers/openai/parameters.py | 132 ++++++++++++ .../providers/openai/voices.py | 140 ++++++++++++ .../src/celeste_speech_generation/py.typed | 1 + .../celeste_speech_generation/streaming.py | 44 ++++ .../src/celeste_speech_generation/voices.py | 17 ++ packages/speech-generation/tests/conftest.py | 24 +++ .../tests/integration_tests/__init__.py | 0 .../test_speech_generation/__init__.py | 0 .../test_speech_generation/test_generate.py | 73 +++++++ .../test_speech_generation/test_stream.py | 59 ++++++ .../src/celeste_text_generation/client.py | 6 +- .../src/celeste_video_generation/client.py | 6 +- pyproject.toml | 10 +- 43 files changed, 2058 insertions(+), 10 deletions(-) create mode 100644 packages/speech-generation/README.md create mode 100644 packages/speech-generation/pyproject.toml create mode 100644 packages/speech-generation/src/celeste_speech_generation/__init__.py create mode 100644 packages/speech-generation/src/celeste_speech_generation/client.py create mode 100644 packages/speech-generation/src/celeste_speech_generation/constraints.py create mode 100644 packages/speech-generation/src/celeste_speech_generation/io.py create mode 100644 packages/speech-generation/src/celeste_speech_generation/models.py create mode 100644 packages/speech-generation/src/celeste_speech_generation/parameters.py create mode 100644 packages/speech-generation/src/celeste_speech_generation/providers/__init__.py create mode 100644 packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/__init__.py create mode 100644 packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/client.py create mode 100644 packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/config.py create mode 100644 packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/models.py create mode 100644 packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/parameters.py create mode 100644 packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/streaming.py create mode 100644 packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/voices.py create mode 100644 packages/speech-generation/src/celeste_speech_generation/providers/google/__init__.py create mode 100644 packages/speech-generation/src/celeste_speech_generation/providers/google/client.py create mode 100644 packages/speech-generation/src/celeste_speech_generation/providers/google/config.py create mode 100644 packages/speech-generation/src/celeste_speech_generation/providers/google/models.py create mode 100644 packages/speech-generation/src/celeste_speech_generation/providers/google/parameters.py create mode 100644 packages/speech-generation/src/celeste_speech_generation/providers/google/voices.py create mode 100644 packages/speech-generation/src/celeste_speech_generation/providers/openai/__init__.py create mode 100644 packages/speech-generation/src/celeste_speech_generation/providers/openai/client.py create mode 100644 packages/speech-generation/src/celeste_speech_generation/providers/openai/config.py create mode 100644 packages/speech-generation/src/celeste_speech_generation/providers/openai/models.py create mode 100644 packages/speech-generation/src/celeste_speech_generation/providers/openai/parameters.py create mode 100644 packages/speech-generation/src/celeste_speech_generation/providers/openai/voices.py create mode 100644 packages/speech-generation/src/celeste_speech_generation/py.typed create mode 100644 packages/speech-generation/src/celeste_speech_generation/streaming.py create mode 100644 packages/speech-generation/src/celeste_speech_generation/voices.py create mode 100644 packages/speech-generation/tests/conftest.py create mode 100644 packages/speech-generation/tests/integration_tests/__init__.py create mode 100644 packages/speech-generation/tests/integration_tests/test_speech_generation/__init__.py create mode 100644 packages/speech-generation/tests/integration_tests/test_speech_generation/test_generate.py create mode 100644 packages/speech-generation/tests/integration_tests/test_speech_generation/test_stream.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c08ac1f..50917af2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,7 +64,7 @@ jobs: python-version: ${{ inputs.python-version || '3.12' }} - run: | if [ -d "packages" ]; then - uv run mypy -p celeste && uv run mypy tests/ && uv run mypy packages/image-generation packages/text-generation packages/video-generation + uv run mypy -p celeste && uv run mypy tests/ && uv run mypy packages/image-generation packages/text-generation packages/video-generation packages/speech-generation --exclude '.*conftest\.py' else uv run mypy -p celeste && uv run mypy tests/ fi diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index fb4ceb92..3134f207 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -61,6 +61,7 @@ jobs: COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }} BYTEDANCE_API_KEY: ${{ secrets.BYTEDANCE_API_KEY }} XAI_API_KEY: ${{ secrets.XAI_API_KEY }} + ELEVENLABS_API_KEY: ${{ secrets.ELEVENLABS_API_KEY }} run: make integration-test build: diff --git a/Makefile b/Makefile index 00344053..99d07cfe 100644 --- a/Makefile +++ b/Makefile @@ -33,7 +33,7 @@ format: # Type checking (fail fast on any error) typecheck: - @uv run mypy -p celeste && uv run mypy tests/ && uv run mypy packages/image-generation packages/text-generation packages/video-generation + @uv run mypy -p celeste && uv run mypy tests/ && uv run mypy packages/image-generation packages/text-generation packages/video-generation packages/speech-generation --exclude '.*conftest\.py' # Testing test: diff --git a/packages/image-generation/src/celeste_image_generation/client.py b/packages/image-generation/src/celeste_image_generation/client.py index b18dc22f..301c9cf3 100644 --- a/packages/image-generation/src/celeste_image_generation/client.py +++ b/packages/image-generation/src/celeste_image_generation/client.py @@ -45,12 +45,14 @@ def _parse_finish_reason( """Parse finish reason from provider response.""" def _create_inputs( - self, *args: str, **parameters: Unpack[ImageGenerationParameters] + self, + *args: str, + prompt: str | None = None, + **parameters: Unpack[ImageGenerationParameters], ) -> ImageGenerationInput: """Map positional arguments to Input type.""" if args: return ImageGenerationInput(prompt=args[0]) - prompt: str | None = parameters.get("prompt") if prompt is None: msg = ( "prompt is required (either as positional argument or keyword argument)" diff --git a/packages/speech-generation/README.md b/packages/speech-generation/README.md new file mode 100644 index 00000000..58115836 --- /dev/null +++ b/packages/speech-generation/README.md @@ -0,0 +1,79 @@ +
+ +# Celeste Logo Celeste Speech Generation + +**Speech Generation capability for Celeste AI** + +[![Python](https://img.shields.io/badge/Python-3.12+-blue?style=for-the-badge)](https://www.python.org/) +[![License](https://img.shields.io/badge/License-Apache_2.0-red?style=for-the-badge)](../../LICENSE) + +[Quick Start](#-quick-start) • [Documentation](https://withceleste.ai/docs) • [Request Provider](https://github.com/withceleste/celeste-python/issues/new) + +
+ +--- + +## 🚀 Quick Start + +```python +from celeste import create_client, Capability, Provider + +client = create_client( + capability=Capability.SPEECH_GENERATION, + provider=Provider.ELEVENLABS, +) + +response = await client.generate(text="Welcome to Celeste AI. Transform your text into natural, expressive speech with just a few lines of code.") +# response.content is an AudioArtifact with binary audio data +``` + +**Install:** +```bash +uv add "celeste-ai[speech-generation]" +``` + +--- + +## Supported Providers + + +
+ +OpenAI +Google +ElevenLabs + + +**Missing a provider?** [Request it](https://github.com/withceleste/celeste-python/issues/new) – ⚡ **we ship fast**. + +
+ +--- + +**Streaming**: ✅ Supported + +**Parameters**: See [API Documentation](https://withceleste.ai/docs/api) for full parameter reference. + +--- + +## 🤝 Contributing + +See [CONTRIBUTING.md](../../CONTRIBUTING.md) for guidelines. + +**Request a provider:** [GitHub Issues](https://github.com/withceleste/celeste-python/issues/new) + +--- + +## 📄 License + +Apache 2.0 License – see [LICENSE](../../LICENSE) for details. + +--- + +
+ +**[Get Started](https://withceleste.ai/docs/quickstart)** • **[Documentation](https://withceleste.ai/docs)** • **[GitHub](https://github.com/withceleste/celeste-python)** + +Made with ❤️ by developers tired of framework lock-in + +
diff --git a/packages/speech-generation/pyproject.toml b/packages/speech-generation/pyproject.toml new file mode 100644 index 00000000..01121501 --- /dev/null +++ b/packages/speech-generation/pyproject.toml @@ -0,0 +1,39 @@ +[project] +name = "celeste-speech-generation" +version = "0.2.8" +description = "Speech generation package for Celeste AI. Unified interface for all providers" +authors = [{name = "Kamilbenkirane", email = "kamil@withceleste.ai"}] +readme = "README.md" +license = {text = "Apache-2.0"} +requires-python = ">=3.12" +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Operating System :: OS Independent", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Typing :: Typed", +] +keywords = ["ai", "speech-generation", "tts", "text-to-speech", "openai", "google", "elevenlabs", "audio-ai"] + +[project.urls] +Homepage = "https://withceleste.ai" +Documentation = "https://withceleste.ai/docs" +Repository = "https://github.com/withceleste/celeste-python" +Issues = "https://github.com/withceleste/celeste-python/issues" + +[tool.uv.sources] +celeste-ai = { workspace = true } + +[project.entry-points."celeste.packages"] +speech-generation = "celeste_speech_generation:register_package" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/celeste_speech_generation"] diff --git a/packages/speech-generation/src/celeste_speech_generation/__init__.py b/packages/speech-generation/src/celeste_speech_generation/__init__.py new file mode 100644 index 00000000..231e54ad --- /dev/null +++ b/packages/speech-generation/src/celeste_speech_generation/__init__.py @@ -0,0 +1,53 @@ +"""Celeste speech generation capability.""" + + +def register_package() -> None: + """Register speech generation package (client and models).""" + from celeste.client import register_client + from celeste.core import Capability + from celeste.models import register_models + from celeste_speech_generation.models import MODELS + from celeste_speech_generation.providers import PROVIDERS + + for provider, client_class in PROVIDERS: + register_client(Capability.SPEECH_GENERATION, provider, client_class) + + register_models(MODELS, capability=Capability.SPEECH_GENERATION) + + +from celeste_speech_generation.io import ( # noqa: E402 + SpeechGenerationChunk, + SpeechGenerationInput, + SpeechGenerationOutput, + SpeechGenerationUsage, +) + +# Aggregate voices from all providers (after Voice is imported) +from celeste_speech_generation.providers.elevenlabs.voices import ( # noqa: E402 + ELEVENLABS_VOICES, +) +from celeste_speech_generation.providers.google.voices import ( # noqa: E402 + GOOGLE_VOICES, +) +from celeste_speech_generation.providers.openai.voices import ( # noqa: E402 + OPENAI_VOICES, +) +from celeste_speech_generation.streaming import SpeechGenerationStream # noqa: E402 +from celeste_speech_generation.voices import Voice # noqa: E402 + +VOICES: list[Voice] = [ + *GOOGLE_VOICES, + *OPENAI_VOICES, + *ELEVENLABS_VOICES, +] + +__all__ = [ + "VOICES", + "SpeechGenerationChunk", + "SpeechGenerationInput", + "SpeechGenerationOutput", + "SpeechGenerationStream", + "SpeechGenerationUsage", + "Voice", + "register_package", +] diff --git a/packages/speech-generation/src/celeste_speech_generation/client.py b/packages/speech-generation/src/celeste_speech_generation/client.py new file mode 100644 index 00000000..1401cb53 --- /dev/null +++ b/packages/speech-generation/src/celeste_speech_generation/client.py @@ -0,0 +1,71 @@ +"""Base client for speech generation.""" + +from abc import abstractmethod +from typing import Any, Unpack + +import httpx + +from celeste.artifacts import AudioArtifact +from celeste.client import Client +from celeste.exceptions import ValidationError +from celeste_speech_generation.io import ( + SpeechGenerationInput, + SpeechGenerationOutput, + SpeechGenerationUsage, +) +from celeste_speech_generation.parameters import SpeechGenerationParameters + + +class SpeechGenerationClient( + Client[SpeechGenerationInput, SpeechGenerationOutput, SpeechGenerationParameters] +): + """Client for speech generation operations.""" + + @abstractmethod + def _init_request(self, inputs: SpeechGenerationInput) -> dict[str, Any]: + """Initialize provider-specific request structure.""" + + @abstractmethod + def _parse_usage(self, response_data: dict[str, Any]) -> SpeechGenerationUsage: + """Parse usage information from provider response.""" + + @abstractmethod + def _parse_content( + self, + response_data: dict[str, Any], + **parameters: Unpack[SpeechGenerationParameters], + ) -> AudioArtifact: + """Parse content from provider response.""" + + def _create_inputs( + self, + *args: str, + text: str | None = None, + **parameters: Unpack[SpeechGenerationParameters], + ) -> SpeechGenerationInput: + """Map positional arguments to Input type.""" + if args: + return SpeechGenerationInput(text=args[0]) + if text is None: + msg = "text is required (either as positional argument or keyword argument)" + raise ValidationError(msg) + return SpeechGenerationInput(text=text) + + @classmethod + def _output_class(cls) -> type[SpeechGenerationOutput]: + """Return the Output class for this client.""" + return SpeechGenerationOutput + + def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: + """Build metadata dictionary from response data.""" + metadata = super()._build_metadata(response_data) + metadata["raw_response"] = response_data + return metadata + + @abstractmethod + async def _make_request( + self, + request_body: dict[str, Any], + **parameters: Unpack[SpeechGenerationParameters], + ) -> httpx.Response: + """Make HTTP request(s) and return response object.""" diff --git a/packages/speech-generation/src/celeste_speech_generation/constraints.py b/packages/speech-generation/src/celeste_speech_generation/constraints.py new file mode 100644 index 00000000..5d6b7aa4 --- /dev/null +++ b/packages/speech-generation/src/celeste_speech_generation/constraints.py @@ -0,0 +1,42 @@ +"""Constraint models for speech generation.""" + +from pydantic import Field + +from celeste.constraints import Constraint +from celeste.exceptions import ConstraintViolationError +from celeste_speech_generation.voices import Voice + + +class VoiceConstraint(Constraint): + """Voice constraint - value must be a valid voice ID or name from the provided voices. + + Accepts both voice IDs and names. If a name is provided, returns the corresponding ID. + """ + + voices: list[Voice] = Field(min_length=1) + + def __call__(self, value: str) -> str: + """Validate value is a valid voice ID or name and return the ID.""" + if not isinstance(value, str): + msg = f"Must be string, got {type(value).__name__}" + raise ConstraintViolationError(msg) + + # Check if value is a voice ID + voice_ids = [v.id for v in self.voices] + if value in voice_ids: + return value + + # Check if value is a voice name and return corresponding ID + voice_name_to_id = {v.name: v.id for v in self.voices} + if value in voice_name_to_id: + return voice_name_to_id[value] + + # Neither ID nor name matches + voice_names = [v.name for v in self.voices] + msg = ( + f"Must be one of {voice_names} (names) or {voice_ids} (IDs), got {value!r}" + ) + raise ConstraintViolationError(msg) + + +__all__ = ["VoiceConstraint"] diff --git a/packages/speech-generation/src/celeste_speech_generation/io.py b/packages/speech-generation/src/celeste_speech_generation/io.py new file mode 100644 index 00000000..f6b0a191 --- /dev/null +++ b/packages/speech-generation/src/celeste_speech_generation/io.py @@ -0,0 +1,40 @@ +"""Input and output types for speech generation.""" + +from celeste.artifacts import AudioArtifact +from celeste.io import Chunk, Input, Output, Usage + + +class SpeechGenerationInput(Input): + """Input for speech generation operations.""" + + text: str + + +class SpeechGenerationUsage(Usage): + """Speech generation usage metrics. + + All fields optional since providers vary. + """ + + +class SpeechGenerationOutput(Output[AudioArtifact]): + """Output with audio artifact content.""" + + +class SpeechGenerationChunk(Chunk[bytes]): + """Typed chunk for speech generation streaming. + + Note: Unlike TextGenerationChunk, this class intentionally omits a finish_reason + field. TTS providers stream raw audio bytes without completion signals - the + stream simply ends when audio generation is complete. + """ + + usage: SpeechGenerationUsage | None = None + + +__all__ = [ + "SpeechGenerationChunk", + "SpeechGenerationInput", + "SpeechGenerationOutput", + "SpeechGenerationUsage", +] diff --git a/packages/speech-generation/src/celeste_speech_generation/models.py b/packages/speech-generation/src/celeste_speech_generation/models.py new file mode 100644 index 00000000..c95d7cd4 --- /dev/null +++ b/packages/speech-generation/src/celeste_speech_generation/models.py @@ -0,0 +1,14 @@ +"""Model definitions for speech generation.""" + +from celeste import Model +from celeste_speech_generation.providers.elevenlabs.models import ( + MODELS as ELEVENLABS_MODELS, +) +from celeste_speech_generation.providers.google.models import MODELS as GOOGLE_MODELS +from celeste_speech_generation.providers.openai.models import MODELS as OPENAI_MODELS + +MODELS: list[Model] = [ + *GOOGLE_MODELS, + *OPENAI_MODELS, + *ELEVENLABS_MODELS, +] diff --git a/packages/speech-generation/src/celeste_speech_generation/parameters.py b/packages/speech-generation/src/celeste_speech_generation/parameters.py new file mode 100644 index 00000000..af54865d --- /dev/null +++ b/packages/speech-generation/src/celeste_speech_generation/parameters.py @@ -0,0 +1,23 @@ +"""Parameters for speech generation.""" + +from enum import StrEnum + +from celeste.parameters import Parameters + + +class SpeechGenerationParameter(StrEnum): + """Unified parameter names for speech generation capability.""" + + VOICE = "voice" + SPEED = "speed" + RESPONSE_FORMAT = "response_format" + PROMPT = "prompt" + + +class SpeechGenerationParameters(Parameters): + """Parameters for speech generation.""" + + voice: str | None + speed: float | None + response_format: str | None + prompt: str | None diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/__init__.py b/packages/speech-generation/src/celeste_speech_generation/providers/__init__.py new file mode 100644 index 00000000..aa8f14f6 --- /dev/null +++ b/packages/speech-generation/src/celeste_speech_generation/providers/__init__.py @@ -0,0 +1,28 @@ +"""Provider implementations for speech generation.""" + +from celeste import Client, Provider + +__all__ = ["PROVIDERS"] + + +def _get_providers() -> list[tuple[Provider, type[Client]]]: + """Lazy-load providers.""" + # Import clients directly from .client modules to avoid __init__.py imports + from celeste_speech_generation.providers.elevenlabs.client import ( + ElevenLabsSpeechGenerationClient, + ) + from celeste_speech_generation.providers.google.client import ( + GoogleSpeechGenerationClient, + ) + from celeste_speech_generation.providers.openai.client import ( + OpenAISpeechGenerationClient, + ) + + return [ + (Provider.GOOGLE, GoogleSpeechGenerationClient), + (Provider.OPENAI, OpenAISpeechGenerationClient), + (Provider.ELEVENLABS, ElevenLabsSpeechGenerationClient), + ] + + +PROVIDERS: list[tuple[Provider, type[Client]]] = _get_providers() diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/__init__.py b/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/__init__.py new file mode 100644 index 00000000..8de72b1e --- /dev/null +++ b/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/__init__.py @@ -0,0 +1,11 @@ +"""ElevenLabs provider for speech generation.""" + +from .client import ElevenLabsSpeechGenerationClient +from .models import MODELS +from .streaming import ElevenLabsSpeechGenerationStream + +__all__ = [ + "MODELS", + "ElevenLabsSpeechGenerationClient", + "ElevenLabsSpeechGenerationStream", +] diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/client.py b/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/client.py new file mode 100644 index 00000000..1c3ae0bd --- /dev/null +++ b/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/client.py @@ -0,0 +1,200 @@ +"""ElevenLabs client implementation for speech generation.""" + +from collections.abc import AsyncIterator +from typing import Any, Unpack + +import httpx + +from celeste.artifacts import AudioArtifact +from celeste.mime_types import ApplicationMimeType, AudioMimeType +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 ELEVENLABS_PARAMETER_MAPPERS +from .streaming import ElevenLabsSpeechGenerationStream +from .voices import ELEVENLABS_VOICES + + +class ElevenLabsSpeechGenerationClient(SpeechGenerationClient): + """ElevenLabs client for speech generation.""" + + @classmethod + def parameter_mappers(cls) -> list[ParameterMapper]: + return ELEVENLABS_PARAMETER_MAPPERS + + def _init_request(self, inputs: SpeechGenerationInput) -> dict[str, Any]: + """Initialize request from ElevenLabs API format.""" + return {"text": inputs.text} + + def _parse_usage(self, response_data: dict[str, Any]) -> SpeechGenerationUsage: + """Parse usage from response. + + ElevenLabs TTS doesn't return usage metrics in response. + """ + return SpeechGenerationUsage() + + def _parse_content( + self, + response_data: dict[str, Any], + **parameters: Unpack[SpeechGenerationParameters], + ) -> AudioArtifact: + """Parse content from response. + + Note: This method is not used for ElevenLabs TTS since we override generate() + to handle binary responses. Kept for interface compliance. + """ + # This should never be called for ElevenLabs TTS + msg = "ElevenLabs TTS returns binary responses, use generate() override" + raise NotImplementedError(msg) + + def _map_output_format_to_mime_type( + self, output_format: str | None + ) -> AudioMimeType: + """Map ElevenLabs output_format string to AudioMimeType.""" + if output_format is None: + return AudioMimeType.MP3 + + # Parse format: {codec}_{sample_rate}_{bitrate} + # e.g., mp3_44100_128, pcm_22050_16 + parts = output_format.split("_") + if not parts: + return AudioMimeType.MP3 + + codec = parts[0].lower() + codec_map: dict[str, AudioMimeType] = { + "mp3": AudioMimeType.MP3, + "pcm": AudioMimeType.PCM, + "aac": AudioMimeType.AAC, + "flac": AudioMimeType.FLAC, + } + return codec_map.get(codec, AudioMimeType.MP3) # Default to MP3 + + async def _make_request( + self, + request_body: dict[str, Any], + **parameters: Unpack[SpeechGenerationParameters], + ) -> httpx.Response: + """Make HTTP request(s) and return response object.""" + voice_id = request_body.get("_voice_id") or ELEVENLABS_VOICES[0].id + request_body.pop("_voice_id", None) # Remove temporary key if present + request_body["model_id"] = self.model.id + endpoint = config.ENDPOINT.format(voice_id=voice_id) + + headers = { + config.AUTH_HEADER_NAME: self.api_key.get_secret_value(), + "Content-Type": ApplicationMimeType.JSON, + } + + return await self.http_client.post( + f"{config.BASE_URL}{endpoint}", + headers=headers, + json_body=request_body, + ) + + async def generate( + self, + *args: str, + **parameters: Unpack[SpeechGenerationParameters], + ) -> SpeechGenerationOutput: + """Generate speech from text. + + Override base generate() to handle binary audio response from ElevenLabs TTS. + """ + inputs = self._create_inputs(*args, **parameters) + inputs, parameters = self._validate_artifacts(inputs, **parameters) + request_body = self._build_request(inputs, **parameters) + response = await self._make_request(request_body, **parameters) + self._handle_error_response(response) + + # Handle binary response (ElevenLabs TTS returns raw audio bytes, not JSON) + audio_bytes = response.content + if not audio_bytes: + msg = "No audio data in response" + raise ValueError(msg) + + # Determine MIME type from output_format parameter + output_format = parameters.get("response_format") or "mp3_44100_128" + mime_type = self._map_output_format_to_mime_type(output_format) + + # Extract headers from response (ElevenLabs returns metadata like request-id in headers) + headers_dict = dict(response.headers) + + return self._output_class()( + content=AudioArtifact(data=audio_bytes, mime_type=mime_type), + usage=SpeechGenerationUsage(), + metadata=self._build_metadata(headers_dict), + ) + + def _stream_class(self) -> type[ElevenLabsSpeechGenerationStream]: + """Return the Stream class for this client.""" + return ElevenLabsSpeechGenerationStream + + def _make_stream_request( + self, + request_body: dict[str, Any], + **parameters: Unpack[SpeechGenerationParameters], + ) -> AsyncIterator[dict[str, Any]]: + """Make HTTP streaming request and return async iterator of binary audio chunks. + + ElevenLabs streams binary audio data, not JSON SSE events. + We wrap the binary stream to yield dicts compatible with Stream interface. + """ + voice_id = request_body.get("_voice_id") or ELEVENLABS_VOICES[0].id + request_body.pop("_voice_id", None) # Remove temporary key if present + request_body["model_id"] = self.model.id + stream_endpoint = config.STREAM_ENDPOINT.format(voice_id=voice_id) + + headers = { + config.AUTH_HEADER_NAME: self.api_key.get_secret_value(), + "Content-Type": ApplicationMimeType.JSON, + } + + return self._stream_binary_audio( + f"{config.BASE_URL}{stream_endpoint}", + headers=headers, + json_body=request_body, + ) + + async def _stream_binary_audio( + self, + url: str, + headers: dict[str, str], + json_body: dict[str, Any], + ) -> AsyncIterator[dict[str, Any]]: + """Stream binary audio data and yield as dict events. + + Wraps httpx streaming to yield dicts compatible with Stream interface. + """ + client = await self.http_client._get_client() + + async with client.stream( + "POST", + url, + json=json_body, + headers=headers, + ) as response: + # Check for errors + if not response.is_success: + error_text = await response.aread() + msg = f"HTTP {response.status_code}: {error_text.decode('utf-8', errors='ignore')}" + raise httpx.HTTPStatusError( + msg, + request=response.request, + response=response, + ) + + # Stream binary audio chunks + async for chunk in response.aiter_bytes(): + if chunk: + # Yield as dict to match Stream interface expectation + yield {"data": chunk} + + +__all__ = ["ElevenLabsSpeechGenerationClient"] diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/config.py b/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/config.py new file mode 100644 index 00000000..f9493128 --- /dev/null +++ b/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/config.py @@ -0,0 +1,10 @@ +"""ElevenLabs provider configuration for speech generation.""" + +# HTTP Configuration +BASE_URL = "https://api.elevenlabs.io" +ENDPOINT = "/v1/text-to-speech/{voice_id}" +STREAM_ENDPOINT = "/v1/text-to-speech/{voice_id}/stream" + +# Authentication +AUTH_HEADER_NAME = "xi-api-key" +AUTH_HEADER_PREFIX = "" # No prefix, just the API key diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/models.py b/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/models.py new file mode 100644 index 00000000..5983e097 --- /dev/null +++ b/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/models.py @@ -0,0 +1,83 @@ +"""ElevenLabs models 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 ELEVENLABS_VOICES + +MODELS: list[Model] = [ + Model( + id="eleven_multilingual_v2", + provider=Provider.ELEVENLABS, + display_name="Eleven Multilingual v2", + streaming=True, + parameter_constraints={ + SpeechGenerationParameter.VOICE: VoiceConstraint(voices=ELEVENLABS_VOICES), + SpeechGenerationParameter.SPEED: Range(min=0.7, max=1.2), + SpeechGenerationParameter.RESPONSE_FORMAT: Choice( + options=[ + "mp3_44100_128", + "pcm_22050_16", + "pcm_24000_16", + "pcm_44100_16", + ] + ), + }, + ), + Model( + id="eleven_turbo_v2", + provider=Provider.ELEVENLABS, + display_name="Eleven Turbo v2", + streaming=True, + parameter_constraints={ + SpeechGenerationParameter.VOICE: VoiceConstraint(voices=ELEVENLABS_VOICES), + SpeechGenerationParameter.SPEED: Range(min=0.7, max=1.2), + SpeechGenerationParameter.RESPONSE_FORMAT: Choice( + options=[ + "mp3_44100_128", + "pcm_22050_16", + "pcm_24000_16", + "pcm_44100_16", + ] + ), + }, + ), + Model( + id="eleven_flash_v2_5", + provider=Provider.ELEVENLABS, + display_name="Eleven Flash v2.5", + streaming=True, + parameter_constraints={ + SpeechGenerationParameter.VOICE: VoiceConstraint(voices=ELEVENLABS_VOICES), + SpeechGenerationParameter.SPEED: Range(min=0.7, max=1.2), + SpeechGenerationParameter.RESPONSE_FORMAT: Choice( + options=[ + "mp3_44100_128", + "pcm_22050_16", + "pcm_24000_16", + "pcm_44100_16", + ] + ), + }, + ), + Model( + id="eleven_monolingual_v1", + provider=Provider.ELEVENLABS, + display_name="Eleven Monolingual v1", + streaming=True, + parameter_constraints={ + SpeechGenerationParameter.VOICE: VoiceConstraint(voices=ELEVENLABS_VOICES), + SpeechGenerationParameter.SPEED: Range(min=0.7, max=1.2), + SpeechGenerationParameter.RESPONSE_FORMAT: Choice( + options=[ + "mp3_44100_128", + "pcm_22050_16", + "pcm_24000_16", + "pcm_44100_16", + ] + ), + }, + ), +] diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/parameters.py b/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/parameters.py new file mode 100644 index 00000000..12e7271e --- /dev/null +++ b/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/parameters.py @@ -0,0 +1,124 @@ +"""ElevenLabs 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 ElevenLabs URL path. + + Note: Voice ID goes in URL path, not request body. + This mapper validates the voice_id but the actual URL construction + happens in _make_request(). + """ + + name = SpeechGenerationParameter.VOICE + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Validate and store voice ID for URL construction. + + ElevenLabs requires voice_id in the URL path, not the request body. + This mapper validates the voice_id and stores it in the request dict + under '_voice_id' for later use in _make_request(). + + Args: + request: Provider request dictionary to modify. + value: The voice ID or name (e.g., 'Rachel', '21m00Tcm4TlvDq8ikWAM'). + model: Model instance with parameter constraints. + + Returns: + Modified request dictionary with _voice_id key. + """ + validated_value = self._validate_value(value, model) + # Voice ID is stored in request for later use in _make_request() + # but not added to request body + if validated_value is not None: + request["_voice_id"] = validated_value + return request + + +class OutputFormatMapper(ParameterMapper): + """Map response_format parameter to ElevenLabs 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 ElevenLabs output_format. + Defaults to 'mp3_44100_128' if not provided. + + Args: + request: Provider request dictionary to modify. + value: Output format string (e.g., 'mp3_44100_128', 'pcm_22050_16'). + 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: + # Default to mp3_44100_128 if not provided + request["output_format"] = "mp3_44100_128" + return request + + request["output_format"] = validated_value + return request + + +class SpeedMapper(ParameterMapper): + """Map speed parameter to ElevenLabs voice_settings.speed 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 ElevenLabs voice_settings.speed. + Valid range is 0.7 to 1.2 for ElevenLabs models. + + Args: + request: Provider request dictionary to modify. + value: The playback speed multiplier (0.7 to 1.2). + model: Model instance with parameter constraints. + + Returns: + Modified request dictionary with voice_settings.speed parameter. + """ + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + # Ensure voice_settings object exists + if "voice_settings" not in request: + request["voice_settings"] = {} + + request["voice_settings"]["speed"] = validated_value + return request + + +ELEVENLABS_PARAMETER_MAPPERS: list[ParameterMapper] = [ + VoiceMapper(), + OutputFormatMapper(), + SpeedMapper(), +] + +__all__ = ["ELEVENLABS_PARAMETER_MAPPERS"] diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/streaming.py b/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/streaming.py new file mode 100644 index 00000000..af2545b1 --- /dev/null +++ b/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/streaming.py @@ -0,0 +1,44 @@ +"""ElevenLabs streaming for speech generation.""" + +from typing import Any + +from celeste_speech_generation.io import ( + SpeechGenerationChunk, + SpeechGenerationUsage, +) +from celeste_speech_generation.streaming import SpeechGenerationStream + + +class ElevenLabsSpeechGenerationStream(SpeechGenerationStream): + """ElevenLabs streaming for speech generation.""" + + def _parse_chunk(self, event: dict[str, Any]) -> SpeechGenerationChunk | None: + """Parse binary audio chunk from event dict. + + Event dict contains {"data": bytes} for binary audio chunks. + """ + audio_bytes = event.get("data") + if audio_bytes is None: + return None + + if not isinstance(audio_bytes, bytes): + return None + + # Return chunk with binary audio data + return SpeechGenerationChunk( + content=audio_bytes, + usage=None, # Usage calculated in _parse_usage() + metadata={"content_length": len(audio_bytes)}, + ) + + def _parse_usage( + self, chunks: list[SpeechGenerationChunk] + ) -> SpeechGenerationUsage: + """Parse usage from chunks. + + ElevenLabs doesn't return usage in streaming response. + """ + return SpeechGenerationUsage() + + +__all__ = ["ElevenLabsSpeechGenerationStream"] diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/voices.py b/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/voices.py new file mode 100644 index 00000000..62262ab1 --- /dev/null +++ b/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/voices.py @@ -0,0 +1,70 @@ +"""ElevenLabs voice definitions for speech generation.""" + +from celeste import Provider +from celeste_speech_generation.voices import Voice + +# Common ElevenLabs voices (hardcoded initially) +ELEVENLABS_VOICES = [ + Voice( + id="21m00Tcm4TlvDq8ikWAM", + provider=Provider.ELEVENLABS, + name="Rachel", + languages=set(), # ElevenLabs voices support multiple languages but don't specify per-voice + ), + Voice( + id="pNInz6obpgDQGcFmaJgB", + provider=Provider.ELEVENLABS, + name="Adam", + languages=set(), + ), + Voice( + id="EXAVITQu4vr4xnSDxMaL", + provider=Provider.ELEVENLABS, + name="Bella", + languages=set(), + ), + Voice( + id="ErXwobaYiN019PkySvjV", + provider=Provider.ELEVENLABS, + name="Antoni", + languages=set(), + ), + Voice( + id="MF3mGyEYCl7XYWbV9V6O", + provider=Provider.ELEVENLABS, + name="Elli", + languages=set(), + ), + Voice( + id="TxGEqnHWrfWFTfGW9XjX", + provider=Provider.ELEVENLABS, + name="Josh", + languages=set(), + ), + Voice( + id="VR6AewLTigWG4xSOukaG", + provider=Provider.ELEVENLABS, + name="Arnold", + languages=set(), + ), + Voice( + id="pMsXgVXv3BLzUgSXRplE", + provider=Provider.ELEVENLABS, + name="Serena", + languages=set(), + ), + Voice( + id="yoZ06aMxZJJ28mfd3POQ", + provider=Provider.ELEVENLABS, + name="Sam", + languages=set(), + ), + Voice( + id="AZnzlk1XvdvUeBnXmlld", + provider=Provider.ELEVENLABS, + name="Domi", + languages=set(), + ), +] + +__all__ = ["ELEVENLABS_VOICES"] diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/google/__init__.py b/packages/speech-generation/src/celeste_speech_generation/providers/google/__init__.py new file mode 100644 index 00000000..35519f93 --- /dev/null +++ b/packages/speech-generation/src/celeste_speech_generation/providers/google/__init__.py @@ -0,0 +1,6 @@ +"""Google provider for speech generation.""" + +from .client import GoogleSpeechGenerationClient +from .models import MODELS + +__all__ = ["MODELS", "GoogleSpeechGenerationClient"] diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/google/client.py b/packages/speech-generation/src/celeste_speech_generation/providers/google/client.py new file mode 100644 index 00000000..3ccfee93 --- /dev/null +++ b/packages/speech-generation/src/celeste_speech_generation/providers/google/client.py @@ -0,0 +1,123 @@ +"""Google client implementation for speech generation.""" + +import base64 +from typing import Any, Unpack + +import httpx + +from celeste.artifacts import AudioArtifact +from celeste.mime_types import ApplicationMimeType, AudioMimeType +from celeste.parameters import ParameterMapper +from celeste_speech_generation.client import SpeechGenerationClient +from celeste_speech_generation.io import ( + SpeechGenerationInput, + SpeechGenerationUsage, +) +from celeste_speech_generation.parameters import SpeechGenerationParameters + +from . import config +from .parameters import GOOGLE_PARAMETER_MAPPERS + + +class GoogleSpeechGenerationClient(SpeechGenerationClient): + """Google client for speech generation.""" + + @classmethod + def parameter_mappers(cls) -> list[ParameterMapper]: + return GOOGLE_PARAMETER_MAPPERS + + def _init_request(self, inputs: SpeechGenerationInput) -> dict[str, Any]: + """Initialize request from Google contents array format.""" + contents = [ + { + "parts": [{"text": inputs.text}], + } + ] + + generation_config: dict[str, Any] = { + "responseModalities": ["AUDIO"], + "speechConfig": {}, + } + + return { + "contents": contents, + "generationConfig": generation_config, + } + + def _parse_usage(self, response_data: dict[str, Any]) -> SpeechGenerationUsage: + """Parse usage from response. + + Google Gemini doesn't return usage metrics for TTS. + """ + return SpeechGenerationUsage() + + def _parse_content( + self, + response_data: dict[str, Any], + **parameters: Unpack[SpeechGenerationParameters], + ) -> AudioArtifact: + """Parse content from response.""" + candidates = response_data.get("candidates", []) + if not candidates: + msg = "No candidates in response" + raise ValueError(msg) + + candidate = candidates[0] + content = candidate.get("content", {}) + parts = content.get("parts", []) + + if not parts: + msg = "No parts in candidate content" + raise ValueError(msg) + + inline_data = parts[0].get("inlineData", {}) + base64_audio = inline_data.get("data") + + if not base64_audio: + msg = "No audio data in response" + raise ValueError(msg) + + # Decode base64 to get raw PCM bytes + # Google returns PCM audio (24kHz, 16-bit, mono) + pcm_bytes = base64.b64decode(base64_audio) + + return AudioArtifact( + data=pcm_bytes, + mime_type=AudioMimeType.PCM, + metadata={ + "sample_rate": config.PCM_SAMPLE_RATE, + "channels": config.PCM_CHANNELS, + "sample_width": config.PCM_SAMPLE_WIDTH, + }, + ) + + def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: + """Build metadata dictionary from response data.""" + # Filter content field before calling super + content_fields = {"candidates"} + filtered_data = { + k: v for k, v in response_data.items() if k not in content_fields + } + return super()._build_metadata(filtered_data) + + async def _make_request( + self, + request_body: dict[str, Any], + **parameters: Unpack[SpeechGenerationParameters], + ) -> httpx.Response: + """Make HTTP request(s) and return response object.""" + endpoint = config.ENDPOINT.format(model_id=self.model.id) + + headers = { + config.AUTH_HEADER_NAME: f"{config.AUTH_HEADER_PREFIX}{self.api_key.get_secret_value()}", + "Content-Type": ApplicationMimeType.JSON, + } + + return await self.http_client.post( + f"{config.BASE_URL}{endpoint}", + headers=headers, + json_body=request_body, + ) + + +__all__ = ["GoogleSpeechGenerationClient"] diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/google/config.py b/packages/speech-generation/src/celeste_speech_generation/providers/google/config.py new file mode 100644 index 00000000..1845d7d1 --- /dev/null +++ b/packages/speech-generation/src/celeste_speech_generation/providers/google/config.py @@ -0,0 +1,15 @@ +"""Google provider configuration for speech generation.""" + +# HTTP Configuration +BASE_URL = "https://generativelanguage.googleapis.com" +ENDPOINT = "/v1beta/models/{model_id}:generateContent" + +# Authentication +AUTH_HEADER_NAME = "x-goog-api-key" +AUTH_HEADER_PREFIX = "" # Empty string for plain key + +# PCM Audio Format Specifications +# Source: Google Gemini TTS docs (ffmpeg -f s16le -ar 24000 -ac 1) +PCM_SAMPLE_RATE = 24000 # Hz +PCM_CHANNELS = 1 # Mono +PCM_SAMPLE_WIDTH = 2 # Bytes (16-bit) diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/google/models.py b/packages/speech-generation/src/celeste_speech_generation/providers/google/models.py new file mode 100644 index 00000000..6e8dcca1 --- /dev/null +++ b/packages/speech-generation/src/celeste_speech_generation/providers/google/models.py @@ -0,0 +1,31 @@ +"""Google models for speech generation.""" + +from celeste import Model, Provider +from celeste.constraints import Str +from celeste_speech_generation.constraints import VoiceConstraint +from celeste_speech_generation.parameters import SpeechGenerationParameter + +from .voices import GOOGLE_VOICES + +MODELS: list[Model] = [ + Model( + id="gemini-2.5-flash-preview-tts", + provider=Provider.GOOGLE, + display_name="Gemini 2.5 Flash TTS (Preview)", + streaming=False, + parameter_constraints={ + SpeechGenerationParameter.VOICE: VoiceConstraint(voices=GOOGLE_VOICES), + SpeechGenerationParameter.PROMPT: Str(), + }, + ), + Model( + id="gemini-2.5-pro-preview-tts", + provider=Provider.GOOGLE, + display_name="Gemini 2.5 Pro TTS (Preview)", + streaming=False, + parameter_constraints={ + SpeechGenerationParameter.VOICE: VoiceConstraint(voices=GOOGLE_VOICES), + SpeechGenerationParameter.PROMPT: Str(), + }, + ), +] diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/google/parameters.py b/packages/speech-generation/src/celeste_speech_generation/providers/google/parameters.py new file mode 100644 index 00000000..0f4dc189 --- /dev/null +++ b/packages/speech-generation/src/celeste_speech_generation/providers/google/parameters.py @@ -0,0 +1,64 @@ +"""Google 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 Google speechConfig.voiceConfig.prebuiltVoiceConfig.voiceName.""" + + name = SpeechGenerationParameter.VOICE + + 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.setdefault("generationConfig", {}).setdefault( + "speechConfig", {} + ).setdefault("voiceConfig", {}).setdefault("prebuiltVoiceConfig", {})[ + "voiceName" + ] = validated_value + return request + + +class PromptMapper(ParameterMapper): + """Map prompt parameter to style instructions in text.""" + + name = SpeechGenerationParameter.PROMPT + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Prepend prompt as style instruction to text using SSML-like tags.""" + if not value or not isinstance(value, str): + return request + + contents = request.get("contents", []) + if contents and contents[0].get("parts"): + text = contents[0]["parts"][0].get("text", "") + contents[0]["parts"][0]["text"] = ( + f"" + ) + + return request + + +GOOGLE_PARAMETER_MAPPERS: list[ParameterMapper] = [ + VoiceMapper(), + PromptMapper(), +] + +__all__ = ["GOOGLE_PARAMETER_MAPPERS"] diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/google/voices.py b/packages/speech-generation/src/celeste_speech_generation/providers/google/voices.py new file mode 100644 index 00000000..789a9f48 --- /dev/null +++ b/packages/speech-generation/src/celeste_speech_generation/providers/google/voices.py @@ -0,0 +1,177 @@ +"""Google voice definitions for speech generation.""" + +from celeste import Provider +from celeste_speech_generation.voices import Voice + +# All Google Gemini voices support all 24 languages +GOOGLE_LANGUAGES = { + "ar-EG", + "de-DE", + "en-US", + "es-US", + "fr-FR", + "hi-IN", + "id-ID", + "it-IT", + "ja-JP", + "ko-KR", + "pt-BR", + "ru-RU", + "nl-NL", + "pl-PL", + "th-TH", + "tr-TR", + "vi-VN", + "ro-RO", + "uk-UA", + "bn-BD", + "en-IN", + "mr-IN", + "ta-IN", + "te-IN", +} + +GOOGLE_VOICES = [ + Voice( + id="Zephyr", provider=Provider.GOOGLE, name="Zephyr", languages=GOOGLE_LANGUAGES + ), + Voice(id="Puck", provider=Provider.GOOGLE, name="Puck", languages=GOOGLE_LANGUAGES), + Voice( + id="Charon", provider=Provider.GOOGLE, name="Charon", languages=GOOGLE_LANGUAGES + ), + Voice(id="Kore", provider=Provider.GOOGLE, name="Kore", languages=GOOGLE_LANGUAGES), + Voice( + id="Fenrir", provider=Provider.GOOGLE, name="Fenrir", languages=GOOGLE_LANGUAGES + ), + Voice(id="Leda", provider=Provider.GOOGLE, name="Leda", languages=GOOGLE_LANGUAGES), + Voice(id="Orus", provider=Provider.GOOGLE, name="Orus", languages=GOOGLE_LANGUAGES), + Voice( + id="Aoede", provider=Provider.GOOGLE, name="Aoede", languages=GOOGLE_LANGUAGES + ), + Voice( + id="Callirrhoe", + provider=Provider.GOOGLE, + name="Callirrhoe", + languages=GOOGLE_LANGUAGES, + ), + Voice( + id="Autonoe", + provider=Provider.GOOGLE, + name="Autonoe", + languages=GOOGLE_LANGUAGES, + ), + Voice( + id="Enceladus", + provider=Provider.GOOGLE, + name="Enceladus", + languages=GOOGLE_LANGUAGES, + ), + Voice( + id="Iapetus", + provider=Provider.GOOGLE, + name="Iapetus", + languages=GOOGLE_LANGUAGES, + ), + Voice( + id="Umbriel", + provider=Provider.GOOGLE, + name="Umbriel", + languages=GOOGLE_LANGUAGES, + ), + Voice( + id="Algieba", + provider=Provider.GOOGLE, + name="Algieba", + languages=GOOGLE_LANGUAGES, + ), + Voice( + id="Despina", + provider=Provider.GOOGLE, + name="Despina", + languages=GOOGLE_LANGUAGES, + ), + Voice( + id="Erinome", + provider=Provider.GOOGLE, + name="Erinome", + languages=GOOGLE_LANGUAGES, + ), + Voice( + id="Algenib", + provider=Provider.GOOGLE, + name="Algenib", + languages=GOOGLE_LANGUAGES, + ), + Voice( + id="Rasalgethi", + provider=Provider.GOOGLE, + name="Rasalgethi", + languages=GOOGLE_LANGUAGES, + ), + Voice( + id="Laomedeia", + provider=Provider.GOOGLE, + name="Laomedeia", + languages=GOOGLE_LANGUAGES, + ), + Voice( + id="Achernar", + provider=Provider.GOOGLE, + name="Achernar", + languages=GOOGLE_LANGUAGES, + ), + Voice( + id="Alnilam", + provider=Provider.GOOGLE, + name="Alnilam", + languages=GOOGLE_LANGUAGES, + ), + Voice( + id="Schedar", + provider=Provider.GOOGLE, + name="Schedar", + languages=GOOGLE_LANGUAGES, + ), + Voice( + id="Gacrux", provider=Provider.GOOGLE, name="Gacrux", languages=GOOGLE_LANGUAGES + ), + Voice( + id="Pulcherrima", + provider=Provider.GOOGLE, + name="Pulcherrima", + languages=GOOGLE_LANGUAGES, + ), + Voice( + id="Achird", provider=Provider.GOOGLE, name="Achird", languages=GOOGLE_LANGUAGES + ), + Voice( + id="Zubenelgenubi", + provider=Provider.GOOGLE, + name="Zubenelgenubi", + languages=GOOGLE_LANGUAGES, + ), + Voice( + id="Vindemiatrix", + provider=Provider.GOOGLE, + name="Vindemiatrix", + languages=GOOGLE_LANGUAGES, + ), + Voice( + id="Sadachbia", + provider=Provider.GOOGLE, + name="Sadachbia", + languages=GOOGLE_LANGUAGES, + ), + Voice( + id="Sadaltager", + provider=Provider.GOOGLE, + name="Sadaltager", + languages=GOOGLE_LANGUAGES, + ), + Voice( + id="Sulafat", + provider=Provider.GOOGLE, + name="Sulafat", + languages=GOOGLE_LANGUAGES, + ), +] diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/openai/__init__.py b/packages/speech-generation/src/celeste_speech_generation/providers/openai/__init__.py new file mode 100644 index 00000000..0887c667 --- /dev/null +++ b/packages/speech-generation/src/celeste_speech_generation/providers/openai/__init__.py @@ -0,0 +1,6 @@ +"""OpenAI provider for speech generation.""" + +from .client import OpenAISpeechGenerationClient +from .models import MODELS + +__all__ = ["MODELS", "OpenAISpeechGenerationClient"] diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/openai/client.py b/packages/speech-generation/src/celeste_speech_generation/providers/openai/client.py new file mode 100644 index 00000000..3465fc35 --- /dev/null +++ b/packages/speech-generation/src/celeste_speech_generation/providers/openai/client.py @@ -0,0 +1,122 @@ +"""OpenAI client implementation for speech generation.""" + +from typing import Any, Unpack + +import httpx + +from celeste.artifacts import AudioArtifact +from celeste.mime_types import ApplicationMimeType, AudioMimeType +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 OPENAI_PARAMETER_MAPPERS + + +class OpenAISpeechGenerationClient(SpeechGenerationClient): + """OpenAI client for speech generation.""" + + @classmethod + def parameter_mappers(cls) -> list[ParameterMapper]: + return OPENAI_PARAMETER_MAPPERS + + def _init_request(self, inputs: SpeechGenerationInput) -> dict[str, Any]: + """Initialize request from OpenAI API format.""" + return {"input": inputs.text} + + def _parse_usage(self, response_data: dict[str, Any]) -> SpeechGenerationUsage: + """Parse usage from response. + + OpenAI TTS doesn't return usage metrics in response. + """ + return SpeechGenerationUsage() + + def _parse_content( + self, + response_data: dict[str, Any], + **parameters: Unpack[SpeechGenerationParameters], + ) -> AudioArtifact: + """Parse content from response. + + Note: This method is not used for OpenAI TTS since we override generate() + to handle binary responses. Kept for interface compliance. + """ + # This should never be called for OpenAI TTS + msg = "OpenAI TTS returns binary responses, use generate() override" + raise NotImplementedError(msg) + + def _map_response_format_to_mime_type( + self, response_format: str | None + ) -> AudioMimeType: + """Map OpenAI response_format to AudioMimeType.""" + format_map: dict[str, AudioMimeType] = { + "mp3": AudioMimeType.MP3, + "opus": AudioMimeType.OGG, # OGG is closest match for Opus + "aac": AudioMimeType.AAC, + "flac": AudioMimeType.FLAC, + } + return format_map.get( + response_format or "", AudioMimeType.MP3 + ) # Default to MP3 + + async def _make_request( + self, + request_body: dict[str, Any], + **parameters: Unpack[SpeechGenerationParameters], + ) -> httpx.Response: + """Make HTTP request(s) and return response object.""" + request_body["model"] = self.model.id + + headers = { + config.AUTH_HEADER_NAME: f"{config.AUTH_HEADER_PREFIX}{self.api_key.get_secret_value()}", + "Content-Type": ApplicationMimeType.JSON, + } + + return await self.http_client.post( + f"{config.BASE_URL}{config.ENDPOINT}", + headers=headers, + json_body=request_body, + ) + + async def generate( + self, + *args: str, + **parameters: Unpack[SpeechGenerationParameters], + ) -> SpeechGenerationOutput: + """Generate speech from text. + + Override base generate() to handle binary audio response from OpenAI TTS. + """ + inputs = self._create_inputs(*args, **parameters) + inputs, parameters = self._validate_artifacts(inputs, **parameters) + request_body = self._build_request(inputs, **parameters) + response = await self._make_request(request_body, **parameters) + self._handle_error_response(response) + + # Handle binary response (OpenAI TTS returns raw audio bytes, not JSON) + audio_bytes = response.content + if not audio_bytes: + msg = "No audio data in response" + raise ValueError(msg) + + # Determine MIME type from response_format parameter (default to mp3) + response_format = parameters.get("response_format") or "mp3" + mime_type = self._map_response_format_to_mime_type(response_format) + + # Extract headers from response (OpenAI may return metadata in headers) + headers_dict = dict(response.headers) + + return self._output_class()( + content=AudioArtifact(data=audio_bytes, mime_type=mime_type), + usage=SpeechGenerationUsage(), + metadata=self._build_metadata(headers_dict), + ) + + +__all__ = ["OpenAISpeechGenerationClient"] diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/openai/config.py b/packages/speech-generation/src/celeste_speech_generation/providers/openai/config.py new file mode 100644 index 00000000..226ded5a --- /dev/null +++ b/packages/speech-generation/src/celeste_speech_generation/providers/openai/config.py @@ -0,0 +1,9 @@ +"""OpenAI provider configuration for speech generation.""" + +# HTTP Configuration +BASE_URL = "https://api.openai.com" +ENDPOINT = "/v1/audio/speech" + +# Authentication +AUTH_HEADER_NAME = "Authorization" +AUTH_HEADER_PREFIX = "Bearer " diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/openai/models.py b/packages/speech-generation/src/celeste_speech_generation/providers/openai/models.py new file mode 100644 index 00000000..f4771222 --- /dev/null +++ b/packages/speech-generation/src/celeste_speech_generation/providers/openai/models.py @@ -0,0 +1,61 @@ +"""OpenAI models for speech generation.""" + +from celeste import Model, Provider +from celeste.constraints import Choice, Range +from celeste.mime_types import AudioMimeType +from celeste_speech_generation.constraints import VoiceConstraint +from celeste_speech_generation.parameters import SpeechGenerationParameter + +from .voices import GPT4O_MINI_TTS_VOICES, TTS1_HD_VOICES, TTS1_VOICES + +# Common response format options for all OpenAI TTS models +_RESPONSE_FORMAT_OPTIONS = [ + AudioMimeType.MP3, + AudioMimeType.OGG, # Maps to "opus" in OpenAI API + AudioMimeType.AAC, + AudioMimeType.FLAC, +] + +MODELS: list[Model] = [ + Model( + id="tts-1", + provider=Provider.OPENAI, + display_name="TTS-1", + streaming=False, + parameter_constraints={ + SpeechGenerationParameter.VOICE: VoiceConstraint(voices=TTS1_VOICES), + SpeechGenerationParameter.SPEED: Range(min=0.25, max=4.0), + SpeechGenerationParameter.RESPONSE_FORMAT: Choice( + options=_RESPONSE_FORMAT_OPTIONS + ), + }, + ), + Model( + id="tts-1-hd", + provider=Provider.OPENAI, + display_name="TTS-1 HD", + streaming=False, + parameter_constraints={ + SpeechGenerationParameter.VOICE: VoiceConstraint(voices=TTS1_HD_VOICES), + SpeechGenerationParameter.SPEED: Range(min=0.25, max=4.0), + SpeechGenerationParameter.RESPONSE_FORMAT: Choice( + options=_RESPONSE_FORMAT_OPTIONS + ), + }, + ), + Model( + id="gpt-4o-mini-tts", + provider=Provider.OPENAI, + display_name="GPT-4o Mini TTS", + streaming=False, + parameter_constraints={ + SpeechGenerationParameter.VOICE: VoiceConstraint( + voices=GPT4O_MINI_TTS_VOICES + ), + SpeechGenerationParameter.SPEED: Range(min=0.25, max=4.0), + SpeechGenerationParameter.RESPONSE_FORMAT: Choice( + options=_RESPONSE_FORMAT_OPTIONS + ), + }, + ), +] diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/openai/parameters.py b/packages/speech-generation/src/celeste_speech_generation/providers/openai/parameters.py new file mode 100644 index 00000000..f824f253 --- /dev/null +++ b/packages/speech-generation/src/celeste_speech_generation/providers/openai/parameters.py @@ -0,0 +1,132 @@ +"""OpenAI parameter mappers for speech generation.""" + +from typing import Any + +from celeste.mime_types import AudioMimeType +from celeste.models import Model +from celeste.parameters import ParameterMapper +from celeste_speech_generation.parameters import SpeechGenerationParameter + + +class VoiceMapper(ParameterMapper): + """Map voice parameter to OpenAI voice 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 OpenAI API voice field. + + Args: + request: Provider request dictionary to modify. + value: The voice ID or name (e.g., 'alloy', 'echo', 'nova'). + model: Model instance with parameter constraints. + + Returns: + Modified request dictionary with voice parameter. + """ + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request["voice"] = validated_value + return request + + +class SpeedMapper(ParameterMapper): + """Map speed parameter to OpenAI speed 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 the OpenAI API speed field. + Valid range is 0.25 to 4.0. + + Args: + request: Provider request dictionary to modify. + value: The playback speed multiplier (0.25 to 4.0). + model: Model instance with parameter constraints. + + Returns: + Modified request dictionary with speed parameter. + """ + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request["speed"] = validated_value + return request + + +class ResponseFormatMapper(ParameterMapper): + """Map response_format parameter to OpenAI response_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 OpenAI API format. + Accepts both string values ('mp3', 'opus') and AudioMimeType enums. + + Args: + request: Provider request dictionary to modify. + value: Output format as string or AudioMimeType (mp3, opus, aac, flac). + model: Model instance with parameter constraints. + + Returns: + Modified request dictionary with response_format parameter. + """ + # Convert string values to AudioMimeType enum before validation + if isinstance(value, str) and not isinstance(value, AudioMimeType): + string_to_mime_type: dict[str, AudioMimeType] = { + "mp3": AudioMimeType.MP3, + "opus": AudioMimeType.OGG, # OpenAI uses "opus" for OGG format + "aac": AudioMimeType.AAC, + "flac": AudioMimeType.FLAC, + } + value = string_to_mime_type.get(value.lower(), value) + + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + # Convert AudioMimeType enum to OpenAI string format + mime_type_to_openai_format: dict[AudioMimeType, str] = { + AudioMimeType.MP3: "mp3", + AudioMimeType.OGG: "opus", # OpenAI uses "opus" for OGG format + AudioMimeType.AAC: "aac", + AudioMimeType.FLAC: "flac", + } + + # validated_value is now guaranteed to be AudioMimeType after constraint validation + response_format = mime_type_to_openai_format.get(validated_value, "mp3") + request["response_format"] = response_format + return request + + +OPENAI_PARAMETER_MAPPERS: list[ParameterMapper] = [ + VoiceMapper(), + SpeedMapper(), + ResponseFormatMapper(), +] + +__all__ = ["OPENAI_PARAMETER_MAPPERS"] diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/openai/voices.py b/packages/speech-generation/src/celeste_speech_generation/providers/openai/voices.py new file mode 100644 index 00000000..67d40966 --- /dev/null +++ b/packages/speech-generation/src/celeste_speech_generation/providers/openai/voices.py @@ -0,0 +1,140 @@ +"""OpenAI voice definitions for speech generation.""" + +from celeste import Provider +from celeste_speech_generation.voices import Voice + +# Model-specific voice lists +# tts-1 supports: alloy, ash, coral, echo, fable, nova, onyx, sage, shimmer (9 voices, NO ballad) +TTS1_VOICES = [ + Voice( + id="alloy", + provider=Provider.OPENAI, + name="Alloy", + languages=set(), # OpenAI voices support multiple languages but don't specify per-voice + ), + Voice( + id="ash", + provider=Provider.OPENAI, + name="Ash", + languages=set(), + ), + Voice( + id="coral", + provider=Provider.OPENAI, + name="Coral", + languages=set(), + ), + Voice( + id="echo", + provider=Provider.OPENAI, + name="Echo", + languages=set(), + ), + Voice( + id="fable", + provider=Provider.OPENAI, + name="Fable", + languages=set(), + ), + Voice( + id="nova", + provider=Provider.OPENAI, + name="Nova", + languages=set(), + ), + Voice( + id="onyx", + provider=Provider.OPENAI, + name="Onyx", + languages=set(), + ), + Voice( + id="sage", + provider=Provider.OPENAI, + name="Sage", + languages=set(), + ), + Voice( + id="shimmer", + provider=Provider.OPENAI, + name="Shimmer", + languages=set(), + ), +] + +# tts-1-hd supports same voices as tts-1 (conservative assumption) +TTS1_HD_VOICES = TTS1_VOICES + +# gpt-4o-mini-tts supports all 10 voices including ballad +GPT4O_MINI_TTS_VOICES = [ + Voice( + id="alloy", + provider=Provider.OPENAI, + name="Alloy", + languages=set(), + ), + Voice( + id="ash", + provider=Provider.OPENAI, + name="Ash", + languages=set(), + ), + Voice( + id="ballad", + provider=Provider.OPENAI, + name="Ballad", + languages=set(), + ), + Voice( + id="coral", + provider=Provider.OPENAI, + name="Coral", + languages=set(), + ), + Voice( + id="echo", + provider=Provider.OPENAI, + name="Echo", + languages=set(), + ), + Voice( + id="fable", + provider=Provider.OPENAI, + name="Fable", + languages=set(), + ), + Voice( + id="nova", + provider=Provider.OPENAI, + name="Nova", + languages=set(), + ), + Voice( + id="onyx", + provider=Provider.OPENAI, + name="Onyx", + languages=set(), + ), + Voice( + id="sage", + provider=Provider.OPENAI, + name="Sage", + languages=set(), + ), + Voice( + id="shimmer", + provider=Provider.OPENAI, + name="Shimmer", + languages=set(), + ), +] + +# Union of all voices for package-level exports +OPENAI_VOICES = GPT4O_MINI_TTS_VOICES + +__all__ = [ + "GPT4O_MINI_TTS_VOICES", + "OPENAI_VOICES", + "TTS1_HD_VOICES", + "TTS1_VOICES", +] diff --git a/packages/speech-generation/src/celeste_speech_generation/py.typed b/packages/speech-generation/src/celeste_speech_generation/py.typed new file mode 100644 index 00000000..321d0ae1 --- /dev/null +++ b/packages/speech-generation/src/celeste_speech_generation/py.typed @@ -0,0 +1 @@ +# Marker file for PEP 561 - this package supports type checking diff --git a/packages/speech-generation/src/celeste_speech_generation/streaming.py b/packages/speech-generation/src/celeste_speech_generation/streaming.py new file mode 100644 index 00000000..84c91110 --- /dev/null +++ b/packages/speech-generation/src/celeste_speech_generation/streaming.py @@ -0,0 +1,44 @@ +"""Streaming for speech generation.""" + +from abc import abstractmethod +from typing import Unpack + +from celeste.artifacts import AudioArtifact +from celeste.streaming import Stream +from celeste_speech_generation.io import ( + SpeechGenerationChunk, + SpeechGenerationOutput, + SpeechGenerationUsage, +) +from celeste_speech_generation.parameters import SpeechGenerationParameters + + +class SpeechGenerationStream( + Stream[SpeechGenerationOutput, SpeechGenerationParameters, SpeechGenerationChunk] +): + """Streaming for speech generation.""" + + def _parse_output( # type: ignore[override] + self, + chunks: list[SpeechGenerationChunk], + **parameters: Unpack[SpeechGenerationParameters], + ) -> SpeechGenerationOutput: + """Assemble chunks into final output.""" + # Speech streaming: concatenate raw bytes + audio_bytes = b"".join(chunk.content for chunk in chunks) + usage = self._parse_usage(chunks) + + return SpeechGenerationOutput( + content=AudioArtifact(data=audio_bytes), + usage=usage, + metadata={}, + ) + + @abstractmethod + def _parse_usage( + self, chunks: list[SpeechGenerationChunk] + ) -> SpeechGenerationUsage: + """Parse usage from chunks (provider-specific).""" + + +__all__ = ["SpeechGenerationStream"] diff --git a/packages/speech-generation/src/celeste_speech_generation/voices.py b/packages/speech-generation/src/celeste_speech_generation/voices.py new file mode 100644 index 00000000..6d95205c --- /dev/null +++ b/packages/speech-generation/src/celeste_speech_generation/voices.py @@ -0,0 +1,17 @@ +"""Voice definitions for speech generation.""" + +from pydantic import BaseModel, Field + +from celeste import Provider + + +class Voice(BaseModel): + """Represents a voice for speech generation.""" + + id: str + provider: Provider + name: str + languages: set[str] = Field(default_factory=set) + + +__all__ = ["Voice"] diff --git a/packages/speech-generation/tests/conftest.py b/packages/speech-generation/tests/conftest.py new file mode 100644 index 00000000..81c92564 --- /dev/null +++ b/packages/speech-generation/tests/conftest.py @@ -0,0 +1,24 @@ +"""Pytest configuration and fixtures for integration tests.""" + +from collections.abc import AsyncGenerator +from typing import Any + +import pytest_asyncio + +from celeste.http import close_all_http_clients + + +@pytest_asyncio.fixture(autouse=True) +async def cleanup_http_clients() -> AsyncGenerator[None, None]: + """Ensure HTTP clients are closed after each test. + + This fixture runs automatically after each test to ensure HTTP clients + are properly closed before pytest-asyncio closes the event loop. + This prevents "Event loop is closed" errors when using pytest-xdist. + """ + yield + await close_all_http_clients() + + +def pytest_configure(config: Any) -> None: # noqa: ANN401 + config.addinivalue_line("markers", "integration: mark test as an integration test") diff --git a/packages/speech-generation/tests/integration_tests/__init__.py b/packages/speech-generation/tests/integration_tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/speech-generation/tests/integration_tests/test_speech_generation/__init__.py b/packages/speech-generation/tests/integration_tests/test_speech_generation/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/speech-generation/tests/integration_tests/test_speech_generation/test_generate.py b/packages/speech-generation/tests/integration_tests/test_speech_generation/test_generate.py new file mode 100644 index 00000000..73f114f9 --- /dev/null +++ b/packages/speech-generation/tests/integration_tests/test_speech_generation/test_generate.py @@ -0,0 +1,73 @@ +"""Integration tests for speech generation across all providers.""" + +import pytest + +from celeste import Capability, Provider, create_client + + +@pytest.mark.parametrize( + ("provider", "model", "parameters"), + [ + (Provider.OPENAI, "tts-1", {"voice": "alloy", "response_format": "mp3"}), + ( + Provider.GOOGLE, + "gemini-2.5-flash-preview-tts", + {"voice": "Zephyr", "speed": 1.0}, + ), + ( + Provider.ELEVENLABS, + "eleven_flash_v2_5", + {"voice": "Rachel", "response_format": "mp3_44100_128"}, + ), + ], +) +@pytest.mark.integration +@pytest.mark.asyncio +async def test_generate(provider: Provider, model: str, parameters: dict) -> None: + """Test speech generation with voice parameter across all providers. + + This test demonstrates that the unified API works identically across + all providers using the same code - proving the abstraction value. + Uses cheapest models to minimize costs. + """ + # Import here to avoid circular import during pytest collection + from celeste_speech_generation import ( + SpeechGenerationOutput, + SpeechGenerationUsage, + ) + + from celeste.artifacts import AudioArtifact + + # Arrange + client = create_client( + capability=Capability.SPEECH_GENERATION, + provider=provider, + ) + text = "Hello, this is a test of the Celeste speech generation capability." + + # Act + response = await client.generate( + text=text, + model=model, + **parameters, + ) + + # Assert + assert isinstance(response, SpeechGenerationOutput), ( + f"Expected SpeechGenerationOutput, got {type(response)}" + ) + assert isinstance(response.content, AudioArtifact), ( + f"Expected AudioArtifact content, got {type(response.content)}" + ) + assert response.content.has_content, ( + f"AudioArtifact has no content (data/path): {response.content}" + ) + assert response.content.data is not None, "AudioArtifact data is None" + assert len(response.content.data) > 0, "Audio data is empty" + + # Validate usage metrics + assert isinstance(response.usage, SpeechGenerationUsage), ( + f"Expected SpeechGenerationUsage, got {type(response.usage)}" + ) + # Note: Speech generation providers typically don't return usage metrics. + # Usage object exists for API consistency but fields are empty. diff --git a/packages/speech-generation/tests/integration_tests/test_speech_generation/test_stream.py b/packages/speech-generation/tests/integration_tests/test_speech_generation/test_stream.py new file mode 100644 index 00000000..25cb639c --- /dev/null +++ b/packages/speech-generation/tests/integration_tests/test_speech_generation/test_stream.py @@ -0,0 +1,59 @@ +"""Integration tests for speech generation streaming across all providers.""" + +import pytest + +from celeste import Capability, Provider, create_client + + +@pytest.mark.parametrize( + ("provider", "model", "parameters"), + [ + # Only ElevenLabs currently supports streaming for speech generation + # OpenAI and Google TTS do not support streaming in the same way + ( + Provider.ELEVENLABS, + "eleven_flash_v2_5", + {"voice": "Rachel", "response_format": "mp3_44100_128"}, + ), + ], +) +@pytest.mark.integration +@pytest.mark.asyncio +async def test_stream(provider: Provider, model: str, parameters: dict) -> None: + """Test speech generation streaming across supported providers. + + Verifies that we receive audio chunks and can assemble them. + """ + # Import here to avoid circular import during pytest collection + from celeste_speech_generation import ( + SpeechGenerationChunk, + SpeechGenerationUsage, + ) + + # Arrange + client = create_client( + capability=Capability.SPEECH_GENERATION, + provider=provider, + ) + text = "Hello, this is a streaming test." + + # Act + chunks = [] + async for chunk in client.stream( + text=text, + model=model, + **parameters, + ): + assert isinstance(chunk, SpeechGenerationChunk) + chunks.append(chunk) + + # Assert + assert len(chunks) > 0, "No chunks received" + total_size = sum(len(chunk.content) for chunk in chunks) + assert total_size > 0, "Total audio size is 0" + + # Verify usage if present in any chunk + has_usage = any(chunk.usage is not None for chunk in chunks) + if has_usage: + last_usage = next(c.usage for c in reversed(chunks) if c.usage) + assert isinstance(last_usage, SpeechGenerationUsage) diff --git a/packages/text-generation/src/celeste_text_generation/client.py b/packages/text-generation/src/celeste_text_generation/client.py index 30aeba88..4b08856c 100644 --- a/packages/text-generation/src/celeste_text_generation/client.py +++ b/packages/text-generation/src/celeste_text_generation/client.py @@ -45,12 +45,14 @@ def _parse_finish_reason( """Parse finish reason from provider response.""" def _create_inputs( - self, *args: str, **parameters: Unpack[TextGenerationParameters] + self, + *args: str, + prompt: str | None = None, + **parameters: Unpack[TextGenerationParameters], ) -> TextGenerationInput: """Map positional arguments to Input type.""" if args: return TextGenerationInput(prompt=args[0]) - prompt: str | None = parameters.get("prompt") if prompt is None: msg = ( "prompt is required (either as positional argument or keyword argument)" diff --git a/packages/video-generation/src/celeste_video_generation/client.py b/packages/video-generation/src/celeste_video_generation/client.py index 680c2329..85305136 100644 --- a/packages/video-generation/src/celeste_video_generation/client.py +++ b/packages/video-generation/src/celeste_video_generation/client.py @@ -38,12 +38,14 @@ def _parse_content( """Parse content from provider response.""" def _create_inputs( - self, *args: str, **parameters: Unpack[VideoGenerationParameters] + self, + *args: str, + prompt: str | None = None, + **parameters: Unpack[VideoGenerationParameters], ) -> VideoGenerationInput: """Map positional arguments to Input type.""" if args: return VideoGenerationInput(prompt=args[0]) - prompt: str | None = parameters.get("prompt") if prompt is None: msg = ( "prompt is required (either as positional argument or keyword argument)" diff --git a/pyproject.toml b/pyproject.toml index d40a1a91..be0d1b6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "celeste-ai" -version = "0.2.12" +version = "0.2.13" 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" @@ -17,7 +17,7 @@ classifiers = [ "Topic :: Scientific/Engineering :: Artificial Intelligence", "Typing :: Typed", ] -keywords = ["ai", "multimodal", "sdk", "openai", "anthropic", "claude", "gemini", "text-generation", "image-generation", "video-generation", "embeddings"] +keywords = ["ai", "multimodal", "sdk", "openai", "anthropic", "claude", "gemini", "text-generation", "image-generation", "video-generation", "speech-generation", "embeddings"] dependencies = [ "pydantic>=2.0", "pydantic-settings>=2.0", @@ -36,10 +36,12 @@ Issues = "https://github.com/withceleste/celeste-python/issues" text-generation = ["celeste-text-generation>=0.2.10"] image-generation = ["celeste-image-generation>=0.2.9"] video-generation = ["celeste-video-generation>=0.2.8"] +speech-generation = ["celeste-speech-generation>=0.2.8"] all = [ "celeste-text-generation>=0.2.10", "celeste-image-generation>=0.2.9", "celeste-video-generation>=0.2.8", + "celeste-speech-generation>=0.2.8", ] [dependency-groups] @@ -63,6 +65,7 @@ members = ["packages/*"] celeste-text-generation = { workspace = true } celeste-image-generation = { workspace = true } celeste-video-generation = { workspace = true } +celeste-speech-generation = { workspace = true } [build-system] requires = ["hatchling"] @@ -81,6 +84,7 @@ pythonpath = [ "packages/text-generation/src", "packages/image-generation/src", "packages/video-generation/src", + "packages/speech-generation/src", ] markers = [ "slow: marks tests as slow (deselect with '-m \"not slow\"')", @@ -169,6 +173,8 @@ module = [ "celeste_image_generation.providers.*", "celeste_video_generation.client", "celeste_video_generation.providers.*", + "celeste_speech_generation.client", + "celeste_speech_generation.providers.*", ] disable_error_code = ["override", "return-value", "arg-type", "call-arg", "assignment", "no-any-return"] From 3d816aabcdd2569c620b4a9ea2f4cb9f0567c52a Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Thu, 4 Dec 2025 16:19:20 +0100 Subject: [PATCH 2/4] Remove legacy ElevenLabs voices and add Laura replacement - Remove 8 legacy voices: Rachel, Antoni, Elli, Josh, Arnold, Serena, Sam, Domi - Keep Adam (replacement for Antoni/Arnold) and Bella (not being replaced) - Add Laura (replaces Domi) with confirmed ID: FGY2WhTYpPnrIDTdsKH5 - Add TODO comments for Janet, Peter, Craig, Riley (not yet available in API) - Update tests to use Laura instead of Rachel Breaking change: Users must migrate from legacy voices before ElevenLabs deprecation on Feb 28, 2026. --- .../providers/elevenlabs/voices.py | 58 ++++--------------- .../test_speech_generation/test_generate.py | 2 +- .../test_speech_generation/test_stream.py | 2 +- 3 files changed, 12 insertions(+), 50 deletions(-) diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/voices.py b/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/voices.py index 62262ab1..18ec82ca 100644 --- a/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/voices.py +++ b/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/voices.py @@ -5,66 +5,28 @@ # Common ElevenLabs voices (hardcoded initially) ELEVENLABS_VOICES = [ - Voice( - id="21m00Tcm4TlvDq8ikWAM", - provider=Provider.ELEVENLABS, - name="Rachel", - languages=set(), # ElevenLabs voices support multiple languages but don't specify per-voice - ), Voice( id="pNInz6obpgDQGcFmaJgB", provider=Provider.ELEVENLABS, name="Adam", - languages=set(), - ), + languages=set(), # ElevenLabs voices support multiple languages but don't specify per-voice + ), # Keep - replacement for Antoni/Arnold Voice( id="EXAVITQu4vr4xnSDxMaL", provider=Provider.ELEVENLABS, name="Bella", languages=set(), - ), - Voice( - id="ErXwobaYiN019PkySvjV", - provider=Provider.ELEVENLABS, - name="Antoni", - languages=set(), - ), - Voice( - id="MF3mGyEYCl7XYWbV9V6O", - provider=Provider.ELEVENLABS, - name="Elli", - languages=set(), - ), - Voice( - id="TxGEqnHWrfWFTfGW9XjX", - provider=Provider.ELEVENLABS, - name="Josh", - languages=set(), - ), - Voice( - id="VR6AewLTigWG4xSOukaG", - provider=Provider.ELEVENLABS, - name="Arnold", - languages=set(), - ), - Voice( - id="pMsXgVXv3BLzUgSXRplE", - provider=Provider.ELEVENLABS, - name="Serena", - languages=set(), - ), - Voice( - id="yoZ06aMxZJJ28mfd3POQ", - provider=Provider.ELEVENLABS, - name="Sam", - languages=set(), - ), + ), # Keep - not being replaced Voice( - id="AZnzlk1XvdvUeBnXmlld", + id="FGY2WhTYpPnrIDTdsKH5", provider=Provider.ELEVENLABS, - name="Domi", + name="Laura", languages=set(), - ), + ), # NEW - replaces Domi (ID confirmed from API) + # TODO: Add Janet when available in API (replaces Rachel, Serena, Glinda) + # TODO: Add Peter when available in API (replaces Elli, Fin) + # TODO: Add Craig when available in API (replaces Josh, Jeremy) + # TODO: Add Riley when available in API (replaces Sam, Grace) ] __all__ = ["ELEVENLABS_VOICES"] diff --git a/packages/speech-generation/tests/integration_tests/test_speech_generation/test_generate.py b/packages/speech-generation/tests/integration_tests/test_speech_generation/test_generate.py index 73f114f9..4a82f36b 100644 --- a/packages/speech-generation/tests/integration_tests/test_speech_generation/test_generate.py +++ b/packages/speech-generation/tests/integration_tests/test_speech_generation/test_generate.py @@ -17,7 +17,7 @@ ( Provider.ELEVENLABS, "eleven_flash_v2_5", - {"voice": "Rachel", "response_format": "mp3_44100_128"}, + {"voice": "Laura", "response_format": "mp3_44100_128"}, ), ], ) diff --git a/packages/speech-generation/tests/integration_tests/test_speech_generation/test_stream.py b/packages/speech-generation/tests/integration_tests/test_speech_generation/test_stream.py index 25cb639c..437a295e 100644 --- a/packages/speech-generation/tests/integration_tests/test_speech_generation/test_stream.py +++ b/packages/speech-generation/tests/integration_tests/test_speech_generation/test_stream.py @@ -13,7 +13,7 @@ ( Provider.ELEVENLABS, "eleven_flash_v2_5", - {"voice": "Rachel", "response_format": "mp3_44100_128"}, + {"voice": "Laura", "response_format": "mp3_44100_128"}, ), ], ) From 60437d394be366cc14a4b390309f26538755caa6 Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Thu, 4 Dec 2025 16:19:29 +0100 Subject: [PATCH 3/4] Move conftest.py to integration_tests directory for consistency - Move conftest.py from tests/ to tests/integration_tests/ - Matches structure used in other packages (text-generation, image-generation, video-generation) - Removes need for --exclude '.*conftest\.py' in mypy commands --- .../speech-generation/tests/{ => integration_tests}/conftest.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/speech-generation/tests/{ => integration_tests}/conftest.py (100%) diff --git a/packages/speech-generation/tests/conftest.py b/packages/speech-generation/tests/integration_tests/conftest.py similarity index 100% rename from packages/speech-generation/tests/conftest.py rename to packages/speech-generation/tests/integration_tests/conftest.py From 30372544293b89a55cc8922c12d0801cb8b21acb Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Thu, 4 Dec 2025 16:19:38 +0100 Subject: [PATCH 4/4] Improve CI/CD workflows for speech-generation package - Add change detection to publish.yml to only run integration tests for changed packages - Use matrix strategy for parallel integration test execution - Add ELEVENLABS_API_KEY to integration test environment - Remove --exclude '.*conftest\.py' from mypy commands (no longer needed) - Update Makefile typecheck command to match CI workflow --- .github/workflows/ci.yml | 2 +- .github/workflows/publish.yml | 76 +++++++++++++++++++++++++++++++---- Makefile | 2 +- 3 files changed, 71 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50917af2..b9be8339 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,7 +64,7 @@ jobs: python-version: ${{ inputs.python-version || '3.12' }} - run: | if [ -d "packages" ]; then - uv run mypy -p celeste && uv run mypy tests/ && uv run mypy packages/image-generation packages/text-generation packages/video-generation packages/speech-generation --exclude '.*conftest\.py' + uv run mypy -p celeste && uv run mypy tests/ && uv run mypy packages/image-generation packages/text-generation packages/video-generation packages/speech-generation else uv run mypy -p celeste && uv run mypy tests/ fi diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3134f207..ef3aacc3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -41,17 +41,74 @@ jobs: uses: ./.github/workflows/ci.yml secrets: inherit - integration-test: - needs: [validate-release, run-ci] + detect-changes: + needs: run-ci runs-on: ubuntu-latest + outputs: + packages: ${{ steps.build-matrix.outputs.packages }} + has-changes: ${{ steps.build-matrix.outputs.has-changes }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: dorny/paths-filter@v3 + id: filter + with: + base: ${{ github.event.before }} + filters: | + text-generation: + - 'packages/text-generation/**' + image-generation: + - 'packages/image-generation/**' + video-generation: + - 'packages/video-generation/**' + speech-generation: + - 'packages/speech-generation/**' + core: + - 'src/celeste/**' + - id: build-matrix + run: | + PACKAGES=() + CORE="${{ steps.filter.outputs.core }}" + + if [[ "$CORE" == "true" || "${{ steps.filter.outputs.text-generation }}" == "true" ]]; then + PACKAGES+=("text-generation") + fi + if [[ "$CORE" == "true" || "${{ steps.filter.outputs.image-generation }}" == "true" ]]; then + PACKAGES+=("image-generation") + fi + if [[ "$CORE" == "true" || "${{ steps.filter.outputs.video-generation }}" == "true" ]]; then + PACKAGES+=("video-generation") + fi + if [[ "$CORE" == "true" || "${{ steps.filter.outputs.speech-generation }}" == "true" ]]; then + PACKAGES+=("speech-generation") + fi + + # Convert to JSON array + JSON=$(printf '%s\n' "${PACKAGES[@]}" | jq -R . | jq -s -c .) + echo "packages=$JSON" >> $GITHUB_OUTPUT + + if [[ ${#PACKAGES[@]} -gt 0 ]]; then + echo "has-changes=true" >> $GITHUB_OUTPUT + else + echo "has-changes=false" >> $GITHUB_OUTPUT + fi + + integration-tests: + needs: [validate-release, run-ci, detect-changes] + if: needs.detect-changes.outputs.has-changes == 'true' + runs-on: ubuntu-latest + timeout-minutes: 30 environment: name: integration-tests + strategy: + fail-fast: false + matrix: + package: ${{ fromJSON(needs.detect-changes.outputs.packages) }} steps: - uses: actions/checkout@v4 - with: - fetch-depth: 1 - uses: ./.github/actions/setup-python-uv - - name: Run integration tests + - name: Run ${{ matrix.package }} integration tests env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} @@ -62,10 +119,15 @@ jobs: BYTEDANCE_API_KEY: ${{ secrets.BYTEDANCE_API_KEY }} XAI_API_KEY: ${{ secrets.XAI_API_KEY }} ELEVENLABS_API_KEY: ${{ secrets.ELEVENLABS_API_KEY }} - run: make integration-test + run: uv run pytest packages/${{ matrix.package }}/tests/integration_tests/ -m integration -v build: - needs: [validate-release, run-ci, integration-test] + needs: [validate-release, run-ci, detect-changes, integration-tests] + if: | + always() && + needs.validate-release.result == 'success' && + needs.run-ci.result == 'success' && + (needs.integration-tests.result == 'success' || needs.integration-tests.result == 'skipped') runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/Makefile b/Makefile index 99d07cfe..2fa2a81d 100644 --- a/Makefile +++ b/Makefile @@ -33,7 +33,7 @@ format: # Type checking (fail fast on any error) typecheck: - @uv run mypy -p celeste && uv run mypy tests/ && uv run mypy packages/image-generation packages/text-generation packages/video-generation packages/speech-generation --exclude '.*conftest\.py' + @uv run mypy -p celeste && uv run mypy tests/ && uv run mypy packages/image-generation packages/text-generation packages/video-generation packages/speech-generation # Testing test: