docs: rewrite developer-guide as architectural reference

- 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
This commit is contained in:
Artem Akymenko
2026-07-12 16:20:28 +03:00
parent 65cb0c75e5
commit 096ea58d74
7 changed files with 613 additions and 91 deletions
-89
View File
@@ -1,89 +0,0 @@
# Architecture Amendment #1: EngineConfig — `lang_code` field
**Date:** 2026-07-12
**Status:** Accepted
**PR:** #12 (Normalize Pipeline Public API)
## Summary
Add `lang_code: str = "a"` to `EngineConfig` and update its definition to clarify the architectural contract.
## Background
During migration from the old `KokoroBackend` to the Plugin Architecture, the `lang_code` parameter became a dead argument. The old backend read it from `**kwargs` and passed it to `KPipeline(lang_code=...)`. The new `KokoroPlugin.create_engine()` hardcodes `lang_code="a"`, ignoring the config entirely. Callers continued passing `lang_code` to `create_pipeline()`, unaware it had no effect.
This is a functional regression relative to the pre-Plugin Architecture behavior.
## Decision
### 1. Updated EngineConfig definition
**Before:**
```
Immutable value object for engine initialization settings.
Contains only engine-specific settings, no resource references.
```
**After:**
```
Immutable configuration of an Engine instance.
Contains parameters that define how a particular Engine instance is
created and that remain constant throughout the lifetime of that Engine.
Plugin implementations may ignore fields that are not applicable to them.
```
### 2. New field
```python
@dataclass(frozen=True)
class EngineConfig:
device: str = "cpu"
lang_code: str = "a"
```
### 3. Architectural rules
- **Fields in EngineConfig are optional unless explicitly required by a plugin.**
- **Plugins MUST ignore unsupported EngineConfig fields.**
- **All parameters that may vary between individual synthesis requests must remain in `SynthesisRequest.parameters`.**
## Rationale
Analysis of real TTS engines (Kokoro, Piper, XTTS, Coqui, StyleTTS2, Fish Speech) confirmed:
| Parameter type | Where it belongs | Example |
|---------------|-----------------|---------|
| Engine instance config (immutable) | `EngineConfig` | `device`, `lang_code` |
| Synthesis parameters (per-request) | `SynthesisRequest.parameters` | `speed`, `split_pattern`, `total_steps` |
`lang_code` determines the engine's behavior at creation time and cannot be changed during the engine's lifetime. It is not a synthesis parameter.
## Impact on existing plugins
| Plugin | `device` | `lang_code` | Notes |
|--------|----------|-------------|-------|
| Kokoro | Reads ✓ | Reads ✓ (was hardcoded, now from config) | Regression fixed |
| SuperTonic | Ignores | Ignores | No change — no language concept |
| Future plugins | May read | May ignore | Field-ignoring rule applies |
## Contract tests added
```python
class TestEngineConfigContract:
def test_default_lang_code(self) # EngineConfig().lang_code == "a"
def test_custom_lang_code(self) # EngineConfig(lang_code="j").lang_code == "j"
def test_immutability_lang_code(self) # frozen — cannot reassign
def test_plugins_may_ignore_irrelevant_fields(self) # field-ignoring rule
def test_engine_config_contains_engine_instance_configuration(self) # definition
```
## Files changed
| File | Change |
|------|--------|
| `abogen/tts_plugin/types.py` | Updated docstring, added `lang_code: str = "a"` |
| `plugins/kokoro/__init__.py` | Reads `config.lang_code` instead of hardcoded `"a"` |
| `abogen/tts_plugin/utils.py` | `create_pipeline()` passes `lang_code` to `EngineConfig` |
| `tests/contracts/test_types_contract.py` | 5 new contract tests |
| `tests/contracts/test_plugin_manager_contract.py` | Updated assertion for `lang_code` |
| `tests/test_behavioral_regression.py` | Updated `test_engine_config_defaults` |
+55
View File
@@ -0,0 +1,55 @@
# Contributing to Abogen
We welcome contributions to Abogen!
## How to Contribute
1. Fork the repository
2. Create a branch for your feature
3. Make your changes
4. Write tests
5. Submit a pull request
## Code Standards
- Follow PEP 8 for Python
- Use TypeScript for JavaScript
- Type hints required for new Python code
- Document complex logic with comments
## Plugin Architecture
When contributing TTS engines, implement the **Plugin Architecture** contract.
See [Developer Guide](developer-guide.md#5-adding-a-new-plugin) for:
- Required exports (`PLUGIN_MANIFEST`, `MODEL_REQUIREMENTS`, `create_engine`)
- Engine / EngineSession contracts
- Capability interfaces (`VoiceLister`, `PreviewGenerator`, etc.)
- Step-by-step plugin creation guide
## Testing
```bash
# All tests
pytest
# Contract tests (architectural compliance)
pytest tests/contracts/
# Behavioral regression tests
pytest tests/test_behavioral_regression.py
```
## Documentation
- Update relevant docs in `docs/` when changing architecture or APIs
- Add docstrings to all public functions/classes
- Follow existing documentation style
## Pull Request Checklist
- [ ] Tests pass (`pytest`)
- [ ] Code follows style guide (`ruff check`, `ruff format`)
- [ ] Documentation updated
- [ ] No legacy architecture references (`TTSBackend`, `register_backend`, `TTSBackendRegistry`)
- [ ] Uses new Plugin Architecture patterns
+270
View File
@@ -0,0 +1,270 @@
# 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`
+68
View File
@@ -0,0 +1,68 @@
# Getting Started
Quickstart for developers working on Abogen.
## Prerequisites
- Python 3.10+
- Node.js 20+
- npm 10+
- Git
- Docker (optional)
## Installation
```bash
# Development install with all extras
pip install -e .[dev]
# Or with uv
uv pip install -e .[dev]
```
## Running the Application
```bash
# Desktop GUI
abogen
# Web UI
abogen-web
# CLI
abogen-cli
```
## Project Structure
```
abogen/
├── pyqt/ - PyQt6 desktop GUI
├── webui/ - Flask web UI
├── tts_plugin/ - Plugin Architecture (Engine, EngineSession, Manifest)
└── plugins/ - Built-in plugins (kokoro, supertonic)
tests/
├── contracts/ - Contract compliance tests
└── ...
```
## Testing
```bash
# All tests
pytest
# Contract tests (architectural compliance)
pytest tests/contracts/
# Behavioral regression tests
pytest tests/test_behavioral_regression.py
```
## Architecture
See [Developer Guide](developer-guide.md) for Plugin Architecture details:
- Engine / EngineSession lifecycle
- Plugin contract (PLUGIN_MANIFEST, create_engine)
- Adding new plugins
- Capability interfaces
+218
View File
@@ -0,0 +1,218 @@
# Testing Guide
This document describes the testing strategy for Abogen's Plugin Architecture.
## Test Categories
### 1. Contract Tests (`tests/contracts/`)
**Purpose**: Verify that every plugin satisfies the architectural contract. These tests ensure the Plugin Architecture's invariants are maintained.
**What They Guarantee**:
- Every plugin exports `PLUGIN_MANIFEST`, `MODEL_REQUIREMENTS`, `create_engine`
- `create_engine` is atomic (succeeds fully or raises and cleans up)
- `Engine.createSession()` returns valid `EngineSession`, transfers ownership
- `Engine.dispose()` is idempotent, never raises
- After `dispose()`, all methods raise `EngineError`
- `EngineSession.synthesize()` returns `SynthesizedAudio` or raises `EngineError` (session remains usable)
- `EngineSession.dispose()` is idempotent, never raises
- Capability interfaces (`VoiceLister`, `PreviewGenerator`, etc.) are correctly implemented
- Plugin Loader discovers, validates, and loads plugins correctly
- Plugin Manager creates, caches, and disposes engines correctly
- Value objects are immutable and have correct equality semantics
- Error hierarchy is preserved (`EngineError` base with subtypes)
**Why They Exist**:
- Provide **compile-time-like guarantees** for a dynamic plugin system
- Enable **safe plugin ecosystem** — host can trust any loaded plugin
- Catch **architectural violations** early (missing dispose, wrong return types, etc.)
- Document the **contract** in executable form
**What Every New Plugin Must Pass**:
```bash
pytest tests/contracts/ -v
# All tests must pass
```
**Running Contract Tests**:
```bash
# All contract tests
pytest tests/contracts/
# Specific contract
pytest tests/contracts/test_engine_contract.py
# With coverage
pytest tests/contracts/ --cov=abogen.tts_plugin
```
---
### 2. Behavioral Tests (`tests/test_behavioral_regression.py`)
**Purpose**: Verify external user-facing behavior using only public API. These tests are **not coupled to internal implementation**.
**What They Test**:
- 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`)
**Why They Test Public Behavior Only**:
- **Refactoring safety**: Internal changes don't break tests
- **Real-world usage**: Tests match how consumers actually use the API
- **Plugin agnostic**: Parametrized across Kokoro, SuperTonic, and mock plugins
- **Regression detection**: Catch behavioral regressions regardless of implementation
**What They Don't Test**:
- Internal class structure
- Private methods
- Implementation details (how audio is generated, model loading internals)
**Running Behavioral Tests**:
```bash
# All behavioral tests
pytest tests/test_behavioral_regression.py -v
# With specific plugin (if installed)
pytest tests/test_behavioral_regression.py -v -k "kokoro"
```
---
### 3. Unit Tests (`tests/`)
**Purpose**: Test individual modules in isolation.
**Examples**:
- `test_book_parser.py` — EPUB/PDF/text parsing
- `test_text_normalization.py` — Text preprocessing
- `test_chunk_helpers.py` — Text chunking logic
- `test_voice_cache.py` — Voice caching
---
### 4. Integration Tests
**Purpose**: Test cross-component interactions.
**Examples**:
- `test_kokoro_plugin.py` — Full Kokoro plugin integration
- `test_supertonic_plugin.py` — Full SuperTonic plugin integration
- `test_conversion_series.py` — End-to-end conversion pipeline
---
## Test Architecture
```
tests/
├── contracts/ # Contract tests (architectural compliance)
│ ├── conftest.py # Shared fixtures (FakeEngine, FakeSession)
│ ├── test_manifest_contract.py
│ ├── test_plugin_contract.py
│ ├── test_engine_contract.py
│ ├── test_session_contract.py
│ ├── test_capabilities_contract.py
│ ├── test_loader_contract.py
│ ├── test_plugin_manager_contract.py
│ ├── test_types_contract.py
│ ├── test_errors_contract.py
│ ├── test_host_context_contract.py
│ └── test_integration.py
├── test_behavioral_regression.py # Behavioral tests (public API)
├── test_kokoro_plugin.py # Kokoro integration
├── test_supertonic_plugin.py # SuperTonic integration
└── ... # Other unit/integration tests
```
---
## Adding Tests for a New Plugin
### Contract Tests (Required)
Create `tests/contracts/test_your_plugin.py`:
```python
"""Contract tests for YourPlugin — verifies architectural compliance."""
from pathlib import Path
from abogen.tts_plugin.loader import load_plugin_from_dir
from abogen.tts_plugin.manifest import PluginManifest
from abogen.tts_plugin.engine import Engine
def test_your_plugin_loads():
result = load_plugin_from_dir(Path("plugins/your_tts"))
assert result.success
assert isinstance(result.manifest, PluginManifest)
assert result.manifest.id == "your_tts"
assert callable(result.create_engine)
def test_your_plugin_creates_engine():
result = load_plugin_from_dir(Path("plugins/your_tts"))
# ... create HostContext, EngineConfig
engine = result.create_engine(ctx, None, EngineConfig(device="cpu"))
assert isinstance(engine, Engine)
engine.dispose()
def test_your_plugin_capabilities():
"""If plugin declares capabilities, verify they're implemented."""
result = load_plugin_from_dir(Path("plugins/your_tts"))
# Check VoiceLister, PreviewGenerator, etc.
...
```
### Behavioral Tests (Recommended)
Add parametrized tests to `tests/test_behavioral_regression.py`:
```python
# In _plugin_ids list, add your plugin
_plugin_ids = ["kokoro", "supertonic", "your_tts"]
_plugin_engines["your_tts"] = _YourMockEngine
_plugin_default_voices["your_tts"] = "voice1"
_plugin_all_voices["your_tts"] = ["voice1", "voice2"]
```
All existing behavioral tests will automatically run against your plugin.
---
## Continuous Integration
```yaml
# .github/workflows/test.yml
- name: Contract Tests
run: pytest tests/contracts/ -v
- name: Behavioral Tests
run: pytest tests/test_behavioral_regression.py -v
- name: Unit & Integration Tests
run: pytest tests/ -v --ignore=tests/test_behavioral_regression.py
```
---
## Test Design Principles
### Contract Tests
- **No mocks** for the system under test (test real plugin loading)
- **Strict assertions** on types and behavior
- **Document architecture** in test names and docstrings
- **Fail fast** on architectural violations
### Behavioral Tests
- **Only public API** (`create_pipeline`, `Engine`, `EngineSession`, `PluginManager`)
- **Parametrized** across plugins
- **Realistic scenarios** (long text, unicode, mixed scripts)
- **No implementation coupling** (test behavior, not internals)
### General
- **Fast**: Unit tests < 1s, Contract tests < 5s, Behavioral < 30s
- **Isolated**: No shared state between tests
- **Deterministic**: Same input → same output
- **Descriptive names**: `test_<component>_<scenario>_<expected>`