From 79b3d26f661e2be242274c7ac361e5b09fb85ca5 Mon Sep 17 00:00:00 2001 From: Artem Akymenko Date: Thu, 9 Jul 2026 08:43:24 +0000 Subject: [PATCH] feat: add frozen Plugin API skeleton Create public API structure for TTS Plugin Architecture: - types.py: immutable value objects (AudioFormat, Duration, VoiceSelection, etc.) - errors.py: EngineError hierarchy (7 typed exceptions) - manifest.py: plugin manifest dataclasses (PluginManifest, EngineManifest, etc.) - engine.py: Engine and EngineSession protocols - capabilities.py: optional capability interfaces (VoiceLister, PreviewGenerator, etc.) - host_context.py: HostContext and HttpClient protocol - plugin.py: plugin contract (create_engine signature) - __init__.py: public API exports All interfaces are fully defined but contain no business logic. API is frozen and ready for implementation in subsequent PRs. --- abogen/tts_plugin/__init__.py | 139 ++++++++++++++++++++++ abogen/tts_plugin/capabilities.py | 103 +++++++++++++++++ abogen/tts_plugin/engine.py | 95 +++++++++++++++ abogen/tts_plugin/errors.py | 62 ++++++++++ abogen/tts_plugin/host_context.py | 46 ++++++++ abogen/tts_plugin/manifest.py | 186 ++++++++++++++++++++++++++++++ abogen/tts_plugin/plugin.py | 55 +++++++++ abogen/tts_plugin/types.py | 105 +++++++++++++++++ 8 files changed, 791 insertions(+) create mode 100644 abogen/tts_plugin/__init__.py create mode 100644 abogen/tts_plugin/capabilities.py create mode 100644 abogen/tts_plugin/engine.py create mode 100644 abogen/tts_plugin/errors.py create mode 100644 abogen/tts_plugin/host_context.py create mode 100644 abogen/tts_plugin/manifest.py create mode 100644 abogen/tts_plugin/plugin.py create mode 100644 abogen/tts_plugin/types.py diff --git a/abogen/tts_plugin/__init__.py b/abogen/tts_plugin/__init__.py new file mode 100644 index 0000000..051f71e --- /dev/null +++ b/abogen/tts_plugin/__init__.py @@ -0,0 +1,139 @@ +"""TTS Plugin Architecture - Public API. + +This package defines the frozen Plugin API for the TTS Plugin Architecture. +All public interfaces are fully defined but contain no business logic. + +Public modules: +- types: Core domain value objects (AudioFormat, Duration, VoiceSelection, etc.) +- errors: Error hierarchy (EngineError and subtypes) +- manifest: Plugin manifest types (PluginManifest, EngineManifest, etc.) +- engine: Engine and EngineSession protocols +- capabilities: Optional capability interfaces (VoiceLister, PreviewGenerator, etc.) +- host_context: HostContext dataclass +- plugin: Plugin contract (create_engine function signature) + +Usage: + from abogen.tts_plugin import ( + # Types + AudioFormat, + Duration, + VoiceSelection, + ParameterValues, + SynthesisRequest, + SynthesizedAudio, + EngineConfig, + # Errors + EngineError, + ModelNotFoundError, + ModelLoadError, + NetworkError, + InvalidInputError, + ConfigurationError, + CancelledError, + InternalError, + # Manifest + PluginManifest, + EngineManifest, + VoiceSourceManifest, + VoiceManifest, + ParameterManifest, + AudioFormatManifest, + EnumOption, + RequirementManifest, + GpuRequirement, + ModelManifest, + # Engine + Engine, + EngineSession, + # Capabilities + VoiceLister, + PreviewGenerator, + StreamingSynthesizer, + CancelableSession, + # Host Context + HostContext, + HttpClient, + ) +""" + +from abogen.tts_plugin.capabilities import ( + CancelableSession, + PreviewGenerator, + StreamingSynthesizer, + VoiceLister, +) +from abogen.tts_plugin.engine import Engine, EngineSession +from abogen.tts_plugin.errors import ( + CancelledError, + ConfigurationError, + EngineError, + InternalError, + InvalidInputError, + ModelLoadError, + ModelNotFoundError, + NetworkError, +) +from abogen.tts_plugin.host_context import HttpClient, HostContext +from abogen.tts_plugin.manifest import ( + AudioFormatManifest, + EngineManifest, + EnumOption, + GpuRequirement, + ModelManifest, + ParameterManifest, + PluginManifest, + RequirementManifest, + VoiceManifest, + VoiceSourceManifest, +) +from abogen.tts_plugin.types import ( + AudioFormat, + Duration, + EngineConfig, + ParameterValues, + SynthesisRequest, + SynthesizedAudio, + VoiceSelection, +) + +__all__ = [ + # Types + "AudioFormat", + "Duration", + "VoiceSelection", + "ParameterValues", + "SynthesisRequest", + "SynthesizedAudio", + "EngineConfig", + # Errors + "EngineError", + "ModelNotFoundError", + "ModelLoadError", + "NetworkError", + "InvalidInputError", + "ConfigurationError", + "CancelledError", + "InternalError", + # Manifest + "PluginManifest", + "EngineManifest", + "VoiceSourceManifest", + "VoiceManifest", + "ParameterManifest", + "AudioFormatManifest", + "EnumOption", + "RequirementManifest", + "GpuRequirement", + "ModelManifest", + # Engine + "Engine", + "EngineSession", + # Capabilities + "VoiceLister", + "PreviewGenerator", + "StreamingSynthesizer", + "CancelableSession", + # Host Context + "HostContext", + "HttpClient", +] diff --git a/abogen/tts_plugin/capabilities.py b/abogen/tts_plugin/capabilities.py new file mode 100644 index 0000000..6430d75 --- /dev/null +++ b/abogen/tts_plugin/capabilities.py @@ -0,0 +1,103 @@ +"""Capability interfaces for the TTS Plugin Architecture. + +This module defines optional capability interfaces that engines can implement. +Capabilities are additive; implementing new capabilities doesn't break old plugins. +""" + +from __future__ import annotations + +from typing import Iterator, Protocol, runtime_checkable + +from abogen.tts_plugin.manifest import VoiceManifest +from abogen.tts_plugin.types import SynthesisRequest, SynthesizedAudio, VoiceSelection + + +@runtime_checkable +class VoiceLister(Protocol): + """Protocol for listing available voices. + + Engines that support voice listing should implement this interface. + """ + + def listVoices(self, sourceId: str) -> list[VoiceManifest]: + """List available voices for a given source. + + Args: + sourceId: The voice source identifier. + + Returns: + List of VoiceManifest describing available voices. + + Raises: + EngineError: On failure. + """ + ... + + +@runtime_checkable +class PreviewGenerator(Protocol): + """Protocol for generating voice previews. + + Engines that support voice preview should implement this interface. + """ + + def generatePreview(self, voice: VoiceSelection, text: str) -> SynthesizedAudio: + """Generate a preview audio for a voice. + + Args: + voice: Voice selection for the preview. + text: Text to use for the preview. + + Returns: + SynthesizedAudio with the preview audio data. + + Raises: + EngineError: On failure. + """ + ... + + +@runtime_checkable +class StreamingSynthesizer(Protocol): + """Protocol for streaming synthesis. + + Optional capability of EngineSession, not Engine. + Engines that support streaming synthesis should implement this interface. + """ + + def synthesizeStream(self, request: SynthesisRequest) -> Iterator[bytes]: + """Synthesize audio in streaming mode. + + Args: + request: The synthesis request. + + Yields: + Audio chunks as they become available. + + Raises: + CancelledError: If cancel() is called during iteration. + EngineError: On synthesis failure. + """ + ... + # This is a generator function; implementation will use yield + yield b"" # pragma: no cover + + +@runtime_checkable +class CancelableSession(Protocol): + """Protocol for cancellation support. + + Optional capability for engines that support cancellation. + cancel() causes synthesize() to raise CancelledError. + """ + + def cancel(self) -> None: + """Cancel in-progress synthesis. + + After cancellation, synthesize() raises CancelledError. + The session remains usable after cancellation. + + Raises: + EngineError: If called after dispose(). + """ + ... diff --git a/abogen/tts_plugin/engine.py b/abogen/tts_plugin/engine.py new file mode 100644 index 0000000..0596f6b --- /dev/null +++ b/abogen/tts_plugin/engine.py @@ -0,0 +1,95 @@ +"""Engine interfaces for the TTS Plugin Architecture. + +This module defines the core Engine and EngineSession protocols. +These are the primary interfaces that plugin implementations must satisfy. +""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +from abogen.tts_plugin.types import SynthesisRequest, SynthesizedAudio + + +@runtime_checkable +class EngineSession(Protocol): + """Protocol for a session that owns mutable execution state. + + An EngineSession is created by Engine.createSession() and owns + mutable execution state isolated from other concurrent work. + It is NOT thread-safe. + + Lifecycle: + 1. Created by Engine.createSession() + 2. Used for synthesis via synthesize() + 3. Disposed via dispose() + + After dispose(), all methods except dispose() raise EngineError. + """ + + def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio: + """Synthesize audio from text. + + Args: + request: The synthesis request containing text, voice, parameters, and format. + + Returns: + SynthesizedAudio with the synthesized audio data. + + Raises: + EngineError: On synthesis failure. Session remains usable after error. + EngineError: If called after dispose(). + """ + ... + + def dispose(self) -> None: + """Release session resources. + + This method is idempotent and safe to call multiple times. + It never raises exceptions (catches and logs internally). + After dispose(), all methods except dispose() raise EngineError. + """ + ... + + +@runtime_checkable +class Engine(Protocol): + """Protocol for a TTS engine that creates sessions. + + An Engine is a factory for EngineSession instances. It is stateless + and thread-safe for createSession(). + + Lifecycle: + 1. Created via create_engine() (plugin contract) + 2. Sessions created via createSession() + 3. Disposed via dispose() + + Thread Safety: + - createSession() is thread-safe and can be called from any thread. + - dispose() must be called after all sessions are disposed. + - Disposing engine while sessions are alive violates API contract. + """ + + def createSession(self) -> EngineSession: + """Create a new session for synthesis. + + Returns: + A new EngineSession instance. Ownership transfers to caller. + + Raises: + EngineError: On failure. No partially initialized session is returned. + """ + ... + + def dispose(self) -> None: + """Release engine resources. + + Caller must ensure all sessions created by this engine are disposed + before calling dispose(). Disposing an engine while any session is + still alive violates the API contract; behavior is undefined. + + This method is idempotent and safe to call multiple times. + It never raises exceptions (catches and logs internally). + After dispose(), all methods except dispose() raise EngineError. + """ + ... diff --git a/abogen/tts_plugin/errors.py b/abogen/tts_plugin/errors.py new file mode 100644 index 0000000..7f52801 --- /dev/null +++ b/abogen/tts_plugin/errors.py @@ -0,0 +1,62 @@ +"""Error hierarchy for the TTS Plugin Architecture. + +This module defines typed exceptions that engines raise. +Engines should never raise raw exceptions; they must use EngineError or its subtypes. +""" + +from __future__ import annotations + + +class EngineError(Exception): + """Base exception for all engine errors. + + All engine operations that can fail should raise EngineError or one of its subtypes. + After dispose(), all methods except dispose() raise EngineError. + """ + + pass + + +class ModelNotFoundError(EngineError): + """Raised when a required model is not found.""" + + pass + + +class ModelLoadError(EngineError): + """Raised when a model fails to load.""" + + pass + + +class NetworkError(EngineError): + """Raised when a network operation fails.""" + + pass + + +class InvalidInputError(EngineError): + """Raised when invalid input is provided to the engine.""" + + pass + + +class ConfigurationError(EngineError): + """Raised when there is a configuration error.""" + + pass + + +class CancelledError(EngineError): + """Raised when an operation is cancelled. + + This is raised by synthesize() when cancel() is called during synthesis. + """ + + pass + + +class InternalError(EngineError): + """Raised when an internal engine error occurs.""" + + pass diff --git a/abogen/tts_plugin/host_context.py b/abogen/tts_plugin/host_context.py new file mode 100644 index 0000000..c07c327 --- /dev/null +++ b/abogen/tts_plugin/host_context.py @@ -0,0 +1,46 @@ +"""Host context for the TTS Plugin Architecture. + +This module defines the HostContext dataclass that provides minimal +host services to plugins. It is the only interface through which +plugins can access host functionality. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from pathlib import Path +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class HttpClient(Protocol): + """Protocol for HTTP client provided by host. + + Plugins can use this for network requests (e.g., API-based engines). + """ + + def get(self, url: str, **kwargs: object) -> object: + """Perform an HTTP GET request.""" + ... + + def post(self, url: str, **kwargs: object) -> object: + """Perform an HTTP POST request.""" + ... + + +@dataclass(frozen=True) +class HostContext: + """Minimal host context provided to plugins. + + Contains only essential host services. No business logic. + + Attributes: + config_dir: Directory for API keys, preferences, and configuration. + logger: Logger for plugin logging. + http_client: HTTP client for network requests. + """ + + config_dir: Path + logger: logging.Logger + http_client: HttpClient diff --git a/abogen/tts_plugin/manifest.py b/abogen/tts_plugin/manifest.py new file mode 100644 index 0000000..4f4a42b --- /dev/null +++ b/abogen/tts_plugin/manifest.py @@ -0,0 +1,186 @@ +"""Plugin manifest types for the TTS Plugin Architecture. + +This module contains static metadata types that describe plugins. +These types have no dependencies and are immutable. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(frozen=True) +class AudioFormatManifest: + """Manifest describing an audio format. + + Attributes: + mime: MIME type of the audio. + extension: File extension. + """ + + mime: str + extension: str + + +@dataclass(frozen=True) +class EnumOption: + """Manifest describing an enum option for a parameter. + + Attributes: + value: The enum value. + label: Human-readable label. + """ + + value: str + label: str + + +@dataclass(frozen=True) +class ParameterManifest: + """Manifest describing a synthesis parameter. + + Attributes: + id: Parameter identifier. + name: Human-readable name. + description: Parameter description. + type: Parameter type ("float", "int", "string", "boolean", "enum"). + default: Default value. + min: Minimum value (optional, for numeric types). + max: Maximum value (optional, for numeric types). + step: Step size (optional, for numeric types). + options: Available options (optional, for enum type). + unit: Unit of measurement (optional). + group: Parameter group (optional). + """ + + id: str + name: str + description: str + type: str + default: Any + min: float | None = None + max: float | None = None + step: float | None = None + options: tuple[EnumOption, ...] = field(default_factory=tuple) + unit: str | None = None + group: str | None = None + + +@dataclass(frozen=True) +class VoiceManifest: + """Manifest describing a voice. + + Attributes: + id: Voice identifier. + name: Human-readable name. + tags: Voice tags (e.g., language, style). + """ + + id: str + name: str + tags: tuple[str, ...] = field(default_factory=tuple) + + +@dataclass(frozen=True) +class VoiceSourceManifest: + """Manifest describing a voice source. + + Attributes: + id: Voice source identifier. + name: Human-readable name. + type: Source type ("list", "speaker_id", "clone", "blend", "generate", "none"). + config: Source-specific configuration. + """ + + id: str + name: str + type: str + config: Any = None + + +@dataclass(frozen=True) +class EngineManifest: + """Manifest describing engine capabilities. + + Attributes: + voiceSources: Available voice sources. + parameters: Available synthesis parameters. + audioFormats: Supported audio formats. + """ + + voiceSources: tuple[VoiceSourceManifest, ...] = field(default_factory=tuple) + parameters: tuple[ParameterManifest, ...] = field(default_factory=tuple) + audioFormats: tuple[AudioFormatManifest, ...] = field(default_factory=tuple) + + +@dataclass(frozen=True) +class GpuRequirement: + """Manifest describing GPU requirements. + + Attributes: + required: Whether GPU is required. + type: GPU type (e.g., "cuda", "rocm"). + memory: Required GPU memory in GB. + """ + + required: bool = False + type: str | None = None + memory: float | None = None + + +@dataclass(frozen=True) +class RequirementManifest: + """Manifest describing plugin requirements. + + Attributes: + gpu: GPU requirements (optional). + memory: Required RAM in GB (optional). + internet: Whether internet is required (optional). + """ + + gpu: GpuRequirement | None = None + memory: float | None = None + internet: bool | None = None + + +@dataclass(frozen=True) +class ModelManifest: + """Manifest describing a model requirement. + + Attributes: + id: Model identifier. + name: Human-readable name. + size: Model size as string (e.g., "100MB", "2GB"). + """ + + id: str + name: str + size: str + + +@dataclass(frozen=True) +class PluginManifest: + """Main manifest for a TTS plugin. + + Attributes: + id: Plugin identifier (unique). + name: Human-readable name. + version: Plugin version. + api_version: API version (semver format: MAJOR.MINOR). + description: Plugin description. + author: Plugin author. + capabilities: List of capability identifiers. + requires: Plugin requirements. + engine: Engine manifest. + """ + + id: str + name: str + version: str + api_version: str + description: str + author: str + capabilities: tuple[str, ...] = field(default_factory=tuple) + requires: RequirementManifest = field(default_factory=RequirementManifest) + engine: EngineManifest = field(default_factory=EngineManifest) diff --git a/abogen/tts_plugin/plugin.py b/abogen/tts_plugin/plugin.py new file mode 100644 index 0000000..2e4e7f6 --- /dev/null +++ b/abogen/tts_plugin/plugin.py @@ -0,0 +1,55 @@ +"""Plugin contract for the TTS Plugin Architecture. + +This module defines the plugin contract that all TTS plugins must implement. +Each plugin must export: +- PLUGIN_MANIFEST: PluginManifest instance +- MODEL_REQUIREMENTS: list of ModelManifest instances +- create_engine(): Factory function that creates an Engine + +The create_engine() function is the entry point for plugin activation. +It must be atomic: succeed fully or raise and clean up. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Protocol, runtime_checkable + +from abogen.tts_plugin.engine import Engine +from abogen.tts_plugin.host_context import HostContext +from abogen.tts_plugin.types import EngineConfig + + +@runtime_checkable +class Plugin(Protocol): + """Protocol defining the plugin contract. + + Every TTS plugin must implement this protocol by exporting: + - PLUGIN_MANIFEST: PluginManifest + - MODEL_REQUIREMENTS: list[ModelManifest] + - create_engine: Callable[[HostContext, Path | None, EngineConfig], Engine] + """ + + def create_engine( + self, + context: HostContext, + model_path: Path | None, + config: EngineConfig, + ) -> Engine: + """Create an engine instance. + + This is the factory function that creates an Engine from a plugin. + It must be atomic: succeed fully or raise EngineError and clean up. + + Args: + context: Host services (config dir, logger, http client). + model_path: Resolved model path, or None for cloud/no-model engines. + config: Engine initialization settings. + + Returns: + A fully initialized Engine instance. + + Raises: + EngineError: On failure. Cleans up partially created resources. + """ + ... diff --git a/abogen/tts_plugin/types.py b/abogen/tts_plugin/types.py new file mode 100644 index 0000000..7ec9602 --- /dev/null +++ b/abogen/tts_plugin/types.py @@ -0,0 +1,105 @@ +"""Core domain types for the TTS Plugin Architecture. + +This module contains immutable value objects that form the core domain. +These types have zero dependencies and are used across the plugin system. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Mapping + + +@dataclass(frozen=True) +class AudioFormat: + """Immutable value object representing an audio format. + + Attributes: + mime: MIME type of the audio (e.g., "audio/wav", "audio/mpeg"). + extension: File extension (e.g., "wav", "mp3"). + """ + + mime: str + extension: str + + +@dataclass(frozen=True) +class Duration: + """Immutable value object representing a time duration. + + Attributes: + seconds: Duration in seconds. + """ + + seconds: float + + +@dataclass(frozen=True) +class VoiceSelection: + """Immutable value object for voice selection. Opaque to engine. + + Attributes: + source: Voice source identifier (e.g., "builtin", "clone"). + key: Voice key within the source. + payload: Optional payload for clone/blend sources. + """ + + source: str + key: str + payload: Any = None + + +@dataclass(frozen=True) +class ParameterValues: + """Immutable value object for synthesis parameters. Behaves like Mapping[str, Any]. + + Attributes: + values: Mapping of parameter names to their values. + """ + + values: Mapping[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class SynthesisRequest: + """Immutable value object for a synthesis request. + + Attributes: + text: Text to synthesize. + voice: Voice selection. + parameters: Synthesis parameters. + format: Desired audio output format. + """ + + text: str + voice: VoiceSelection + parameters: ParameterValues + format: AudioFormat + + +@dataclass(frozen=True) +class SynthesizedAudio: + """Immutable value object for synthesized audio result. + + Attributes: + data: Raw audio bytes. + format: Audio format of the result. + duration: Duration of the audio. + """ + + data: bytes + format: AudioFormat + duration: Duration + + +@dataclass(frozen=True) +class EngineConfig: + """Immutable value object for engine initialization settings. + + Contains only engine-specific settings, no resource references. + + Attributes: + device: Device to use (e.g., "cpu", "cuda:0"). + """ + + device: str = "cpu"