chore: normalize line endings to LF, add .gitattributes

- Add .gitattributes with text=auto eol=lf for all text files
- Renormalize all files in index to LF line endings
- Fixes massive whitespace-only diffs between main and feature branch
This commit is contained in:
Artem Akymenko
2026-07-12 16:20:42 +03:00
parent c85ea9d64f
commit d8fcfb1cce
60 changed files with 19745 additions and 19745 deletions
+63 -63
View File
@@ -1,63 +1,63 @@
name: Build multi-arch Docker Image name: Build multi-arch Docker Image
on: on:
# Build and push # Build and push
#release: #release:
# types: [published] # types: [published]
# Build only # Build only
#push: it #push: it
# branches: [main] # branches: [main]
# TODO - enable build on pull requests if build times can be reduced # TODO - enable build on pull requests if build times can be reduced
# pull_request: # pull_request:
workflow_dispatch: workflow_dispatch:
env: env:
IMAGE_REPOSITORY: ghcr.io/denizsafak/abogen IMAGE_REPOSITORY: ghcr.io/denizsafak/abogen
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
- name: Login to Github Container Registry - name: Login to Github Container Registry
# Only if we need to push an image # Only if we need to push an image
# if: ${{ github.event_name == 'release' && github.event.action == 'published' }} # if: ${{ github.event_name == 'release' && github.event.action == 'published' }}
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
# Setup for buildx # Setup for buildx
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
id: buildx id: buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
# Debugging information # Debugging information
- name: Docker info - name: Docker info
run: docker info run: docker info
- name: Buildx inspect - name: Buildx inspect
run: docker buildx inspect run: docker buildx inspect
# Build and (optionally) push the image # Build and (optionally) push the image
- name: Build image - name: Build image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: ./abogen context: ./abogen
file: ./abogen/Dockerfile file: ./abogen/Dockerfile
# platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x # platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
# platforms: linux/amd64,linux/arm64 # platforms: linux/amd64,linux/arm64
platforms: linux/amd64 # using the solution mentioned in https://github.com/denizsafak/abogen/issues/46 platforms: linux/amd64 # using the solution mentioned in https://github.com/denizsafak/abogen/issues/46
# Only push if we are publishing a release # Only push if we are publishing a release
# push: ${{ github.event_name == 'release' && github.event.action == 'published' }} # push: ${{ github.event_name == 'release' && github.event.action == 'published' }}
push: true push: true
# Use a 'temp' tag, that won't be pushed, for non-release builds # Use a 'temp' tag, that won't be pushed, for non-release builds
tags: ${{ env.IMAGE_REPOSITORY }}:${{ github.event.release.tag_name || 'latest' }} tags: ${{ env.IMAGE_REPOSITORY }}:${{ github.event.release.tag_name || 'latest' }}
# Use a cache to reduce build times # Use a cache to reduce build times
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
cache-from: type=gha cache-from: type=gha
+30 -30
View File
@@ -1,31 +1,31 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools --> <!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg height="800px" width="800px" version="1.1" id="_x32_" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" <svg height="800px" width="800px" version="1.1" id="_x32_" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 512 512" xml:space="preserve"> viewBox="0 0 512 512" xml:space="preserve">
<style type="text/css"> <style type="text/css">
.st0{fill:#808080;} .st0{fill:#808080;}
</style> </style>
<g> <g>
<path class="st0" d="M502.325,307.303l-39.006-30.805c-6.215-4.908-9.665-12.429-9.668-20.348c0-0.084,0-0.168,0-0.252 <path class="st0" d="M502.325,307.303l-39.006-30.805c-6.215-4.908-9.665-12.429-9.668-20.348c0-0.084,0-0.168,0-0.252
c-0.014-7.936,3.44-15.478,9.667-20.396l39.007-30.806c8.933-7.055,12.093-19.185,7.737-29.701l-17.134-41.366 c-0.014-7.936,3.44-15.478,9.667-20.396l39.007-30.806c8.933-7.055,12.093-19.185,7.737-29.701l-17.134-41.366
c-4.356-10.516-15.167-16.86-26.472-15.532l-49.366,5.8c-7.881,0.926-15.656-1.966-21.258-7.586 c-4.356-10.516-15.167-16.86-26.472-15.532l-49.366,5.8c-7.881,0.926-15.656-1.966-21.258-7.586
c-0.059-0.06-0.118-0.119-0.177-0.178c-5.597-5.602-8.476-13.36-7.552-21.225l5.799-49.363 c-0.059-0.06-0.118-0.119-0.177-0.178c-5.597-5.602-8.476-13.36-7.552-21.225l5.799-49.363
c1.328-11.305-5.015-22.116-15.531-26.472L337.004,1.939c-10.516-4.356-22.646-1.196-29.701,7.736l-30.805,39.005 c1.328-11.305-5.015-22.116-15.531-26.472L337.004,1.939c-10.516-4.356-22.646-1.196-29.701,7.736l-30.805,39.005
c-4.908,6.215-12.43,9.665-20.349,9.668c-0.084,0-0.168,0-0.252,0c-7.935,0.014-15.477-3.44-20.395-9.667L204.697,9.675 c-4.908,6.215-12.43,9.665-20.349,9.668c-0.084,0-0.168,0-0.252,0c-7.935,0.014-15.477-3.44-20.395-9.667L204.697,9.675
c-7.055-8.933-19.185-12.092-29.702-7.736L133.63,19.072c-10.516,4.356-16.86,15.167-15.532,26.473l5.799,49.366 c-7.055-8.933-19.185-12.092-29.702-7.736L133.63,19.072c-10.516,4.356-16.86,15.167-15.532,26.473l5.799,49.366
c0.926,7.881-1.964,15.656-7.585,21.257c-0.059,0.059-0.118,0.118-0.178,0.178c-5.602,5.598-13.36,8.477-21.226,7.552 c0.926,7.881-1.964,15.656-7.585,21.257c-0.059,0.059-0.118,0.118-0.178,0.178c-5.602,5.598-13.36,8.477-21.226,7.552
l-49.363-5.799c-11.305-1.328-22.116,5.015-26.472,15.531L1.939,174.996c-4.356,10.516-1.196,22.646,7.736,29.701l39.006,30.805 l-49.363-5.799c-11.305-1.328-22.116,5.015-26.472,15.531L1.939,174.996c-4.356,10.516-1.196,22.646,7.736,29.701l39.006,30.805
c6.215,4.908,9.665,12.429,9.668,20.348c0,0.084,0,0.167,0,0.251c0.014,7.935-3.44,15.477-9.667,20.395L9.675,307.303 c6.215,4.908,9.665,12.429,9.668,20.348c0,0.084,0,0.167,0,0.251c0.014,7.935-3.44,15.477-9.667,20.395L9.675,307.303
c-8.933,7.055-12.092,19.185-7.736,29.701l17.134,41.365c4.356,10.516,15.168,16.86,26.472,15.532l49.366-5.799 c-8.933,7.055-12.092,19.185-7.736,29.701l17.134,41.365c4.356,10.516,15.168,16.86,26.472,15.532l49.366-5.799
c7.882-0.926,15.656,1.965,21.258,7.586c0.059,0.059,0.118,0.119,0.178,0.178c5.597,5.603,8.476,13.36,7.552,21.226l-5.799,49.364 c7.882-0.926,15.656,1.965,21.258,7.586c0.059,0.059,0.118,0.119,0.178,0.178c5.597,5.603,8.476,13.36,7.552,21.226l-5.799,49.364
c-1.328,11.305,5.015,22.116,15.532,26.472l41.366,17.134c10.516,4.356,22.646,1.196,29.701-7.736l30.804-39.005 c-1.328,11.305,5.015,22.116,15.532,26.472l41.366,17.134c10.516,4.356,22.646,1.196,29.701-7.736l30.804-39.005
c4.908-6.215,12.43-9.665,20.348-9.669c0.084,0,0.168,0,0.251,0c7.936-0.014,15.478,3.44,20.396,9.667l30.806,39.007 c4.908-6.215,12.43-9.665,20.348-9.669c0.084,0,0.168,0,0.251,0c7.936-0.014,15.478,3.44,20.396,9.667l30.806,39.007
c7.055,8.933,19.185,12.093,29.701,7.736l41.366-17.134c10.516-4.356,16.86-15.168,15.532-26.472l-5.8-49.366 c7.055,8.933,19.185,12.093,29.701,7.736l41.366-17.134c10.516-4.356,16.86-15.168,15.532-26.472l-5.8-49.366
c-0.926-7.881,1.965-15.656,7.586-21.257c0.059-0.059,0.119-0.119,0.178-0.178c5.602-5.597,13.36-8.476,21.225-7.552l49.364,5.799 c-0.926-7.881,1.965-15.656,7.586-21.257c0.059-0.059,0.119-0.119,0.178-0.178c5.602-5.597,13.36-8.476,21.225-7.552l49.364,5.799
c11.305,1.328,22.117-5.015,26.472-15.531l17.134-41.365C514.418,326.488,511.258,314.358,502.325,307.303z M281.292,329.698 c11.305,1.328,22.117-5.015,26.472-15.531l17.134-41.365C514.418,326.488,511.258,314.358,502.325,307.303z M281.292,329.698
c-39.68,16.436-85.172-2.407-101.607-42.087c-16.436-39.68,2.407-85.171,42.087-101.608c39.68-16.436,85.172,2.407,101.608,42.088 c-39.68,16.436-85.172-2.407-101.607-42.087c-16.436-39.68,2.407-85.171,42.087-101.608c39.68-16.436,85.172,2.407,101.608,42.088
C339.815,267.771,320.972,313.262,281.292,329.698z"/> C339.815,267.771,320.972,313.262,281.292,329.698z"/>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

+591 -591
View File
File diff suppressed because it is too large Load Diff
+4304 -4304
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+584 -584
View File
File diff suppressed because it is too large Load Diff
+170 -170
View File
@@ -1,170 +1,170 @@
"""TTS Plugin Architecture - Public API. """TTS Plugin Architecture - Public API.
This package defines the frozen Plugin API for the TTS Plugin Architecture. This package defines the frozen Plugin API for the TTS Plugin Architecture.
All public interfaces are fully defined but contain no business logic. All public interfaces are fully defined but contain no business logic.
Public modules: Public modules:
- types: Core domain value objects (AudioFormat, Duration, VoiceSelection, etc.) - types: Core domain value objects (AudioFormat, Duration, VoiceSelection, etc.)
- errors: Error hierarchy (EngineError and subtypes) - errors: Error hierarchy (EngineError and subtypes)
- manifest: Plugin manifest types (PluginManifest, EngineManifest, etc.) - manifest: Plugin manifest types (PluginManifest, EngineManifest, etc.)
- engine: Engine and EngineSession protocols - engine: Engine and EngineSession protocols
- capabilities: Optional capability interfaces (VoiceLister, PreviewGenerator, etc.) - capabilities: Optional capability interfaces (VoiceLister, PreviewGenerator, etc.)
- host_context: HostContext dataclass - host_context: HostContext dataclass
- plugin: Plugin contract (create_engine function signature) - plugin: Plugin contract (create_engine function signature)
- loader: Plugin discovery and loading - loader: Plugin discovery and loading
- plugin_manager: Plugin management and engine creation - plugin_manager: Plugin management and engine creation
- utils: Direct utility functions (get_voices, create_pipeline, etc.) - utils: Direct utility functions (get_voices, create_pipeline, etc.)
Usage: Usage:
from abogen.tts_plugin import ( from abogen.tts_plugin import (
# Types # Types
AudioFormat, AudioFormat,
Duration, Duration,
VoiceSelection, VoiceSelection,
ParameterValues, ParameterValues,
SynthesisRequest, SynthesisRequest,
SynthesizedAudio, SynthesizedAudio,
EngineConfig, EngineConfig,
# Errors # Errors
EngineError, EngineError,
ModelNotFoundError, ModelNotFoundError,
ModelLoadError, ModelLoadError,
NetworkError, NetworkError,
InvalidInputError, InvalidInputError,
ConfigurationError, ConfigurationError,
CancelledError, CancelledError,
InternalError, InternalError,
# Manifest # Manifest
PluginManifest, PluginManifest,
EngineManifest, EngineManifest,
VoiceSourceManifest, VoiceSourceManifest,
VoiceManifest, VoiceManifest,
ParameterManifest, ParameterManifest,
AudioFormatManifest, AudioFormatManifest,
EnumOption, EnumOption,
RequirementManifest, RequirementManifest,
GpuRequirement, GpuRequirement,
ModelManifest, ModelManifest,
# Engine # Engine
Engine, Engine,
EngineSession, EngineSession,
# Capabilities # Capabilities
VoiceLister, VoiceLister,
PreviewGenerator, PreviewGenerator,
StreamingSynthesizer, StreamingSynthesizer,
CancelableSession, CancelableSession,
# Host Context # Host Context
HostContext, HostContext,
HttpClient, HttpClient,
# Plugin Manager # Plugin Manager
get_plugin_manager, get_plugin_manager,
reset_plugin_manager, reset_plugin_manager,
# Utils # Utils
get_voices, get_voices,
get_default_voice, get_default_voice,
is_plugin_registered, is_plugin_registered,
resolve_voice_to_plugin, resolve_voice_to_plugin,
create_pipeline, create_pipeline,
) )
""" """
from abogen.tts_plugin.capabilities import ( from abogen.tts_plugin.capabilities import (
CancelableSession, CancelableSession,
PreviewGenerator, PreviewGenerator,
StreamingSynthesizer, StreamingSynthesizer,
VoiceLister, VoiceLister,
) )
from abogen.tts_plugin.engine import Engine, EngineSession from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.errors import ( from abogen.tts_plugin.errors import (
CancelledError, CancelledError,
ConfigurationError, ConfigurationError,
EngineError, EngineError,
InternalError, InternalError,
InvalidInputError, InvalidInputError,
ModelLoadError, ModelLoadError,
ModelNotFoundError, ModelNotFoundError,
NetworkError, NetworkError,
) )
from abogen.tts_plugin.host_context import HttpClient, HostContext from abogen.tts_plugin.host_context import HttpClient, HostContext
from abogen.tts_plugin.manifest import ( from abogen.tts_plugin.manifest import (
AudioFormatManifest, AudioFormatManifest,
EngineManifest, EngineManifest,
EnumOption, EnumOption,
GpuRequirement, GpuRequirement,
ModelManifest, ModelManifest,
ParameterManifest, ParameterManifest,
PluginManifest, PluginManifest,
RequirementManifest, RequirementManifest,
VoiceManifest, VoiceManifest,
VoiceSourceManifest, VoiceSourceManifest,
) )
from abogen.tts_plugin.types import ( from abogen.tts_plugin.types import (
AudioFormat, AudioFormat,
Duration, Duration,
EngineConfig, EngineConfig,
ParameterValues, ParameterValues,
SynthesisRequest, SynthesisRequest,
SynthesizedAudio, SynthesizedAudio,
VoiceSelection, VoiceSelection,
) )
# Plugin Manager and Utils # Plugin Manager and Utils
from abogen.tts_plugin.plugin_manager import get_plugin_manager, reset_plugin_manager from abogen.tts_plugin.plugin_manager import get_plugin_manager, reset_plugin_manager
from abogen.tts_plugin.utils import ( from abogen.tts_plugin.utils import (
create_pipeline, create_pipeline,
get_default_voice, get_default_voice,
get_voices, get_voices,
is_plugin_registered, is_plugin_registered,
resolve_voice_to_plugin, resolve_voice_to_plugin,
) )
__all__ = [ __all__ = [
# Types # Types
"AudioFormat", "AudioFormat",
"Duration", "Duration",
"VoiceSelection", "VoiceSelection",
"ParameterValues", "ParameterValues",
"SynthesisRequest", "SynthesisRequest",
"SynthesizedAudio", "SynthesizedAudio",
"EngineConfig", "EngineConfig",
# Errors # Errors
"EngineError", "EngineError",
"ModelNotFoundError", "ModelNotFoundError",
"ModelLoadError", "ModelLoadError",
"NetworkError", "NetworkError",
"InvalidInputError", "InvalidInputError",
"ConfigurationError", "ConfigurationError",
"CancelledError", "CancelledError",
"InternalError", "InternalError",
# Manifest # Manifest
"PluginManifest", "PluginManifest",
"EngineManifest", "EngineManifest",
"VoiceSourceManifest", "VoiceSourceManifest",
"VoiceManifest", "VoiceManifest",
"ParameterManifest", "ParameterManifest",
"AudioFormatManifest", "AudioFormatManifest",
"EnumOption", "EnumOption",
"RequirementManifest", "RequirementManifest",
"GpuRequirement", "GpuRequirement",
"ModelManifest", "ModelManifest",
# Engine # Engine
"Engine", "Engine",
"EngineSession", "EngineSession",
# Capabilities # Capabilities
"VoiceLister", "VoiceLister",
"PreviewGenerator", "PreviewGenerator",
"StreamingSynthesizer", "StreamingSynthesizer",
"CancelableSession", "CancelableSession",
# Host Context # Host Context
"HostContext", "HostContext",
"HttpClient", "HttpClient",
# Plugin Manager # Plugin Manager
"get_plugin_manager", "get_plugin_manager",
"reset_plugin_manager", "reset_plugin_manager",
# Utils # Utils
"get_voices", "get_voices",
"get_default_voice", "get_default_voice",
"is_plugin_registered", "is_plugin_registered",
"resolve_voice_to_plugin", "resolve_voice_to_plugin",
"create_pipeline", "create_pipeline",
] ]
+103 -103
View File
@@ -1,103 +1,103 @@
"""Capability interfaces for the TTS Plugin Architecture. """Capability interfaces for the TTS Plugin Architecture.
This module defines optional capability interfaces that engines can implement. This module defines optional capability interfaces that engines can implement.
Capabilities are additive; implementing new capabilities doesn't break old plugins. Capabilities are additive; implementing new capabilities doesn't break old plugins.
""" """
from __future__ import annotations from __future__ import annotations
from typing import Iterator, Protocol, runtime_checkable from typing import Iterator, Protocol, runtime_checkable
from abogen.tts_plugin.manifest import VoiceManifest from abogen.tts_plugin.manifest import VoiceManifest
from abogen.tts_plugin.types import SynthesisRequest, SynthesizedAudio, VoiceSelection from abogen.tts_plugin.types import SynthesisRequest, SynthesizedAudio, VoiceSelection
@runtime_checkable @runtime_checkable
class VoiceLister(Protocol): class VoiceLister(Protocol):
"""Protocol for listing available voices. """Protocol for listing available voices.
Engines that support voice listing should implement this interface. Engines that support voice listing should implement this interface.
""" """
def listVoices(self, sourceId: str) -> list[VoiceManifest]: def listVoices(self, sourceId: str) -> list[VoiceManifest]:
"""List available voices for a given source. """List available voices for a given source.
Args: Args:
sourceId: The voice source identifier. sourceId: The voice source identifier.
Returns: Returns:
List of VoiceManifest describing available voices. List of VoiceManifest describing available voices.
Raises: Raises:
EngineError: On failure. EngineError: On failure.
""" """
... ...
@runtime_checkable @runtime_checkable
class PreviewGenerator(Protocol): class PreviewGenerator(Protocol):
"""Protocol for generating voice previews. """Protocol for generating voice previews.
Engines that support voice preview should implement this interface. Engines that support voice preview should implement this interface.
""" """
def generatePreview(self, voice: VoiceSelection, text: str) -> SynthesizedAudio: def generatePreview(self, voice: VoiceSelection, text: str) -> SynthesizedAudio:
"""Generate a preview audio for a voice. """Generate a preview audio for a voice.
Args: Args:
voice: Voice selection for the preview. voice: Voice selection for the preview.
text: Text to use for the preview. text: Text to use for the preview.
Returns: Returns:
SynthesizedAudio with the preview audio data. SynthesizedAudio with the preview audio data.
Raises: Raises:
EngineError: On failure. EngineError: On failure.
""" """
... ...
@runtime_checkable @runtime_checkable
class StreamingSynthesizer(Protocol): class StreamingSynthesizer(Protocol):
"""Protocol for streaming synthesis. """Protocol for streaming synthesis.
Optional capability of EngineSession, not Engine. Optional capability of EngineSession, not Engine.
Engines that support streaming synthesis should implement this interface. Engines that support streaming synthesis should implement this interface.
""" """
def synthesizeStream(self, request: SynthesisRequest) -> Iterator[bytes]: def synthesizeStream(self, request: SynthesisRequest) -> Iterator[bytes]:
"""Synthesize audio in streaming mode. """Synthesize audio in streaming mode.
Args: Args:
request: The synthesis request. request: The synthesis request.
Yields: Yields:
Audio chunks as they become available. Audio chunks as they become available.
Raises: Raises:
CancelledError: If cancel() is called during iteration. CancelledError: If cancel() is called during iteration.
EngineError: On synthesis failure. EngineError: On synthesis failure.
""" """
... ...
# This is a generator function; implementation will use yield # This is a generator function; implementation will use yield
yield b"" # pragma: no cover yield b"" # pragma: no cover
@runtime_checkable @runtime_checkable
class CancelableSession(Protocol): class CancelableSession(Protocol):
"""Protocol for cancellation support. """Protocol for cancellation support.
Optional capability for engines that support cancellation. Optional capability for engines that support cancellation.
cancel() causes synthesize() to raise CancelledError. cancel() causes synthesize() to raise CancelledError.
""" """
def cancel(self) -> None: def cancel(self) -> None:
"""Cancel in-progress synthesis. """Cancel in-progress synthesis.
After cancellation, synthesize() raises CancelledError. After cancellation, synthesize() raises CancelledError.
The session remains usable after cancellation. The session remains usable after cancellation.
Raises: Raises:
EngineError: If called after dispose(). EngineError: If called after dispose().
""" """
... ...
+95 -95
View File
@@ -1,95 +1,95 @@
"""Engine interfaces for the TTS Plugin Architecture. """Engine interfaces for the TTS Plugin Architecture.
This module defines the core Engine and EngineSession protocols. This module defines the core Engine and EngineSession protocols.
These are the primary interfaces that plugin implementations must satisfy. These are the primary interfaces that plugin implementations must satisfy.
""" """
from __future__ import annotations from __future__ import annotations
from typing import Protocol, runtime_checkable from typing import Protocol, runtime_checkable
from abogen.tts_plugin.types import SynthesisRequest, SynthesizedAudio from abogen.tts_plugin.types import SynthesisRequest, SynthesizedAudio
@runtime_checkable @runtime_checkable
class EngineSession(Protocol): class EngineSession(Protocol):
"""Protocol for a session that owns mutable execution state. """Protocol for a session that owns mutable execution state.
An EngineSession is created by Engine.createSession() and owns An EngineSession is created by Engine.createSession() and owns
mutable execution state isolated from other concurrent work. mutable execution state isolated from other concurrent work.
It is NOT thread-safe. It is NOT thread-safe.
Lifecycle: Lifecycle:
1. Created by Engine.createSession() 1. Created by Engine.createSession()
2. Used for synthesis via synthesize() 2. Used for synthesis via synthesize()
3. Disposed via dispose() 3. Disposed via dispose()
After dispose(), all methods except dispose() raise EngineError. After dispose(), all methods except dispose() raise EngineError.
""" """
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio: def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
"""Synthesize audio from text. """Synthesize audio from text.
Args: Args:
request: The synthesis request containing text, voice, parameters, and format. request: The synthesis request containing text, voice, parameters, and format.
Returns: Returns:
SynthesizedAudio with the synthesized audio data. SynthesizedAudio with the synthesized audio data.
Raises: Raises:
EngineError: On synthesis failure. Session remains usable after error. EngineError: On synthesis failure. Session remains usable after error.
EngineError: If called after dispose(). EngineError: If called after dispose().
""" """
... ...
def dispose(self) -> None: def dispose(self) -> None:
"""Release session resources. """Release session resources.
This method is idempotent and safe to call multiple times. This method is idempotent and safe to call multiple times.
It never raises exceptions (catches and logs internally). It never raises exceptions (catches and logs internally).
After dispose(), all methods except dispose() raise EngineError. After dispose(), all methods except dispose() raise EngineError.
""" """
... ...
@runtime_checkable @runtime_checkable
class Engine(Protocol): class Engine(Protocol):
"""Protocol for a TTS engine that creates sessions. """Protocol for a TTS engine that creates sessions.
An Engine is a factory for EngineSession instances. It is stateless An Engine is a factory for EngineSession instances. It is stateless
and thread-safe for createSession(). and thread-safe for createSession().
Lifecycle: Lifecycle:
1. Created via create_engine() (plugin contract) 1. Created via create_engine() (plugin contract)
2. Sessions created via createSession() 2. Sessions created via createSession()
3. Disposed via dispose() 3. Disposed via dispose()
Thread Safety: Thread Safety:
- createSession() is thread-safe and can be called from any thread. - createSession() is thread-safe and can be called from any thread.
- dispose() must be called after all sessions are disposed. - dispose() must be called after all sessions are disposed.
- Disposing engine while sessions are alive violates API contract. - Disposing engine while sessions are alive violates API contract.
""" """
def createSession(self) -> EngineSession: def createSession(self) -> EngineSession:
"""Create a new session for synthesis. """Create a new session for synthesis.
Returns: Returns:
A new EngineSession instance. Ownership transfers to caller. A new EngineSession instance. Ownership transfers to caller.
Raises: Raises:
EngineError: On failure. No partially initialized session is returned. EngineError: On failure. No partially initialized session is returned.
""" """
... ...
def dispose(self) -> None: def dispose(self) -> None:
"""Release engine resources. """Release engine resources.
Caller must ensure all sessions created by this engine are disposed Caller must ensure all sessions created by this engine are disposed
before calling dispose(). Disposing an engine while any session is before calling dispose(). Disposing an engine while any session is
still alive violates the API contract; behavior is undefined. still alive violates the API contract; behavior is undefined.
This method is idempotent and safe to call multiple times. This method is idempotent and safe to call multiple times.
It never raises exceptions (catches and logs internally). It never raises exceptions (catches and logs internally).
After dispose(), all methods except dispose() raise EngineError. After dispose(), all methods except dispose() raise EngineError.
""" """
... ...
+62 -62
View File
@@ -1,62 +1,62 @@
"""Error hierarchy for the TTS Plugin Architecture. """Error hierarchy for the TTS Plugin Architecture.
This module defines typed exceptions that engines raise. This module defines typed exceptions that engines raise.
Engines should never raise raw exceptions; they must use EngineError or its subtypes. Engines should never raise raw exceptions; they must use EngineError or its subtypes.
""" """
from __future__ import annotations from __future__ import annotations
class EngineError(Exception): class EngineError(Exception):
"""Base exception for all engine errors. """Base exception for all engine errors.
All engine operations that can fail should raise EngineError or one of its subtypes. All engine operations that can fail should raise EngineError or one of its subtypes.
After dispose(), all methods except dispose() raise EngineError. After dispose(), all methods except dispose() raise EngineError.
""" """
pass pass
class ModelNotFoundError(EngineError): class ModelNotFoundError(EngineError):
"""Raised when a required model is not found.""" """Raised when a required model is not found."""
pass pass
class ModelLoadError(EngineError): class ModelLoadError(EngineError):
"""Raised when a model fails to load.""" """Raised when a model fails to load."""
pass pass
class NetworkError(EngineError): class NetworkError(EngineError):
"""Raised when a network operation fails.""" """Raised when a network operation fails."""
pass pass
class InvalidInputError(EngineError): class InvalidInputError(EngineError):
"""Raised when invalid input is provided to the engine.""" """Raised when invalid input is provided to the engine."""
pass pass
class ConfigurationError(EngineError): class ConfigurationError(EngineError):
"""Raised when there is a configuration error.""" """Raised when there is a configuration error."""
pass pass
class CancelledError(EngineError): class CancelledError(EngineError):
"""Raised when an operation is cancelled. """Raised when an operation is cancelled.
This is raised by synthesize() when cancel() is called during synthesis. This is raised by synthesize() when cancel() is called during synthesis.
""" """
pass pass
class InternalError(EngineError): class InternalError(EngineError):
"""Raised when an internal engine error occurs.""" """Raised when an internal engine error occurs."""
pass pass
+46 -46
View File
@@ -1,46 +1,46 @@
"""Host context for the TTS Plugin Architecture. """Host context for the TTS Plugin Architecture.
This module defines the HostContext dataclass that provides minimal This module defines the HostContext dataclass that provides minimal
host services to plugins. It is the only interface through which host services to plugins. It is the only interface through which
plugins can access host functionality. plugins can access host functionality.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Protocol, runtime_checkable from typing import Protocol, runtime_checkable
@runtime_checkable @runtime_checkable
class HttpClient(Protocol): class HttpClient(Protocol):
"""Protocol for HTTP client provided by host. """Protocol for HTTP client provided by host.
Plugins can use this for network requests (e.g., API-based engines). Plugins can use this for network requests (e.g., API-based engines).
""" """
def get(self, url: str, **kwargs: object) -> object: def get(self, url: str, **kwargs: object) -> object:
"""Perform an HTTP GET request.""" """Perform an HTTP GET request."""
... ...
def post(self, url: str, **kwargs: object) -> object: def post(self, url: str, **kwargs: object) -> object:
"""Perform an HTTP POST request.""" """Perform an HTTP POST request."""
... ...
@dataclass(frozen=True) @dataclass(frozen=True)
class HostContext: class HostContext:
"""Minimal host context provided to plugins. """Minimal host context provided to plugins.
Contains only essential host services. No business logic. Contains only essential host services. No business logic.
Attributes: Attributes:
config_dir: Directory for API keys, preferences, and configuration. config_dir: Directory for API keys, preferences, and configuration.
logger: Logger for plugin logging. logger: Logger for plugin logging.
http_client: HTTP client for network requests. http_client: HTTP client for network requests.
""" """
config_dir: Path config_dir: Path
logger: logging.Logger logger: logging.Logger
http_client: HttpClient http_client: HttpClient
+365 -365
View File
@@ -1,365 +1,365 @@
"""Plugin loader infrastructure for the TTS Plugin Architecture. """Plugin loader infrastructure for the TTS Plugin Architecture.
This module provides functionality to discover, import, validate, and load This module provides functionality to discover, import, validate, and load
TTS plugins. It handles both valid and invalid plugins, providing diagnostic TTS plugins. It handles both valid and invalid plugins, providing diagnostic
messages for errors. messages for errors.
The loader does NOT: The loader does NOT:
- Create Engine instances (that's the plugin's create_engine() responsibility) - Create Engine instances (that's the plugin's create_engine() responsibility)
- Manage plugin lifecycle (that's the Plugin Manager's responsibility) - Manage plugin lifecycle (that's the Plugin Manager's responsibility)
- Implement any TTS engine functionality - Implement any TTS engine functionality
""" """
from __future__ import annotations from __future__ import annotations
import importlib import importlib
import re import re
import sys import sys
import types import types
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any, Callable from typing import Any, Callable
from abogen.tts_plugin.manifest import ModelManifest, PluginManifest from abogen.tts_plugin.manifest import ModelManifest, PluginManifest
# Host API version for compatibility checking # Host API version for compatibility checking
HOST_API_VERSION = "1.0" HOST_API_VERSION = "1.0"
@dataclass(frozen=True) @dataclass(frozen=True)
class PluginLoadError: class PluginLoadError:
"""Diagnostic information for a failed plugin load. """Diagnostic information for a failed plugin load.
Attributes: Attributes:
plugin_id: Plugin identifier if available, otherwise directory name. plugin_id: Plugin identifier if available, otherwise directory name.
path: Path to the plugin directory. path: Path to the plugin directory.
errors: List of error messages describing what went wrong. errors: List of error messages describing what went wrong.
""" """
plugin_id: str plugin_id: str
path: Path path: Path
errors: tuple[str, ...] = field(default_factory=tuple) errors: tuple[str, ...] = field(default_factory=tuple)
@dataclass(frozen=True) @dataclass(frozen=True)
class PluginLoadResult: class PluginLoadResult:
"""Result of loading a plugin. """Result of loading a plugin.
Attributes: Attributes:
success: Whether the plugin loaded successfully. success: Whether the plugin loaded successfully.
manifest: The plugin manifest if successful. manifest: The plugin manifest if successful.
model_requirements: Model requirements if successful. model_requirements: Model requirements if successful.
create_engine: The create_engine function if successful. create_engine: The create_engine function if successful.
module: The plugin module if successful. module: The plugin module if successful.
error: Error information if failed. error: Error information if failed.
""" """
success: bool success: bool
manifest: PluginManifest | None = None manifest: PluginManifest | None = None
model_requirements: tuple[ModelManifest, ...] | None = None model_requirements: tuple[ModelManifest, ...] | None = None
create_engine: Callable[..., Any] | None = None create_engine: Callable[..., Any] | None = None
module: types.ModuleType | None = None module: types.ModuleType | None = None
error: PluginLoadError | None = None error: PluginLoadError | None = None
def _parse_api_version(version: str) -> tuple[int, int] | None: def _parse_api_version(version: str) -> tuple[int, int] | None:
"""Parse an api_version string into (major, minor) tuple. """Parse an api_version string into (major, minor) tuple.
Args: Args:
version: Version string in format "MAJOR.MINOR". version: Version string in format "MAJOR.MINOR".
Returns: Returns:
Tuple of (major, minor) or None if invalid format. Tuple of (major, minor) or None if invalid format.
""" """
match = re.match(r"^(\d+)\.(\d+)$", version) match = re.match(r"^(\d+)\.(\d+)$", version)
if match: if match:
return int(match.group(1)), int(match.group(2)) return int(match.group(1)), int(match.group(2))
return None return None
def _check_api_version_compatibility(plugin_version: str) -> str | None: def _check_api_version_compatibility(plugin_version: str) -> str | None:
"""Check if plugin api_version is compatible with host. """Check if plugin api_version is compatible with host.
Architecture spec: Architecture spec:
- Format: semver (MAJOR.MINOR) - Format: semver (MAJOR.MINOR)
- Compatibility: Host rejects plugin if major version differs - Compatibility: Host rejects plugin if major version differs
- Minor version: backward compatible, Host accepts higher minor - Minor version: backward compatible, Host accepts higher minor
Args: Args:
plugin_version: Plugin's api_version string. plugin_version: Plugin's api_version string.
Returns: Returns:
Error message if incompatible, None if compatible. Error message if incompatible, None if compatible.
""" """
plugin_ver = _parse_api_version(plugin_version) plugin_ver = _parse_api_version(plugin_version)
if plugin_ver is None: if plugin_ver is None:
return f"Invalid api_version format: '{plugin_version}'. Expected format: MAJOR.MINOR" return f"Invalid api_version format: '{plugin_version}'. Expected format: MAJOR.MINOR"
host_ver = _parse_api_version(HOST_API_VERSION) host_ver = _parse_api_version(HOST_API_VERSION)
if host_ver is None: if host_ver is None:
return f"Invalid host api_version format: '{HOST_API_VERSION}'" return f"Invalid host api_version format: '{HOST_API_VERSION}'"
if plugin_ver[0] != host_ver[0]: if plugin_ver[0] != host_ver[0]:
return ( return (
f"api_version major mismatch: plugin={plugin_ver[0]}, host={host_ver[0]}. " f"api_version major mismatch: plugin={plugin_ver[0]}, host={host_ver[0]}. "
f"Major version must match for compatibility." f"Major version must match for compatibility."
) )
return None return None
def _validate_manifest(module: types.ModuleType, plugin_dir: Path) -> list[str]: def _validate_manifest(module: types.ModuleType, plugin_dir: Path) -> list[str]:
"""Validate that a plugin module has required exports. """Validate that a plugin module has required exports.
Args: Args:
module: The imported plugin module. module: The imported plugin module.
plugin_dir: Path to the plugin directory. plugin_dir: Path to the plugin directory.
Returns: Returns:
List of error messages (empty if valid). List of error messages (empty if valid).
""" """
errors: list[str] = [] errors: list[str] = []
# Check PLUGIN_MANIFEST # Check PLUGIN_MANIFEST
manifest = getattr(module, "PLUGIN_MANIFEST", None) manifest = getattr(module, "PLUGIN_MANIFEST", None)
if manifest is None: if manifest is None:
errors.append("Missing PLUGIN_MANIFEST export") errors.append("Missing PLUGIN_MANIFEST export")
elif not isinstance(manifest, PluginManifest): elif not isinstance(manifest, PluginManifest):
errors.append( errors.append(
f"PLUGIN_MANIFEST must be a PluginManifest instance, " f"PLUGIN_MANIFEST must be a PluginManifest instance, "
f"got {type(manifest).__name__}" f"got {type(manifest).__name__}"
) )
# Check MODEL_REQUIREMENTS # Check MODEL_REQUIREMENTS
model_reqs = getattr(module, "MODEL_REQUIREMENTS", None) model_reqs = getattr(module, "MODEL_REQUIREMENTS", None)
if model_reqs is None: if model_reqs is None:
errors.append("Missing MODEL_REQUIREMENTS export") errors.append("Missing MODEL_REQUIREMENTS export")
elif not isinstance(model_reqs, list): elif not isinstance(model_reqs, list):
errors.append( errors.append(
f"MODEL_REQUIREMENTS must be a list, got {type(model_reqs).__name__}" f"MODEL_REQUIREMENTS must be a list, got {type(model_reqs).__name__}"
) )
else: else:
for i, req in enumerate(model_reqs): for i, req in enumerate(model_reqs):
if not isinstance(req, ModelManifest): if not isinstance(req, ModelManifest):
errors.append( errors.append(
f"MODEL_REQUIREMENTS[{i}] must be a ModelManifest instance, " f"MODEL_REQUIREMENTS[{i}] must be a ModelManifest instance, "
f"got {type(req).__name__}" f"got {type(req).__name__}"
) )
# Check create_engine # Check create_engine
create_engine = getattr(module, "create_engine", None) create_engine = getattr(module, "create_engine", None)
if create_engine is None: if create_engine is None:
errors.append("Missing create_engine export") errors.append("Missing create_engine export")
elif not callable(create_engine): elif not callable(create_engine):
errors.append( errors.append(
f"create_engine must be callable, got {type(create_engine).__name__}" f"create_engine must be callable, got {type(create_engine).__name__}"
) )
return errors return errors
def _validate_capabilities(manifest: PluginManifest) -> list[str]: def _validate_capabilities(manifest: PluginManifest) -> list[str]:
"""Validate plugin capabilities. """Validate plugin capabilities.
Args: Args:
manifest: The plugin manifest to validate. manifest: The plugin manifest to validate.
Returns: Returns:
List of error messages (empty if valid). List of error messages (empty if valid).
""" """
errors: list[str] = [] errors: list[str] = []
# Known capabilities (can be extended) # Known capabilities (can be extended)
known_capabilities = frozenset({ known_capabilities = frozenset({
"voice_list", "voice_list",
"preview", "preview",
"voice_clone", "voice_clone",
"voice_blend", "voice_blend",
"streaming", "streaming",
"cancel", "cancel",
}) })
for cap in manifest.capabilities: for cap in manifest.capabilities:
if cap not in known_capabilities: if cap not in known_capabilities:
errors.append(f"Unknown capability: '{cap}'") errors.append(f"Unknown capability: '{cap}'")
return errors return errors
def _validate_api_version(manifest: PluginManifest) -> list[str]: def _validate_api_version(manifest: PluginManifest) -> list[str]:
"""Validate api_version compatibility. """Validate api_version compatibility.
Args: Args:
manifest: The plugin manifest to validate. manifest: The plugin manifest to validate.
Returns: Returns:
List of error messages (empty if valid). List of error messages (empty if valid).
""" """
errors: list[str] = [] errors: list[str] = []
error = _check_api_version_compatibility(manifest.api_version) error = _check_api_version_compatibility(manifest.api_version)
if error: if error:
errors.append(error) errors.append(error)
return errors return errors
def load_plugin_from_dir(plugin_dir: Path) -> PluginLoadResult: def load_plugin_from_dir(plugin_dir: Path) -> PluginLoadResult:
"""Load and validate a plugin from a directory. """Load and validate a plugin from a directory.
The plugin directory must contain an __init__.py that exports: The plugin directory must contain an __init__.py that exports:
- PLUGIN_MANIFEST: PluginManifest - PLUGIN_MANIFEST: PluginManifest
- MODEL_REQUIREMENTS: list[ModelManifest] - MODEL_REQUIREMENTS: list[ModelManifest]
- create_engine: Callable - create_engine: Callable
Args: Args:
plugin_dir: Path to the plugin directory. plugin_dir: Path to the plugin directory.
Returns: Returns:
PluginLoadResult with success status and either plugin data or error info. PluginLoadResult with success status and either plugin data or error info.
""" """
plugin_id = plugin_dir.name plugin_id = plugin_dir.name
errors: list[str] = [] errors: list[str] = []
# Check if directory exists # Check if directory exists
if not plugin_dir.exists(): if not plugin_dir.exists():
return PluginLoadResult( return PluginLoadResult(
success=False, success=False,
error=PluginLoadError( error=PluginLoadError(
plugin_id=plugin_id, plugin_id=plugin_id,
path=plugin_dir, path=plugin_dir,
errors=(f"Plugin directory does not exist: {plugin_dir}",), errors=(f"Plugin directory does not exist: {plugin_dir}",),
), ),
) )
# Check for __init__.py # Check for __init__.py
init_file = plugin_dir / "__init__.py" init_file = plugin_dir / "__init__.py"
if not init_file.exists(): if not init_file.exists():
return PluginLoadResult( return PluginLoadResult(
success=False, success=False,
error=PluginLoadError( error=PluginLoadError(
plugin_id=plugin_id, plugin_id=plugin_id,
path=plugin_dir, path=plugin_dir,
errors=("Missing __init__.py in plugin directory",), errors=("Missing __init__.py in plugin directory",),
), ),
) )
# Import the module # Import the module
module_name = f"abogen.tts_plugin._loaded.{plugin_id}" module_name = f"abogen.tts_plugin._loaded.{plugin_id}"
try: try:
# Remove from cache if already imported (for testing) # Remove from cache if already imported (for testing)
if module_name in sys.modules: if module_name in sys.modules:
del sys.modules[module_name] del sys.modules[module_name]
spec = importlib.util.spec_from_file_location( spec = importlib.util.spec_from_file_location(
module_name, init_file, submodule_search_locations=[] module_name, init_file, submodule_search_locations=[]
) )
if spec is None or spec.loader is None: if spec is None or spec.loader is None:
return PluginLoadResult( return PluginLoadResult(
success=False, success=False,
error=PluginLoadError( error=PluginLoadError(
plugin_id=plugin_id, plugin_id=plugin_id,
path=plugin_dir, path=plugin_dir,
errors=(f"Failed to create module spec for {init_file}",), errors=(f"Failed to create module spec for {init_file}",),
), ),
) )
module = importlib.util.module_from_spec(spec) module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module sys.modules[module_name] = module
spec.loader.exec_module(module) spec.loader.exec_module(module)
except Exception as e: except Exception as e:
# Clean up module from sys.modules on import failure # Clean up module from sys.modules on import failure
if module_name in sys.modules: if module_name in sys.modules:
del sys.modules[module_name] del sys.modules[module_name]
return PluginLoadResult( return PluginLoadResult(
success=False, success=False,
error=PluginLoadError( error=PluginLoadError(
plugin_id=plugin_id, plugin_id=plugin_id,
path=plugin_dir, path=plugin_dir,
errors=(f"Failed to import plugin module: {e}",), errors=(f"Failed to import plugin module: {e}",),
), ),
) )
# Validate manifest # Validate manifest
manifest_errors = _validate_manifest(module, plugin_dir) manifest_errors = _validate_manifest(module, plugin_dir)
errors.extend(manifest_errors) errors.extend(manifest_errors)
# If manifest is valid, perform additional validation # If manifest is valid, perform additional validation
manifest = getattr(module, "PLUGIN_MANIFEST", None) manifest = getattr(module, "PLUGIN_MANIFEST", None)
if isinstance(manifest, PluginManifest): if isinstance(manifest, PluginManifest):
# Validate api_version # Validate api_version
api_errors = _validate_api_version(manifest) api_errors = _validate_api_version(manifest)
errors.extend(api_errors) errors.extend(api_errors)
# Validate capabilities # Validate capabilities
cap_errors = _validate_capabilities(manifest) cap_errors = _validate_capabilities(manifest)
errors.extend(cap_errors) errors.extend(cap_errors)
# Use manifest id if available # Use manifest id if available
plugin_id = manifest.id plugin_id = manifest.id
# Check if any errors occurred # Check if any errors occurred
if errors: if errors:
# Clean up module from sys.modules # Clean up module from sys.modules
if module_name in sys.modules: if module_name in sys.modules:
del sys.modules[module_name] del sys.modules[module_name]
return PluginLoadResult( return PluginLoadResult(
success=False, success=False,
error=PluginLoadError( error=PluginLoadError(
plugin_id=plugin_id, plugin_id=plugin_id,
path=plugin_dir, path=plugin_dir,
errors=tuple(errors), errors=tuple(errors),
), ),
) )
# Get MODEL_REQUIREMENTS # Get MODEL_REQUIREMENTS
model_requirements = tuple(getattr(module, "MODEL_REQUIREMENTS", [])) model_requirements = tuple(getattr(module, "MODEL_REQUIREMENTS", []))
create_engine = getattr(module, "create_engine", None) create_engine = getattr(module, "create_engine", None)
return PluginLoadResult( return PluginLoadResult(
success=True, success=True,
manifest=manifest, manifest=manifest,
model_requirements=model_requirements, model_requirements=model_requirements,
create_engine=create_engine, create_engine=create_engine,
module=module, module=module,
) )
def discover_plugins(plugin_dirs: list[Path]) -> list[PluginLoadResult]: def discover_plugins(plugin_dirs: list[Path]) -> list[PluginLoadResult]:
"""Discover and load plugins from multiple directories. """Discover and load plugins from multiple directories.
Args: Args:
plugin_dirs: List of directories to scan for plugins. plugin_dirs: List of directories to scan for plugins.
Returns: Returns:
List of PluginLoadResult, one per plugin directory found. List of PluginLoadResult, one per plugin directory found.
""" """
results: list[PluginLoadResult] = [] results: list[PluginLoadResult] = []
for plugin_dir in plugin_dirs: for plugin_dir in plugin_dirs:
if not plugin_dir.exists(): if not plugin_dir.exists():
continue continue
# Scan for subdirectories (each is a potential plugin) # Scan for subdirectories (each is a potential plugin)
for item in sorted(plugin_dir.iterdir()): for item in sorted(plugin_dir.iterdir()):
if item.is_dir() and not item.name.startswith("."): if item.is_dir() and not item.name.startswith("."):
result = load_plugin_from_dir(item) result = load_plugin_from_dir(item)
results.append(result) results.append(result)
return results return results
def load_plugin( def load_plugin(
plugin_dir: Path, plugin_dir: Path,
) -> PluginLoadResult: ) -> PluginLoadResult:
"""Load a single plugin from a directory. """Load a single plugin from a directory.
This is the main entry point for loading a plugin. This is the main entry point for loading a plugin.
Args: Args:
plugin_dir: Path to the plugin directory. plugin_dir: Path to the plugin directory.
Returns: Returns:
PluginLoadResult with success status and either plugin data or error info. PluginLoadResult with success status and either plugin data or error info.
""" """
return load_plugin_from_dir(plugin_dir) return load_plugin_from_dir(plugin_dir)
+189 -189
View File
@@ -1,189 +1,189 @@
"""Plugin manifest types for the TTS Plugin Architecture. """Plugin manifest types for the TTS Plugin Architecture.
This module contains static metadata types that describe plugins. This module contains static metadata types that describe plugins.
These types have no dependencies and are immutable. These types have no dependencies and are immutable.
""" """
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
@dataclass(frozen=True) @dataclass(frozen=True)
class AudioFormatManifest: class AudioFormatManifest:
"""Manifest describing an audio format. """Manifest describing an audio format.
Attributes: Attributes:
mime: MIME type of the audio. mime: MIME type of the audio.
extension: File extension. extension: File extension.
""" """
mime: str mime: str
extension: str extension: str
@dataclass(frozen=True) @dataclass(frozen=True)
class EnumOption: class EnumOption:
"""Manifest describing an enum option for a parameter. """Manifest describing an enum option for a parameter.
Attributes: Attributes:
value: The enum value. value: The enum value.
label: Human-readable label. label: Human-readable label.
""" """
value: str value: str
label: str label: str
@dataclass(frozen=True) @dataclass(frozen=True)
class ParameterManifest: class ParameterManifest:
"""Manifest describing a synthesis parameter. """Manifest describing a synthesis parameter.
Attributes: Attributes:
id: Parameter identifier. id: Parameter identifier.
name: Human-readable name. name: Human-readable name.
description: Parameter description. description: Parameter description.
type: Parameter type ("float", "int", "string", "boolean", "enum"). type: Parameter type ("float", "int", "string", "boolean", "enum").
default: Default value. default: Default value.
min: Minimum value (optional, for numeric types). min: Minimum value (optional, for numeric types).
max: Maximum value (optional, for numeric types). max: Maximum value (optional, for numeric types).
step: Step size (optional, for numeric types). step: Step size (optional, for numeric types).
options: Available options (optional, for enum type). options: Available options (optional, for enum type).
unit: Unit of measurement (optional). unit: Unit of measurement (optional).
group: Parameter group (optional). group: Parameter group (optional).
""" """
id: str id: str
name: str name: str
description: str description: str
type: str type: str
default: Any default: Any
min: float | None = None min: float | None = None
max: float | None = None max: float | None = None
step: float | None = None step: float | None = None
options: tuple[EnumOption, ...] = field(default_factory=tuple) options: tuple[EnumOption, ...] = field(default_factory=tuple)
unit: str | None = None unit: str | None = None
group: str | None = None group: str | None = None
@dataclass(frozen=True) @dataclass(frozen=True)
class VoiceManifest: class VoiceManifest:
"""Manifest describing a voice. """Manifest describing a voice.
Attributes: Attributes:
id: Voice identifier. id: Voice identifier.
name: Human-readable name. name: Human-readable name.
tags: Voice tags (e.g., language, style). tags: Voice tags (e.g., language, style).
""" """
id: str id: str
name: str name: str
tags: tuple[str, ...] = field(default_factory=tuple) tags: tuple[str, ...] = field(default_factory=tuple)
@dataclass(frozen=True) @dataclass(frozen=True)
class VoiceSourceManifest: class VoiceSourceManifest:
"""Manifest describing a voice source. """Manifest describing a voice source.
Attributes: Attributes:
id: Voice source identifier. id: Voice source identifier.
name: Human-readable name. name: Human-readable name.
type: Source type ("list", "speaker_id", "clone", "blend", "generate", "none"). type: Source type ("list", "speaker_id", "clone", "blend", "generate", "none").
config: Source-specific configuration. config: Source-specific configuration.
""" """
id: str id: str
name: str name: str
type: str type: str
config: Any = None config: Any = None
@dataclass(frozen=True) @dataclass(frozen=True)
class EngineManifest: class EngineManifest:
"""Manifest describing engine capabilities. """Manifest describing engine capabilities.
Attributes: Attributes:
voiceSources: Available voice sources. voiceSources: Available voice sources.
parameters: Available synthesis parameters. parameters: Available synthesis parameters.
audioFormats: Supported audio formats. audioFormats: Supported audio formats.
""" """
voiceSources: tuple[VoiceSourceManifest, ...] = field(default_factory=tuple) voiceSources: tuple[VoiceSourceManifest, ...] = field(default_factory=tuple)
parameters: tuple[ParameterManifest, ...] = field(default_factory=tuple) parameters: tuple[ParameterManifest, ...] = field(default_factory=tuple)
audioFormats: tuple[AudioFormatManifest, ...] = field(default_factory=tuple) audioFormats: tuple[AudioFormatManifest, ...] = field(default_factory=tuple)
@dataclass(frozen=True) @dataclass(frozen=True)
class GpuRequirement: class GpuRequirement:
"""Manifest describing GPU requirements. """Manifest describing GPU requirements.
Attributes: Attributes:
required: Whether GPU is required. required: Whether GPU is required.
type: GPU type (e.g., "cuda", "rocm"). type: GPU type (e.g., "cuda", "rocm").
memory: Required GPU memory in GB. memory: Required GPU memory in GB.
""" """
required: bool = False required: bool = False
type: str | None = None type: str | None = None
memory: float | None = None memory: float | None = None
@dataclass(frozen=True) @dataclass(frozen=True)
class RequirementManifest: class RequirementManifest:
"""Manifest describing plugin requirements. """Manifest describing plugin requirements.
Attributes: Attributes:
gpu: GPU requirements (optional). gpu: GPU requirements (optional).
memory: Required RAM in GB (optional). memory: Required RAM in GB (optional).
internet: Whether internet is required (optional). internet: Whether internet is required (optional).
""" """
gpu: GpuRequirement | None = None gpu: GpuRequirement | None = None
memory: float | None = None memory: float | None = None
internet: bool | None = None internet: bool | None = None
@dataclass(frozen=True) @dataclass(frozen=True)
class ModelManifest: class ModelManifest:
"""Manifest describing a model requirement. """Manifest describing a model requirement.
Attributes: Attributes:
id: Model identifier. id: Model identifier.
name: Human-readable name. name: Human-readable name.
size: Model size as string (e.g., "100MB", "2GB"). size: Model size as string (e.g., "100MB", "2GB").
""" """
id: str id: str
name: str name: str
size: str size: str
@dataclass(frozen=True) @dataclass(frozen=True)
class PluginManifest: class PluginManifest:
"""Main manifest for a TTS plugin. """Main manifest for a TTS plugin.
Attributes: Attributes:
id: Plugin identifier (unique). id: Plugin identifier (unique).
name: Human-readable name. name: Human-readable name.
version: Plugin version. version: Plugin version.
api_version: API version (semver format: MAJOR.MINOR). api_version: API version (semver format: MAJOR.MINOR).
description: Plugin description. description: Plugin description.
author: Plugin author. author: Plugin author.
capabilities: List of capability identifiers. capabilities: List of capability identifiers.
requires: Plugin requirements. requires: Plugin requirements.
engine: Engine manifest. engine: Engine manifest.
voices: Optional static voice catalog. None = not declared (use VoiceLister), voices: Optional static voice catalog. None = not declared (use VoiceLister),
empty tuple = explicitly no static voices, non-empty = static catalog. empty tuple = explicitly no static voices, non-empty = static catalog.
""" """
id: str id: str
name: str name: str
version: str version: str
api_version: str api_version: str
description: str description: str
author: str author: str
capabilities: tuple[str, ...] = field(default_factory=tuple) capabilities: tuple[str, ...] = field(default_factory=tuple)
requires: RequirementManifest = field(default_factory=RequirementManifest) requires: RequirementManifest = field(default_factory=RequirementManifest)
engine: EngineManifest = field(default_factory=EngineManifest) engine: EngineManifest = field(default_factory=EngineManifest)
voices: tuple[VoiceManifest, ...] | None = None voices: tuple[VoiceManifest, ...] | None = None
+55 -55
View File
@@ -1,55 +1,55 @@
"""Plugin contract for the TTS Plugin Architecture. """Plugin contract for the TTS Plugin Architecture.
This module defines the plugin contract that all TTS plugins must implement. This module defines the plugin contract that all TTS plugins must implement.
Each plugin must export: Each plugin must export:
- PLUGIN_MANIFEST: PluginManifest instance - PLUGIN_MANIFEST: PluginManifest instance
- MODEL_REQUIREMENTS: list of ModelManifest instances - MODEL_REQUIREMENTS: list of ModelManifest instances
- create_engine(): Factory function that creates an Engine - create_engine(): Factory function that creates an Engine
The create_engine() function is the entry point for plugin activation. The create_engine() function is the entry point for plugin activation.
It must be atomic: succeed fully or raise and clean up. It must be atomic: succeed fully or raise and clean up.
""" """
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Protocol, runtime_checkable from typing import Protocol, runtime_checkable
from abogen.tts_plugin.engine import Engine from abogen.tts_plugin.engine import Engine
from abogen.tts_plugin.host_context import HostContext from abogen.tts_plugin.host_context import HostContext
from abogen.tts_plugin.types import EngineConfig from abogen.tts_plugin.types import EngineConfig
@runtime_checkable @runtime_checkable
class Plugin(Protocol): class Plugin(Protocol):
"""Protocol defining the plugin contract. """Protocol defining the plugin contract.
Every TTS plugin must implement this protocol by exporting: Every TTS plugin must implement this protocol by exporting:
- PLUGIN_MANIFEST: PluginManifest - PLUGIN_MANIFEST: PluginManifest
- MODEL_REQUIREMENTS: list[ModelManifest] - MODEL_REQUIREMENTS: list[ModelManifest]
- create_engine: Callable[[HostContext, Path | None, EngineConfig], Engine] - create_engine: Callable[[HostContext, Path | None, EngineConfig], Engine]
""" """
def create_engine( def create_engine(
self, self,
context: HostContext, context: HostContext,
model_path: Path | None, model_path: Path | None,
config: EngineConfig, config: EngineConfig,
) -> Engine: ) -> Engine:
"""Create an engine instance. """Create an engine instance.
This is the factory function that creates an Engine from a plugin. This is the factory function that creates an Engine from a plugin.
It must be atomic: succeed fully or raise EngineError and clean up. It must be atomic: succeed fully or raise EngineError and clean up.
Args: Args:
context: Host services (config dir, logger, http client). context: Host services (config dir, logger, http client).
model_path: Resolved model path, or None for cloud/no-model engines. model_path: Resolved model path, or None for cloud/no-model engines.
config: Engine initialization settings. config: Engine initialization settings.
Returns: Returns:
A fully initialized Engine instance. A fully initialized Engine instance.
Raises: Raises:
EngineError: On failure. Cleans up partially created resources. EngineError: On failure. Cleans up partially created resources.
""" """
... ...
+153 -153
View File
@@ -1,153 +1,153 @@
"""Plugin Manager """Plugin Manager
Provides a simple interface for consumers to access TTS engines via the Provides a simple interface for consumers to access TTS engines via the
new Plugin Architecture. Discovers, loads, and manages plugins from the new Plugin Architecture. Discovers, loads, and manages plugins from the
plugins directory. plugins directory.
Usage: Usage:
from abogen.tts_plugin.plugin_manager import get_plugin_manager from abogen.tts_plugin.plugin_manager import get_plugin_manager
manager = get_plugin_manager() manager = get_plugin_manager()
engine = manager.create_engine("kokoro", lang_code="a", device="cpu") engine = manager.create_engine("kokoro", lang_code="a", device="cpu")
session = engine.create_session() session = engine.create_session()
try: try:
result = session.synthesize("Hello world") result = session.synthesize("Hello world")
finally: finally:
session.dispose() session.dispose()
""" """
from typing import Any, Dict, List, Optional, Type from typing import Any, Dict, List, Optional, Type
from abogen.tts_plugin.engine import Engine, EngineSession from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.manifest import PluginManifest from abogen.tts_plugin.manifest import PluginManifest
from abogen.tts_plugin.types import AudioFormat from abogen.tts_plugin.types import AudioFormat
class PluginManager: class PluginManager:
"""Manages TTS plugins and provides a simple interface for consumers.""" """Manages TTS plugins and provides a simple interface for consumers."""
def __init__(self) -> None: def __init__(self) -> None:
self._plugins: Dict[str, dict] = {} self._plugins: Dict[str, dict] = {}
self._engines: Dict[str, Engine] = {} self._engines: Dict[str, Engine] = {}
self._loaded = False self._loaded = False
def discover(self, plugins_dir: str = "plugins") -> None: def discover(self, plugins_dir: str = "plugins") -> None:
"""Discover and load all plugins from the given directory.""" """Discover and load all plugins from the given directory."""
import os import os
from pathlib import Path from pathlib import Path
from abogen.tts_plugin.loader import load_plugin_from_dir from abogen.tts_plugin.loader import load_plugin_from_dir
self._plugins.clear() self._plugins.clear()
self._engines.clear() self._engines.clear()
plugins_path = Path(plugins_dir) plugins_path = Path(plugins_dir)
if not plugins_path.exists(): if not plugins_path.exists():
self._loaded = True self._loaded = True
return return
for entry in plugins_path.iterdir(): for entry in plugins_path.iterdir():
if entry.is_dir() and (entry / "__init__.py").exists(): if entry.is_dir() and (entry / "__init__.py").exists():
try: try:
result = load_plugin_from_dir(entry) result = load_plugin_from_dir(entry)
if result.success and result.manifest is not None: if result.success and result.manifest is not None:
self._plugins[result.manifest.id] = { self._plugins[result.manifest.id] = {
"manifest": result.manifest, "manifest": result.manifest,
"create_engine": result.create_engine, "create_engine": result.create_engine,
"module": result.module, "module": result.module,
} }
except Exception as e: except Exception as e:
# Log error but continue with other plugins # Log error but continue with other plugins
print(f"Warning: Failed to load plugin from {entry}: {e}") print(f"Warning: Failed to load plugin from {entry}: {e}")
self._loaded = True self._loaded = True
def _ensure_loaded(self) -> None: def _ensure_loaded(self) -> None:
"""Ensure plugins have been discovered.""" """Ensure plugins have been discovered."""
if not self._loaded: if not self._loaded:
self.discover() self.discover()
def list_plugins(self) -> List[PluginManifest]: def list_plugins(self) -> List[PluginManifest]:
"""Return manifests for all loaded plugins.""" """Return manifests for all loaded plugins."""
self._ensure_loaded() self._ensure_loaded()
return [info["manifest"] for info in self._plugins.values()] return [info["manifest"] for info in self._plugins.values()]
def get_plugin(self, plugin_id: str) -> Optional[dict]: def get_plugin(self, plugin_id: str) -> Optional[dict]:
"""Get plugin info by ID.""" """Get plugin info by ID."""
self._ensure_loaded() self._ensure_loaded()
return self._plugins.get(plugin_id) return self._plugins.get(plugin_id)
def has_plugin(self, plugin_id: str) -> bool: def has_plugin(self, plugin_id: str) -> bool:
"""Check if a plugin is loaded.""" """Check if a plugin is loaded."""
self._ensure_loaded() self._ensure_loaded()
return plugin_id in self._plugins return plugin_id in self._plugins
def create_engine(self, plugin_id: str, **kwargs: Any) -> Engine: def create_engine(self, plugin_id: str, **kwargs: Any) -> Engine:
"""Create an engine instance for the given plugin. """Create an engine instance for the given plugin.
Args: Args:
plugin_id: The plugin identifier (e.g., "kokoro") plugin_id: The plugin identifier (e.g., "kokoro")
**kwargs: Arguments passed to the engine constructor **kwargs: Arguments passed to the engine constructor
Returns: Returns:
An Engine instance An Engine instance
Raises: Raises:
KeyError: If plugin_id is not found KeyError: If plugin_id is not found
Exception: If engine creation fails Exception: If engine creation fails
""" """
self._ensure_loaded() self._ensure_loaded()
if plugin_id not in self._plugins: if plugin_id not in self._plugins:
raise KeyError(f"Plugin not found: {plugin_id}") raise KeyError(f"Plugin not found: {plugin_id}")
plugin_info = self._plugins[plugin_id] plugin_info = self._plugins[plugin_id]
create_engine_func = plugin_info["create_engine"] create_engine_func = plugin_info["create_engine"]
# Create engine using the plugin's factory # Create engine using the plugin's factory
engine = create_engine_func(**kwargs) engine = create_engine_func(**kwargs)
return engine return engine
def get_or_create_engine(self, plugin_id: str, **kwargs: Any) -> Engine: def get_or_create_engine(self, plugin_id: str, **kwargs: Any) -> Engine:
"""Get an existing engine or create a new one. """Get an existing engine or create a new one.
Engines are cached by plugin_id. If you need multiple instances Engines are cached by plugin_id. If you need multiple instances
with different parameters, use create_engine() directly. with different parameters, use create_engine() directly.
""" """
self._ensure_loaded() self._ensure_loaded()
cache_key = plugin_id cache_key = plugin_id
if cache_key in self._engines: if cache_key in self._engines:
return self._engines[cache_key] return self._engines[cache_key]
engine = self.create_engine(plugin_id, **kwargs) engine = self.create_engine(plugin_id, **kwargs)
self._engines[cache_key] = engine self._engines[cache_key] = engine
return engine return engine
def dispose_all(self) -> None: def dispose_all(self) -> None:
"""Dispose all cached engines.""" """Dispose all cached engines."""
for engine in self._engines.values(): for engine in self._engines.values():
try: try:
engine.dispose() engine.dispose()
except Exception: except Exception:
pass # dispose() should never raise pass # dispose() should never raise
self._engines.clear() self._engines.clear()
# Global singleton # Global singleton
_manager: Optional[PluginManager] = None _manager: Optional[PluginManager] = None
def get_plugin_manager() -> PluginManager: def get_plugin_manager() -> PluginManager:
"""Get the global PluginManager instance.""" """Get the global PluginManager instance."""
global _manager global _manager
if _manager is None: if _manager is None:
_manager = PluginManager() _manager = PluginManager()
return _manager return _manager
def reset_plugin_manager() -> None: def reset_plugin_manager() -> None:
"""Reset the global PluginManager (for testing).""" """Reset the global PluginManager (for testing)."""
global _manager global _manager
if _manager is not None: if _manager is not None:
_manager.dispose_all() _manager.dispose_all()
_manager = None _manager = None
+111 -111
View File
@@ -1,111 +1,111 @@
"""Core domain types for the TTS Plugin Architecture. """Core domain types for the TTS Plugin Architecture.
This module contains immutable value objects that form the core domain. This module contains immutable value objects that form the core domain.
These types have zero dependencies and are used across the plugin system. These types have zero dependencies and are used across the plugin system.
""" """
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Mapping from typing import Any, Mapping
@dataclass(frozen=True) @dataclass(frozen=True)
class AudioFormat: class AudioFormat:
"""Immutable value object representing an audio format. """Immutable value object representing an audio format.
Attributes: Attributes:
mime: MIME type of the audio (e.g., "audio/wav", "audio/mpeg"). mime: MIME type of the audio (e.g., "audio/wav", "audio/mpeg").
extension: File extension (e.g., "wav", "mp3"). extension: File extension (e.g., "wav", "mp3").
""" """
mime: str mime: str
extension: str extension: str
@dataclass(frozen=True) @dataclass(frozen=True)
class Duration: class Duration:
"""Immutable value object representing a time duration. """Immutable value object representing a time duration.
Attributes: Attributes:
seconds: Duration in seconds. seconds: Duration in seconds.
""" """
seconds: float seconds: float
@dataclass(frozen=True) @dataclass(frozen=True)
class VoiceSelection: class VoiceSelection:
"""Immutable value object for voice selection. Opaque to engine. """Immutable value object for voice selection. Opaque to engine.
Attributes: Attributes:
source: Voice source identifier (e.g., "builtin", "clone"). source: Voice source identifier (e.g., "builtin", "clone").
key: Voice key within the source. key: Voice key within the source.
payload: Optional payload for clone/blend sources. payload: Optional payload for clone/blend sources.
""" """
source: str source: str
key: str key: str
payload: Any = None payload: Any = None
@dataclass(frozen=True) @dataclass(frozen=True)
class ParameterValues: class ParameterValues:
"""Immutable value object for synthesis parameters. Behaves like Mapping[str, Any]. """Immutable value object for synthesis parameters. Behaves like Mapping[str, Any].
Attributes: Attributes:
values: Mapping of parameter names to their values. values: Mapping of parameter names to their values.
""" """
values: Mapping[str, Any] = field(default_factory=dict) values: Mapping[str, Any] = field(default_factory=dict)
@dataclass(frozen=True) @dataclass(frozen=True)
class SynthesisRequest: class SynthesisRequest:
"""Immutable value object for a synthesis request. """Immutable value object for a synthesis request.
Attributes: Attributes:
text: Text to synthesize. text: Text to synthesize.
voice: Voice selection. voice: Voice selection.
parameters: Synthesis parameters. parameters: Synthesis parameters.
format: Desired audio output format. format: Desired audio output format.
""" """
text: str text: str
voice: VoiceSelection voice: VoiceSelection
parameters: ParameterValues parameters: ParameterValues
format: AudioFormat format: AudioFormat
@dataclass(frozen=True) @dataclass(frozen=True)
class SynthesizedAudio: class SynthesizedAudio:
"""Immutable value object for synthesized audio result. """Immutable value object for synthesized audio result.
Attributes: Attributes:
data: Raw audio bytes. data: Raw audio bytes.
format: Audio format of the result. format: Audio format of the result.
duration: Duration of the audio. duration: Duration of the audio.
""" """
data: bytes data: bytes
format: AudioFormat format: AudioFormat
duration: Duration duration: Duration
@dataclass(frozen=True) @dataclass(frozen=True)
class EngineConfig: class EngineConfig:
"""Immutable configuration of an Engine instance. """Immutable configuration of an Engine instance.
Contains parameters that define how a particular Engine instance is Contains parameters that define how a particular Engine instance is
created and that remain constant throughout the lifetime of that Engine. created and that remain constant throughout the lifetime of that Engine.
Plugin implementations may ignore fields that are not applicable to them. Plugin implementations may ignore fields that are not applicable to them.
Attributes: Attributes:
device: Device to use (e.g., "cpu", "cuda:0"). device: Device to use (e.g., "cpu", "cuda:0").
lang_code: Language code for the engine (e.g., "a" for Kokoro English). lang_code: Language code for the engine (e.g., "a" for Kokoro English).
Plugins that do not require a language code ignore this field. Plugins that do not require a language code ignore this field.
""" """
device: str = "cpu" device: str = "cpu"
lang_code: str = "a" lang_code: str = "a"
+235 -235
View File
@@ -1,235 +1,235 @@
"""TTS Plugin Architecture — direct utility functions. """TTS Plugin Architecture — direct utility functions.
Provides helpers that replace the former compatibility adapter by Provides helpers that replace the former compatibility adapter by
calling the Plugin Manager directly. calling the Plugin Manager directly.
""" """
from __future__ import annotations from __future__ import annotations
from typing import Any, Iterator from typing import Any, Iterator
import numpy as np import numpy as np
from abogen.tts_plugin.plugin_manager import get_plugin_manager from abogen.tts_plugin.plugin_manager import get_plugin_manager
def get_voices(plugin_id: str) -> tuple[str, ...]: def get_voices(plugin_id: str) -> tuple[str, ...]:
"""Return the voice-id tuple for *plugin_id*. """Return the voice-id tuple for *plugin_id*.
Uses the official Plugin Architecture: PluginManager Engine VoiceLister. Uses the official Plugin Architecture: PluginManager Engine VoiceLister.
First checks plugin manifest for static voice catalog. First checks plugin manifest for static voice catalog.
""" """
import logging import logging
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from abogen.tts_plugin.host_context import HostContext from abogen.tts_plugin.host_context import HostContext
from abogen.tts_plugin.types import EngineConfig from abogen.tts_plugin.types import EngineConfig
manager = get_plugin_manager() manager = get_plugin_manager()
if not manager.has_plugin(plugin_id): if not manager.has_plugin(plugin_id):
return () return ()
# Check manifest for static voice catalog # Check manifest for static voice catalog
plugin_info = manager.get_plugin(plugin_id) plugin_info = manager.get_plugin(plugin_id)
if plugin_info is not None: if plugin_info is not None:
manifest = plugin_info.get("manifest") manifest = plugin_info.get("manifest")
if manifest is not None and manifest.voices is not None: if manifest is not None and manifest.voices is not None:
return tuple(v.id for v in manifest.voices) return tuple(v.id for v in manifest.voices)
ctx = HostContext( ctx = HostContext(
config_dir=Path(tempfile.gettempdir()), config_dir=Path(tempfile.gettempdir()),
logger=logging.getLogger(f"abogen.utils.{plugin_id}"), logger=logging.getLogger(f"abogen.utils.{plugin_id}"),
http_client=type("_StubHttpClient", (), { http_client=type("_StubHttpClient", (), {
"get": staticmethod(lambda url, **kw: None), "get": staticmethod(lambda url, **kw: None),
"post": staticmethod(lambda url, **kw: None), "post": staticmethod(lambda url, **kw: None),
})(), })(),
) )
try: try:
engine = manager.create_engine( engine = manager.create_engine(
plugin_id, plugin_id,
context=ctx, context=ctx,
model_path=None, model_path=None,
config=EngineConfig(device="cpu"), config=EngineConfig(device="cpu"),
) )
except Exception: except Exception:
return () return ()
try: try:
from abogen.tts_plugin.capabilities import VoiceLister from abogen.tts_plugin.capabilities import VoiceLister
if isinstance(engine, VoiceLister): if isinstance(engine, VoiceLister):
manifests = engine.listVoices("builtin") manifests = engine.listVoices("builtin")
return tuple(v.id for v in manifests) return tuple(v.id for v in manifests)
return () return ()
except Exception: except Exception:
return () return ()
finally: finally:
engine.dispose() engine.dispose()
def get_default_voice(plugin_id: str, fallback: str = "") -> str: def get_default_voice(plugin_id: str, fallback: str = "") -> str:
"""Return the first voice of *plugin_id*, or *fallback*.""" """Return the first voice of *plugin_id*, or *fallback*."""
voices = get_voices(plugin_id) voices = get_voices(plugin_id)
return voices[0] if voices else fallback return voices[0] if voices else fallback
def is_plugin_registered(plugin_id: str) -> bool: def is_plugin_registered(plugin_id: str) -> bool:
"""Check whether *plugin_id* is loaded by the Plugin Manager.""" """Check whether *plugin_id* is loaded by the Plugin Manager."""
return get_plugin_manager().has_plugin(plugin_id) return get_plugin_manager().has_plugin(plugin_id)
def resolve_voice_to_plugin(spec: str, fallback: str = "kokoro") -> str: def resolve_voice_to_plugin(spec: str, fallback: str = "kokoro") -> str:
"""Determine which plugin owns the given voice specification. """Determine which plugin owns the given voice specification.
Resolution rules: Resolution rules:
1. Empty spec -> fallback 1. Empty spec -> fallback
2. Kokoro formula (contains '*' or '+') -> "kokoro" 2. Kokoro formula (contains '*' or '+') -> "kokoro"
3. Exact voice-id match against loaded plugins -> plugin id 3. Exact voice-id match against loaded plugins -> plugin id
4. Unknown voice -> fallback 4. Unknown voice -> fallback
""" """
raw = str(spec or "").strip() raw = str(spec or "").strip()
if not raw: if not raw:
return fallback return fallback
if "*" in raw or "+" in raw: if "*" in raw or "+" in raw:
return "kokoro" return "kokoro"
upper = raw.upper() upper = raw.upper()
manager = get_plugin_manager() manager = get_plugin_manager()
for manifest in manager.list_plugins(): for manifest in manager.list_plugins():
for voice_source in manifest.engine.voiceSources: for voice_source in manifest.engine.voiceSources:
if voice_source.type == "list" and isinstance(voice_source.config, dict): if voice_source.type == "list" and isinstance(voice_source.config, dict):
try: try:
engine = manager.create_engine(manifest.id) engine = manager.create_engine(manifest.id)
try: try:
if hasattr(engine, "listVoices"): if hasattr(engine, "listVoices"):
voice_manifests = engine.listVoices(voice_source.id) voice_manifests = engine.listVoices(voice_source.id)
voice_ids = [v.id.upper() for v in voice_manifests] voice_ids = [v.id.upper() for v in voice_manifests]
if upper in voice_ids: if upper in voice_ids:
return manifest.id return manifest.id
finally: finally:
engine.dispose() engine.dispose()
except Exception: except Exception:
continue continue
return fallback return fallback
class Pipeline: class Pipeline:
"""Callable wrapper around Engine / EngineSession. """Callable wrapper around Engine / EngineSession.
Presents the same interface that old callers expect:: Presents the same interface that old callers expect::
pipeline = create_pipeline("kokoro", lang_code="a", device="cpu") pipeline = create_pipeline("kokoro", lang_code="a", device="cpu")
for segment in pipeline(text, voice="af_nova", speed=1.0): for segment in pipeline(text, voice="af_nova", speed=1.0):
audio = segment.audio audio = segment.audio
""" """
def __init__(self, engine: Any, **engine_kwargs: Any) -> None: def __init__(self, engine: Any, **engine_kwargs: Any) -> None:
self._engine = engine self._engine = engine
self._engine_kwargs = engine_kwargs self._engine_kwargs = engine_kwargs
self._session: Any = None self._session: Any = None
def _ensure_session(self) -> Any: def _ensure_session(self) -> Any:
if self._session is None: if self._session is None:
self._session = self._engine.createSession() self._session = self._engine.createSession()
return self._session return self._session
def __call__( def __call__(
self, self,
text: str, text: str,
voice: str = "default", voice: str = "default",
speed: float = 1.0, speed: float = 1.0,
split_pattern: str | None = None, split_pattern: str | None = None,
**kwargs: Any, **kwargs: Any,
) -> Iterator[Any]: ) -> Iterator[Any]:
from abogen.tts_plugin.types import ( from abogen.tts_plugin.types import (
AudioFormat, AudioFormat,
ParameterValues, ParameterValues,
SynthesisRequest, SynthesisRequest,
VoiceSelection, VoiceSelection,
) )
session = self._ensure_session() session = self._ensure_session()
params: dict[str, Any] = {"speed": speed} params: dict[str, Any] = {"speed": speed}
if split_pattern is not None: if split_pattern is not None:
params["split_pattern"] = split_pattern params["split_pattern"] = split_pattern
params.update(kwargs) params.update(kwargs)
request = SynthesisRequest( request = SynthesisRequest(
text=text, text=text,
voice=VoiceSelection(source="builtin", key=voice), voice=VoiceSelection(source="builtin", key=voice),
parameters=ParameterValues(values=params), parameters=ParameterValues(values=params),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
) )
result = session.synthesize(request) result = session.synthesize(request)
audio_array = np.frombuffer(result.data, dtype=np.float32) audio_array = np.frombuffer(result.data, dtype=np.float32)
from dataclasses import dataclass from dataclasses import dataclass
@dataclass @dataclass
class Segment: class Segment:
graphemes: str graphemes: str
audio: np.ndarray audio: np.ndarray
yield Segment(graphemes=text, audio=audio_array) yield Segment(graphemes=text, audio=audio_array)
def dispose(self) -> None: def dispose(self) -> None:
if self._session is not None: if self._session is not None:
try: try:
self._session.dispose() self._session.dispose()
except Exception: except Exception:
pass pass
self._session = None self._session = None
def __del__(self) -> None: def __del__(self) -> None:
self.dispose() self.dispose()
def create_pipeline( def create_pipeline(
plugin_id: str, plugin_id: str,
*, *,
lang_code: str = "a", lang_code: str = "a",
device: str = "cpu", device: str = "cpu",
) -> Pipeline: ) -> Pipeline:
"""Create a callable TTS pipeline via the Plugin Architecture. """Create a callable TTS pipeline via the Plugin Architecture.
Builds a proper HostContext and EngineConfig, then delegates to the Builds a proper HostContext and EngineConfig, then delegates to the
PluginManager to create the engine. Returns a :class:`Pipeline` whose PluginManager to create the engine. Returns a :class:`Pipeline` whose
``__call__`` interface matches the callable protocol used by consumers. ``__call__`` interface matches the callable protocol used by consumers.
Args: Args:
plugin_id: Plugin identifier (e.g., "kokoro", "supertonic"). plugin_id: Plugin identifier (e.g., "kokoro", "supertonic").
lang_code: Language code for the engine. lang_code: Language code for the engine.
device: Device to use (e.g., "cpu", "cuda:0"). device: Device to use (e.g., "cpu", "cuda:0").
Returns: Returns:
A callable Pipeline instance. A callable Pipeline instance.
""" """
import logging import logging
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from abogen.tts_plugin.host_context import HostContext from abogen.tts_plugin.host_context import HostContext
from abogen.tts_plugin.types import EngineConfig from abogen.tts_plugin.types import EngineConfig
manager = get_plugin_manager() manager = get_plugin_manager()
ctx = HostContext( ctx = HostContext(
config_dir=Path(tempfile.gettempdir()), config_dir=Path(tempfile.gettempdir()),
logger=logging.getLogger(f"abogen.pipeline.{plugin_id}"), logger=logging.getLogger(f"abogen.pipeline.{plugin_id}"),
http_client=type("_StubHttpClient", (), { http_client=type("_StubHttpClient", (), {
"get": staticmethod(lambda url, **kw: None), "get": staticmethod(lambda url, **kw: None),
"post": staticmethod(lambda url, **kw: None), "post": staticmethod(lambda url, **kw: None),
})(), })(),
) )
config = EngineConfig(device=device, lang_code=lang_code) config = EngineConfig(device=device, lang_code=lang_code)
engine = manager.create_engine(plugin_id, context=ctx, model_path=None, config=config) engine = manager.create_engine(plugin_id, context=ctx, model_path=None, config=config)
return Pipeline(engine) return Pipeline(engine)
+548 -548
View File
File diff suppressed because it is too large Load Diff
+146 -146
View File
@@ -1,146 +1,146 @@
from __future__ import annotations from __future__ import annotations
import os import os
import threading import threading
from typing import Callable, Dict, Iterable, Optional, Set, Tuple from typing import Callable, Dict, Iterable, Optional, Set, Tuple
try: # pragma: no cover - optional dependency guard try: # pragma: no cover - optional dependency guard
from huggingface_hub import hf_hub_download # type: ignore from huggingface_hub import hf_hub_download # type: ignore
from huggingface_hub.utils import LocalEntryNotFoundError # type: ignore from huggingface_hub.utils import LocalEntryNotFoundError # type: ignore
except Exception: # pragma: no cover - import fallback except Exception: # pragma: no cover - import fallback
hf_hub_download = None # type: ignore[assignment] hf_hub_download = None # type: ignore[assignment]
LocalEntryNotFoundError = None # type: ignore[assignment] LocalEntryNotFoundError = None # type: ignore[assignment]
if LocalEntryNotFoundError is None: # pragma: no cover - fallback for tests if LocalEntryNotFoundError is None: # pragma: no cover - fallback for tests
class LocalEntryNotFoundError(Exception): class LocalEntryNotFoundError(Exception):
pass pass
from abogen.tts_plugin.utils import get_voices from abogen.tts_plugin.utils import get_voices
_CACHE_LOCK = threading.Lock() _CACHE_LOCK = threading.Lock()
_CACHED_VOICES: Set[str] = set() _CACHED_VOICES: Set[str] = set()
_BOOTSTRAP_LOCK = threading.Lock() _BOOTSTRAP_LOCK = threading.Lock()
_BOOTSTRAPPED = False _BOOTSTRAPPED = False
def _normalize_targets(voices: Optional[Iterable[str]]) -> Set[str]: def _normalize_targets(voices: Optional[Iterable[str]]) -> Set[str]:
kokoro_voices = get_voices("kokoro") kokoro_voices = get_voices("kokoro")
if not voices: if not voices:
return set(kokoro_voices) return set(kokoro_voices)
normalized: Set[str] = set() normalized: Set[str] = set()
for voice in voices: for voice in voices:
if not voice: if not voice:
continue continue
voice_id = str(voice).strip() voice_id = str(voice).strip()
if not voice_id: if not voice_id:
continue continue
if voice_id in kokoro_voices: if voice_id in kokoro_voices:
normalized.add(voice_id) normalized.add(voice_id)
return normalized return normalized
def ensure_voice_assets( def ensure_voice_assets(
voices: Optional[Iterable[str]] = None, voices: Optional[Iterable[str]] = None,
*, *,
repo_id: str = "hexgrad/Kokoro-82M", repo_id: str = "hexgrad/Kokoro-82M",
cache_dir: Optional[str] = None, cache_dir: Optional[str] = None,
on_progress: Optional[Callable[[str], None]] = None, on_progress: Optional[Callable[[str], None]] = None,
) -> Tuple[Set[str], Dict[str, str]]: ) -> Tuple[Set[str], Dict[str, str]]:
"""Ensure Kokoro voice weight files are present locally. """Ensure Kokoro voice weight files are present locally.
Returns a tuple of (downloaded voices, errors) where errors maps the Returns a tuple of (downloaded voices, errors) where errors maps the
voice id to the underlying exception message. voice id to the underlying exception message.
""" """
if hf_hub_download is None: if hf_hub_download is None:
raise RuntimeError("huggingface_hub is required to cache voices") raise RuntimeError("huggingface_hub is required to cache voices")
effective_cache_dir = cache_dir effective_cache_dir = cache_dir
if effective_cache_dir is None: if effective_cache_dir is None:
env_cache_dir = os.environ.get("ABOGEN_VOICE_CACHE_DIR", "").strip() env_cache_dir = os.environ.get("ABOGEN_VOICE_CACHE_DIR", "").strip()
effective_cache_dir = env_cache_dir or None effective_cache_dir = env_cache_dir or None
targets = _normalize_targets(voices) targets = _normalize_targets(voices)
if not targets: if not targets:
return set(), {} return set(), {}
with _CACHE_LOCK: with _CACHE_LOCK:
missing = [voice for voice in targets if voice not in _CACHED_VOICES] missing = [voice for voice in targets if voice not in _CACHED_VOICES]
downloaded: Set[str] = set() downloaded: Set[str] = set()
errors: Dict[str, str] = {} errors: Dict[str, str] = {}
for voice_id in missing: for voice_id in missing:
if on_progress: if on_progress:
on_progress(f"Fetching voice asset '{voice_id}'") on_progress(f"Fetching voice asset '{voice_id}'")
try: try:
downloaded_flag = _ensure_single_voice_asset( downloaded_flag = _ensure_single_voice_asset(
voice_id, voice_id,
repo_id=repo_id, repo_id=repo_id,
cache_dir=effective_cache_dir, cache_dir=effective_cache_dir,
) )
except Exception as exc: # pragma: no cover - network variance except Exception as exc: # pragma: no cover - network variance
errors[voice_id] = str(exc) errors[voice_id] = str(exc)
continue continue
if downloaded_flag: if downloaded_flag:
downloaded.add(voice_id) downloaded.add(voice_id)
with _CACHE_LOCK: with _CACHE_LOCK:
_CACHED_VOICES.add(voice_id) _CACHED_VOICES.add(voice_id)
return downloaded, errors return downloaded, errors
def bootstrap_voice_cache( def bootstrap_voice_cache(
voices: Optional[Iterable[str]] = None, voices: Optional[Iterable[str]] = None,
*, *,
repo_id: str = "hexgrad/Kokoro-82M", repo_id: str = "hexgrad/Kokoro-82M",
cache_dir: Optional[str] = None, cache_dir: Optional[str] = None,
on_progress: Optional[Callable[[str], None]] = None, on_progress: Optional[Callable[[str], None]] = None,
) -> Tuple[Set[str], Dict[str, str]]: ) -> Tuple[Set[str], Dict[str, str]]:
"""Ensure voices are cached once per process. """Ensure voices are cached once per process.
Subsequent calls are no-ops and return empty structures. Subsequent calls are no-ops and return empty structures.
""" """
global _BOOTSTRAPPED global _BOOTSTRAPPED
with _BOOTSTRAP_LOCK: with _BOOTSTRAP_LOCK:
if _BOOTSTRAPPED: if _BOOTSTRAPPED:
return set(), {} return set(), {}
downloaded, errors = ensure_voice_assets( downloaded, errors = ensure_voice_assets(
voices, voices,
repo_id=repo_id, repo_id=repo_id,
cache_dir=cache_dir, cache_dir=cache_dir,
on_progress=on_progress, on_progress=on_progress,
) )
_BOOTSTRAPPED = True _BOOTSTRAPPED = True
return downloaded, errors return downloaded, errors
def _ensure_single_voice_asset( def _ensure_single_voice_asset(
voice_id: str, voice_id: str,
*, *,
repo_id: str, repo_id: str,
cache_dir: Optional[str], cache_dir: Optional[str],
) -> bool: ) -> bool:
if hf_hub_download is None: if hf_hub_download is None:
raise RuntimeError("huggingface_hub is required to cache voices") raise RuntimeError("huggingface_hub is required to cache voices")
filename = f"voices/{voice_id}.pt" filename = f"voices/{voice_id}.pt"
common_kwargs = { common_kwargs = {
"repo_id": repo_id, "repo_id": repo_id,
"filename": filename, "filename": filename,
} }
if cache_dir is not None: if cache_dir is not None:
common_kwargs["cache_dir"] = cache_dir common_kwargs["cache_dir"] = cache_dir
try: try:
hf_hub_download(local_files_only=True, **common_kwargs) hf_hub_download(local_files_only=True, **common_kwargs)
return False return False
except LocalEntryNotFoundError: except LocalEntryNotFoundError:
pass pass
hf_hub_download(resume_download=True, **common_kwargs) hf_hub_download(resume_download=True, **common_kwargs)
return True return True
+82 -82
View File
@@ -1,82 +1,82 @@
import re import re
from typing import List, Tuple from typing import List, Tuple
from abogen.tts_plugin.utils import get_voices from abogen.tts_plugin.utils import get_voices
# Calls parsing and loads the voice to gpu or cpu # Calls parsing and loads the voice to gpu or cpu
def get_new_voice(pipeline, formula, use_gpu): def get_new_voice(pipeline, formula, use_gpu):
try: try:
weighted_voice = parse_voice_formula(pipeline, formula) weighted_voice = parse_voice_formula(pipeline, formula)
# device = "cuda" if use_gpu else "cpu" # device = "cuda" if use_gpu else "cpu"
# Setting the device "cuda" gives "Error occurred: split_with_sizes(): argument 'split_sizes' (position 2)" # Setting the device "cuda" gives "Error occurred: split_with_sizes(): argument 'split_sizes' (position 2)"
# error when the device is gpu. So disabling this for now. # error when the device is gpu. So disabling this for now.
device = "cpu" device = "cpu"
return weighted_voice.to(device) return weighted_voice.to(device)
except Exception as e: except Exception as e:
raise ValueError(f"Failed to create voice: {str(e)}") raise ValueError(f"Failed to create voice: {str(e)}")
def parse_formula_terms(formula: str) -> List[Tuple[str, float]]: def parse_formula_terms(formula: str) -> List[Tuple[str, float]]:
if not formula or not formula.strip(): if not formula or not formula.strip():
raise ValueError("Empty voice formula") raise ValueError("Empty voice formula")
terms: List[Tuple[str, float]] = [] terms: List[Tuple[str, float]] = []
kokoro_voices = get_voices("kokoro") kokoro_voices = get_voices("kokoro")
for segment in formula.split("+"): for segment in formula.split("+"):
part = segment.strip() part = segment.strip()
if not part: if not part:
continue continue
if "*" not in part: if "*" not in part:
raise ValueError("Each component must be in the form voice*weight") raise ValueError("Each component must be in the form voice*weight")
voice_name, raw_weight = part.split("*", 1) voice_name, raw_weight = part.split("*", 1)
voice_name = voice_name.strip() voice_name = voice_name.strip()
if voice_name not in kokoro_voices: if voice_name not in kokoro_voices:
raise ValueError(f"Unknown voice: {voice_name}") raise ValueError(f"Unknown voice: {voice_name}")
try: try:
weight = float(raw_weight.strip()) weight = float(raw_weight.strip())
except ValueError as exc: except ValueError as exc:
raise ValueError(f"Invalid weight for {voice_name}") from exc raise ValueError(f"Invalid weight for {voice_name}") from exc
if weight <= 0: if weight <= 0:
raise ValueError(f"Weight for {voice_name} must be positive") raise ValueError(f"Weight for {voice_name} must be positive")
terms.append((voice_name, weight)) terms.append((voice_name, weight))
if not terms: if not terms:
raise ValueError("Voice weights must sum to a positive value") raise ValueError("Voice weights must sum to a positive value")
return terms return terms
def parse_voice_formula(pipeline, formula): def parse_voice_formula(pipeline, formula):
terms = parse_formula_terms(formula) terms = parse_formula_terms(formula)
total_weight = sum(weight for _, weight in terms) total_weight = sum(weight for _, weight in terms)
if total_weight <= 0: if total_weight <= 0:
raise ValueError("Voice weights must sum to a positive value") raise ValueError("Voice weights must sum to a positive value")
weighted_sum = None weighted_sum = None
for voice_name, weight in terms: for voice_name, weight in terms:
normalized_weight = weight / total_weight if total_weight > 0 else weight normalized_weight = weight / total_weight if total_weight > 0 else weight
voice_tensor = pipeline.load_single_voice(voice_name) voice_tensor = pipeline.load_single_voice(voice_name)
if weighted_sum is None: if weighted_sum is None:
weighted_sum = normalized_weight * voice_tensor weighted_sum = normalized_weight * voice_tensor
else: else:
weighted_sum += normalized_weight * voice_tensor weighted_sum += normalized_weight * voice_tensor
if weighted_sum is None: if weighted_sum is None:
raise ValueError("Voice formula produced no components") raise ValueError("Voice formula produced no components")
return weighted_sum return weighted_sum
def calculate_sum_from_formula(formula): def calculate_sum_from_formula(formula):
weights = re.findall(r"\* *([\d.]+)", formula) weights = re.findall(r"\* *([\d.]+)", formula)
total_sum = sum(float(weight) for weight in weights) total_sum = sum(float(weight) for weight in weights)
return total_sum return total_sum
def extract_voice_ids(formula: str) -> List[str]: def extract_voice_ids(formula: str) -> List[str]:
return [voice for voice, _ in parse_formula_terms(formula)] return [voice for voice, _ in parse_formula_terms(formula)]
+230 -230
View File
@@ -1,230 +1,230 @@
import json import json
import os import os
from typing import Any, Dict, Iterable, List, Tuple from typing import Any, Dict, Iterable, List, Tuple
from abogen.tts_plugin.utils import get_voices, is_plugin_registered from abogen.tts_plugin.utils import get_voices, is_plugin_registered
from abogen.utils import get_user_config_path from abogen.utils import get_user_config_path
def _get_profiles_path(): def _get_profiles_path():
config_path = get_user_config_path() config_path = get_user_config_path()
config_dir = os.path.dirname(config_path) config_dir = os.path.dirname(config_path)
return os.path.join(config_dir, "voice_profiles.json") return os.path.join(config_dir, "voice_profiles.json")
def load_profiles(): def load_profiles():
"""Load all voice profiles from JSON file.""" """Load all voice profiles from JSON file."""
path = _get_profiles_path() path = _get_profiles_path()
if os.path.exists(path): if os.path.exists(path):
try: try:
with open(path, "r", encoding="utf-8") as f: with open(path, "r", encoding="utf-8") as f:
data = json.load(f) data = json.load(f)
# always expect abogen_voice_profiles wrapper # always expect abogen_voice_profiles wrapper
if isinstance(data, dict) and "abogen_voice_profiles" in data: if isinstance(data, dict) and "abogen_voice_profiles" in data:
return data["abogen_voice_profiles"] return data["abogen_voice_profiles"]
# fallback: treat as profiles dict # fallback: treat as profiles dict
if isinstance(data, dict): if isinstance(data, dict):
return data return data
except Exception: except Exception:
return {} return {}
return {} return {}
def save_profiles(profiles): def save_profiles(profiles):
"""Save all voice profiles to JSON file.""" """Save all voice profiles to JSON file."""
path = _get_profiles_path() path = _get_profiles_path()
os.makedirs(os.path.dirname(path), exist_ok=True) os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f: with open(path, "w", encoding="utf-8") as f:
# always save with abogen_voice_profiles wrapper # always save with abogen_voice_profiles wrapper
json.dump({"abogen_voice_profiles": profiles}, f, indent=2) json.dump({"abogen_voice_profiles": profiles}, f, indent=2)
def delete_profile(name): def delete_profile(name):
"""Remove a profile by name.""" """Remove a profile by name."""
profiles = load_profiles() profiles = load_profiles()
if name in profiles: if name in profiles:
del profiles[name] del profiles[name]
save_profiles(profiles) save_profiles(profiles)
def duplicate_profile(src, dest): def duplicate_profile(src, dest):
"""Duplicate an existing profile.""" """Duplicate an existing profile."""
profiles = load_profiles() profiles = load_profiles()
if src in profiles and dest: if src in profiles and dest:
profiles[dest] = profiles[src] profiles[dest] = profiles[src]
save_profiles(profiles) save_profiles(profiles)
def export_profiles(export_path): def export_profiles(export_path):
"""Export all profiles to specified JSON file.""" """Export all profiles to specified JSON file."""
profiles = load_profiles() profiles = load_profiles()
with open(export_path, "w", encoding="utf-8") as f: with open(export_path, "w", encoding="utf-8") as f:
json.dump({"abogen_voice_profiles": profiles}, f, indent=2) json.dump({"abogen_voice_profiles": profiles}, f, indent=2)
def serialize_profiles() -> Dict[str, Dict[str, Iterable[Tuple[str, float]]]]: def serialize_profiles() -> Dict[str, Dict[str, Iterable[Tuple[str, float]]]]:
"""Return profiles in canonical dictionary form.""" """Return profiles in canonical dictionary form."""
return load_profiles() return load_profiles()
def _normalize_supertonic_voice(value: Any) -> str: def _normalize_supertonic_voice(value: Any) -> str:
raw = str(value or "").strip().upper() raw = str(value or "").strip().upper()
supertonic_voices = get_voices("supertonic") supertonic_voices = get_voices("supertonic")
return raw if raw in supertonic_voices else "M1" return raw if raw in supertonic_voices else "M1"
def _coerce_supertonic_steps(value: Any) -> int: def _coerce_supertonic_steps(value: Any) -> int:
try: try:
steps = int(value) steps = int(value)
except (TypeError, ValueError): except (TypeError, ValueError):
return 5 return 5
return max(2, min(15, steps)) return max(2, min(15, steps))
def _coerce_supertonic_speed(value: Any) -> float: def _coerce_supertonic_speed(value: Any) -> float:
try: try:
speed = float(value) speed = float(value)
except (TypeError, ValueError): except (TypeError, ValueError):
return 1.0 return 1.0
return max(0.7, min(2.0, speed)) return max(0.7, min(2.0, speed))
def normalize_profile_entry(entry: Any) -> Dict[str, Any]: def normalize_profile_entry(entry: Any) -> Dict[str, Any]:
"""Normalize a stored profile entry. """Normalize a stored profile entry.
Backwards compatible: Backwards compatible:
- Legacy Kokoro-only entries: {language, voices} - Legacy Kokoro-only entries: {language, voices}
- New entries: include provider. - New entries: include provider.
""" """
if not isinstance(entry, dict): if not isinstance(entry, dict):
return {} return {}
provider = str(entry.get("provider") or "kokoro").strip().lower() provider = str(entry.get("provider") or "kokoro").strip().lower()
if not is_plugin_registered(provider): if not is_plugin_registered(provider):
provider = "kokoro" provider = "kokoro"
language = str(entry.get("language") or "a").strip().lower() or "a" language = str(entry.get("language") or "a").strip().lower() or "a"
if provider == "supertonic": if provider == "supertonic":
return { return {
"provider": "supertonic", "provider": "supertonic",
"language": language, "language": language,
"voice": _normalize_supertonic_voice( "voice": _normalize_supertonic_voice(
entry.get("voice") or entry.get("voice_name") or entry.get("name") entry.get("voice") or entry.get("voice_name") or entry.get("name")
), ),
"total_steps": _coerce_supertonic_steps( "total_steps": _coerce_supertonic_steps(
entry.get("total_steps") entry.get("total_steps")
or entry.get("supertonic_total_steps") or entry.get("supertonic_total_steps")
or entry.get("quality") or entry.get("quality")
), ),
"speed": _coerce_supertonic_speed( "speed": _coerce_supertonic_speed(
entry.get("speed") or entry.get("supertonic_speed") entry.get("speed") or entry.get("supertonic_speed")
), ),
} }
voices = _normalize_voice_entries(entry.get("voices", [])) voices = _normalize_voice_entries(entry.get("voices", []))
if not voices: if not voices:
return {} return {}
return { return {
"provider": "kokoro", "provider": "kokoro",
"language": language, "language": language,
"voices": voices, "voices": voices,
} }
def _normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]: def _normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]:
normalized: List[Tuple[str, float]] = [] normalized: List[Tuple[str, float]] = []
kokoro_voices = get_voices("kokoro") kokoro_voices = get_voices("kokoro")
for item in entries or []: for item in entries or []:
if isinstance(item, dict): if isinstance(item, dict):
voice = item.get("id") or item.get("voice") voice = item.get("id") or item.get("voice")
weight = item.get("weight") weight = item.get("weight")
elif isinstance(item, (list, tuple)) and len(item) >= 2: elif isinstance(item, (list, tuple)) and len(item) >= 2:
voice, weight = item[0], item[1] voice, weight = item[0], item[1]
else: else:
continue continue
if voice not in kokoro_voices: if voice not in kokoro_voices:
continue continue
if weight is None: if weight is None:
continue continue
try: try:
weight_val = float(weight) weight_val = float(weight)
except (TypeError, ValueError): except (TypeError, ValueError):
continue continue
if weight_val <= 0: if weight_val <= 0:
continue continue
normalized.append((voice, weight_val)) normalized.append((voice, weight_val))
return normalized return normalized
def normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]: def normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]:
"""Public helper to normalize voice-weight pairs from arbitrary payloads.""" """Public helper to normalize voice-weight pairs from arbitrary payloads."""
return _normalize_voice_entries(entries) return _normalize_voice_entries(entries)
def save_profile(name: str, *, language: str, voices: Iterable) -> None: def save_profile(name: str, *, language: str, voices: Iterable) -> None:
"""Persist a single profile after validating its data.""" """Persist a single profile after validating its data."""
name = (name or "").strip() name = (name or "").strip()
if not name: if not name:
raise ValueError("Profile name is required") raise ValueError("Profile name is required")
normalized = _normalize_voice_entries(voices) normalized = _normalize_voice_entries(voices)
if not normalized: if not normalized:
raise ValueError("At least one voice with a weight above zero is required") raise ValueError("At least one voice with a weight above zero is required")
if not language: if not language:
language = "a" language = "a"
profiles = load_profiles() profiles = load_profiles()
profiles[name] = {"provider": "kokoro", "language": language, "voices": normalized} profiles[name] = {"provider": "kokoro", "language": language, "voices": normalized}
save_profiles(profiles) save_profiles(profiles)
def remove_profile(name: str) -> None: def remove_profile(name: str) -> None:
delete_profile(name) delete_profile(name)
def import_profiles_data(data: Dict, *, replace_existing: bool = False) -> List[str]: def import_profiles_data(data: Dict, *, replace_existing: bool = False) -> List[str]:
"""Merge profiles from a dictionary structure and persist them. """Merge profiles from a dictionary structure and persist them.
Returns the list of profile names that were added or updated. Returns the list of profile names that were added or updated.
""" """
if not isinstance(data, dict): if not isinstance(data, dict):
raise ValueError("Invalid profile payload") raise ValueError("Invalid profile payload")
if "abogen_voice_profiles" in data: if "abogen_voice_profiles" in data:
data = data["abogen_voice_profiles"] data = data["abogen_voice_profiles"]
if not isinstance(data, dict): if not isinstance(data, dict):
raise ValueError("Invalid profile payload") raise ValueError("Invalid profile payload")
current = load_profiles() current = load_profiles()
updated: List[str] = [] updated: List[str] = []
for name, entry in data.items(): for name, entry in data.items():
normalized = normalize_profile_entry(entry) normalized = normalize_profile_entry(entry)
if not normalized: if not normalized:
continue continue
if name in current and not replace_existing: if name in current and not replace_existing:
# skip duplicates unless explicit replacement is requested # skip duplicates unless explicit replacement is requested
continue continue
current[name] = normalized current[name] = normalized
updated.append(name) updated.append(name)
if updated: if updated:
save_profiles(current) save_profiles(current)
return updated return updated
def export_profiles_payload(names: Iterable[str] | None = None) -> Dict[str, Dict]: def export_profiles_payload(names: Iterable[str] | None = None) -> Dict[str, Dict]:
"""Return profiles limited to the provided names for download/export.""" """Return profiles limited to the provided names for download/export."""
profiles = load_profiles() profiles = load_profiles()
if names is None: if names is None:
subset = profiles subset = profiles
else: else:
subset = {name: profiles[name] for name in names if name in profiles} subset = {name: profiles[name] for name in names if name in profiles}
return {"abogen_voice_profiles": subset} return {"abogen_voice_profiles": subset}
+254 -254
View File
@@ -1,254 +1,254 @@
from __future__ import annotations from __future__ import annotations
import json import json
import re import re
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
import numpy as np import numpy as np
from abogen.debug_tts_samples import MARKER_PREFIX, MARKER_SUFFIX, build_debug_epub, iter_expected_codes from abogen.debug_tts_samples import MARKER_PREFIX, MARKER_SUFFIX, build_debug_epub, iter_expected_codes
from abogen.kokoro_text_normalization import normalize_for_pipeline from abogen.kokoro_text_normalization import normalize_for_pipeline
from abogen.normalization_settings import build_apostrophe_config from abogen.normalization_settings import build_apostrophe_config
from abogen.text_extractor import extract_from_path from abogen.text_extractor import extract_from_path
from abogen.voice_cache import ensure_voice_assets from abogen.voice_cache import ensure_voice_assets
from abogen.webui.conversion_runner import SAMPLE_RATE, SPLIT_PATTERN, _select_device, _to_float32, _resolve_voice, _spec_to_voice_ids from abogen.webui.conversion_runner import SAMPLE_RATE, SPLIT_PATTERN, _select_device, _to_float32, _resolve_voice, _spec_to_voice_ids
from abogen.tts_plugin.utils import create_pipeline from abogen.tts_plugin.utils import create_pipeline
_MARKER_RE = re.compile(re.escape(MARKER_PREFIX) + r"(?P<code>[A-Z0-9_]+)" + re.escape(MARKER_SUFFIX)) _MARKER_RE = re.compile(re.escape(MARKER_PREFIX) + r"(?P<code>[A-Z0-9_]+)" + re.escape(MARKER_SUFFIX))
@dataclass(frozen=True) @dataclass(frozen=True)
class DebugWavArtifact: class DebugWavArtifact:
label: str label: str
filename: str filename: str
code: Optional[str] = None code: Optional[str] = None
text: Optional[str] = None text: Optional[str] = None
def _resolve_voice_setting(value: str) -> tuple[str, Optional[str], Optional[str]]: def _resolve_voice_setting(value: str) -> tuple[str, Optional[str], Optional[str]]:
"""Resolve settings voice strings into a pipeline-ready voice spec. """Resolve settings voice strings into a pipeline-ready voice spec.
Supports "profile:<name>" by converting it into a concrete voice formula. Supports "profile:<name>" by converting it into a concrete voice formula.
Returns (resolved_voice_spec, profile_name, profile_language). Returns (resolved_voice_spec, profile_name, profile_language).
""" """
from abogen.webui.routes.utils.voice import resolve_voice_setting from abogen.webui.routes.utils.voice import resolve_voice_setting
return resolve_voice_setting(value) return resolve_voice_setting(value)
def _load_pipeline(language: str, use_gpu: bool) -> Any: def _load_pipeline(language: str, use_gpu: bool) -> Any:
device = "cpu" device = "cpu"
if use_gpu: if use_gpu:
device = _select_device() device = _select_device()
return create_pipeline("kokoro", lang_code=language, device=device) return create_pipeline("kokoro", lang_code=language, device=device)
def _extract_cases_from_text(text: str) -> List[Tuple[str, str]]: def _extract_cases_from_text(text: str) -> List[Tuple[str, str]]:
raw = str(text or "") raw = str(text or "")
matches = list(_MARKER_RE.finditer(raw)) matches = list(_MARKER_RE.finditer(raw))
cases: List[Tuple[str, str]] = [] cases: List[Tuple[str, str]] = []
if not matches: if not matches:
return cases return cases
for idx, match in enumerate(matches): for idx, match in enumerate(matches):
code = match.group("code") code = match.group("code")
start = match.end() start = match.end()
end = matches[idx + 1].start() if idx + 1 < len(matches) else len(raw) end = matches[idx + 1].start() if idx + 1 < len(matches) else len(raw)
snippet = raw[start:end] snippet = raw[start:end]
# Keep it small and predictable: collapse whitespace. # Keep it small and predictable: collapse whitespace.
snippet = " ".join(snippet.strip().split()) snippet = " ".join(snippet.strip().split())
cases.append((code, snippet)) cases.append((code, snippet))
return cases return cases
def _spoken_id(code: str) -> str: def _spoken_id(code: str) -> str:
# Make IDs pronounceable and stable (avoid reading as a word). # Make IDs pronounceable and stable (avoid reading as a word).
out: List[str] = [] out: List[str] = []
for ch in str(code or ""): for ch in str(code or ""):
if ch == "_": if ch == "_":
out.append(" ") out.append(" ")
elif ch.isalnum(): elif ch.isalnum():
out.append(ch) out.append(ch)
else: else:
out.append(" ") out.append(" ")
# Add spaces between alnum to encourage letter-by-letter reading. # Add spaces between alnum to encourage letter-by-letter reading.
spaced = " ".join("".join(out).split()) spaced = " ".join("".join(out).split())
return spaced return spaced
def run_debug_tts_wavs( def run_debug_tts_wavs(
*, *,
output_root: Path, output_root: Path,
settings: Mapping[str, Any], settings: Mapping[str, Any],
epub_path: Optional[Path] = None, epub_path: Optional[Path] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Generate WAV artifacts for the debug EPUB samples. """Generate WAV artifacts for the debug EPUB samples.
Writes: Writes:
- overall.wav: concatenation of all samples - overall.wav: concatenation of all samples
- case_<CODE>.wav: each sample rendered separately - case_<CODE>.wav: each sample rendered separately
- manifest.json: metadata + file list - manifest.json: metadata + file list
""" """
output_root = Path(output_root) output_root = Path(output_root)
output_root.mkdir(parents=True, exist_ok=True) output_root.mkdir(parents=True, exist_ok=True)
run_id = uuid.uuid4().hex run_id = uuid.uuid4().hex
run_dir = output_root / "debug" / run_id run_dir = output_root / "debug" / run_id
run_dir.mkdir(parents=True, exist_ok=True) run_dir.mkdir(parents=True, exist_ok=True)
if epub_path is None: if epub_path is None:
epub_path = run_dir / "abogen_debug_samples.epub" epub_path = run_dir / "abogen_debug_samples.epub"
build_debug_epub(epub_path) build_debug_epub(epub_path)
else: else:
epub_path = Path(epub_path) epub_path = Path(epub_path)
extraction = extract_from_path(epub_path) extraction = extract_from_path(epub_path)
combined_text = extraction.combined_text or "\n\n".join((c.text or "") for c in extraction.chapters) combined_text = extraction.combined_text or "\n\n".join((c.text or "") for c in extraction.chapters)
cases = _extract_cases_from_text(combined_text) cases = _extract_cases_from_text(combined_text)
# Prefer the canonical sample catalog for text (EPUB extraction may include headings). # Prefer the canonical sample catalog for text (EPUB extraction may include headings).
try: try:
from abogen.debug_tts_samples import DEBUG_TTS_SAMPLES from abogen.debug_tts_samples import DEBUG_TTS_SAMPLES
sample_text_by_code = {sample.code: sample.text for sample in DEBUG_TTS_SAMPLES} sample_text_by_code = {sample.code: sample.text for sample in DEBUG_TTS_SAMPLES}
except Exception: except Exception:
sample_text_by_code = {} sample_text_by_code = {}
expected = list(iter_expected_codes()) expected = list(iter_expected_codes())
found_codes = {code for code, _ in cases} found_codes = {code for code, _ in cases}
missing = [code for code in expected if code not in found_codes] missing = [code for code in expected if code not in found_codes]
if missing: if missing:
raise RuntimeError(f"Debug EPUB missing expected codes: {', '.join(missing)}") raise RuntimeError(f"Debug EPUB missing expected codes: {', '.join(missing)}")
language = str(settings.get("language") or "a").strip() or "a" language = str(settings.get("language") or "a").strip() or "a"
# Kokoro's KPipeline expects short language codes like "a" (American English), # Kokoro's KPipeline expects short language codes like "a" (American English),
# but older settings may store ISO-like values such as "en". # but older settings may store ISO-like values such as "en".
language_aliases = { language_aliases = {
"en": "a", "en": "a",
"en-us": "a", "en-us": "a",
"en_us": "a", "en_us": "a",
"en-gb": "b", "en-gb": "b",
"en_gb": "b", "en_gb": "b",
"es": "e", "es": "e",
"es-es": "e", "es-es": "e",
"fr": "f", "fr": "f",
"fr-fr": "f", "fr-fr": "f",
"hi": "h", "hi": "h",
"it": "i", "it": "i",
"pt": "p", "pt": "p",
"pt-br": "p", "pt-br": "p",
"ja": "j", "ja": "j",
"jp": "j", "jp": "j",
"zh": "z", "zh": "z",
"zh-cn": "z", "zh-cn": "z",
} }
language = language_aliases.get(language.lower(), language) language = language_aliases.get(language.lower(), language)
voice_spec = str(settings.get("default_voice") or "").strip() voice_spec = str(settings.get("default_voice") or "").strip()
use_gpu = bool(settings.get("use_gpu", False)) use_gpu = bool(settings.get("use_gpu", False))
speed = float(settings.get("default_speed", 1.0) or 1.0) speed = float(settings.get("default_speed", 1.0) or 1.0)
# Settings may store "profile:<name>" which is not a Kokoro voice ID. # Settings may store "profile:<name>" which is not a Kokoro voice ID.
# Resolve it to a concrete voice formula (e.g. "af_heart*0.5+...") so Kokoro # Resolve it to a concrete voice formula (e.g. "af_heart*0.5+...") so Kokoro
# doesn't attempt to download a non-existent "voices/profile:<name>.pt". # doesn't attempt to download a non-existent "voices/profile:<name>.pt".
try: try:
resolved_voice, _profile_name, profile_language = _resolve_voice_setting(voice_spec) resolved_voice, _profile_name, profile_language = _resolve_voice_setting(voice_spec)
if resolved_voice: if resolved_voice:
voice_spec = resolved_voice voice_spec = resolved_voice
if profile_language: if profile_language:
language = str(profile_language).strip() or language language = str(profile_language).strip() or language
except Exception: except Exception:
# Voice profile resolution is best-effort; fall back to raw voice_spec. # Voice profile resolution is best-effort; fall back to raw voice_spec.
pass pass
# Best-effort voice caching (only for known Kokoro internal voices). # Best-effort voice caching (only for known Kokoro internal voices).
voice_ids = _spec_to_voice_ids(voice_spec) voice_ids = _spec_to_voice_ids(voice_spec)
if voice_ids: if voice_ids:
try: try:
ensure_voice_assets(voice_ids) ensure_voice_assets(voice_ids)
except Exception: except Exception:
# Network / optional dependency variance; debug runner can still proceed. # Network / optional dependency variance; debug runner can still proceed.
pass pass
pipeline = _load_pipeline(language, use_gpu) pipeline = _load_pipeline(language, use_gpu)
voice_choice = _resolve_voice(pipeline, voice_spec, use_gpu) voice_choice = _resolve_voice(pipeline, voice_spec, use_gpu)
apostrophe_config = build_apostrophe_config(settings=settings) apostrophe_config = build_apostrophe_config(settings=settings)
normalization_settings = dict(settings) normalization_settings = dict(settings)
artifacts: List[DebugWavArtifact] = [] artifacts: List[DebugWavArtifact] = []
overall_path = run_dir / "overall.wav" overall_path = run_dir / "overall.wav"
overall_audio: List[np.ndarray] = [] overall_audio: List[np.ndarray] = []
def synth(text: str, *, apply_normalization: bool = True) -> np.ndarray: def synth(text: str, *, apply_normalization: bool = True) -> np.ndarray:
normalized = ( normalized = (
normalize_for_pipeline( normalize_for_pipeline(
text, text,
config=apostrophe_config, config=apostrophe_config,
settings=normalization_settings, settings=normalization_settings,
) )
if apply_normalization if apply_normalization
else str(text or "") else str(text or "")
) )
parts: List[np.ndarray] = [] parts: List[np.ndarray] = []
for segment in pipeline( for segment in pipeline(
normalized, normalized,
voice=voice_choice, voice=voice_choice,
speed=speed, speed=speed,
split_pattern=SPLIT_PATTERN, split_pattern=SPLIT_PATTERN,
): ):
audio = _to_float32(getattr(segment, "audio", None)) audio = _to_float32(getattr(segment, "audio", None))
if audio.size: if audio.size:
parts.append(audio) parts.append(audio)
if not parts: if not parts:
return np.zeros(0, dtype="float32") return np.zeros(0, dtype="float32")
return np.concatenate(parts).astype("float32", copy=False) return np.concatenate(parts).astype("float32", copy=False)
pause_1s = np.zeros(int(1.0 * SAMPLE_RATE), dtype="float32") pause_1s = np.zeros(int(1.0 * SAMPLE_RATE), dtype="float32")
between_cases = np.zeros(int(0.35 * SAMPLE_RATE), dtype="float32") between_cases = np.zeros(int(0.35 * SAMPLE_RATE), dtype="float32")
# Per sample # Per sample
for code, snippet in cases: for code, snippet in cases:
snippet = sample_text_by_code.get(code, snippet) snippet = sample_text_by_code.get(code, snippet)
if not snippet: if not snippet:
continue continue
id_audio = synth(_spoken_id(code), apply_normalization=False) id_audio = synth(_spoken_id(code), apply_normalization=False)
text_audio = synth(snippet, apply_normalization=True) text_audio = synth(snippet, apply_normalization=True)
audio = np.concatenate([id_audio, pause_1s, text_audio]).astype("float32", copy=False) audio = np.concatenate([id_audio, pause_1s, text_audio]).astype("float32", copy=False)
filename = f"case_{code}.wav" filename = f"case_{code}.wav"
path = run_dir / filename path = run_dir / filename
# Write float32 PCM WAV. # Write float32 PCM WAV.
import soundfile as sf import soundfile as sf
sf.write(path, audio, SAMPLE_RATE, subtype="FLOAT") sf.write(path, audio, SAMPLE_RATE, subtype="FLOAT")
artifacts.append(DebugWavArtifact(label=f"{code}", filename=filename, code=code, text=snippet)) artifacts.append(DebugWavArtifact(label=f"{code}", filename=filename, code=code, text=snippet))
overall_audio.append(audio) overall_audio.append(audio)
overall_audio.append(between_cases) overall_audio.append(between_cases)
# Overall # Overall
if overall_audio: if overall_audio:
combined = np.concatenate(overall_audio).astype("float32", copy=False) combined = np.concatenate(overall_audio).astype("float32", copy=False)
else: else:
combined = np.zeros(0, dtype="float32") combined = np.zeros(0, dtype="float32")
import soundfile as sf import soundfile as sf
sf.write(overall_path, combined, SAMPLE_RATE, subtype="FLOAT") sf.write(overall_path, combined, SAMPLE_RATE, subtype="FLOAT")
artifacts.insert(0, DebugWavArtifact(label="Overall", filename="overall.wav", code=None, text=None)) artifacts.insert(0, DebugWavArtifact(label="Overall", filename="overall.wav", code=None, text=None))
manifest = { manifest = {
"run_id": run_id, "run_id": run_id,
"epub": str(epub_path), "epub": str(epub_path),
"artifacts": [artifact.__dict__ for artifact in artifacts], "artifacts": [artifact.__dict__ for artifact in artifacts],
"sample_rate": SAMPLE_RATE, "sample_rate": SAMPLE_RATE,
} }
(run_dir / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8") (run_dir / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
try: try:
pipeline.dispose() pipeline.dispose()
except Exception: except Exception:
pass pass
return manifest return manifest
+681 -681
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+245 -245
View File
@@ -1,245 +1,245 @@
import io import io
import threading import threading
from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple
import numpy as np import numpy as np
import soundfile as sf import soundfile as sf
from flask import current_app, send_file from flask import current_app, send_file
from flask.typing import ResponseReturnValue from flask.typing import ResponseReturnValue
SPLIT_PATTERN = r"\n+" SPLIT_PATTERN = r"\n+"
SAMPLE_RATE = 24000 SAMPLE_RATE = 24000
_preview_pipelines: Dict[Tuple[str, str], Any] = {} _preview_pipelines: Dict[Tuple[str, str], Any] = {}
_preview_pipeline_lock = threading.Lock() _preview_pipeline_lock = threading.Lock()
def clear_preview_pipelines() -> None: def clear_preview_pipelines() -> None:
"""Dispose all cached preview pipelines and clear the cache.""" """Dispose all cached preview pipelines and clear the cache."""
with _preview_pipeline_lock: with _preview_pipeline_lock:
for pipeline in _preview_pipelines.values(): for pipeline in _preview_pipelines.values():
try: try:
pipeline.dispose() pipeline.dispose()
except Exception: except Exception:
pass pass
_preview_pipelines.clear() _preview_pipelines.clear()
def _select_device() -> str: def _select_device() -> str:
import platform import platform
try: try:
import torch # type: ignore[import-not-found] import torch # type: ignore[import-not-found]
except Exception: except Exception:
return "cpu" return "cpu"
system = platform.system() system = platform.system()
if system == "Darwin" and platform.processor() == "arm": if system == "Darwin" and platform.processor() == "arm":
try: try:
if torch.backends.mps.is_available(): if torch.backends.mps.is_available():
return "mps" return "mps"
except Exception: except Exception:
pass pass
return "cpu" return "cpu"
try: try:
if torch.cuda.is_available(): if torch.cuda.is_available():
return "cuda" return "cuda"
except Exception: except Exception:
pass pass
return "cpu" return "cpu"
def _resolve_pipeline(language: str, use_gpu: bool) -> Tuple[Any, bool]: def _resolve_pipeline(language: str, use_gpu: bool) -> Tuple[Any, bool]:
devices: List[str] = ["cpu"] devices: List[str] = ["cpu"]
if use_gpu: if use_gpu:
preferred = _select_device() preferred = _select_device()
if preferred != "cpu": if preferred != "cpu":
devices.insert(0, preferred) devices.insert(0, preferred)
last_error: Optional[Exception] = None last_error: Optional[Exception] = None
for device in devices: for device in devices:
try: try:
return get_preview_pipeline(language, device), device != "cpu" return get_preview_pipeline(language, device), device != "cpu"
except Exception as exc: except Exception as exc:
last_error = exc last_error = exc
raise RuntimeError("Preview pipeline is unavailable") from last_error raise RuntimeError("Preview pipeline is unavailable") from last_error
def _to_float32(audio_segment) -> np.ndarray: def _to_float32(audio_segment) -> np.ndarray:
if audio_segment is None: if audio_segment is None:
return np.zeros(0, dtype="float32") return np.zeros(0, dtype="float32")
tensor = audio_segment tensor = audio_segment
if hasattr(tensor, "detach"): if hasattr(tensor, "detach"):
tensor = tensor.detach() tensor = tensor.detach()
if hasattr(tensor, "cpu"): if hasattr(tensor, "cpu"):
try: try:
tensor = tensor.cpu() tensor = tensor.cpu()
except Exception: except Exception:
pass pass
if hasattr(tensor, "numpy"): if hasattr(tensor, "numpy"):
return np.asarray(tensor.numpy(), dtype="float32").reshape(-1) return np.asarray(tensor.numpy(), dtype="float32").reshape(-1)
return np.asarray(tensor, dtype="float32").reshape(-1) return np.asarray(tensor, dtype="float32").reshape(-1)
def get_preview_pipeline(language: str, device: str) -> Any: def get_preview_pipeline(language: str, device: str) -> Any:
key = (language, device) key = (language, device)
with _preview_pipeline_lock: with _preview_pipeline_lock:
pipeline = _preview_pipelines.get(key) pipeline = _preview_pipelines.get(key)
if pipeline is not None: if pipeline is not None:
return pipeline return pipeline
from abogen.tts_plugin.utils import create_pipeline from abogen.tts_plugin.utils import create_pipeline
pipeline = create_pipeline("kokoro", lang_code=language, device=device) pipeline = create_pipeline("kokoro", lang_code=language, device=device)
_preview_pipelines[key] = pipeline _preview_pipelines[key] = pipeline
return pipeline return pipeline
def generate_preview_audio( def generate_preview_audio(
text: str, text: str,
voice_spec: str, voice_spec: str,
language: str, language: str,
speed: float, speed: float,
use_gpu: bool, use_gpu: bool,
tts_provider: str = "kokoro", tts_provider: str = "kokoro",
supertonic_total_steps: int = 5, supertonic_total_steps: int = 5,
max_seconds: float = 8.0, max_seconds: float = 8.0,
pronunciation_overrides: Optional[Iterable[Mapping[str, Any]]] = None, pronunciation_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
manual_overrides: Optional[Iterable[Mapping[str, Any]]] = None, manual_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
speakers: Optional[Mapping[str, Any]] = None, speakers: Optional[Mapping[str, Any]] = None,
) -> bytes: ) -> bytes:
if not text.strip(): if not text.strip():
raise ValueError("Preview text is required") raise ValueError("Preview text is required")
provider = (tts_provider or "kokoro").strip().lower() provider = (tts_provider or "kokoro").strip().lower()
# Apply pronunciation/manual overrides first so tokens like `Unfu*k` still match # Apply pronunciation/manual overrides first so tokens like `Unfu*k` still match
# before any downstream normalization potentially strips punctuation. # before any downstream normalization potentially strips punctuation.
source_text = text source_text = text
if pronunciation_overrides or manual_overrides or speakers: if pronunciation_overrides or manual_overrides or speakers:
try: try:
from abogen.webui import conversion_runner as runner from abogen.webui import conversion_runner as runner
class _PreviewJob: class _PreviewJob:
def __init__(self): def __init__(self):
self.language = language self.language = language
self.voice = voice_spec self.voice = voice_spec
self.speakers = speakers self.speakers = speakers
self.manual_overrides = list(manual_overrides or []) self.manual_overrides = list(manual_overrides or [])
self.pronunciation_overrides = list(pronunciation_overrides or []) self.pronunciation_overrides = list(pronunciation_overrides or [])
job = _PreviewJob() job = _PreviewJob()
merged = runner._merge_pronunciation_overrides(job) merged = runner._merge_pronunciation_overrides(job)
rules = runner._compile_pronunciation_rules(merged) rules = runner._compile_pronunciation_rules(merged)
source_text = runner._apply_pronunciation_rules(source_text, rules) source_text = runner._apply_pronunciation_rules(source_text, rules)
except Exception: except Exception:
current_app.logger.exception("Preview override application failed; using raw text") current_app.logger.exception("Preview override application failed; using raw text")
source_text = text source_text = text
normalized_text = source_text normalized_text = source_text
if provider != "supertonic": if provider != "supertonic":
try: try:
from abogen.kokoro_text_normalization import normalize_for_pipeline from abogen.kokoro_text_normalization import normalize_for_pipeline
normalized_text = normalize_for_pipeline(source_text) normalized_text = normalize_for_pipeline(source_text)
except Exception: except Exception:
current_app.logger.exception("Preview normalization failed; using raw text") current_app.logger.exception("Preview normalization failed; using raw text")
normalized_text = source_text normalized_text = source_text
if provider == "supertonic": if provider == "supertonic":
from abogen.tts_plugin.utils import create_pipeline from abogen.tts_plugin.utils import create_pipeline
pipeline = create_pipeline("supertonic") pipeline = create_pipeline("supertonic")
segments = pipeline( segments = pipeline(
normalized_text, normalized_text,
voice=voice_spec, voice=voice_spec,
speed=speed, speed=speed,
split_pattern=SPLIT_PATTERN, split_pattern=SPLIT_PATTERN,
total_steps=supertonic_total_steps, total_steps=supertonic_total_steps,
) )
else: else:
pipeline, pipeline_uses_gpu = _resolve_pipeline(language, use_gpu) pipeline, pipeline_uses_gpu = _resolve_pipeline(language, use_gpu)
if pipeline is None: if pipeline is None:
raise RuntimeError("Preview pipeline is unavailable") raise RuntimeError("Preview pipeline is unavailable")
voice_choice: Any = voice_spec voice_choice: Any = voice_spec
if voice_spec and "*" in voice_spec: if voice_spec and "*" in voice_spec:
from abogen.voice_formulas import get_new_voice from abogen.voice_formulas import get_new_voice
voice_choice = get_new_voice(pipeline, voice_spec, pipeline_uses_gpu) voice_choice = get_new_voice(pipeline, voice_spec, pipeline_uses_gpu)
segments = pipeline( segments = pipeline(
normalized_text, normalized_text,
voice=voice_choice, voice=voice_choice,
speed=speed, speed=speed,
split_pattern=SPLIT_PATTERN, split_pattern=SPLIT_PATTERN,
) )
audio_chunks: List[np.ndarray] = [] audio_chunks: List[np.ndarray] = []
accumulated = 0 accumulated = 0
max_samples = int(max(1.0, max_seconds) * SAMPLE_RATE) max_samples = int(max(1.0, max_seconds) * SAMPLE_RATE)
for segment in segments: for segment in segments:
graphemes = getattr(segment, "graphemes", "").strip() graphemes = getattr(segment, "graphemes", "").strip()
if not graphemes: if not graphemes:
continue continue
audio = _to_float32(getattr(segment, "audio", None)) audio = _to_float32(getattr(segment, "audio", None))
if audio.size == 0: if audio.size == 0:
continue continue
remaining = max_samples - accumulated remaining = max_samples - accumulated
if remaining <= 0: if remaining <= 0:
break break
if audio.shape[0] > remaining: if audio.shape[0] > remaining:
audio = audio[:remaining] audio = audio[:remaining]
audio_chunks.append(audio) audio_chunks.append(audio)
accumulated += audio.shape[0] accumulated += audio.shape[0]
if accumulated >= max_samples: if accumulated >= max_samples:
break break
if not audio_chunks: if not audio_chunks:
raise RuntimeError("Preview could not be generated") raise RuntimeError("Preview could not be generated")
audio_data = np.concatenate(audio_chunks) audio_data = np.concatenate(audio_chunks)
buffer = io.BytesIO() buffer = io.BytesIO()
sf.write(buffer, audio_data, SAMPLE_RATE, format="WAV") sf.write(buffer, audio_data, SAMPLE_RATE, format="WAV")
return buffer.getvalue() return buffer.getvalue()
def synthesize_preview( def synthesize_preview(
text: str, text: str,
voice_spec: str, voice_spec: str,
language: str, language: str,
speed: float, speed: float,
use_gpu: bool, use_gpu: bool,
tts_provider: str = "kokoro", tts_provider: str = "kokoro",
supertonic_total_steps: int = 5, supertonic_total_steps: int = 5,
max_seconds: float = 8.0, max_seconds: float = 8.0,
pronunciation_overrides: Optional[Iterable[Mapping[str, Any]]] = None, pronunciation_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
manual_overrides: Optional[Iterable[Mapping[str, Any]]] = None, manual_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
speakers: Optional[Mapping[str, Any]] = None, speakers: Optional[Mapping[str, Any]] = None,
) -> ResponseReturnValue: ) -> ResponseReturnValue:
try: try:
audio_bytes = generate_preview_audio( audio_bytes = generate_preview_audio(
text=text, text=text,
voice_spec=voice_spec, voice_spec=voice_spec,
language=language, language=language,
speed=speed, speed=speed,
use_gpu=use_gpu, use_gpu=use_gpu,
tts_provider=tts_provider, tts_provider=tts_provider,
supertonic_total_steps=supertonic_total_steps, supertonic_total_steps=supertonic_total_steps,
max_seconds=max_seconds, max_seconds=max_seconds,
pronunciation_overrides=pronunciation_overrides, pronunciation_overrides=pronunciation_overrides,
manual_overrides=manual_overrides, manual_overrides=manual_overrides,
speakers=speakers, speakers=speakers,
) )
except Exception as e: except Exception as e:
raise e raise e
buffer = io.BytesIO(audio_bytes) buffer = io.BytesIO(audio_bytes)
response = send_file( response = send_file(
buffer, buffer,
mimetype="audio/wav", mimetype="audio/wav",
as_attachment=False, as_attachment=False,
download_name="speaker_preview.wav", download_name="speaker_preview.wav",
) )
response.headers["Cache-Control"] = "no-store" response.headers["Cache-Control"] = "no-store"
return response return response
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+177 -177
View File
@@ -1,178 +1,178 @@
"""Kokoro TTS Plugin for the TTS Plugin Architecture. """Kokoro TTS Plugin for the TTS Plugin Architecture.
This plugin provides a Kokoro-based TTS engine that implements the This plugin provides a Kokoro-based TTS engine that implements the
Plugin API contract. It wraps the existing Kokoro backend in the Plugin API contract. It wraps the existing Kokoro backend in the
new Engine/EngineSession architecture. new Engine/EngineSession architecture.
Exports: Exports:
- PLUGIN_MANIFEST: PluginManifest - PLUGIN_MANIFEST: PluginManifest
- MODEL_REQUIREMENTS: list[ModelManifest] - MODEL_REQUIREMENTS: list[ModelManifest]
- create_engine: Factory function - create_engine: Factory function
""" """
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from abogen.tts_plugin.engine import Engine from abogen.tts_plugin.engine import Engine
from abogen.tts_plugin.host_context import HostContext from abogen.tts_plugin.host_context import HostContext
from abogen.tts_plugin.manifest import ( from abogen.tts_plugin.manifest import (
AudioFormatManifest, AudioFormatManifest,
EngineManifest, EngineManifest,
ModelManifest, ModelManifest,
ParameterManifest, ParameterManifest,
PluginManifest, PluginManifest,
RequirementManifest, RequirementManifest,
VoiceManifest, VoiceManifest,
VoiceSourceManifest, VoiceSourceManifest,
) )
from abogen.tts_plugin.types import EngineConfig from abogen.tts_plugin.types import EngineConfig
from .engine import KokoroEngine from .engine import KokoroEngine
def _load_kpipeline() -> Any: def _load_kpipeline() -> Any:
"""Lazy-load Kokoro dependencies.""" """Lazy-load Kokoro dependencies."""
from kokoro import KPipeline # type: ignore[import-not-found] from kokoro import KPipeline # type: ignore[import-not-found]
return KPipeline return KPipeline
PLUGIN_MANIFEST = PluginManifest( PLUGIN_MANIFEST = PluginManifest(
id="kokoro", id="kokoro",
name="Kokoro", name="Kokoro",
version="0.9.4", version="0.9.4",
api_version="1.0", api_version="1.0",
description="Kokoro TTS engine - high quality multilingual text-to-speech", description="Kokoro TTS engine - high quality multilingual text-to-speech",
author="Kokoro Team", author="Kokoro Team",
capabilities=("voice_list",), capabilities=("voice_list",),
requires=RequirementManifest( requires=RequirementManifest(
internet=False, internet=False,
), ),
engine=EngineManifest( engine=EngineManifest(
voiceSources=( voiceSources=(
VoiceSourceManifest( VoiceSourceManifest(
id="builtin", id="builtin",
name="Built-in Voices", name="Built-in Voices",
type="list", type="list",
config={"voices": "See listVoices()"}, config={"voices": "See listVoices()"},
), ),
), ),
parameters=( parameters=(
ParameterManifest( ParameterManifest(
id="speed", id="speed",
name="Speed", name="Speed",
description="Speech speed multiplier", description="Speech speed multiplier",
type="float", type="float",
default=1.0, default=1.0,
min=0.5, min=0.5,
max=2.0, max=2.0,
step=0.1, step=0.1,
), ),
), ),
audioFormats=( audioFormats=(
AudioFormatManifest(mime="audio/wav", extension="wav"), AudioFormatManifest(mime="audio/wav", extension="wav"),
), ),
), ),
voices=( voices=(
VoiceManifest(id="af_alloy", name="Alloy", tags=("en", "female")), VoiceManifest(id="af_alloy", name="Alloy", tags=("en", "female")),
VoiceManifest(id="af_aoede", name="Aoede", tags=("en", "female")), VoiceManifest(id="af_aoede", name="Aoede", tags=("en", "female")),
VoiceManifest(id="af_bella", name="Bella", tags=("en", "female")), VoiceManifest(id="af_bella", name="Bella", tags=("en", "female")),
VoiceManifest(id="af_heart", name="Heart", tags=("en", "female")), VoiceManifest(id="af_heart", name="Heart", tags=("en", "female")),
VoiceManifest(id="af_jessica", name="Jessica", tags=("en", "female")), VoiceManifest(id="af_jessica", name="Jessica", tags=("en", "female")),
VoiceManifest(id="af_kore", name="Kore", tags=("en", "female")), VoiceManifest(id="af_kore", name="Kore", tags=("en", "female")),
VoiceManifest(id="af_nicole", name="Nicole", tags=("en", "female")), VoiceManifest(id="af_nicole", name="Nicole", tags=("en", "female")),
VoiceManifest(id="af_nova", name="Nova", tags=("en", "female")), VoiceManifest(id="af_nova", name="Nova", tags=("en", "female")),
VoiceManifest(id="af_river", name="River", tags=("en", "female")), VoiceManifest(id="af_river", name="River", tags=("en", "female")),
VoiceManifest(id="af_sarah", name="Sarah", tags=("en", "female")), VoiceManifest(id="af_sarah", name="Sarah", tags=("en", "female")),
VoiceManifest(id="af_sky", name="Sky", tags=("en", "female")), VoiceManifest(id="af_sky", name="Sky", tags=("en", "female")),
VoiceManifest(id="am_adam", name="Adam", tags=("en", "male")), VoiceManifest(id="am_adam", name="Adam", tags=("en", "male")),
VoiceManifest(id="am_echo", name="Echo", tags=("en", "male")), VoiceManifest(id="am_echo", name="Echo", tags=("en", "male")),
VoiceManifest(id="am_eric", name="Eric", tags=("en", "male")), VoiceManifest(id="am_eric", name="Eric", tags=("en", "male")),
VoiceManifest(id="am_fenrir", name="Fenrir", tags=("en", "male")), VoiceManifest(id="am_fenrir", name="Fenrir", tags=("en", "male")),
VoiceManifest(id="am_liam", name="Liam", tags=("en", "male")), VoiceManifest(id="am_liam", name="Liam", tags=("en", "male")),
VoiceManifest(id="am_michael", name="Michael", tags=("en", "male")), VoiceManifest(id="am_michael", name="Michael", tags=("en", "male")),
VoiceManifest(id="am_onyx", name="Onyx", tags=("en", "male")), VoiceManifest(id="am_onyx", name="Onyx", tags=("en", "male")),
VoiceManifest(id="am_puck", name="Puck", tags=("en", "male")), VoiceManifest(id="am_puck", name="Puck", tags=("en", "male")),
VoiceManifest(id="am_santa", name="Santa", tags=("en", "male")), VoiceManifest(id="am_santa", name="Santa", tags=("en", "male")),
VoiceManifest(id="bf_alice", name="Alice", tags=("en", "female")), VoiceManifest(id="bf_alice", name="Alice", tags=("en", "female")),
VoiceManifest(id="bf_emma", name="Emma", tags=("en", "female")), VoiceManifest(id="bf_emma", name="Emma", tags=("en", "female")),
VoiceManifest(id="bf_isabella", name="Isabella", tags=("en", "female")), VoiceManifest(id="bf_isabella", name="Isabella", tags=("en", "female")),
VoiceManifest(id="bf_lily", name="Lily", tags=("en", "female")), VoiceManifest(id="bf_lily", name="Lily", tags=("en", "female")),
VoiceManifest(id="bm_daniel", name="Daniel", tags=("en", "male")), VoiceManifest(id="bm_daniel", name="Daniel", tags=("en", "male")),
VoiceManifest(id="bm_fable", name="Fable", tags=("en", "male")), VoiceManifest(id="bm_fable", name="Fable", tags=("en", "male")),
VoiceManifest(id="bm_george", name="George", tags=("en", "male")), VoiceManifest(id="bm_george", name="George", tags=("en", "male")),
VoiceManifest(id="bm_lewis", name="Lewis", tags=("en", "male")), VoiceManifest(id="bm_lewis", name="Lewis", tags=("en", "male")),
VoiceManifest(id="ef_dora", name="Dora", tags=("es", "female")), VoiceManifest(id="ef_dora", name="Dora", tags=("es", "female")),
VoiceManifest(id="em_alex", name="Alex", tags=("es", "male")), VoiceManifest(id="em_alex", name="Alex", tags=("es", "male")),
VoiceManifest(id="em_santa", name="Santa", tags=("es", "male")), VoiceManifest(id="em_santa", name="Santa", tags=("es", "male")),
VoiceManifest(id="ff_siwis", name="Siwis", tags=("fr", "female")), VoiceManifest(id="ff_siwis", name="Siwis", tags=("fr", "female")),
VoiceManifest(id="hf_alpha", name="Alpha", tags=("hi", "female")), VoiceManifest(id="hf_alpha", name="Alpha", tags=("hi", "female")),
VoiceManifest(id="hf_beta", name="Beta", tags=("hi", "female")), VoiceManifest(id="hf_beta", name="Beta", tags=("hi", "female")),
VoiceManifest(id="hm_omega", name="Omega", tags=("hi", "male")), VoiceManifest(id="hm_omega", name="Omega", tags=("hi", "male")),
VoiceManifest(id="hm_psi", name="Psi", tags=("hi", "male")), VoiceManifest(id="hm_psi", name="Psi", tags=("hi", "male")),
VoiceManifest(id="if_sara", name="Sara", tags=("it", "female")), VoiceManifest(id="if_sara", name="Sara", tags=("it", "female")),
VoiceManifest(id="im_nicola", name="Nicola", tags=("it", "male")), VoiceManifest(id="im_nicola", name="Nicola", tags=("it", "male")),
VoiceManifest(id="jf_alpha", name="Alpha", tags=("ja", "female")), VoiceManifest(id="jf_alpha", name="Alpha", tags=("ja", "female")),
VoiceManifest(id="jf_gongitsune", name="Gongitsune", tags=("ja", "female")), VoiceManifest(id="jf_gongitsune", name="Gongitsune", tags=("ja", "female")),
VoiceManifest(id="jf_nezumi", name="Nezumi", tags=("ja", "female")), VoiceManifest(id="jf_nezumi", name="Nezumi", tags=("ja", "female")),
VoiceManifest(id="jf_tebukuro", name="Tebukuro", tags=("ja", "female")), VoiceManifest(id="jf_tebukuro", name="Tebukuro", tags=("ja", "female")),
VoiceManifest(id="jm_kumo", name="Kumo", tags=("ja", "male")), VoiceManifest(id="jm_kumo", name="Kumo", tags=("ja", "male")),
VoiceManifest(id="pf_dora", name="Dora", tags=("pt", "female")), VoiceManifest(id="pf_dora", name="Dora", tags=("pt", "female")),
VoiceManifest(id="pm_alex", name="Alex", tags=("pt", "male")), VoiceManifest(id="pm_alex", name="Alex", tags=("pt", "male")),
VoiceManifest(id="pm_santa", name="Santa", tags=("pt", "male")), VoiceManifest(id="pm_santa", name="Santa", tags=("pt", "male")),
VoiceManifest(id="zf_xiaobei", name="Xiaobei", tags=("zh", "female")), VoiceManifest(id="zf_xiaobei", name="Xiaobei", tags=("zh", "female")),
VoiceManifest(id="zf_xiaoni", name="Xiaoni", tags=("zh", "female")), VoiceManifest(id="zf_xiaoni", name="Xiaoni", tags=("zh", "female")),
VoiceManifest(id="zf_xiaoxiao", name="Xiaoxiao", tags=("zh", "female")), VoiceManifest(id="zf_xiaoxiao", name="Xiaoxiao", tags=("zh", "female")),
VoiceManifest(id="zf_xiaoyi", name="Xiaoyi", tags=("zh", "female")), VoiceManifest(id="zf_xiaoyi", name="Xiaoyi", tags=("zh", "female")),
VoiceManifest(id="zm_yunjian", name="Yunjian", tags=("zh", "male")), VoiceManifest(id="zm_yunjian", name="Yunjian", tags=("zh", "male")),
VoiceManifest(id="zm_yunxi", name="Yunxi", tags=("zh", "male")), VoiceManifest(id="zm_yunxi", name="Yunxi", tags=("zh", "male")),
VoiceManifest(id="zm_yunxia", name="Yunxia", tags=("zh", "female")), VoiceManifest(id="zm_yunxia", name="Yunxia", tags=("zh", "female")),
VoiceManifest(id="zm_yunyang", name="Yunyang", tags=("zh", "male")), VoiceManifest(id="zm_yunyang", name="Yunyang", tags=("zh", "male")),
), ),
) )
MODEL_REQUIREMENTS: list[ModelManifest] = [] MODEL_REQUIREMENTS: list[ModelManifest] = []
def create_engine( def create_engine(
context: HostContext, context: HostContext,
model_path: Path | None, model_path: Path | None,
config: EngineConfig, config: EngineConfig,
) -> Engine: ) -> Engine:
"""Create a Kokoro engine instance. """Create a Kokoro engine instance.
This function is the plugin entry point. It must be atomic: This function is the plugin entry point. It must be atomic:
succeed fully or raise EngineError and clean up. succeed fully or raise EngineError and clean up.
Args: Args:
context: Host services (config dir, logger, http client). context: Host services (config dir, logger, http client).
model_path: Resolved model path, or None for default. model_path: Resolved model path, or None for default.
config: Engine initialization settings (device, etc.). config: Engine initialization settings (device, etc.).
Returns: Returns:
A fully initialized KokoroEngine instance. A fully initialized KokoroEngine instance.
Raises: Raises:
EngineError: On failure. Cleans up partially created resources. EngineError: On failure. Cleans up partially created resources.
""" """
try: try:
KPipeline = _load_kpipeline() KPipeline = _load_kpipeline()
# Determine repo_id from model_path or use default # Determine repo_id from model_path or use default
repo_id = "hexgrad/Kokoro-82M" repo_id = "hexgrad/Kokoro-82M"
if model_path is not None: if model_path is not None:
# If a specific model path is provided, use it as repo_id # If a specific model path is provided, use it as repo_id
repo_id = str(model_path) repo_id = str(model_path)
pipeline = KPipeline( pipeline = KPipeline(
lang_code=config.lang_code, lang_code=config.lang_code,
repo_id=repo_id, repo_id=repo_id,
device=config.device, device=config.device,
) )
engine = KokoroEngine(pipeline) engine = KokoroEngine(pipeline)
return engine return engine
except Exception as e: except Exception as e:
from abogen.tts_plugin.errors import EngineError as EngineErrorClass from abogen.tts_plugin.errors import EngineError as EngineErrorClass
raise EngineErrorClass(f"Failed to create Kokoro engine: {e}") from e raise EngineErrorClass(f"Failed to create Kokoro engine: {e}") from e
+118 -118
View File
@@ -1,118 +1,118 @@
"""Kokoro Engine adapter for the TTS Plugin Architecture. """Kokoro Engine adapter for the TTS Plugin Architecture.
This module adapts the existing Kokoro backend to the new Engine/EngineSession This module adapts the existing Kokoro backend to the new Engine/EngineSession
protocol. It wraps the KokoroBackend without modifying it. protocol. It wraps the KokoroBackend without modifying it.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Any from typing import Any
import numpy as np import numpy as np
from abogen.tts_plugin.capabilities import VoiceLister from abogen.tts_plugin.capabilities import VoiceLister
from abogen.tts_plugin.engine import Engine, EngineSession from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.errors import EngineError from abogen.tts_plugin.errors import EngineError
from abogen.tts_plugin.manifest import VoiceManifest from abogen.tts_plugin.manifest import VoiceManifest
from abogen.tts_plugin.types import ( from abogen.tts_plugin.types import (
AudioFormat, AudioFormat,
Duration, Duration,
SynthesisRequest, SynthesisRequest,
SynthesizedAudio, SynthesizedAudio,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Sample rate for Kokoro audio # Sample rate for Kokoro audio
_KOKORO_SAMPLE_RATE = 24000 _KOKORO_SAMPLE_RATE = 24000
class KokoroSession: class KokoroSession:
"""EngineSession implementation for Kokoro. """EngineSession implementation for Kokoro.
Owns mutable execution state for synthesis. Owns mutable execution state for synthesis.
NOT thread-safe. NOT thread-safe.
""" """
def __init__(self, pipeline: Any) -> None: def __init__(self, pipeline: Any) -> None:
self._pipeline = pipeline self._pipeline = pipeline
self._disposed = False self._disposed = False
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio: def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
"""Synthesize audio from text using Kokoro.""" """Synthesize audio from text using Kokoro."""
if self._disposed: if self._disposed:
raise EngineError("Session disposed") raise EngineError("Session disposed")
try: try:
voice = request.voice.key voice = request.voice.key
speed = request.parameters.values.get("speed", 1.0) speed = request.parameters.values.get("speed", 1.0)
split_pattern = request.parameters.values.get("split_pattern", None) split_pattern = request.parameters.values.get("split_pattern", None)
audio_parts: list[np.ndarray] = [] audio_parts: list[np.ndarray] = []
for segment in self._pipeline( for segment in self._pipeline(
request.text, request.text,
voice=voice, voice=voice,
speed=speed, speed=speed,
split_pattern=split_pattern, split_pattern=split_pattern,
): ):
audio = segment.audio audio = segment.audio
if hasattr(audio, "numpy"): if hasattr(audio, "numpy"):
audio = audio.numpy() audio = audio.numpy()
audio_parts.append(np.asarray(audio, dtype="float32")) audio_parts.append(np.asarray(audio, dtype="float32"))
if not audio_parts: if not audio_parts:
return SynthesizedAudio( return SynthesizedAudio(
data=b"", data=b"",
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=0.0), duration=Duration(seconds=0.0),
) )
combined = np.concatenate(audio_parts).astype("float32", copy=False) combined = np.concatenate(audio_parts).astype("float32", copy=False)
audio_bytes = combined.tobytes() audio_bytes = combined.tobytes()
duration_seconds = len(combined) / _KOKORO_SAMPLE_RATE duration_seconds = len(combined) / _KOKORO_SAMPLE_RATE
return SynthesizedAudio( return SynthesizedAudio(
data=audio_bytes, data=audio_bytes,
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=duration_seconds), duration=Duration(seconds=duration_seconds),
) )
except EngineError: except EngineError:
raise raise
except Exception as e: except Exception as e:
raise EngineError(f"Synthesis failed: {e}") from e raise EngineError(f"Synthesis failed: {e}") from e
def dispose(self) -> None: def dispose(self) -> None:
"""Release session resources. Idempotent.""" """Release session resources. Idempotent."""
self._disposed = True self._disposed = True
class KokoroEngine: class KokoroEngine:
"""Engine implementation for Kokoro. """Engine implementation for Kokoro.
Factory for KokoroSession instances. Stateless and thread-safe. Factory for KokoroSession instances. Stateless and thread-safe.
""" """
def __init__(self, pipeline: Any) -> None: def __init__(self, pipeline: Any) -> None:
self._pipeline = pipeline self._pipeline = pipeline
self._disposed = False self._disposed = False
def createSession(self) -> KokoroSession: def createSession(self) -> KokoroSession:
"""Create a new KokoroSession.""" """Create a new KokoroSession."""
if self._disposed: if self._disposed:
raise EngineError("Engine disposed") raise EngineError("Engine disposed")
return KokoroSession(self._pipeline) return KokoroSession(self._pipeline)
def dispose(self) -> None: def dispose(self) -> None:
"""Release engine resources. Idempotent.""" """Release engine resources. Idempotent."""
self._disposed = True self._disposed = True
def listVoices(self, sourceId: str) -> list[VoiceManifest]: def listVoices(self, sourceId: str) -> list[VoiceManifest]:
"""List available Kokoro voices. Implements VoiceLister capability. """List available Kokoro voices. Implements VoiceLister capability.
Note: Static voices are declared in the plugin manifest. Note: Static voices are declared in the plugin manifest.
This method is a fallback for dynamic plugins. This method is a fallback for dynamic plugins.
""" """
if self._disposed: if self._disposed:
raise EngineError("Engine disposed") raise EngineError("Engine disposed")
return [] return []
+136 -136
View File
@@ -1,136 +1,136 @@
"""SuperTonic TTS Plugin for the TTS Plugin Architecture. """SuperTonic TTS Plugin for the TTS Plugin Architecture.
This plugin provides a SuperTonic-based TTS engine that implements the This plugin provides a SuperTonic-based TTS engine that implements the
Plugin API contract. It wraps the existing SuperTonic backend in the Plugin API contract. It wraps the existing SuperTonic backend in the
new Engine/EngineSession architecture. new Engine/EngineSession architecture.
Exports: Exports:
- PLUGIN_MANIFEST: PluginManifest - PLUGIN_MANIFEST: PluginManifest
- MODEL_REQUIREMENTS: list[ModelManifest] - MODEL_REQUIREMENTS: list[ModelManifest]
- create_engine: Factory function - create_engine: Factory function
""" """
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from abogen.tts_plugin.engine import Engine from abogen.tts_plugin.engine import Engine
from abogen.tts_plugin.host_context import HostContext from abogen.tts_plugin.host_context import HostContext
from abogen.tts_plugin.manifest import ( from abogen.tts_plugin.manifest import (
AudioFormatManifest, AudioFormatManifest,
EngineManifest, EngineManifest,
ModelManifest, ModelManifest,
ParameterManifest, ParameterManifest,
PluginManifest, PluginManifest,
RequirementManifest, RequirementManifest,
VoiceManifest, VoiceManifest,
VoiceSourceManifest, VoiceSourceManifest,
) )
from abogen.tts_plugin.types import EngineConfig from abogen.tts_plugin.types import EngineConfig
from .engine import SuperTonicEngine from .engine import SuperTonicEngine
def _load_supertonic_pipeline() -> Any: def _load_supertonic_pipeline() -> Any:
"""Lazy-load SuperTonic dependencies and create pipeline.""" """Lazy-load SuperTonic dependencies and create pipeline."""
from plugins.supertonic.pipeline import SupertonicPipeline from plugins.supertonic.pipeline import SupertonicPipeline
return SupertonicPipeline( return SupertonicPipeline(
sample_rate=24000, sample_rate=24000,
auto_download=True, auto_download=True,
total_steps=5, total_steps=5,
) )
PLUGIN_MANIFEST = PluginManifest( PLUGIN_MANIFEST = PluginManifest(
id="supertonic", id="supertonic",
name="SuperTonic", name="SuperTonic",
version="0.1.0", version="0.1.0",
api_version="1.0", api_version="1.0",
description="SuperTonic TTS engine - fast high-quality text-to-speech", description="SuperTonic TTS engine - fast high-quality text-to-speech",
author="SuperTonic Team", author="SuperTonic Team",
capabilities=("voice_list",), capabilities=("voice_list",),
requires=RequirementManifest( requires=RequirementManifest(
internet=False, internet=False,
), ),
engine=EngineManifest( engine=EngineManifest(
voiceSources=( voiceSources=(
VoiceSourceManifest( VoiceSourceManifest(
id="builtin", id="builtin",
name="Built-in Voices", name="Built-in Voices",
type="list", type="list",
config={"voices": "See listVoices()"}, config={"voices": "See listVoices()"},
), ),
), ),
parameters=( parameters=(
ParameterManifest( ParameterManifest(
id="speed", id="speed",
name="Speed", name="Speed",
description="Speech speed multiplier", description="Speech speed multiplier",
type="float", type="float",
default=1.0, default=1.0,
min=0.7, min=0.7,
max=2.0, max=2.0,
step=0.1, step=0.1,
), ),
ParameterManifest( ParameterManifest(
id="total_steps", id="total_steps",
name="Quality Steps", name="Quality Steps",
description="Inference steps (higher = better quality, slower)", description="Inference steps (higher = better quality, slower)",
type="int", type="int",
default=5, default=5,
min=2, min=2,
max=15, max=15,
step=1, step=1,
), ),
), ),
audioFormats=( audioFormats=(
AudioFormatManifest(mime="audio/wav", extension="wav"), AudioFormatManifest(mime="audio/wav", extension="wav"),
), ),
), ),
voices=( voices=(
VoiceManifest(id="M1", name="Male 1", tags=("male",)), VoiceManifest(id="M1", name="Male 1", tags=("male",)),
VoiceManifest(id="M2", name="Male 2", tags=("male",)), VoiceManifest(id="M2", name="Male 2", tags=("male",)),
VoiceManifest(id="M3", name="Male 3", tags=("male",)), VoiceManifest(id="M3", name="Male 3", tags=("male",)),
VoiceManifest(id="M4", name="Male 4", tags=("male",)), VoiceManifest(id="M4", name="Male 4", tags=("male",)),
VoiceManifest(id="M5", name="Male 5", tags=("male",)), VoiceManifest(id="M5", name="Male 5", tags=("male",)),
VoiceManifest(id="F1", name="Female 1", tags=("female",)), VoiceManifest(id="F1", name="Female 1", tags=("female",)),
VoiceManifest(id="F2", name="Female 2", tags=("female",)), VoiceManifest(id="F2", name="Female 2", tags=("female",)),
VoiceManifest(id="F3", name="Female 3", tags=("female",)), VoiceManifest(id="F3", name="Female 3", tags=("female",)),
VoiceManifest(id="F4", name="Female 4", tags=("female",)), VoiceManifest(id="F4", name="Female 4", tags=("female",)),
VoiceManifest(id="F5", name="Female 5", tags=("female",)), VoiceManifest(id="F5", name="Female 5", tags=("female",)),
), ),
) )
MODEL_REQUIREMENTS: list[ModelManifest] = [] MODEL_REQUIREMENTS: list[ModelManifest] = []
def create_engine( def create_engine(
context: HostContext, context: HostContext,
model_path: Path | None, model_path: Path | None,
config: EngineConfig, config: EngineConfig,
) -> Engine: ) -> Engine:
"""Create a SuperTonic engine instance. """Create a SuperTonic engine instance.
This function is the plugin entry point. It must be atomic: This function is the plugin entry point. It must be atomic:
succeed fully or raise EngineError and clean up. succeed fully or raise EngineError and clean up.
Args: Args:
context: Host services (config dir, logger, http client). context: Host services (config dir, logger, http client).
model_path: Resolved model path, or None for default. model_path: Resolved model path, or None for default.
config: Engine initialization settings (device, etc.). config: Engine initialization settings (device, etc.).
Returns: Returns:
A fully initialized SuperTonicEngine instance. A fully initialized SuperTonicEngine instance.
Raises: Raises:
EngineError: On failure. Cleans up partially created resources. EngineError: On failure. Cleans up partially created resources.
""" """
try: try:
pipeline = _load_supertonic_pipeline() pipeline = _load_supertonic_pipeline()
engine = SuperTonicEngine(pipeline) engine = SuperTonicEngine(pipeline)
return engine return engine
except Exception as e: except Exception as e:
from abogen.tts_plugin.errors import EngineError as EngineErrorClass from abogen.tts_plugin.errors import EngineError as EngineErrorClass
raise EngineErrorClass(f"Failed to create SuperTonic engine: {e}") from e raise EngineErrorClass(f"Failed to create SuperTonic engine: {e}") from e
+125 -125
View File
@@ -1,125 +1,125 @@
"""SuperTonic Engine adapter for the TTS Plugin Architecture. """SuperTonic Engine adapter for the TTS Plugin Architecture.
This module adapts the existing SuperTonic backend to the new Engine/EngineSession This module adapts the existing SuperTonic backend to the new Engine/EngineSession
protocol. It wraps the SupertonicPipeline without modifying it. protocol. It wraps the SupertonicPipeline without modifying it.
""" """
from __future__ import annotations from __future__ import annotations
import io import io
import logging import logging
from typing import Any from typing import Any
import numpy as np import numpy as np
from abogen.tts_plugin.capabilities import VoiceLister from abogen.tts_plugin.capabilities import VoiceLister
from abogen.tts_plugin.engine import Engine, EngineSession from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.errors import EngineError from abogen.tts_plugin.errors import EngineError
from abogen.tts_plugin.manifest import VoiceManifest from abogen.tts_plugin.manifest import VoiceManifest
from abogen.tts_plugin.types import ( from abogen.tts_plugin.types import (
AudioFormat, AudioFormat,
Duration, Duration,
SynthesisRequest, SynthesisRequest,
SynthesizedAudio, SynthesizedAudio,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Sample rate for SuperTonic audio # Sample rate for SuperTonic audio
_SUPERTONIC_SAMPLE_RATE = 24000 _SUPERTONIC_SAMPLE_RATE = 24000
class SuperTonicSession: class SuperTonicSession:
"""EngineSession implementation for SuperTonic. """EngineSession implementation for SuperTonic.
Owns mutable execution state for synthesis. Owns mutable execution state for synthesis.
NOT thread-safe. NOT thread-safe.
""" """
def __init__(self, pipeline: Any) -> None: def __init__(self, pipeline: Any) -> None:
self._pipeline = pipeline self._pipeline = pipeline
self._disposed = False self._disposed = False
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio: def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
"""Synthesize audio from text using SuperTonic.""" """Synthesize audio from text using SuperTonic."""
if self._disposed: if self._disposed:
raise EngineError("Session disposed") raise EngineError("Session disposed")
try: try:
import soundfile as sf import soundfile as sf
voice = request.voice.key voice = request.voice.key
speed = float(request.parameters.values.get("speed", 1.0)) speed = float(request.parameters.values.get("speed", 1.0))
total_steps = request.parameters.values.get("total_steps", None) total_steps = request.parameters.values.get("total_steps", None)
split_pattern = request.parameters.values.get("split_pattern", None) split_pattern = request.parameters.values.get("split_pattern", None)
if total_steps is not None: if total_steps is not None:
total_steps = int(total_steps) total_steps = int(total_steps)
audio_parts: list[np.ndarray] = [] audio_parts: list[np.ndarray] = []
for segment in self._pipeline( for segment in self._pipeline(
request.text, request.text,
voice=voice, voice=voice,
speed=speed, speed=speed,
split_pattern=split_pattern, split_pattern=split_pattern,
total_steps=total_steps, total_steps=total_steps,
): ):
audio_parts.append(segment.audio) audio_parts.append(segment.audio)
if not audio_parts: if not audio_parts:
return SynthesizedAudio( return SynthesizedAudio(
data=b"", data=b"",
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=0.0), duration=Duration(seconds=0.0),
) )
combined = np.concatenate(audio_parts).astype("float32", copy=False) combined = np.concatenate(audio_parts).astype("float32", copy=False)
buf = io.BytesIO() buf = io.BytesIO()
sf.write(buf, combined, self._pipeline.sample_rate, format="WAV") sf.write(buf, combined, self._pipeline.sample_rate, format="WAV")
audio_bytes = buf.getvalue() audio_bytes = buf.getvalue()
duration_seconds = len(combined) / self._pipeline.sample_rate duration_seconds = len(combined) / self._pipeline.sample_rate
return SynthesizedAudio( return SynthesizedAudio(
data=audio_bytes, data=audio_bytes,
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=duration_seconds), duration=Duration(seconds=duration_seconds),
) )
except EngineError: except EngineError:
raise raise
except Exception as e: except Exception as e:
raise EngineError(f"Synthesis failed: {e}") from e raise EngineError(f"Synthesis failed: {e}") from e
def dispose(self) -> None: def dispose(self) -> None:
"""Release session resources. Idempotent.""" """Release session resources. Idempotent."""
self._disposed = True self._disposed = True
class SuperTonicEngine: class SuperTonicEngine:
"""Engine implementation for SuperTonic. """Engine implementation for SuperTonic.
Factory for SuperTonicSession instances. Stateless and thread-safe. Factory for SuperTonicSession instances. Stateless and thread-safe.
""" """
def __init__(self, pipeline: Any) -> None: def __init__(self, pipeline: Any) -> None:
self._pipeline = pipeline self._pipeline = pipeline
self._disposed = False self._disposed = False
def createSession(self) -> SuperTonicSession: def createSession(self) -> SuperTonicSession:
"""Create a new SuperTonicSession.""" """Create a new SuperTonicSession."""
if self._disposed: if self._disposed:
raise EngineError("Engine disposed") raise EngineError("Engine disposed")
return SuperTonicSession(self._pipeline) return SuperTonicSession(self._pipeline)
def dispose(self) -> None: def dispose(self) -> None:
"""Release engine resources. Idempotent.""" """Release engine resources. Idempotent."""
self._disposed = True self._disposed = True
def listVoices(self, sourceId: str) -> list[VoiceManifest]: def listVoices(self, sourceId: str) -> list[VoiceManifest]:
"""List available SuperTonic voices. Implements VoiceLister capability. """List available SuperTonic voices. Implements VoiceLister capability.
Note: Static voice catalog is declared in plugin manifest. Note: Static voice catalog is declared in plugin manifest.
This method is retained for VoiceLister interface compliance. This method is retained for VoiceLister interface compliance.
""" """
if self._disposed: if self._disposed:
raise EngineError("Engine disposed") raise EngineError("Engine disposed")
return [] return []
+266 -266
View File
@@ -1,266 +1,266 @@
"""SuperTonic Pipeline — self-contained TTS pipeline for the plugin. """SuperTonic Pipeline — self-contained TTS pipeline for the plugin.
This module provides the SuperTonicPipeline class and supporting utilities This module provides the SuperTonicPipeline class and supporting utilities
used by the SuperTonic plugin. It is independent of the legacy used by the SuperTonic plugin. It is independent of the legacy
abogen.tts_backends module. abogen.tts_backends module.
""" """
from __future__ import annotations from __future__ import annotations
import ast import ast
import logging import logging
import re import re
from typing import Any, Iterable, Iterator, Optional from typing import Any, Iterable, Iterator, Optional
import numpy as np import numpy as np
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _ensure_float32_mono(wav: Any) -> np.ndarray: def _ensure_float32_mono(wav: Any) -> np.ndarray:
arr = np.asarray(wav, dtype="float32") arr = np.asarray(wav, dtype="float32")
if arr.ndim == 2: if arr.ndim == 2:
if arr.shape[0] == 1 and arr.shape[1] > 1: if arr.shape[0] == 1 and arr.shape[1] > 1:
arr = arr.reshape(-1) arr = arr.reshape(-1)
else: else:
arr = arr[:, 0] arr = arr[:, 0]
return arr.reshape(-1) return arr.reshape(-1)
def _resample_linear(audio: np.ndarray, src_rate: int, dst_rate: int) -> np.ndarray: def _resample_linear(audio: np.ndarray, src_rate: int, dst_rate: int) -> np.ndarray:
if src_rate == dst_rate: if src_rate == dst_rate:
return audio return audio
if audio.size == 0: if audio.size == 0:
return audio return audio
ratio = dst_rate / float(src_rate) ratio = dst_rate / float(src_rate)
new_len = int(round(audio.size * ratio)) new_len = int(round(audio.size * ratio))
if new_len <= 1: if new_len <= 1:
return np.zeros(0, dtype="float32") return np.zeros(0, dtype="float32")
x_old = np.linspace(0.0, 1.0, num=audio.size, endpoint=False) x_old = np.linspace(0.0, 1.0, num=audio.size, endpoint=False)
x_new = np.linspace(0.0, 1.0, num=new_len, endpoint=False) x_new = np.linspace(0.0, 1.0, num=new_len, endpoint=False)
return np.interp(x_new, x_old, audio).astype("float32", copy=False) return np.interp(x_new, x_old, audio).astype("float32", copy=False)
def _split_text( def _split_text(
text: str, *, split_pattern: Optional[str], max_chunk_length: int text: str, *, split_pattern: Optional[str], max_chunk_length: int
) -> list[str]: ) -> list[str]:
stripped = (text or "").strip() stripped = (text or "").strip()
if not stripped: if not stripped:
return [] return []
parts: list[str] parts: list[str]
if split_pattern: if split_pattern:
try: try:
parts = [p.strip() for p in re.split(split_pattern, stripped) if p.strip()] parts = [p.strip() for p in re.split(split_pattern, stripped) if p.strip()]
except re.error: except re.error:
parts = [stripped] parts = [stripped]
else: else:
parts = [stripped] parts = [stripped]
result: list[str] = [] result: list[str] = []
for part in parts: for part in parts:
if len(part) <= max_chunk_length: if len(part) <= max_chunk_length:
result.append(part) result.append(part)
continue continue
start = 0 start = 0
while start < len(part): while start < len(part):
end = min(len(part), start + max_chunk_length) end = min(len(part), start + max_chunk_length)
if end < len(part): if end < len(part):
ws = part.rfind(" ", start, end) ws = part.rfind(" ", start, end)
if ws > start + 40: if ws > start + 40:
end = ws end = ws
chunk = part[start:end].strip() chunk = part[start:end].strip()
if chunk: if chunk:
result.append(chunk) result.append(chunk)
start = end start = end
return result return result
_UNSUPPORTED_CHARS_RE = re.compile( _UNSUPPORTED_CHARS_RE = re.compile(
r"unsupported character\(s\):\s*(\[[^\]]*\])", re.IGNORECASE r"unsupported character\(s\):\s*(\[[^\]]*\])", re.IGNORECASE
) )
def _parse_unsupported_characters(error: BaseException) -> list[str]: def _parse_unsupported_characters(error: BaseException) -> list[str]:
"""Best-effort extraction of unsupported characters from SuperTonic errors.""" """Best-effort extraction of unsupported characters from SuperTonic errors."""
message = " ".join( message = " ".join(
str(part) for part in getattr(error, "args", ()) if part is not None str(part) for part in getattr(error, "args", ()) if part is not None
) or str(error) ) or str(error)
match = _UNSUPPORTED_CHARS_RE.search(message) match = _UNSUPPORTED_CHARS_RE.search(message)
if not match: if not match:
return [] return []
raw = match.group(1) raw = match.group(1)
try: try:
value = ast.literal_eval(raw) value = ast.literal_eval(raw)
except Exception: except Exception:
return [] return []
if isinstance(value, (list, tuple)): if isinstance(value, (list, tuple)):
out: list[str] = [] out: list[str] = []
for item in value: for item in value:
if item is None: if item is None:
continue continue
s = str(item) s = str(item)
if s: if s:
out.append(s) out.append(s)
return out return out
if isinstance(value, str) and value: if isinstance(value, str) and value:
return [value] return [value]
return [] return []
def _remove_unsupported_characters(text: str, unsupported: Iterable[str]) -> str: def _remove_unsupported_characters(text: str, unsupported: Iterable[str]) -> str:
result = text result = text
for item in unsupported: for item in unsupported:
if not item: if not item:
continue continue
result = result.replace(item, "") result = result.replace(item, "")
return result return result
def _configure_supertonic_gpu() -> None: def _configure_supertonic_gpu() -> None:
"""Patch supertonic's config to enable GPU acceleration if available.""" """Patch supertonic's config to enable GPU acceleration if available."""
try: try:
import onnxruntime as ort import onnxruntime as ort
available = ort.get_available_providers() available = ort.get_available_providers()
providers = [] providers = []
if "CUDAExecutionProvider" in available: if "CUDAExecutionProvider" in available:
providers.append("CUDAExecutionProvider") providers.append("CUDAExecutionProvider")
providers.append("CPUExecutionProvider") providers.append("CPUExecutionProvider")
import supertonic.config as supertonic_config import supertonic.config as supertonic_config
import supertonic.loader as supertonic_loader import supertonic.loader as supertonic_loader
supertonic_config.DEFAULT_ONNX_PROVIDERS = providers supertonic_config.DEFAULT_ONNX_PROVIDERS = providers
supertonic_loader.DEFAULT_ONNX_PROVIDERS = providers supertonic_loader.DEFAULT_ONNX_PROVIDERS = providers
logger.info("Supertonic ONNX providers configured: %s", providers) logger.info("Supertonic ONNX providers configured: %s", providers)
except Exception as exc: except Exception as exc:
logger.warning("Could not configure supertonic GPU providers: %s", exc) logger.warning("Could not configure supertonic GPU providers: %s", exc)
class SupertonicSegment: class SupertonicSegment:
"""A single synthesized audio segment.""" """A single synthesized audio segment."""
__slots__ = ("graphemes", "audio") __slots__ = ("graphemes", "audio")
def __init__(self, graphemes: str, audio: np.ndarray) -> None: def __init__(self, graphemes: str, audio: np.ndarray) -> None:
self.graphemes = graphemes self.graphemes = graphemes
self.audio = audio self.audio = audio
class SupertonicPipeline: class SupertonicPipeline:
"""Minimal adapter that mimics Kokoro's pipeline iteration interface.""" """Minimal adapter that mimics Kokoro's pipeline iteration interface."""
def __init__( def __init__(
self, self,
*, *,
sample_rate: int, sample_rate: int,
auto_download: bool = True, auto_download: bool = True,
total_steps: int = 5, total_steps: int = 5,
max_chunk_length: int = 300, max_chunk_length: int = 300,
) -> None: ) -> None:
self.sample_rate = int(sample_rate) self.sample_rate = int(sample_rate)
self.total_steps = int(total_steps) self.total_steps = int(total_steps)
self.max_chunk_length = int(max_chunk_length) self.max_chunk_length = int(max_chunk_length)
_configure_supertonic_gpu() _configure_supertonic_gpu()
try: try:
from supertonic import TTS # type: ignore[import-not-found] from supertonic import TTS # type: ignore[import-not-found]
except Exception as exc: # pragma: no cover except Exception as exc: # pragma: no cover
raise RuntimeError( raise RuntimeError(
"Supertonic is not installed. Install it with `pip install supertonic`." "Supertonic is not installed. Install it with `pip install supertonic`."
) from exc ) from exc
self._tts = TTS(auto_download=auto_download) self._tts = TTS(auto_download=auto_download)
def __call__( def __call__(
self, self,
text: str, text: str,
*, *,
voice: str, voice: str,
speed: float, speed: float,
split_pattern: Optional[str] = None, split_pattern: Optional[str] = None,
total_steps: Optional[int] = None, total_steps: Optional[int] = None,
) -> Iterator[SupertonicSegment]: ) -> Iterator[SupertonicSegment]:
voice_name = (voice or "").strip() or "M1" voice_name = (voice or "").strip() or "M1"
steps = int(total_steps) if total_steps is not None else self.total_steps steps = int(total_steps) if total_steps is not None else self.total_steps
steps = max(2, min(15, steps)) steps = max(2, min(15, steps))
speed_value = float(speed) if speed is not None else 1.0 speed_value = float(speed) if speed is not None else 1.0
speed_value = max(0.7, min(2.0, speed_value)) speed_value = max(0.7, min(2.0, speed_value))
style = self._tts.get_voice_style(voice_name=voice_name) style = self._tts.get_voice_style(voice_name=voice_name)
chunks = _split_text( chunks = _split_text(
text, split_pattern=split_pattern, max_chunk_length=self.max_chunk_length text, split_pattern=split_pattern, max_chunk_length=self.max_chunk_length
) )
for chunk in chunks: for chunk in chunks:
chunk_to_speak = chunk chunk_to_speak = chunk
removed: set[str] = set() removed: set[str] = set()
last_exc: Exception | None = None last_exc: Exception | None = None
for attempt in range(3): for attempt in range(3):
try: try:
wav, duration = self._tts.synthesize( wav, duration = self._tts.synthesize(
text=chunk_to_speak, text=chunk_to_speak,
voice_style=style, voice_style=style,
total_steps=steps, total_steps=steps,
speed=speed_value, speed=speed_value,
max_chunk_length=self.max_chunk_length, max_chunk_length=self.max_chunk_length,
silence_duration=0.0, silence_duration=0.0,
verbose=False, verbose=False,
) )
break break
except ValueError as exc: except ValueError as exc:
last_exc = exc last_exc = exc
unsupported = _parse_unsupported_characters(exc) unsupported = _parse_unsupported_characters(exc)
if not unsupported: if not unsupported:
raise raise
removed.update(unsupported) removed.update(unsupported)
sanitized = _remove_unsupported_characters( sanitized = _remove_unsupported_characters(
chunk_to_speak, unsupported chunk_to_speak, unsupported
).strip() ).strip()
if sanitized == chunk_to_speak.strip(): if sanitized == chunk_to_speak.strip():
raise raise
chunk_to_speak = sanitized chunk_to_speak = sanitized
if not chunk_to_speak: if not chunk_to_speak:
logger.warning( logger.warning(
"SuperTonic: dropped a chunk after removing unsupported characters: %s", "SuperTonic: dropped a chunk after removing unsupported characters: %s",
sorted(removed), sorted(removed),
) )
break break
if attempt == 0: if attempt == 0:
logger.warning( logger.warning(
"SuperTonic: removed unsupported characters %s and retried.", "SuperTonic: removed unsupported characters %s and retried.",
sorted(removed), sorted(removed),
) )
else: else:
assert last_exc is not None assert last_exc is not None
raise last_exc raise last_exc
if not chunk_to_speak: if not chunk_to_speak:
continue continue
audio = _ensure_float32_mono(wav) audio = _ensure_float32_mono(wav)
src_rate = self.sample_rate src_rate = self.sample_rate
try: try:
dur = float(duration) dur = float(duration)
if dur > 0 and audio.size > 0: if dur > 0 and audio.size > 0:
inferred = int(round(audio.size / dur)) inferred = int(round(audio.size / dur))
if 8000 <= inferred <= 96000: if 8000 <= inferred <= 96000:
src_rate = inferred src_rate = inferred
except Exception: except Exception:
pass pass
if src_rate != self.sample_rate: if src_rate != self.sample_rate:
audio = _resample_linear(audio, src_rate, self.sample_rate) audio = _resample_linear(audio, src_rate, self.sample_rate)
yield SupertonicSegment(graphemes=chunk_to_speak, audio=audio) yield SupertonicSegment(graphemes=chunk_to_speak, audio=audio)
+5 -5
View File
@@ -1,5 +1,5 @@
"""Contract tests for the TTS Plugin API. """Contract tests for the TTS Plugin API.
This package contains reusable contract tests that any TTS plugin implementation This package contains reusable contract tests that any TTS plugin implementation
must satisfy. Tests use only the public API and are engine-agnostic. must satisfy. Tests use only the public API and are engine-agnostic.
""" """
+231 -231
View File
@@ -1,231 +1,231 @@
"""Shared fixtures and stubs for contract tests. """Shared fixtures and stubs for contract tests.
This module provides minimal stub implementations that satisfy the public API This module provides minimal stub implementations that satisfy the public API
for testing purposes. These stubs do NOT contain real business logic. for testing purposes. These stubs do NOT contain real business logic.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Iterator from typing import Iterator
import pytest import pytest
from abogen.tts_plugin.engine import Engine, EngineSession from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.host_context import HostContext from abogen.tts_plugin.host_context import HostContext
from abogen.tts_plugin.types import ( from abogen.tts_plugin.types import (
AudioFormat, AudioFormat,
Duration, Duration,
EngineConfig, EngineConfig,
ParameterValues, ParameterValues,
SynthesisRequest, SynthesisRequest,
SynthesizedAudio, SynthesizedAudio,
VoiceSelection, VoiceSelection,
) )
class FakeHttpClient: class FakeHttpClient:
"""Stub HTTP client that satisfies the HttpClient protocol.""" """Stub HTTP client that satisfies the HttpClient protocol."""
def get(self, url: str, **kwargs: object) -> object: def get(self, url: str, **kwargs: object) -> object:
return None return None
def post(self, url: str, **kwargs: object) -> object: def post(self, url: str, **kwargs: object) -> object:
return None return None
class FakeEngineSession: class FakeEngineSession:
"""Stub EngineSession for testing protocol compliance.""" """Stub EngineSession for testing protocol compliance."""
def __init__(self) -> None: def __init__(self) -> None:
self._disposed = False self._disposed = False
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio: def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
if self._disposed: if self._disposed:
from abogen.tts_plugin.errors import EngineError from abogen.tts_plugin.errors import EngineError
raise EngineError("Session disposed") raise EngineError("Session disposed")
return SynthesizedAudio( return SynthesizedAudio(
data=b"\x00" * 100, data=b"\x00" * 100,
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=1.0), duration=Duration(seconds=1.0),
) )
def dispose(self) -> None: def dispose(self) -> None:
self._disposed = True self._disposed = True
class FakeStreamingSession: class FakeStreamingSession:
"""Stub EngineSession with StreamingSynthesizer capability.""" """Stub EngineSession with StreamingSynthesizer capability."""
def __init__(self) -> None: def __init__(self) -> None:
self._disposed = False self._disposed = False
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio: def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
if self._disposed: if self._disposed:
from abogen.tts_plugin.errors import EngineError from abogen.tts_plugin.errors import EngineError
raise EngineError("Session disposed") raise EngineError("Session disposed")
return SynthesizedAudio( return SynthesizedAudio(
data=b"\x00" * 100, data=b"\x00" * 100,
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=1.0), duration=Duration(seconds=1.0),
) )
def synthesizeStream(self, request: SynthesisRequest) -> Iterator[bytes]: def synthesizeStream(self, request: SynthesisRequest) -> Iterator[bytes]:
if self._disposed: if self._disposed:
from abogen.tts_plugin.errors import EngineError from abogen.tts_plugin.errors import EngineError
raise EngineError("Session disposed") raise EngineError("Session disposed")
for i in range(3): for i in range(3):
yield b"\x00" * 50 yield b"\x00" * 50
def dispose(self) -> None: def dispose(self) -> None:
self._disposed = True self._disposed = True
class FakeCancelableSession: class FakeCancelableSession:
"""Stub EngineSession with CancelableSession capability.""" """Stub EngineSession with CancelableSession capability."""
def __init__(self) -> None: def __init__(self) -> None:
self._disposed = False self._disposed = False
self._cancelled = False self._cancelled = False
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio: def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
if self._disposed: if self._disposed:
from abogen.tts_plugin.errors import EngineError from abogen.tts_plugin.errors import EngineError
raise EngineError("Session disposed") raise EngineError("Session disposed")
if self._cancelled: if self._cancelled:
from abogen.tts_plugin.errors import CancelledError from abogen.tts_plugin.errors import CancelledError
raise CancelledError("Cancelled") raise CancelledError("Cancelled")
return SynthesizedAudio( return SynthesizedAudio(
data=b"\x00" * 100, data=b"\x00" * 100,
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=1.0), duration=Duration(seconds=1.0),
) )
def cancel(self) -> None: def cancel(self) -> None:
if self._disposed: if self._disposed:
from abogen.tts_plugin.errors import EngineError from abogen.tts_plugin.errors import EngineError
raise EngineError("Session disposed") raise EngineError("Session disposed")
self._cancelled = True self._cancelled = True
def dispose(self) -> None: def dispose(self) -> None:
self._disposed = True self._disposed = True
class FakeEngine: class FakeEngine:
"""Stub Engine for testing protocol compliance.""" """Stub Engine for testing protocol compliance."""
def __init__(self, session_class: type = FakeEngineSession) -> None: def __init__(self, session_class: type = FakeEngineSession) -> None:
self._disposed = False self._disposed = False
self._session_class = session_class self._session_class = session_class
def createSession(self) -> EngineSession: def createSession(self) -> EngineSession:
if self._disposed: if self._disposed:
from abogen.tts_plugin.errors import EngineError from abogen.tts_plugin.errors import EngineError
raise EngineError("Engine disposed") raise EngineError("Engine disposed")
return self._session_class() return self._session_class()
def dispose(self) -> None: def dispose(self) -> None:
self._disposed = True self._disposed = True
class FakeVoiceListerEngine: class FakeVoiceListerEngine:
"""Stub Engine that also implements VoiceLister.""" """Stub Engine that also implements VoiceLister."""
def __init__(self) -> None: def __init__(self) -> None:
self._disposed = False self._disposed = False
def createSession(self) -> EngineSession: def createSession(self) -> EngineSession:
if self._disposed: if self._disposed:
from abogen.tts_plugin.errors import EngineError from abogen.tts_plugin.errors import EngineError
raise EngineError("Engine disposed") raise EngineError("Engine disposed")
return FakeEngineSession() return FakeEngineSession()
def listVoices(self, sourceId: str) -> list: def listVoices(self, sourceId: str) -> list:
from abogen.tts_plugin.manifest import VoiceManifest from abogen.tts_plugin.manifest import VoiceManifest
return [ return [
VoiceManifest(id="voice1", name="Voice 1", tags=("en",)), VoiceManifest(id="voice1", name="Voice 1", tags=("en",)),
VoiceManifest(id="voice2", name="Voice 2", tags=("es",)), VoiceManifest(id="voice2", name="Voice 2", tags=("es",)),
] ]
def dispose(self) -> None: def dispose(self) -> None:
self._disposed = True self._disposed = True
class FakePreviewEngine: class FakePreviewEngine:
"""Stub Engine that also implements PreviewGenerator.""" """Stub Engine that also implements PreviewGenerator."""
def __init__(self) -> None: def __init__(self) -> None:
self._disposed = False self._disposed = False
def createSession(self) -> EngineSession: def createSession(self) -> EngineSession:
if self._disposed: if self._disposed:
from abogen.tts_plugin.errors import EngineError from abogen.tts_plugin.errors import EngineError
raise EngineError("Engine disposed") raise EngineError("Engine disposed")
return FakeEngineSession() return FakeEngineSession()
def generatePreview(self, voice: VoiceSelection, text: str) -> SynthesizedAudio: def generatePreview(self, voice: VoiceSelection, text: str) -> SynthesizedAudio:
return SynthesizedAudio( return SynthesizedAudio(
data=b"\x00" * 50, data=b"\x00" * 50,
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=0.5), duration=Duration(seconds=0.5),
) )
def dispose(self) -> None: def dispose(self) -> None:
self._disposed = True self._disposed = True
@pytest.fixture @pytest.fixture
def fake_http_client() -> FakeHttpClient: def fake_http_client() -> FakeHttpClient:
return FakeHttpClient() return FakeHttpClient()
@pytest.fixture @pytest.fixture
def host_context(tmp_path: Path, fake_http_client: FakeHttpClient) -> HostContext: def host_context(tmp_path: Path, fake_http_client: FakeHttpClient) -> HostContext:
return HostContext( return HostContext(
config_dir=tmp_path, config_dir=tmp_path,
logger=logging.getLogger("test"), logger=logging.getLogger("test"),
http_client=fake_http_client, http_client=fake_http_client,
) )
@pytest.fixture @pytest.fixture
def fake_engine() -> FakeEngine: def fake_engine() -> FakeEngine:
return FakeEngine() return FakeEngine()
@pytest.fixture @pytest.fixture
def fake_session() -> FakeEngineSession: def fake_session() -> FakeEngineSession:
return FakeEngineSession() return FakeEngineSession()
@pytest.fixture @pytest.fixture
def default_voice() -> VoiceSelection: def default_voice() -> VoiceSelection:
return VoiceSelection(source="builtin", key="af_nova") return VoiceSelection(source="builtin", key="af_nova")
@pytest.fixture @pytest.fixture
def default_format() -> AudioFormat: def default_format() -> AudioFormat:
return AudioFormat(mime="audio/wav", extension="wav") return AudioFormat(mime="audio/wav", extension="wav")
@pytest.fixture @pytest.fixture
def default_request( def default_request(
default_voice: VoiceSelection, default_format: AudioFormat default_voice: VoiceSelection, default_format: AudioFormat
) -> SynthesisRequest: ) -> SynthesisRequest:
return SynthesisRequest( return SynthesisRequest(
text="Hello, world!", text="Hello, world!",
voice=default_voice, voice=default_voice,
parameters=ParameterValues(values={}), parameters=ParameterValues(values={}),
format=default_format, format=default_format,
) )
+120 -120
View File
@@ -1,120 +1,120 @@
"""Base contract tests for Engine implementations. """Base contract tests for Engine implementations.
Any new TTS plugin must inherit from these classes to verify Any new TTS plugin must inherit from these classes to verify
it satisfies the Engine/EngineSession protocol. it satisfies the Engine/EngineSession protocol.
Usage: Usage:
from tests.contracts.engine_contract import EngineContractMixin from tests.contracts.engine_contract import EngineContractMixin
class TestMyEngine(EngineContractMixin): class TestMyEngine(EngineContractMixin):
@pytest.fixture @pytest.fixture
def engine(self): def engine(self):
return create_my_engine() return create_my_engine()
""" """
from __future__ import annotations from __future__ import annotations
import pytest import pytest
from abogen.tts_plugin.engine import Engine, EngineSession from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.errors import EngineError from abogen.tts_plugin.errors import EngineError
from abogen.tts_plugin.types import ( from abogen.tts_plugin.types import (
AudioFormat, AudioFormat,
ParameterValues, ParameterValues,
SynthesisRequest, SynthesisRequest,
SynthesizedAudio, SynthesizedAudio,
VoiceSelection, VoiceSelection,
) )
class EngineContractMixin: class EngineContractMixin:
"""Base contract tests for Engine implementations. """Base contract tests for Engine implementations.
Subclasses must define a module-level ``engine`` fixture returning Subclasses must define a module-level ``engine`` fixture returning
a fully initialized Engine instance. The tests below will use it a fully initialized Engine instance. The tests below will use it
via pytest's standard fixture resolution. via pytest's standard fixture resolution.
""" """
def _req(self, text: str = "Hello", voice: str | None = None) -> SynthesisRequest: def _req(self, text: str = "Hello", voice: str | None = None) -> SynthesisRequest:
return SynthesisRequest( return SynthesisRequest(
text=text, text=text,
voice=VoiceSelection(source="builtin", key=voice or "default"), voice=VoiceSelection(source="builtin", key=voice or "default"),
parameters=ParameterValues(values={}), parameters=ParameterValues(values={}),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
) )
# ── Engine protocol ────────────────────────────────────── # ── Engine protocol ──────────────────────────────────────
def test_engine_satisfies_protocol(self, engine: Engine) -> None: def test_engine_satisfies_protocol(self, engine: Engine) -> None:
assert isinstance(engine, Engine) assert isinstance(engine, Engine)
def test_create_session_returns_session(self, engine: Engine) -> None: def test_create_session_returns_session(self, engine: Engine) -> None:
session = engine.createSession() session = engine.createSession()
assert isinstance(session, EngineSession) assert isinstance(session, EngineSession)
session.dispose() session.dispose()
def test_create_session_returns_new_instances(self, engine: Engine) -> None: def test_create_session_returns_new_instances(self, engine: Engine) -> None:
s1 = engine.createSession() s1 = engine.createSession()
s2 = engine.createSession() s2 = engine.createSession()
assert s1 is not s2 assert s1 is not s2
s1.dispose() s1.dispose()
s2.dispose() s2.dispose()
def test_dispose_is_idempotent(self, engine: Engine) -> None: def test_dispose_is_idempotent(self, engine: Engine) -> None:
engine.dispose() engine.dispose()
engine.dispose() engine.dispose()
def test_create_session_after_dispose_raises(self, engine: Engine) -> None: def test_create_session_after_dispose_raises(self, engine: Engine) -> None:
engine.dispose() engine.dispose()
with pytest.raises(EngineError): with pytest.raises(EngineError):
engine.createSession() engine.createSession()
# ── Session protocol ───────────────────────────────────── # ── Session protocol ─────────────────────────────────────
def test_session_satisfies_protocol(self, engine: Engine) -> None: def test_session_satisfies_protocol(self, engine: Engine) -> None:
session = engine.createSession() session = engine.createSession()
assert isinstance(session, EngineSession) assert isinstance(session, EngineSession)
session.dispose() session.dispose()
engine.dispose() engine.dispose()
def test_session_synthesize_returns_audio(self, engine: Engine) -> None: def test_session_synthesize_returns_audio(self, engine: Engine) -> None:
session = engine.createSession() session = engine.createSession()
result = session.synthesize(self._req()) result = session.synthesize(self._req())
assert isinstance(result, SynthesizedAudio) assert isinstance(result, SynthesizedAudio)
assert isinstance(result.data, bytes) assert isinstance(result.data, bytes)
assert len(result.data) > 0 assert len(result.data) > 0
session.dispose() session.dispose()
engine.dispose() engine.dispose()
def test_session_dispose_is_idempotent(self, engine: Engine) -> None: def test_session_dispose_is_idempotent(self, engine: Engine) -> None:
session = engine.createSession() session = engine.createSession()
session.dispose() session.dispose()
session.dispose() session.dispose()
engine.dispose() engine.dispose()
def test_session_synthesize_after_dispose_raises(self, engine: Engine) -> None: def test_session_synthesize_after_dispose_raises(self, engine: Engine) -> None:
session = engine.createSession() session = engine.createSession()
session.dispose() session.dispose()
with pytest.raises(EngineError): with pytest.raises(EngineError):
session.synthesize(self._req()) session.synthesize(self._req())
engine.dispose() engine.dispose()
def test_session_multiple_synthesize(self, engine: Engine) -> None: def test_session_multiple_synthesize(self, engine: Engine) -> None:
session = engine.createSession() session = engine.createSession()
r1 = session.synthesize(self._req()) r1 = session.synthesize(self._req())
r2 = session.synthesize(self._req()) r2 = session.synthesize(self._req())
assert isinstance(r1.data, bytes) assert isinstance(r1.data, bytes)
assert isinstance(r2.data, bytes) assert isinstance(r2.data, bytes)
session.dispose() session.dispose()
engine.dispose() engine.dispose()
# ── Lifecycle ──────────────────────────────────────────── # ── Lifecycle ────────────────────────────────────────────
def test_full_lifecycle(self, engine: Engine) -> None: def test_full_lifecycle(self, engine: Engine) -> None:
s1 = engine.createSession() s1 = engine.createSession()
s2 = engine.createSession() s2 = engine.createSession()
s1.synthesize(self._req()) s1.synthesize(self._req())
s2.synthesize(self._req()) s2.synthesize(self._req())
s1.dispose() s1.dispose()
s2.dispose() s2.dispose()
engine.dispose() engine.dispose()
+183 -183
View File
@@ -1,183 +1,183 @@
"""Contract tests for capability interfaces. """Contract tests for capability interfaces.
These tests verify that capability interfaces satisfy the architectural requirements: These tests verify that capability interfaces satisfy the architectural requirements:
- VoiceLister: lists voices for a source - VoiceLister: lists voices for a source
- PreviewGenerator: generates preview audio - PreviewGenerator: generates preview audio
- StreamingSynthesizer: yields audio chunks - StreamingSynthesizer: yields audio chunks
- CancelableSession: cancels in-progress synthesis - CancelableSession: cancels in-progress synthesis
""" """
import pytest import pytest
from abogen.tts_plugin.capabilities import ( from abogen.tts_plugin.capabilities import (
CancelableSession, CancelableSession,
PreviewGenerator, PreviewGenerator,
StreamingSynthesizer, StreamingSynthesizer,
VoiceLister, VoiceLister,
) )
from abogen.tts_plugin.errors import CancelledError, EngineError from abogen.tts_plugin.errors import CancelledError, EngineError
from abogen.tts_plugin.manifest import VoiceManifest from abogen.tts_plugin.manifest import VoiceManifest
from abogen.tts_plugin.types import ( from abogen.tts_plugin.types import (
AudioFormat, AudioFormat,
Duration, Duration,
ParameterValues, ParameterValues,
SynthesisRequest, SynthesisRequest,
SynthesizedAudio, SynthesizedAudio,
VoiceSelection, VoiceSelection,
) )
from .conftest import FakeCancelableSession, FakeStreamingSession, FakeVoiceListerEngine from .conftest import FakeCancelableSession, FakeStreamingSession, FakeVoiceListerEngine
class TestVoiceListerProtocolContract: class TestVoiceListerProtocolContract:
"""Contract tests for VoiceLister protocol.""" """Contract tests for VoiceLister protocol."""
def test_voice_lister_is_protocol(self) -> None: def test_voice_lister_is_protocol(self) -> None:
assert hasattr(VoiceLister, "__protocol_attrs__") assert hasattr(VoiceLister, "__protocol_attrs__")
def test_voice_lister_satisfied_by_engine(self) -> None: def test_voice_lister_satisfied_by_engine(self) -> None:
engine = FakeVoiceListerEngine() engine = FakeVoiceListerEngine()
assert isinstance(engine, VoiceLister) assert isinstance(engine, VoiceLister)
def test_list_voices_returns_list(self) -> None: def test_list_voices_returns_list(self) -> None:
engine = FakeVoiceListerEngine() engine = FakeVoiceListerEngine()
voices = engine.listVoices("builtin") voices = engine.listVoices("builtin")
assert isinstance(voices, list) assert isinstance(voices, list)
def test_list_voices_returns_voice_manifests(self) -> None: def test_list_voices_returns_voice_manifests(self) -> None:
engine = FakeVoiceListerEngine() engine = FakeVoiceListerEngine()
voices = engine.listVoices("builtin") voices = engine.listVoices("builtin")
for voice in voices: for voice in voices:
assert isinstance(voice, VoiceManifest) assert isinstance(voice, VoiceManifest)
def test_list_voices_has_required_fields(self) -> None: def test_list_voices_has_required_fields(self) -> None:
engine = FakeVoiceListerEngine() engine = FakeVoiceListerEngine()
voices = engine.listVoices("builtin") voices = engine.listVoices("builtin")
for voice in voices: for voice in voices:
assert hasattr(voice, "id") assert hasattr(voice, "id")
assert hasattr(voice, "name") assert hasattr(voice, "name")
assert hasattr(voice, "tags") assert hasattr(voice, "tags")
class TestPreviewGeneratorProtocolContract: class TestPreviewGeneratorProtocolContract:
"""Contract tests for PreviewGenerator protocol.""" """Contract tests for PreviewGenerator protocol."""
def test_preview_generator_is_protocol(self) -> None: def test_preview_generator_is_protocol(self) -> None:
assert hasattr(PreviewGenerator, "__protocol_attrs__") assert hasattr(PreviewGenerator, "__protocol_attrs__")
def test_preview_generator_satisfied_by_engine(self) -> None: def test_preview_generator_satisfied_by_engine(self) -> None:
from .conftest import FakePreviewEngine from .conftest import FakePreviewEngine
engine = FakePreviewEngine() engine = FakePreviewEngine()
assert isinstance(engine, PreviewGenerator) assert isinstance(engine, PreviewGenerator)
def test_generate_preview_returns_synthesized_audio(self) -> None: def test_generate_preview_returns_synthesized_audio(self) -> None:
from .conftest import FakePreviewEngine from .conftest import FakePreviewEngine
engine = FakePreviewEngine() engine = FakePreviewEngine()
voice = VoiceSelection(source="builtin", key="af_nova") voice = VoiceSelection(source="builtin", key="af_nova")
result = engine.generatePreview(voice, "Hello") result = engine.generatePreview(voice, "Hello")
assert isinstance(result, SynthesizedAudio) assert isinstance(result, SynthesizedAudio)
def test_generate_preview_has_valid_data(self) -> None: def test_generate_preview_has_valid_data(self) -> None:
from .conftest import FakePreviewEngine from .conftest import FakePreviewEngine
engine = FakePreviewEngine() engine = FakePreviewEngine()
voice = VoiceSelection(source="builtin", key="af_nova") voice = VoiceSelection(source="builtin", key="af_nova")
result = engine.generatePreview(voice, "Hello") result = engine.generatePreview(voice, "Hello")
assert isinstance(result.data, bytes) assert isinstance(result.data, bytes)
assert len(result.data) > 0 assert len(result.data) > 0
class TestStreamingSynthesizerProtocolContract: class TestStreamingSynthesizerProtocolContract:
"""Contract tests for StreamingSynthesizer protocol.""" """Contract tests for StreamingSynthesizer protocol."""
def test_streaming_synthesizer_is_protocol(self) -> None: def test_streaming_synthesizer_is_protocol(self) -> None:
assert hasattr(StreamingSynthesizer, "__protocol_attrs__") assert hasattr(StreamingSynthesizer, "__protocol_attrs__")
def test_streaming_session_satisfies_protocol(self) -> None: def test_streaming_session_satisfies_protocol(self) -> None:
session = FakeStreamingSession() session = FakeStreamingSession()
assert isinstance(session, StreamingSynthesizer) assert isinstance(session, StreamingSynthesizer)
def test_synthesize_stream_yields_bytes(self) -> None: def test_synthesize_stream_yields_bytes(self) -> None:
session = FakeStreamingSession() session = FakeStreamingSession()
request = SynthesisRequest( request = SynthesisRequest(
text="Hello", text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"), voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(), parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
) )
chunks = list(session.synthesizeStream(request)) chunks = list(session.synthesizeStream(request))
assert len(chunks) > 0 assert len(chunks) > 0
for chunk in chunks: for chunk in chunks:
assert isinstance(chunk, bytes) assert isinstance(chunk, bytes)
def test_streaming_iterator_exhaustion(self) -> None: def test_streaming_iterator_exhaustion(self) -> None:
"""Architecture spec: Iterator exhaustion = synthesis complete.""" """Architecture spec: Iterator exhaustion = synthesis complete."""
session = FakeStreamingSession() session = FakeStreamingSession()
request = SynthesisRequest( request = SynthesisRequest(
text="Hello", text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"), voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(), parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
) )
chunks = list(session.synthesizeStream(request)) chunks = list(session.synthesizeStream(request))
assert len(chunks) == 3 assert len(chunks) == 3
def test_streaming_after_dispose_raises(self) -> None: def test_streaming_after_dispose_raises(self) -> None:
"""Architecture spec: After dispose(), methods raise EngineError.""" """Architecture spec: After dispose(), methods raise EngineError."""
session = FakeStreamingSession() session = FakeStreamingSession()
session.dispose() session.dispose()
request = SynthesisRequest( request = SynthesisRequest(
text="Hello", text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"), voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(), parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
) )
with pytest.raises(EngineError): with pytest.raises(EngineError):
list(session.synthesizeStream(request)) list(session.synthesizeStream(request))
class TestCancelableSessionProtocolContract: class TestCancelableSessionProtocolContract:
"""Contract tests for CancelableSession protocol.""" """Contract tests for CancelableSession protocol."""
def test_cancelable_session_is_protocol(self) -> None: def test_cancelable_session_is_protocol(self) -> None:
assert hasattr(CancelableSession, "__protocol_attrs__") assert hasattr(CancelableSession, "__protocol_attrs__")
def test_cancelable_session_satisfies_protocol(self) -> None: def test_cancelable_session_satisfies_protocol(self) -> None:
session = FakeCancelableSession() session = FakeCancelableSession()
assert isinstance(session, CancelableSession) assert isinstance(session, CancelableSession)
def test_cancel_causes_synthesize_to_raise_cancelled(self) -> None: def test_cancel_causes_synthesize_to_raise_cancelled(self) -> None:
"""Architecture spec: cancel() causes synthesize() to raise CancelledError.""" """Architecture spec: cancel() causes synthesize() to raise CancelledError."""
session = FakeCancelableSession() session = FakeCancelableSession()
request = SynthesisRequest( request = SynthesisRequest(
text="Hello", text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"), voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(), parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
) )
# Cancel # Cancel
session.cancel() session.cancel()
# synthesize should raise CancelledError # synthesize should raise CancelledError
with pytest.raises(CancelledError): with pytest.raises(CancelledError):
session.synthesize(request) session.synthesize(request)
def test_cancel_after_dispose_raises(self) -> None: def test_cancel_after_dispose_raises(self) -> None:
"""Architecture spec: cancel() raises EngineError if called after dispose().""" """Architecture spec: cancel() raises EngineError if called after dispose()."""
session = FakeCancelableSession() session = FakeCancelableSession()
session.dispose() session.dispose()
with pytest.raises(EngineError): with pytest.raises(EngineError):
session.cancel() session.cancel()
def test_session_usable_after_cancel(self) -> None: def test_session_usable_after_cancel(self) -> None:
"""Architecture spec: EngineSession remains usable after cancellation.""" """Architecture spec: EngineSession remains usable after cancellation."""
session = FakeCancelableSession() session = FakeCancelableSession()
# Cancel # Cancel
session.cancel() session.cancel()
# Dispose and create new session for synthesis # Dispose and create new session for synthesis
session.dispose() session.dispose()
+106 -106
View File
@@ -1,106 +1,106 @@
"""Contract tests for Engine protocol. """Contract tests for Engine protocol.
These tests verify that Engine implementations satisfy the architectural requirements: These tests verify that Engine implementations satisfy the architectural requirements:
- createSession() returns EngineSession - createSession() returns EngineSession
- dispose() is idempotent - dispose() is idempotent
- After dispose(), createSession() raises EngineError - After dispose(), createSession() raises EngineError
- Engine is thread-safe for createSession() - Engine is thread-safe for createSession()
""" """
import pytest import pytest
from abogen.tts_plugin.engine import Engine, EngineSession from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.errors import EngineError from abogen.tts_plugin.errors import EngineError
from .conftest import FakeEngine, FakeEngineSession from .conftest import FakeEngine, FakeEngineSession
class TestEngineProtocolContract: class TestEngineProtocolContract:
"""Contract tests for the Engine protocol itself.""" """Contract tests for the Engine protocol itself."""
def test_engine_is_protocol(self) -> None: def test_engine_is_protocol(self) -> None:
assert hasattr(Engine, "__protocol_attrs__") assert hasattr(Engine, "__protocol_attrs__")
def test_engine_session_is_protocol(self) -> None: def test_engine_session_is_protocol(self) -> None:
assert hasattr(EngineSession, "__protocol_attrs__") assert hasattr(EngineSession, "__protocol_attrs__")
def test_fake_engine_satisfies_protocol(self) -> None: def test_fake_engine_satisfies_protocol(self) -> None:
engine = FakeEngine() engine = FakeEngine()
assert isinstance(engine, Engine) assert isinstance(engine, Engine)
def test_fake_session_satisfies_protocol(self) -> None: def test_fake_session_satisfies_protocol(self) -> None:
session = FakeEngineSession() session = FakeEngineSession()
assert isinstance(session, EngineSession) assert isinstance(session, EngineSession)
class TestEngineCreateSessionContract: class TestEngineCreateSessionContract:
"""Contract tests for Engine.createSession().""" """Contract tests for Engine.createSession()."""
def test_create_session_returns_engine_session(self) -> None: def test_create_session_returns_engine_session(self) -> None:
engine = FakeEngine() engine = FakeEngine()
session = engine.createSession() session = engine.createSession()
assert isinstance(session, EngineSession) assert isinstance(session, EngineSession)
def test_create_session_returns_new_instance(self) -> None: def test_create_session_returns_new_instance(self) -> None:
engine = FakeEngine() engine = FakeEngine()
session1 = engine.createSession() session1 = engine.createSession()
session2 = engine.createSession() session2 = engine.createSession()
assert session1 is not session2 assert session1 is not session2
def test_create_session_ownership_transfers(self) -> None: def test_create_session_ownership_transfers(self) -> None:
"""Architecture spec: Ownership transfers to caller.""" """Architecture spec: Ownership transfers to caller."""
engine = FakeEngine() engine = FakeEngine()
session = engine.createSession() session = engine.createSession()
assert isinstance(session, EngineSession) assert isinstance(session, EngineSession)
class TestEngineDisposeContract: class TestEngineDisposeContract:
"""Contract tests for Engine.dispose().""" """Contract tests for Engine.dispose()."""
def test_dispose_is_idempotent(self) -> None: def test_dispose_is_idempotent(self) -> None:
"""Architecture spec: dispose() is idempotent.""" """Architecture spec: dispose() is idempotent."""
engine = FakeEngine() engine = FakeEngine()
engine.dispose() engine.dispose()
engine.dispose() # Should not raise engine.dispose() # Should not raise
def test_dispose_never_raises(self) -> None: def test_dispose_never_raises(self) -> None:
"""Architecture spec: dispose() never raises exceptions.""" """Architecture spec: dispose() never raises exceptions."""
engine = FakeEngine() engine = FakeEngine()
engine.dispose() # Should not raise engine.dispose() # Should not raise
def test_create_session_after_dispose_raises(self) -> None: def test_create_session_after_dispose_raises(self) -> None:
"""Architecture spec: After dispose(), all methods except dispose() raise EngineError.""" """Architecture spec: After dispose(), all methods except dispose() raise EngineError."""
engine = FakeEngine() engine = FakeEngine()
engine.dispose() engine.dispose()
with pytest.raises(EngineError): with pytest.raises(EngineError):
engine.createSession() engine.createSession()
class TestEngineLifecycleContract: class TestEngineLifecycleContract:
"""Contract tests for Engine lifecycle.""" """Contract tests for Engine lifecycle."""
def test_full_lifecycle(self) -> None: def test_full_lifecycle(self) -> None:
"""Test complete engine lifecycle: create -> sessions -> dispose.""" """Test complete engine lifecycle: create -> sessions -> dispose."""
engine = FakeEngine() engine = FakeEngine()
# Create sessions # Create sessions
session1 = engine.createSession() session1 = engine.createSession()
session2 = engine.createSession() session2 = engine.createSession()
# Use sessions # Use sessions
assert isinstance(session1, EngineSession) assert isinstance(session1, EngineSession)
assert isinstance(session2, EngineSession) assert isinstance(session2, EngineSession)
# Dispose sessions # Dispose sessions
session1.dispose() session1.dispose()
session2.dispose() session2.dispose()
# Dispose engine # Dispose engine
engine.dispose() engine.dispose()
def test_engine_disposed_session_raises(self) -> None: def test_engine_disposed_session_raises(self) -> None:
"""Architecture spec: After dispose(), all methods except dispose() raise EngineError.""" """Architecture spec: After dispose(), all methods except dispose() raise EngineError."""
engine = FakeEngine() engine = FakeEngine()
engine.dispose() engine.dispose()
with pytest.raises(EngineError): with pytest.raises(EngineError):
engine.createSession() engine.createSession()
+85 -85
View File
@@ -1,85 +1,85 @@
"""Contract tests for error hierarchy. """Contract tests for error hierarchy.
These tests verify that the error hierarchy satisfies the architectural requirements: These tests verify that the error hierarchy satisfies the architectural requirements:
- All errors inherit from EngineError - All errors inherit from EngineError
- EngineError inherits from Exception - EngineError inherits from Exception
- Each error type is properly classified - Each error type is properly classified
""" """
import pytest import pytest
from abogen.tts_plugin.errors import ( from abogen.tts_plugin.errors import (
CancelledError, CancelledError,
ConfigurationError, ConfigurationError,
EngineError, EngineError,
InternalError, InternalError,
InvalidInputError, InvalidInputError,
ModelLoadError, ModelLoadError,
ModelNotFoundError, ModelNotFoundError,
NetworkError, NetworkError,
) )
class TestErrorHierarchyContract: class TestErrorHierarchyContract:
"""Contract tests for the error hierarchy.""" """Contract tests for the error hierarchy."""
def test_engine_error_is_exception(self) -> None: def test_engine_error_is_exception(self) -> None:
assert issubclass(EngineError, Exception) assert issubclass(EngineError, Exception)
def test_all_errors_inherit_from_engine_error(self) -> None: def test_all_errors_inherit_from_engine_error(self) -> None:
error_classes = [ error_classes = [
ModelNotFoundError, ModelNotFoundError,
ModelLoadError, ModelLoadError,
NetworkError, NetworkError,
InvalidInputError, InvalidInputError,
ConfigurationError, ConfigurationError,
CancelledError, CancelledError,
InternalError, InternalError,
] ]
for error_class in error_classes: for error_class in error_classes:
assert issubclass(error_class, EngineError), ( assert issubclass(error_class, EngineError), (
f"{error_class.__name__} must inherit from EngineError" f"{error_class.__name__} must inherit from EngineError"
) )
def test_all_errors_are_catchable(self) -> None: def test_all_errors_are_catchable(self) -> None:
error_classes = [ error_classes = [
EngineError, EngineError,
ModelNotFoundError, ModelNotFoundError,
ModelLoadError, ModelLoadError,
NetworkError, NetworkError,
InvalidInputError, InvalidInputError,
ConfigurationError, ConfigurationError,
CancelledError, CancelledError,
InternalError, InternalError,
] ]
for error_class in error_classes: for error_class in error_classes:
with pytest.raises(EngineError): with pytest.raises(EngineError):
raise error_class("test message") raise error_class("test message")
def test_error_message_preserved(self) -> None: def test_error_message_preserved(self) -> None:
msg = "Model not found: bert-base" msg = "Model not found: bert-base"
with pytest.raises(ModelNotFoundError, match=msg): with pytest.raises(ModelNotFoundError, match=msg):
raise ModelNotFoundError(msg) raise ModelNotFoundError(msg)
def test_error_can_be_caught_as_engine_error(self) -> None: def test_error_can_be_caught_as_engine_error(self) -> None:
with pytest.raises(EngineError): with pytest.raises(EngineError):
raise ModelNotFoundError("test") raise ModelNotFoundError("test")
def test_cancelled_error_is_engine_error(self) -> None: def test_cancelled_error_is_engine_error(self) -> None:
"""CancelledError is a subtype of EngineError per architecture spec.""" """CancelledError is a subtype of EngineError per architecture spec."""
assert issubclass(CancelledError, EngineError) assert issubclass(CancelledError, EngineError)
def test_error_hierarchy_no_cycles(self) -> None: def test_error_hierarchy_no_cycles(self) -> None:
"""Verify no circular inheritance.""" """Verify no circular inheritance."""
error_classes = [ error_classes = [
EngineError, EngineError,
ModelNotFoundError, ModelNotFoundError,
ModelLoadError, ModelLoadError,
NetworkError, NetworkError,
InvalidInputError, InvalidInputError,
ConfigurationError, ConfigurationError,
CancelledError, CancelledError,
InternalError, InternalError,
] ]
for cls in error_classes: for cls in error_classes:
assert cls not in cls.__bases__ assert cls not in cls.__bases__
+89 -89
View File
@@ -1,89 +1,89 @@
"""Contract tests for HostContext. """Contract tests for HostContext.
These tests verify that HostContext satisfies the architectural requirements: These tests verify that HostContext satisfies the architectural requirements:
- Minimal (3 fields maximum) - Minimal (3 fields maximum)
- Frozen dataclass - Frozen dataclass
- config_dir: Path - config_dir: Path
- logger: Logger - logger: Logger
- http_client: HttpClient protocol - http_client: HttpClient protocol
""" """
import logging import logging
from pathlib import Path from pathlib import Path
import pytest import pytest
from abogen.tts_plugin.host_context import HttpClient, HostContext from abogen.tts_plugin.host_context import HttpClient, HostContext
class TestHostContextContract: class TestHostContextContract:
"""Contract tests for HostContext dataclass.""" """Contract tests for HostContext dataclass."""
def test_is_frozen_dataclass(self) -> None: def test_is_frozen_dataclass(self) -> None:
assert hasattr(HostContext, "__dataclass_params__") assert hasattr(HostContext, "__dataclass_params__")
assert HostContext.__dataclass_params__.frozen is True assert HostContext.__dataclass_params__.frozen is True
def test_required_fields(self, tmp_path: Path) -> None: def test_required_fields(self, tmp_path: Path) -> None:
logger = logging.getLogger("test") logger = logging.getLogger("test")
class FakeClient: class FakeClient:
def get(self, url: str, **kwargs: object) -> object: def get(self, url: str, **kwargs: object) -> object:
return None return None
def post(self, url: str, **kwargs: object) -> object: def post(self, url: str, **kwargs: object) -> object:
return None return None
ctx = HostContext( ctx = HostContext(
config_dir=tmp_path, config_dir=tmp_path,
logger=logger, logger=logger,
http_client=FakeClient(), http_client=FakeClient(),
) )
assert ctx.config_dir == tmp_path assert ctx.config_dir == tmp_path
assert ctx.logger is logger assert ctx.logger is logger
def test_immutability(self, tmp_path: Path) -> None: def test_immutability(self, tmp_path: Path) -> None:
class FakeClient: class FakeClient:
def get(self, url: str, **kwargs: object) -> object: def get(self, url: str, **kwargs: object) -> object:
return None return None
def post(self, url: str, **kwargs: object) -> object: def post(self, url: str, **kwargs: object) -> object:
return None return None
ctx = HostContext( ctx = HostContext(
config_dir=tmp_path, config_dir=tmp_path,
logger=logging.getLogger("test"), logger=logging.getLogger("test"),
http_client=FakeClient(), http_client=FakeClient(),
) )
with pytest.raises(AttributeError): with pytest.raises(AttributeError):
ctx.config_dir = Path("/other") # type: ignore[misc] ctx.config_dir = Path("/other") # type: ignore[misc]
def test_max_three_fields(self) -> None: def test_max_three_fields(self) -> None:
"""Architecture spec: HostContext is minimal (3 fields max).""" """Architecture spec: HostContext is minimal (3 fields max)."""
import dataclasses import dataclasses
fields = dataclasses.fields(HostContext) fields = dataclasses.fields(HostContext)
assert len(fields) <= 3 assert len(fields) <= 3
class TestHttpClientProtocolContract: class TestHttpClientProtocolContract:
"""Contract tests for HttpClient protocol.""" """Contract tests for HttpClient protocol."""
def test_http_client_is_protocol(self) -> None: def test_http_client_is_protocol(self) -> None:
assert hasattr(HttpClient, "__protocol_attrs__") assert hasattr(HttpClient, "__protocol_attrs__")
def test_http_client_has_get(self) -> None: def test_http_client_has_get(self) -> None:
assert hasattr(HttpClient, "get") assert hasattr(HttpClient, "get")
def test_http_client_has_post(self) -> None: def test_http_client_has_post(self) -> None:
assert hasattr(HttpClient, "post") assert hasattr(HttpClient, "post")
def test_http_client_satisfied(self) -> None: def test_http_client_satisfied(self) -> None:
class FakeClient: class FakeClient:
def get(self, url: str, **kwargs: object) -> object: def get(self, url: str, **kwargs: object) -> object:
return None return None
def post(self, url: str, **kwargs: object) -> object: def post(self, url: str, **kwargs: object) -> object:
return None return None
client = FakeClient() client = FakeClient()
assert isinstance(client, HttpClient) assert isinstance(client, HttpClient)
+420 -420
View File
@@ -1,420 +1,420 @@
"""Integration tests for the TTS Plugin Architecture. """Integration tests for the TTS Plugin Architecture.
These tests verify: These tests verify:
1. Consumer Flow: consumer plugin engine session synthesis result 1. Consumer Flow: consumer plugin engine session synthesis result
2. Lifecycle: dispose, no leaks, error handling 2. Lifecycle: dispose, no leaks, error handling
3. Regression: old path vs new path equivalence 3. Regression: old path vs new path equivalence
Tests use mock plugins to avoid requiring real TTS dependencies. Tests use mock plugins to avoid requiring real TTS dependencies.
""" """
import pytest import pytest
from typing import Any, Iterator from typing import Any, Iterator
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import numpy as np import numpy as np
from abogen.tts_plugin.engine import Engine, EngineSession from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.errors import EngineError from abogen.tts_plugin.errors import EngineError
from abogen.tts_plugin.plugin_manager import PluginManager, get_plugin_manager, reset_plugin_manager from abogen.tts_plugin.plugin_manager import PluginManager, get_plugin_manager, reset_plugin_manager
from abogen.tts_plugin.utils import Pipeline, create_pipeline from abogen.tts_plugin.utils import Pipeline, create_pipeline
from abogen.tts_plugin.types import ( from abogen.tts_plugin.types import (
AudioFormat, AudioFormat,
Duration, Duration,
ParameterValues, ParameterValues,
SynthesisRequest, SynthesisRequest,
SynthesizedAudio, SynthesizedAudio,
VoiceSelection, VoiceSelection,
) )
class MockEngineSession: class MockEngineSession:
"""Mock EngineSession that records calls for verification.""" """Mock EngineSession that records calls for verification."""
def __init__(self): def __init__(self):
self._disposed = False self._disposed = False
self.synthesize_calls = [] self.synthesize_calls = []
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio: def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
if self._disposed: if self._disposed:
raise EngineError("Session disposed") raise EngineError("Session disposed")
self.synthesize_calls.append(request) self.synthesize_calls.append(request)
# Return fake audio # Return fake audio
audio = np.ones(1000, dtype=np.float32) * 0.5 audio = np.ones(1000, dtype=np.float32) * 0.5
return SynthesizedAudio( return SynthesizedAudio(
data=audio.tobytes(), data=audio.tobytes(),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=1000 / 24000), duration=Duration(seconds=1000 / 24000),
) )
def dispose(self) -> None: def dispose(self) -> None:
self._disposed = True self._disposed = True
class MockEngine: class MockEngine:
"""Mock Engine that creates MockEngineSessions.""" """Mock Engine that creates MockEngineSessions."""
def __init__(self, **kwargs): def __init__(self, **kwargs):
self.kwargs = kwargs self.kwargs = kwargs
self._disposed = False self._disposed = False
self.sessions_created = [] self.sessions_created = []
def createSession(self) -> MockEngineSession: def createSession(self) -> MockEngineSession:
if self._disposed: if self._disposed:
raise EngineError("Engine disposed") raise EngineError("Engine disposed")
session = MockEngineSession() session = MockEngineSession()
self.sessions_created.append(session) self.sessions_created.append(session)
return session return session
def dispose(self) -> None: def dispose(self) -> None:
self._disposed = True self._disposed = True
def create_mock_plugin(create_engine_func=None): def create_mock_plugin(create_engine_func=None):
"""Helper to create a mock plugin module.""" """Helper to create a mock plugin module."""
if create_engine_func is None: if create_engine_func is None:
create_engine_func = lambda **kwargs: MockEngine(**kwargs) create_engine_func = lambda **kwargs: MockEngine(**kwargs)
from abogen.tts_plugin.manifest import PluginManifest, EngineManifest from abogen.tts_plugin.manifest import PluginManifest, EngineManifest
manifest = PluginManifest( manifest = PluginManifest(
id="mock_tts", id="mock_tts",
name="Mock TTS", name="Mock TTS",
version="1.0.0", version="1.0.0",
api_version="1.0", api_version="1.0",
description="Mock TTS for testing", description="Mock TTS for testing",
author="Test", author="Test",
capabilities=(), capabilities=(),
requires=None, requires=None,
engine=EngineManifest( engine=EngineManifest(
voiceSources=(), voiceSources=(),
parameters=(), parameters=(),
audioFormats=(), audioFormats=(),
), ),
) )
return { return {
"PLUGIN_MANIFEST": manifest, "PLUGIN_MANIFEST": manifest,
"MODEL_REQUIREMENTS": [], "MODEL_REQUIREMENTS": [],
"create_engine": create_mock_plugin_engine if create_engine_func is None else create_engine_func, "create_engine": create_mock_plugin_engine if create_engine_func is None else create_engine_func,
} }
def create_mock_plugin_engine(**kwargs): def create_mock_plugin_engine(**kwargs):
"""Default mock plugin engine factory.""" """Default mock plugin engine factory."""
return MockEngine(**kwargs) return MockEngine(**kwargs)
class TestConsumerFlow: class TestConsumerFlow:
"""Consumer Flow Test: consumer → plugin → engine → session → synthesis → result""" """Consumer Flow Test: consumer → plugin → engine → session → synthesis → result"""
def test_full_consumer_flow(self): def test_full_consumer_flow(self):
"""Verify complete flow from consumer to audio output.""" """Verify complete flow from consumer to audio output."""
manager = PluginManager() manager = PluginManager()
# Register mock plugin # Register mock plugin
mock_plugin = create_mock_plugin() mock_plugin = create_mock_plugin()
manager._plugins["mock_tts"] = mock_plugin manager._plugins["mock_tts"] = mock_plugin
manager._loaded = True manager._loaded = True
# Step 1: Consumer gets plugin # Step 1: Consumer gets plugin
assert manager.has_plugin("mock_tts") is True assert manager.has_plugin("mock_tts") is True
# Step 2: Plugin creates engine # Step 2: Plugin creates engine
engine = manager.create_engine("mock_tts") engine = manager.create_engine("mock_tts")
assert engine is not None assert engine is not None
assert isinstance(engine, MockEngine) assert isinstance(engine, MockEngine)
# Step 3: Engine creates session # Step 3: Engine creates session
session = engine.createSession() session = engine.createSession()
assert session is not None assert session is not None
assert isinstance(session, MockEngineSession) assert isinstance(session, MockEngineSession)
# Step 4: Session synthesizes # Step 4: Session synthesizes
request = SynthesisRequest( request = SynthesisRequest(
text="Hello world", text="Hello world",
voice=VoiceSelection(source="builtin", key="default"), voice=VoiceSelection(source="builtin", key="default"),
parameters=ParameterValues(values={"speed": 1.0}), parameters=ParameterValues(values={"speed": 1.0}),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
) )
result = session.synthesize(request) result = session.synthesize(request)
# Step 5: Result returned # Step 5: Result returned
assert result is not None assert result is not None
assert isinstance(result, SynthesizedAudio) assert isinstance(result, SynthesizedAudio)
assert len(result.data) > 0 assert len(result.data) > 0
assert result.format.mime == "audio/wav" assert result.format.mime == "audio/wav"
assert result.duration.seconds > 0 assert result.duration.seconds > 0
def test_consumer_flow_via_pipeline(self): def test_consumer_flow_via_pipeline(self):
"""Verify flow through Pipeline utility matches direct flow.""" """Verify flow through Pipeline utility matches direct flow."""
manager = PluginManager() manager = PluginManager()
# Register mock plugin # Register mock plugin
mock_plugin = create_mock_plugin() mock_plugin = create_mock_plugin()
manager._plugins["mock_tts"] = mock_plugin manager._plugins["mock_tts"] = mock_plugin
manager._loaded = True manager._loaded = True
# Use Pipeline utility # Use Pipeline utility
with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager): with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
backend = create_pipeline("mock_tts") backend = create_pipeline("mock_tts")
# Call like old TTSBackend # Call like old TTSBackend
segments = list(backend("Hello world", voice="default", speed=1.0)) segments = list(backend("Hello world", voice="default", speed=1.0))
# Verify result # Verify result
assert len(segments) >= 1 assert len(segments) >= 1
segment = segments[0] segment = segments[0]
assert hasattr(segment, "graphemes") assert hasattr(segment, "graphemes")
assert hasattr(segment, "audio") assert hasattr(segment, "audio")
assert segment.graphemes == "Hello world" assert segment.graphemes == "Hello world"
class TestLifecycle: class TestLifecycle:
"""Lifecycle Test: dispose, no leaks, error handling""" """Lifecycle Test: dispose, no leaks, error handling"""
def test_session_dispose_is_idempotent(self): def test_session_dispose_is_idempotent(self):
"""dispose() can be called multiple times safely.""" """dispose() can be called multiple times safely."""
session = MockEngineSession() session = MockEngineSession()
session.dispose() session.dispose()
session.dispose() # Should not raise session.dispose() # Should not raise
assert session._disposed is True assert session._disposed is True
def test_session_synthesize_after_dispose_raises(self): def test_session_synthesize_after_dispose_raises(self):
"""synthesize() after dispose() raises EngineError.""" """synthesize() after dispose() raises EngineError."""
session = MockEngineSession() session = MockEngineSession()
session.dispose() session.dispose()
request = SynthesisRequest( request = SynthesisRequest(
text="test", text="test",
voice=VoiceSelection(source="builtin", key="default"), voice=VoiceSelection(source="builtin", key="default"),
parameters=ParameterValues(), parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
) )
with pytest.raises(EngineError): with pytest.raises(EngineError):
session.synthesize(request) session.synthesize(request)
def test_engine_dispose_is_idempotent(self): def test_engine_dispose_is_idempotent(self):
"""Engine dispose() can be called multiple times safely.""" """Engine dispose() can be called multiple times safely."""
engine = MockEngine() engine = MockEngine()
engine.dispose() engine.dispose()
engine.dispose() # Should not raise engine.dispose() # Should not raise
assert engine._disposed is True assert engine._disposed is True
def test_engine_create_session_after_dispose_raises(self): def test_engine_create_session_after_dispose_raises(self):
"""createSession() after dispose() raises EngineError.""" """createSession() after dispose() raises EngineError."""
engine = MockEngine() engine = MockEngine()
engine.dispose() engine.dispose()
with pytest.raises(EngineError): with pytest.raises(EngineError):
engine.createSession() engine.createSession()
def test_full_lifecycle(self): def test_full_lifecycle(self):
"""Test complete lifecycle: create → use → dispose.""" """Test complete lifecycle: create → use → dispose."""
engine = MockEngine() engine = MockEngine()
# Create and use session # Create and use session
session = engine.createSession() session = engine.createSession()
request = SynthesisRequest( request = SynthesisRequest(
text="test", text="test",
voice=VoiceSelection(source="builtin", key="default"), voice=VoiceSelection(source="builtin", key="default"),
parameters=ParameterValues(), parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
) )
result = session.synthesize(request) result = session.synthesize(request)
assert len(result.data) > 0 assert len(result.data) > 0
# Dispose session # Dispose session
session.dispose() session.dispose()
assert session._disposed is True assert session._disposed is True
# Dispose engine # Dispose engine
engine.dispose() engine.dispose()
assert engine._disposed is True assert engine._disposed is True
def test_no_session_leak_on_engine_dispose(self): def test_no_session_leak_on_engine_dispose(self):
"""Engine can be disposed even if sessions were created.""" """Engine can be disposed even if sessions were created."""
engine = MockEngine() engine = MockEngine()
# Create multiple sessions # Create multiple sessions
session1 = engine.createSession() session1 = engine.createSession()
session2 = engine.createSession() session2 = engine.createSession()
# Use sessions # Use sessions
request = SynthesisRequest( request = SynthesisRequest(
text="test", text="test",
voice=VoiceSelection(source="builtin", key="default"), voice=VoiceSelection(source="builtin", key="default"),
parameters=ParameterValues(), parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
) )
session1.synthesize(request) session1.synthesize(request)
session2.synthesize(request) session2.synthesize(request)
# Dispose engine (sessions still exist but engine is disposed) # Dispose engine (sessions still exist but engine is disposed)
engine.dispose() engine.dispose()
assert engine._disposed is True assert engine._disposed is True
# Sessions can still be used (they hold reference to pipeline) # Sessions can still be used (they hold reference to pipeline)
result = session1.synthesize(request) result = session1.synthesize(request)
assert len(result.data) > 0 assert len(result.data) > 0
def test_error_handling_in_synthesis(self): def test_error_handling_in_synthesis(self):
"""Error during synthesis is handled correctly.""" """Error during synthesis is handled correctly."""
class FailingSession: class FailingSession:
def synthesize(self, request): def synthesize(self, request):
raise EngineError("Synthesis failed") raise EngineError("Synthesis failed")
def dispose(self): def dispose(self):
pass pass
session = FailingSession() session = FailingSession()
request = SynthesisRequest( request = SynthesisRequest(
text="test", text="test",
voice=VoiceSelection(source="builtin", key="default"), voice=VoiceSelection(source="builtin", key="default"),
parameters=ParameterValues(), parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
) )
with pytest.raises(EngineError, match="Synthesis failed"): with pytest.raises(EngineError, match="Synthesis failed"):
session.synthesize(request) session.synthesize(request)
class TestRegression: class TestRegression:
"""Regression Test: old path vs new path equivalence""" """Regression Test: old path vs new path equivalence"""
def test_old_path_vs_new_path_same_result(self): def test_old_path_vs_new_path_same_result(self):
"""Both paths should produce equivalent results.""" """Both paths should produce equivalent results."""
# Setup mock plugin # Setup mock plugin
manager = PluginManager() manager = PluginManager()
mock_plugin = create_mock_plugin() mock_plugin = create_mock_plugin()
manager._plugins["mock_tts"] = mock_plugin manager._plugins["mock_tts"] = mock_plugin
manager._loaded = True manager._loaded = True
# New path: Plugin Manager → Engine → Session → Synthesis # New path: Plugin Manager → Engine → Session → Synthesis
with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager): with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
new_backend = create_pipeline("mock_tts") new_backend = create_pipeline("mock_tts")
new_segments = list(new_backend("Hello world", voice="default", speed=1.0)) new_segments = list(new_backend("Hello world", voice="default", speed=1.0))
# Old path: Direct MockEngine (simulating old registry) # Old path: Direct MockEngine (simulating old registry)
old_engine = MockEngine() old_engine = MockEngine()
old_session = old_engine.createSession() old_session = old_engine.createSession()
request = SynthesisRequest( request = SynthesisRequest(
text="Hello world", text="Hello world",
voice=VoiceSelection(source="builtin", key="default"), voice=VoiceSelection(source="builtin", key="default"),
parameters=ParameterValues(values={"speed": 1.0}), parameters=ParameterValues(values={"speed": 1.0}),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
) )
old_result = old_session.synthesize(request) old_result = old_session.synthesize(request)
# Compare results # Compare results
# New path returns segments, old path returns SynthesizedAudio # New path returns segments, old path returns SynthesizedAudio
# But both should have valid audio data # But both should have valid audio data
assert len(new_segments) >= 1 assert len(new_segments) >= 1
assert len(old_result.data) > 0 assert len(old_result.data) > 0
# Both should have same format # Both should have same format
assert new_segments[0].audio.dtype == np.float32 assert new_segments[0].audio.dtype == np.float32
def test_pipeline_matches_old_interface(self): def test_pipeline_matches_old_interface(self):
"""Pipeline utility should match old TTSBackend interface.""" """Pipeline utility should match old TTSBackend interface."""
manager = PluginManager() manager = PluginManager()
mock_plugin = create_mock_plugin() mock_plugin = create_mock_plugin()
manager._plugins["mock_tts"] = mock_plugin manager._plugins["mock_tts"] = mock_plugin
manager._loaded = True manager._loaded = True
with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager): with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
backend = create_pipeline("mock_tts", lang_code="a", device="cpu") backend = create_pipeline("mock_tts", lang_code="a", device="cpu")
# Old interface: pipeline(text, voice=..., speed=..., split_pattern=...) # Old interface: pipeline(text, voice=..., speed=..., split_pattern=...)
segments = list(backend( segments = list(backend(
"Hello world", "Hello world",
voice="af_heart", voice="af_heart",
speed=1.0, speed=1.0,
split_pattern=r"\n+" split_pattern=r"\n+"
)) ))
# Should return segments with graphemes and audio # Should return segments with graphemes and audio
assert len(segments) >= 1 assert len(segments) >= 1
segment = segments[0] segment = segments[0]
assert segment.graphemes == "Hello world" assert segment.graphemes == "Hello world"
assert isinstance(segment.audio, np.ndarray) assert isinstance(segment.audio, np.ndarray)
assert segment.audio.dtype == np.float32 assert segment.audio.dtype == np.float32
assert len(segment.audio) > 0 assert len(segment.audio) > 0
class TestPluginManagerIntegration: class TestPluginManagerIntegration:
"""Integration tests for PluginManager.""" """Integration tests for PluginManager."""
def test_plugin_manager_singleton_pattern(self): def test_plugin_manager_singleton_pattern(self):
"""Global plugin manager follows singleton pattern.""" """Global plugin manager follows singleton pattern."""
reset_plugin_manager() reset_plugin_manager()
manager1 = get_plugin_manager() manager1 = get_plugin_manager()
manager2 = get_plugin_manager() manager2 = get_plugin_manager()
assert manager1 is manager2 assert manager1 is manager2
reset_plugin_manager() reset_plugin_manager()
manager3 = get_plugin_manager() manager3 = get_plugin_manager()
assert manager1 is not manager3 assert manager1 is not manager3
def test_plugin_manager_discover_plugins(self): def test_plugin_manager_discover_plugins(self):
"""Plugin manager can discover plugins from directory.""" """Plugin manager can discover plugins from directory."""
manager = PluginManager() manager = PluginManager()
# Discover from test plugins directory # Discover from test plugins directory
manager.discover("tests/plugins") manager.discover("tests/plugins")
# Should find valid_plugin # Should find valid_plugin
# (This depends on test plugins existing) # (This depends on test plugins existing)
plugins = manager.list_plugins() plugins = manager.list_plugins()
assert isinstance(plugins, list) assert isinstance(plugins, list)
def test_plugin_manager_dispose_all(self): def test_plugin_manager_dispose_all(self):
"""Plugin manager can dispose all cached engines.""" """Plugin manager can dispose all cached engines."""
manager = PluginManager() manager = PluginManager()
# Register mock plugin # Register mock plugin
mock_plugin = create_mock_plugin() mock_plugin = create_mock_plugin()
manager._plugins["mock_tts"] = mock_plugin manager._plugins["mock_tts"] = mock_plugin
manager._loaded = True manager._loaded = True
# Create engines # Create engines
engine1 = manager.get_or_create_engine("mock_tts") engine1 = manager.get_or_create_engine("mock_tts")
engine2 = manager.get_or_create_engine("mock_tts") engine2 = manager.get_or_create_engine("mock_tts")
# Dispose all # Dispose all
manager.dispose_all() manager.dispose_all()
# Engines should be disposed # Engines should be disposed
assert engine1._disposed is True assert engine1._disposed is True
assert engine2._disposed is True assert engine2._disposed is True
# Cache should be empty # Cache should be empty
assert len(manager._engines) == 0 assert len(manager._engines) == 0
class TestNoCompatLayer: class TestNoCompatLayer:
"""Regression: confirm the compatibility layer has been removed.""" """Regression: confirm the compatibility layer has been removed."""
def test_compat_module_does_not_exist(self): def test_compat_module_does_not_exist(self):
"""abogen.tts_plugin.compat must not be importable.""" """abogen.tts_plugin.compat must not be importable."""
import importlib import importlib
with pytest.raises((ImportError, ModuleNotFoundError)): with pytest.raises((ImportError, ModuleNotFoundError)):
importlib.import_module("abogen.tts_plugin.compat") importlib.import_module("abogen.tts_plugin.compat")
def test_consumers_use_plugin_architecture_directly(self): def test_consumers_use_plugin_architecture_directly(self):
"""Key consumers import from abogen.tts_plugin.utils, not compat.""" """Key consumers import from abogen.tts_plugin.utils, not compat."""
import inspect, abogen.voice_profiles, abogen.voice_formulas, abogen.voice_cache import inspect, abogen.voice_profiles, abogen.voice_formulas, abogen.voice_cache
for mod in (abogen.voice_profiles, abogen.voice_formulas, abogen.voice_cache): for mod in (abogen.voice_profiles, abogen.voice_formulas, abogen.voice_cache):
source = inspect.getsource(mod) source = inspect.getsource(mod)
assert "tts_plugin.compat" not in source, ( assert "tts_plugin.compat" not in source, (
f"{mod.__name__} still references tts_plugin.compat" f"{mod.__name__} still references tts_plugin.compat"
) )
+436 -436
View File
@@ -1,436 +1,436 @@
"""Comprehensive tests for the plugin loader infrastructure. """Comprehensive tests for the plugin loader infrastructure.
These tests verify that the loader correctly: These tests verify that the loader correctly:
- Discovers plugins in directories - Discovers plugins in directories
- Imports plugin modules - Imports plugin modules
- Validates PLUGIN_MANIFEST, MODEL_REQUIREMENTS, create_engine - Validates PLUGIN_MANIFEST, MODEL_REQUIREMENTS, create_engine
- Validates api_version compatibility - Validates api_version compatibility
- Validates capabilities - Validates capabilities
- Provides diagnostic messages for errors - Provides diagnostic messages for errors
- Rejects invalid plugins - Rejects invalid plugins
""" """
from __future__ import annotations from __future__ import annotations
import sys import sys
from pathlib import Path from pathlib import Path
import pytest import pytest
from abogen.tts_plugin.loader import ( from abogen.tts_plugin.loader import (
HOST_API_VERSION, HOST_API_VERSION,
PluginLoadError, PluginLoadError,
PluginLoadResult, PluginLoadResult,
_check_api_version_compatibility, _check_api_version_compatibility,
_parse_api_version, _parse_api_version,
_validate_api_version, _validate_api_version,
_validate_capabilities, _validate_capabilities,
_validate_manifest, _validate_manifest,
discover_plugins, discover_plugins,
load_plugin, load_plugin,
load_plugin_from_dir, load_plugin_from_dir,
) )
from abogen.tts_plugin.manifest import ( from abogen.tts_plugin.manifest import (
EngineManifest, EngineManifest,
ModelManifest, ModelManifest,
PluginManifest, PluginManifest,
) )
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# Path fixtures # Path fixtures
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
@pytest.fixture @pytest.fixture
def plugins_dir() -> Path: def plugins_dir() -> Path:
return Path(__file__).parent.parent / "plugins" return Path(__file__).parent.parent / "plugins"
@pytest.fixture @pytest.fixture
def fake_plugin_dir(plugins_dir: Path) -> Path: def fake_plugin_dir(plugins_dir: Path) -> Path:
return plugins_dir / "fake_plugin" return plugins_dir / "fake_plugin"
@pytest.fixture @pytest.fixture
def missing_manifest_dir(plugins_dir: Path) -> Path: def missing_manifest_dir(plugins_dir: Path) -> Path:
return plugins_dir / "missing_manifest" return plugins_dir / "missing_manifest"
@pytest.fixture @pytest.fixture
def invalid_api_version_dir(plugins_dir: Path) -> Path: def invalid_api_version_dir(plugins_dir: Path) -> Path:
return plugins_dir / "invalid_api_version" return plugins_dir / "invalid_api_version"
@pytest.fixture @pytest.fixture
def invalid_capabilities_dir(plugins_dir: Path) -> Path: def invalid_capabilities_dir(plugins_dir: Path) -> Path:
return plugins_dir / "invalid_capabilities" return plugins_dir / "invalid_capabilities"
@pytest.fixture @pytest.fixture
def missing_create_engine_dir(plugins_dir: Path) -> Path: def missing_create_engine_dir(plugins_dir: Path) -> Path:
return plugins_dir / "missing_create_engine" return plugins_dir / "missing_create_engine"
@pytest.fixture @pytest.fixture
def import_error_dir(plugins_dir: Path) -> Path: def import_error_dir(plugins_dir: Path) -> Path:
return plugins_dir / "import_error" return plugins_dir / "import_error"
@pytest.fixture @pytest.fixture
def missing_model_requirements_dir(plugins_dir: Path) -> Path: def missing_model_requirements_dir(plugins_dir: Path) -> Path:
return plugins_dir / "missing_model_requirements" return plugins_dir / "missing_model_requirements"
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# Unit tests: _parse_api_version # Unit tests: _parse_api_version
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
class TestParseApiVersion: class TestParseApiVersion:
def test_valid_version(self) -> None: def test_valid_version(self) -> None:
assert _parse_api_version("1.0") == (1, 0) assert _parse_api_version("1.0") == (1, 0)
assert _parse_api_version("2.5") == (2, 5) assert _parse_api_version("2.5") == (2, 5)
assert _parse_api_version("10.20") == (10, 20) assert _parse_api_version("10.20") == (10, 20)
def test_invalid_format(self) -> None: def test_invalid_format(self) -> None:
assert _parse_api_version("1") is None assert _parse_api_version("1") is None
assert _parse_api_version("1.0.0") is None assert _parse_api_version("1.0.0") is None
assert _parse_api_version("abc") is None assert _parse_api_version("abc") is None
assert _parse_api_version("") is None assert _parse_api_version("") is None
assert _parse_api_version("1.x") is None assert _parse_api_version("1.x") is None
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# Unit tests: _check_api_version_compatibility # Unit tests: _check_api_version_compatibility
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
class TestCheckApiVersionCompatibility: class TestCheckApiVersionCompatibility:
def test_compatible_version(self) -> None: def test_compatible_version(self) -> None:
assert _check_api_version_compatibility("1.0") is None assert _check_api_version_compatibility("1.0") is None
assert _check_api_version_compatibility("1.5") is None assert _check_api_version_compatibility("1.5") is None
def test_major_mismatch(self) -> None: def test_major_mismatch(self) -> None:
error = _check_api_version_compatibility("2.0") error = _check_api_version_compatibility("2.0")
assert error is not None assert error is not None
assert "major mismatch" in error assert "major mismatch" in error
def test_invalid_format(self) -> None: def test_invalid_format(self) -> None:
error = _check_api_version_compatibility("invalid") error = _check_api_version_compatibility("invalid")
assert error is not None assert error is not None
assert "Invalid api_version format" in error assert "Invalid api_version format" in error
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# Unit tests: _validate_manifest # Unit tests: _validate_manifest
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
class TestValidateManifest: class TestValidateManifest:
def test_valid_manifest(self) -> None: def test_valid_manifest(self) -> None:
class FakeModule: class FakeModule:
PLUGIN_MANIFEST = PluginManifest( PLUGIN_MANIFEST = PluginManifest(
id="test", name="Test", version="1.0.0", id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test", api_version="1.0", description="Test", author="Test",
) )
MODEL_REQUIREMENTS: list = [] MODEL_REQUIREMENTS: list = []
create_engine = lambda *a, **kw: None create_engine = lambda *a, **kw: None
errors = _validate_manifest(FakeModule(), Path("/tmp")) errors = _validate_manifest(FakeModule(), Path("/tmp"))
assert errors == [] assert errors == []
def test_missing_manifest(self) -> None: def test_missing_manifest(self) -> None:
class FakeModule: class FakeModule:
MODEL_REQUIREMENTS: list = [] MODEL_REQUIREMENTS: list = []
create_engine = lambda *a, **kw: None create_engine = lambda *a, **kw: None
errors = _validate_manifest(FakeModule(), Path("/tmp")) errors = _validate_manifest(FakeModule(), Path("/tmp"))
assert any("Missing PLUGIN_MANIFEST" in e for e in errors) assert any("Missing PLUGIN_MANIFEST" in e for e in errors)
def test_wrong_manifest_type(self) -> None: def test_wrong_manifest_type(self) -> None:
class FakeModule: class FakeModule:
PLUGIN_MANIFEST = "not a manifest" PLUGIN_MANIFEST = "not a manifest"
MODEL_REQUIREMENTS: list = [] MODEL_REQUIREMENTS: list = []
create_engine = lambda *a, **kw: None create_engine = lambda *a, **kw: None
errors = _validate_manifest(FakeModule(), Path("/tmp")) errors = _validate_manifest(FakeModule(), Path("/tmp"))
assert any("PluginManifest instance" in e for e in errors) assert any("PluginManifest instance" in e for e in errors)
def test_missing_model_requirements(self) -> None: def test_missing_model_requirements(self) -> None:
class FakeModule: class FakeModule:
PLUGIN_MANIFEST = PluginManifest( PLUGIN_MANIFEST = PluginManifest(
id="test", name="Test", version="1.0.0", id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test", api_version="1.0", description="Test", author="Test",
) )
create_engine = lambda *a, **kw: None create_engine = lambda *a, **kw: None
errors = _validate_manifest(FakeModule(), Path("/tmp")) errors = _validate_manifest(FakeModule(), Path("/tmp"))
assert any("Missing MODEL_REQUIREMENTS" in e for e in errors) assert any("Missing MODEL_REQUIREMENTS" in e for e in errors)
def test_wrong_model_requirements_type(self) -> None: def test_wrong_model_requirements_type(self) -> None:
class FakeModule: class FakeModule:
PLUGIN_MANIFEST = PluginManifest( PLUGIN_MANIFEST = PluginManifest(
id="test", name="Test", version="1.0.0", id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test", api_version="1.0", description="Test", author="Test",
) )
MODEL_REQUIREMENTS = "not a list" MODEL_REQUIREMENTS = "not a list"
create_engine = lambda *a, **kw: None create_engine = lambda *a, **kw: None
errors = _validate_manifest(FakeModule(), Path("/tmp")) errors = _validate_manifest(FakeModule(), Path("/tmp"))
assert any("must be a list" in e for e in errors) assert any("must be a list" in e for e in errors)
def test_invalid_model_requirements_item(self) -> None: def test_invalid_model_requirements_item(self) -> None:
class FakeModule: class FakeModule:
PLUGIN_MANIFEST = PluginManifest( PLUGIN_MANIFEST = PluginManifest(
id="test", name="Test", version="1.0.0", id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test", api_version="1.0", description="Test", author="Test",
) )
MODEL_REQUIREMENTS = ["not a model manifest"] MODEL_REQUIREMENTS = ["not a model manifest"]
create_engine = lambda *a, **kw: None create_engine = lambda *a, **kw: None
errors = _validate_manifest(FakeModule(), Path("/tmp")) errors = _validate_manifest(FakeModule(), Path("/tmp"))
assert any("ModelManifest instance" in e for e in errors) assert any("ModelManifest instance" in e for e in errors)
def test_missing_create_engine(self) -> None: def test_missing_create_engine(self) -> None:
class FakeModule: class FakeModule:
PLUGIN_MANIFEST = PluginManifest( PLUGIN_MANIFEST = PluginManifest(
id="test", name="Test", version="1.0.0", id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test", api_version="1.0", description="Test", author="Test",
) )
MODEL_REQUIREMENTS: list = [] MODEL_REQUIREMENTS: list = []
errors = _validate_manifest(FakeModule(), Path("/tmp")) errors = _validate_manifest(FakeModule(), Path("/tmp"))
assert any("Missing create_engine" in e for e in errors) assert any("Missing create_engine" in e for e in errors)
def test_create_engine_not_callable(self) -> None: def test_create_engine_not_callable(self) -> None:
class FakeModule: class FakeModule:
PLUGIN_MANIFEST = PluginManifest( PLUGIN_MANIFEST = PluginManifest(
id="test", name="Test", version="1.0.0", id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test", api_version="1.0", description="Test", author="Test",
) )
MODEL_REQUIREMENTS: list = [] MODEL_REQUIREMENTS: list = []
create_engine = "not callable" create_engine = "not callable"
errors = _validate_manifest(FakeModule(), Path("/tmp")) errors = _validate_manifest(FakeModule(), Path("/tmp"))
assert any("must be callable" in e for e in errors) assert any("must be callable" in e for e in errors)
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# Unit tests: _validate_capabilities # Unit tests: _validate_capabilities
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
class TestValidateCapabilities: class TestValidateCapabilities:
def test_valid_capabilities(self) -> None: def test_valid_capabilities(self) -> None:
manifest = PluginManifest( manifest = PluginManifest(
id="test", name="Test", version="1.0.0", id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test", api_version="1.0", description="Test", author="Test",
capabilities=("voice_list", "preview"), capabilities=("voice_list", "preview"),
) )
errors = _validate_capabilities(manifest) errors = _validate_capabilities(manifest)
assert errors == [] assert errors == []
def test_unknown_capability(self) -> None: def test_unknown_capability(self) -> None:
manifest = PluginManifest( manifest = PluginManifest(
id="test", name="Test", version="1.0.0", id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test", api_version="1.0", description="Test", author="Test",
capabilities=("voice_list", "unknown_cap"), capabilities=("voice_list", "unknown_cap"),
) )
errors = _validate_capabilities(manifest) errors = _validate_capabilities(manifest)
assert any("unknown_cap" in e for e in errors) assert any("unknown_cap" in e for e in errors)
def test_empty_capabilities(self) -> None: def test_empty_capabilities(self) -> None:
manifest = PluginManifest( manifest = PluginManifest(
id="test", name="Test", version="1.0.0", id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test", api_version="1.0", description="Test", author="Test",
capabilities=(), capabilities=(),
) )
errors = _validate_capabilities(manifest) errors = _validate_capabilities(manifest)
assert errors == [] assert errors == []
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# Unit tests: _validate_api_version # Unit tests: _validate_api_version
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
class TestValidateApiVersion: class TestValidateApiVersion:
def test_compatible(self) -> None: def test_compatible(self) -> None:
manifest = PluginManifest( manifest = PluginManifest(
id="test", name="Test", version="1.0.0", id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test", api_version="1.0", description="Test", author="Test",
) )
errors = _validate_api_version(manifest) errors = _validate_api_version(manifest)
assert errors == [] assert errors == []
def test_incompatible(self) -> None: def test_incompatible(self) -> None:
manifest = PluginManifest( manifest = PluginManifest(
id="test", name="Test", version="1.0.0", id="test", name="Test", version="1.0.0",
api_version="2.0", description="Test", author="Test", api_version="2.0", description="Test", author="Test",
) )
errors = _validate_api_version(manifest) errors = _validate_api_version(manifest)
assert len(errors) > 0 assert len(errors) > 0
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# Integration tests: load_plugin_from_dir # Integration tests: load_plugin_from_dir
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
class TestLoadPluginFromDir: class TestLoadPluginFromDir:
def test_load_valid_plugin(self, fake_plugin_dir: Path) -> None: def test_load_valid_plugin(self, fake_plugin_dir: Path) -> None:
result = load_plugin_from_dir(fake_plugin_dir) result = load_plugin_from_dir(fake_plugin_dir)
assert result.success is True assert result.success is True
assert result.manifest is not None assert result.manifest is not None
assert result.manifest.id == "fake_plugin" assert result.manifest.id == "fake_plugin"
assert result.model_requirements is not None assert result.model_requirements is not None
assert result.create_engine is not None assert result.create_engine is not None
assert result.module is not None assert result.module is not None
assert result.error is None assert result.error is None
def test_plugin_satisfies_protocol(self, fake_plugin_dir: Path) -> None: def test_plugin_satisfies_protocol(self, fake_plugin_dir: Path) -> None:
from abogen.tts_plugin.engine import Engine from abogen.tts_plugin.engine import Engine
from abogen.tts_plugin.host_context import HostContext from abogen.tts_plugin.host_context import HostContext
import logging import logging
result = load_plugin_from_dir(fake_plugin_dir) result = load_plugin_from_dir(fake_plugin_dir)
assert result.success is True assert result.success is True
# Create engine using the loaded create_engine function # Create engine using the loaded create_engine function
ctx = HostContext( ctx = HostContext(
config_dir=Path("/tmp/test"), config_dir=Path("/tmp/test"),
logger=logging.getLogger("test"), logger=logging.getLogger("test"),
http_client=type("FakeClient", (), {"get": lambda s, **kw: None, "post": lambda s, **kw: None})(), http_client=type("FakeClient", (), {"get": lambda s, **kw: None, "post": lambda s, **kw: None})(),
) )
engine = result.create_engine(ctx, None, __import__("abogen.tts_plugin.types", fromlist=["EngineConfig"]).EngineConfig()) engine = result.create_engine(ctx, None, __import__("abogen.tts_plugin.types", fromlist=["EngineConfig"]).EngineConfig())
assert isinstance(engine, Engine) assert isinstance(engine, Engine)
engine.dispose() engine.dispose()
def test_nonexistent_directory(self, tmp_path: Path) -> None: def test_nonexistent_directory(self, tmp_path: Path) -> None:
result = load_plugin_from_dir(tmp_path / "nonexistent") result = load_plugin_from_dir(tmp_path / "nonexistent")
assert result.success is False assert result.success is False
assert result.error is not None assert result.error is not None
assert "does not exist" in result.error.errors[0] assert "does not exist" in result.error.errors[0]
def test_missing_init_file(self, tmp_path: Path) -> None: def test_missing_init_file(self, tmp_path: Path) -> None:
plugin_dir = tmp_path / "no_init" plugin_dir = tmp_path / "no_init"
plugin_dir.mkdir() plugin_dir.mkdir()
result = load_plugin_from_dir(plugin_dir) result = load_plugin_from_dir(plugin_dir)
assert result.success is False assert result.success is False
assert result.error is not None assert result.error is not None
assert "__init__.py" in result.error.errors[0] assert "__init__.py" in result.error.errors[0]
def test_import_error(self, import_error_dir: Path) -> None: def test_import_error(self, import_error_dir: Path) -> None:
result = load_plugin_from_dir(import_error_dir) result = load_plugin_from_dir(import_error_dir)
assert result.success is False assert result.success is False
assert result.error is not None assert result.error is not None
assert "Failed to import" in result.error.errors[0] assert "Failed to import" in result.error.errors[0]
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# Integration tests: invalid plugins # Integration tests: invalid plugins
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
class TestInvalidPlugins: class TestInvalidPlugins:
def test_missing_manifest(self, missing_manifest_dir: Path) -> None: def test_missing_manifest(self, missing_manifest_dir: Path) -> None:
result = load_plugin_from_dir(missing_manifest_dir) result = load_plugin_from_dir(missing_manifest_dir)
assert result.success is False assert result.success is False
assert result.error is not None assert result.error is not None
assert any("Missing PLUGIN_MANIFEST" in e for e in result.error.errors) assert any("Missing PLUGIN_MANIFEST" in e for e in result.error.errors)
def test_invalid_api_version(self, invalid_api_version_dir: Path) -> None: def test_invalid_api_version(self, invalid_api_version_dir: Path) -> None:
result = load_plugin_from_dir(invalid_api_version_dir) result = load_plugin_from_dir(invalid_api_version_dir)
assert result.success is False assert result.success is False
assert result.error is not None assert result.error is not None
assert any("major mismatch" in e for e in result.error.errors) assert any("major mismatch" in e for e in result.error.errors)
def test_invalid_capabilities(self, invalid_capabilities_dir: Path) -> None: def test_invalid_capabilities(self, invalid_capabilities_dir: Path) -> None:
result = load_plugin_from_dir(invalid_capabilities_dir) result = load_plugin_from_dir(invalid_capabilities_dir)
assert result.success is False assert result.success is False
assert result.error is not None assert result.error is not None
assert any("Unknown capability" in e for e in result.error.errors) assert any("Unknown capability" in e for e in result.error.errors)
def test_missing_create_engine(self, missing_create_engine_dir: Path) -> None: def test_missing_create_engine(self, missing_create_engine_dir: Path) -> None:
result = load_plugin_from_dir(missing_create_engine_dir) result = load_plugin_from_dir(missing_create_engine_dir)
assert result.success is False assert result.success is False
assert result.error is not None assert result.error is not None
assert any("Missing create_engine" in e for e in result.error.errors) assert any("Missing create_engine" in e for e in result.error.errors)
def test_missing_model_requirements(self, missing_model_requirements_dir: Path) -> None: def test_missing_model_requirements(self, missing_model_requirements_dir: Path) -> None:
result = load_plugin_from_dir(missing_model_requirements_dir) result = load_plugin_from_dir(missing_model_requirements_dir)
assert result.success is False assert result.success is False
assert result.error is not None assert result.error is not None
assert any("Missing MODEL_REQUIREMENTS" in e for e in result.error.errors) assert any("Missing MODEL_REQUIREMENTS" in e for e in result.error.errors)
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# Integration tests: discover_plugins # Integration tests: discover_plugins
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
class TestDiscoverPlugins: class TestDiscoverPlugins:
def test_discover_from_valid_dir(self, plugins_dir: Path) -> None: def test_discover_from_valid_dir(self, plugins_dir: Path) -> None:
results = discover_plugins([plugins_dir]) results = discover_plugins([plugins_dir])
# Should find multiple plugins (valid and invalid) # Should find multiple plugins (valid and invalid)
assert len(results) > 0 assert len(results) > 0
def test_discover_includes_valid_plugin(self, plugins_dir: Path) -> None: def test_discover_includes_valid_plugin(self, plugins_dir: Path) -> None:
results = discover_plugins([plugins_dir]) results = discover_plugins([plugins_dir])
valid = [r for r in results if r.success] valid = [r for r in results if r.success]
assert len(valid) >= 1 assert len(valid) >= 1
assert any(r.manifest and r.manifest.id == "fake_plugin" for r in valid) assert any(r.manifest and r.manifest.id == "fake_plugin" for r in valid)
def test_discover_includes_invalid_plugins(self, plugins_dir: Path) -> None: def test_discover_includes_invalid_plugins(self, plugins_dir: Path) -> None:
results = discover_plugins([plugins_dir]) results = discover_plugins([plugins_dir])
invalid = [r for r in results if not r.success] invalid = [r for r in results if not r.success]
assert len(invalid) >= 1 assert len(invalid) >= 1
def test_discover_nonexistent_dir(self, tmp_path: Path) -> None: def test_discover_nonexistent_dir(self, tmp_path: Path) -> None:
results = discover_plugins([tmp_path / "nonexistent"]) results = discover_plugins([tmp_path / "nonexistent"])
assert results == [] assert results == []
def test_discover_multiple_dirs(self, plugins_dir: Path, tmp_path: Path) -> None: def test_discover_multiple_dirs(self, plugins_dir: Path, tmp_path: Path) -> None:
results = discover_plugins([plugins_dir, tmp_path / "nonexistent"]) results = discover_plugins([plugins_dir, tmp_path / "nonexistent"])
assert len(results) > 0 assert len(results) > 0
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# Diagnostic messages tests # Diagnostic messages tests
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
class TestDiagnosticMessages: class TestDiagnosticMessages:
def test_error_contains_plugin_id(self, missing_manifest_dir: Path) -> None: def test_error_contains_plugin_id(self, missing_manifest_dir: Path) -> None:
result = load_plugin_from_dir(missing_manifest_dir) result = load_plugin_from_dir(missing_manifest_dir)
assert result.error is not None assert result.error is not None
assert result.error.plugin_id == "missing_manifest" assert result.error.plugin_id == "missing_manifest"
def test_error_contains_path(self, missing_manifest_dir: Path) -> None: def test_error_contains_path(self, missing_manifest_dir: Path) -> None:
result = load_plugin_from_dir(missing_manifest_dir) result = load_plugin_from_dir(missing_manifest_dir)
assert result.error is not None assert result.error is not None
assert result.error.path == missing_manifest_dir assert result.error.path == missing_manifest_dir
def test_error_contains_messages(self, missing_manifest_dir: Path) -> None: def test_error_contains_messages(self, missing_manifest_dir: Path) -> None:
result = load_plugin_from_dir(missing_manifest_dir) result = load_plugin_from_dir(missing_manifest_dir)
assert result.error is not None assert result.error is not None
assert len(result.error.errors) > 0 assert len(result.error.errors) > 0
def test_multiple_errors(self, invalid_api_version_dir: Path) -> None: def test_multiple_errors(self, invalid_api_version_dir: Path) -> None:
# This plugin has multiple issues # This plugin has multiple issues
result = load_plugin_from_dir(invalid_api_version_dir) result = load_plugin_from_dir(invalid_api_version_dir)
assert result.error is not None assert result.error is not None
# Should have at least the api_version error # Should have at least the api_version error
assert len(result.error.errors) >= 1 assert len(result.error.errors) >= 1
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# No partial registration tests # No partial registration tests
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
class TestNoPartialRegistration: class TestNoPartialRegistration:
def test_invalid_plugin_no_manifest_attr(self, missing_manifest_dir: Path) -> None: def test_invalid_plugin_no_manifest_attr(self, missing_manifest_dir: Path) -> None:
"""After failed load, module should not remain in sys.modules.""" """After failed load, module should not remain in sys.modules."""
result = load_plugin_from_dir(missing_manifest_dir) result = load_plugin_from_dir(missing_manifest_dir)
assert result.success is False assert result.success is False
# Module should not be registered # Module should not be registered
module_name = f"abogen.tts_plugin._loaded.missing_manifest" module_name = f"abogen.tts_plugin._loaded.missing_manifest"
assert module_name not in sys.modules assert module_name not in sys.modules
def test_import_error_no_registration(self, import_error_dir: Path) -> None: def test_import_error_no_registration(self, import_error_dir: Path) -> None:
"""After import error, module should not remain in sys.modules.""" """After import error, module should not remain in sys.modules."""
result = load_plugin_from_dir(import_error_dir) result = load_plugin_from_dir(import_error_dir)
assert result.success is False assert result.success is False
module_name = f"abogen.tts_plugin._loaded.import_error" module_name = f"abogen.tts_plugin._loaded.import_error"
assert module_name not in sys.modules assert module_name not in sys.modules
+290 -290
View File
@@ -1,290 +1,290 @@
"""Contract tests for plugin manifest types. """Contract tests for plugin manifest types.
These tests verify that manifest types satisfy the architectural requirements: These tests verify that manifest types satisfy the architectural requirements:
- All required fields are present - All required fields are present
- api_version follows semver format - api_version follows semver format
- capabilities are properly defined - capabilities are properly defined
- engine manifest describes the engine correctly - engine manifest describes the engine correctly
""" """
import re import re
import pytest import pytest
from abogen.tts_plugin.manifest import ( from abogen.tts_plugin.manifest import (
AudioFormatManifest, AudioFormatManifest,
EngineManifest, EngineManifest,
EnumOption, EnumOption,
GpuRequirement, GpuRequirement,
ModelManifest, ModelManifest,
ParameterManifest, ParameterManifest,
PluginManifest, PluginManifest,
RequirementManifest, RequirementManifest,
VoiceManifest, VoiceManifest,
VoiceSourceManifest, VoiceSourceManifest,
) )
class TestPluginManifestContract: class TestPluginManifestContract:
"""Contract tests for PluginManifest.""" """Contract tests for PluginManifest."""
def test_required_fields(self) -> None: def test_required_fields(self) -> None:
manifest = PluginManifest( manifest = PluginManifest(
id="test-plugin", id="test-plugin",
name="Test Plugin", name="Test Plugin",
version="1.0.0", version="1.0.0",
api_version="1.0", api_version="1.0",
description="A test plugin", description="A test plugin",
author="Test Author", author="Test Author",
) )
assert manifest.id == "test-plugin" assert manifest.id == "test-plugin"
assert manifest.name == "Test Plugin" assert manifest.name == "Test Plugin"
assert manifest.version == "1.0.0" assert manifest.version == "1.0.0"
assert manifest.api_version == "1.0" assert manifest.api_version == "1.0"
assert manifest.description == "A test plugin" assert manifest.description == "A test plugin"
assert manifest.author == "Test Author" assert manifest.author == "Test Author"
def test_api_version_semver_format(self) -> None: def test_api_version_semver_format(self) -> None:
"""Architecture spec: api_version format is semver (MAJOR.MINOR).""" """Architecture spec: api_version format is semver (MAJOR.MINOR)."""
valid_versions = ["1.0", "2.1", "10.5"] valid_versions = ["1.0", "2.1", "10.5"]
for version in valid_versions: for version in valid_versions:
manifest = PluginManifest( manifest = PluginManifest(
id="test", id="test",
name="Test", name="Test",
version="1.0.0", version="1.0.0",
api_version=version, api_version=version,
description="Test", description="Test",
author="Test", author="Test",
) )
assert re.match(r"^\d+\.\d+$", manifest.api_version) assert re.match(r"^\d+\.\d+$", manifest.api_version)
def test_capabilities_default_empty(self) -> None: def test_capabilities_default_empty(self) -> None:
manifest = PluginManifest( manifest = PluginManifest(
id="test", id="test",
name="Test", name="Test",
version="1.0.0", version="1.0.0",
api_version="1.0", api_version="1.0",
description="Test", description="Test",
author="Test", author="Test",
) )
assert manifest.capabilities == () assert manifest.capabilities == ()
def test_capabilities_tuple(self) -> None: def test_capabilities_tuple(self) -> None:
manifest = PluginManifest( manifest = PluginManifest(
id="test", id="test",
name="Test", name="Test",
version="1.0.0", version="1.0.0",
api_version="1.0", api_version="1.0",
description="Test", description="Test",
author="Test", author="Test",
capabilities=("voice_list", "preview"), capabilities=("voice_list", "preview"),
) )
assert "voice_list" in manifest.capabilities assert "voice_list" in manifest.capabilities
assert "preview" in manifest.capabilities assert "preview" in manifest.capabilities
def test_requires_default(self) -> None: def test_requires_default(self) -> None:
manifest = PluginManifest( manifest = PluginManifest(
id="test", id="test",
name="Test", name="Test",
version="1.0.0", version="1.0.0",
api_version="1.0", api_version="1.0",
description="Test", description="Test",
author="Test", author="Test",
) )
assert isinstance(manifest.requires, RequirementManifest) assert isinstance(manifest.requires, RequirementManifest)
def test_engine_default(self) -> None: def test_engine_default(self) -> None:
manifest = PluginManifest( manifest = PluginManifest(
id="test", id="test",
name="Test", name="Test",
version="1.0.0", version="1.0.0",
api_version="1.0", api_version="1.0",
description="Test", description="Test",
author="Test", author="Test",
) )
assert isinstance(manifest.engine, EngineManifest) assert isinstance(manifest.engine, EngineManifest)
class TestEngineManifestContract: class TestEngineManifestContract:
"""Contract tests for EngineManifest.""" """Contract tests for EngineManifest."""
def test_required_fields(self) -> None: def test_required_fields(self) -> None:
manifest = EngineManifest( manifest = EngineManifest(
voiceSources=( voiceSources=(
VoiceSourceManifest(id="builtin", name="Builtin", type="list"), VoiceSourceManifest(id="builtin", name="Builtin", type="list"),
), ),
parameters=( parameters=(
ParameterManifest( ParameterManifest(
id="speed", name="Speed", description="Speed", type="float", default=1.0 id="speed", name="Speed", description="Speed", type="float", default=1.0
), ),
), ),
audioFormats=(AudioFormatManifest(mime="audio/wav", extension="wav"),), audioFormats=(AudioFormatManifest(mime="audio/wav", extension="wav"),),
) )
assert len(manifest.voiceSources) == 1 assert len(manifest.voiceSources) == 1
assert len(manifest.parameters) == 1 assert len(manifest.parameters) == 1
assert len(manifest.audioFormats) == 1 assert len(manifest.audioFormats) == 1
def test_defaults_empty(self) -> None: def test_defaults_empty(self) -> None:
manifest = EngineManifest() manifest = EngineManifest()
assert manifest.voiceSources == () assert manifest.voiceSources == ()
assert manifest.parameters == () assert manifest.parameters == ()
assert manifest.audioFormats == () assert manifest.audioFormats == ()
class TestVoiceSourceManifestContract: class TestVoiceSourceManifestContract:
"""Contract tests for VoiceSourceManifest.""" """Contract tests for VoiceSourceManifest."""
def test_required_fields(self) -> None: def test_required_fields(self) -> None:
vs = VoiceSourceManifest(id="builtin", name="Builtin", type="list") vs = VoiceSourceManifest(id="builtin", name="Builtin", type="list")
assert vs.id == "builtin" assert vs.id == "builtin"
assert vs.name == "Builtin" assert vs.name == "Builtin"
assert vs.type == "list" assert vs.type == "list"
def test_valid_types(self) -> None: def test_valid_types(self) -> None:
valid_types = ["list", "speaker_id", "clone", "blend", "generate", "none"] valid_types = ["list", "speaker_id", "clone", "blend", "generate", "none"]
for vtype in valid_types: for vtype in valid_types:
vs = VoiceSourceManifest(id="test", name="Test", type=vtype) vs = VoiceSourceManifest(id="test", name="Test", type=vtype)
assert vs.type == vtype assert vs.type == vtype
def test_config_optional(self) -> None: def test_config_optional(self) -> None:
vs = VoiceSourceManifest(id="test", name="Test", type="list") vs = VoiceSourceManifest(id="test", name="Test", type="list")
assert vs.config is None assert vs.config is None
def test_config_any(self) -> None: def test_config_any(self) -> None:
config = {"voices": ["af_nova", "af_sky"]} config = {"voices": ["af_nova", "af_sky"]}
vs = VoiceSourceManifest(id="test", name="Test", type="list", config=config) vs = VoiceSourceManifest(id="test", name="Test", type="list", config=config)
assert vs.config == config assert vs.config == config
class TestVoiceManifestContract: class TestVoiceManifestContract:
"""Contract tests for VoiceManifest.""" """Contract tests for VoiceManifest."""
def test_required_fields(self) -> None: def test_required_fields(self) -> None:
v = VoiceManifest(id="af_nova", name="Nova") v = VoiceManifest(id="af_nova", name="Nova")
assert v.id == "af_nova" assert v.id == "af_nova"
assert v.name == "Nova" assert v.name == "Nova"
def test_tags_default_empty(self) -> None: def test_tags_default_empty(self) -> None:
v = VoiceManifest(id="af_nova", name="Nova") v = VoiceManifest(id="af_nova", name="Nova")
assert v.tags == () assert v.tags == ()
def test_tags_tuple(self) -> None: def test_tags_tuple(self) -> None:
v = VoiceManifest(id="af_nova", name="Nova", tags=("en", "female")) v = VoiceManifest(id="af_nova", name="Nova", tags=("en", "female"))
assert "en" in v.tags assert "en" in v.tags
assert "female" in v.tags assert "female" in v.tags
class TestParameterManifestContract: class TestParameterManifestContract:
"""Contract tests for ParameterManifest.""" """Contract tests for ParameterManifest."""
def test_required_fields(self) -> None: def test_required_fields(self) -> None:
p = ParameterManifest( p = ParameterManifest(
id="speed", name="Speed", description="Speech speed", type="float", default=1.0 id="speed", name="Speed", description="Speech speed", type="float", default=1.0
) )
assert p.id == "speed" assert p.id == "speed"
assert p.name == "Speed" assert p.name == "Speed"
assert p.description == "Speech speed" assert p.description == "Speech speed"
assert p.type == "float" assert p.type == "float"
assert p.default == 1.0 assert p.default == 1.0
def test_valid_types(self) -> None: def test_valid_types(self) -> None:
valid_types = ["float", "int", "string", "boolean", "enum"] valid_types = ["float", "int", "string", "boolean", "enum"]
for ptype in valid_types: for ptype in valid_types:
p = ParameterManifest( p = ParameterManifest(
id="test", name="Test", description="Test", type=ptype, default=None id="test", name="Test", description="Test", type=ptype, default=None
) )
assert p.type == ptype assert p.type == ptype
def test_optional_numeric_bounds(self) -> None: def test_optional_numeric_bounds(self) -> None:
p = ParameterManifest( p = ParameterManifest(
id="speed", id="speed",
name="Speed", name="Speed",
description="Speed", description="Speed",
type="float", type="float",
default=1.0, default=1.0,
min=0.5, min=0.5,
max=2.0, max=2.0,
step=0.1, step=0.1,
) )
assert p.min == 0.5 assert p.min == 0.5
assert p.max == 2.0 assert p.max == 2.0
assert p.step == 0.1 assert p.step == 0.1
def test_enum_options(self) -> None: def test_enum_options(self) -> None:
options = ( options = (
EnumOption(value="low", label="Low"), EnumOption(value="low", label="Low"),
EnumOption(value="high", label="High"), EnumOption(value="high", label="High"),
) )
p = ParameterManifest( p = ParameterManifest(
id="quality", id="quality",
name="Quality", name="Quality",
description="Quality", description="Quality",
type="enum", type="enum",
default="low", default="low",
options=options, options=options,
) )
assert len(p.options) == 2 assert len(p.options) == 2
assert p.options[0].value == "low" assert p.options[0].value == "low"
class TestAudioFormatManifestContract: class TestAudioFormatManifestContract:
"""Contract tests for AudioFormatManifest.""" """Contract tests for AudioFormatManifest."""
def test_required_fields(self) -> None: def test_required_fields(self) -> None:
af = AudioFormatManifest(mime="audio/wav", extension="wav") af = AudioFormatManifest(mime="audio/wav", extension="wav")
assert af.mime == "audio/wav" assert af.mime == "audio/wav"
assert af.extension == "wav" assert af.extension == "wav"
class TestEnumOptionContract: class TestEnumOptionContract:
"""Contract tests for EnumOption.""" """Contract tests for EnumOption."""
def test_required_fields(self) -> None: def test_required_fields(self) -> None:
opt = EnumOption(value="low", label="Low Quality") opt = EnumOption(value="low", label="Low Quality")
assert opt.value == "low" assert opt.value == "low"
assert opt.label == "Low Quality" assert opt.label == "Low Quality"
class TestRequirementManifestContract: class TestRequirementManifestContract:
"""Contract tests for RequirementManifest.""" """Contract tests for RequirementManifest."""
def test_defaults(self) -> None: def test_defaults(self) -> None:
req = RequirementManifest() req = RequirementManifest()
assert req.gpu is None assert req.gpu is None
assert req.memory is None assert req.memory is None
assert req.internet is None assert req.internet is None
def test_with_gpu(self) -> None: def test_with_gpu(self) -> None:
gpu = GpuRequirement(required=True, type="cuda", memory=8.0) gpu = GpuRequirement(required=True, type="cuda", memory=8.0)
req = RequirementManifest(gpu=gpu) req = RequirementManifest(gpu=gpu)
assert req.gpu.required is True assert req.gpu.required is True
assert req.gpu.type == "cuda" assert req.gpu.type == "cuda"
assert req.gpu.memory == 8.0 assert req.gpu.memory == 8.0
def test_with_internet(self) -> None: def test_with_internet(self) -> None:
req = RequirementManifest(internet=True) req = RequirementManifest(internet=True)
assert req.internet is True assert req.internet is True
class TestGpuRequirementContract: class TestGpuRequirementContract:
"""Contract tests for GpuRequirement.""" """Contract tests for GpuRequirement."""
def test_defaults(self) -> None: def test_defaults(self) -> None:
gpu = GpuRequirement() gpu = GpuRequirement()
assert gpu.required is False assert gpu.required is False
assert gpu.type is None assert gpu.type is None
assert gpu.memory is None assert gpu.memory is None
def test_required_gpu(self) -> None: def test_required_gpu(self) -> None:
gpu = GpuRequirement(required=True, type="cuda", memory=8.0) gpu = GpuRequirement(required=True, type="cuda", memory=8.0)
assert gpu.required is True assert gpu.required is True
class TestModelManifestContract: class TestModelManifestContract:
"""Contract tests for ModelManifest.""" """Contract tests for ModelManifest."""
def test_required_fields(self) -> None: def test_required_fields(self) -> None:
m = ModelManifest(id="xtts_v2", name="XTTS v2", size="2GB") m = ModelManifest(id="xtts_v2", name="XTTS v2", size="2GB")
assert m.id == "xtts_v2" assert m.id == "xtts_v2"
assert m.name == "XTTS v2" assert m.name == "XTTS v2"
assert m.size == "2GB" assert m.size == "2GB"
+146 -146
View File
@@ -1,146 +1,146 @@
"""Contract tests for plugin contract. """Contract tests for plugin contract.
These tests verify that plugin modules satisfy the architectural requirements: These tests verify that plugin modules satisfy the architectural requirements:
- Must export PLUGIN_MANIFEST: PluginManifest - Must export PLUGIN_MANIFEST: PluginManifest
- Must export MODEL_REQUIREMENTS: list[ModelManifest] - Must export MODEL_REQUIREMENTS: list[ModelManifest]
- Must export create_engine: Callable[[HostContext, Path | None, EngineConfig], Engine] - Must export create_engine: Callable[[HostContext, Path | None, EngineConfig], Engine]
- create_engine() must be atomic - create_engine() must be atomic
""" """
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import pytest import pytest
from abogen.tts_plugin.engine import Engine from abogen.tts_plugin.engine import Engine
from abogen.tts_plugin.host_context import HostContext from abogen.tts_plugin.host_context import HostContext
from abogen.tts_plugin.manifest import EngineManifest, ModelManifest, PluginManifest from abogen.tts_plugin.manifest import EngineManifest, ModelManifest, PluginManifest
from abogen.tts_plugin.plugin import Plugin from abogen.tts_plugin.plugin import Plugin
from abogen.tts_plugin.types import EngineConfig from abogen.tts_plugin.types import EngineConfig
from .conftest import FakeEngine from .conftest import FakeEngine
class FakePluginModule: class FakePluginModule:
"""Stub plugin module that satisfies the plugin contract.""" """Stub plugin module that satisfies the plugin contract."""
PLUGIN_MANIFEST = PluginManifest( PLUGIN_MANIFEST = PluginManifest(
id="fake-plugin", id="fake-plugin",
name="Fake Plugin", name="Fake Plugin",
version="1.0.0", version="1.0.0",
api_version="1.0", api_version="1.0",
description="A fake plugin for testing", description="A fake plugin for testing",
author="Test Author", author="Test Author",
capabilities=(), capabilities=(),
engine=EngineManifest(), engine=EngineManifest(),
) )
MODEL_REQUIREMENTS: list[ModelManifest] = [] MODEL_REQUIREMENTS: list[ModelManifest] = []
@staticmethod @staticmethod
def create_engine( def create_engine(
context: HostContext, context: HostContext,
model_path: Path | None, model_path: Path | None,
config: EngineConfig, config: EngineConfig,
) -> Engine: ) -> Engine:
return FakeEngine() return FakeEngine()
class TestPluginProtocolContract: class TestPluginProtocolContract:
"""Contract tests for the Plugin protocol.""" """Contract tests for the Plugin protocol."""
def test_plugin_is_protocol(self) -> None: def test_plugin_is_protocol(self) -> None:
assert hasattr(Plugin, "__protocol_attrs__") assert hasattr(Plugin, "__protocol_attrs__")
class TestPluginExportsContract: class TestPluginExportsContract:
"""Contract tests for required plugin exports.""" """Contract tests for required plugin exports."""
def test_plugin_has_plugin_manifest(self) -> None: def test_plugin_has_plugin_manifest(self) -> None:
"""Architecture spec: Plugin must export PLUGIN_MANIFEST.""" """Architecture spec: Plugin must export PLUGIN_MANIFEST."""
assert hasattr(FakePluginModule, "PLUGIN_MANIFEST") assert hasattr(FakePluginModule, "PLUGIN_MANIFEST")
assert isinstance(FakePluginModule.PLUGIN_MANIFEST, PluginManifest) assert isinstance(FakePluginModule.PLUGIN_MANIFEST, PluginManifest)
def test_plugin_has_model_requirements(self) -> None: def test_plugin_has_model_requirements(self) -> None:
"""Architecture spec: Plugin must export MODEL_REQUIREMENTS.""" """Architecture spec: Plugin must export MODEL_REQUIREMENTS."""
assert hasattr(FakePluginModule, "MODEL_REQUIREMENTS") assert hasattr(FakePluginModule, "MODEL_REQUIREMENTS")
assert isinstance(FakePluginModule.MODEL_REQUIREMENTS, list) assert isinstance(FakePluginModule.MODEL_REQUIREMENTS, list)
def test_plugin_has_create_engine(self) -> None: def test_plugin_has_create_engine(self) -> None:
"""Architecture spec: Plugin must export create_engine.""" """Architecture spec: Plugin must export create_engine."""
assert hasattr(FakePluginModule, "create_engine") assert hasattr(FakePluginModule, "create_engine")
assert callable(FakePluginModule.create_engine) assert callable(FakePluginModule.create_engine)
def test_plugin_manifest_required_fields(self) -> None: def test_plugin_manifest_required_fields(self) -> None:
"""Architecture spec: PluginManifest has required fields.""" """Architecture spec: PluginManifest has required fields."""
manifest = FakePluginModule.PLUGIN_MANIFEST manifest = FakePluginModule.PLUGIN_MANIFEST
assert manifest.id assert manifest.id
assert manifest.name assert manifest.name
assert manifest.version assert manifest.version
assert manifest.api_version assert manifest.api_version
assert manifest.description assert manifest.description
assert manifest.author assert manifest.author
def test_plugin_manifest_capabilities_is_tuple(self) -> None: def test_plugin_manifest_capabilities_is_tuple(self) -> None:
manifest = FakePluginModule.PLUGIN_MANIFEST manifest = FakePluginModule.PLUGIN_MANIFEST
assert isinstance(manifest.capabilities, tuple) assert isinstance(manifest.capabilities, tuple)
class TestCreateEngineContract: class TestCreateEngineContract:
"""Contract tests for create_engine() function.""" """Contract tests for create_engine() function."""
def test_create_engine_returns_engine(self) -> None: def test_create_engine_returns_engine(self) -> None:
"""Architecture spec: create_engine() returns Engine.""" """Architecture spec: create_engine() returns Engine."""
ctx = HostContext( ctx = HostContext(
config_dir=Path("/tmp/test"), config_dir=Path("/tmp/test"),
logger=logging.getLogger("test"), logger=logging.getLogger("test"),
http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(), http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(),
) )
engine = FakePluginModule.create_engine(ctx, None, EngineConfig()) engine = FakePluginModule.create_engine(ctx, None, EngineConfig())
assert isinstance(engine, Engine) assert isinstance(engine, Engine)
def test_create_engine_atomic(self) -> None: def test_create_engine_atomic(self) -> None:
"""Architecture spec: create_engine() is atomic (all-or-nothing).""" """Architecture spec: create_engine() is atomic (all-or-nothing)."""
ctx = HostContext( ctx = HostContext(
config_dir=Path("/tmp/test"), config_dir=Path("/tmp/test"),
logger=logging.getLogger("test"), logger=logging.getLogger("test"),
http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(), http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(),
) )
engine = FakePluginModule.create_engine(ctx, None, EngineConfig()) engine = FakePluginModule.create_engine(ctx, None, EngineConfig())
assert isinstance(engine, Engine) assert isinstance(engine, Engine)
engine.dispose() engine.dispose()
def test_create_engine_with_none_model_path(self) -> None: def test_create_engine_with_none_model_path(self) -> None:
"""Architecture spec: model_path can be None for cloud/no-model engines.""" """Architecture spec: model_path can be None for cloud/no-model engines."""
ctx = HostContext( ctx = HostContext(
config_dir=Path("/tmp/test"), config_dir=Path("/tmp/test"),
logger=logging.getLogger("test"), logger=logging.getLogger("test"),
http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(), http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(),
) )
engine = FakePluginModule.create_engine(ctx, None, EngineConfig()) engine = FakePluginModule.create_engine(ctx, None, EngineConfig())
assert isinstance(engine, Engine) assert isinstance(engine, Engine)
engine.dispose() engine.dispose()
def test_create_engine_with_model_path(self) -> None: def test_create_engine_with_model_path(self) -> None:
"""Architecture spec: model_path is Path | None.""" """Architecture spec: model_path is Path | None."""
ctx = HostContext( ctx = HostContext(
config_dir=Path("/tmp/test"), config_dir=Path("/tmp/test"),
logger=logging.getLogger("test"), logger=logging.getLogger("test"),
http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(), http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(),
) )
engine = FakePluginModule.create_engine(ctx, Path("/models/test"), EngineConfig()) engine = FakePluginModule.create_engine(ctx, Path("/models/test"), EngineConfig())
assert isinstance(engine, Engine) assert isinstance(engine, Engine)
engine.dispose() engine.dispose()
class TestModelRequirementsContract: class TestModelRequirementsContract:
"""Contract tests for MODEL_REQUIREMENTS.""" """Contract tests for MODEL_REQUIREMENTS."""
def test_model_requirements_is_list(self) -> None: def test_model_requirements_is_list(self) -> None:
assert isinstance(FakePluginModule.MODEL_REQUIREMENTS, list) assert isinstance(FakePluginModule.MODEL_REQUIREMENTS, list)
def test_model_requirements_contains_model_manifests(self) -> None: def test_model_requirements_contains_model_manifests(self) -> None:
"""If non-empty, each item must be a ModelManifest.""" """If non-empty, each item must be a ModelManifest."""
for req in FakePluginModule.MODEL_REQUIREMENTS: for req in FakePluginModule.MODEL_REQUIREMENTS:
assert isinstance(req, ModelManifest) assert isinstance(req, ModelManifest)
+274 -274
View File
@@ -1,274 +1,274 @@
"""Integration tests for Plugin Manager and direct utility functions.""" """Integration tests for Plugin Manager and direct utility functions."""
import pytest import pytest
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from abogen.tts_plugin.plugin_manager import PluginManager, get_plugin_manager, reset_plugin_manager from abogen.tts_plugin.plugin_manager import PluginManager, get_plugin_manager, reset_plugin_manager
from abogen.tts_plugin.utils import Pipeline, create_pipeline from abogen.tts_plugin.utils import Pipeline, create_pipeline
from abogen.tts_plugin.engine import Engine, EngineSession from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.types import SynthesisRequest, SynthesizedAudio, AudioFormat from abogen.tts_plugin.types import SynthesisRequest, SynthesizedAudio, AudioFormat
class FakeEngine: class FakeEngine:
"""Fake Engine for testing.""" """Fake Engine for testing."""
def __init__(self, **kwargs): def __init__(self, **kwargs):
self.kwargs = kwargs self.kwargs = kwargs
self._disposed = False self._disposed = False
def createSession(self): def createSession(self):
return FakeEngineSession() return FakeEngineSession()
def dispose(self): def dispose(self):
self._disposed = True self._disposed = True
@property @property
def manifest(self): def manifest(self):
return MagicMock() return MagicMock()
class FakeEngineSession: class FakeEngineSession:
"""Fake EngineSession for testing.""" """Fake EngineSession for testing."""
def __init__(self): def __init__(self):
self._disposed = False self._disposed = False
def synthesize(self, request): def synthesize(self, request):
# Return fake audio # Return fake audio
import numpy as np import numpy as np
from abogen.tts_plugin.types import AudioFormat, Duration, SynthesizedAudio from abogen.tts_plugin.types import AudioFormat, Duration, SynthesizedAudio
audio = np.zeros(1000, dtype=np.float32) audio = np.zeros(1000, dtype=np.float32)
return SynthesizedAudio( return SynthesizedAudio(
data=audio.tobytes(), data=audio.tobytes(),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=0.04167), # 1000 samples / 24000 Hz duration=Duration(seconds=0.04167), # 1000 samples / 24000 Hz
) )
def dispose(self): def dispose(self):
self._disposed = True self._disposed = True
class TestPluginManager: class TestPluginManager:
"""Test PluginManager functionality.""" """Test PluginManager functionality."""
def test_plugin_manager_creation(self): def test_plugin_manager_creation(self):
"""PluginManager can be created.""" """PluginManager can be created."""
manager = PluginManager() manager = PluginManager()
assert manager is not None assert manager is not None
def test_plugin_manager_list_discovers_plugins(self): def test_plugin_manager_list_discovers_plugins(self):
"""PluginManager discovers plugins from plugins directory.""" """PluginManager discovers plugins from plugins directory."""
manager = PluginManager() manager = PluginManager()
manager.discover("plugins") manager.discover("plugins")
plugins = manager.list_plugins() plugins = manager.list_plugins()
# Should discover kokoro plugin if it exists # Should discover kokoro plugin if it exists
assert isinstance(plugins, list) assert isinstance(plugins, list)
def test_plugin_manager_has_plugin_after_discover(self): def test_plugin_manager_has_plugin_after_discover(self):
"""PluginManager reports plugins after discovery.""" """PluginManager reports plugins after discovery."""
manager = PluginManager() manager = PluginManager()
manager.discover("plugins") manager.discover("plugins")
# kokoro plugin should be discovered if plugins/kokoro exists # kokoro plugin should be discovered if plugins/kokoro exists
# This is expected behavior # This is expected behavior
assert isinstance(manager._plugins, dict) assert isinstance(manager._plugins, dict)
def test_plugin_manager_get_plugin_after_discover(self): def test_plugin_manager_get_plugin_after_discover(self):
"""PluginManager returns plugin info after discovery.""" """PluginManager returns plugin info after discovery."""
manager = PluginManager() manager = PluginManager()
manager.discover("plugins") manager.discover("plugins")
# kokoro plugin should be discovered if plugins/kokoro exists # kokoro plugin should be discovered if plugins/kokoro exists
assert isinstance(manager._plugins, dict) assert isinstance(manager._plugins, dict)
def test_plugin_manager_create_engine_not_found(self): def test_plugin_manager_create_engine_not_found(self):
"""PluginManager raises KeyError for unknown plugins.""" """PluginManager raises KeyError for unknown plugins."""
manager = PluginManager() manager = PluginManager()
with pytest.raises(KeyError, match="Plugin not found"): with pytest.raises(KeyError, match="Plugin not found"):
manager.create_engine("nonexistent") manager.create_engine("nonexistent")
def test_plugin_manager_discover_with_empty_dir(self): def test_plugin_manager_discover_with_empty_dir(self):
"""PluginManager handles missing plugins directory.""" """PluginManager handles missing plugins directory."""
manager = PluginManager() manager = PluginManager()
manager.discover("/nonexistent/path") manager.discover("/nonexistent/path")
plugins = manager.list_plugins() plugins = manager.list_plugins()
assert plugins == [] assert plugins == []
def test_global_plugin_manager_singleton(self): def test_global_plugin_manager_singleton(self):
"""Global PluginManager is a singleton.""" """Global PluginManager is a singleton."""
reset_plugin_manager() reset_plugin_manager()
manager1 = get_plugin_manager() manager1 = get_plugin_manager()
manager2 = get_plugin_manager() manager2 = get_plugin_manager()
assert manager1 is manager2 assert manager1 is manager2
reset_plugin_manager() reset_plugin_manager()
def test_reset_plugin_manager(self): def test_reset_plugin_manager(self):
"""reset_plugin_manager clears the singleton.""" """reset_plugin_manager clears the singleton."""
manager1 = get_plugin_manager() manager1 = get_plugin_manager()
reset_plugin_manager() reset_plugin_manager()
manager2 = get_plugin_manager() manager2 = get_plugin_manager()
assert manager1 is not manager2 assert manager1 is not manager2
reset_plugin_manager() reset_plugin_manager()
class TestPipeline: class TestPipeline:
"""Test Pipeline functionality.""" """Test Pipeline functionality."""
def test_pipeline_creation(self): def test_pipeline_creation(self):
"""Pipeline can be created.""" """Pipeline can be created."""
engine = FakeEngine() engine = FakeEngine()
backend = Pipeline(engine) backend = Pipeline(engine)
assert backend is not None assert backend is not None
def test_pipeline_callable(self): def test_pipeline_callable(self):
"""Pipeline is callable like old TTSBackend.""" """Pipeline is callable like old TTSBackend."""
engine = FakeEngine() engine = FakeEngine()
backend = Pipeline(engine) backend = Pipeline(engine)
# Should be callable # Should be callable
assert callable(backend) assert callable(backend)
def test_pipeline_synthesize(self): def test_pipeline_synthesize(self):
"""Pipeline can synthesize text.""" """Pipeline can synthesize text."""
engine = FakeEngine() engine = FakeEngine()
backend = Pipeline(engine) backend = Pipeline(engine)
# Call the backend # Call the backend
segments = list(backend("Hello world", voice="default", speed=1.0)) segments = list(backend("Hello world", voice="default", speed=1.0))
# Should return at least one segment # Should return at least one segment
assert len(segments) >= 1 assert len(segments) >= 1
# Segment should have graphemes and audio # Segment should have graphemes and audio
segment = segments[0] segment = segments[0]
assert hasattr(segment, "graphemes") assert hasattr(segment, "graphemes")
assert hasattr(segment, "audio") assert hasattr(segment, "audio")
assert segment.graphemes == "Hello world" assert segment.graphemes == "Hello world"
def test_pipeline_dispose(self): def test_pipeline_dispose(self):
"""Pipeline can be disposed.""" """Pipeline can be disposed."""
engine = FakeEngine() engine = FakeEngine()
backend = Pipeline(engine) backend = Pipeline(engine)
# Create a session by calling # Create a session by calling
list(backend("test")) list(backend("test"))
# Dispose should not raise # Dispose should not raise
backend.dispose() backend.dispose()
# Double dispose should be safe # Double dispose should be safe
backend.dispose() backend.dispose()
class TestCreatePipelineCompat: class TestCreatePipelineCompat:
"""Test create_pipeline utility function.""" """Test create_pipeline utility function."""
def test_create_pipeline_returns_callable(self): def test_create_pipeline_returns_callable(self):
"""create_pipeline returns a callable backend.""" """create_pipeline returns a callable backend."""
from abogen.tts_plugin.host_context import HostContext from abogen.tts_plugin.host_context import HostContext
from abogen.tts_plugin.types import EngineConfig from abogen.tts_plugin.types import EngineConfig
# Mock the plugin manager # Mock the plugin manager
with patch("abogen.tts_plugin.utils.get_plugin_manager") as mock_get_manager: with patch("abogen.tts_plugin.utils.get_plugin_manager") as mock_get_manager:
mock_manager = MagicMock() mock_manager = MagicMock()
mock_get_manager.return_value = mock_manager mock_get_manager.return_value = mock_manager
mock_engine = FakeEngine() mock_engine = FakeEngine()
mock_manager.create_engine.return_value = mock_engine mock_manager.create_engine.return_value = mock_engine
backend = create_pipeline("kokoro", lang_code="a", device="cpu") backend = create_pipeline("kokoro", lang_code="a", device="cpu")
assert callable(backend) assert callable(backend)
mock_manager.create_engine.assert_called_once() mock_manager.create_engine.assert_called_once()
call_args = mock_manager.create_engine.call_args call_args = mock_manager.create_engine.call_args
assert call_args.args[0] == "kokoro" assert call_args.args[0] == "kokoro"
assert isinstance(call_args.kwargs["context"], HostContext) assert isinstance(call_args.kwargs["context"], HostContext)
assert call_args.kwargs["model_path"] is None assert call_args.kwargs["model_path"] is None
assert isinstance(call_args.kwargs["config"], EngineConfig) assert isinstance(call_args.kwargs["config"], EngineConfig)
assert call_args.kwargs["config"].device == "cpu" assert call_args.kwargs["config"].device == "cpu"
assert call_args.kwargs["config"].lang_code == "a" assert call_args.kwargs["config"].lang_code == "a"
def test_create_pipeline_raises_for_unknown_plugin(self): def test_create_pipeline_raises_for_unknown_plugin(self):
"""create_pipeline raises KeyError for unknown plugins.""" """create_pipeline raises KeyError for unknown plugins."""
with patch("abogen.tts_plugin.utils.get_plugin_manager") as mock_get_manager: with patch("abogen.tts_plugin.utils.get_plugin_manager") as mock_get_manager:
mock_manager = MagicMock() mock_manager = MagicMock()
mock_get_manager.return_value = mock_manager mock_get_manager.return_value = mock_manager
mock_manager.create_engine.side_effect = KeyError("Plugin not found") mock_manager.create_engine.side_effect = KeyError("Plugin not found")
with pytest.raises(KeyError): with pytest.raises(KeyError):
create_pipeline("nonexistent") create_pipeline("nonexistent")
class TestPluginManagerWithFakePlugins: class TestPluginManagerWithFakePlugins:
"""Test PluginManager with fake plugin loading.""" """Test PluginManager with fake plugin loading."""
def test_plugin_manager_create_engine_from_plugin(self): def test_plugin_manager_create_engine_from_plugin(self):
"""PluginManager creates engine from loaded plugin.""" """PluginManager creates engine from loaded plugin."""
manager = PluginManager() manager = PluginManager()
# Manually add a fake plugin # Manually add a fake plugin
def fake_create_engine(**kwargs): def fake_create_engine(**kwargs):
return FakeEngine(**kwargs) return FakeEngine(**kwargs)
manager._plugins["fake"] = { manager._plugins["fake"] = {
"manifest": MagicMock(), "manifest": MagicMock(),
"create_engine": fake_create_engine, "create_engine": fake_create_engine,
} }
manager._loaded = True manager._loaded = True
# Create engine # Create engine
engine = manager.create_engine("fake", param="value") engine = manager.create_engine("fake", param="value")
assert isinstance(engine, FakeEngine) assert isinstance(engine, FakeEngine)
assert engine.kwargs == {"param": "value"} assert engine.kwargs == {"param": "value"}
def test_plugin_manager_get_or_create_engine(self): def test_plugin_manager_get_or_create_engine(self):
"""PluginManager caches engines.""" """PluginManager caches engines."""
manager = PluginManager() manager = PluginManager()
call_count = 0 call_count = 0
def fake_create_engine(**kwargs): def fake_create_engine(**kwargs):
nonlocal call_count nonlocal call_count
call_count += 1 call_count += 1
return FakeEngine(**kwargs) return FakeEngine(**kwargs)
manager._plugins["fake"] = { manager._plugins["fake"] = {
"manifest": MagicMock(), "manifest": MagicMock(),
"create_engine": fake_create_engine, "create_engine": fake_create_engine,
} }
manager._loaded = True manager._loaded = True
# Get engine twice # Get engine twice
engine1 = manager.get_or_create_engine("fake") engine1 = manager.get_or_create_engine("fake")
engine2 = manager.get_or_create_engine("fake") engine2 = manager.get_or_create_engine("fake")
# Should be same instance # Should be same instance
assert engine1 is engine2 assert engine1 is engine2
assert call_count == 1 assert call_count == 1
def test_plugin_manager_dispose_all(self): def test_plugin_manager_dispose_all(self):
"""PluginManager disposes all cached engines.""" """PluginManager disposes all cached engines."""
manager = PluginManager() manager = PluginManager()
def fake_create_engine(**kwargs): def fake_create_engine(**kwargs):
return FakeEngine(**kwargs) return FakeEngine(**kwargs)
manager._plugins["fake"] = { manager._plugins["fake"] = {
"manifest": MagicMock(), "manifest": MagicMock(),
"create_engine": fake_create_engine, "create_engine": fake_create_engine,
} }
manager._loaded = True manager._loaded = True
# Create and cache engines # Create and cache engines
engine1 = manager.get_or_create_engine("fake") engine1 = manager.get_or_create_engine("fake")
engine2 = manager.get_or_create_engine("fake") engine2 = manager.get_or_create_engine("fake")
# Dispose all # Dispose all
manager.dispose_all() manager.dispose_all()
# Engines should be disposed # Engines should be disposed
assert engine1._disposed is True assert engine1._disposed is True
assert engine2._disposed is True assert engine2._disposed is True
# Cache should be empty # Cache should be empty
assert len(manager._engines) == 0 assert len(manager._engines) == 0
+135 -135
View File
@@ -1,135 +1,135 @@
"""Contract tests for EngineSession protocol. """Contract tests for EngineSession protocol.
These tests verify that EngineSession implementations satisfy the architectural requirements: These tests verify that EngineSession implementations satisfy the architectural requirements:
- synthesize() returns SynthesizedAudio - synthesize() returns SynthesizedAudio
- dispose() is idempotent - dispose() is idempotent
- After dispose(), synthesize() raises EngineError - After dispose(), synthesize() raises EngineError
- Session remains usable after synthesize() failure - Session remains usable after synthesize() failure
""" """
import pytest import pytest
from abogen.tts_plugin.engine import EngineSession from abogen.tts_plugin.engine import EngineSession
from abogen.tts_plugin.errors import EngineError from abogen.tts_plugin.errors import EngineError
from abogen.tts_plugin.types import ( from abogen.tts_plugin.types import (
AudioFormat, AudioFormat,
Duration, Duration,
ParameterValues, ParameterValues,
SynthesisRequest, SynthesisRequest,
SynthesizedAudio, SynthesizedAudio,
VoiceSelection, VoiceSelection,
) )
from .conftest import FakeEngineSession from .conftest import FakeEngineSession
class TestEngineSessionProtocolContract: class TestEngineSessionProtocolContract:
"""Contract tests for the EngineSession protocol itself.""" """Contract tests for the EngineSession protocol itself."""
def test_engine_session_is_protocol(self) -> None: def test_engine_session_is_protocol(self) -> None:
assert hasattr(EngineSession, "__protocol_attrs__") assert hasattr(EngineSession, "__protocol_attrs__")
def test_fake_session_satisfies_protocol(self) -> None: def test_fake_session_satisfies_protocol(self) -> None:
session = FakeEngineSession() session = FakeEngineSession()
assert isinstance(session, EngineSession) assert isinstance(session, EngineSession)
class TestSessionSynthesizeContract: class TestSessionSynthesizeContract:
"""Contract tests for EngineSession.synthesize().""" """Contract tests for EngineSession.synthesize()."""
def test_synthesize_returns_synthesized_audio(self) -> None: def test_synthesize_returns_synthesized_audio(self) -> None:
session = FakeEngineSession() session = FakeEngineSession()
request = SynthesisRequest( request = SynthesisRequest(
text="Hello", text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"), voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(), parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
) )
result = session.synthesize(request) result = session.synthesize(request)
assert isinstance(result, SynthesizedAudio) assert isinstance(result, SynthesizedAudio)
def test_synthesize_returns_valid_audio_data(self) -> None: def test_synthesize_returns_valid_audio_data(self) -> None:
session = FakeEngineSession() session = FakeEngineSession()
request = SynthesisRequest( request = SynthesisRequest(
text="Hello", text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"), voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(), parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
) )
result = session.synthesize(request) result = session.synthesize(request)
assert isinstance(result.data, bytes) assert isinstance(result.data, bytes)
assert len(result.data) > 0 assert len(result.data) > 0
assert isinstance(result.format, AudioFormat) assert isinstance(result.format, AudioFormat)
assert isinstance(result.duration, Duration) assert isinstance(result.duration, Duration)
class TestSessionDisposeContract: class TestSessionDisposeContract:
"""Contract tests for EngineSession.dispose().""" """Contract tests for EngineSession.dispose()."""
def test_dispose_is_idempotent(self) -> None: def test_dispose_is_idempotent(self) -> None:
"""Architecture spec: dispose() is idempotent.""" """Architecture spec: dispose() is idempotent."""
session = FakeEngineSession() session = FakeEngineSession()
session.dispose() session.dispose()
session.dispose() # Should not raise session.dispose() # Should not raise
def test_dispose_never_raises(self) -> None: def test_dispose_never_raises(self) -> None:
"""Architecture spec: dispose() never raises exceptions.""" """Architecture spec: dispose() never raises exceptions."""
session = FakeEngineSession() session = FakeEngineSession()
session.dispose() # Should not raise session.dispose() # Should not raise
class TestSessionAfterDisposeContract: class TestSessionAfterDisposeContract:
"""Contract tests for behavior after dispose().""" """Contract tests for behavior after dispose()."""
def test_synthesize_after_dispose_raises(self) -> None: def test_synthesize_after_dispose_raises(self) -> None:
"""Architecture spec: After dispose(), all methods except dispose() raise EngineError.""" """Architecture spec: After dispose(), all methods except dispose() raise EngineError."""
session = FakeEngineSession() session = FakeEngineSession()
session.dispose() session.dispose()
request = SynthesisRequest( request = SynthesisRequest(
text="Hello", text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"), voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(), parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
) )
with pytest.raises(EngineError): with pytest.raises(EngineError):
session.synthesize(request) session.synthesize(request)
class TestSessionLifecycleContract: class TestSessionLifecycleContract:
"""Contract tests for EngineSession lifecycle.""" """Contract tests for EngineSession lifecycle."""
def test_full_lifecycle(self) -> None: def test_full_lifecycle(self) -> None:
"""Test complete session lifecycle: create -> synthesize -> dispose.""" """Test complete session lifecycle: create -> synthesize -> dispose."""
session = FakeEngineSession() session = FakeEngineSession()
request = SynthesisRequest( request = SynthesisRequest(
text="Hello", text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"), voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(), parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
) )
# Synthesize # Synthesize
result = session.synthesize(request) result = session.synthesize(request)
assert isinstance(result, SynthesizedAudio) assert isinstance(result, SynthesizedAudio)
# Dispose # Dispose
session.dispose() session.dispose()
def test_multiple_synthesize_before_dispose(self) -> None: def test_multiple_synthesize_before_dispose(self) -> None:
"""Architecture spec: Session remains usable after synthesize() failure.""" """Architecture spec: Session remains usable after synthesize() failure."""
session = FakeEngineSession() session = FakeEngineSession()
request = SynthesisRequest( request = SynthesisRequest(
text="Hello", text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"), voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(), parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
) )
# Multiple synthesize calls # Multiple synthesize calls
result1 = session.synthesize(request) result1 = session.synthesize(request)
result2 = session.synthesize(request) result2 = session.synthesize(request)
assert isinstance(result1, SynthesizedAudio) assert isinstance(result1, SynthesizedAudio)
assert isinstance(result2, SynthesizedAudio) assert isinstance(result2, SynthesizedAudio)
# Dispose # Dispose
session.dispose() session.dispose()
+244 -244
View File
@@ -1,244 +1,244 @@
"""Contract tests for core domain value objects. """Contract tests for core domain value objects.
These tests verify that value objects satisfy the architectural requirements: These tests verify that value objects satisfy the architectural requirements:
- Frozen (immutable) dataclasses - Frozen (immutable) dataclasses
- Correct field definitions - Correct field definitions
- Proper equality behavior - Proper equality behavior
""" """
import pytest import pytest
from abogen.tts_plugin.types import ( from abogen.tts_plugin.types import (
AudioFormat, AudioFormat,
Duration, Duration,
EngineConfig, EngineConfig,
ParameterValues, ParameterValues,
SynthesisRequest, SynthesisRequest,
SynthesizedAudio, SynthesizedAudio,
VoiceSelection, VoiceSelection,
) )
class TestAudioFormatContract: class TestAudioFormatContract:
"""Contract tests for AudioFormat value object.""" """Contract tests for AudioFormat value object."""
def test_is_frozen_dataclass(self) -> None: def test_is_frozen_dataclass(self) -> None:
assert hasattr(AudioFormat, "__dataclass_params__") assert hasattr(AudioFormat, "__dataclass_params__")
assert AudioFormat.__dataclass_params__.frozen is True assert AudioFormat.__dataclass_params__.frozen is True
def test_required_fields(self) -> None: def test_required_fields(self) -> None:
af = AudioFormat(mime="audio/wav", extension="wav") af = AudioFormat(mime="audio/wav", extension="wav")
assert af.mime == "audio/wav" assert af.mime == "audio/wav"
assert af.extension == "wav" assert af.extension == "wav"
def test_immutability(self) -> None: def test_immutability(self) -> None:
af = AudioFormat(mime="audio/wav", extension="wav") af = AudioFormat(mime="audio/wav", extension="wav")
with pytest.raises(AttributeError): with pytest.raises(AttributeError):
af.mime = "audio/mpeg" # type: ignore[misc] af.mime = "audio/mpeg" # type: ignore[misc]
def test_equality(self) -> None: def test_equality(self) -> None:
af1 = AudioFormat(mime="audio/wav", extension="wav") af1 = AudioFormat(mime="audio/wav", extension="wav")
af2 = AudioFormat(mime="audio/wav", extension="wav") af2 = AudioFormat(mime="audio/wav", extension="wav")
assert af1 == af2 assert af1 == af2
def test_inequality(self) -> None: def test_inequality(self) -> None:
af1 = AudioFormat(mime="audio/wav", extension="wav") af1 = AudioFormat(mime="audio/wav", extension="wav")
af2 = AudioFormat(mime="audio/mpeg", extension="mp3") af2 = AudioFormat(mime="audio/mpeg", extension="mp3")
assert af1 != af2 assert af1 != af2
def test_hashable(self) -> None: def test_hashable(self) -> None:
af = AudioFormat(mime="audio/wav", extension="wav") af = AudioFormat(mime="audio/wav", extension="wav")
assert hash(af) == hash(AudioFormat(mime="audio/wav", extension="wav")) assert hash(af) == hash(AudioFormat(mime="audio/wav", extension="wav"))
class TestDurationContract: class TestDurationContract:
"""Contract tests for Duration value object.""" """Contract tests for Duration value object."""
def test_is_frozen_dataclass(self) -> None: def test_is_frozen_dataclass(self) -> None:
assert hasattr(Duration, "__dataclass_params__") assert hasattr(Duration, "__dataclass_params__")
assert Duration.__dataclass_params__.frozen is True assert Duration.__dataclass_params__.frozen is True
def test_required_fields(self) -> None: def test_required_fields(self) -> None:
d = Duration(seconds=1.5) d = Duration(seconds=1.5)
assert d.seconds == 1.5 assert d.seconds == 1.5
def test_immutability(self) -> None: def test_immutability(self) -> None:
d = Duration(seconds=1.0) d = Duration(seconds=1.0)
with pytest.raises(AttributeError): with pytest.raises(AttributeError):
d.seconds = 2.0 # type: ignore[misc] d.seconds = 2.0 # type: ignore[misc]
def test_equality(self) -> None: def test_equality(self) -> None:
d1 = Duration(seconds=1.0) d1 = Duration(seconds=1.0)
d2 = Duration(seconds=1.0) d2 = Duration(seconds=1.0)
assert d1 == d2 assert d1 == d2
class TestVoiceSelectionContract: class TestVoiceSelectionContract:
"""Contract tests for VoiceSelection value object.""" """Contract tests for VoiceSelection value object."""
def test_is_frozen_dataclass(self) -> None: def test_is_frozen_dataclass(self) -> None:
assert hasattr(VoiceSelection, "__dataclass_params__") assert hasattr(VoiceSelection, "__dataclass_params__")
assert VoiceSelection.__dataclass_params__.frozen is True assert VoiceSelection.__dataclass_params__.frozen is True
def test_required_fields(self) -> None: def test_required_fields(self) -> None:
vs = VoiceSelection(source="builtin", key="af_nova") vs = VoiceSelection(source="builtin", key="af_nova")
assert vs.source == "builtin" assert vs.source == "builtin"
assert vs.key == "af_nova" assert vs.key == "af_nova"
def test_payload_default_none(self) -> None: def test_payload_default_none(self) -> None:
vs = VoiceSelection(source="builtin", key="af_nova") vs = VoiceSelection(source="builtin", key="af_nova")
assert vs.payload is None assert vs.payload is None
def test_payload_optional(self) -> None: def test_payload_optional(self) -> None:
vs = VoiceSelection(source="clone", key="my_voice", payload=b"audio_data") vs = VoiceSelection(source="clone", key="my_voice", payload=b"audio_data")
assert vs.payload == b"audio_data" assert vs.payload == b"audio_data"
def test_immutability(self) -> None: def test_immutability(self) -> None:
vs = VoiceSelection(source="builtin", key="af_nova") vs = VoiceSelection(source="builtin", key="af_nova")
with pytest.raises(AttributeError): with pytest.raises(AttributeError):
vs.source = "other" # type: ignore[misc] vs.source = "other" # type: ignore[misc]
class TestParameterValuesContract: class TestParameterValuesContract:
"""Contract tests for ParameterValues value object.""" """Contract tests for ParameterValues value object."""
def test_is_frozen_dataclass(self) -> None: def test_is_frozen_dataclass(self) -> None:
assert hasattr(ParameterValues, "__dataclass_params__") assert hasattr(ParameterValues, "__dataclass_params__")
assert ParameterValues.__dataclass_params__.frozen is True assert ParameterValues.__dataclass_params__.frozen is True
def test_default_empty(self) -> None: def test_default_empty(self) -> None:
pv = ParameterValues() pv = ParameterValues()
assert pv.values == {} assert pv.values == {}
def test_with_values(self) -> None: def test_with_values(self) -> None:
pv = ParameterValues(values={"speed": 1.0, "pitch": 0.5}) pv = ParameterValues(values={"speed": 1.0, "pitch": 0.5})
assert pv.values["speed"] == 1.0 assert pv.values["speed"] == 1.0
assert pv.values["pitch"] == 0.5 assert pv.values["pitch"] == 0.5
def test_immutability(self) -> None: def test_immutability(self) -> None:
pv = ParameterValues(values={"speed": 1.0}) pv = ParameterValues(values={"speed": 1.0})
with pytest.raises(AttributeError): with pytest.raises(AttributeError):
pv.values = {} # type: ignore[misc] pv.values = {} # type: ignore[misc]
class TestSynthesisRequestContract: class TestSynthesisRequestContract:
"""Contract tests for SynthesisRequest value object.""" """Contract tests for SynthesisRequest value object."""
def test_is_frozen_dataclass(self) -> None: def test_is_frozen_dataclass(self) -> None:
assert hasattr(SynthesisRequest, "__dataclass_params__") assert hasattr(SynthesisRequest, "__dataclass_params__")
assert SynthesisRequest.__dataclass_params__.frozen is True assert SynthesisRequest.__dataclass_params__.frozen is True
def test_required_fields(self) -> None: def test_required_fields(self) -> None:
req = SynthesisRequest( req = SynthesisRequest(
text="Hello", text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"), voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(), parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
) )
assert req.text == "Hello" assert req.text == "Hello"
assert req.voice.source == "builtin" assert req.voice.source == "builtin"
assert req.format.mime == "audio/wav" assert req.format.mime == "audio/wav"
def test_immutability(self) -> None: def test_immutability(self) -> None:
req = SynthesisRequest( req = SynthesisRequest(
text="Hello", text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"), voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(), parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
) )
with pytest.raises(AttributeError): with pytest.raises(AttributeError):
req.text = "World" # type: ignore[misc] req.text = "World" # type: ignore[misc]
class TestSynthesizedAudioContract: class TestSynthesizedAudioContract:
"""Contract tests for SynthesizedAudio value object.""" """Contract tests for SynthesizedAudio value object."""
def test_is_frozen_dataclass(self) -> None: def test_is_frozen_dataclass(self) -> None:
assert hasattr(SynthesizedAudio, "__dataclass_params__") assert hasattr(SynthesizedAudio, "__dataclass_params__")
assert SynthesizedAudio.__dataclass_params__.frozen is True assert SynthesizedAudio.__dataclass_params__.frozen is True
def test_required_fields(self) -> None: def test_required_fields(self) -> None:
audio = SynthesizedAudio( audio = SynthesizedAudio(
data=b"\x00" * 100, data=b"\x00" * 100,
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=1.0), duration=Duration(seconds=1.0),
) )
assert audio.data == b"\x00" * 100 assert audio.data == b"\x00" * 100
assert audio.format.mime == "audio/wav" assert audio.format.mime == "audio/wav"
assert audio.duration.seconds == 1.0 assert audio.duration.seconds == 1.0
def test_immutability(self) -> None: def test_immutability(self) -> None:
audio = SynthesizedAudio( audio = SynthesizedAudio(
data=b"\x00" * 100, data=b"\x00" * 100,
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=1.0), duration=Duration(seconds=1.0),
) )
with pytest.raises(AttributeError): with pytest.raises(AttributeError):
audio.data = b"\x00" # type: ignore[misc] audio.data = b"\x00" # type: ignore[misc]
class TestEngineConfigContract: class TestEngineConfigContract:
"""Contract tests for EngineConfig value object.""" """Contract tests for EngineConfig value object."""
def test_is_frozen_dataclass(self) -> None: def test_is_frozen_dataclass(self) -> None:
assert hasattr(EngineConfig, "__dataclass_params__") assert hasattr(EngineConfig, "__dataclass_params__")
assert EngineConfig.__dataclass_params__.frozen is True assert EngineConfig.__dataclass_params__.frozen is True
def test_default_device(self) -> None: def test_default_device(self) -> None:
config = EngineConfig() config = EngineConfig()
assert config.device == "cpu" assert config.device == "cpu"
def test_custom_device(self) -> None: def test_custom_device(self) -> None:
config = EngineConfig(device="cuda:0") config = EngineConfig(device="cuda:0")
assert config.device == "cuda:0" assert config.device == "cuda:0"
def test_default_lang_code(self) -> None: def test_default_lang_code(self) -> None:
config = EngineConfig() config = EngineConfig()
assert config.lang_code == "a" assert config.lang_code == "a"
def test_custom_lang_code(self) -> None: def test_custom_lang_code(self) -> None:
config = EngineConfig(lang_code="j") config = EngineConfig(lang_code="j")
assert config.lang_code == "j" assert config.lang_code == "j"
def test_immutability(self) -> None: def test_immutability(self) -> None:
config = EngineConfig() config = EngineConfig()
with pytest.raises(AttributeError): with pytest.raises(AttributeError):
config.device = "cuda:0" # type: ignore[misc] config.device = "cuda:0" # type: ignore[misc]
def test_immutability_lang_code(self) -> None: def test_immutability_lang_code(self) -> None:
config = EngineConfig() config = EngineConfig()
with pytest.raises(AttributeError): with pytest.raises(AttributeError):
config.lang_code = "j" # type: ignore[misc] config.lang_code = "j" # type: ignore[misc]
def test_unknown_keys_ignored_per_spec(self) -> None: def test_unknown_keys_ignored_per_spec(self) -> None:
"""Architecture spec: Unknown keys are ignored (no error). """Architecture spec: Unknown keys are ignored (no error).
EngineConfig is frozen, so unknown keys cannot be set after creation. EngineConfig is frozen, so unknown keys cannot be set after creation.
This test verifies the default behavior matches the spec. This test verifies the default behavior matches the spec.
""" """
config = EngineConfig() config = EngineConfig()
assert config.device == "cpu" assert config.device == "cpu"
def test_plugins_may_ignore_irrelevant_fields(self) -> None: def test_plugins_may_ignore_irrelevant_fields(self) -> None:
"""Architecture Amendment #1: Plugins ignore unsupported fields. """Architecture Amendment #1: Plugins ignore unsupported fields.
EngineConfig may contain fields that are not relevant to every plugin. EngineConfig may contain fields that are not relevant to every plugin.
Plugins MUST ignore fields they do not need, not raise on them. Plugins MUST ignore fields they do not need, not raise on them.
""" """
config = EngineConfig(device="cuda:0", lang_code="j") config = EngineConfig(device="cuda:0", lang_code="j")
assert config.device == "cuda:0" assert config.device == "cuda:0"
assert config.lang_code == "j" assert config.lang_code == "j"
# A plugin that only needs device simply reads config.device # A plugin that only needs device simply reads config.device
# and ignores config.lang_code — this must not raise. # and ignores config.lang_code — this must not raise.
def test_engine_config_contains_engine_instance_configuration(self) -> None: def test_engine_config_contains_engine_instance_configuration(self) -> None:
"""Architecture Amendment #1: EngineConfig definition. """Architecture Amendment #1: EngineConfig definition.
EngineConfig contains parameters that define how a particular EngineConfig contains parameters that define how a particular
Engine instance is created and that remain constant throughout Engine instance is created and that remain constant throughout
the lifetime of that Engine. the lifetime of that Engine.
""" """
config = EngineConfig(device="cpu", lang_code="a") config = EngineConfig(device="cpu", lang_code="a")
# Both fields are init-time, immutable, engine-scoped. # Both fields are init-time, immutable, engine-scoped.
assert config.device == "cpu" assert config.device == "cpu"
assert config.lang_code == "a" assert config.lang_code == "a"
+92 -92
View File
@@ -1,92 +1,92 @@
"""Fake plugin for testing the plugin loader. """Fake plugin for testing the plugin loader.
This is a minimal valid plugin that satisfies the Plugin API contract. This is a minimal valid plugin that satisfies the Plugin API contract.
It does NOT perform any real TTS synthesis. It does NOT perform any real TTS synthesis.
""" """
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from abogen.tts_plugin.engine import Engine, EngineSession from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.errors import EngineError from abogen.tts_plugin.errors import EngineError
from abogen.tts_plugin.host_context import HostContext from abogen.tts_plugin.host_context import HostContext
from abogen.tts_plugin.manifest import ( from abogen.tts_plugin.manifest import (
AudioFormatManifest, AudioFormatManifest,
EngineManifest, EngineManifest,
PluginManifest, PluginManifest,
VoiceSourceManifest, VoiceSourceManifest,
) )
from abogen.tts_plugin.types import ( from abogen.tts_plugin.types import (
AudioFormat, AudioFormat,
Duration, Duration,
EngineConfig, EngineConfig,
ParameterValues, ParameterValues,
SynthesisRequest, SynthesisRequest,
SynthesizedAudio, SynthesizedAudio,
) )
class FakeSession: class FakeSession:
"""Minimal EngineSession implementation for testing.""" """Minimal EngineSession implementation for testing."""
def __init__(self) -> None: def __init__(self) -> None:
self._disposed = False self._disposed = False
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio: def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
if self._disposed: if self._disposed:
raise EngineError("Session disposed") raise EngineError("Session disposed")
return SynthesizedAudio( return SynthesizedAudio(
data=b"\x00" * 100, data=b"\x00" * 100,
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=1.0), duration=Duration(seconds=1.0),
) )
def dispose(self) -> None: def dispose(self) -> None:
self._disposed = True self._disposed = True
class FakeEngine: class FakeEngine:
"""Minimal Engine implementation for testing.""" """Minimal Engine implementation for testing."""
def __init__(self) -> None: def __init__(self) -> None:
self._disposed = False self._disposed = False
def createSession(self) -> EngineSession: def createSession(self) -> EngineSession:
if self._disposed: if self._disposed:
raise EngineError("Engine disposed") raise EngineError("Engine disposed")
return FakeSession() return FakeSession()
def dispose(self) -> None: def dispose(self) -> None:
self._disposed = True self._disposed = True
PLUGIN_MANIFEST = PluginManifest( PLUGIN_MANIFEST = PluginManifest(
id="fake_plugin", id="fake_plugin",
name="Fake Plugin", name="Fake Plugin",
version="1.0.0", version="1.0.0",
api_version="1.0", api_version="1.0",
description="A fake plugin for testing", description="A fake plugin for testing",
author="Test Author", author="Test Author",
capabilities=(), capabilities=(),
engine=EngineManifest( engine=EngineManifest(
voiceSources=( voiceSources=(
VoiceSourceManifest(id="builtin", name="Builtin", type="list"), VoiceSourceManifest(id="builtin", name="Builtin", type="list"),
), ),
audioFormats=( audioFormats=(
AudioFormatManifest(mime="audio/wav", extension="wav"), AudioFormatManifest(mime="audio/wav", extension="wav"),
), ),
), ),
) )
MODEL_REQUIREMENTS: list[Any] = [] MODEL_REQUIREMENTS: list[Any] = []
def create_engine( def create_engine(
context: HostContext, context: HostContext,
model_path: Path | None, model_path: Path | None,
config: EngineConfig, config: EngineConfig,
) -> Engine: ) -> Engine:
"""Create a fake engine instance.""" """Create a fake engine instance."""
return FakeEngine() return FakeEngine()
+18 -18
View File
@@ -1,18 +1,18 @@
"""Invalid plugin: raises ImportError during import.""" """Invalid plugin: raises ImportError during import."""
from __future__ import annotations from __future__ import annotations
# This plugin intentionally raises an ImportError # This plugin intentionally raises an ImportError
raise ImportError("Simulated import error for testing") raise ImportError("Simulated import error for testing")
# The following code will never be reached, but is here for documentation # The following code will never be reached, but is here for documentation
from abogen.tts_plugin.manifest import PluginManifest from abogen.tts_plugin.manifest import PluginManifest
PLUGIN_MANIFEST = PluginManifest( PLUGIN_MANIFEST = PluginManifest(
id="import_error", id="import_error",
name="Import Error Plugin", name="Import Error Plugin",
version="1.0.0", version="1.0.0",
api_version="1.0", api_version="1.0",
description="Plugin that fails to import", description="Plugin that fails to import",
author="Test Author", author="Test Author",
) )
+29 -29
View File
@@ -1,29 +1,29 @@
"""Invalid plugin: incompatible api_version (major version mismatch).""" """Invalid plugin: incompatible api_version (major version mismatch)."""
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from abogen.tts_plugin.manifest import PluginManifest from abogen.tts_plugin.manifest import PluginManifest
from abogen.tts_plugin.types import EngineConfig from abogen.tts_plugin.types import EngineConfig
# api_version "2.0" has major version 2, but host expects major version 1 # api_version "2.0" has major version 2, but host expects major version 1
PLUGIN_MANIFEST = PluginManifest( PLUGIN_MANIFEST = PluginManifest(
id="invalid_api_version", id="invalid_api_version",
name="Invalid API Version Plugin", name="Invalid API Version Plugin",
version="1.0.0", version="1.0.0",
api_version="2.0", # Major version mismatch! api_version="2.0", # Major version mismatch!
description="Plugin with incompatible api_version", description="Plugin with incompatible api_version",
author="Test Author", author="Test Author",
) )
MODEL_REQUIREMENTS: list[Any] = [] MODEL_REQUIREMENTS: list[Any] = []
def create_engine( def create_engine(
context: Any, context: Any,
model_path: Path | None, model_path: Path | None,
config: EngineConfig, config: EngineConfig,
) -> Any: ) -> Any:
raise NotImplementedError("This plugin is invalid") raise NotImplementedError("This plugin is invalid")
+29 -29
View File
@@ -1,29 +1,29 @@
"""Invalid plugin: unknown capabilities.""" """Invalid plugin: unknown capabilities."""
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from abogen.tts_plugin.manifest import PluginManifest from abogen.tts_plugin.manifest import PluginManifest
from abogen.tts_plugin.types import EngineConfig from abogen.tts_plugin.types import EngineConfig
PLUGIN_MANIFEST = PluginManifest( PLUGIN_MANIFEST = PluginManifest(
id="invalid_capabilities", id="invalid_capabilities",
name="Invalid Capabilities Plugin", name="Invalid Capabilities Plugin",
version="1.0.0", version="1.0.0",
api_version="1.0", api_version="1.0",
description="Plugin with unknown capabilities", description="Plugin with unknown capabilities",
author="Test Author", author="Test Author",
capabilities=("voice_list", "unknown_capability", "another_unknown"), capabilities=("voice_list", "unknown_capability", "another_unknown"),
) )
MODEL_REQUIREMENTS: list[Any] = [] MODEL_REQUIREMENTS: list[Any] = []
def create_engine( def create_engine(
context: Any, context: Any,
model_path: Path | None, model_path: Path | None,
config: EngineConfig, config: EngineConfig,
) -> Any: ) -> Any:
raise NotImplementedError("This plugin is invalid") raise NotImplementedError("This plugin is invalid")
+18 -18
View File
@@ -1,18 +1,18 @@
"""Invalid plugin: missing create_engine function.""" """Invalid plugin: missing create_engine function."""
from __future__ import annotations from __future__ import annotations
from abogen.tts_plugin.manifest import PluginManifest from abogen.tts_plugin.manifest import PluginManifest
PLUGIN_MANIFEST = PluginManifest( PLUGIN_MANIFEST = PluginManifest(
id="missing_create_engine", id="missing_create_engine",
name="Missing Create Engine Plugin", name="Missing Create Engine Plugin",
version="1.0.0", version="1.0.0",
api_version="1.0", api_version="1.0",
description="Plugin missing create_engine", description="Plugin missing create_engine",
author="Test Author", author="Test Author",
) )
MODEL_REQUIREMENTS: list = [] MODEL_REQUIREMENTS: list = []
# This plugin intentionally does NOT export create_engine # This plugin intentionally does NOT export create_engine
+10 -10
View File
@@ -1,10 +1,10 @@
"""Invalid plugin: missing PLUGIN_MANIFEST.""" """Invalid plugin: missing PLUGIN_MANIFEST."""
from __future__ import annotations from __future__ import annotations
# This plugin intentionally does NOT export PLUGIN_MANIFEST # This plugin intentionally does NOT export PLUGIN_MANIFEST
MODEL_REQUIREMENTS: list = [] MODEL_REQUIREMENTS: list = []
def create_engine(context, model_path, config): def create_engine(context, model_path, config):
raise NotImplementedError("This plugin is invalid") raise NotImplementedError("This plugin is invalid")
@@ -1,28 +1,28 @@
"""Invalid plugin: missing MODEL_REQUIREMENTS.""" """Invalid plugin: missing MODEL_REQUIREMENTS."""
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from abogen.tts_plugin.manifest import PluginManifest from abogen.tts_plugin.manifest import PluginManifest
from abogen.tts_plugin.types import EngineConfig from abogen.tts_plugin.types import EngineConfig
PLUGIN_MANIFEST = PluginManifest( PLUGIN_MANIFEST = PluginManifest(
id="missing_model_requirements", id="missing_model_requirements",
name="Missing Model Requirements Plugin", name="Missing Model Requirements Plugin",
version="1.0.0", version="1.0.0",
api_version="1.0", api_version="1.0",
description="Plugin missing MODEL_REQUIREMENTS", description="Plugin missing MODEL_REQUIREMENTS",
author="Test Author", author="Test Author",
) )
# This plugin intentionally does NOT export MODEL_REQUIREMENTS # This plugin intentionally does NOT export MODEL_REQUIREMENTS
def create_engine( def create_engine(
context: Any, context: Any,
model_path: Path | None, model_path: Path | None,
config: EngineConfig, config: EngineConfig,
) -> Any: ) -> Any:
raise NotImplementedError("This plugin is invalid") raise NotImplementedError("This plugin is invalid")
File diff suppressed because it is too large Load Diff
+52 -52
View File
@@ -1,52 +1,52 @@
from types import SimpleNamespace from types import SimpleNamespace
from typing import cast from typing import cast
from abogen.tts_plugin.utils import get_voices from abogen.tts_plugin.utils import get_voices
from abogen.webui.conversion_runner import ( from abogen.webui.conversion_runner import (
_chapter_voice_spec, _chapter_voice_spec,
_chunk_voice_spec, _chunk_voice_spec,
_collect_required_voice_ids, _collect_required_voice_ids,
) )
from abogen.webui.service import Job from abogen.webui.service import Job
def _sample_job(formula: str) -> Job: def _sample_job(formula: str) -> Job:
return cast( return cast(
Job, Job,
SimpleNamespace( SimpleNamespace(
voice="__custom_mix", voice="__custom_mix",
speakers={ speakers={
"narrator": { "narrator": {
"resolved_voice": formula, "resolved_voice": formula,
} }
}, },
chapters=[], chapters=[],
chunks=[{}], chunks=[{}],
), ),
) )
def test_chapter_voice_spec_uses_resolved_formula(): def test_chapter_voice_spec_uses_resolved_formula():
formula = "af_nova*0.7+am_liam*0.3" formula = "af_nova*0.7+am_liam*0.3"
job = _sample_job(formula) job = _sample_job(formula)
assert _chapter_voice_spec(job, None) == formula assert _chapter_voice_spec(job, None) == formula
def test_chunk_voice_fallback_uses_resolved_formula(): def test_chunk_voice_fallback_uses_resolved_formula():
formula = "af_nova*0.7+am_liam*0.3" formula = "af_nova*0.7+am_liam*0.3"
job = _sample_job(formula) job = _sample_job(formula)
result = _chunk_voice_spec(job, {}, "") result = _chunk_voice_spec(job, {}, "")
assert result == formula assert result == formula
def test_voice_collection_includes_formula_components(): def test_voice_collection_includes_formula_components():
formula = "af_nova*0.7+am_liam*0.3" formula = "af_nova*0.7+am_liam*0.3"
job = _sample_job(formula) job = _sample_job(formula)
voices = _collect_required_voice_ids(job) voices = _collect_required_voice_ids(job)
assert {"af_nova", "am_liam"}.issubset(voices) assert {"af_nova", "am_liam"}.issubset(voices)
assert voices.issuperset(get_voices("kokoro")) assert voices.issuperset(get_voices("kokoro"))
+196 -196
View File
@@ -1,196 +1,196 @@
"""Tests for the Kokoro TTS Plugin. """Tests for the Kokoro TTS Plugin.
These tests verify that the Kokoro plugin: These tests verify that the Kokoro plugin:
- Loads correctly through the Plugin Loader - Loads correctly through the Plugin Loader
- Has a valid manifest - Has a valid manifest
- Creates a valid Engine - Creates a valid Engine
- Satisfies the Engine/EngineSession contract (via EngineContractMixin) - Satisfies the Engine/EngineSession contract (via EngineContractMixin)
- Implements VoiceLister capability - Implements VoiceLister capability
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import pytest import pytest
from abogen.tts_plugin.engine import Engine, EngineSession from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.host_context import HostContext from abogen.tts_plugin.host_context import HostContext
from abogen.tts_plugin.loader import load_plugin_from_dir from abogen.tts_plugin.loader import load_plugin_from_dir
from abogen.tts_plugin.manifest import PluginManifest from abogen.tts_plugin.manifest import PluginManifest
from abogen.tts_plugin.types import ( from abogen.tts_plugin.types import (
AudioFormat, AudioFormat,
EngineConfig, EngineConfig,
ParameterValues, ParameterValues,
SynthesisRequest, SynthesisRequest,
VoiceSelection, VoiceSelection,
) )
from tests.contracts.engine_contract import EngineContractMixin from tests.contracts.engine_contract import EngineContractMixin
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# Helpers # Helpers
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
def _kokoro_available() -> bool: def _kokoro_available() -> bool:
try: try:
from kokoro import KPipeline # type: ignore[import-not-found] from kokoro import KPipeline # type: ignore[import-not-found]
return True return True
except ImportError: except ImportError:
return False return False
def _make_mock_engine() -> Any: def _make_mock_engine() -> Any:
from plugins.kokoro.engine import KokoroEngine from plugins.kokoro.engine import KokoroEngine
class MockPipeline: class MockPipeline:
def __call__(self, text, voice, speed, split_pattern=None): def __call__(self, text, voice, speed, split_pattern=None):
class MockSegment: class MockSegment:
def __init__(self): def __init__(self):
self.audio = MockAudio() self.audio = MockAudio()
class MockAudio: class MockAudio:
def numpy(self): def numpy(self):
import numpy as np import numpy as np
return np.zeros(24000, dtype="float32") return np.zeros(24000, dtype="float32")
return [MockSegment()] return [MockSegment()]
engine = KokoroEngine(MockPipeline()) engine = KokoroEngine(MockPipeline())
# Override listVoices for testing (real engine reads from manifest) # Override listVoices for testing (real engine reads from manifest)
from abogen.tts_plugin.manifest import VoiceManifest from abogen.tts_plugin.manifest import VoiceManifest
engine.listVoices = lambda source_id: [ engine.listVoices = lambda source_id: [
VoiceManifest(id="test_voice_1", name="Test Voice 1", tags=("en",)), VoiceManifest(id="test_voice_1", name="Test Voice 1", tags=("en",)),
VoiceManifest(id="test_voice_2", name="Test Voice 2", tags=("es",)), VoiceManifest(id="test_voice_2", name="Test Voice 2", tags=("es",)),
] ]
return engine return engine
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# Fixtures # Fixtures
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
@pytest.fixture @pytest.fixture
def kokoro_plugin_dir() -> Path: def kokoro_plugin_dir() -> Path:
return Path(__file__).parent.parent / "plugins" / "kokoro" return Path(__file__).parent.parent / "plugins" / "kokoro"
@pytest.fixture @pytest.fixture
def host_context(tmp_path: Path) -> HostContext: def host_context(tmp_path: Path) -> HostContext:
class FakeHttpClient: class FakeHttpClient:
def get(self, url: str, **kwargs: object) -> object: def get(self, url: str, **kwargs: object) -> object:
return None return None
def post(self, url: str, **kwargs: object) -> object: def post(self, url: str, **kwargs: object) -> object:
return None return None
return HostContext( return HostContext(
config_dir=tmp_path, config_dir=tmp_path,
logger=logging.getLogger("test"), logger=logging.getLogger("test"),
http_client=FakeHttpClient(), http_client=FakeHttpClient(),
) )
@pytest.fixture @pytest.fixture
def engine() -> Engine: def engine() -> Engine:
return _make_mock_engine() return _make_mock_engine()
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# Plugin Loading Tests # Plugin Loading Tests
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
class TestKokoroPluginLoading: class TestKokoroPluginLoading:
def test_plugin_loads_successfully(self, kokoro_plugin_dir: Path) -> None: def test_plugin_loads_successfully(self, kokoro_plugin_dir: Path) -> None:
result = load_plugin_from_dir(kokoro_plugin_dir) result = load_plugin_from_dir(kokoro_plugin_dir)
assert result.success is True assert result.success is True
assert result.manifest is not None assert result.manifest is not None
assert result.create_engine is not None assert result.create_engine is not None
def test_plugin_has_valid_manifest(self, kokoro_plugin_dir: Path) -> None: def test_plugin_has_valid_manifest(self, kokoro_plugin_dir: Path) -> None:
result = load_plugin_from_dir(kokoro_plugin_dir) result = load_plugin_from_dir(kokoro_plugin_dir)
assert result.success is True assert result.success is True
manifest = result.manifest manifest = result.manifest
assert isinstance(manifest, PluginManifest) assert isinstance(manifest, PluginManifest)
assert manifest.id == "kokoro" assert manifest.id == "kokoro"
assert manifest.name == "Kokoro" assert manifest.name == "Kokoro"
assert manifest.api_version == "1.0" assert manifest.api_version == "1.0"
def test_plugin_has_model_requirements(self, kokoro_plugin_dir: Path) -> None: def test_plugin_has_model_requirements(self, kokoro_plugin_dir: Path) -> None:
result = load_plugin_from_dir(kokoro_plugin_dir) result = load_plugin_from_dir(kokoro_plugin_dir)
assert result.success is True assert result.success is True
assert result.model_requirements is not None assert result.model_requirements is not None
assert isinstance(result.model_requirements, tuple) assert isinstance(result.model_requirements, tuple)
def test_plugin_manifest_capabilities(self, kokoro_plugin_dir: Path) -> None: def test_plugin_manifest_capabilities(self, kokoro_plugin_dir: Path) -> None:
result = load_plugin_from_dir(kokoro_plugin_dir) result = load_plugin_from_dir(kokoro_plugin_dir)
assert result.success is True assert result.success is True
assert "voice_list" in result.manifest.capabilities assert "voice_list" in result.manifest.capabilities
def test_plugin_manifest_engine(self, kokoro_plugin_dir: Path) -> None: def test_plugin_manifest_engine(self, kokoro_plugin_dir: Path) -> None:
result = load_plugin_from_dir(kokoro_plugin_dir) result = load_plugin_from_dir(kokoro_plugin_dir)
assert result.success is True assert result.success is True
engine_manifest = result.manifest.engine engine_manifest = result.manifest.engine
assert len(engine_manifest.voiceSources) > 0 assert len(engine_manifest.voiceSources) > 0
assert len(engine_manifest.audioFormats) > 0 assert len(engine_manifest.audioFormats) > 0
assert len(engine_manifest.parameters) > 0 assert len(engine_manifest.parameters) > 0
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# Engine Creation (real backend, skipped if not installed) # Engine Creation (real backend, skipped if not installed)
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
class TestKokoroEngineCreation: class TestKokoroEngineCreation:
@pytest.mark.skipif(not _kokoro_available(), reason="Kokoro not installed") @pytest.mark.skipif(not _kokoro_available(), reason="Kokoro not installed")
def test_create_engine(self, kokoro_plugin_dir: Path, host_context: HostContext) -> None: def test_create_engine(self, kokoro_plugin_dir: Path, host_context: HostContext) -> None:
result = load_plugin_from_dir(kokoro_plugin_dir) result = load_plugin_from_dir(kokoro_plugin_dir)
assert result.success is True assert result.success is True
engine = result.create_engine(host_context, None, EngineConfig()) engine = result.create_engine(host_context, None, EngineConfig())
assert isinstance(engine, Engine) assert isinstance(engine, Engine)
engine.dispose() engine.dispose()
@pytest.mark.skipif(not _kokoro_available(), reason="Kokoro not installed") @pytest.mark.skipif(not _kokoro_available(), reason="Kokoro not installed")
def test_engine_satisfies_protocol(self, kokoro_plugin_dir: Path, host_context: HostContext) -> None: def test_engine_satisfies_protocol(self, kokoro_plugin_dir: Path, host_context: HostContext) -> None:
result = load_plugin_from_dir(kokoro_plugin_dir) result = load_plugin_from_dir(kokoro_plugin_dir)
assert result.success is True assert result.success is True
engine = result.create_engine(host_context, None, EngineConfig()) engine = result.create_engine(host_context, None, EngineConfig())
assert isinstance(engine, Engine) assert isinstance(engine, Engine)
engine.dispose() engine.dispose()
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# Engine / Session Contract (inherited from base) # Engine / Session Contract (inherited from base)
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
class TestKokoroEngineContract(EngineContractMixin): class TestKokoroEngineContract(EngineContractMixin):
"""Every test from EngineContractMixin runs against KokoroEngine.""" """Every test from EngineContractMixin runs against KokoroEngine."""
@pytest.fixture @pytest.fixture
def default_voice(self) -> str: def default_voice(self) -> str:
return "af_nova" return "af_nova"
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# VoiceLister Tests # VoiceLister Tests
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
class TestKokoroVoiceLister: class TestKokoroVoiceLister:
def test_list_voices(self) -> None: def test_list_voices(self) -> None:
engine = _make_mock_engine() engine = _make_mock_engine()
voices = engine.listVoices("builtin") voices = engine.listVoices("builtin")
assert len(voices) > 0 assert len(voices) > 0
assert all(hasattr(v, "id") for v in voices) assert all(hasattr(v, "id") for v in voices)
assert all(hasattr(v, "name") for v in voices) assert all(hasattr(v, "name") for v in voices)
engine.dispose() engine.dispose()
def test_voices_have_tags(self) -> None: def test_voices_have_tags(self) -> None:
engine = _make_mock_engine() engine = _make_mock_engine()
voices = engine.listVoices("builtin") voices = engine.listVoices("builtin")
for voice in voices: for voice in voices:
assert isinstance(voice.tags, tuple) assert isinstance(voice.tags, tuple)
assert len(voice.tags) > 0 assert len(voice.tags) > 0
engine.dispose() engine.dispose()
+60 -60
View File
@@ -1,60 +1,60 @@
from abogen.webui.routes.utils import preview from abogen.webui.routes.utils import preview
def test_preview_applies_manual_override_before_normalization(monkeypatch): def test_preview_applies_manual_override_before_normalization(monkeypatch):
# Don't run real TTS/normalization; just exercise the override stage by # Don't run real TTS/normalization; just exercise the override stage by
# forcing provider=kokoro and then stubbing normalize_for_pipeline. # forcing provider=kokoro and then stubbing normalize_for_pipeline.
monkeypatch.setattr(preview, "get_preview_pipeline", lambda language, device: None) monkeypatch.setattr(preview, "get_preview_pipeline", lambda language, device: None)
# Stub normalize_for_pipeline to be identity; we only care that overrides run. # Stub normalize_for_pipeline to be identity; we only care that overrides run.
class _Norm: class _Norm:
@staticmethod @staticmethod
def normalize_for_pipeline(text): def normalize_for_pipeline(text):
return text return text
monkeypatch.setitem( monkeypatch.setitem(
__import__("sys").modules, "abogen.kokoro_text_normalization", _Norm __import__("sys").modules, "abogen.kokoro_text_normalization", _Norm
) )
# And stub the kokoro pipeline path so generate_preview_audio won't proceed. # And stub the kokoro pipeline path so generate_preview_audio won't proceed.
# We'll instead validate by calling the override logic through generate_preview_audio # We'll instead validate by calling the override logic through generate_preview_audio
# with provider=supertonic and stub create_backend to capture input. # with provider=supertonic and stub create_backend to capture input.
captured = {} captured = {}
class DummyPipeline: class DummyPipeline:
def __init__(self, **kwargs): def __init__(self, **kwargs):
pass pass
def __call__(self, text, **kwargs): def __call__(self, text, **kwargs):
captured["text"] = text captured["text"] = text
return iter(()) return iter(())
from abogen.tts_plugin import utils from abogen.tts_plugin import utils
original_create_pipeline = utils.create_pipeline original_create_pipeline = utils.create_pipeline
def _mock_create_pipeline(backend_id, **kwargs): def _mock_create_pipeline(backend_id, **kwargs):
if backend_id == "supertonic": if backend_id == "supertonic":
return DummyPipeline(**kwargs) return DummyPipeline(**kwargs)
return original_create_pipeline(backend_id, **kwargs) return original_create_pipeline(backend_id, **kwargs)
monkeypatch.setattr(utils, "create_pipeline", _mock_create_pipeline) monkeypatch.setattr(utils, "create_pipeline", _mock_create_pipeline)
try: try:
preview.generate_preview_audio( preview.generate_preview_audio(
text="He said Unfu*k loudly.", text="He said Unfu*k loudly.",
voice_spec="M1", voice_spec="M1",
language="en", language="en",
speed=1.0, speed=1.0,
use_gpu=False, use_gpu=False,
tts_provider="supertonic", tts_provider="supertonic",
manual_overrides=[{"token": "Unfu*k", "pronunciation": "Unfuck"}], manual_overrides=[{"token": "Unfu*k", "pronunciation": "Unfuck"}],
) )
except Exception: except Exception:
# generate_preview_audio will raise because no audio chunks; that's fine. # generate_preview_audio will raise because no audio chunks; that's fine.
pass pass
assert "text" in captured assert "text" in captured
assert "Unfuck" in captured["text"] assert "Unfuck" in captured["text"]
assert "Unfu*k" not in captured["text"] assert "Unfu*k" not in captured["text"]
+281 -281
View File
@@ -1,281 +1,281 @@
"""Tests for the SuperTonic TTS Plugin. """Tests for the SuperTonic TTS Plugin.
These tests verify that the SuperTonic plugin: These tests verify that the SuperTonic plugin:
- Loads correctly through the Plugin Loader - Loads correctly through the Plugin Loader
- Has a valid manifest - Has a valid manifest
- Creates a valid Engine - Creates a valid Engine
- Satisfies the Engine/EngineSession contract (via EngineContractMixin) - Satisfies the Engine/EngineSession contract (via EngineContractMixin)
- Implements VoiceLister capability - Implements VoiceLister capability
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import pytest import pytest
from abogen.tts_plugin.engine import Engine, EngineSession from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.host_context import HostContext from abogen.tts_plugin.host_context import HostContext
from abogen.tts_plugin.loader import load_plugin_from_dir from abogen.tts_plugin.loader import load_plugin_from_dir
from abogen.tts_plugin.manifest import PluginManifest from abogen.tts_plugin.manifest import PluginManifest
from abogen.tts_plugin.types import ( from abogen.tts_plugin.types import (
AudioFormat, AudioFormat,
EngineConfig, EngineConfig,
ParameterValues, ParameterValues,
SynthesisRequest, SynthesisRequest,
VoiceSelection, VoiceSelection,
) )
from tests.contracts.engine_contract import EngineContractMixin from tests.contracts.engine_contract import EngineContractMixin
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# Helpers # Helpers
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
def _supertonic_available() -> bool: def _supertonic_available() -> bool:
try: try:
from supertonic import TTS # type: ignore[import-not-found] from supertonic import TTS # type: ignore[import-not-found]
return True return True
except ImportError: except ImportError:
return False return False
def _make_mock_engine() -> Any: def _make_mock_engine() -> Any:
from plugins.supertonic.engine import SuperTonicEngine from plugins.supertonic.engine import SuperTonicEngine
class MockSegment: class MockSegment:
def __init__(self): def __init__(self):
import numpy as np import numpy as np
self.audio = np.zeros(24000, dtype="float32") self.audio = np.zeros(24000, dtype="float32")
class MockPipeline: class MockPipeline:
sample_rate = 24000 sample_rate = 24000
def __call__(self, text, voice, speed, split_pattern=None, total_steps=None): def __call__(self, text, voice, speed, split_pattern=None, total_steps=None):
return [MockSegment()] return [MockSegment()]
engine = SuperTonicEngine(MockPipeline()) engine = SuperTonicEngine(MockPipeline())
# Override listVoices for testing (real engine reads from manifest) # Override listVoices for testing (real engine reads from manifest)
from abogen.tts_plugin.manifest import VoiceManifest from abogen.tts_plugin.manifest import VoiceManifest
original_list_voices = engine.listVoices original_list_voices = engine.listVoices
def mock_list_voices(source_id): def mock_list_voices(source_id):
if engine._disposed: if engine._disposed:
from abogen.tts_plugin.errors import EngineError from abogen.tts_plugin.errors import EngineError
raise EngineError("Engine disposed") raise EngineError("Engine disposed")
return [ return [
VoiceManifest(id="M1", name="Male 1", tags=("male",)), VoiceManifest(id="M1", name="Male 1", tags=("male",)),
VoiceManifest(id="M2", name="Male 2", tags=("male",)), VoiceManifest(id="M2", name="Male 2", tags=("male",)),
VoiceManifest(id="M3", name="Male 3", tags=("male",)), VoiceManifest(id="M3", name="Male 3", tags=("male",)),
VoiceManifest(id="M4", name="Male 4", tags=("male",)), VoiceManifest(id="M4", name="Male 4", tags=("male",)),
VoiceManifest(id="M5", name="Male 5", tags=("male",)), VoiceManifest(id="M5", name="Male 5", tags=("male",)),
VoiceManifest(id="F1", name="Female 1", tags=("female",)), VoiceManifest(id="F1", name="Female 1", tags=("female",)),
VoiceManifest(id="F2", name="Female 2", tags=("female",)), VoiceManifest(id="F2", name="Female 2", tags=("female",)),
VoiceManifest(id="F3", name="Female 3", tags=("female",)), VoiceManifest(id="F3", name="Female 3", tags=("female",)),
VoiceManifest(id="F4", name="Female 4", tags=("female",)), VoiceManifest(id="F4", name="Female 4", tags=("female",)),
VoiceManifest(id="F5", name="Female 5", tags=("female",)), VoiceManifest(id="F5", name="Female 5", tags=("female",)),
] ]
engine.listVoices = mock_list_voices engine.listVoices = mock_list_voices
return engine return engine
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# Fixtures # Fixtures
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
@pytest.fixture @pytest.fixture
def supertonic_plugin_dir() -> Path: def supertonic_plugin_dir() -> Path:
return Path(__file__).parent.parent / "plugins" / "supertonic" return Path(__file__).parent.parent / "plugins" / "supertonic"
@pytest.fixture @pytest.fixture
def host_context(tmp_path: Path) -> HostContext: def host_context(tmp_path: Path) -> HostContext:
class FakeHttpClient: class FakeHttpClient:
def get(self, url: str, **kwargs: object) -> object: def get(self, url: str, **kwargs: object) -> object:
return None return None
def post(self, url: str, **kwargs: object) -> object: def post(self, url: str, **kwargs: object) -> object:
return None return None
return HostContext( return HostContext(
config_dir=tmp_path, config_dir=tmp_path,
logger=logging.getLogger("test"), logger=logging.getLogger("test"),
http_client=FakeHttpClient(), http_client=FakeHttpClient(),
) )
@pytest.fixture @pytest.fixture
def engine() -> Engine: def engine() -> Engine:
return _make_mock_engine() return _make_mock_engine()
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# Plugin Loading Tests # Plugin Loading Tests
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
class TestSuperTonicPluginLoading: class TestSuperTonicPluginLoading:
def test_plugin_loads_successfully(self, supertonic_plugin_dir: Path) -> None: def test_plugin_loads_successfully(self, supertonic_plugin_dir: Path) -> None:
result = load_plugin_from_dir(supertonic_plugin_dir) result = load_plugin_from_dir(supertonic_plugin_dir)
assert result.success is True assert result.success is True
assert result.manifest is not None assert result.manifest is not None
assert result.create_engine is not None assert result.create_engine is not None
def test_plugin_has_valid_manifest(self, supertonic_plugin_dir: Path) -> None: def test_plugin_has_valid_manifest(self, supertonic_plugin_dir: Path) -> None:
result = load_plugin_from_dir(supertonic_plugin_dir) result = load_plugin_from_dir(supertonic_plugin_dir)
assert result.success is True assert result.success is True
manifest = result.manifest manifest = result.manifest
assert isinstance(manifest, PluginManifest) assert isinstance(manifest, PluginManifest)
assert manifest.id == "supertonic" assert manifest.id == "supertonic"
assert manifest.name == "SuperTonic" assert manifest.name == "SuperTonic"
assert manifest.api_version == "1.0" assert manifest.api_version == "1.0"
def test_plugin_has_model_requirements(self, supertonic_plugin_dir: Path) -> None: def test_plugin_has_model_requirements(self, supertonic_plugin_dir: Path) -> None:
result = load_plugin_from_dir(supertonic_plugin_dir) result = load_plugin_from_dir(supertonic_plugin_dir)
assert result.success is True assert result.success is True
assert result.model_requirements is not None assert result.model_requirements is not None
assert isinstance(result.model_requirements, tuple) assert isinstance(result.model_requirements, tuple)
def test_plugin_manifest_capabilities(self, supertonic_plugin_dir: Path) -> None: def test_plugin_manifest_capabilities(self, supertonic_plugin_dir: Path) -> None:
result = load_plugin_from_dir(supertonic_plugin_dir) result = load_plugin_from_dir(supertonic_plugin_dir)
assert result.success is True assert result.success is True
assert "voice_list" in result.manifest.capabilities assert "voice_list" in result.manifest.capabilities
def test_plugin_manifest_engine(self, supertonic_plugin_dir: Path) -> None: def test_plugin_manifest_engine(self, supertonic_plugin_dir: Path) -> None:
result = load_plugin_from_dir(supertonic_plugin_dir) result = load_plugin_from_dir(supertonic_plugin_dir)
assert result.success is True assert result.success is True
engine_manifest = result.manifest.engine engine_manifest = result.manifest.engine
assert len(engine_manifest.voiceSources) > 0 assert len(engine_manifest.voiceSources) > 0
assert len(engine_manifest.audioFormats) > 0 assert len(engine_manifest.audioFormats) > 0
assert len(engine_manifest.parameters) > 0 assert len(engine_manifest.parameters) > 0
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# Engine Creation (real backend, skipped if not installed) # Engine Creation (real backend, skipped if not installed)
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
class TestSuperTonicEngineCreation: class TestSuperTonicEngineCreation:
@pytest.mark.skipif(not _supertonic_available(), reason="SuperTonic not installed") @pytest.mark.skipif(not _supertonic_available(), reason="SuperTonic not installed")
def test_create_engine(self, supertonic_plugin_dir: Path, host_context: HostContext) -> None: def test_create_engine(self, supertonic_plugin_dir: Path, host_context: HostContext) -> None:
result = load_plugin_from_dir(supertonic_plugin_dir) result = load_plugin_from_dir(supertonic_plugin_dir)
assert result.success is True assert result.success is True
engine = result.create_engine(host_context, None, EngineConfig()) engine = result.create_engine(host_context, None, EngineConfig())
assert isinstance(engine, Engine) assert isinstance(engine, Engine)
engine.dispose() engine.dispose()
@pytest.mark.skipif(not _supertonic_available(), reason="SuperTonic not installed") @pytest.mark.skipif(not _supertonic_available(), reason="SuperTonic not installed")
def test_engine_satisfies_protocol(self, supertonic_plugin_dir: Path, host_context: HostContext) -> None: def test_engine_satisfies_protocol(self, supertonic_plugin_dir: Path, host_context: HostContext) -> None:
result = load_plugin_from_dir(supertonic_plugin_dir) result = load_plugin_from_dir(supertonic_plugin_dir)
assert result.success is True assert result.success is True
engine = result.create_engine(host_context, None, EngineConfig()) engine = result.create_engine(host_context, None, EngineConfig())
assert isinstance(engine, Engine) assert isinstance(engine, Engine)
engine.dispose() engine.dispose()
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# Engine / Session Contract (inherited from base) # Engine / Session Contract (inherited from base)
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
class TestSuperTonicEngineContract(EngineContractMixin): class TestSuperTonicEngineContract(EngineContractMixin):
"""Every test from EngineContractMixin runs against SuperTonicEngine.""" """Every test from EngineContractMixin runs against SuperTonicEngine."""
@pytest.fixture @pytest.fixture
def default_voice(self) -> str: def default_voice(self) -> str:
return "M1" return "M1"
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# VoiceLister Tests # VoiceLister Tests
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
class TestSuperTonicVoiceLister: class TestSuperTonicVoiceLister:
def test_list_voices(self) -> None: def test_list_voices(self) -> None:
engine = _make_mock_engine() engine = _make_mock_engine()
voices = engine.listVoices("builtin") voices = engine.listVoices("builtin")
assert len(voices) == 10 assert len(voices) == 10
assert all(hasattr(v, "id") for v in voices) assert all(hasattr(v, "id") for v in voices)
assert all(hasattr(v, "name") for v in voices) assert all(hasattr(v, "name") for v in voices)
engine.dispose() engine.dispose()
def test_voices_have_tags(self) -> None: def test_voices_have_tags(self) -> None:
engine = _make_mock_engine() engine = _make_mock_engine()
for voice in engine.listVoices("builtin"): for voice in engine.listVoices("builtin"):
assert isinstance(voice.tags, tuple) assert isinstance(voice.tags, tuple)
assert len(voice.tags) > 0 assert len(voice.tags) > 0
engine.dispose() engine.dispose()
def test_male_voices_have_male_tag(self) -> None: def test_male_voices_have_male_tag(self) -> None:
engine = _make_mock_engine() engine = _make_mock_engine()
for v in engine.listVoices("builtin"): for v in engine.listVoices("builtin"):
if v.id.startswith("M"): if v.id.startswith("M"):
assert "male" in v.tags assert "male" in v.tags
engine.dispose() engine.dispose()
def test_female_voices_have_female_tag(self) -> None: def test_female_voices_have_female_tag(self) -> None:
engine = _make_mock_engine() engine = _make_mock_engine()
for v in engine.listVoices("builtin"): for v in engine.listVoices("builtin"):
if v.id.startswith("F"): if v.id.startswith("F"):
assert "female" in v.tags assert "female" in v.tags
engine.dispose() engine.dispose()
def test_list_voices_after_dispose_raises(self) -> None: def test_list_voices_after_dispose_raises(self) -> None:
from abogen.tts_plugin.errors import EngineError from abogen.tts_plugin.errors import EngineError
engine = _make_mock_engine() engine = _make_mock_engine()
engine.dispose() engine.dispose()
with pytest.raises(EngineError): with pytest.raises(EngineError):
engine.listVoices("builtin") engine.listVoices("builtin")
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# SuperTonic-specific parameter tests # SuperTonic-specific parameter tests
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
class TestSuperTonicParameters: class TestSuperTonicParameters:
def test_speed_parameter(self) -> None: def test_speed_parameter(self) -> None:
engine = _make_mock_engine() engine = _make_mock_engine()
session = engine.createSession() session = engine.createSession()
request = SynthesisRequest( request = SynthesisRequest(
text="Hello", text="Hello",
voice=VoiceSelection(source="builtin", key="M1"), voice=VoiceSelection(source="builtin", key="M1"),
parameters=ParameterValues(values={"speed": 1.5}), parameters=ParameterValues(values={"speed": 1.5}),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
) )
result = session.synthesize(request) result = session.synthesize(request)
assert isinstance(result.data, bytes) assert isinstance(result.data, bytes)
session.dispose() session.dispose()
engine.dispose() engine.dispose()
def test_total_steps_parameter(self) -> None: def test_total_steps_parameter(self) -> None:
engine = _make_mock_engine() engine = _make_mock_engine()
session = engine.createSession() session = engine.createSession()
request = SynthesisRequest( request = SynthesisRequest(
text="Hello", text="Hello",
voice=VoiceSelection(source="builtin", key="M1"), voice=VoiceSelection(source="builtin", key="M1"),
parameters=ParameterValues(values={"total_steps": 10}), parameters=ParameterValues(values={"total_steps": 10}),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
) )
result = session.synthesize(request) result = session.synthesize(request)
assert isinstance(result.data, bytes) assert isinstance(result.data, bytes)
session.dispose() session.dispose()
engine.dispose() engine.dispose()
def test_default_parameters(self) -> None: def test_default_parameters(self) -> None:
engine = _make_mock_engine() engine = _make_mock_engine()
session = engine.createSession() session = engine.createSession()
request = SynthesisRequest( request = SynthesisRequest(
text="Hello", text="Hello",
voice=VoiceSelection(source="builtin", key="M1"), voice=VoiceSelection(source="builtin", key="M1"),
parameters=ParameterValues(values={}), parameters=ParameterValues(values={}),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
) )
result = session.synthesize(request) result = session.synthesize(request)
assert isinstance(result.data, bytes) assert isinstance(result.data, bytes)
session.dispose() session.dispose()
engine.dispose() engine.dispose()
+69 -69
View File
@@ -1,69 +1,69 @@
from types import SimpleNamespace from types import SimpleNamespace
from typing import cast from typing import cast
import pytest import pytest
from abogen.tts_plugin.utils import get_voices from abogen.tts_plugin.utils import get_voices
from abogen.voice_cache import ( from abogen.voice_cache import (
LocalEntryNotFoundError, LocalEntryNotFoundError,
_CACHED_VOICES, _CACHED_VOICES,
ensure_voice_assets, ensure_voice_assets,
) )
from abogen.webui.conversion_runner import _collect_required_voice_ids from abogen.webui.conversion_runner import _collect_required_voice_ids
from abogen.webui.service import Job from abogen.webui.service import Job
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def clear_voice_cache(): def clear_voice_cache():
_CACHED_VOICES.clear() _CACHED_VOICES.clear()
yield yield
_CACHED_VOICES.clear() _CACHED_VOICES.clear()
def test_ensure_voice_assets_downloads_missing(monkeypatch): def test_ensure_voice_assets_downloads_missing(monkeypatch):
recorded = [] recorded = []
cached = set() cached = set()
def fake_download(**kwargs): def fake_download(**kwargs):
filename = kwargs["filename"] filename = kwargs["filename"]
if kwargs.get("local_files_only"): if kwargs.get("local_files_only"):
if filename in cached: if filename in cached:
return f"/tmp/{filename}" return f"/tmp/{filename}"
raise LocalEntryNotFoundError(f"{filename} missing") raise LocalEntryNotFoundError(f"{filename} missing")
recorded.append(filename) recorded.append(filename)
cached.add(filename) cached.add(filename)
return f"/tmp/{filename}" return f"/tmp/{filename}"
monkeypatch.setattr("abogen.voice_cache.hf_hub_download", fake_download) monkeypatch.setattr("abogen.voice_cache.hf_hub_download", fake_download)
downloaded, errors = ensure_voice_assets(["af_nova", "am_liam"]) downloaded, errors = ensure_voice_assets(["af_nova", "am_liam"])
assert downloaded == {"af_nova", "am_liam"} assert downloaded == {"af_nova", "am_liam"}
assert errors == {} assert errors == {}
assert set(recorded) == {"voices/af_nova.pt", "voices/am_liam.pt"} assert set(recorded) == {"voices/af_nova.pt", "voices/am_liam.pt"}
recorded.clear() recorded.clear()
downloaded_again, errors_again = ensure_voice_assets(["af_nova"]) downloaded_again, errors_again = ensure_voice_assets(["af_nova"])
assert downloaded_again == set() assert downloaded_again == set()
assert errors_again == {} assert errors_again == {}
assert recorded == [] assert recorded == []
def test_collect_required_voice_ids_includes_all(): def test_collect_required_voice_ids_includes_all():
job = SimpleNamespace( job = SimpleNamespace(
voice="af_nova", voice="af_nova",
chapters=[{"voice_formula": "af_nova*0.7+am_liam*0.3"}], chapters=[{"voice_formula": "af_nova*0.7+am_liam*0.3"}],
chunks=[{"voice": "am_michael"}], chunks=[{"voice": "am_michael"}],
speakers={ speakers={
"hero": {"voice_formula": "af_nova*0.6+am_liam*0.4"}, "hero": {"voice_formula": "af_nova*0.6+am_liam*0.4"},
"narrator": {"voice": "af_nova"}, "narrator": {"voice": "af_nova"},
}, },
) )
voices = _collect_required_voice_ids(cast(Job, job)) voices = _collect_required_voice_ids(cast(Job, job))
assert {"af_nova", "am_liam", "am_michael"}.issubset(voices) assert {"af_nova", "am_liam", "am_michael"}.issubset(voices)
assert voices.issuperset(get_voices("kokoro")) assert voices.issuperset(get_voices("kokoro"))