mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
- Remove implementation details that will rot (code templates, tutorials) - Keep only stable contracts: ownership, lifecycle, protocols, error semantics - 270 lines → reference that only changes when architecture changes
271 lines
11 KiB
Markdown
271 lines
11 KiB
Markdown
# TTS Plugin Architecture — Architectural Reference
|
|
|
|
This document describes the **stable architectural contracts** of the TTS Plugin Architecture. It documents invariants that only change when the architecture itself changes.
|
|
|
|
---
|
|
|
|
## 1. Architecture Overview
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|
│ Host Application │
|
|
│ ┌─────────────┐ ┌──────────────┐ ┌────────────────────────┐ │
|
|
│ │ Plugin │ │ HostContext │ │ Plugin Discovery │ │
|
|
│ │ Manager │──│ (config_dir, │ │ (plugin directories) │ │
|
|
│ │ │ │ logger, │ │ │ │
|
|
│ │ - discover │ │ http_client)│ └────────────────────────┘ │
|
|
│ │ - validate │ └──────────────┘ │ │
|
|
│ │ - activate │ ▼ │
|
|
│ │ - dispose │ ┌─────────────────────────────────────────┐ │
|
|
│ └──────┬──────┘ │ Plugin Package │ │
|
|
│ │ │ ┌──────────────┐ ┌─────────────────┐ │ │
|
|
│ ▼ │ │ PLUGIN_ │ │ MODEL_ │ │ │
|
|
│ ┌────────────┐ │ │ MANIFEST │ │ REQUIREMENTS │ │ │
|
|
│ │ Engine │◄──┤ │ create_engine│ │ │ │ │
|
|
│ └──────┬─────┘ │ └──────────────┘ └─────────────────┘ │ │
|
|
│ │ └─────────────────────────────────────────┘ │
|
|
│ │ createSession() │
|
|
│ ▼ │
|
|
│ ┌─────────────┐ │
|
|
│ │EngineSession│ │
|
|
│ └──────┬──────┘ │
|
|
│ │ synthesize() │
|
|
│ ▼ │
|
|
│ ┌────────────────┐ │
|
|
│ │SynthesizedAudio│ │
|
|
│ └────────────────┘ │
|
|
└─────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
### Core Components
|
|
|
|
| Component | Responsibility |
|
|
|-----------|----------------|
|
|
| **PluginManifest** | Static metadata: id, name, version, api_version, capabilities, engine manifest |
|
|
| **EngineManifest** | Voice sources, parameters, audio formats |
|
|
| **HostContext** | Minimal host services: config_dir, logger, http_client |
|
|
| **Engine** | Stateless factory for sessions; thread-safe `createSession()` |
|
|
| **EngineSession** | Owns mutable execution state; not thread-safe |
|
|
| **PluginManager** | Discovers, validates, and manages plugin lifecycle |
|
|
| **Capabilities** | Optional interfaces: VoiceLister, PreviewGenerator, StreamingSynthesizer, CancelableSession |
|
|
|
|
---
|
|
|
|
## 2. Ownership Model
|
|
|
|
### Engine Ownership
|
|
```
|
|
PluginManager.create_engine() → Engine
|
|
```
|
|
- **PluginManager** creates and caches engines
|
|
- **Caller** receives `Engine` instance
|
|
- **Caller** must dispose all sessions **before** disposing engine
|
|
- **Engine.dispose()** releases engine resources
|
|
- After `Engine.dispose()`: all methods except `dispose()` raise `EngineError`
|
|
|
|
### Session Ownership
|
|
```
|
|
Engine.createSession() → EngineSession
|
|
```
|
|
- **Engine** creates session
|
|
- **Ownership transfers to caller** immediately
|
|
- **Caller** is responsible for `session.dispose()`
|
|
- **Engine does NOT track sessions** — no registry, no callbacks
|
|
- After `session.dispose()`: all methods except `dispose()` raise `EngineError`
|
|
|
|
### Disposal Order (Invariant)
|
|
```python
|
|
# Correct
|
|
engine = manager.create_engine("id")
|
|
session = engine.createSession()
|
|
try:
|
|
audio = session.synthesize(request)
|
|
finally:
|
|
session.dispose() # 1. Sessions FIRST
|
|
engine.dispose() # 2. Then engine
|
|
|
|
# INCORRECT — violates contract (undefined behavior)
|
|
engine.dispose()
|
|
session.synthesize(request) # EngineError
|
|
```
|
|
|
|
---
|
|
|
|
## 3. Lifecycle State Machine
|
|
|
|
```
|
|
DISCOVERY
|
|
PluginManager.discover(plugin_dirs)
|
|
→ Loads PLUGIN_MANIFEST, MODEL_REQUIREMENTS
|
|
→ Validates api_version (major must match)
|
|
→ Validates declared capabilities are implemented
|
|
|
|
MODEL_DOWNLOAD (if MODEL_REQUIREMENTS non-empty)
|
|
Host reads MODEL_REQUIREMENTS
|
|
Downloads/caches models
|
|
Resolves model_path
|
|
|
|
ACTIVATION
|
|
create_engine(context, model_path, config)
|
|
→ Atomic: succeeds fully or raises EngineError
|
|
→ Returns Engine
|
|
|
|
SESSION_CREATION
|
|
engine.createSession() → EngineSession
|
|
→ Ownership transfers to caller
|
|
→ Raises EngineError on failure
|
|
→ Never returns partial session
|
|
|
|
SYNTHESIS
|
|
session.synthesize(request)
|
|
→ Returns SynthesizedAudio
|
|
→ Raises EngineError on failure
|
|
→ Session remains usable after error
|
|
|
|
SESSION_DISPOSAL
|
|
session.dispose()
|
|
→ Idempotent, never raises
|
|
→ After: all methods raise EngineError
|
|
|
|
DEACTIVATION
|
|
engine.dispose()
|
|
→ Caller MUST dispose all sessions first
|
|
→ Idempotent, never raises
|
|
→ After: all methods raise EngineError
|
|
```
|
|
|
|
---
|
|
|
|
## 4. Protocol Contracts
|
|
|
|
### Engine (Protocol)
|
|
```python
|
|
@runtime_checkable
|
|
class Engine(Protocol):
|
|
def createSession(self) -> EngineSession:
|
|
"""Create a new session. Thread-safe. Transfers ownership."""
|
|
...
|
|
|
|
def dispose(self) -> None:
|
|
"""Release engine resources.
|
|
Caller must dispose all sessions first.
|
|
Idempotent, never raises.
|
|
After: all methods except dispose() raise EngineError."""
|
|
...
|
|
```
|
|
|
|
### EngineSession (Protocol)
|
|
```python
|
|
@runtime_checkable
|
|
class EngineSession(Protocol):
|
|
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
|
|
"""Synthesize audio.
|
|
Returns SynthesizedAudio or raises EngineError.
|
|
Session remains usable after error."""
|
|
...
|
|
|
|
def dispose(self) -> None:
|
|
"""Release session resources.
|
|
Idempotent, never raises.
|
|
After: all methods except dispose() raise EngineError."""
|
|
...
|
|
```
|
|
|
|
### Capability Protocols (Optional)
|
|
- **VoiceLister**: `listVoices(source_id: str) -> list[VoiceManifest]`
|
|
- **PreviewGenerator**: `generatePreview(voice: VoiceSelection, text: str) -> SynthesizedAudio`
|
|
- **StreamingSynthesizer**: `synthesizeStream(request: SynthesisRequest) -> Iterator[bytes]`
|
|
- **CancelableSession**: `cancel() -> None` (causes in-flight synthesize to raise `CancelledError`)
|
|
|
|
---
|
|
|
|
## 5. Error Semantics
|
|
|
|
```
|
|
EngineError (base)
|
|
├── ModelNotFoundError # Required model not found
|
|
├── ModelLoadError # Model failed to load
|
|
├── NetworkError # Network operation failed
|
|
├── InvalidInputError # Request validation failed
|
|
├── ConfigurationError # Invalid configuration
|
|
├── CancelledError # Operation cancelled via CancelableSession
|
|
└── InternalError # Unexpected internal failure
|
|
```
|
|
|
|
### When Each Is Raised
|
|
| Error | Raised By | Conditions |
|
|
|-------|-----------|------------|
|
|
| `ModelNotFoundError` | `create_engine()` | Required model not found at `model_path` |
|
|
| `ModelLoadError` | `create_engine()` | Model exists but fails to load |
|
|
| `NetworkError` | `synthesize()`, `create_engine()` | Network call fails (cloud engines) |
|
|
| `InvalidInputError` | `synthesize()` | Request validation fails (empty text, invalid voice, etc.) |
|
|
| `ConfigurationError` | `create_engine()` | Config values invalid for this engine |
|
|
| `CancelledError` | `synthesize()`, `synthesizeStream()` | `CancelableSession.cancel()` called |
|
|
| `InternalError` | Any | Unexpected internal failure (bug) |
|
|
|
|
### Dispose Contract
|
|
- `dispose()` is **idempotent** and **never raises**
|
|
- After `dispose()`: all methods except `dispose()` raise `EngineError`
|
|
- Engine: caller must dispose all sessions first; violating this is undefined behavior
|
|
|
|
---
|
|
|
|
## 6. Capabilities
|
|
|
|
| Capability | Interface | Enables |
|
|
|------------|-----------|---------|
|
|
| `voice_list` | `VoiceLister` | `listVoices(source_id)` — enumerate available voices |
|
|
| `preview` | `PreviewGenerator` | `generatePreview(voice, text)` — preview without session |
|
|
| `streaming` | `StreamingSynthesizer` | `synthesizeStream(request)` — chunked audio output |
|
|
| `cancel` | `CancelableSession` | `cancel()` — interrupt in-flight synthesis |
|
|
|
|
Plugins declare capabilities in `PluginManifest.capabilities`. Host validates at load time.
|
|
|
|
---
|
|
|
|
## 7. Contract Tests
|
|
|
|
**Location**: `tests/contracts/`
|
|
|
|
**Purpose**: Verify every plugin satisfies the architectural contracts.
|
|
|
|
**Guarantees**:
|
|
- Required exports exist (`PLUGIN_MANIFEST`, `MODEL_REQUIREMENTS`, `create_engine`)
|
|
- `create_engine` is atomic
|
|
- `Engine.createSession()` transfers ownership, never returns partial
|
|
- `dispose()` is idempotent on Engine and EngineSession
|
|
- After `dispose()`, methods raise `EngineError`
|
|
- `synthesize()` raises typed `EngineError` subtypes, session remains usable
|
|
- Declared capabilities are actually implemented
|
|
- Plugin loader validates manifest, api_version, capabilities
|
|
|
|
**Run**: `pytest tests/contracts/ -v`
|
|
|
|
---
|
|
|
|
## 8. Behavioral Tests
|
|
|
|
**Location**: `tests/test_behavioral_regression.py`
|
|
|
|
**Purpose**: Verify user-facing behavior via public API only (`create_pipeline`, `Engine`, `EngineSession`, `PluginManager`).
|
|
|
|
**Scope**:
|
|
- Synthesis with various inputs (short, long, empty, unicode, mixed scripts)
|
|
- Voice selection and listing
|
|
- Parameter handling (speed, etc.)
|
|
- Error scenarios (unknown plugin, disposal, etc.)
|
|
- Resource cleanup (dispose idempotency, no leaks)
|
|
- Pipeline utility (`create_pipeline`)
|
|
|
|
**Run**: `pytest tests/test_behavioral_regression.py -v`
|
|
|
|
---
|
|
|
|
## 9. Reference
|
|
|
|
- **Architecture Spec**: `docs/architecture-final-v2.md`
|
|
- **Amendment (lang_code)**: `docs/architecture-amendment-001.md`
|
|
- **Migration Roadmap**: `docs/migration-roadmap.md`
|
|
- **Plugin Examples**: `plugins/kokoro/`, `plugins/supertonic/`
|
|
- **Protocol Definitions**: `abogen/tts_plugin/engine.py`, `abogen/tts_plugin/capabilities.py`
|