mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 05:40:26 +02:00
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:
@@ -1,63 +1,63 @@
|
||||
name: Build multi-arch Docker Image
|
||||
|
||||
on:
|
||||
# Build and push
|
||||
#release:
|
||||
# types: [published]
|
||||
# Build only
|
||||
#push: it
|
||||
# branches: [main]
|
||||
# TODO - enable build on pull requests if build times can be reduced
|
||||
# pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
IMAGE_REPOSITORY: ghcr.io/denizsafak/abogen
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Login to Github Container Registry
|
||||
# Only if we need to push an image
|
||||
# if: ${{ github.event_name == 'release' && github.event.action == 'published' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Setup for buildx
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# Debugging information
|
||||
- name: Docker info
|
||||
run: docker info
|
||||
|
||||
- name: Buildx inspect
|
||||
run: docker buildx inspect
|
||||
|
||||
# Build and (optionally) push the image
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./abogen
|
||||
file: ./abogen/Dockerfile
|
||||
# platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
|
||||
# platforms: linux/amd64,linux/arm64
|
||||
platforms: linux/amd64 # using the solution mentioned in https://github.com/denizsafak/abogen/issues/46
|
||||
# Only push if we are publishing a release
|
||||
# push: ${{ github.event_name == 'release' && github.event.action == 'published' }}
|
||||
push: true
|
||||
# Use a 'temp' tag, that won't be pushed, for non-release builds
|
||||
tags: ${{ env.IMAGE_REPOSITORY }}:${{ github.event.release.tag_name || 'latest' }}
|
||||
# Use a cache to reduce build times
|
||||
cache-to: type=gha,mode=max
|
||||
cache-from: type=gha
|
||||
name: Build multi-arch Docker Image
|
||||
|
||||
on:
|
||||
# Build and push
|
||||
#release:
|
||||
# types: [published]
|
||||
# Build only
|
||||
#push: it
|
||||
# branches: [main]
|
||||
# TODO - enable build on pull requests if build times can be reduced
|
||||
# pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
IMAGE_REPOSITORY: ghcr.io/denizsafak/abogen
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Login to Github Container Registry
|
||||
# Only if we need to push an image
|
||||
# if: ${{ github.event_name == 'release' && github.event.action == 'published' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Setup for buildx
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# Debugging information
|
||||
- name: Docker info
|
||||
run: docker info
|
||||
|
||||
- name: Buildx inspect
|
||||
run: docker buildx inspect
|
||||
|
||||
# Build and (optionally) push the image
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./abogen
|
||||
file: ./abogen/Dockerfile
|
||||
# platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
|
||||
# platforms: linux/amd64,linux/arm64
|
||||
platforms: linux/amd64 # using the solution mentioned in https://github.com/denizsafak/abogen/issues/46
|
||||
# Only push if we are publishing a release
|
||||
# push: ${{ github.event_name == 'release' && github.event.action == 'published' }}
|
||||
push: true
|
||||
# Use a 'temp' tag, that won't be pushed, for non-release builds
|
||||
tags: ${{ env.IMAGE_REPOSITORY }}:${{ github.event.release.tag_name || 'latest' }}
|
||||
# Use a cache to reduce build times
|
||||
cache-to: type=gha,mode=max
|
||||
cache-from: type=gha
|
||||
|
||||
+30
-30
@@ -1,31 +1,31 @@
|
||||
<?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">
|
||||
<!-- 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"
|
||||
viewBox="0 0 512 512" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#808080;}
|
||||
</style>
|
||||
<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
|
||||
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-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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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"/>
|
||||
</g>
|
||||
<?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">
|
||||
<!-- 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"
|
||||
viewBox="0 0 512 512" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#808080;}
|
||||
</style>
|
||||
<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
|
||||
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-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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.5 KiB |
+591
-591
File diff suppressed because it is too large
Load Diff
+4304
-4304
File diff suppressed because it is too large
Load Diff
+591
-591
File diff suppressed because it is too large
Load Diff
+1598
-1598
File diff suppressed because it is too large
Load Diff
+584
-584
File diff suppressed because it is too large
Load Diff
+170
-170
@@ -1,170 +1,170 @@
|
||||
"""TTS Plugin Architecture - Public API.
|
||||
|
||||
This package defines the frozen Plugin API for the TTS Plugin Architecture.
|
||||
All public interfaces are fully defined but contain no business logic.
|
||||
|
||||
Public modules:
|
||||
- types: Core domain value objects (AudioFormat, Duration, VoiceSelection, etc.)
|
||||
- errors: Error hierarchy (EngineError and subtypes)
|
||||
- manifest: Plugin manifest types (PluginManifest, EngineManifest, etc.)
|
||||
- engine: Engine and EngineSession protocols
|
||||
- capabilities: Optional capability interfaces (VoiceLister, PreviewGenerator, etc.)
|
||||
- host_context: HostContext dataclass
|
||||
- plugin: Plugin contract (create_engine function signature)
|
||||
- loader: Plugin discovery and loading
|
||||
- plugin_manager: Plugin management and engine creation
|
||||
- utils: Direct utility functions (get_voices, create_pipeline, etc.)
|
||||
|
||||
Usage:
|
||||
from abogen.tts_plugin import (
|
||||
# Types
|
||||
AudioFormat,
|
||||
Duration,
|
||||
VoiceSelection,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
SynthesizedAudio,
|
||||
EngineConfig,
|
||||
# Errors
|
||||
EngineError,
|
||||
ModelNotFoundError,
|
||||
ModelLoadError,
|
||||
NetworkError,
|
||||
InvalidInputError,
|
||||
ConfigurationError,
|
||||
CancelledError,
|
||||
InternalError,
|
||||
# Manifest
|
||||
PluginManifest,
|
||||
EngineManifest,
|
||||
VoiceSourceManifest,
|
||||
VoiceManifest,
|
||||
ParameterManifest,
|
||||
AudioFormatManifest,
|
||||
EnumOption,
|
||||
RequirementManifest,
|
||||
GpuRequirement,
|
||||
ModelManifest,
|
||||
# Engine
|
||||
Engine,
|
||||
EngineSession,
|
||||
# Capabilities
|
||||
VoiceLister,
|
||||
PreviewGenerator,
|
||||
StreamingSynthesizer,
|
||||
CancelableSession,
|
||||
# Host Context
|
||||
HostContext,
|
||||
HttpClient,
|
||||
# Plugin Manager
|
||||
get_plugin_manager,
|
||||
reset_plugin_manager,
|
||||
# Utils
|
||||
get_voices,
|
||||
get_default_voice,
|
||||
is_plugin_registered,
|
||||
resolve_voice_to_plugin,
|
||||
create_pipeline,
|
||||
)
|
||||
"""
|
||||
|
||||
from abogen.tts_plugin.capabilities import (
|
||||
CancelableSession,
|
||||
PreviewGenerator,
|
||||
StreamingSynthesizer,
|
||||
VoiceLister,
|
||||
)
|
||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
||||
from abogen.tts_plugin.errors import (
|
||||
CancelledError,
|
||||
ConfigurationError,
|
||||
EngineError,
|
||||
InternalError,
|
||||
InvalidInputError,
|
||||
ModelLoadError,
|
||||
ModelNotFoundError,
|
||||
NetworkError,
|
||||
)
|
||||
from abogen.tts_plugin.host_context import HttpClient, HostContext
|
||||
from abogen.tts_plugin.manifest import (
|
||||
AudioFormatManifest,
|
||||
EngineManifest,
|
||||
EnumOption,
|
||||
GpuRequirement,
|
||||
ModelManifest,
|
||||
ParameterManifest,
|
||||
PluginManifest,
|
||||
RequirementManifest,
|
||||
VoiceManifest,
|
||||
VoiceSourceManifest,
|
||||
)
|
||||
from abogen.tts_plugin.types import (
|
||||
AudioFormat,
|
||||
Duration,
|
||||
EngineConfig,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
SynthesizedAudio,
|
||||
VoiceSelection,
|
||||
)
|
||||
|
||||
# Plugin Manager and Utils
|
||||
from abogen.tts_plugin.plugin_manager import get_plugin_manager, reset_plugin_manager
|
||||
from abogen.tts_plugin.utils import (
|
||||
create_pipeline,
|
||||
get_default_voice,
|
||||
get_voices,
|
||||
is_plugin_registered,
|
||||
resolve_voice_to_plugin,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Types
|
||||
"AudioFormat",
|
||||
"Duration",
|
||||
"VoiceSelection",
|
||||
"ParameterValues",
|
||||
"SynthesisRequest",
|
||||
"SynthesizedAudio",
|
||||
"EngineConfig",
|
||||
# Errors
|
||||
"EngineError",
|
||||
"ModelNotFoundError",
|
||||
"ModelLoadError",
|
||||
"NetworkError",
|
||||
"InvalidInputError",
|
||||
"ConfigurationError",
|
||||
"CancelledError",
|
||||
"InternalError",
|
||||
# Manifest
|
||||
"PluginManifest",
|
||||
"EngineManifest",
|
||||
"VoiceSourceManifest",
|
||||
"VoiceManifest",
|
||||
"ParameterManifest",
|
||||
"AudioFormatManifest",
|
||||
"EnumOption",
|
||||
"RequirementManifest",
|
||||
"GpuRequirement",
|
||||
"ModelManifest",
|
||||
# Engine
|
||||
"Engine",
|
||||
"EngineSession",
|
||||
# Capabilities
|
||||
"VoiceLister",
|
||||
"PreviewGenerator",
|
||||
"StreamingSynthesizer",
|
||||
"CancelableSession",
|
||||
# Host Context
|
||||
"HostContext",
|
||||
"HttpClient",
|
||||
# Plugin Manager
|
||||
"get_plugin_manager",
|
||||
"reset_plugin_manager",
|
||||
# Utils
|
||||
"get_voices",
|
||||
"get_default_voice",
|
||||
"is_plugin_registered",
|
||||
"resolve_voice_to_plugin",
|
||||
"create_pipeline",
|
||||
]
|
||||
"""TTS Plugin Architecture - Public API.
|
||||
|
||||
This package defines the frozen Plugin API for the TTS Plugin Architecture.
|
||||
All public interfaces are fully defined but contain no business logic.
|
||||
|
||||
Public modules:
|
||||
- types: Core domain value objects (AudioFormat, Duration, VoiceSelection, etc.)
|
||||
- errors: Error hierarchy (EngineError and subtypes)
|
||||
- manifest: Plugin manifest types (PluginManifest, EngineManifest, etc.)
|
||||
- engine: Engine and EngineSession protocols
|
||||
- capabilities: Optional capability interfaces (VoiceLister, PreviewGenerator, etc.)
|
||||
- host_context: HostContext dataclass
|
||||
- plugin: Plugin contract (create_engine function signature)
|
||||
- loader: Plugin discovery and loading
|
||||
- plugin_manager: Plugin management and engine creation
|
||||
- utils: Direct utility functions (get_voices, create_pipeline, etc.)
|
||||
|
||||
Usage:
|
||||
from abogen.tts_plugin import (
|
||||
# Types
|
||||
AudioFormat,
|
||||
Duration,
|
||||
VoiceSelection,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
SynthesizedAudio,
|
||||
EngineConfig,
|
||||
# Errors
|
||||
EngineError,
|
||||
ModelNotFoundError,
|
||||
ModelLoadError,
|
||||
NetworkError,
|
||||
InvalidInputError,
|
||||
ConfigurationError,
|
||||
CancelledError,
|
||||
InternalError,
|
||||
# Manifest
|
||||
PluginManifest,
|
||||
EngineManifest,
|
||||
VoiceSourceManifest,
|
||||
VoiceManifest,
|
||||
ParameterManifest,
|
||||
AudioFormatManifest,
|
||||
EnumOption,
|
||||
RequirementManifest,
|
||||
GpuRequirement,
|
||||
ModelManifest,
|
||||
# Engine
|
||||
Engine,
|
||||
EngineSession,
|
||||
# Capabilities
|
||||
VoiceLister,
|
||||
PreviewGenerator,
|
||||
StreamingSynthesizer,
|
||||
CancelableSession,
|
||||
# Host Context
|
||||
HostContext,
|
||||
HttpClient,
|
||||
# Plugin Manager
|
||||
get_plugin_manager,
|
||||
reset_plugin_manager,
|
||||
# Utils
|
||||
get_voices,
|
||||
get_default_voice,
|
||||
is_plugin_registered,
|
||||
resolve_voice_to_plugin,
|
||||
create_pipeline,
|
||||
)
|
||||
"""
|
||||
|
||||
from abogen.tts_plugin.capabilities import (
|
||||
CancelableSession,
|
||||
PreviewGenerator,
|
||||
StreamingSynthesizer,
|
||||
VoiceLister,
|
||||
)
|
||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
||||
from abogen.tts_plugin.errors import (
|
||||
CancelledError,
|
||||
ConfigurationError,
|
||||
EngineError,
|
||||
InternalError,
|
||||
InvalidInputError,
|
||||
ModelLoadError,
|
||||
ModelNotFoundError,
|
||||
NetworkError,
|
||||
)
|
||||
from abogen.tts_plugin.host_context import HttpClient, HostContext
|
||||
from abogen.tts_plugin.manifest import (
|
||||
AudioFormatManifest,
|
||||
EngineManifest,
|
||||
EnumOption,
|
||||
GpuRequirement,
|
||||
ModelManifest,
|
||||
ParameterManifest,
|
||||
PluginManifest,
|
||||
RequirementManifest,
|
||||
VoiceManifest,
|
||||
VoiceSourceManifest,
|
||||
)
|
||||
from abogen.tts_plugin.types import (
|
||||
AudioFormat,
|
||||
Duration,
|
||||
EngineConfig,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
SynthesizedAudio,
|
||||
VoiceSelection,
|
||||
)
|
||||
|
||||
# Plugin Manager and Utils
|
||||
from abogen.tts_plugin.plugin_manager import get_plugin_manager, reset_plugin_manager
|
||||
from abogen.tts_plugin.utils import (
|
||||
create_pipeline,
|
||||
get_default_voice,
|
||||
get_voices,
|
||||
is_plugin_registered,
|
||||
resolve_voice_to_plugin,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Types
|
||||
"AudioFormat",
|
||||
"Duration",
|
||||
"VoiceSelection",
|
||||
"ParameterValues",
|
||||
"SynthesisRequest",
|
||||
"SynthesizedAudio",
|
||||
"EngineConfig",
|
||||
# Errors
|
||||
"EngineError",
|
||||
"ModelNotFoundError",
|
||||
"ModelLoadError",
|
||||
"NetworkError",
|
||||
"InvalidInputError",
|
||||
"ConfigurationError",
|
||||
"CancelledError",
|
||||
"InternalError",
|
||||
# Manifest
|
||||
"PluginManifest",
|
||||
"EngineManifest",
|
||||
"VoiceSourceManifest",
|
||||
"VoiceManifest",
|
||||
"ParameterManifest",
|
||||
"AudioFormatManifest",
|
||||
"EnumOption",
|
||||
"RequirementManifest",
|
||||
"GpuRequirement",
|
||||
"ModelManifest",
|
||||
# Engine
|
||||
"Engine",
|
||||
"EngineSession",
|
||||
# Capabilities
|
||||
"VoiceLister",
|
||||
"PreviewGenerator",
|
||||
"StreamingSynthesizer",
|
||||
"CancelableSession",
|
||||
# Host Context
|
||||
"HostContext",
|
||||
"HttpClient",
|
||||
# Plugin Manager
|
||||
"get_plugin_manager",
|
||||
"reset_plugin_manager",
|
||||
# Utils
|
||||
"get_voices",
|
||||
"get_default_voice",
|
||||
"is_plugin_registered",
|
||||
"resolve_voice_to_plugin",
|
||||
"create_pipeline",
|
||||
]
|
||||
|
||||
+103
-103
@@ -1,103 +1,103 @@
|
||||
"""Capability interfaces for the TTS Plugin Architecture.
|
||||
|
||||
This module defines optional capability interfaces that engines can implement.
|
||||
Capabilities are additive; implementing new capabilities doesn't break old plugins.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterator, Protocol, runtime_checkable
|
||||
|
||||
from abogen.tts_plugin.manifest import VoiceManifest
|
||||
from abogen.tts_plugin.types import SynthesisRequest, SynthesizedAudio, VoiceSelection
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class VoiceLister(Protocol):
|
||||
"""Protocol for listing available voices.
|
||||
|
||||
Engines that support voice listing should implement this interface.
|
||||
"""
|
||||
|
||||
def listVoices(self, sourceId: str) -> list[VoiceManifest]:
|
||||
"""List available voices for a given source.
|
||||
|
||||
Args:
|
||||
sourceId: The voice source identifier.
|
||||
|
||||
Returns:
|
||||
List of VoiceManifest describing available voices.
|
||||
|
||||
Raises:
|
||||
EngineError: On failure.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PreviewGenerator(Protocol):
|
||||
"""Protocol for generating voice previews.
|
||||
|
||||
Engines that support voice preview should implement this interface.
|
||||
"""
|
||||
|
||||
def generatePreview(self, voice: VoiceSelection, text: str) -> SynthesizedAudio:
|
||||
"""Generate a preview audio for a voice.
|
||||
|
||||
Args:
|
||||
voice: Voice selection for the preview.
|
||||
text: Text to use for the preview.
|
||||
|
||||
Returns:
|
||||
SynthesizedAudio with the preview audio data.
|
||||
|
||||
Raises:
|
||||
EngineError: On failure.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class StreamingSynthesizer(Protocol):
|
||||
"""Protocol for streaming synthesis.
|
||||
|
||||
Optional capability of EngineSession, not Engine.
|
||||
Engines that support streaming synthesis should implement this interface.
|
||||
"""
|
||||
|
||||
def synthesizeStream(self, request: SynthesisRequest) -> Iterator[bytes]:
|
||||
"""Synthesize audio in streaming mode.
|
||||
|
||||
Args:
|
||||
request: The synthesis request.
|
||||
|
||||
Yields:
|
||||
Audio chunks as they become available.
|
||||
|
||||
Raises:
|
||||
CancelledError: If cancel() is called during iteration.
|
||||
EngineError: On synthesis failure.
|
||||
"""
|
||||
...
|
||||
# This is a generator function; implementation will use yield
|
||||
yield b"" # pragma: no cover
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CancelableSession(Protocol):
|
||||
"""Protocol for cancellation support.
|
||||
|
||||
Optional capability for engines that support cancellation.
|
||||
cancel() causes synthesize() to raise CancelledError.
|
||||
"""
|
||||
|
||||
def cancel(self) -> None:
|
||||
"""Cancel in-progress synthesis.
|
||||
|
||||
After cancellation, synthesize() raises CancelledError.
|
||||
The session remains usable after cancellation.
|
||||
|
||||
Raises:
|
||||
EngineError: If called after dispose().
|
||||
"""
|
||||
...
|
||||
"""Capability interfaces for the TTS Plugin Architecture.
|
||||
|
||||
This module defines optional capability interfaces that engines can implement.
|
||||
Capabilities are additive; implementing new capabilities doesn't break old plugins.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterator, Protocol, runtime_checkable
|
||||
|
||||
from abogen.tts_plugin.manifest import VoiceManifest
|
||||
from abogen.tts_plugin.types import SynthesisRequest, SynthesizedAudio, VoiceSelection
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class VoiceLister(Protocol):
|
||||
"""Protocol for listing available voices.
|
||||
|
||||
Engines that support voice listing should implement this interface.
|
||||
"""
|
||||
|
||||
def listVoices(self, sourceId: str) -> list[VoiceManifest]:
|
||||
"""List available voices for a given source.
|
||||
|
||||
Args:
|
||||
sourceId: The voice source identifier.
|
||||
|
||||
Returns:
|
||||
List of VoiceManifest describing available voices.
|
||||
|
||||
Raises:
|
||||
EngineError: On failure.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PreviewGenerator(Protocol):
|
||||
"""Protocol for generating voice previews.
|
||||
|
||||
Engines that support voice preview should implement this interface.
|
||||
"""
|
||||
|
||||
def generatePreview(self, voice: VoiceSelection, text: str) -> SynthesizedAudio:
|
||||
"""Generate a preview audio for a voice.
|
||||
|
||||
Args:
|
||||
voice: Voice selection for the preview.
|
||||
text: Text to use for the preview.
|
||||
|
||||
Returns:
|
||||
SynthesizedAudio with the preview audio data.
|
||||
|
||||
Raises:
|
||||
EngineError: On failure.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class StreamingSynthesizer(Protocol):
|
||||
"""Protocol for streaming synthesis.
|
||||
|
||||
Optional capability of EngineSession, not Engine.
|
||||
Engines that support streaming synthesis should implement this interface.
|
||||
"""
|
||||
|
||||
def synthesizeStream(self, request: SynthesisRequest) -> Iterator[bytes]:
|
||||
"""Synthesize audio in streaming mode.
|
||||
|
||||
Args:
|
||||
request: The synthesis request.
|
||||
|
||||
Yields:
|
||||
Audio chunks as they become available.
|
||||
|
||||
Raises:
|
||||
CancelledError: If cancel() is called during iteration.
|
||||
EngineError: On synthesis failure.
|
||||
"""
|
||||
...
|
||||
# This is a generator function; implementation will use yield
|
||||
yield b"" # pragma: no cover
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CancelableSession(Protocol):
|
||||
"""Protocol for cancellation support.
|
||||
|
||||
Optional capability for engines that support cancellation.
|
||||
cancel() causes synthesize() to raise CancelledError.
|
||||
"""
|
||||
|
||||
def cancel(self) -> None:
|
||||
"""Cancel in-progress synthesis.
|
||||
|
||||
After cancellation, synthesize() raises CancelledError.
|
||||
The session remains usable after cancellation.
|
||||
|
||||
Raises:
|
||||
EngineError: If called after dispose().
|
||||
"""
|
||||
...
|
||||
|
||||
+95
-95
@@ -1,95 +1,95 @@
|
||||
"""Engine interfaces for the TTS Plugin Architecture.
|
||||
|
||||
This module defines the core Engine and EngineSession protocols.
|
||||
These are the primary interfaces that plugin implementations must satisfy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from abogen.tts_plugin.types import SynthesisRequest, SynthesizedAudio
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class EngineSession(Protocol):
|
||||
"""Protocol for a session that owns mutable execution state.
|
||||
|
||||
An EngineSession is created by Engine.createSession() and owns
|
||||
mutable execution state isolated from other concurrent work.
|
||||
It is NOT thread-safe.
|
||||
|
||||
Lifecycle:
|
||||
1. Created by Engine.createSession()
|
||||
2. Used for synthesis via synthesize()
|
||||
3. Disposed via dispose()
|
||||
|
||||
After dispose(), all methods except dispose() raise EngineError.
|
||||
"""
|
||||
|
||||
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
|
||||
"""Synthesize audio from text.
|
||||
|
||||
Args:
|
||||
request: The synthesis request containing text, voice, parameters, and format.
|
||||
|
||||
Returns:
|
||||
SynthesizedAudio with the synthesized audio data.
|
||||
|
||||
Raises:
|
||||
EngineError: On synthesis failure. Session remains usable after error.
|
||||
EngineError: If called after dispose().
|
||||
"""
|
||||
...
|
||||
|
||||
def dispose(self) -> None:
|
||||
"""Release session resources.
|
||||
|
||||
This method is idempotent and safe to call multiple times.
|
||||
It never raises exceptions (catches and logs internally).
|
||||
After dispose(), all methods except dispose() raise EngineError.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Engine(Protocol):
|
||||
"""Protocol for a TTS engine that creates sessions.
|
||||
|
||||
An Engine is a factory for EngineSession instances. It is stateless
|
||||
and thread-safe for createSession().
|
||||
|
||||
Lifecycle:
|
||||
1. Created via create_engine() (plugin contract)
|
||||
2. Sessions created via createSession()
|
||||
3. Disposed via dispose()
|
||||
|
||||
Thread Safety:
|
||||
- createSession() is thread-safe and can be called from any thread.
|
||||
- dispose() must be called after all sessions are disposed.
|
||||
- Disposing engine while sessions are alive violates API contract.
|
||||
"""
|
||||
|
||||
def createSession(self) -> EngineSession:
|
||||
"""Create a new session for synthesis.
|
||||
|
||||
Returns:
|
||||
A new EngineSession instance. Ownership transfers to caller.
|
||||
|
||||
Raises:
|
||||
EngineError: On failure. No partially initialized session is returned.
|
||||
"""
|
||||
...
|
||||
|
||||
def dispose(self) -> None:
|
||||
"""Release engine resources.
|
||||
|
||||
Caller must ensure all sessions created by this engine are disposed
|
||||
before calling dispose(). Disposing an engine while any session is
|
||||
still alive violates the API contract; behavior is undefined.
|
||||
|
||||
This method is idempotent and safe to call multiple times.
|
||||
It never raises exceptions (catches and logs internally).
|
||||
After dispose(), all methods except dispose() raise EngineError.
|
||||
"""
|
||||
...
|
||||
"""Engine interfaces for the TTS Plugin Architecture.
|
||||
|
||||
This module defines the core Engine and EngineSession protocols.
|
||||
These are the primary interfaces that plugin implementations must satisfy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from abogen.tts_plugin.types import SynthesisRequest, SynthesizedAudio
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class EngineSession(Protocol):
|
||||
"""Protocol for a session that owns mutable execution state.
|
||||
|
||||
An EngineSession is created by Engine.createSession() and owns
|
||||
mutable execution state isolated from other concurrent work.
|
||||
It is NOT thread-safe.
|
||||
|
||||
Lifecycle:
|
||||
1. Created by Engine.createSession()
|
||||
2. Used for synthesis via synthesize()
|
||||
3. Disposed via dispose()
|
||||
|
||||
After dispose(), all methods except dispose() raise EngineError.
|
||||
"""
|
||||
|
||||
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
|
||||
"""Synthesize audio from text.
|
||||
|
||||
Args:
|
||||
request: The synthesis request containing text, voice, parameters, and format.
|
||||
|
||||
Returns:
|
||||
SynthesizedAudio with the synthesized audio data.
|
||||
|
||||
Raises:
|
||||
EngineError: On synthesis failure. Session remains usable after error.
|
||||
EngineError: If called after dispose().
|
||||
"""
|
||||
...
|
||||
|
||||
def dispose(self) -> None:
|
||||
"""Release session resources.
|
||||
|
||||
This method is idempotent and safe to call multiple times.
|
||||
It never raises exceptions (catches and logs internally).
|
||||
After dispose(), all methods except dispose() raise EngineError.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Engine(Protocol):
|
||||
"""Protocol for a TTS engine that creates sessions.
|
||||
|
||||
An Engine is a factory for EngineSession instances. It is stateless
|
||||
and thread-safe for createSession().
|
||||
|
||||
Lifecycle:
|
||||
1. Created via create_engine() (plugin contract)
|
||||
2. Sessions created via createSession()
|
||||
3. Disposed via dispose()
|
||||
|
||||
Thread Safety:
|
||||
- createSession() is thread-safe and can be called from any thread.
|
||||
- dispose() must be called after all sessions are disposed.
|
||||
- Disposing engine while sessions are alive violates API contract.
|
||||
"""
|
||||
|
||||
def createSession(self) -> EngineSession:
|
||||
"""Create a new session for synthesis.
|
||||
|
||||
Returns:
|
||||
A new EngineSession instance. Ownership transfers to caller.
|
||||
|
||||
Raises:
|
||||
EngineError: On failure. No partially initialized session is returned.
|
||||
"""
|
||||
...
|
||||
|
||||
def dispose(self) -> None:
|
||||
"""Release engine resources.
|
||||
|
||||
Caller must ensure all sessions created by this engine are disposed
|
||||
before calling dispose(). Disposing an engine while any session is
|
||||
still alive violates the API contract; behavior is undefined.
|
||||
|
||||
This method is idempotent and safe to call multiple times.
|
||||
It never raises exceptions (catches and logs internally).
|
||||
After dispose(), all methods except dispose() raise EngineError.
|
||||
"""
|
||||
...
|
||||
|
||||
+62
-62
@@ -1,62 +1,62 @@
|
||||
"""Error hierarchy for the TTS Plugin Architecture.
|
||||
|
||||
This module defines typed exceptions that engines raise.
|
||||
Engines should never raise raw exceptions; they must use EngineError or its subtypes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class EngineError(Exception):
|
||||
"""Base exception for all engine errors.
|
||||
|
||||
All engine operations that can fail should raise EngineError or one of its subtypes.
|
||||
After dispose(), all methods except dispose() raise EngineError.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ModelNotFoundError(EngineError):
|
||||
"""Raised when a required model is not found."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ModelLoadError(EngineError):
|
||||
"""Raised when a model fails to load."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class NetworkError(EngineError):
|
||||
"""Raised when a network operation fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class InvalidInputError(EngineError):
|
||||
"""Raised when invalid input is provided to the engine."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ConfigurationError(EngineError):
|
||||
"""Raised when there is a configuration error."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class CancelledError(EngineError):
|
||||
"""Raised when an operation is cancelled.
|
||||
|
||||
This is raised by synthesize() when cancel() is called during synthesis.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class InternalError(EngineError):
|
||||
"""Raised when an internal engine error occurs."""
|
||||
|
||||
pass
|
||||
"""Error hierarchy for the TTS Plugin Architecture.
|
||||
|
||||
This module defines typed exceptions that engines raise.
|
||||
Engines should never raise raw exceptions; they must use EngineError or its subtypes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class EngineError(Exception):
|
||||
"""Base exception for all engine errors.
|
||||
|
||||
All engine operations that can fail should raise EngineError or one of its subtypes.
|
||||
After dispose(), all methods except dispose() raise EngineError.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ModelNotFoundError(EngineError):
|
||||
"""Raised when a required model is not found."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ModelLoadError(EngineError):
|
||||
"""Raised when a model fails to load."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class NetworkError(EngineError):
|
||||
"""Raised when a network operation fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class InvalidInputError(EngineError):
|
||||
"""Raised when invalid input is provided to the engine."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ConfigurationError(EngineError):
|
||||
"""Raised when there is a configuration error."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class CancelledError(EngineError):
|
||||
"""Raised when an operation is cancelled.
|
||||
|
||||
This is raised by synthesize() when cancel() is called during synthesis.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class InternalError(EngineError):
|
||||
"""Raised when an internal engine error occurs."""
|
||||
|
||||
pass
|
||||
|
||||
@@ -1,46 +1,46 @@
|
||||
"""Host context for the TTS Plugin Architecture.
|
||||
|
||||
This module defines the HostContext dataclass that provides minimal
|
||||
host services to plugins. It is the only interface through which
|
||||
plugins can access host functionality.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class HttpClient(Protocol):
|
||||
"""Protocol for HTTP client provided by host.
|
||||
|
||||
Plugins can use this for network requests (e.g., API-based engines).
|
||||
"""
|
||||
|
||||
def get(self, url: str, **kwargs: object) -> object:
|
||||
"""Perform an HTTP GET request."""
|
||||
...
|
||||
|
||||
def post(self, url: str, **kwargs: object) -> object:
|
||||
"""Perform an HTTP POST request."""
|
||||
...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HostContext:
|
||||
"""Minimal host context provided to plugins.
|
||||
|
||||
Contains only essential host services. No business logic.
|
||||
|
||||
Attributes:
|
||||
config_dir: Directory for API keys, preferences, and configuration.
|
||||
logger: Logger for plugin logging.
|
||||
http_client: HTTP client for network requests.
|
||||
"""
|
||||
|
||||
config_dir: Path
|
||||
logger: logging.Logger
|
||||
http_client: HttpClient
|
||||
"""Host context for the TTS Plugin Architecture.
|
||||
|
||||
This module defines the HostContext dataclass that provides minimal
|
||||
host services to plugins. It is the only interface through which
|
||||
plugins can access host functionality.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class HttpClient(Protocol):
|
||||
"""Protocol for HTTP client provided by host.
|
||||
|
||||
Plugins can use this for network requests (e.g., API-based engines).
|
||||
"""
|
||||
|
||||
def get(self, url: str, **kwargs: object) -> object:
|
||||
"""Perform an HTTP GET request."""
|
||||
...
|
||||
|
||||
def post(self, url: str, **kwargs: object) -> object:
|
||||
"""Perform an HTTP POST request."""
|
||||
...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HostContext:
|
||||
"""Minimal host context provided to plugins.
|
||||
|
||||
Contains only essential host services. No business logic.
|
||||
|
||||
Attributes:
|
||||
config_dir: Directory for API keys, preferences, and configuration.
|
||||
logger: Logger for plugin logging.
|
||||
http_client: HTTP client for network requests.
|
||||
"""
|
||||
|
||||
config_dir: Path
|
||||
logger: logging.Logger
|
||||
http_client: HttpClient
|
||||
|
||||
+365
-365
@@ -1,365 +1,365 @@
|
||||
"""Plugin loader infrastructure for the TTS Plugin Architecture.
|
||||
|
||||
This module provides functionality to discover, import, validate, and load
|
||||
TTS plugins. It handles both valid and invalid plugins, providing diagnostic
|
||||
messages for errors.
|
||||
|
||||
The loader does NOT:
|
||||
- Create Engine instances (that's the plugin's create_engine() responsibility)
|
||||
- Manage plugin lifecycle (that's the Plugin Manager's responsibility)
|
||||
- Implement any TTS engine functionality
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import re
|
||||
import sys
|
||||
import types
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from abogen.tts_plugin.manifest import ModelManifest, PluginManifest
|
||||
|
||||
|
||||
# Host API version for compatibility checking
|
||||
HOST_API_VERSION = "1.0"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PluginLoadError:
|
||||
"""Diagnostic information for a failed plugin load.
|
||||
|
||||
Attributes:
|
||||
plugin_id: Plugin identifier if available, otherwise directory name.
|
||||
path: Path to the plugin directory.
|
||||
errors: List of error messages describing what went wrong.
|
||||
"""
|
||||
|
||||
plugin_id: str
|
||||
path: Path
|
||||
errors: tuple[str, ...] = field(default_factory=tuple)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PluginLoadResult:
|
||||
"""Result of loading a plugin.
|
||||
|
||||
Attributes:
|
||||
success: Whether the plugin loaded successfully.
|
||||
manifest: The plugin manifest if successful.
|
||||
model_requirements: Model requirements if successful.
|
||||
create_engine: The create_engine function if successful.
|
||||
module: The plugin module if successful.
|
||||
error: Error information if failed.
|
||||
"""
|
||||
|
||||
success: bool
|
||||
manifest: PluginManifest | None = None
|
||||
model_requirements: tuple[ModelManifest, ...] | None = None
|
||||
create_engine: Callable[..., Any] | None = None
|
||||
module: types.ModuleType | None = None
|
||||
error: PluginLoadError | None = None
|
||||
|
||||
|
||||
def _parse_api_version(version: str) -> tuple[int, int] | None:
|
||||
"""Parse an api_version string into (major, minor) tuple.
|
||||
|
||||
Args:
|
||||
version: Version string in format "MAJOR.MINOR".
|
||||
|
||||
Returns:
|
||||
Tuple of (major, minor) or None if invalid format.
|
||||
"""
|
||||
match = re.match(r"^(\d+)\.(\d+)$", version)
|
||||
if match:
|
||||
return int(match.group(1)), int(match.group(2))
|
||||
return None
|
||||
|
||||
|
||||
def _check_api_version_compatibility(plugin_version: str) -> str | None:
|
||||
"""Check if plugin api_version is compatible with host.
|
||||
|
||||
Architecture spec:
|
||||
- Format: semver (MAJOR.MINOR)
|
||||
- Compatibility: Host rejects plugin if major version differs
|
||||
- Minor version: backward compatible, Host accepts higher minor
|
||||
|
||||
Args:
|
||||
plugin_version: Plugin's api_version string.
|
||||
|
||||
Returns:
|
||||
Error message if incompatible, None if compatible.
|
||||
"""
|
||||
plugin_ver = _parse_api_version(plugin_version)
|
||||
if plugin_ver is None:
|
||||
return f"Invalid api_version format: '{plugin_version}'. Expected format: MAJOR.MINOR"
|
||||
|
||||
host_ver = _parse_api_version(HOST_API_VERSION)
|
||||
if host_ver is None:
|
||||
return f"Invalid host api_version format: '{HOST_API_VERSION}'"
|
||||
|
||||
if plugin_ver[0] != host_ver[0]:
|
||||
return (
|
||||
f"api_version major mismatch: plugin={plugin_ver[0]}, host={host_ver[0]}. "
|
||||
f"Major version must match for compatibility."
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _validate_manifest(module: types.ModuleType, plugin_dir: Path) -> list[str]:
|
||||
"""Validate that a plugin module has required exports.
|
||||
|
||||
Args:
|
||||
module: The imported plugin module.
|
||||
plugin_dir: Path to the plugin directory.
|
||||
|
||||
Returns:
|
||||
List of error messages (empty if valid).
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
# Check PLUGIN_MANIFEST
|
||||
manifest = getattr(module, "PLUGIN_MANIFEST", None)
|
||||
if manifest is None:
|
||||
errors.append("Missing PLUGIN_MANIFEST export")
|
||||
elif not isinstance(manifest, PluginManifest):
|
||||
errors.append(
|
||||
f"PLUGIN_MANIFEST must be a PluginManifest instance, "
|
||||
f"got {type(manifest).__name__}"
|
||||
)
|
||||
|
||||
# Check MODEL_REQUIREMENTS
|
||||
model_reqs = getattr(module, "MODEL_REQUIREMENTS", None)
|
||||
if model_reqs is None:
|
||||
errors.append("Missing MODEL_REQUIREMENTS export")
|
||||
elif not isinstance(model_reqs, list):
|
||||
errors.append(
|
||||
f"MODEL_REQUIREMENTS must be a list, got {type(model_reqs).__name__}"
|
||||
)
|
||||
else:
|
||||
for i, req in enumerate(model_reqs):
|
||||
if not isinstance(req, ModelManifest):
|
||||
errors.append(
|
||||
f"MODEL_REQUIREMENTS[{i}] must be a ModelManifest instance, "
|
||||
f"got {type(req).__name__}"
|
||||
)
|
||||
|
||||
# Check create_engine
|
||||
create_engine = getattr(module, "create_engine", None)
|
||||
if create_engine is None:
|
||||
errors.append("Missing create_engine export")
|
||||
elif not callable(create_engine):
|
||||
errors.append(
|
||||
f"create_engine must be callable, got {type(create_engine).__name__}"
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def _validate_capabilities(manifest: PluginManifest) -> list[str]:
|
||||
"""Validate plugin capabilities.
|
||||
|
||||
Args:
|
||||
manifest: The plugin manifest to validate.
|
||||
|
||||
Returns:
|
||||
List of error messages (empty if valid).
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
# Known capabilities (can be extended)
|
||||
known_capabilities = frozenset({
|
||||
"voice_list",
|
||||
"preview",
|
||||
"voice_clone",
|
||||
"voice_blend",
|
||||
"streaming",
|
||||
"cancel",
|
||||
})
|
||||
|
||||
for cap in manifest.capabilities:
|
||||
if cap not in known_capabilities:
|
||||
errors.append(f"Unknown capability: '{cap}'")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def _validate_api_version(manifest: PluginManifest) -> list[str]:
|
||||
"""Validate api_version compatibility.
|
||||
|
||||
Args:
|
||||
manifest: The plugin manifest to validate.
|
||||
|
||||
Returns:
|
||||
List of error messages (empty if valid).
|
||||
"""
|
||||
errors: list[str] = []
|
||||
error = _check_api_version_compatibility(manifest.api_version)
|
||||
if error:
|
||||
errors.append(error)
|
||||
return errors
|
||||
|
||||
|
||||
def load_plugin_from_dir(plugin_dir: Path) -> PluginLoadResult:
|
||||
"""Load and validate a plugin from a directory.
|
||||
|
||||
The plugin directory must contain an __init__.py that exports:
|
||||
- PLUGIN_MANIFEST: PluginManifest
|
||||
- MODEL_REQUIREMENTS: list[ModelManifest]
|
||||
- create_engine: Callable
|
||||
|
||||
Args:
|
||||
plugin_dir: Path to the plugin directory.
|
||||
|
||||
Returns:
|
||||
PluginLoadResult with success status and either plugin data or error info.
|
||||
"""
|
||||
plugin_id = plugin_dir.name
|
||||
errors: list[str] = []
|
||||
|
||||
# Check if directory exists
|
||||
if not plugin_dir.exists():
|
||||
return PluginLoadResult(
|
||||
success=False,
|
||||
error=PluginLoadError(
|
||||
plugin_id=plugin_id,
|
||||
path=plugin_dir,
|
||||
errors=(f"Plugin directory does not exist: {plugin_dir}",),
|
||||
),
|
||||
)
|
||||
|
||||
# Check for __init__.py
|
||||
init_file = plugin_dir / "__init__.py"
|
||||
if not init_file.exists():
|
||||
return PluginLoadResult(
|
||||
success=False,
|
||||
error=PluginLoadError(
|
||||
plugin_id=plugin_id,
|
||||
path=plugin_dir,
|
||||
errors=("Missing __init__.py in plugin directory",),
|
||||
),
|
||||
)
|
||||
|
||||
# Import the module
|
||||
module_name = f"abogen.tts_plugin._loaded.{plugin_id}"
|
||||
try:
|
||||
# Remove from cache if already imported (for testing)
|
||||
if module_name in sys.modules:
|
||||
del sys.modules[module_name]
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
module_name, init_file, submodule_search_locations=[]
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
return PluginLoadResult(
|
||||
success=False,
|
||||
error=PluginLoadError(
|
||||
plugin_id=plugin_id,
|
||||
path=plugin_dir,
|
||||
errors=(f"Failed to create module spec for {init_file}",),
|
||||
),
|
||||
)
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
except Exception as e:
|
||||
# Clean up module from sys.modules on import failure
|
||||
if module_name in sys.modules:
|
||||
del sys.modules[module_name]
|
||||
return PluginLoadResult(
|
||||
success=False,
|
||||
error=PluginLoadError(
|
||||
plugin_id=plugin_id,
|
||||
path=plugin_dir,
|
||||
errors=(f"Failed to import plugin module: {e}",),
|
||||
),
|
||||
)
|
||||
|
||||
# Validate manifest
|
||||
manifest_errors = _validate_manifest(module, plugin_dir)
|
||||
errors.extend(manifest_errors)
|
||||
|
||||
# If manifest is valid, perform additional validation
|
||||
manifest = getattr(module, "PLUGIN_MANIFEST", None)
|
||||
if isinstance(manifest, PluginManifest):
|
||||
# Validate api_version
|
||||
api_errors = _validate_api_version(manifest)
|
||||
errors.extend(api_errors)
|
||||
|
||||
# Validate capabilities
|
||||
cap_errors = _validate_capabilities(manifest)
|
||||
errors.extend(cap_errors)
|
||||
|
||||
# Use manifest id if available
|
||||
plugin_id = manifest.id
|
||||
|
||||
# Check if any errors occurred
|
||||
if errors:
|
||||
# Clean up module from sys.modules
|
||||
if module_name in sys.modules:
|
||||
del sys.modules[module_name]
|
||||
|
||||
return PluginLoadResult(
|
||||
success=False,
|
||||
error=PluginLoadError(
|
||||
plugin_id=plugin_id,
|
||||
path=plugin_dir,
|
||||
errors=tuple(errors),
|
||||
),
|
||||
)
|
||||
|
||||
# Get MODEL_REQUIREMENTS
|
||||
model_requirements = tuple(getattr(module, "MODEL_REQUIREMENTS", []))
|
||||
create_engine = getattr(module, "create_engine", None)
|
||||
|
||||
return PluginLoadResult(
|
||||
success=True,
|
||||
manifest=manifest,
|
||||
model_requirements=model_requirements,
|
||||
create_engine=create_engine,
|
||||
module=module,
|
||||
)
|
||||
|
||||
|
||||
def discover_plugins(plugin_dirs: list[Path]) -> list[PluginLoadResult]:
|
||||
"""Discover and load plugins from multiple directories.
|
||||
|
||||
Args:
|
||||
plugin_dirs: List of directories to scan for plugins.
|
||||
|
||||
Returns:
|
||||
List of PluginLoadResult, one per plugin directory found.
|
||||
"""
|
||||
results: list[PluginLoadResult] = []
|
||||
|
||||
for plugin_dir in plugin_dirs:
|
||||
if not plugin_dir.exists():
|
||||
continue
|
||||
|
||||
# Scan for subdirectories (each is a potential plugin)
|
||||
for item in sorted(plugin_dir.iterdir()):
|
||||
if item.is_dir() and not item.name.startswith("."):
|
||||
result = load_plugin_from_dir(item)
|
||||
results.append(result)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def load_plugin(
|
||||
plugin_dir: Path,
|
||||
) -> PluginLoadResult:
|
||||
"""Load a single plugin from a directory.
|
||||
|
||||
This is the main entry point for loading a plugin.
|
||||
|
||||
Args:
|
||||
plugin_dir: Path to the plugin directory.
|
||||
|
||||
Returns:
|
||||
PluginLoadResult with success status and either plugin data or error info.
|
||||
"""
|
||||
return load_plugin_from_dir(plugin_dir)
|
||||
"""Plugin loader infrastructure for the TTS Plugin Architecture.
|
||||
|
||||
This module provides functionality to discover, import, validate, and load
|
||||
TTS plugins. It handles both valid and invalid plugins, providing diagnostic
|
||||
messages for errors.
|
||||
|
||||
The loader does NOT:
|
||||
- Create Engine instances (that's the plugin's create_engine() responsibility)
|
||||
- Manage plugin lifecycle (that's the Plugin Manager's responsibility)
|
||||
- Implement any TTS engine functionality
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import re
|
||||
import sys
|
||||
import types
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from abogen.tts_plugin.manifest import ModelManifest, PluginManifest
|
||||
|
||||
|
||||
# Host API version for compatibility checking
|
||||
HOST_API_VERSION = "1.0"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PluginLoadError:
|
||||
"""Diagnostic information for a failed plugin load.
|
||||
|
||||
Attributes:
|
||||
plugin_id: Plugin identifier if available, otherwise directory name.
|
||||
path: Path to the plugin directory.
|
||||
errors: List of error messages describing what went wrong.
|
||||
"""
|
||||
|
||||
plugin_id: str
|
||||
path: Path
|
||||
errors: tuple[str, ...] = field(default_factory=tuple)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PluginLoadResult:
|
||||
"""Result of loading a plugin.
|
||||
|
||||
Attributes:
|
||||
success: Whether the plugin loaded successfully.
|
||||
manifest: The plugin manifest if successful.
|
||||
model_requirements: Model requirements if successful.
|
||||
create_engine: The create_engine function if successful.
|
||||
module: The plugin module if successful.
|
||||
error: Error information if failed.
|
||||
"""
|
||||
|
||||
success: bool
|
||||
manifest: PluginManifest | None = None
|
||||
model_requirements: tuple[ModelManifest, ...] | None = None
|
||||
create_engine: Callable[..., Any] | None = None
|
||||
module: types.ModuleType | None = None
|
||||
error: PluginLoadError | None = None
|
||||
|
||||
|
||||
def _parse_api_version(version: str) -> tuple[int, int] | None:
|
||||
"""Parse an api_version string into (major, minor) tuple.
|
||||
|
||||
Args:
|
||||
version: Version string in format "MAJOR.MINOR".
|
||||
|
||||
Returns:
|
||||
Tuple of (major, minor) or None if invalid format.
|
||||
"""
|
||||
match = re.match(r"^(\d+)\.(\d+)$", version)
|
||||
if match:
|
||||
return int(match.group(1)), int(match.group(2))
|
||||
return None
|
||||
|
||||
|
||||
def _check_api_version_compatibility(plugin_version: str) -> str | None:
|
||||
"""Check if plugin api_version is compatible with host.
|
||||
|
||||
Architecture spec:
|
||||
- Format: semver (MAJOR.MINOR)
|
||||
- Compatibility: Host rejects plugin if major version differs
|
||||
- Minor version: backward compatible, Host accepts higher minor
|
||||
|
||||
Args:
|
||||
plugin_version: Plugin's api_version string.
|
||||
|
||||
Returns:
|
||||
Error message if incompatible, None if compatible.
|
||||
"""
|
||||
plugin_ver = _parse_api_version(plugin_version)
|
||||
if plugin_ver is None:
|
||||
return f"Invalid api_version format: '{plugin_version}'. Expected format: MAJOR.MINOR"
|
||||
|
||||
host_ver = _parse_api_version(HOST_API_VERSION)
|
||||
if host_ver is None:
|
||||
return f"Invalid host api_version format: '{HOST_API_VERSION}'"
|
||||
|
||||
if plugin_ver[0] != host_ver[0]:
|
||||
return (
|
||||
f"api_version major mismatch: plugin={plugin_ver[0]}, host={host_ver[0]}. "
|
||||
f"Major version must match for compatibility."
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _validate_manifest(module: types.ModuleType, plugin_dir: Path) -> list[str]:
|
||||
"""Validate that a plugin module has required exports.
|
||||
|
||||
Args:
|
||||
module: The imported plugin module.
|
||||
plugin_dir: Path to the plugin directory.
|
||||
|
||||
Returns:
|
||||
List of error messages (empty if valid).
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
# Check PLUGIN_MANIFEST
|
||||
manifest = getattr(module, "PLUGIN_MANIFEST", None)
|
||||
if manifest is None:
|
||||
errors.append("Missing PLUGIN_MANIFEST export")
|
||||
elif not isinstance(manifest, PluginManifest):
|
||||
errors.append(
|
||||
f"PLUGIN_MANIFEST must be a PluginManifest instance, "
|
||||
f"got {type(manifest).__name__}"
|
||||
)
|
||||
|
||||
# Check MODEL_REQUIREMENTS
|
||||
model_reqs = getattr(module, "MODEL_REQUIREMENTS", None)
|
||||
if model_reqs is None:
|
||||
errors.append("Missing MODEL_REQUIREMENTS export")
|
||||
elif not isinstance(model_reqs, list):
|
||||
errors.append(
|
||||
f"MODEL_REQUIREMENTS must be a list, got {type(model_reqs).__name__}"
|
||||
)
|
||||
else:
|
||||
for i, req in enumerate(model_reqs):
|
||||
if not isinstance(req, ModelManifest):
|
||||
errors.append(
|
||||
f"MODEL_REQUIREMENTS[{i}] must be a ModelManifest instance, "
|
||||
f"got {type(req).__name__}"
|
||||
)
|
||||
|
||||
# Check create_engine
|
||||
create_engine = getattr(module, "create_engine", None)
|
||||
if create_engine is None:
|
||||
errors.append("Missing create_engine export")
|
||||
elif not callable(create_engine):
|
||||
errors.append(
|
||||
f"create_engine must be callable, got {type(create_engine).__name__}"
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def _validate_capabilities(manifest: PluginManifest) -> list[str]:
|
||||
"""Validate plugin capabilities.
|
||||
|
||||
Args:
|
||||
manifest: The plugin manifest to validate.
|
||||
|
||||
Returns:
|
||||
List of error messages (empty if valid).
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
# Known capabilities (can be extended)
|
||||
known_capabilities = frozenset({
|
||||
"voice_list",
|
||||
"preview",
|
||||
"voice_clone",
|
||||
"voice_blend",
|
||||
"streaming",
|
||||
"cancel",
|
||||
})
|
||||
|
||||
for cap in manifest.capabilities:
|
||||
if cap not in known_capabilities:
|
||||
errors.append(f"Unknown capability: '{cap}'")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def _validate_api_version(manifest: PluginManifest) -> list[str]:
|
||||
"""Validate api_version compatibility.
|
||||
|
||||
Args:
|
||||
manifest: The plugin manifest to validate.
|
||||
|
||||
Returns:
|
||||
List of error messages (empty if valid).
|
||||
"""
|
||||
errors: list[str] = []
|
||||
error = _check_api_version_compatibility(manifest.api_version)
|
||||
if error:
|
||||
errors.append(error)
|
||||
return errors
|
||||
|
||||
|
||||
def load_plugin_from_dir(plugin_dir: Path) -> PluginLoadResult:
|
||||
"""Load and validate a plugin from a directory.
|
||||
|
||||
The plugin directory must contain an __init__.py that exports:
|
||||
- PLUGIN_MANIFEST: PluginManifest
|
||||
- MODEL_REQUIREMENTS: list[ModelManifest]
|
||||
- create_engine: Callable
|
||||
|
||||
Args:
|
||||
plugin_dir: Path to the plugin directory.
|
||||
|
||||
Returns:
|
||||
PluginLoadResult with success status and either plugin data or error info.
|
||||
"""
|
||||
plugin_id = plugin_dir.name
|
||||
errors: list[str] = []
|
||||
|
||||
# Check if directory exists
|
||||
if not plugin_dir.exists():
|
||||
return PluginLoadResult(
|
||||
success=False,
|
||||
error=PluginLoadError(
|
||||
plugin_id=plugin_id,
|
||||
path=plugin_dir,
|
||||
errors=(f"Plugin directory does not exist: {plugin_dir}",),
|
||||
),
|
||||
)
|
||||
|
||||
# Check for __init__.py
|
||||
init_file = plugin_dir / "__init__.py"
|
||||
if not init_file.exists():
|
||||
return PluginLoadResult(
|
||||
success=False,
|
||||
error=PluginLoadError(
|
||||
plugin_id=plugin_id,
|
||||
path=plugin_dir,
|
||||
errors=("Missing __init__.py in plugin directory",),
|
||||
),
|
||||
)
|
||||
|
||||
# Import the module
|
||||
module_name = f"abogen.tts_plugin._loaded.{plugin_id}"
|
||||
try:
|
||||
# Remove from cache if already imported (for testing)
|
||||
if module_name in sys.modules:
|
||||
del sys.modules[module_name]
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
module_name, init_file, submodule_search_locations=[]
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
return PluginLoadResult(
|
||||
success=False,
|
||||
error=PluginLoadError(
|
||||
plugin_id=plugin_id,
|
||||
path=plugin_dir,
|
||||
errors=(f"Failed to create module spec for {init_file}",),
|
||||
),
|
||||
)
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
except Exception as e:
|
||||
# Clean up module from sys.modules on import failure
|
||||
if module_name in sys.modules:
|
||||
del sys.modules[module_name]
|
||||
return PluginLoadResult(
|
||||
success=False,
|
||||
error=PluginLoadError(
|
||||
plugin_id=plugin_id,
|
||||
path=plugin_dir,
|
||||
errors=(f"Failed to import plugin module: {e}",),
|
||||
),
|
||||
)
|
||||
|
||||
# Validate manifest
|
||||
manifest_errors = _validate_manifest(module, plugin_dir)
|
||||
errors.extend(manifest_errors)
|
||||
|
||||
# If manifest is valid, perform additional validation
|
||||
manifest = getattr(module, "PLUGIN_MANIFEST", None)
|
||||
if isinstance(manifest, PluginManifest):
|
||||
# Validate api_version
|
||||
api_errors = _validate_api_version(manifest)
|
||||
errors.extend(api_errors)
|
||||
|
||||
# Validate capabilities
|
||||
cap_errors = _validate_capabilities(manifest)
|
||||
errors.extend(cap_errors)
|
||||
|
||||
# Use manifest id if available
|
||||
plugin_id = manifest.id
|
||||
|
||||
# Check if any errors occurred
|
||||
if errors:
|
||||
# Clean up module from sys.modules
|
||||
if module_name in sys.modules:
|
||||
del sys.modules[module_name]
|
||||
|
||||
return PluginLoadResult(
|
||||
success=False,
|
||||
error=PluginLoadError(
|
||||
plugin_id=plugin_id,
|
||||
path=plugin_dir,
|
||||
errors=tuple(errors),
|
||||
),
|
||||
)
|
||||
|
||||
# Get MODEL_REQUIREMENTS
|
||||
model_requirements = tuple(getattr(module, "MODEL_REQUIREMENTS", []))
|
||||
create_engine = getattr(module, "create_engine", None)
|
||||
|
||||
return PluginLoadResult(
|
||||
success=True,
|
||||
manifest=manifest,
|
||||
model_requirements=model_requirements,
|
||||
create_engine=create_engine,
|
||||
module=module,
|
||||
)
|
||||
|
||||
|
||||
def discover_plugins(plugin_dirs: list[Path]) -> list[PluginLoadResult]:
|
||||
"""Discover and load plugins from multiple directories.
|
||||
|
||||
Args:
|
||||
plugin_dirs: List of directories to scan for plugins.
|
||||
|
||||
Returns:
|
||||
List of PluginLoadResult, one per plugin directory found.
|
||||
"""
|
||||
results: list[PluginLoadResult] = []
|
||||
|
||||
for plugin_dir in plugin_dirs:
|
||||
if not plugin_dir.exists():
|
||||
continue
|
||||
|
||||
# Scan for subdirectories (each is a potential plugin)
|
||||
for item in sorted(plugin_dir.iterdir()):
|
||||
if item.is_dir() and not item.name.startswith("."):
|
||||
result = load_plugin_from_dir(item)
|
||||
results.append(result)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def load_plugin(
|
||||
plugin_dir: Path,
|
||||
) -> PluginLoadResult:
|
||||
"""Load a single plugin from a directory.
|
||||
|
||||
This is the main entry point for loading a plugin.
|
||||
|
||||
Args:
|
||||
plugin_dir: Path to the plugin directory.
|
||||
|
||||
Returns:
|
||||
PluginLoadResult with success status and either plugin data or error info.
|
||||
"""
|
||||
return load_plugin_from_dir(plugin_dir)
|
||||
|
||||
+189
-189
@@ -1,189 +1,189 @@
|
||||
"""Plugin manifest types for the TTS Plugin Architecture.
|
||||
|
||||
This module contains static metadata types that describe plugins.
|
||||
These types have no dependencies and are immutable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AudioFormatManifest:
|
||||
"""Manifest describing an audio format.
|
||||
|
||||
Attributes:
|
||||
mime: MIME type of the audio.
|
||||
extension: File extension.
|
||||
"""
|
||||
|
||||
mime: str
|
||||
extension: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EnumOption:
|
||||
"""Manifest describing an enum option for a parameter.
|
||||
|
||||
Attributes:
|
||||
value: The enum value.
|
||||
label: Human-readable label.
|
||||
"""
|
||||
|
||||
value: str
|
||||
label: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParameterManifest:
|
||||
"""Manifest describing a synthesis parameter.
|
||||
|
||||
Attributes:
|
||||
id: Parameter identifier.
|
||||
name: Human-readable name.
|
||||
description: Parameter description.
|
||||
type: Parameter type ("float", "int", "string", "boolean", "enum").
|
||||
default: Default value.
|
||||
min: Minimum value (optional, for numeric types).
|
||||
max: Maximum value (optional, for numeric types).
|
||||
step: Step size (optional, for numeric types).
|
||||
options: Available options (optional, for enum type).
|
||||
unit: Unit of measurement (optional).
|
||||
group: Parameter group (optional).
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
type: str
|
||||
default: Any
|
||||
min: float | None = None
|
||||
max: float | None = None
|
||||
step: float | None = None
|
||||
options: tuple[EnumOption, ...] = field(default_factory=tuple)
|
||||
unit: str | None = None
|
||||
group: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VoiceManifest:
|
||||
"""Manifest describing a voice.
|
||||
|
||||
Attributes:
|
||||
id: Voice identifier.
|
||||
name: Human-readable name.
|
||||
tags: Voice tags (e.g., language, style).
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
tags: tuple[str, ...] = field(default_factory=tuple)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VoiceSourceManifest:
|
||||
"""Manifest describing a voice source.
|
||||
|
||||
Attributes:
|
||||
id: Voice source identifier.
|
||||
name: Human-readable name.
|
||||
type: Source type ("list", "speaker_id", "clone", "blend", "generate", "none").
|
||||
config: Source-specific configuration.
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
type: str
|
||||
config: Any = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EngineManifest:
|
||||
"""Manifest describing engine capabilities.
|
||||
|
||||
Attributes:
|
||||
voiceSources: Available voice sources.
|
||||
parameters: Available synthesis parameters.
|
||||
audioFormats: Supported audio formats.
|
||||
"""
|
||||
|
||||
voiceSources: tuple[VoiceSourceManifest, ...] = field(default_factory=tuple)
|
||||
parameters: tuple[ParameterManifest, ...] = field(default_factory=tuple)
|
||||
audioFormats: tuple[AudioFormatManifest, ...] = field(default_factory=tuple)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GpuRequirement:
|
||||
"""Manifest describing GPU requirements.
|
||||
|
||||
Attributes:
|
||||
required: Whether GPU is required.
|
||||
type: GPU type (e.g., "cuda", "rocm").
|
||||
memory: Required GPU memory in GB.
|
||||
"""
|
||||
|
||||
required: bool = False
|
||||
type: str | None = None
|
||||
memory: float | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RequirementManifest:
|
||||
"""Manifest describing plugin requirements.
|
||||
|
||||
Attributes:
|
||||
gpu: GPU requirements (optional).
|
||||
memory: Required RAM in GB (optional).
|
||||
internet: Whether internet is required (optional).
|
||||
"""
|
||||
|
||||
gpu: GpuRequirement | None = None
|
||||
memory: float | None = None
|
||||
internet: bool | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelManifest:
|
||||
"""Manifest describing a model requirement.
|
||||
|
||||
Attributes:
|
||||
id: Model identifier.
|
||||
name: Human-readable name.
|
||||
size: Model size as string (e.g., "100MB", "2GB").
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
size: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PluginManifest:
|
||||
"""Main manifest for a TTS plugin.
|
||||
|
||||
Attributes:
|
||||
id: Plugin identifier (unique).
|
||||
name: Human-readable name.
|
||||
version: Plugin version.
|
||||
api_version: API version (semver format: MAJOR.MINOR).
|
||||
description: Plugin description.
|
||||
author: Plugin author.
|
||||
capabilities: List of capability identifiers.
|
||||
requires: Plugin requirements.
|
||||
engine: Engine manifest.
|
||||
voices: Optional static voice catalog. None = not declared (use VoiceLister),
|
||||
empty tuple = explicitly no static voices, non-empty = static catalog.
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
version: str
|
||||
api_version: str
|
||||
description: str
|
||||
author: str
|
||||
capabilities: tuple[str, ...] = field(default_factory=tuple)
|
||||
requires: RequirementManifest = field(default_factory=RequirementManifest)
|
||||
engine: EngineManifest = field(default_factory=EngineManifest)
|
||||
voices: tuple[VoiceManifest, ...] | None = None
|
||||
"""Plugin manifest types for the TTS Plugin Architecture.
|
||||
|
||||
This module contains static metadata types that describe plugins.
|
||||
These types have no dependencies and are immutable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AudioFormatManifest:
|
||||
"""Manifest describing an audio format.
|
||||
|
||||
Attributes:
|
||||
mime: MIME type of the audio.
|
||||
extension: File extension.
|
||||
"""
|
||||
|
||||
mime: str
|
||||
extension: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EnumOption:
|
||||
"""Manifest describing an enum option for a parameter.
|
||||
|
||||
Attributes:
|
||||
value: The enum value.
|
||||
label: Human-readable label.
|
||||
"""
|
||||
|
||||
value: str
|
||||
label: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParameterManifest:
|
||||
"""Manifest describing a synthesis parameter.
|
||||
|
||||
Attributes:
|
||||
id: Parameter identifier.
|
||||
name: Human-readable name.
|
||||
description: Parameter description.
|
||||
type: Parameter type ("float", "int", "string", "boolean", "enum").
|
||||
default: Default value.
|
||||
min: Minimum value (optional, for numeric types).
|
||||
max: Maximum value (optional, for numeric types).
|
||||
step: Step size (optional, for numeric types).
|
||||
options: Available options (optional, for enum type).
|
||||
unit: Unit of measurement (optional).
|
||||
group: Parameter group (optional).
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
type: str
|
||||
default: Any
|
||||
min: float | None = None
|
||||
max: float | None = None
|
||||
step: float | None = None
|
||||
options: tuple[EnumOption, ...] = field(default_factory=tuple)
|
||||
unit: str | None = None
|
||||
group: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VoiceManifest:
|
||||
"""Manifest describing a voice.
|
||||
|
||||
Attributes:
|
||||
id: Voice identifier.
|
||||
name: Human-readable name.
|
||||
tags: Voice tags (e.g., language, style).
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
tags: tuple[str, ...] = field(default_factory=tuple)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VoiceSourceManifest:
|
||||
"""Manifest describing a voice source.
|
||||
|
||||
Attributes:
|
||||
id: Voice source identifier.
|
||||
name: Human-readable name.
|
||||
type: Source type ("list", "speaker_id", "clone", "blend", "generate", "none").
|
||||
config: Source-specific configuration.
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
type: str
|
||||
config: Any = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EngineManifest:
|
||||
"""Manifest describing engine capabilities.
|
||||
|
||||
Attributes:
|
||||
voiceSources: Available voice sources.
|
||||
parameters: Available synthesis parameters.
|
||||
audioFormats: Supported audio formats.
|
||||
"""
|
||||
|
||||
voiceSources: tuple[VoiceSourceManifest, ...] = field(default_factory=tuple)
|
||||
parameters: tuple[ParameterManifest, ...] = field(default_factory=tuple)
|
||||
audioFormats: tuple[AudioFormatManifest, ...] = field(default_factory=tuple)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GpuRequirement:
|
||||
"""Manifest describing GPU requirements.
|
||||
|
||||
Attributes:
|
||||
required: Whether GPU is required.
|
||||
type: GPU type (e.g., "cuda", "rocm").
|
||||
memory: Required GPU memory in GB.
|
||||
"""
|
||||
|
||||
required: bool = False
|
||||
type: str | None = None
|
||||
memory: float | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RequirementManifest:
|
||||
"""Manifest describing plugin requirements.
|
||||
|
||||
Attributes:
|
||||
gpu: GPU requirements (optional).
|
||||
memory: Required RAM in GB (optional).
|
||||
internet: Whether internet is required (optional).
|
||||
"""
|
||||
|
||||
gpu: GpuRequirement | None = None
|
||||
memory: float | None = None
|
||||
internet: bool | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelManifest:
|
||||
"""Manifest describing a model requirement.
|
||||
|
||||
Attributes:
|
||||
id: Model identifier.
|
||||
name: Human-readable name.
|
||||
size: Model size as string (e.g., "100MB", "2GB").
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
size: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PluginManifest:
|
||||
"""Main manifest for a TTS plugin.
|
||||
|
||||
Attributes:
|
||||
id: Plugin identifier (unique).
|
||||
name: Human-readable name.
|
||||
version: Plugin version.
|
||||
api_version: API version (semver format: MAJOR.MINOR).
|
||||
description: Plugin description.
|
||||
author: Plugin author.
|
||||
capabilities: List of capability identifiers.
|
||||
requires: Plugin requirements.
|
||||
engine: Engine manifest.
|
||||
voices: Optional static voice catalog. None = not declared (use VoiceLister),
|
||||
empty tuple = explicitly no static voices, non-empty = static catalog.
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
version: str
|
||||
api_version: str
|
||||
description: str
|
||||
author: str
|
||||
capabilities: tuple[str, ...] = field(default_factory=tuple)
|
||||
requires: RequirementManifest = field(default_factory=RequirementManifest)
|
||||
engine: EngineManifest = field(default_factory=EngineManifest)
|
||||
voices: tuple[VoiceManifest, ...] | None = None
|
||||
|
||||
+55
-55
@@ -1,55 +1,55 @@
|
||||
"""Plugin contract for the TTS Plugin Architecture.
|
||||
|
||||
This module defines the plugin contract that all TTS plugins must implement.
|
||||
Each plugin must export:
|
||||
- PLUGIN_MANIFEST: PluginManifest instance
|
||||
- MODEL_REQUIREMENTS: list of ModelManifest instances
|
||||
- create_engine(): Factory function that creates an Engine
|
||||
|
||||
The create_engine() function is the entry point for plugin activation.
|
||||
It must be atomic: succeed fully or raise and clean up.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from abogen.tts_plugin.engine import Engine
|
||||
from abogen.tts_plugin.host_context import HostContext
|
||||
from abogen.tts_plugin.types import EngineConfig
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Plugin(Protocol):
|
||||
"""Protocol defining the plugin contract.
|
||||
|
||||
Every TTS plugin must implement this protocol by exporting:
|
||||
- PLUGIN_MANIFEST: PluginManifest
|
||||
- MODEL_REQUIREMENTS: list[ModelManifest]
|
||||
- create_engine: Callable[[HostContext, Path | None, EngineConfig], Engine]
|
||||
"""
|
||||
|
||||
def create_engine(
|
||||
self,
|
||||
context: HostContext,
|
||||
model_path: Path | None,
|
||||
config: EngineConfig,
|
||||
) -> Engine:
|
||||
"""Create an engine instance.
|
||||
|
||||
This is the factory function that creates an Engine from a plugin.
|
||||
It must be atomic: succeed fully or raise EngineError and clean up.
|
||||
|
||||
Args:
|
||||
context: Host services (config dir, logger, http client).
|
||||
model_path: Resolved model path, or None for cloud/no-model engines.
|
||||
config: Engine initialization settings.
|
||||
|
||||
Returns:
|
||||
A fully initialized Engine instance.
|
||||
|
||||
Raises:
|
||||
EngineError: On failure. Cleans up partially created resources.
|
||||
"""
|
||||
...
|
||||
"""Plugin contract for the TTS Plugin Architecture.
|
||||
|
||||
This module defines the plugin contract that all TTS plugins must implement.
|
||||
Each plugin must export:
|
||||
- PLUGIN_MANIFEST: PluginManifest instance
|
||||
- MODEL_REQUIREMENTS: list of ModelManifest instances
|
||||
- create_engine(): Factory function that creates an Engine
|
||||
|
||||
The create_engine() function is the entry point for plugin activation.
|
||||
It must be atomic: succeed fully or raise and clean up.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from abogen.tts_plugin.engine import Engine
|
||||
from abogen.tts_plugin.host_context import HostContext
|
||||
from abogen.tts_plugin.types import EngineConfig
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Plugin(Protocol):
|
||||
"""Protocol defining the plugin contract.
|
||||
|
||||
Every TTS plugin must implement this protocol by exporting:
|
||||
- PLUGIN_MANIFEST: PluginManifest
|
||||
- MODEL_REQUIREMENTS: list[ModelManifest]
|
||||
- create_engine: Callable[[HostContext, Path | None, EngineConfig], Engine]
|
||||
"""
|
||||
|
||||
def create_engine(
|
||||
self,
|
||||
context: HostContext,
|
||||
model_path: Path | None,
|
||||
config: EngineConfig,
|
||||
) -> Engine:
|
||||
"""Create an engine instance.
|
||||
|
||||
This is the factory function that creates an Engine from a plugin.
|
||||
It must be atomic: succeed fully or raise EngineError and clean up.
|
||||
|
||||
Args:
|
||||
context: Host services (config dir, logger, http client).
|
||||
model_path: Resolved model path, or None for cloud/no-model engines.
|
||||
config: Engine initialization settings.
|
||||
|
||||
Returns:
|
||||
A fully initialized Engine instance.
|
||||
|
||||
Raises:
|
||||
EngineError: On failure. Cleans up partially created resources.
|
||||
"""
|
||||
...
|
||||
|
||||
+153
-153
@@ -1,153 +1,153 @@
|
||||
"""Plugin Manager
|
||||
|
||||
Provides a simple interface for consumers to access TTS engines via the
|
||||
new Plugin Architecture. Discovers, loads, and manages plugins from the
|
||||
plugins directory.
|
||||
|
||||
Usage:
|
||||
from abogen.tts_plugin.plugin_manager import get_plugin_manager
|
||||
|
||||
manager = get_plugin_manager()
|
||||
engine = manager.create_engine("kokoro", lang_code="a", device="cpu")
|
||||
session = engine.create_session()
|
||||
try:
|
||||
result = session.synthesize("Hello world")
|
||||
finally:
|
||||
session.dispose()
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional, Type
|
||||
|
||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
||||
from abogen.tts_plugin.manifest import PluginManifest
|
||||
from abogen.tts_plugin.types import AudioFormat
|
||||
|
||||
|
||||
class PluginManager:
|
||||
"""Manages TTS plugins and provides a simple interface for consumers."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._plugins: Dict[str, dict] = {}
|
||||
self._engines: Dict[str, Engine] = {}
|
||||
self._loaded = False
|
||||
|
||||
def discover(self, plugins_dir: str = "plugins") -> None:
|
||||
"""Discover and load all plugins from the given directory."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
from abogen.tts_plugin.loader import load_plugin_from_dir
|
||||
|
||||
self._plugins.clear()
|
||||
self._engines.clear()
|
||||
|
||||
plugins_path = Path(plugins_dir)
|
||||
if not plugins_path.exists():
|
||||
self._loaded = True
|
||||
return
|
||||
|
||||
for entry in plugins_path.iterdir():
|
||||
if entry.is_dir() and (entry / "__init__.py").exists():
|
||||
try:
|
||||
result = load_plugin_from_dir(entry)
|
||||
if result.success and result.manifest is not None:
|
||||
self._plugins[result.manifest.id] = {
|
||||
"manifest": result.manifest,
|
||||
"create_engine": result.create_engine,
|
||||
"module": result.module,
|
||||
}
|
||||
except Exception as e:
|
||||
# Log error but continue with other plugins
|
||||
print(f"Warning: Failed to load plugin from {entry}: {e}")
|
||||
|
||||
self._loaded = True
|
||||
|
||||
def _ensure_loaded(self) -> None:
|
||||
"""Ensure plugins have been discovered."""
|
||||
if not self._loaded:
|
||||
self.discover()
|
||||
|
||||
def list_plugins(self) -> List[PluginManifest]:
|
||||
"""Return manifests for all loaded plugins."""
|
||||
self._ensure_loaded()
|
||||
return [info["manifest"] for info in self._plugins.values()]
|
||||
|
||||
def get_plugin(self, plugin_id: str) -> Optional[dict]:
|
||||
"""Get plugin info by ID."""
|
||||
self._ensure_loaded()
|
||||
return self._plugins.get(plugin_id)
|
||||
|
||||
def has_plugin(self, plugin_id: str) -> bool:
|
||||
"""Check if a plugin is loaded."""
|
||||
self._ensure_loaded()
|
||||
return plugin_id in self._plugins
|
||||
|
||||
def create_engine(self, plugin_id: str, **kwargs: Any) -> Engine:
|
||||
"""Create an engine instance for the given plugin.
|
||||
|
||||
Args:
|
||||
plugin_id: The plugin identifier (e.g., "kokoro")
|
||||
**kwargs: Arguments passed to the engine constructor
|
||||
|
||||
Returns:
|
||||
An Engine instance
|
||||
|
||||
Raises:
|
||||
KeyError: If plugin_id is not found
|
||||
Exception: If engine creation fails
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
|
||||
if plugin_id not in self._plugins:
|
||||
raise KeyError(f"Plugin not found: {plugin_id}")
|
||||
|
||||
plugin_info = self._plugins[plugin_id]
|
||||
create_engine_func = plugin_info["create_engine"]
|
||||
|
||||
# Create engine using the plugin's factory
|
||||
engine = create_engine_func(**kwargs)
|
||||
return engine
|
||||
|
||||
def get_or_create_engine(self, plugin_id: str, **kwargs: Any) -> Engine:
|
||||
"""Get an existing engine or create a new one.
|
||||
|
||||
Engines are cached by plugin_id. If you need multiple instances
|
||||
with different parameters, use create_engine() directly.
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
|
||||
cache_key = plugin_id
|
||||
if cache_key in self._engines:
|
||||
return self._engines[cache_key]
|
||||
|
||||
engine = self.create_engine(plugin_id, **kwargs)
|
||||
self._engines[cache_key] = engine
|
||||
return engine
|
||||
|
||||
def dispose_all(self) -> None:
|
||||
"""Dispose all cached engines."""
|
||||
for engine in self._engines.values():
|
||||
try:
|
||||
engine.dispose()
|
||||
except Exception:
|
||||
pass # dispose() should never raise
|
||||
self._engines.clear()
|
||||
|
||||
|
||||
# Global singleton
|
||||
_manager: Optional[PluginManager] = None
|
||||
|
||||
|
||||
def get_plugin_manager() -> PluginManager:
|
||||
"""Get the global PluginManager instance."""
|
||||
global _manager
|
||||
if _manager is None:
|
||||
_manager = PluginManager()
|
||||
return _manager
|
||||
|
||||
|
||||
def reset_plugin_manager() -> None:
|
||||
"""Reset the global PluginManager (for testing)."""
|
||||
global _manager
|
||||
if _manager is not None:
|
||||
_manager.dispose_all()
|
||||
_manager = None
|
||||
"""Plugin Manager
|
||||
|
||||
Provides a simple interface for consumers to access TTS engines via the
|
||||
new Plugin Architecture. Discovers, loads, and manages plugins from the
|
||||
plugins directory.
|
||||
|
||||
Usage:
|
||||
from abogen.tts_plugin.plugin_manager import get_plugin_manager
|
||||
|
||||
manager = get_plugin_manager()
|
||||
engine = manager.create_engine("kokoro", lang_code="a", device="cpu")
|
||||
session = engine.create_session()
|
||||
try:
|
||||
result = session.synthesize("Hello world")
|
||||
finally:
|
||||
session.dispose()
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional, Type
|
||||
|
||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
||||
from abogen.tts_plugin.manifest import PluginManifest
|
||||
from abogen.tts_plugin.types import AudioFormat
|
||||
|
||||
|
||||
class PluginManager:
|
||||
"""Manages TTS plugins and provides a simple interface for consumers."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._plugins: Dict[str, dict] = {}
|
||||
self._engines: Dict[str, Engine] = {}
|
||||
self._loaded = False
|
||||
|
||||
def discover(self, plugins_dir: str = "plugins") -> None:
|
||||
"""Discover and load all plugins from the given directory."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
from abogen.tts_plugin.loader import load_plugin_from_dir
|
||||
|
||||
self._plugins.clear()
|
||||
self._engines.clear()
|
||||
|
||||
plugins_path = Path(plugins_dir)
|
||||
if not plugins_path.exists():
|
||||
self._loaded = True
|
||||
return
|
||||
|
||||
for entry in plugins_path.iterdir():
|
||||
if entry.is_dir() and (entry / "__init__.py").exists():
|
||||
try:
|
||||
result = load_plugin_from_dir(entry)
|
||||
if result.success and result.manifest is not None:
|
||||
self._plugins[result.manifest.id] = {
|
||||
"manifest": result.manifest,
|
||||
"create_engine": result.create_engine,
|
||||
"module": result.module,
|
||||
}
|
||||
except Exception as e:
|
||||
# Log error but continue with other plugins
|
||||
print(f"Warning: Failed to load plugin from {entry}: {e}")
|
||||
|
||||
self._loaded = True
|
||||
|
||||
def _ensure_loaded(self) -> None:
|
||||
"""Ensure plugins have been discovered."""
|
||||
if not self._loaded:
|
||||
self.discover()
|
||||
|
||||
def list_plugins(self) -> List[PluginManifest]:
|
||||
"""Return manifests for all loaded plugins."""
|
||||
self._ensure_loaded()
|
||||
return [info["manifest"] for info in self._plugins.values()]
|
||||
|
||||
def get_plugin(self, plugin_id: str) -> Optional[dict]:
|
||||
"""Get plugin info by ID."""
|
||||
self._ensure_loaded()
|
||||
return self._plugins.get(plugin_id)
|
||||
|
||||
def has_plugin(self, plugin_id: str) -> bool:
|
||||
"""Check if a plugin is loaded."""
|
||||
self._ensure_loaded()
|
||||
return plugin_id in self._plugins
|
||||
|
||||
def create_engine(self, plugin_id: str, **kwargs: Any) -> Engine:
|
||||
"""Create an engine instance for the given plugin.
|
||||
|
||||
Args:
|
||||
plugin_id: The plugin identifier (e.g., "kokoro")
|
||||
**kwargs: Arguments passed to the engine constructor
|
||||
|
||||
Returns:
|
||||
An Engine instance
|
||||
|
||||
Raises:
|
||||
KeyError: If plugin_id is not found
|
||||
Exception: If engine creation fails
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
|
||||
if plugin_id not in self._plugins:
|
||||
raise KeyError(f"Plugin not found: {plugin_id}")
|
||||
|
||||
plugin_info = self._plugins[plugin_id]
|
||||
create_engine_func = plugin_info["create_engine"]
|
||||
|
||||
# Create engine using the plugin's factory
|
||||
engine = create_engine_func(**kwargs)
|
||||
return engine
|
||||
|
||||
def get_or_create_engine(self, plugin_id: str, **kwargs: Any) -> Engine:
|
||||
"""Get an existing engine or create a new one.
|
||||
|
||||
Engines are cached by plugin_id. If you need multiple instances
|
||||
with different parameters, use create_engine() directly.
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
|
||||
cache_key = plugin_id
|
||||
if cache_key in self._engines:
|
||||
return self._engines[cache_key]
|
||||
|
||||
engine = self.create_engine(plugin_id, **kwargs)
|
||||
self._engines[cache_key] = engine
|
||||
return engine
|
||||
|
||||
def dispose_all(self) -> None:
|
||||
"""Dispose all cached engines."""
|
||||
for engine in self._engines.values():
|
||||
try:
|
||||
engine.dispose()
|
||||
except Exception:
|
||||
pass # dispose() should never raise
|
||||
self._engines.clear()
|
||||
|
||||
|
||||
# Global singleton
|
||||
_manager: Optional[PluginManager] = None
|
||||
|
||||
|
||||
def get_plugin_manager() -> PluginManager:
|
||||
"""Get the global PluginManager instance."""
|
||||
global _manager
|
||||
if _manager is None:
|
||||
_manager = PluginManager()
|
||||
return _manager
|
||||
|
||||
|
||||
def reset_plugin_manager() -> None:
|
||||
"""Reset the global PluginManager (for testing)."""
|
||||
global _manager
|
||||
if _manager is not None:
|
||||
_manager.dispose_all()
|
||||
_manager = None
|
||||
|
||||
+111
-111
@@ -1,111 +1,111 @@
|
||||
"""Core domain types for the TTS Plugin Architecture.
|
||||
|
||||
This module contains immutable value objects that form the core domain.
|
||||
These types have zero dependencies and are used across the plugin system.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AudioFormat:
|
||||
"""Immutable value object representing an audio format.
|
||||
|
||||
Attributes:
|
||||
mime: MIME type of the audio (e.g., "audio/wav", "audio/mpeg").
|
||||
extension: File extension (e.g., "wav", "mp3").
|
||||
"""
|
||||
|
||||
mime: str
|
||||
extension: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Duration:
|
||||
"""Immutable value object representing a time duration.
|
||||
|
||||
Attributes:
|
||||
seconds: Duration in seconds.
|
||||
"""
|
||||
|
||||
seconds: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VoiceSelection:
|
||||
"""Immutable value object for voice selection. Opaque to engine.
|
||||
|
||||
Attributes:
|
||||
source: Voice source identifier (e.g., "builtin", "clone").
|
||||
key: Voice key within the source.
|
||||
payload: Optional payload for clone/blend sources.
|
||||
"""
|
||||
|
||||
source: str
|
||||
key: str
|
||||
payload: Any = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParameterValues:
|
||||
"""Immutable value object for synthesis parameters. Behaves like Mapping[str, Any].
|
||||
|
||||
Attributes:
|
||||
values: Mapping of parameter names to their values.
|
||||
"""
|
||||
|
||||
values: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SynthesisRequest:
|
||||
"""Immutable value object for a synthesis request.
|
||||
|
||||
Attributes:
|
||||
text: Text to synthesize.
|
||||
voice: Voice selection.
|
||||
parameters: Synthesis parameters.
|
||||
format: Desired audio output format.
|
||||
"""
|
||||
|
||||
text: str
|
||||
voice: VoiceSelection
|
||||
parameters: ParameterValues
|
||||
format: AudioFormat
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SynthesizedAudio:
|
||||
"""Immutable value object for synthesized audio result.
|
||||
|
||||
Attributes:
|
||||
data: Raw audio bytes.
|
||||
format: Audio format of the result.
|
||||
duration: Duration of the audio.
|
||||
"""
|
||||
|
||||
data: bytes
|
||||
format: AudioFormat
|
||||
duration: Duration
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EngineConfig:
|
||||
"""Immutable configuration of an Engine instance.
|
||||
|
||||
Contains parameters that define how a particular Engine instance is
|
||||
created and that remain constant throughout the lifetime of that Engine.
|
||||
|
||||
Plugin implementations may ignore fields that are not applicable to them.
|
||||
|
||||
Attributes:
|
||||
device: Device to use (e.g., "cpu", "cuda:0").
|
||||
lang_code: Language code for the engine (e.g., "a" for Kokoro English).
|
||||
Plugins that do not require a language code ignore this field.
|
||||
"""
|
||||
|
||||
device: str = "cpu"
|
||||
lang_code: str = "a"
|
||||
"""Core domain types for the TTS Plugin Architecture.
|
||||
|
||||
This module contains immutable value objects that form the core domain.
|
||||
These types have zero dependencies and are used across the plugin system.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AudioFormat:
|
||||
"""Immutable value object representing an audio format.
|
||||
|
||||
Attributes:
|
||||
mime: MIME type of the audio (e.g., "audio/wav", "audio/mpeg").
|
||||
extension: File extension (e.g., "wav", "mp3").
|
||||
"""
|
||||
|
||||
mime: str
|
||||
extension: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Duration:
|
||||
"""Immutable value object representing a time duration.
|
||||
|
||||
Attributes:
|
||||
seconds: Duration in seconds.
|
||||
"""
|
||||
|
||||
seconds: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VoiceSelection:
|
||||
"""Immutable value object for voice selection. Opaque to engine.
|
||||
|
||||
Attributes:
|
||||
source: Voice source identifier (e.g., "builtin", "clone").
|
||||
key: Voice key within the source.
|
||||
payload: Optional payload for clone/blend sources.
|
||||
"""
|
||||
|
||||
source: str
|
||||
key: str
|
||||
payload: Any = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParameterValues:
|
||||
"""Immutable value object for synthesis parameters. Behaves like Mapping[str, Any].
|
||||
|
||||
Attributes:
|
||||
values: Mapping of parameter names to their values.
|
||||
"""
|
||||
|
||||
values: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SynthesisRequest:
|
||||
"""Immutable value object for a synthesis request.
|
||||
|
||||
Attributes:
|
||||
text: Text to synthesize.
|
||||
voice: Voice selection.
|
||||
parameters: Synthesis parameters.
|
||||
format: Desired audio output format.
|
||||
"""
|
||||
|
||||
text: str
|
||||
voice: VoiceSelection
|
||||
parameters: ParameterValues
|
||||
format: AudioFormat
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SynthesizedAudio:
|
||||
"""Immutable value object for synthesized audio result.
|
||||
|
||||
Attributes:
|
||||
data: Raw audio bytes.
|
||||
format: Audio format of the result.
|
||||
duration: Duration of the audio.
|
||||
"""
|
||||
|
||||
data: bytes
|
||||
format: AudioFormat
|
||||
duration: Duration
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EngineConfig:
|
||||
"""Immutable configuration of an Engine instance.
|
||||
|
||||
Contains parameters that define how a particular Engine instance is
|
||||
created and that remain constant throughout the lifetime of that Engine.
|
||||
|
||||
Plugin implementations may ignore fields that are not applicable to them.
|
||||
|
||||
Attributes:
|
||||
device: Device to use (e.g., "cpu", "cuda:0").
|
||||
lang_code: Language code for the engine (e.g., "a" for Kokoro English).
|
||||
Plugins that do not require a language code ignore this field.
|
||||
"""
|
||||
|
||||
device: str = "cpu"
|
||||
lang_code: str = "a"
|
||||
|
||||
+235
-235
@@ -1,235 +1,235 @@
|
||||
"""TTS Plugin Architecture — direct utility functions.
|
||||
|
||||
Provides helpers that replace the former compatibility adapter by
|
||||
calling the Plugin Manager directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Iterator
|
||||
|
||||
import numpy as np
|
||||
|
||||
from abogen.tts_plugin.plugin_manager import get_plugin_manager
|
||||
|
||||
|
||||
def get_voices(plugin_id: str) -> tuple[str, ...]:
|
||||
"""Return the voice-id tuple for *plugin_id*.
|
||||
|
||||
Uses the official Plugin Architecture: PluginManager → Engine → VoiceLister.
|
||||
First checks plugin manifest for static voice catalog.
|
||||
"""
|
||||
import logging
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from abogen.tts_plugin.host_context import HostContext
|
||||
from abogen.tts_plugin.types import EngineConfig
|
||||
|
||||
manager = get_plugin_manager()
|
||||
if not manager.has_plugin(plugin_id):
|
||||
return ()
|
||||
|
||||
# Check manifest for static voice catalog
|
||||
plugin_info = manager.get_plugin(plugin_id)
|
||||
if plugin_info is not None:
|
||||
manifest = plugin_info.get("manifest")
|
||||
if manifest is not None and manifest.voices is not None:
|
||||
return tuple(v.id for v in manifest.voices)
|
||||
|
||||
ctx = HostContext(
|
||||
config_dir=Path(tempfile.gettempdir()),
|
||||
logger=logging.getLogger(f"abogen.utils.{plugin_id}"),
|
||||
http_client=type("_StubHttpClient", (), {
|
||||
"get": staticmethod(lambda url, **kw: None),
|
||||
"post": staticmethod(lambda url, **kw: None),
|
||||
})(),
|
||||
)
|
||||
|
||||
try:
|
||||
engine = manager.create_engine(
|
||||
plugin_id,
|
||||
context=ctx,
|
||||
model_path=None,
|
||||
config=EngineConfig(device="cpu"),
|
||||
)
|
||||
except Exception:
|
||||
return ()
|
||||
|
||||
try:
|
||||
from abogen.tts_plugin.capabilities import VoiceLister
|
||||
|
||||
if isinstance(engine, VoiceLister):
|
||||
manifests = engine.listVoices("builtin")
|
||||
return tuple(v.id for v in manifests)
|
||||
return ()
|
||||
except Exception:
|
||||
return ()
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def get_default_voice(plugin_id: str, fallback: str = "") -> str:
|
||||
"""Return the first voice of *plugin_id*, or *fallback*."""
|
||||
voices = get_voices(plugin_id)
|
||||
return voices[0] if voices else fallback
|
||||
|
||||
|
||||
def is_plugin_registered(plugin_id: str) -> bool:
|
||||
"""Check whether *plugin_id* is loaded by the Plugin Manager."""
|
||||
return get_plugin_manager().has_plugin(plugin_id)
|
||||
|
||||
|
||||
def resolve_voice_to_plugin(spec: str, fallback: str = "kokoro") -> str:
|
||||
"""Determine which plugin owns the given voice specification.
|
||||
|
||||
Resolution rules:
|
||||
1. Empty spec -> fallback
|
||||
2. Kokoro formula (contains '*' or '+') -> "kokoro"
|
||||
3. Exact voice-id match against loaded plugins -> plugin id
|
||||
4. Unknown voice -> fallback
|
||||
"""
|
||||
raw = str(spec or "").strip()
|
||||
if not raw:
|
||||
return fallback
|
||||
|
||||
if "*" in raw or "+" in raw:
|
||||
return "kokoro"
|
||||
|
||||
upper = raw.upper()
|
||||
manager = get_plugin_manager()
|
||||
|
||||
for manifest in manager.list_plugins():
|
||||
for voice_source in manifest.engine.voiceSources:
|
||||
if voice_source.type == "list" and isinstance(voice_source.config, dict):
|
||||
try:
|
||||
engine = manager.create_engine(manifest.id)
|
||||
try:
|
||||
if hasattr(engine, "listVoices"):
|
||||
voice_manifests = engine.listVoices(voice_source.id)
|
||||
voice_ids = [v.id.upper() for v in voice_manifests]
|
||||
if upper in voice_ids:
|
||||
return manifest.id
|
||||
finally:
|
||||
engine.dispose()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return fallback
|
||||
|
||||
|
||||
class Pipeline:
|
||||
"""Callable wrapper around Engine / EngineSession.
|
||||
|
||||
Presents the same interface that old callers expect::
|
||||
|
||||
pipeline = create_pipeline("kokoro", lang_code="a", device="cpu")
|
||||
for segment in pipeline(text, voice="af_nova", speed=1.0):
|
||||
audio = segment.audio
|
||||
"""
|
||||
|
||||
def __init__(self, engine: Any, **engine_kwargs: Any) -> None:
|
||||
self._engine = engine
|
||||
self._engine_kwargs = engine_kwargs
|
||||
self._session: Any = None
|
||||
|
||||
def _ensure_session(self) -> Any:
|
||||
if self._session is None:
|
||||
self._session = self._engine.createSession()
|
||||
return self._session
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
text: str,
|
||||
voice: str = "default",
|
||||
speed: float = 1.0,
|
||||
split_pattern: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Iterator[Any]:
|
||||
from abogen.tts_plugin.types import (
|
||||
AudioFormat,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
VoiceSelection,
|
||||
)
|
||||
|
||||
session = self._ensure_session()
|
||||
|
||||
params: dict[str, Any] = {"speed": speed}
|
||||
if split_pattern is not None:
|
||||
params["split_pattern"] = split_pattern
|
||||
params.update(kwargs)
|
||||
|
||||
request = SynthesisRequest(
|
||||
text=text,
|
||||
voice=VoiceSelection(source="builtin", key=voice),
|
||||
parameters=ParameterValues(values=params),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
|
||||
result = session.synthesize(request)
|
||||
audio_array = np.frombuffer(result.data, dtype=np.float32)
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class Segment:
|
||||
graphemes: str
|
||||
audio: np.ndarray
|
||||
|
||||
yield Segment(graphemes=text, audio=audio_array)
|
||||
|
||||
def dispose(self) -> None:
|
||||
if self._session is not None:
|
||||
try:
|
||||
self._session.dispose()
|
||||
except Exception:
|
||||
pass
|
||||
self._session = None
|
||||
|
||||
def __del__(self) -> None:
|
||||
self.dispose()
|
||||
|
||||
|
||||
def create_pipeline(
|
||||
plugin_id: str,
|
||||
*,
|
||||
lang_code: str = "a",
|
||||
device: str = "cpu",
|
||||
) -> Pipeline:
|
||||
"""Create a callable TTS pipeline via the Plugin Architecture.
|
||||
|
||||
Builds a proper HostContext and EngineConfig, then delegates to the
|
||||
PluginManager to create the engine. Returns a :class:`Pipeline` whose
|
||||
``__call__`` interface matches the callable protocol used by consumers.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier (e.g., "kokoro", "supertonic").
|
||||
lang_code: Language code for the engine.
|
||||
device: Device to use (e.g., "cpu", "cuda:0").
|
||||
|
||||
Returns:
|
||||
A callable Pipeline instance.
|
||||
"""
|
||||
import logging
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from abogen.tts_plugin.host_context import HostContext
|
||||
from abogen.tts_plugin.types import EngineConfig
|
||||
|
||||
manager = get_plugin_manager()
|
||||
|
||||
ctx = HostContext(
|
||||
config_dir=Path(tempfile.gettempdir()),
|
||||
logger=logging.getLogger(f"abogen.pipeline.{plugin_id}"),
|
||||
http_client=type("_StubHttpClient", (), {
|
||||
"get": staticmethod(lambda url, **kw: None),
|
||||
"post": staticmethod(lambda url, **kw: None),
|
||||
})(),
|
||||
)
|
||||
|
||||
config = EngineConfig(device=device, lang_code=lang_code)
|
||||
|
||||
engine = manager.create_engine(plugin_id, context=ctx, model_path=None, config=config)
|
||||
return Pipeline(engine)
|
||||
"""TTS Plugin Architecture — direct utility functions.
|
||||
|
||||
Provides helpers that replace the former compatibility adapter by
|
||||
calling the Plugin Manager directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Iterator
|
||||
|
||||
import numpy as np
|
||||
|
||||
from abogen.tts_plugin.plugin_manager import get_plugin_manager
|
||||
|
||||
|
||||
def get_voices(plugin_id: str) -> tuple[str, ...]:
|
||||
"""Return the voice-id tuple for *plugin_id*.
|
||||
|
||||
Uses the official Plugin Architecture: PluginManager → Engine → VoiceLister.
|
||||
First checks plugin manifest for static voice catalog.
|
||||
"""
|
||||
import logging
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from abogen.tts_plugin.host_context import HostContext
|
||||
from abogen.tts_plugin.types import EngineConfig
|
||||
|
||||
manager = get_plugin_manager()
|
||||
if not manager.has_plugin(plugin_id):
|
||||
return ()
|
||||
|
||||
# Check manifest for static voice catalog
|
||||
plugin_info = manager.get_plugin(plugin_id)
|
||||
if plugin_info is not None:
|
||||
manifest = plugin_info.get("manifest")
|
||||
if manifest is not None and manifest.voices is not None:
|
||||
return tuple(v.id for v in manifest.voices)
|
||||
|
||||
ctx = HostContext(
|
||||
config_dir=Path(tempfile.gettempdir()),
|
||||
logger=logging.getLogger(f"abogen.utils.{plugin_id}"),
|
||||
http_client=type("_StubHttpClient", (), {
|
||||
"get": staticmethod(lambda url, **kw: None),
|
||||
"post": staticmethod(lambda url, **kw: None),
|
||||
})(),
|
||||
)
|
||||
|
||||
try:
|
||||
engine = manager.create_engine(
|
||||
plugin_id,
|
||||
context=ctx,
|
||||
model_path=None,
|
||||
config=EngineConfig(device="cpu"),
|
||||
)
|
||||
except Exception:
|
||||
return ()
|
||||
|
||||
try:
|
||||
from abogen.tts_plugin.capabilities import VoiceLister
|
||||
|
||||
if isinstance(engine, VoiceLister):
|
||||
manifests = engine.listVoices("builtin")
|
||||
return tuple(v.id for v in manifests)
|
||||
return ()
|
||||
except Exception:
|
||||
return ()
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def get_default_voice(plugin_id: str, fallback: str = "") -> str:
|
||||
"""Return the first voice of *plugin_id*, or *fallback*."""
|
||||
voices = get_voices(plugin_id)
|
||||
return voices[0] if voices else fallback
|
||||
|
||||
|
||||
def is_plugin_registered(plugin_id: str) -> bool:
|
||||
"""Check whether *plugin_id* is loaded by the Plugin Manager."""
|
||||
return get_plugin_manager().has_plugin(plugin_id)
|
||||
|
||||
|
||||
def resolve_voice_to_plugin(spec: str, fallback: str = "kokoro") -> str:
|
||||
"""Determine which plugin owns the given voice specification.
|
||||
|
||||
Resolution rules:
|
||||
1. Empty spec -> fallback
|
||||
2. Kokoro formula (contains '*' or '+') -> "kokoro"
|
||||
3. Exact voice-id match against loaded plugins -> plugin id
|
||||
4. Unknown voice -> fallback
|
||||
"""
|
||||
raw = str(spec or "").strip()
|
||||
if not raw:
|
||||
return fallback
|
||||
|
||||
if "*" in raw or "+" in raw:
|
||||
return "kokoro"
|
||||
|
||||
upper = raw.upper()
|
||||
manager = get_plugin_manager()
|
||||
|
||||
for manifest in manager.list_plugins():
|
||||
for voice_source in manifest.engine.voiceSources:
|
||||
if voice_source.type == "list" and isinstance(voice_source.config, dict):
|
||||
try:
|
||||
engine = manager.create_engine(manifest.id)
|
||||
try:
|
||||
if hasattr(engine, "listVoices"):
|
||||
voice_manifests = engine.listVoices(voice_source.id)
|
||||
voice_ids = [v.id.upper() for v in voice_manifests]
|
||||
if upper in voice_ids:
|
||||
return manifest.id
|
||||
finally:
|
||||
engine.dispose()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return fallback
|
||||
|
||||
|
||||
class Pipeline:
|
||||
"""Callable wrapper around Engine / EngineSession.
|
||||
|
||||
Presents the same interface that old callers expect::
|
||||
|
||||
pipeline = create_pipeline("kokoro", lang_code="a", device="cpu")
|
||||
for segment in pipeline(text, voice="af_nova", speed=1.0):
|
||||
audio = segment.audio
|
||||
"""
|
||||
|
||||
def __init__(self, engine: Any, **engine_kwargs: Any) -> None:
|
||||
self._engine = engine
|
||||
self._engine_kwargs = engine_kwargs
|
||||
self._session: Any = None
|
||||
|
||||
def _ensure_session(self) -> Any:
|
||||
if self._session is None:
|
||||
self._session = self._engine.createSession()
|
||||
return self._session
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
text: str,
|
||||
voice: str = "default",
|
||||
speed: float = 1.0,
|
||||
split_pattern: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Iterator[Any]:
|
||||
from abogen.tts_plugin.types import (
|
||||
AudioFormat,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
VoiceSelection,
|
||||
)
|
||||
|
||||
session = self._ensure_session()
|
||||
|
||||
params: dict[str, Any] = {"speed": speed}
|
||||
if split_pattern is not None:
|
||||
params["split_pattern"] = split_pattern
|
||||
params.update(kwargs)
|
||||
|
||||
request = SynthesisRequest(
|
||||
text=text,
|
||||
voice=VoiceSelection(source="builtin", key=voice),
|
||||
parameters=ParameterValues(values=params),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
|
||||
result = session.synthesize(request)
|
||||
audio_array = np.frombuffer(result.data, dtype=np.float32)
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class Segment:
|
||||
graphemes: str
|
||||
audio: np.ndarray
|
||||
|
||||
yield Segment(graphemes=text, audio=audio_array)
|
||||
|
||||
def dispose(self) -> None:
|
||||
if self._session is not None:
|
||||
try:
|
||||
self._session.dispose()
|
||||
except Exception:
|
||||
pass
|
||||
self._session = None
|
||||
|
||||
def __del__(self) -> None:
|
||||
self.dispose()
|
||||
|
||||
|
||||
def create_pipeline(
|
||||
plugin_id: str,
|
||||
*,
|
||||
lang_code: str = "a",
|
||||
device: str = "cpu",
|
||||
) -> Pipeline:
|
||||
"""Create a callable TTS pipeline via the Plugin Architecture.
|
||||
|
||||
Builds a proper HostContext and EngineConfig, then delegates to the
|
||||
PluginManager to create the engine. Returns a :class:`Pipeline` whose
|
||||
``__call__`` interface matches the callable protocol used by consumers.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier (e.g., "kokoro", "supertonic").
|
||||
lang_code: Language code for the engine.
|
||||
device: Device to use (e.g., "cpu", "cuda:0").
|
||||
|
||||
Returns:
|
||||
A callable Pipeline instance.
|
||||
"""
|
||||
import logging
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from abogen.tts_plugin.host_context import HostContext
|
||||
from abogen.tts_plugin.types import EngineConfig
|
||||
|
||||
manager = get_plugin_manager()
|
||||
|
||||
ctx = HostContext(
|
||||
config_dir=Path(tempfile.gettempdir()),
|
||||
logger=logging.getLogger(f"abogen.pipeline.{plugin_id}"),
|
||||
http_client=type("_StubHttpClient", (), {
|
||||
"get": staticmethod(lambda url, **kw: None),
|
||||
"post": staticmethod(lambda url, **kw: None),
|
||||
})(),
|
||||
)
|
||||
|
||||
config = EngineConfig(device=device, lang_code=lang_code)
|
||||
|
||||
engine = manager.create_engine(plugin_id, context=ctx, model_path=None, config=config)
|
||||
return Pipeline(engine)
|
||||
|
||||
+548
-548
File diff suppressed because it is too large
Load Diff
+146
-146
@@ -1,146 +1,146 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from typing import Callable, Dict, Iterable, Optional, Set, Tuple
|
||||
|
||||
try: # pragma: no cover - optional dependency guard
|
||||
from huggingface_hub import hf_hub_download # type: ignore
|
||||
from huggingface_hub.utils import LocalEntryNotFoundError # type: ignore
|
||||
except Exception: # pragma: no cover - import fallback
|
||||
hf_hub_download = None # type: ignore[assignment]
|
||||
LocalEntryNotFoundError = None # type: ignore[assignment]
|
||||
|
||||
if LocalEntryNotFoundError is None: # pragma: no cover - fallback for tests
|
||||
|
||||
class LocalEntryNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
from abogen.tts_plugin.utils import get_voices
|
||||
|
||||
_CACHE_LOCK = threading.Lock()
|
||||
_CACHED_VOICES: Set[str] = set()
|
||||
_BOOTSTRAP_LOCK = threading.Lock()
|
||||
_BOOTSTRAPPED = False
|
||||
|
||||
|
||||
def _normalize_targets(voices: Optional[Iterable[str]]) -> Set[str]:
|
||||
kokoro_voices = get_voices("kokoro")
|
||||
if not voices:
|
||||
return set(kokoro_voices)
|
||||
normalized: Set[str] = set()
|
||||
for voice in voices:
|
||||
if not voice:
|
||||
continue
|
||||
voice_id = str(voice).strip()
|
||||
if not voice_id:
|
||||
continue
|
||||
if voice_id in kokoro_voices:
|
||||
normalized.add(voice_id)
|
||||
return normalized
|
||||
|
||||
|
||||
def ensure_voice_assets(
|
||||
voices: Optional[Iterable[str]] = None,
|
||||
*,
|
||||
repo_id: str = "hexgrad/Kokoro-82M",
|
||||
cache_dir: Optional[str] = None,
|
||||
on_progress: Optional[Callable[[str], None]] = None,
|
||||
) -> Tuple[Set[str], Dict[str, str]]:
|
||||
"""Ensure Kokoro voice weight files are present locally.
|
||||
|
||||
Returns a tuple of (downloaded voices, errors) where errors maps the
|
||||
voice id to the underlying exception message.
|
||||
"""
|
||||
|
||||
if hf_hub_download is None:
|
||||
raise RuntimeError("huggingface_hub is required to cache voices")
|
||||
|
||||
effective_cache_dir = cache_dir
|
||||
if effective_cache_dir is None:
|
||||
env_cache_dir = os.environ.get("ABOGEN_VOICE_CACHE_DIR", "").strip()
|
||||
effective_cache_dir = env_cache_dir or None
|
||||
|
||||
targets = _normalize_targets(voices)
|
||||
if not targets:
|
||||
return set(), {}
|
||||
|
||||
with _CACHE_LOCK:
|
||||
missing = [voice for voice in targets if voice not in _CACHED_VOICES]
|
||||
|
||||
downloaded: Set[str] = set()
|
||||
errors: Dict[str, str] = {}
|
||||
|
||||
for voice_id in missing:
|
||||
if on_progress:
|
||||
on_progress(f"Fetching voice asset '{voice_id}'")
|
||||
try:
|
||||
downloaded_flag = _ensure_single_voice_asset(
|
||||
voice_id,
|
||||
repo_id=repo_id,
|
||||
cache_dir=effective_cache_dir,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - network variance
|
||||
errors[voice_id] = str(exc)
|
||||
continue
|
||||
|
||||
if downloaded_flag:
|
||||
downloaded.add(voice_id)
|
||||
with _CACHE_LOCK:
|
||||
_CACHED_VOICES.add(voice_id)
|
||||
|
||||
return downloaded, errors
|
||||
|
||||
|
||||
def bootstrap_voice_cache(
|
||||
voices: Optional[Iterable[str]] = None,
|
||||
*,
|
||||
repo_id: str = "hexgrad/Kokoro-82M",
|
||||
cache_dir: Optional[str] = None,
|
||||
on_progress: Optional[Callable[[str], None]] = None,
|
||||
) -> Tuple[Set[str], Dict[str, str]]:
|
||||
"""Ensure voices are cached once per process.
|
||||
|
||||
Subsequent calls are no-ops and return empty structures.
|
||||
"""
|
||||
|
||||
global _BOOTSTRAPPED
|
||||
with _BOOTSTRAP_LOCK:
|
||||
if _BOOTSTRAPPED:
|
||||
return set(), {}
|
||||
downloaded, errors = ensure_voice_assets(
|
||||
voices,
|
||||
repo_id=repo_id,
|
||||
cache_dir=cache_dir,
|
||||
on_progress=on_progress,
|
||||
)
|
||||
_BOOTSTRAPPED = True
|
||||
return downloaded, errors
|
||||
|
||||
|
||||
def _ensure_single_voice_asset(
|
||||
voice_id: str,
|
||||
*,
|
||||
repo_id: str,
|
||||
cache_dir: Optional[str],
|
||||
) -> bool:
|
||||
if hf_hub_download is None:
|
||||
raise RuntimeError("huggingface_hub is required to cache voices")
|
||||
|
||||
filename = f"voices/{voice_id}.pt"
|
||||
common_kwargs = {
|
||||
"repo_id": repo_id,
|
||||
"filename": filename,
|
||||
}
|
||||
if cache_dir is not None:
|
||||
common_kwargs["cache_dir"] = cache_dir
|
||||
|
||||
try:
|
||||
hf_hub_download(local_files_only=True, **common_kwargs)
|
||||
return False
|
||||
except LocalEntryNotFoundError:
|
||||
pass
|
||||
|
||||
hf_hub_download(resume_download=True, **common_kwargs)
|
||||
return True
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from typing import Callable, Dict, Iterable, Optional, Set, Tuple
|
||||
|
||||
try: # pragma: no cover - optional dependency guard
|
||||
from huggingface_hub import hf_hub_download # type: ignore
|
||||
from huggingface_hub.utils import LocalEntryNotFoundError # type: ignore
|
||||
except Exception: # pragma: no cover - import fallback
|
||||
hf_hub_download = None # type: ignore[assignment]
|
||||
LocalEntryNotFoundError = None # type: ignore[assignment]
|
||||
|
||||
if LocalEntryNotFoundError is None: # pragma: no cover - fallback for tests
|
||||
|
||||
class LocalEntryNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
from abogen.tts_plugin.utils import get_voices
|
||||
|
||||
_CACHE_LOCK = threading.Lock()
|
||||
_CACHED_VOICES: Set[str] = set()
|
||||
_BOOTSTRAP_LOCK = threading.Lock()
|
||||
_BOOTSTRAPPED = False
|
||||
|
||||
|
||||
def _normalize_targets(voices: Optional[Iterable[str]]) -> Set[str]:
|
||||
kokoro_voices = get_voices("kokoro")
|
||||
if not voices:
|
||||
return set(kokoro_voices)
|
||||
normalized: Set[str] = set()
|
||||
for voice in voices:
|
||||
if not voice:
|
||||
continue
|
||||
voice_id = str(voice).strip()
|
||||
if not voice_id:
|
||||
continue
|
||||
if voice_id in kokoro_voices:
|
||||
normalized.add(voice_id)
|
||||
return normalized
|
||||
|
||||
|
||||
def ensure_voice_assets(
|
||||
voices: Optional[Iterable[str]] = None,
|
||||
*,
|
||||
repo_id: str = "hexgrad/Kokoro-82M",
|
||||
cache_dir: Optional[str] = None,
|
||||
on_progress: Optional[Callable[[str], None]] = None,
|
||||
) -> Tuple[Set[str], Dict[str, str]]:
|
||||
"""Ensure Kokoro voice weight files are present locally.
|
||||
|
||||
Returns a tuple of (downloaded voices, errors) where errors maps the
|
||||
voice id to the underlying exception message.
|
||||
"""
|
||||
|
||||
if hf_hub_download is None:
|
||||
raise RuntimeError("huggingface_hub is required to cache voices")
|
||||
|
||||
effective_cache_dir = cache_dir
|
||||
if effective_cache_dir is None:
|
||||
env_cache_dir = os.environ.get("ABOGEN_VOICE_CACHE_DIR", "").strip()
|
||||
effective_cache_dir = env_cache_dir or None
|
||||
|
||||
targets = _normalize_targets(voices)
|
||||
if not targets:
|
||||
return set(), {}
|
||||
|
||||
with _CACHE_LOCK:
|
||||
missing = [voice for voice in targets if voice not in _CACHED_VOICES]
|
||||
|
||||
downloaded: Set[str] = set()
|
||||
errors: Dict[str, str] = {}
|
||||
|
||||
for voice_id in missing:
|
||||
if on_progress:
|
||||
on_progress(f"Fetching voice asset '{voice_id}'")
|
||||
try:
|
||||
downloaded_flag = _ensure_single_voice_asset(
|
||||
voice_id,
|
||||
repo_id=repo_id,
|
||||
cache_dir=effective_cache_dir,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - network variance
|
||||
errors[voice_id] = str(exc)
|
||||
continue
|
||||
|
||||
if downloaded_flag:
|
||||
downloaded.add(voice_id)
|
||||
with _CACHE_LOCK:
|
||||
_CACHED_VOICES.add(voice_id)
|
||||
|
||||
return downloaded, errors
|
||||
|
||||
|
||||
def bootstrap_voice_cache(
|
||||
voices: Optional[Iterable[str]] = None,
|
||||
*,
|
||||
repo_id: str = "hexgrad/Kokoro-82M",
|
||||
cache_dir: Optional[str] = None,
|
||||
on_progress: Optional[Callable[[str], None]] = None,
|
||||
) -> Tuple[Set[str], Dict[str, str]]:
|
||||
"""Ensure voices are cached once per process.
|
||||
|
||||
Subsequent calls are no-ops and return empty structures.
|
||||
"""
|
||||
|
||||
global _BOOTSTRAPPED
|
||||
with _BOOTSTRAP_LOCK:
|
||||
if _BOOTSTRAPPED:
|
||||
return set(), {}
|
||||
downloaded, errors = ensure_voice_assets(
|
||||
voices,
|
||||
repo_id=repo_id,
|
||||
cache_dir=cache_dir,
|
||||
on_progress=on_progress,
|
||||
)
|
||||
_BOOTSTRAPPED = True
|
||||
return downloaded, errors
|
||||
|
||||
|
||||
def _ensure_single_voice_asset(
|
||||
voice_id: str,
|
||||
*,
|
||||
repo_id: str,
|
||||
cache_dir: Optional[str],
|
||||
) -> bool:
|
||||
if hf_hub_download is None:
|
||||
raise RuntimeError("huggingface_hub is required to cache voices")
|
||||
|
||||
filename = f"voices/{voice_id}.pt"
|
||||
common_kwargs = {
|
||||
"repo_id": repo_id,
|
||||
"filename": filename,
|
||||
}
|
||||
if cache_dir is not None:
|
||||
common_kwargs["cache_dir"] = cache_dir
|
||||
|
||||
try:
|
||||
hf_hub_download(local_files_only=True, **common_kwargs)
|
||||
return False
|
||||
except LocalEntryNotFoundError:
|
||||
pass
|
||||
|
||||
hf_hub_download(resume_download=True, **common_kwargs)
|
||||
return True
|
||||
|
||||
+82
-82
@@ -1,82 +1,82 @@
|
||||
import re
|
||||
from typing import List, Tuple
|
||||
|
||||
from abogen.tts_plugin.utils import get_voices
|
||||
|
||||
|
||||
# Calls parsing and loads the voice to gpu or cpu
|
||||
def get_new_voice(pipeline, formula, use_gpu):
|
||||
try:
|
||||
weighted_voice = parse_voice_formula(pipeline, formula)
|
||||
# device = "cuda" if use_gpu else "cpu"
|
||||
# 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.
|
||||
device = "cpu"
|
||||
return weighted_voice.to(device)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to create voice: {str(e)}")
|
||||
|
||||
|
||||
def parse_formula_terms(formula: str) -> List[Tuple[str, float]]:
|
||||
if not formula or not formula.strip():
|
||||
raise ValueError("Empty voice formula")
|
||||
|
||||
terms: List[Tuple[str, float]] = []
|
||||
kokoro_voices = get_voices("kokoro")
|
||||
for segment in formula.split("+"):
|
||||
part = segment.strip()
|
||||
if not part:
|
||||
continue
|
||||
if "*" not in part:
|
||||
raise ValueError("Each component must be in the form voice*weight")
|
||||
voice_name, raw_weight = part.split("*", 1)
|
||||
voice_name = voice_name.strip()
|
||||
if voice_name not in kokoro_voices:
|
||||
raise ValueError(f"Unknown voice: {voice_name}")
|
||||
try:
|
||||
weight = float(raw_weight.strip())
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Invalid weight for {voice_name}") from exc
|
||||
if weight <= 0:
|
||||
raise ValueError(f"Weight for {voice_name} must be positive")
|
||||
terms.append((voice_name, weight))
|
||||
|
||||
if not terms:
|
||||
raise ValueError("Voice weights must sum to a positive value")
|
||||
|
||||
return terms
|
||||
|
||||
|
||||
def parse_voice_formula(pipeline, formula):
|
||||
terms = parse_formula_terms(formula)
|
||||
|
||||
total_weight = sum(weight for _, weight in terms)
|
||||
if total_weight <= 0:
|
||||
raise ValueError("Voice weights must sum to a positive value")
|
||||
|
||||
weighted_sum = None
|
||||
|
||||
for voice_name, weight in terms:
|
||||
normalized_weight = weight / total_weight if total_weight > 0 else weight
|
||||
|
||||
voice_tensor = pipeline.load_single_voice(voice_name)
|
||||
|
||||
if weighted_sum is None:
|
||||
weighted_sum = normalized_weight * voice_tensor
|
||||
else:
|
||||
weighted_sum += normalized_weight * voice_tensor
|
||||
|
||||
if weighted_sum is None:
|
||||
raise ValueError("Voice formula produced no components")
|
||||
|
||||
return weighted_sum
|
||||
|
||||
|
||||
def calculate_sum_from_formula(formula):
|
||||
weights = re.findall(r"\* *([\d.]+)", formula)
|
||||
total_sum = sum(float(weight) for weight in weights)
|
||||
return total_sum
|
||||
|
||||
|
||||
def extract_voice_ids(formula: str) -> List[str]:
|
||||
return [voice for voice, _ in parse_formula_terms(formula)]
|
||||
import re
|
||||
from typing import List, Tuple
|
||||
|
||||
from abogen.tts_plugin.utils import get_voices
|
||||
|
||||
|
||||
# Calls parsing and loads the voice to gpu or cpu
|
||||
def get_new_voice(pipeline, formula, use_gpu):
|
||||
try:
|
||||
weighted_voice = parse_voice_formula(pipeline, formula)
|
||||
# device = "cuda" if use_gpu else "cpu"
|
||||
# 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.
|
||||
device = "cpu"
|
||||
return weighted_voice.to(device)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to create voice: {str(e)}")
|
||||
|
||||
|
||||
def parse_formula_terms(formula: str) -> List[Tuple[str, float]]:
|
||||
if not formula or not formula.strip():
|
||||
raise ValueError("Empty voice formula")
|
||||
|
||||
terms: List[Tuple[str, float]] = []
|
||||
kokoro_voices = get_voices("kokoro")
|
||||
for segment in formula.split("+"):
|
||||
part = segment.strip()
|
||||
if not part:
|
||||
continue
|
||||
if "*" not in part:
|
||||
raise ValueError("Each component must be in the form voice*weight")
|
||||
voice_name, raw_weight = part.split("*", 1)
|
||||
voice_name = voice_name.strip()
|
||||
if voice_name not in kokoro_voices:
|
||||
raise ValueError(f"Unknown voice: {voice_name}")
|
||||
try:
|
||||
weight = float(raw_weight.strip())
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Invalid weight for {voice_name}") from exc
|
||||
if weight <= 0:
|
||||
raise ValueError(f"Weight for {voice_name} must be positive")
|
||||
terms.append((voice_name, weight))
|
||||
|
||||
if not terms:
|
||||
raise ValueError("Voice weights must sum to a positive value")
|
||||
|
||||
return terms
|
||||
|
||||
|
||||
def parse_voice_formula(pipeline, formula):
|
||||
terms = parse_formula_terms(formula)
|
||||
|
||||
total_weight = sum(weight for _, weight in terms)
|
||||
if total_weight <= 0:
|
||||
raise ValueError("Voice weights must sum to a positive value")
|
||||
|
||||
weighted_sum = None
|
||||
|
||||
for voice_name, weight in terms:
|
||||
normalized_weight = weight / total_weight if total_weight > 0 else weight
|
||||
|
||||
voice_tensor = pipeline.load_single_voice(voice_name)
|
||||
|
||||
if weighted_sum is None:
|
||||
weighted_sum = normalized_weight * voice_tensor
|
||||
else:
|
||||
weighted_sum += normalized_weight * voice_tensor
|
||||
|
||||
if weighted_sum is None:
|
||||
raise ValueError("Voice formula produced no components")
|
||||
|
||||
return weighted_sum
|
||||
|
||||
|
||||
def calculate_sum_from_formula(formula):
|
||||
weights = re.findall(r"\* *([\d.]+)", formula)
|
||||
total_sum = sum(float(weight) for weight in weights)
|
||||
return total_sum
|
||||
|
||||
|
||||
def extract_voice_ids(formula: str) -> List[str]:
|
||||
return [voice for voice, _ in parse_formula_terms(formula)]
|
||||
|
||||
+230
-230
@@ -1,230 +1,230 @@
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Dict, Iterable, List, Tuple
|
||||
|
||||
from abogen.tts_plugin.utils import get_voices, is_plugin_registered
|
||||
from abogen.utils import get_user_config_path
|
||||
|
||||
|
||||
def _get_profiles_path():
|
||||
config_path = get_user_config_path()
|
||||
config_dir = os.path.dirname(config_path)
|
||||
return os.path.join(config_dir, "voice_profiles.json")
|
||||
|
||||
|
||||
def load_profiles():
|
||||
"""Load all voice profiles from JSON file."""
|
||||
path = _get_profiles_path()
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
# always expect abogen_voice_profiles wrapper
|
||||
if isinstance(data, dict) and "abogen_voice_profiles" in data:
|
||||
return data["abogen_voice_profiles"]
|
||||
# fallback: treat as profiles dict
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
except Exception:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def save_profiles(profiles):
|
||||
"""Save all voice profiles to JSON file."""
|
||||
path = _get_profiles_path()
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
# always save with abogen_voice_profiles wrapper
|
||||
json.dump({"abogen_voice_profiles": profiles}, f, indent=2)
|
||||
|
||||
|
||||
def delete_profile(name):
|
||||
"""Remove a profile by name."""
|
||||
profiles = load_profiles()
|
||||
if name in profiles:
|
||||
del profiles[name]
|
||||
save_profiles(profiles)
|
||||
|
||||
|
||||
def duplicate_profile(src, dest):
|
||||
"""Duplicate an existing profile."""
|
||||
profiles = load_profiles()
|
||||
if src in profiles and dest:
|
||||
profiles[dest] = profiles[src]
|
||||
save_profiles(profiles)
|
||||
|
||||
|
||||
def export_profiles(export_path):
|
||||
"""Export all profiles to specified JSON file."""
|
||||
profiles = load_profiles()
|
||||
with open(export_path, "w", encoding="utf-8") as f:
|
||||
json.dump({"abogen_voice_profiles": profiles}, f, indent=2)
|
||||
|
||||
|
||||
def serialize_profiles() -> Dict[str, Dict[str, Iterable[Tuple[str, float]]]]:
|
||||
"""Return profiles in canonical dictionary form."""
|
||||
return load_profiles()
|
||||
|
||||
|
||||
def _normalize_supertonic_voice(value: Any) -> str:
|
||||
raw = str(value or "").strip().upper()
|
||||
supertonic_voices = get_voices("supertonic")
|
||||
return raw if raw in supertonic_voices else "M1"
|
||||
|
||||
|
||||
def _coerce_supertonic_steps(value: Any) -> int:
|
||||
try:
|
||||
steps = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 5
|
||||
return max(2, min(15, steps))
|
||||
|
||||
|
||||
def _coerce_supertonic_speed(value: Any) -> float:
|
||||
try:
|
||||
speed = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return 1.0
|
||||
return max(0.7, min(2.0, speed))
|
||||
|
||||
|
||||
def normalize_profile_entry(entry: Any) -> Dict[str, Any]:
|
||||
"""Normalize a stored profile entry.
|
||||
|
||||
Backwards compatible:
|
||||
- Legacy Kokoro-only entries: {language, voices}
|
||||
- New entries: include provider.
|
||||
"""
|
||||
|
||||
if not isinstance(entry, dict):
|
||||
return {}
|
||||
|
||||
provider = str(entry.get("provider") or "kokoro").strip().lower()
|
||||
if not is_plugin_registered(provider):
|
||||
provider = "kokoro"
|
||||
|
||||
language = str(entry.get("language") or "a").strip().lower() or "a"
|
||||
|
||||
if provider == "supertonic":
|
||||
return {
|
||||
"provider": "supertonic",
|
||||
"language": language,
|
||||
"voice": _normalize_supertonic_voice(
|
||||
entry.get("voice") or entry.get("voice_name") or entry.get("name")
|
||||
),
|
||||
"total_steps": _coerce_supertonic_steps(
|
||||
entry.get("total_steps")
|
||||
or entry.get("supertonic_total_steps")
|
||||
or entry.get("quality")
|
||||
),
|
||||
"speed": _coerce_supertonic_speed(
|
||||
entry.get("speed") or entry.get("supertonic_speed")
|
||||
),
|
||||
}
|
||||
|
||||
voices = _normalize_voice_entries(entry.get("voices", []))
|
||||
if not voices:
|
||||
return {}
|
||||
return {
|
||||
"provider": "kokoro",
|
||||
"language": language,
|
||||
"voices": voices,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]:
|
||||
normalized: List[Tuple[str, float]] = []
|
||||
kokoro_voices = get_voices("kokoro")
|
||||
for item in entries or []:
|
||||
if isinstance(item, dict):
|
||||
voice = item.get("id") or item.get("voice")
|
||||
weight = item.get("weight")
|
||||
elif isinstance(item, (list, tuple)) and len(item) >= 2:
|
||||
voice, weight = item[0], item[1]
|
||||
else:
|
||||
continue
|
||||
if voice not in kokoro_voices:
|
||||
continue
|
||||
if weight is None:
|
||||
continue
|
||||
try:
|
||||
weight_val = float(weight)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if weight_val <= 0:
|
||||
continue
|
||||
normalized.append((voice, weight_val))
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]:
|
||||
"""Public helper to normalize voice-weight pairs from arbitrary payloads."""
|
||||
|
||||
return _normalize_voice_entries(entries)
|
||||
|
||||
|
||||
def save_profile(name: str, *, language: str, voices: Iterable) -> None:
|
||||
"""Persist a single profile after validating its data."""
|
||||
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("Profile name is required")
|
||||
|
||||
normalized = _normalize_voice_entries(voices)
|
||||
if not normalized:
|
||||
raise ValueError("At least one voice with a weight above zero is required")
|
||||
|
||||
if not language:
|
||||
language = "a"
|
||||
|
||||
profiles = load_profiles()
|
||||
profiles[name] = {"provider": "kokoro", "language": language, "voices": normalized}
|
||||
save_profiles(profiles)
|
||||
|
||||
|
||||
def remove_profile(name: str) -> None:
|
||||
delete_profile(name)
|
||||
|
||||
|
||||
def import_profiles_data(data: Dict, *, replace_existing: bool = False) -> List[str]:
|
||||
"""Merge profiles from a dictionary structure and persist them.
|
||||
|
||||
Returns the list of profile names that were added or updated.
|
||||
"""
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Invalid profile payload")
|
||||
|
||||
if "abogen_voice_profiles" in data:
|
||||
data = data["abogen_voice_profiles"]
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Invalid profile payload")
|
||||
|
||||
current = load_profiles()
|
||||
updated: List[str] = []
|
||||
for name, entry in data.items():
|
||||
normalized = normalize_profile_entry(entry)
|
||||
if not normalized:
|
||||
continue
|
||||
if name in current and not replace_existing:
|
||||
# skip duplicates unless explicit replacement is requested
|
||||
continue
|
||||
current[name] = normalized
|
||||
updated.append(name)
|
||||
|
||||
if updated:
|
||||
save_profiles(current)
|
||||
return updated
|
||||
|
||||
|
||||
def export_profiles_payload(names: Iterable[str] | None = None) -> Dict[str, Dict]:
|
||||
"""Return profiles limited to the provided names for download/export."""
|
||||
|
||||
profiles = load_profiles()
|
||||
if names is None:
|
||||
subset = profiles
|
||||
else:
|
||||
subset = {name: profiles[name] for name in names if name in profiles}
|
||||
return {"abogen_voice_profiles": subset}
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Dict, Iterable, List, Tuple
|
||||
|
||||
from abogen.tts_plugin.utils import get_voices, is_plugin_registered
|
||||
from abogen.utils import get_user_config_path
|
||||
|
||||
|
||||
def _get_profiles_path():
|
||||
config_path = get_user_config_path()
|
||||
config_dir = os.path.dirname(config_path)
|
||||
return os.path.join(config_dir, "voice_profiles.json")
|
||||
|
||||
|
||||
def load_profiles():
|
||||
"""Load all voice profiles from JSON file."""
|
||||
path = _get_profiles_path()
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
# always expect abogen_voice_profiles wrapper
|
||||
if isinstance(data, dict) and "abogen_voice_profiles" in data:
|
||||
return data["abogen_voice_profiles"]
|
||||
# fallback: treat as profiles dict
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
except Exception:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def save_profiles(profiles):
|
||||
"""Save all voice profiles to JSON file."""
|
||||
path = _get_profiles_path()
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
# always save with abogen_voice_profiles wrapper
|
||||
json.dump({"abogen_voice_profiles": profiles}, f, indent=2)
|
||||
|
||||
|
||||
def delete_profile(name):
|
||||
"""Remove a profile by name."""
|
||||
profiles = load_profiles()
|
||||
if name in profiles:
|
||||
del profiles[name]
|
||||
save_profiles(profiles)
|
||||
|
||||
|
||||
def duplicate_profile(src, dest):
|
||||
"""Duplicate an existing profile."""
|
||||
profiles = load_profiles()
|
||||
if src in profiles and dest:
|
||||
profiles[dest] = profiles[src]
|
||||
save_profiles(profiles)
|
||||
|
||||
|
||||
def export_profiles(export_path):
|
||||
"""Export all profiles to specified JSON file."""
|
||||
profiles = load_profiles()
|
||||
with open(export_path, "w", encoding="utf-8") as f:
|
||||
json.dump({"abogen_voice_profiles": profiles}, f, indent=2)
|
||||
|
||||
|
||||
def serialize_profiles() -> Dict[str, Dict[str, Iterable[Tuple[str, float]]]]:
|
||||
"""Return profiles in canonical dictionary form."""
|
||||
return load_profiles()
|
||||
|
||||
|
||||
def _normalize_supertonic_voice(value: Any) -> str:
|
||||
raw = str(value or "").strip().upper()
|
||||
supertonic_voices = get_voices("supertonic")
|
||||
return raw if raw in supertonic_voices else "M1"
|
||||
|
||||
|
||||
def _coerce_supertonic_steps(value: Any) -> int:
|
||||
try:
|
||||
steps = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 5
|
||||
return max(2, min(15, steps))
|
||||
|
||||
|
||||
def _coerce_supertonic_speed(value: Any) -> float:
|
||||
try:
|
||||
speed = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return 1.0
|
||||
return max(0.7, min(2.0, speed))
|
||||
|
||||
|
||||
def normalize_profile_entry(entry: Any) -> Dict[str, Any]:
|
||||
"""Normalize a stored profile entry.
|
||||
|
||||
Backwards compatible:
|
||||
- Legacy Kokoro-only entries: {language, voices}
|
||||
- New entries: include provider.
|
||||
"""
|
||||
|
||||
if not isinstance(entry, dict):
|
||||
return {}
|
||||
|
||||
provider = str(entry.get("provider") or "kokoro").strip().lower()
|
||||
if not is_plugin_registered(provider):
|
||||
provider = "kokoro"
|
||||
|
||||
language = str(entry.get("language") or "a").strip().lower() or "a"
|
||||
|
||||
if provider == "supertonic":
|
||||
return {
|
||||
"provider": "supertonic",
|
||||
"language": language,
|
||||
"voice": _normalize_supertonic_voice(
|
||||
entry.get("voice") or entry.get("voice_name") or entry.get("name")
|
||||
),
|
||||
"total_steps": _coerce_supertonic_steps(
|
||||
entry.get("total_steps")
|
||||
or entry.get("supertonic_total_steps")
|
||||
or entry.get("quality")
|
||||
),
|
||||
"speed": _coerce_supertonic_speed(
|
||||
entry.get("speed") or entry.get("supertonic_speed")
|
||||
),
|
||||
}
|
||||
|
||||
voices = _normalize_voice_entries(entry.get("voices", []))
|
||||
if not voices:
|
||||
return {}
|
||||
return {
|
||||
"provider": "kokoro",
|
||||
"language": language,
|
||||
"voices": voices,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]:
|
||||
normalized: List[Tuple[str, float]] = []
|
||||
kokoro_voices = get_voices("kokoro")
|
||||
for item in entries or []:
|
||||
if isinstance(item, dict):
|
||||
voice = item.get("id") or item.get("voice")
|
||||
weight = item.get("weight")
|
||||
elif isinstance(item, (list, tuple)) and len(item) >= 2:
|
||||
voice, weight = item[0], item[1]
|
||||
else:
|
||||
continue
|
||||
if voice not in kokoro_voices:
|
||||
continue
|
||||
if weight is None:
|
||||
continue
|
||||
try:
|
||||
weight_val = float(weight)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if weight_val <= 0:
|
||||
continue
|
||||
normalized.append((voice, weight_val))
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]:
|
||||
"""Public helper to normalize voice-weight pairs from arbitrary payloads."""
|
||||
|
||||
return _normalize_voice_entries(entries)
|
||||
|
||||
|
||||
def save_profile(name: str, *, language: str, voices: Iterable) -> None:
|
||||
"""Persist a single profile after validating its data."""
|
||||
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("Profile name is required")
|
||||
|
||||
normalized = _normalize_voice_entries(voices)
|
||||
if not normalized:
|
||||
raise ValueError("At least one voice with a weight above zero is required")
|
||||
|
||||
if not language:
|
||||
language = "a"
|
||||
|
||||
profiles = load_profiles()
|
||||
profiles[name] = {"provider": "kokoro", "language": language, "voices": normalized}
|
||||
save_profiles(profiles)
|
||||
|
||||
|
||||
def remove_profile(name: str) -> None:
|
||||
delete_profile(name)
|
||||
|
||||
|
||||
def import_profiles_data(data: Dict, *, replace_existing: bool = False) -> List[str]:
|
||||
"""Merge profiles from a dictionary structure and persist them.
|
||||
|
||||
Returns the list of profile names that were added or updated.
|
||||
"""
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Invalid profile payload")
|
||||
|
||||
if "abogen_voice_profiles" in data:
|
||||
data = data["abogen_voice_profiles"]
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Invalid profile payload")
|
||||
|
||||
current = load_profiles()
|
||||
updated: List[str] = []
|
||||
for name, entry in data.items():
|
||||
normalized = normalize_profile_entry(entry)
|
||||
if not normalized:
|
||||
continue
|
||||
if name in current and not replace_existing:
|
||||
# skip duplicates unless explicit replacement is requested
|
||||
continue
|
||||
current[name] = normalized
|
||||
updated.append(name)
|
||||
|
||||
if updated:
|
||||
save_profiles(current)
|
||||
return updated
|
||||
|
||||
|
||||
def export_profiles_payload(names: Iterable[str] | None = None) -> Dict[str, Dict]:
|
||||
"""Return profiles limited to the provided names for download/export."""
|
||||
|
||||
profiles = load_profiles()
|
||||
if names is None:
|
||||
subset = profiles
|
||||
else:
|
||||
subset = {name: profiles[name] for name in names if name in profiles}
|
||||
return {"abogen_voice_profiles": subset}
|
||||
|
||||
+254
-254
@@ -1,254 +1,254 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
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.normalization_settings import build_apostrophe_config
|
||||
from abogen.text_extractor import extract_from_path
|
||||
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.tts_plugin.utils import create_pipeline
|
||||
|
||||
|
||||
_MARKER_RE = re.compile(re.escape(MARKER_PREFIX) + r"(?P<code>[A-Z0-9_]+)" + re.escape(MARKER_SUFFIX))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DebugWavArtifact:
|
||||
label: str
|
||||
filename: str
|
||||
code: Optional[str] = None
|
||||
text: Optional[str] = None
|
||||
|
||||
|
||||
def _resolve_voice_setting(value: str) -> tuple[str, Optional[str], Optional[str]]:
|
||||
"""Resolve settings voice strings into a pipeline-ready voice spec.
|
||||
|
||||
Supports "profile:<name>" by converting it into a concrete voice formula.
|
||||
Returns (resolved_voice_spec, profile_name, profile_language).
|
||||
"""
|
||||
|
||||
from abogen.webui.routes.utils.voice import resolve_voice_setting
|
||||
|
||||
return resolve_voice_setting(value)
|
||||
|
||||
|
||||
def _load_pipeline(language: str, use_gpu: bool) -> Any:
|
||||
device = "cpu"
|
||||
if use_gpu:
|
||||
device = _select_device()
|
||||
return create_pipeline("kokoro", lang_code=language, device=device)
|
||||
|
||||
|
||||
def _extract_cases_from_text(text: str) -> List[Tuple[str, str]]:
|
||||
raw = str(text or "")
|
||||
matches = list(_MARKER_RE.finditer(raw))
|
||||
cases: List[Tuple[str, str]] = []
|
||||
if not matches:
|
||||
return cases
|
||||
for idx, match in enumerate(matches):
|
||||
code = match.group("code")
|
||||
start = match.end()
|
||||
end = matches[idx + 1].start() if idx + 1 < len(matches) else len(raw)
|
||||
snippet = raw[start:end]
|
||||
# Keep it small and predictable: collapse whitespace.
|
||||
snippet = " ".join(snippet.strip().split())
|
||||
cases.append((code, snippet))
|
||||
return cases
|
||||
|
||||
|
||||
def _spoken_id(code: str) -> str:
|
||||
# Make IDs pronounceable and stable (avoid reading as a word).
|
||||
out: List[str] = []
|
||||
for ch in str(code or ""):
|
||||
if ch == "_":
|
||||
out.append(" ")
|
||||
elif ch.isalnum():
|
||||
out.append(ch)
|
||||
else:
|
||||
out.append(" ")
|
||||
# Add spaces between alnum to encourage letter-by-letter reading.
|
||||
spaced = " ".join("".join(out).split())
|
||||
return spaced
|
||||
|
||||
|
||||
def run_debug_tts_wavs(
|
||||
*,
|
||||
output_root: Path,
|
||||
settings: Mapping[str, Any],
|
||||
epub_path: Optional[Path] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Generate WAV artifacts for the debug EPUB samples.
|
||||
|
||||
Writes:
|
||||
- overall.wav: concatenation of all samples
|
||||
- case_<CODE>.wav: each sample rendered separately
|
||||
- manifest.json: metadata + file list
|
||||
"""
|
||||
|
||||
output_root = Path(output_root)
|
||||
output_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
run_id = uuid.uuid4().hex
|
||||
run_dir = output_root / "debug" / run_id
|
||||
run_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if epub_path is None:
|
||||
epub_path = run_dir / "abogen_debug_samples.epub"
|
||||
build_debug_epub(epub_path)
|
||||
else:
|
||||
epub_path = 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)
|
||||
cases = _extract_cases_from_text(combined_text)
|
||||
|
||||
# Prefer the canonical sample catalog for text (EPUB extraction may include headings).
|
||||
try:
|
||||
from abogen.debug_tts_samples import DEBUG_TTS_SAMPLES
|
||||
|
||||
sample_text_by_code = {sample.code: sample.text for sample in DEBUG_TTS_SAMPLES}
|
||||
except Exception:
|
||||
sample_text_by_code = {}
|
||||
|
||||
expected = list(iter_expected_codes())
|
||||
found_codes = {code for code, _ in cases}
|
||||
missing = [code for code in expected if code not in found_codes]
|
||||
if missing:
|
||||
raise RuntimeError(f"Debug EPUB missing expected codes: {', '.join(missing)}")
|
||||
|
||||
language = str(settings.get("language") or "a").strip() or "a"
|
||||
# Kokoro's KPipeline expects short language codes like "a" (American English),
|
||||
# but older settings may store ISO-like values such as "en".
|
||||
language_aliases = {
|
||||
"en": "a",
|
||||
"en-us": "a",
|
||||
"en_us": "a",
|
||||
"en-gb": "b",
|
||||
"en_gb": "b",
|
||||
"es": "e",
|
||||
"es-es": "e",
|
||||
"fr": "f",
|
||||
"fr-fr": "f",
|
||||
"hi": "h",
|
||||
"it": "i",
|
||||
"pt": "p",
|
||||
"pt-br": "p",
|
||||
"ja": "j",
|
||||
"jp": "j",
|
||||
"zh": "z",
|
||||
"zh-cn": "z",
|
||||
}
|
||||
language = language_aliases.get(language.lower(), language)
|
||||
voice_spec = str(settings.get("default_voice") or "").strip()
|
||||
use_gpu = bool(settings.get("use_gpu", False))
|
||||
speed = float(settings.get("default_speed", 1.0) or 1.0)
|
||||
|
||||
# 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
|
||||
# doesn't attempt to download a non-existent "voices/profile:<name>.pt".
|
||||
try:
|
||||
resolved_voice, _profile_name, profile_language = _resolve_voice_setting(voice_spec)
|
||||
if resolved_voice:
|
||||
voice_spec = resolved_voice
|
||||
if profile_language:
|
||||
language = str(profile_language).strip() or language
|
||||
except Exception:
|
||||
# Voice profile resolution is best-effort; fall back to raw voice_spec.
|
||||
pass
|
||||
|
||||
# Best-effort voice caching (only for known Kokoro internal voices).
|
||||
voice_ids = _spec_to_voice_ids(voice_spec)
|
||||
if voice_ids:
|
||||
try:
|
||||
ensure_voice_assets(voice_ids)
|
||||
except Exception:
|
||||
# Network / optional dependency variance; debug runner can still proceed.
|
||||
pass
|
||||
|
||||
pipeline = _load_pipeline(language, use_gpu)
|
||||
voice_choice = _resolve_voice(pipeline, voice_spec, use_gpu)
|
||||
|
||||
apostrophe_config = build_apostrophe_config(settings=settings)
|
||||
normalization_settings = dict(settings)
|
||||
|
||||
artifacts: List[DebugWavArtifact] = []
|
||||
|
||||
overall_path = run_dir / "overall.wav"
|
||||
overall_audio: List[np.ndarray] = []
|
||||
|
||||
def synth(text: str, *, apply_normalization: bool = True) -> np.ndarray:
|
||||
normalized = (
|
||||
normalize_for_pipeline(
|
||||
text,
|
||||
config=apostrophe_config,
|
||||
settings=normalization_settings,
|
||||
)
|
||||
if apply_normalization
|
||||
else str(text or "")
|
||||
)
|
||||
parts: List[np.ndarray] = []
|
||||
for segment in pipeline(
|
||||
normalized,
|
||||
voice=voice_choice,
|
||||
speed=speed,
|
||||
split_pattern=SPLIT_PATTERN,
|
||||
):
|
||||
audio = _to_float32(getattr(segment, "audio", None))
|
||||
if audio.size:
|
||||
parts.append(audio)
|
||||
if not parts:
|
||||
return np.zeros(0, dtype="float32")
|
||||
return np.concatenate(parts).astype("float32", copy=False)
|
||||
|
||||
pause_1s = np.zeros(int(1.0 * SAMPLE_RATE), dtype="float32")
|
||||
between_cases = np.zeros(int(0.35 * SAMPLE_RATE), dtype="float32")
|
||||
|
||||
# Per sample
|
||||
for code, snippet in cases:
|
||||
snippet = sample_text_by_code.get(code, snippet)
|
||||
if not snippet:
|
||||
continue
|
||||
id_audio = synth(_spoken_id(code), apply_normalization=False)
|
||||
text_audio = synth(snippet, apply_normalization=True)
|
||||
audio = np.concatenate([id_audio, pause_1s, text_audio]).astype("float32", copy=False)
|
||||
filename = f"case_{code}.wav"
|
||||
path = run_dir / filename
|
||||
# Write float32 PCM WAV.
|
||||
import soundfile as sf
|
||||
|
||||
sf.write(path, audio, SAMPLE_RATE, subtype="FLOAT")
|
||||
artifacts.append(DebugWavArtifact(label=f"{code}", filename=filename, code=code, text=snippet))
|
||||
overall_audio.append(audio)
|
||||
overall_audio.append(between_cases)
|
||||
|
||||
# Overall
|
||||
if overall_audio:
|
||||
combined = np.concatenate(overall_audio).astype("float32", copy=False)
|
||||
else:
|
||||
combined = np.zeros(0, dtype="float32")
|
||||
import soundfile as sf
|
||||
|
||||
sf.write(overall_path, combined, SAMPLE_RATE, subtype="FLOAT")
|
||||
artifacts.insert(0, DebugWavArtifact(label="Overall", filename="overall.wav", code=None, text=None))
|
||||
|
||||
manifest = {
|
||||
"run_id": run_id,
|
||||
"epub": str(epub_path),
|
||||
"artifacts": [artifact.__dict__ for artifact in artifacts],
|
||||
"sample_rate": SAMPLE_RATE,
|
||||
}
|
||||
(run_dir / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
||||
try:
|
||||
pipeline.dispose()
|
||||
except Exception:
|
||||
pass
|
||||
return manifest
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
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.normalization_settings import build_apostrophe_config
|
||||
from abogen.text_extractor import extract_from_path
|
||||
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.tts_plugin.utils import create_pipeline
|
||||
|
||||
|
||||
_MARKER_RE = re.compile(re.escape(MARKER_PREFIX) + r"(?P<code>[A-Z0-9_]+)" + re.escape(MARKER_SUFFIX))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DebugWavArtifact:
|
||||
label: str
|
||||
filename: str
|
||||
code: Optional[str] = None
|
||||
text: Optional[str] = None
|
||||
|
||||
|
||||
def _resolve_voice_setting(value: str) -> tuple[str, Optional[str], Optional[str]]:
|
||||
"""Resolve settings voice strings into a pipeline-ready voice spec.
|
||||
|
||||
Supports "profile:<name>" by converting it into a concrete voice formula.
|
||||
Returns (resolved_voice_spec, profile_name, profile_language).
|
||||
"""
|
||||
|
||||
from abogen.webui.routes.utils.voice import resolve_voice_setting
|
||||
|
||||
return resolve_voice_setting(value)
|
||||
|
||||
|
||||
def _load_pipeline(language: str, use_gpu: bool) -> Any:
|
||||
device = "cpu"
|
||||
if use_gpu:
|
||||
device = _select_device()
|
||||
return create_pipeline("kokoro", lang_code=language, device=device)
|
||||
|
||||
|
||||
def _extract_cases_from_text(text: str) -> List[Tuple[str, str]]:
|
||||
raw = str(text or "")
|
||||
matches = list(_MARKER_RE.finditer(raw))
|
||||
cases: List[Tuple[str, str]] = []
|
||||
if not matches:
|
||||
return cases
|
||||
for idx, match in enumerate(matches):
|
||||
code = match.group("code")
|
||||
start = match.end()
|
||||
end = matches[idx + 1].start() if idx + 1 < len(matches) else len(raw)
|
||||
snippet = raw[start:end]
|
||||
# Keep it small and predictable: collapse whitespace.
|
||||
snippet = " ".join(snippet.strip().split())
|
||||
cases.append((code, snippet))
|
||||
return cases
|
||||
|
||||
|
||||
def _spoken_id(code: str) -> str:
|
||||
# Make IDs pronounceable and stable (avoid reading as a word).
|
||||
out: List[str] = []
|
||||
for ch in str(code or ""):
|
||||
if ch == "_":
|
||||
out.append(" ")
|
||||
elif ch.isalnum():
|
||||
out.append(ch)
|
||||
else:
|
||||
out.append(" ")
|
||||
# Add spaces between alnum to encourage letter-by-letter reading.
|
||||
spaced = " ".join("".join(out).split())
|
||||
return spaced
|
||||
|
||||
|
||||
def run_debug_tts_wavs(
|
||||
*,
|
||||
output_root: Path,
|
||||
settings: Mapping[str, Any],
|
||||
epub_path: Optional[Path] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Generate WAV artifacts for the debug EPUB samples.
|
||||
|
||||
Writes:
|
||||
- overall.wav: concatenation of all samples
|
||||
- case_<CODE>.wav: each sample rendered separately
|
||||
- manifest.json: metadata + file list
|
||||
"""
|
||||
|
||||
output_root = Path(output_root)
|
||||
output_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
run_id = uuid.uuid4().hex
|
||||
run_dir = output_root / "debug" / run_id
|
||||
run_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if epub_path is None:
|
||||
epub_path = run_dir / "abogen_debug_samples.epub"
|
||||
build_debug_epub(epub_path)
|
||||
else:
|
||||
epub_path = 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)
|
||||
cases = _extract_cases_from_text(combined_text)
|
||||
|
||||
# Prefer the canonical sample catalog for text (EPUB extraction may include headings).
|
||||
try:
|
||||
from abogen.debug_tts_samples import DEBUG_TTS_SAMPLES
|
||||
|
||||
sample_text_by_code = {sample.code: sample.text for sample in DEBUG_TTS_SAMPLES}
|
||||
except Exception:
|
||||
sample_text_by_code = {}
|
||||
|
||||
expected = list(iter_expected_codes())
|
||||
found_codes = {code for code, _ in cases}
|
||||
missing = [code for code in expected if code not in found_codes]
|
||||
if missing:
|
||||
raise RuntimeError(f"Debug EPUB missing expected codes: {', '.join(missing)}")
|
||||
|
||||
language = str(settings.get("language") or "a").strip() or "a"
|
||||
# Kokoro's KPipeline expects short language codes like "a" (American English),
|
||||
# but older settings may store ISO-like values such as "en".
|
||||
language_aliases = {
|
||||
"en": "a",
|
||||
"en-us": "a",
|
||||
"en_us": "a",
|
||||
"en-gb": "b",
|
||||
"en_gb": "b",
|
||||
"es": "e",
|
||||
"es-es": "e",
|
||||
"fr": "f",
|
||||
"fr-fr": "f",
|
||||
"hi": "h",
|
||||
"it": "i",
|
||||
"pt": "p",
|
||||
"pt-br": "p",
|
||||
"ja": "j",
|
||||
"jp": "j",
|
||||
"zh": "z",
|
||||
"zh-cn": "z",
|
||||
}
|
||||
language = language_aliases.get(language.lower(), language)
|
||||
voice_spec = str(settings.get("default_voice") or "").strip()
|
||||
use_gpu = bool(settings.get("use_gpu", False))
|
||||
speed = float(settings.get("default_speed", 1.0) or 1.0)
|
||||
|
||||
# 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
|
||||
# doesn't attempt to download a non-existent "voices/profile:<name>.pt".
|
||||
try:
|
||||
resolved_voice, _profile_name, profile_language = _resolve_voice_setting(voice_spec)
|
||||
if resolved_voice:
|
||||
voice_spec = resolved_voice
|
||||
if profile_language:
|
||||
language = str(profile_language).strip() or language
|
||||
except Exception:
|
||||
# Voice profile resolution is best-effort; fall back to raw voice_spec.
|
||||
pass
|
||||
|
||||
# Best-effort voice caching (only for known Kokoro internal voices).
|
||||
voice_ids = _spec_to_voice_ids(voice_spec)
|
||||
if voice_ids:
|
||||
try:
|
||||
ensure_voice_assets(voice_ids)
|
||||
except Exception:
|
||||
# Network / optional dependency variance; debug runner can still proceed.
|
||||
pass
|
||||
|
||||
pipeline = _load_pipeline(language, use_gpu)
|
||||
voice_choice = _resolve_voice(pipeline, voice_spec, use_gpu)
|
||||
|
||||
apostrophe_config = build_apostrophe_config(settings=settings)
|
||||
normalization_settings = dict(settings)
|
||||
|
||||
artifacts: List[DebugWavArtifact] = []
|
||||
|
||||
overall_path = run_dir / "overall.wav"
|
||||
overall_audio: List[np.ndarray] = []
|
||||
|
||||
def synth(text: str, *, apply_normalization: bool = True) -> np.ndarray:
|
||||
normalized = (
|
||||
normalize_for_pipeline(
|
||||
text,
|
||||
config=apostrophe_config,
|
||||
settings=normalization_settings,
|
||||
)
|
||||
if apply_normalization
|
||||
else str(text or "")
|
||||
)
|
||||
parts: List[np.ndarray] = []
|
||||
for segment in pipeline(
|
||||
normalized,
|
||||
voice=voice_choice,
|
||||
speed=speed,
|
||||
split_pattern=SPLIT_PATTERN,
|
||||
):
|
||||
audio = _to_float32(getattr(segment, "audio", None))
|
||||
if audio.size:
|
||||
parts.append(audio)
|
||||
if not parts:
|
||||
return np.zeros(0, dtype="float32")
|
||||
return np.concatenate(parts).astype("float32", copy=False)
|
||||
|
||||
pause_1s = np.zeros(int(1.0 * SAMPLE_RATE), dtype="float32")
|
||||
between_cases = np.zeros(int(0.35 * SAMPLE_RATE), dtype="float32")
|
||||
|
||||
# Per sample
|
||||
for code, snippet in cases:
|
||||
snippet = sample_text_by_code.get(code, snippet)
|
||||
if not snippet:
|
||||
continue
|
||||
id_audio = synth(_spoken_id(code), apply_normalization=False)
|
||||
text_audio = synth(snippet, apply_normalization=True)
|
||||
audio = np.concatenate([id_audio, pause_1s, text_audio]).astype("float32", copy=False)
|
||||
filename = f"case_{code}.wav"
|
||||
path = run_dir / filename
|
||||
# Write float32 PCM WAV.
|
||||
import soundfile as sf
|
||||
|
||||
sf.write(path, audio, SAMPLE_RATE, subtype="FLOAT")
|
||||
artifacts.append(DebugWavArtifact(label=f"{code}", filename=filename, code=code, text=snippet))
|
||||
overall_audio.append(audio)
|
||||
overall_audio.append(between_cases)
|
||||
|
||||
# Overall
|
||||
if overall_audio:
|
||||
combined = np.concatenate(overall_audio).astype("float32", copy=False)
|
||||
else:
|
||||
combined = np.zeros(0, dtype="float32")
|
||||
import soundfile as sf
|
||||
|
||||
sf.write(overall_path, combined, SAMPLE_RATE, subtype="FLOAT")
|
||||
artifacts.insert(0, DebugWavArtifact(label="Overall", filename="overall.wav", code=None, text=None))
|
||||
|
||||
manifest = {
|
||||
"run_id": run_id,
|
||||
"epub": str(epub_path),
|
||||
"artifacts": [artifact.__dict__ for artifact in artifacts],
|
||||
"sample_rate": SAMPLE_RATE,
|
||||
}
|
||||
(run_dir / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
||||
try:
|
||||
pipeline.dispose()
|
||||
except Exception:
|
||||
pass
|
||||
return manifest
|
||||
|
||||
+681
-681
File diff suppressed because it is too large
Load Diff
+1098
-1098
File diff suppressed because it is too large
Load Diff
@@ -1,245 +1,245 @@
|
||||
import io
|
||||
import threading
|
||||
from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
from flask import current_app, send_file
|
||||
from flask.typing import ResponseReturnValue
|
||||
|
||||
|
||||
SPLIT_PATTERN = r"\n+"
|
||||
SAMPLE_RATE = 24000
|
||||
|
||||
_preview_pipelines: Dict[Tuple[str, str], Any] = {}
|
||||
_preview_pipeline_lock = threading.Lock()
|
||||
|
||||
|
||||
def clear_preview_pipelines() -> None:
|
||||
"""Dispose all cached preview pipelines and clear the cache."""
|
||||
with _preview_pipeline_lock:
|
||||
for pipeline in _preview_pipelines.values():
|
||||
try:
|
||||
pipeline.dispose()
|
||||
except Exception:
|
||||
pass
|
||||
_preview_pipelines.clear()
|
||||
|
||||
|
||||
def _select_device() -> str:
|
||||
import platform
|
||||
|
||||
try:
|
||||
import torch # type: ignore[import-not-found]
|
||||
except Exception:
|
||||
return "cpu"
|
||||
|
||||
system = platform.system()
|
||||
if system == "Darwin" and platform.processor() == "arm":
|
||||
try:
|
||||
if torch.backends.mps.is_available():
|
||||
return "mps"
|
||||
except Exception:
|
||||
pass
|
||||
return "cpu"
|
||||
|
||||
try:
|
||||
if torch.cuda.is_available():
|
||||
return "cuda"
|
||||
except Exception:
|
||||
pass
|
||||
return "cpu"
|
||||
|
||||
|
||||
def _resolve_pipeline(language: str, use_gpu: bool) -> Tuple[Any, bool]:
|
||||
devices: List[str] = ["cpu"]
|
||||
if use_gpu:
|
||||
preferred = _select_device()
|
||||
if preferred != "cpu":
|
||||
devices.insert(0, preferred)
|
||||
|
||||
last_error: Optional[Exception] = None
|
||||
for device in devices:
|
||||
try:
|
||||
return get_preview_pipeline(language, device), device != "cpu"
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
|
||||
raise RuntimeError("Preview pipeline is unavailable") from last_error
|
||||
|
||||
|
||||
def _to_float32(audio_segment) -> np.ndarray:
|
||||
if audio_segment is None:
|
||||
return np.zeros(0, dtype="float32")
|
||||
|
||||
tensor = audio_segment
|
||||
if hasattr(tensor, "detach"):
|
||||
tensor = tensor.detach()
|
||||
if hasattr(tensor, "cpu"):
|
||||
try:
|
||||
tensor = tensor.cpu()
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(tensor, "numpy"):
|
||||
return np.asarray(tensor.numpy(), dtype="float32").reshape(-1)
|
||||
return np.asarray(tensor, dtype="float32").reshape(-1)
|
||||
|
||||
def get_preview_pipeline(language: str, device: str) -> Any:
|
||||
key = (language, device)
|
||||
with _preview_pipeline_lock:
|
||||
pipeline = _preview_pipelines.get(key)
|
||||
if pipeline is not None:
|
||||
return pipeline
|
||||
from abogen.tts_plugin.utils import create_pipeline
|
||||
|
||||
pipeline = create_pipeline("kokoro", lang_code=language, device=device)
|
||||
_preview_pipelines[key] = pipeline
|
||||
return pipeline
|
||||
|
||||
def generate_preview_audio(
|
||||
text: str,
|
||||
voice_spec: str,
|
||||
language: str,
|
||||
speed: float,
|
||||
use_gpu: bool,
|
||||
tts_provider: str = "kokoro",
|
||||
supertonic_total_steps: int = 5,
|
||||
max_seconds: float = 8.0,
|
||||
pronunciation_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
||||
manual_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
||||
speakers: Optional[Mapping[str, Any]] = None,
|
||||
) -> bytes:
|
||||
if not text.strip():
|
||||
raise ValueError("Preview text is required")
|
||||
|
||||
provider = (tts_provider or "kokoro").strip().lower()
|
||||
|
||||
# Apply pronunciation/manual overrides first so tokens like `Unfu*k` still match
|
||||
# before any downstream normalization potentially strips punctuation.
|
||||
source_text = text
|
||||
if pronunciation_overrides or manual_overrides or speakers:
|
||||
try:
|
||||
from abogen.webui import conversion_runner as runner
|
||||
|
||||
class _PreviewJob:
|
||||
def __init__(self):
|
||||
self.language = language
|
||||
self.voice = voice_spec
|
||||
self.speakers = speakers
|
||||
self.manual_overrides = list(manual_overrides or [])
|
||||
self.pronunciation_overrides = list(pronunciation_overrides or [])
|
||||
|
||||
job = _PreviewJob()
|
||||
merged = runner._merge_pronunciation_overrides(job)
|
||||
rules = runner._compile_pronunciation_rules(merged)
|
||||
source_text = runner._apply_pronunciation_rules(source_text, rules)
|
||||
except Exception:
|
||||
current_app.logger.exception("Preview override application failed; using raw text")
|
||||
source_text = text
|
||||
|
||||
normalized_text = source_text
|
||||
if provider != "supertonic":
|
||||
try:
|
||||
from abogen.kokoro_text_normalization import normalize_for_pipeline
|
||||
|
||||
normalized_text = normalize_for_pipeline(source_text)
|
||||
except Exception:
|
||||
current_app.logger.exception("Preview normalization failed; using raw text")
|
||||
normalized_text = source_text
|
||||
|
||||
if provider == "supertonic":
|
||||
from abogen.tts_plugin.utils import create_pipeline
|
||||
|
||||
pipeline = create_pipeline("supertonic")
|
||||
segments = pipeline(
|
||||
normalized_text,
|
||||
voice=voice_spec,
|
||||
speed=speed,
|
||||
split_pattern=SPLIT_PATTERN,
|
||||
total_steps=supertonic_total_steps,
|
||||
)
|
||||
else:
|
||||
pipeline, pipeline_uses_gpu = _resolve_pipeline(language, use_gpu)
|
||||
if pipeline is None:
|
||||
raise RuntimeError("Preview pipeline is unavailable")
|
||||
|
||||
voice_choice: Any = voice_spec
|
||||
if voice_spec and "*" in voice_spec:
|
||||
from abogen.voice_formulas import get_new_voice
|
||||
|
||||
voice_choice = get_new_voice(pipeline, voice_spec, pipeline_uses_gpu)
|
||||
|
||||
segments = pipeline(
|
||||
normalized_text,
|
||||
voice=voice_choice,
|
||||
speed=speed,
|
||||
split_pattern=SPLIT_PATTERN,
|
||||
)
|
||||
|
||||
audio_chunks: List[np.ndarray] = []
|
||||
accumulated = 0
|
||||
max_samples = int(max(1.0, max_seconds) * SAMPLE_RATE)
|
||||
|
||||
for segment in segments:
|
||||
graphemes = getattr(segment, "graphemes", "").strip()
|
||||
if not graphemes:
|
||||
continue
|
||||
audio = _to_float32(getattr(segment, "audio", None))
|
||||
if audio.size == 0:
|
||||
continue
|
||||
remaining = max_samples - accumulated
|
||||
if remaining <= 0:
|
||||
break
|
||||
if audio.shape[0] > remaining:
|
||||
audio = audio[:remaining]
|
||||
audio_chunks.append(audio)
|
||||
accumulated += audio.shape[0]
|
||||
if accumulated >= max_samples:
|
||||
break
|
||||
|
||||
if not audio_chunks:
|
||||
raise RuntimeError("Preview could not be generated")
|
||||
|
||||
audio_data = np.concatenate(audio_chunks)
|
||||
buffer = io.BytesIO()
|
||||
sf.write(buffer, audio_data, SAMPLE_RATE, format="WAV")
|
||||
return buffer.getvalue()
|
||||
|
||||
def synthesize_preview(
|
||||
text: str,
|
||||
voice_spec: str,
|
||||
language: str,
|
||||
speed: float,
|
||||
use_gpu: bool,
|
||||
tts_provider: str = "kokoro",
|
||||
supertonic_total_steps: int = 5,
|
||||
max_seconds: float = 8.0,
|
||||
pronunciation_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
||||
manual_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
||||
speakers: Optional[Mapping[str, Any]] = None,
|
||||
) -> ResponseReturnValue:
|
||||
try:
|
||||
audio_bytes = generate_preview_audio(
|
||||
text=text,
|
||||
voice_spec=voice_spec,
|
||||
language=language,
|
||||
speed=speed,
|
||||
use_gpu=use_gpu,
|
||||
tts_provider=tts_provider,
|
||||
supertonic_total_steps=supertonic_total_steps,
|
||||
max_seconds=max_seconds,
|
||||
pronunciation_overrides=pronunciation_overrides,
|
||||
manual_overrides=manual_overrides,
|
||||
speakers=speakers,
|
||||
)
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
buffer = io.BytesIO(audio_bytes)
|
||||
response = send_file(
|
||||
buffer,
|
||||
mimetype="audio/wav",
|
||||
as_attachment=False,
|
||||
download_name="speaker_preview.wav",
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
import io
|
||||
import threading
|
||||
from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
from flask import current_app, send_file
|
||||
from flask.typing import ResponseReturnValue
|
||||
|
||||
|
||||
SPLIT_PATTERN = r"\n+"
|
||||
SAMPLE_RATE = 24000
|
||||
|
||||
_preview_pipelines: Dict[Tuple[str, str], Any] = {}
|
||||
_preview_pipeline_lock = threading.Lock()
|
||||
|
||||
|
||||
def clear_preview_pipelines() -> None:
|
||||
"""Dispose all cached preview pipelines and clear the cache."""
|
||||
with _preview_pipeline_lock:
|
||||
for pipeline in _preview_pipelines.values():
|
||||
try:
|
||||
pipeline.dispose()
|
||||
except Exception:
|
||||
pass
|
||||
_preview_pipelines.clear()
|
||||
|
||||
|
||||
def _select_device() -> str:
|
||||
import platform
|
||||
|
||||
try:
|
||||
import torch # type: ignore[import-not-found]
|
||||
except Exception:
|
||||
return "cpu"
|
||||
|
||||
system = platform.system()
|
||||
if system == "Darwin" and platform.processor() == "arm":
|
||||
try:
|
||||
if torch.backends.mps.is_available():
|
||||
return "mps"
|
||||
except Exception:
|
||||
pass
|
||||
return "cpu"
|
||||
|
||||
try:
|
||||
if torch.cuda.is_available():
|
||||
return "cuda"
|
||||
except Exception:
|
||||
pass
|
||||
return "cpu"
|
||||
|
||||
|
||||
def _resolve_pipeline(language: str, use_gpu: bool) -> Tuple[Any, bool]:
|
||||
devices: List[str] = ["cpu"]
|
||||
if use_gpu:
|
||||
preferred = _select_device()
|
||||
if preferred != "cpu":
|
||||
devices.insert(0, preferred)
|
||||
|
||||
last_error: Optional[Exception] = None
|
||||
for device in devices:
|
||||
try:
|
||||
return get_preview_pipeline(language, device), device != "cpu"
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
|
||||
raise RuntimeError("Preview pipeline is unavailable") from last_error
|
||||
|
||||
|
||||
def _to_float32(audio_segment) -> np.ndarray:
|
||||
if audio_segment is None:
|
||||
return np.zeros(0, dtype="float32")
|
||||
|
||||
tensor = audio_segment
|
||||
if hasattr(tensor, "detach"):
|
||||
tensor = tensor.detach()
|
||||
if hasattr(tensor, "cpu"):
|
||||
try:
|
||||
tensor = tensor.cpu()
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(tensor, "numpy"):
|
||||
return np.asarray(tensor.numpy(), dtype="float32").reshape(-1)
|
||||
return np.asarray(tensor, dtype="float32").reshape(-1)
|
||||
|
||||
def get_preview_pipeline(language: str, device: str) -> Any:
|
||||
key = (language, device)
|
||||
with _preview_pipeline_lock:
|
||||
pipeline = _preview_pipelines.get(key)
|
||||
if pipeline is not None:
|
||||
return pipeline
|
||||
from abogen.tts_plugin.utils import create_pipeline
|
||||
|
||||
pipeline = create_pipeline("kokoro", lang_code=language, device=device)
|
||||
_preview_pipelines[key] = pipeline
|
||||
return pipeline
|
||||
|
||||
def generate_preview_audio(
|
||||
text: str,
|
||||
voice_spec: str,
|
||||
language: str,
|
||||
speed: float,
|
||||
use_gpu: bool,
|
||||
tts_provider: str = "kokoro",
|
||||
supertonic_total_steps: int = 5,
|
||||
max_seconds: float = 8.0,
|
||||
pronunciation_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
||||
manual_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
||||
speakers: Optional[Mapping[str, Any]] = None,
|
||||
) -> bytes:
|
||||
if not text.strip():
|
||||
raise ValueError("Preview text is required")
|
||||
|
||||
provider = (tts_provider or "kokoro").strip().lower()
|
||||
|
||||
# Apply pronunciation/manual overrides first so tokens like `Unfu*k` still match
|
||||
# before any downstream normalization potentially strips punctuation.
|
||||
source_text = text
|
||||
if pronunciation_overrides or manual_overrides or speakers:
|
||||
try:
|
||||
from abogen.webui import conversion_runner as runner
|
||||
|
||||
class _PreviewJob:
|
||||
def __init__(self):
|
||||
self.language = language
|
||||
self.voice = voice_spec
|
||||
self.speakers = speakers
|
||||
self.manual_overrides = list(manual_overrides or [])
|
||||
self.pronunciation_overrides = list(pronunciation_overrides or [])
|
||||
|
||||
job = _PreviewJob()
|
||||
merged = runner._merge_pronunciation_overrides(job)
|
||||
rules = runner._compile_pronunciation_rules(merged)
|
||||
source_text = runner._apply_pronunciation_rules(source_text, rules)
|
||||
except Exception:
|
||||
current_app.logger.exception("Preview override application failed; using raw text")
|
||||
source_text = text
|
||||
|
||||
normalized_text = source_text
|
||||
if provider != "supertonic":
|
||||
try:
|
||||
from abogen.kokoro_text_normalization import normalize_for_pipeline
|
||||
|
||||
normalized_text = normalize_for_pipeline(source_text)
|
||||
except Exception:
|
||||
current_app.logger.exception("Preview normalization failed; using raw text")
|
||||
normalized_text = source_text
|
||||
|
||||
if provider == "supertonic":
|
||||
from abogen.tts_plugin.utils import create_pipeline
|
||||
|
||||
pipeline = create_pipeline("supertonic")
|
||||
segments = pipeline(
|
||||
normalized_text,
|
||||
voice=voice_spec,
|
||||
speed=speed,
|
||||
split_pattern=SPLIT_PATTERN,
|
||||
total_steps=supertonic_total_steps,
|
||||
)
|
||||
else:
|
||||
pipeline, pipeline_uses_gpu = _resolve_pipeline(language, use_gpu)
|
||||
if pipeline is None:
|
||||
raise RuntimeError("Preview pipeline is unavailable")
|
||||
|
||||
voice_choice: Any = voice_spec
|
||||
if voice_spec and "*" in voice_spec:
|
||||
from abogen.voice_formulas import get_new_voice
|
||||
|
||||
voice_choice = get_new_voice(pipeline, voice_spec, pipeline_uses_gpu)
|
||||
|
||||
segments = pipeline(
|
||||
normalized_text,
|
||||
voice=voice_choice,
|
||||
speed=speed,
|
||||
split_pattern=SPLIT_PATTERN,
|
||||
)
|
||||
|
||||
audio_chunks: List[np.ndarray] = []
|
||||
accumulated = 0
|
||||
max_samples = int(max(1.0, max_seconds) * SAMPLE_RATE)
|
||||
|
||||
for segment in segments:
|
||||
graphemes = getattr(segment, "graphemes", "").strip()
|
||||
if not graphemes:
|
||||
continue
|
||||
audio = _to_float32(getattr(segment, "audio", None))
|
||||
if audio.size == 0:
|
||||
continue
|
||||
remaining = max_samples - accumulated
|
||||
if remaining <= 0:
|
||||
break
|
||||
if audio.shape[0] > remaining:
|
||||
audio = audio[:remaining]
|
||||
audio_chunks.append(audio)
|
||||
accumulated += audio.shape[0]
|
||||
if accumulated >= max_samples:
|
||||
break
|
||||
|
||||
if not audio_chunks:
|
||||
raise RuntimeError("Preview could not be generated")
|
||||
|
||||
audio_data = np.concatenate(audio_chunks)
|
||||
buffer = io.BytesIO()
|
||||
sf.write(buffer, audio_data, SAMPLE_RATE, format="WAV")
|
||||
return buffer.getvalue()
|
||||
|
||||
def synthesize_preview(
|
||||
text: str,
|
||||
voice_spec: str,
|
||||
language: str,
|
||||
speed: float,
|
||||
use_gpu: bool,
|
||||
tts_provider: str = "kokoro",
|
||||
supertonic_total_steps: int = 5,
|
||||
max_seconds: float = 8.0,
|
||||
pronunciation_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
||||
manual_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
||||
speakers: Optional[Mapping[str, Any]] = None,
|
||||
) -> ResponseReturnValue:
|
||||
try:
|
||||
audio_bytes = generate_preview_audio(
|
||||
text=text,
|
||||
voice_spec=voice_spec,
|
||||
language=language,
|
||||
speed=speed,
|
||||
use_gpu=use_gpu,
|
||||
tts_provider=tts_provider,
|
||||
supertonic_total_steps=supertonic_total_steps,
|
||||
max_seconds=max_seconds,
|
||||
pronunciation_overrides=pronunciation_overrides,
|
||||
manual_overrides=manual_overrides,
|
||||
speakers=speakers,
|
||||
)
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
buffer = io.BytesIO(audio_bytes)
|
||||
response = send_file(
|
||||
buffer,
|
||||
mimetype="audio/wav",
|
||||
as_attachment=False,
|
||||
download_name="speaker_preview.wav",
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+808
-808
File diff suppressed because it is too large
Load Diff
+177
-177
@@ -1,178 +1,178 @@
|
||||
"""Kokoro TTS Plugin for the TTS Plugin Architecture.
|
||||
|
||||
This plugin provides a Kokoro-based TTS engine that implements the
|
||||
Plugin API contract. It wraps the existing Kokoro backend in the
|
||||
new Engine/EngineSession architecture.
|
||||
|
||||
Exports:
|
||||
- PLUGIN_MANIFEST: PluginManifest
|
||||
- MODEL_REQUIREMENTS: list[ModelManifest]
|
||||
- create_engine: Factory function
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from abogen.tts_plugin.engine import Engine
|
||||
from abogen.tts_plugin.host_context import HostContext
|
||||
from abogen.tts_plugin.manifest import (
|
||||
AudioFormatManifest,
|
||||
EngineManifest,
|
||||
ModelManifest,
|
||||
ParameterManifest,
|
||||
PluginManifest,
|
||||
RequirementManifest,
|
||||
VoiceManifest,
|
||||
VoiceSourceManifest,
|
||||
"""Kokoro TTS Plugin for the TTS Plugin Architecture.
|
||||
|
||||
This plugin provides a Kokoro-based TTS engine that implements the
|
||||
Plugin API contract. It wraps the existing Kokoro backend in the
|
||||
new Engine/EngineSession architecture.
|
||||
|
||||
Exports:
|
||||
- PLUGIN_MANIFEST: PluginManifest
|
||||
- MODEL_REQUIREMENTS: list[ModelManifest]
|
||||
- create_engine: Factory function
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from abogen.tts_plugin.engine import Engine
|
||||
from abogen.tts_plugin.host_context import HostContext
|
||||
from abogen.tts_plugin.manifest import (
|
||||
AudioFormatManifest,
|
||||
EngineManifest,
|
||||
ModelManifest,
|
||||
ParameterManifest,
|
||||
PluginManifest,
|
||||
RequirementManifest,
|
||||
VoiceManifest,
|
||||
VoiceSourceManifest,
|
||||
)
|
||||
from abogen.tts_plugin.types import EngineConfig
|
||||
|
||||
from .engine import KokoroEngine
|
||||
|
||||
|
||||
def _load_kpipeline() -> Any:
|
||||
"""Lazy-load Kokoro dependencies."""
|
||||
from kokoro import KPipeline # type: ignore[import-not-found]
|
||||
return KPipeline
|
||||
|
||||
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="kokoro",
|
||||
name="Kokoro",
|
||||
version="0.9.4",
|
||||
api_version="1.0",
|
||||
description="Kokoro TTS engine - high quality multilingual text-to-speech",
|
||||
author="Kokoro Team",
|
||||
capabilities=("voice_list",),
|
||||
requires=RequirementManifest(
|
||||
internet=False,
|
||||
),
|
||||
engine=EngineManifest(
|
||||
voiceSources=(
|
||||
VoiceSourceManifest(
|
||||
id="builtin",
|
||||
name="Built-in Voices",
|
||||
type="list",
|
||||
config={"voices": "See listVoices()"},
|
||||
),
|
||||
),
|
||||
parameters=(
|
||||
ParameterManifest(
|
||||
id="speed",
|
||||
name="Speed",
|
||||
description="Speech speed multiplier",
|
||||
type="float",
|
||||
default=1.0,
|
||||
min=0.5,
|
||||
max=2.0,
|
||||
step=0.1,
|
||||
),
|
||||
),
|
||||
audioFormats=(
|
||||
AudioFormatManifest(mime="audio/wav", extension="wav"),
|
||||
),
|
||||
),
|
||||
voices=(
|
||||
VoiceManifest(id="af_alloy", name="Alloy", tags=("en", "female")),
|
||||
VoiceManifest(id="af_aoede", name="Aoede", tags=("en", "female")),
|
||||
VoiceManifest(id="af_bella", name="Bella", tags=("en", "female")),
|
||||
VoiceManifest(id="af_heart", name="Heart", tags=("en", "female")),
|
||||
VoiceManifest(id="af_jessica", name="Jessica", tags=("en", "female")),
|
||||
VoiceManifest(id="af_kore", name="Kore", tags=("en", "female")),
|
||||
VoiceManifest(id="af_nicole", name="Nicole", tags=("en", "female")),
|
||||
VoiceManifest(id="af_nova", name="Nova", tags=("en", "female")),
|
||||
VoiceManifest(id="af_river", name="River", tags=("en", "female")),
|
||||
VoiceManifest(id="af_sarah", name="Sarah", tags=("en", "female")),
|
||||
VoiceManifest(id="af_sky", name="Sky", tags=("en", "female")),
|
||||
VoiceManifest(id="am_adam", name="Adam", tags=("en", "male")),
|
||||
VoiceManifest(id="am_echo", name="Echo", tags=("en", "male")),
|
||||
VoiceManifest(id="am_eric", name="Eric", tags=("en", "male")),
|
||||
VoiceManifest(id="am_fenrir", name="Fenrir", tags=("en", "male")),
|
||||
VoiceManifest(id="am_liam", name="Liam", tags=("en", "male")),
|
||||
VoiceManifest(id="am_michael", name="Michael", tags=("en", "male")),
|
||||
VoiceManifest(id="am_onyx", name="Onyx", tags=("en", "male")),
|
||||
VoiceManifest(id="am_puck", name="Puck", tags=("en", "male")),
|
||||
VoiceManifest(id="am_santa", name="Santa", tags=("en", "male")),
|
||||
VoiceManifest(id="bf_alice", name="Alice", tags=("en", "female")),
|
||||
VoiceManifest(id="bf_emma", name="Emma", tags=("en", "female")),
|
||||
VoiceManifest(id="bf_isabella", name="Isabella", tags=("en", "female")),
|
||||
VoiceManifest(id="bf_lily", name="Lily", tags=("en", "female")),
|
||||
VoiceManifest(id="bm_daniel", name="Daniel", tags=("en", "male")),
|
||||
VoiceManifest(id="bm_fable", name="Fable", tags=("en", "male")),
|
||||
VoiceManifest(id="bm_george", name="George", tags=("en", "male")),
|
||||
VoiceManifest(id="bm_lewis", name="Lewis", tags=("en", "male")),
|
||||
VoiceManifest(id="ef_dora", name="Dora", tags=("es", "female")),
|
||||
VoiceManifest(id="em_alex", name="Alex", tags=("es", "male")),
|
||||
VoiceManifest(id="em_santa", name="Santa", tags=("es", "male")),
|
||||
VoiceManifest(id="ff_siwis", name="Siwis", tags=("fr", "female")),
|
||||
VoiceManifest(id="hf_alpha", name="Alpha", tags=("hi", "female")),
|
||||
VoiceManifest(id="hf_beta", name="Beta", tags=("hi", "female")),
|
||||
VoiceManifest(id="hm_omega", name="Omega", tags=("hi", "male")),
|
||||
VoiceManifest(id="hm_psi", name="Psi", tags=("hi", "male")),
|
||||
VoiceManifest(id="if_sara", name="Sara", tags=("it", "female")),
|
||||
VoiceManifest(id="im_nicola", name="Nicola", tags=("it", "male")),
|
||||
VoiceManifest(id="jf_alpha", name="Alpha", tags=("ja", "female")),
|
||||
VoiceManifest(id="jf_gongitsune", name="Gongitsune", tags=("ja", "female")),
|
||||
VoiceManifest(id="jf_nezumi", name="Nezumi", tags=("ja", "female")),
|
||||
VoiceManifest(id="jf_tebukuro", name="Tebukuro", tags=("ja", "female")),
|
||||
VoiceManifest(id="jm_kumo", name="Kumo", tags=("ja", "male")),
|
||||
VoiceManifest(id="pf_dora", name="Dora", tags=("pt", "female")),
|
||||
VoiceManifest(id="pm_alex", name="Alex", tags=("pt", "male")),
|
||||
VoiceManifest(id="pm_santa", name="Santa", tags=("pt", "male")),
|
||||
VoiceManifest(id="zf_xiaobei", name="Xiaobei", tags=("zh", "female")),
|
||||
VoiceManifest(id="zf_xiaoni", name="Xiaoni", tags=("zh", "female")),
|
||||
VoiceManifest(id="zf_xiaoxiao", name="Xiaoxiao", tags=("zh", "female")),
|
||||
VoiceManifest(id="zf_xiaoyi", name="Xiaoyi", tags=("zh", "female")),
|
||||
VoiceManifest(id="zm_yunjian", name="Yunjian", tags=("zh", "male")),
|
||||
VoiceManifest(id="zm_yunxi", name="Yunxi", tags=("zh", "male")),
|
||||
VoiceManifest(id="zm_yunxia", name="Yunxia", tags=("zh", "female")),
|
||||
VoiceManifest(id="zm_yunyang", name="Yunyang", tags=("zh", "male")),
|
||||
),
|
||||
)
|
||||
|
||||
MODEL_REQUIREMENTS: list[ModelManifest] = []
|
||||
|
||||
|
||||
def create_engine(
|
||||
context: HostContext,
|
||||
model_path: Path | None,
|
||||
config: EngineConfig,
|
||||
) -> Engine:
|
||||
"""Create a Kokoro engine instance.
|
||||
|
||||
This function is the plugin entry point. It must be atomic:
|
||||
succeed fully or raise EngineError and clean up.
|
||||
|
||||
Args:
|
||||
context: Host services (config dir, logger, http client).
|
||||
model_path: Resolved model path, or None for default.
|
||||
config: Engine initialization settings (device, etc.).
|
||||
|
||||
Returns:
|
||||
A fully initialized KokoroEngine instance.
|
||||
|
||||
Raises:
|
||||
EngineError: On failure. Cleans up partially created resources.
|
||||
"""
|
||||
try:
|
||||
KPipeline = _load_kpipeline()
|
||||
|
||||
# Determine repo_id from model_path or use default
|
||||
repo_id = "hexgrad/Kokoro-82M"
|
||||
if model_path is not None:
|
||||
# If a specific model path is provided, use it as repo_id
|
||||
repo_id = str(model_path)
|
||||
|
||||
pipeline = KPipeline(
|
||||
lang_code=config.lang_code,
|
||||
repo_id=repo_id,
|
||||
device=config.device,
|
||||
)
|
||||
|
||||
engine = KokoroEngine(pipeline)
|
||||
return engine
|
||||
except Exception as e:
|
||||
from abogen.tts_plugin.errors import EngineError as EngineErrorClass
|
||||
raise EngineErrorClass(f"Failed to create Kokoro engine: {e}") from e
|
||||
from abogen.tts_plugin.types import EngineConfig
|
||||
|
||||
from .engine import KokoroEngine
|
||||
|
||||
|
||||
def _load_kpipeline() -> Any:
|
||||
"""Lazy-load Kokoro dependencies."""
|
||||
from kokoro import KPipeline # type: ignore[import-not-found]
|
||||
return KPipeline
|
||||
|
||||
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="kokoro",
|
||||
name="Kokoro",
|
||||
version="0.9.4",
|
||||
api_version="1.0",
|
||||
description="Kokoro TTS engine - high quality multilingual text-to-speech",
|
||||
author="Kokoro Team",
|
||||
capabilities=("voice_list",),
|
||||
requires=RequirementManifest(
|
||||
internet=False,
|
||||
),
|
||||
engine=EngineManifest(
|
||||
voiceSources=(
|
||||
VoiceSourceManifest(
|
||||
id="builtin",
|
||||
name="Built-in Voices",
|
||||
type="list",
|
||||
config={"voices": "See listVoices()"},
|
||||
),
|
||||
),
|
||||
parameters=(
|
||||
ParameterManifest(
|
||||
id="speed",
|
||||
name="Speed",
|
||||
description="Speech speed multiplier",
|
||||
type="float",
|
||||
default=1.0,
|
||||
min=0.5,
|
||||
max=2.0,
|
||||
step=0.1,
|
||||
),
|
||||
),
|
||||
audioFormats=(
|
||||
AudioFormatManifest(mime="audio/wav", extension="wav"),
|
||||
),
|
||||
),
|
||||
voices=(
|
||||
VoiceManifest(id="af_alloy", name="Alloy", tags=("en", "female")),
|
||||
VoiceManifest(id="af_aoede", name="Aoede", tags=("en", "female")),
|
||||
VoiceManifest(id="af_bella", name="Bella", tags=("en", "female")),
|
||||
VoiceManifest(id="af_heart", name="Heart", tags=("en", "female")),
|
||||
VoiceManifest(id="af_jessica", name="Jessica", tags=("en", "female")),
|
||||
VoiceManifest(id="af_kore", name="Kore", tags=("en", "female")),
|
||||
VoiceManifest(id="af_nicole", name="Nicole", tags=("en", "female")),
|
||||
VoiceManifest(id="af_nova", name="Nova", tags=("en", "female")),
|
||||
VoiceManifest(id="af_river", name="River", tags=("en", "female")),
|
||||
VoiceManifest(id="af_sarah", name="Sarah", tags=("en", "female")),
|
||||
VoiceManifest(id="af_sky", name="Sky", tags=("en", "female")),
|
||||
VoiceManifest(id="am_adam", name="Adam", tags=("en", "male")),
|
||||
VoiceManifest(id="am_echo", name="Echo", tags=("en", "male")),
|
||||
VoiceManifest(id="am_eric", name="Eric", tags=("en", "male")),
|
||||
VoiceManifest(id="am_fenrir", name="Fenrir", tags=("en", "male")),
|
||||
VoiceManifest(id="am_liam", name="Liam", tags=("en", "male")),
|
||||
VoiceManifest(id="am_michael", name="Michael", tags=("en", "male")),
|
||||
VoiceManifest(id="am_onyx", name="Onyx", tags=("en", "male")),
|
||||
VoiceManifest(id="am_puck", name="Puck", tags=("en", "male")),
|
||||
VoiceManifest(id="am_santa", name="Santa", tags=("en", "male")),
|
||||
VoiceManifest(id="bf_alice", name="Alice", tags=("en", "female")),
|
||||
VoiceManifest(id="bf_emma", name="Emma", tags=("en", "female")),
|
||||
VoiceManifest(id="bf_isabella", name="Isabella", tags=("en", "female")),
|
||||
VoiceManifest(id="bf_lily", name="Lily", tags=("en", "female")),
|
||||
VoiceManifest(id="bm_daniel", name="Daniel", tags=("en", "male")),
|
||||
VoiceManifest(id="bm_fable", name="Fable", tags=("en", "male")),
|
||||
VoiceManifest(id="bm_george", name="George", tags=("en", "male")),
|
||||
VoiceManifest(id="bm_lewis", name="Lewis", tags=("en", "male")),
|
||||
VoiceManifest(id="ef_dora", name="Dora", tags=("es", "female")),
|
||||
VoiceManifest(id="em_alex", name="Alex", tags=("es", "male")),
|
||||
VoiceManifest(id="em_santa", name="Santa", tags=("es", "male")),
|
||||
VoiceManifest(id="ff_siwis", name="Siwis", tags=("fr", "female")),
|
||||
VoiceManifest(id="hf_alpha", name="Alpha", tags=("hi", "female")),
|
||||
VoiceManifest(id="hf_beta", name="Beta", tags=("hi", "female")),
|
||||
VoiceManifest(id="hm_omega", name="Omega", tags=("hi", "male")),
|
||||
VoiceManifest(id="hm_psi", name="Psi", tags=("hi", "male")),
|
||||
VoiceManifest(id="if_sara", name="Sara", tags=("it", "female")),
|
||||
VoiceManifest(id="im_nicola", name="Nicola", tags=("it", "male")),
|
||||
VoiceManifest(id="jf_alpha", name="Alpha", tags=("ja", "female")),
|
||||
VoiceManifest(id="jf_gongitsune", name="Gongitsune", tags=("ja", "female")),
|
||||
VoiceManifest(id="jf_nezumi", name="Nezumi", tags=("ja", "female")),
|
||||
VoiceManifest(id="jf_tebukuro", name="Tebukuro", tags=("ja", "female")),
|
||||
VoiceManifest(id="jm_kumo", name="Kumo", tags=("ja", "male")),
|
||||
VoiceManifest(id="pf_dora", name="Dora", tags=("pt", "female")),
|
||||
VoiceManifest(id="pm_alex", name="Alex", tags=("pt", "male")),
|
||||
VoiceManifest(id="pm_santa", name="Santa", tags=("pt", "male")),
|
||||
VoiceManifest(id="zf_xiaobei", name="Xiaobei", tags=("zh", "female")),
|
||||
VoiceManifest(id="zf_xiaoni", name="Xiaoni", tags=("zh", "female")),
|
||||
VoiceManifest(id="zf_xiaoxiao", name="Xiaoxiao", tags=("zh", "female")),
|
||||
VoiceManifest(id="zf_xiaoyi", name="Xiaoyi", tags=("zh", "female")),
|
||||
VoiceManifest(id="zm_yunjian", name="Yunjian", tags=("zh", "male")),
|
||||
VoiceManifest(id="zm_yunxi", name="Yunxi", tags=("zh", "male")),
|
||||
VoiceManifest(id="zm_yunxia", name="Yunxia", tags=("zh", "female")),
|
||||
VoiceManifest(id="zm_yunyang", name="Yunyang", tags=("zh", "male")),
|
||||
),
|
||||
)
|
||||
|
||||
MODEL_REQUIREMENTS: list[ModelManifest] = []
|
||||
|
||||
|
||||
def create_engine(
|
||||
context: HostContext,
|
||||
model_path: Path | None,
|
||||
config: EngineConfig,
|
||||
) -> Engine:
|
||||
"""Create a Kokoro engine instance.
|
||||
|
||||
This function is the plugin entry point. It must be atomic:
|
||||
succeed fully or raise EngineError and clean up.
|
||||
|
||||
Args:
|
||||
context: Host services (config dir, logger, http client).
|
||||
model_path: Resolved model path, or None for default.
|
||||
config: Engine initialization settings (device, etc.).
|
||||
|
||||
Returns:
|
||||
A fully initialized KokoroEngine instance.
|
||||
|
||||
Raises:
|
||||
EngineError: On failure. Cleans up partially created resources.
|
||||
"""
|
||||
try:
|
||||
KPipeline = _load_kpipeline()
|
||||
|
||||
# Determine repo_id from model_path or use default
|
||||
repo_id = "hexgrad/Kokoro-82M"
|
||||
if model_path is not None:
|
||||
# If a specific model path is provided, use it as repo_id
|
||||
repo_id = str(model_path)
|
||||
|
||||
pipeline = KPipeline(
|
||||
lang_code=config.lang_code,
|
||||
repo_id=repo_id,
|
||||
device=config.device,
|
||||
)
|
||||
|
||||
engine = KokoroEngine(pipeline)
|
||||
return engine
|
||||
except Exception as e:
|
||||
from abogen.tts_plugin.errors import EngineError as EngineErrorClass
|
||||
raise EngineErrorClass(f"Failed to create Kokoro engine: {e}") from e
|
||||
|
||||
+118
-118
@@ -1,118 +1,118 @@
|
||||
"""Kokoro Engine adapter for the TTS Plugin Architecture.
|
||||
|
||||
This module adapts the existing Kokoro backend to the new Engine/EngineSession
|
||||
protocol. It wraps the KokoroBackend without modifying it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from abogen.tts_plugin.capabilities import VoiceLister
|
||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
||||
from abogen.tts_plugin.errors import EngineError
|
||||
from abogen.tts_plugin.manifest import VoiceManifest
|
||||
from abogen.tts_plugin.types import (
|
||||
AudioFormat,
|
||||
Duration,
|
||||
SynthesisRequest,
|
||||
SynthesizedAudio,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Sample rate for Kokoro audio
|
||||
_KOKORO_SAMPLE_RATE = 24000
|
||||
|
||||
|
||||
class KokoroSession:
|
||||
"""EngineSession implementation for Kokoro.
|
||||
|
||||
Owns mutable execution state for synthesis.
|
||||
NOT thread-safe.
|
||||
"""
|
||||
|
||||
def __init__(self, pipeline: Any) -> None:
|
||||
self._pipeline = pipeline
|
||||
self._disposed = False
|
||||
|
||||
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
|
||||
"""Synthesize audio from text using Kokoro."""
|
||||
if self._disposed:
|
||||
raise EngineError("Session disposed")
|
||||
|
||||
try:
|
||||
voice = request.voice.key
|
||||
speed = request.parameters.values.get("speed", 1.0)
|
||||
split_pattern = request.parameters.values.get("split_pattern", None)
|
||||
|
||||
audio_parts: list[np.ndarray] = []
|
||||
for segment in self._pipeline(
|
||||
request.text,
|
||||
voice=voice,
|
||||
speed=speed,
|
||||
split_pattern=split_pattern,
|
||||
):
|
||||
audio = segment.audio
|
||||
if hasattr(audio, "numpy"):
|
||||
audio = audio.numpy()
|
||||
audio_parts.append(np.asarray(audio, dtype="float32"))
|
||||
|
||||
if not audio_parts:
|
||||
return SynthesizedAudio(
|
||||
data=b"",
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
duration=Duration(seconds=0.0),
|
||||
)
|
||||
|
||||
combined = np.concatenate(audio_parts).astype("float32", copy=False)
|
||||
audio_bytes = combined.tobytes()
|
||||
duration_seconds = len(combined) / _KOKORO_SAMPLE_RATE
|
||||
|
||||
return SynthesizedAudio(
|
||||
data=audio_bytes,
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
duration=Duration(seconds=duration_seconds),
|
||||
)
|
||||
except EngineError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise EngineError(f"Synthesis failed: {e}") from e
|
||||
|
||||
def dispose(self) -> None:
|
||||
"""Release session resources. Idempotent."""
|
||||
self._disposed = True
|
||||
|
||||
|
||||
class KokoroEngine:
|
||||
"""Engine implementation for Kokoro.
|
||||
|
||||
Factory for KokoroSession instances. Stateless and thread-safe.
|
||||
"""
|
||||
|
||||
def __init__(self, pipeline: Any) -> None:
|
||||
self._pipeline = pipeline
|
||||
self._disposed = False
|
||||
|
||||
def createSession(self) -> KokoroSession:
|
||||
"""Create a new KokoroSession."""
|
||||
if self._disposed:
|
||||
raise EngineError("Engine disposed")
|
||||
return KokoroSession(self._pipeline)
|
||||
|
||||
def dispose(self) -> None:
|
||||
"""Release engine resources. Idempotent."""
|
||||
self._disposed = True
|
||||
|
||||
def listVoices(self, sourceId: str) -> list[VoiceManifest]:
|
||||
"""List available Kokoro voices. Implements VoiceLister capability.
|
||||
|
||||
Note: Static voices are declared in the plugin manifest.
|
||||
This method is a fallback for dynamic plugins.
|
||||
"""
|
||||
if self._disposed:
|
||||
raise EngineError("Engine disposed")
|
||||
return []
|
||||
"""Kokoro Engine adapter for the TTS Plugin Architecture.
|
||||
|
||||
This module adapts the existing Kokoro backend to the new Engine/EngineSession
|
||||
protocol. It wraps the KokoroBackend without modifying it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from abogen.tts_plugin.capabilities import VoiceLister
|
||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
||||
from abogen.tts_plugin.errors import EngineError
|
||||
from abogen.tts_plugin.manifest import VoiceManifest
|
||||
from abogen.tts_plugin.types import (
|
||||
AudioFormat,
|
||||
Duration,
|
||||
SynthesisRequest,
|
||||
SynthesizedAudio,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Sample rate for Kokoro audio
|
||||
_KOKORO_SAMPLE_RATE = 24000
|
||||
|
||||
|
||||
class KokoroSession:
|
||||
"""EngineSession implementation for Kokoro.
|
||||
|
||||
Owns mutable execution state for synthesis.
|
||||
NOT thread-safe.
|
||||
"""
|
||||
|
||||
def __init__(self, pipeline: Any) -> None:
|
||||
self._pipeline = pipeline
|
||||
self._disposed = False
|
||||
|
||||
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
|
||||
"""Synthesize audio from text using Kokoro."""
|
||||
if self._disposed:
|
||||
raise EngineError("Session disposed")
|
||||
|
||||
try:
|
||||
voice = request.voice.key
|
||||
speed = request.parameters.values.get("speed", 1.0)
|
||||
split_pattern = request.parameters.values.get("split_pattern", None)
|
||||
|
||||
audio_parts: list[np.ndarray] = []
|
||||
for segment in self._pipeline(
|
||||
request.text,
|
||||
voice=voice,
|
||||
speed=speed,
|
||||
split_pattern=split_pattern,
|
||||
):
|
||||
audio = segment.audio
|
||||
if hasattr(audio, "numpy"):
|
||||
audio = audio.numpy()
|
||||
audio_parts.append(np.asarray(audio, dtype="float32"))
|
||||
|
||||
if not audio_parts:
|
||||
return SynthesizedAudio(
|
||||
data=b"",
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
duration=Duration(seconds=0.0),
|
||||
)
|
||||
|
||||
combined = np.concatenate(audio_parts).astype("float32", copy=False)
|
||||
audio_bytes = combined.tobytes()
|
||||
duration_seconds = len(combined) / _KOKORO_SAMPLE_RATE
|
||||
|
||||
return SynthesizedAudio(
|
||||
data=audio_bytes,
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
duration=Duration(seconds=duration_seconds),
|
||||
)
|
||||
except EngineError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise EngineError(f"Synthesis failed: {e}") from e
|
||||
|
||||
def dispose(self) -> None:
|
||||
"""Release session resources. Idempotent."""
|
||||
self._disposed = True
|
||||
|
||||
|
||||
class KokoroEngine:
|
||||
"""Engine implementation for Kokoro.
|
||||
|
||||
Factory for KokoroSession instances. Stateless and thread-safe.
|
||||
"""
|
||||
|
||||
def __init__(self, pipeline: Any) -> None:
|
||||
self._pipeline = pipeline
|
||||
self._disposed = False
|
||||
|
||||
def createSession(self) -> KokoroSession:
|
||||
"""Create a new KokoroSession."""
|
||||
if self._disposed:
|
||||
raise EngineError("Engine disposed")
|
||||
return KokoroSession(self._pipeline)
|
||||
|
||||
def dispose(self) -> None:
|
||||
"""Release engine resources. Idempotent."""
|
||||
self._disposed = True
|
||||
|
||||
def listVoices(self, sourceId: str) -> list[VoiceManifest]:
|
||||
"""List available Kokoro voices. Implements VoiceLister capability.
|
||||
|
||||
Note: Static voices are declared in the plugin manifest.
|
||||
This method is a fallback for dynamic plugins.
|
||||
"""
|
||||
if self._disposed:
|
||||
raise EngineError("Engine disposed")
|
||||
return []
|
||||
|
||||
+136
-136
@@ -1,136 +1,136 @@
|
||||
"""SuperTonic TTS Plugin for the TTS Plugin Architecture.
|
||||
|
||||
This plugin provides a SuperTonic-based TTS engine that implements the
|
||||
Plugin API contract. It wraps the existing SuperTonic backend in the
|
||||
new Engine/EngineSession architecture.
|
||||
|
||||
Exports:
|
||||
- PLUGIN_MANIFEST: PluginManifest
|
||||
- MODEL_REQUIREMENTS: list[ModelManifest]
|
||||
- create_engine: Factory function
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from abogen.tts_plugin.engine import Engine
|
||||
from abogen.tts_plugin.host_context import HostContext
|
||||
from abogen.tts_plugin.manifest import (
|
||||
AudioFormatManifest,
|
||||
EngineManifest,
|
||||
ModelManifest,
|
||||
ParameterManifest,
|
||||
PluginManifest,
|
||||
RequirementManifest,
|
||||
VoiceManifest,
|
||||
VoiceSourceManifest,
|
||||
)
|
||||
from abogen.tts_plugin.types import EngineConfig
|
||||
|
||||
from .engine import SuperTonicEngine
|
||||
|
||||
|
||||
def _load_supertonic_pipeline() -> Any:
|
||||
"""Lazy-load SuperTonic dependencies and create pipeline."""
|
||||
from plugins.supertonic.pipeline import SupertonicPipeline
|
||||
|
||||
return SupertonicPipeline(
|
||||
sample_rate=24000,
|
||||
auto_download=True,
|
||||
total_steps=5,
|
||||
)
|
||||
|
||||
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="supertonic",
|
||||
name="SuperTonic",
|
||||
version="0.1.0",
|
||||
api_version="1.0",
|
||||
description="SuperTonic TTS engine - fast high-quality text-to-speech",
|
||||
author="SuperTonic Team",
|
||||
capabilities=("voice_list",),
|
||||
requires=RequirementManifest(
|
||||
internet=False,
|
||||
),
|
||||
engine=EngineManifest(
|
||||
voiceSources=(
|
||||
VoiceSourceManifest(
|
||||
id="builtin",
|
||||
name="Built-in Voices",
|
||||
type="list",
|
||||
config={"voices": "See listVoices()"},
|
||||
),
|
||||
),
|
||||
parameters=(
|
||||
ParameterManifest(
|
||||
id="speed",
|
||||
name="Speed",
|
||||
description="Speech speed multiplier",
|
||||
type="float",
|
||||
default=1.0,
|
||||
min=0.7,
|
||||
max=2.0,
|
||||
step=0.1,
|
||||
),
|
||||
ParameterManifest(
|
||||
id="total_steps",
|
||||
name="Quality Steps",
|
||||
description="Inference steps (higher = better quality, slower)",
|
||||
type="int",
|
||||
default=5,
|
||||
min=2,
|
||||
max=15,
|
||||
step=1,
|
||||
),
|
||||
),
|
||||
audioFormats=(
|
||||
AudioFormatManifest(mime="audio/wav", extension="wav"),
|
||||
),
|
||||
),
|
||||
voices=(
|
||||
VoiceManifest(id="M1", name="Male 1", tags=("male",)),
|
||||
VoiceManifest(id="M2", name="Male 2", tags=("male",)),
|
||||
VoiceManifest(id="M3", name="Male 3", tags=("male",)),
|
||||
VoiceManifest(id="M4", name="Male 4", tags=("male",)),
|
||||
VoiceManifest(id="M5", name="Male 5", tags=("male",)),
|
||||
VoiceManifest(id="F1", name="Female 1", tags=("female",)),
|
||||
VoiceManifest(id="F2", name="Female 2", tags=("female",)),
|
||||
VoiceManifest(id="F3", name="Female 3", tags=("female",)),
|
||||
VoiceManifest(id="F4", name="Female 4", tags=("female",)),
|
||||
VoiceManifest(id="F5", name="Female 5", tags=("female",)),
|
||||
),
|
||||
)
|
||||
|
||||
MODEL_REQUIREMENTS: list[ModelManifest] = []
|
||||
|
||||
|
||||
def create_engine(
|
||||
context: HostContext,
|
||||
model_path: Path | None,
|
||||
config: EngineConfig,
|
||||
) -> Engine:
|
||||
"""Create a SuperTonic engine instance.
|
||||
|
||||
This function is the plugin entry point. It must be atomic:
|
||||
succeed fully or raise EngineError and clean up.
|
||||
|
||||
Args:
|
||||
context: Host services (config dir, logger, http client).
|
||||
model_path: Resolved model path, or None for default.
|
||||
config: Engine initialization settings (device, etc.).
|
||||
|
||||
Returns:
|
||||
A fully initialized SuperTonicEngine instance.
|
||||
|
||||
Raises:
|
||||
EngineError: On failure. Cleans up partially created resources.
|
||||
"""
|
||||
try:
|
||||
pipeline = _load_supertonic_pipeline()
|
||||
engine = SuperTonicEngine(pipeline)
|
||||
return engine
|
||||
except Exception as e:
|
||||
from abogen.tts_plugin.errors import EngineError as EngineErrorClass
|
||||
raise EngineErrorClass(f"Failed to create SuperTonic engine: {e}") from e
|
||||
"""SuperTonic TTS Plugin for the TTS Plugin Architecture.
|
||||
|
||||
This plugin provides a SuperTonic-based TTS engine that implements the
|
||||
Plugin API contract. It wraps the existing SuperTonic backend in the
|
||||
new Engine/EngineSession architecture.
|
||||
|
||||
Exports:
|
||||
- PLUGIN_MANIFEST: PluginManifest
|
||||
- MODEL_REQUIREMENTS: list[ModelManifest]
|
||||
- create_engine: Factory function
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from abogen.tts_plugin.engine import Engine
|
||||
from abogen.tts_plugin.host_context import HostContext
|
||||
from abogen.tts_plugin.manifest import (
|
||||
AudioFormatManifest,
|
||||
EngineManifest,
|
||||
ModelManifest,
|
||||
ParameterManifest,
|
||||
PluginManifest,
|
||||
RequirementManifest,
|
||||
VoiceManifest,
|
||||
VoiceSourceManifest,
|
||||
)
|
||||
from abogen.tts_plugin.types import EngineConfig
|
||||
|
||||
from .engine import SuperTonicEngine
|
||||
|
||||
|
||||
def _load_supertonic_pipeline() -> Any:
|
||||
"""Lazy-load SuperTonic dependencies and create pipeline."""
|
||||
from plugins.supertonic.pipeline import SupertonicPipeline
|
||||
|
||||
return SupertonicPipeline(
|
||||
sample_rate=24000,
|
||||
auto_download=True,
|
||||
total_steps=5,
|
||||
)
|
||||
|
||||
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="supertonic",
|
||||
name="SuperTonic",
|
||||
version="0.1.0",
|
||||
api_version="1.0",
|
||||
description="SuperTonic TTS engine - fast high-quality text-to-speech",
|
||||
author="SuperTonic Team",
|
||||
capabilities=("voice_list",),
|
||||
requires=RequirementManifest(
|
||||
internet=False,
|
||||
),
|
||||
engine=EngineManifest(
|
||||
voiceSources=(
|
||||
VoiceSourceManifest(
|
||||
id="builtin",
|
||||
name="Built-in Voices",
|
||||
type="list",
|
||||
config={"voices": "See listVoices()"},
|
||||
),
|
||||
),
|
||||
parameters=(
|
||||
ParameterManifest(
|
||||
id="speed",
|
||||
name="Speed",
|
||||
description="Speech speed multiplier",
|
||||
type="float",
|
||||
default=1.0,
|
||||
min=0.7,
|
||||
max=2.0,
|
||||
step=0.1,
|
||||
),
|
||||
ParameterManifest(
|
||||
id="total_steps",
|
||||
name="Quality Steps",
|
||||
description="Inference steps (higher = better quality, slower)",
|
||||
type="int",
|
||||
default=5,
|
||||
min=2,
|
||||
max=15,
|
||||
step=1,
|
||||
),
|
||||
),
|
||||
audioFormats=(
|
||||
AudioFormatManifest(mime="audio/wav", extension="wav"),
|
||||
),
|
||||
),
|
||||
voices=(
|
||||
VoiceManifest(id="M1", name="Male 1", tags=("male",)),
|
||||
VoiceManifest(id="M2", name="Male 2", tags=("male",)),
|
||||
VoiceManifest(id="M3", name="Male 3", tags=("male",)),
|
||||
VoiceManifest(id="M4", name="Male 4", tags=("male",)),
|
||||
VoiceManifest(id="M5", name="Male 5", tags=("male",)),
|
||||
VoiceManifest(id="F1", name="Female 1", tags=("female",)),
|
||||
VoiceManifest(id="F2", name="Female 2", tags=("female",)),
|
||||
VoiceManifest(id="F3", name="Female 3", tags=("female",)),
|
||||
VoiceManifest(id="F4", name="Female 4", tags=("female",)),
|
||||
VoiceManifest(id="F5", name="Female 5", tags=("female",)),
|
||||
),
|
||||
)
|
||||
|
||||
MODEL_REQUIREMENTS: list[ModelManifest] = []
|
||||
|
||||
|
||||
def create_engine(
|
||||
context: HostContext,
|
||||
model_path: Path | None,
|
||||
config: EngineConfig,
|
||||
) -> Engine:
|
||||
"""Create a SuperTonic engine instance.
|
||||
|
||||
This function is the plugin entry point. It must be atomic:
|
||||
succeed fully or raise EngineError and clean up.
|
||||
|
||||
Args:
|
||||
context: Host services (config dir, logger, http client).
|
||||
model_path: Resolved model path, or None for default.
|
||||
config: Engine initialization settings (device, etc.).
|
||||
|
||||
Returns:
|
||||
A fully initialized SuperTonicEngine instance.
|
||||
|
||||
Raises:
|
||||
EngineError: On failure. Cleans up partially created resources.
|
||||
"""
|
||||
try:
|
||||
pipeline = _load_supertonic_pipeline()
|
||||
engine = SuperTonicEngine(pipeline)
|
||||
return engine
|
||||
except Exception as e:
|
||||
from abogen.tts_plugin.errors import EngineError as EngineErrorClass
|
||||
raise EngineErrorClass(f"Failed to create SuperTonic engine: {e}") from e
|
||||
|
||||
+125
-125
@@ -1,125 +1,125 @@
|
||||
"""SuperTonic Engine adapter for the TTS Plugin Architecture.
|
||||
|
||||
This module adapts the existing SuperTonic backend to the new Engine/EngineSession
|
||||
protocol. It wraps the SupertonicPipeline without modifying it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from abogen.tts_plugin.capabilities import VoiceLister
|
||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
||||
from abogen.tts_plugin.errors import EngineError
|
||||
from abogen.tts_plugin.manifest import VoiceManifest
|
||||
from abogen.tts_plugin.types import (
|
||||
AudioFormat,
|
||||
Duration,
|
||||
SynthesisRequest,
|
||||
SynthesizedAudio,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Sample rate for SuperTonic audio
|
||||
_SUPERTONIC_SAMPLE_RATE = 24000
|
||||
|
||||
|
||||
class SuperTonicSession:
|
||||
"""EngineSession implementation for SuperTonic.
|
||||
|
||||
Owns mutable execution state for synthesis.
|
||||
NOT thread-safe.
|
||||
"""
|
||||
|
||||
def __init__(self, pipeline: Any) -> None:
|
||||
self._pipeline = pipeline
|
||||
self._disposed = False
|
||||
|
||||
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
|
||||
"""Synthesize audio from text using SuperTonic."""
|
||||
if self._disposed:
|
||||
raise EngineError("Session disposed")
|
||||
|
||||
try:
|
||||
import soundfile as sf
|
||||
|
||||
voice = request.voice.key
|
||||
speed = float(request.parameters.values.get("speed", 1.0))
|
||||
total_steps = request.parameters.values.get("total_steps", None)
|
||||
split_pattern = request.parameters.values.get("split_pattern", None)
|
||||
|
||||
if total_steps is not None:
|
||||
total_steps = int(total_steps)
|
||||
|
||||
audio_parts: list[np.ndarray] = []
|
||||
for segment in self._pipeline(
|
||||
request.text,
|
||||
voice=voice,
|
||||
speed=speed,
|
||||
split_pattern=split_pattern,
|
||||
total_steps=total_steps,
|
||||
):
|
||||
audio_parts.append(segment.audio)
|
||||
|
||||
if not audio_parts:
|
||||
return SynthesizedAudio(
|
||||
data=b"",
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
duration=Duration(seconds=0.0),
|
||||
)
|
||||
|
||||
combined = np.concatenate(audio_parts).astype("float32", copy=False)
|
||||
buf = io.BytesIO()
|
||||
sf.write(buf, combined, self._pipeline.sample_rate, format="WAV")
|
||||
audio_bytes = buf.getvalue()
|
||||
duration_seconds = len(combined) / self._pipeline.sample_rate
|
||||
|
||||
return SynthesizedAudio(
|
||||
data=audio_bytes,
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
duration=Duration(seconds=duration_seconds),
|
||||
)
|
||||
except EngineError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise EngineError(f"Synthesis failed: {e}") from e
|
||||
|
||||
def dispose(self) -> None:
|
||||
"""Release session resources. Idempotent."""
|
||||
self._disposed = True
|
||||
|
||||
|
||||
class SuperTonicEngine:
|
||||
"""Engine implementation for SuperTonic.
|
||||
|
||||
Factory for SuperTonicSession instances. Stateless and thread-safe.
|
||||
"""
|
||||
|
||||
def __init__(self, pipeline: Any) -> None:
|
||||
self._pipeline = pipeline
|
||||
self._disposed = False
|
||||
|
||||
def createSession(self) -> SuperTonicSession:
|
||||
"""Create a new SuperTonicSession."""
|
||||
if self._disposed:
|
||||
raise EngineError("Engine disposed")
|
||||
return SuperTonicSession(self._pipeline)
|
||||
|
||||
def dispose(self) -> None:
|
||||
"""Release engine resources. Idempotent."""
|
||||
self._disposed = True
|
||||
|
||||
def listVoices(self, sourceId: str) -> list[VoiceManifest]:
|
||||
"""List available SuperTonic voices. Implements VoiceLister capability.
|
||||
|
||||
Note: Static voice catalog is declared in plugin manifest.
|
||||
This method is retained for VoiceLister interface compliance.
|
||||
"""
|
||||
if self._disposed:
|
||||
raise EngineError("Engine disposed")
|
||||
return []
|
||||
"""SuperTonic Engine adapter for the TTS Plugin Architecture.
|
||||
|
||||
This module adapts the existing SuperTonic backend to the new Engine/EngineSession
|
||||
protocol. It wraps the SupertonicPipeline without modifying it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from abogen.tts_plugin.capabilities import VoiceLister
|
||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
||||
from abogen.tts_plugin.errors import EngineError
|
||||
from abogen.tts_plugin.manifest import VoiceManifest
|
||||
from abogen.tts_plugin.types import (
|
||||
AudioFormat,
|
||||
Duration,
|
||||
SynthesisRequest,
|
||||
SynthesizedAudio,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Sample rate for SuperTonic audio
|
||||
_SUPERTONIC_SAMPLE_RATE = 24000
|
||||
|
||||
|
||||
class SuperTonicSession:
|
||||
"""EngineSession implementation for SuperTonic.
|
||||
|
||||
Owns mutable execution state for synthesis.
|
||||
NOT thread-safe.
|
||||
"""
|
||||
|
||||
def __init__(self, pipeline: Any) -> None:
|
||||
self._pipeline = pipeline
|
||||
self._disposed = False
|
||||
|
||||
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
|
||||
"""Synthesize audio from text using SuperTonic."""
|
||||
if self._disposed:
|
||||
raise EngineError("Session disposed")
|
||||
|
||||
try:
|
||||
import soundfile as sf
|
||||
|
||||
voice = request.voice.key
|
||||
speed = float(request.parameters.values.get("speed", 1.0))
|
||||
total_steps = request.parameters.values.get("total_steps", None)
|
||||
split_pattern = request.parameters.values.get("split_pattern", None)
|
||||
|
||||
if total_steps is not None:
|
||||
total_steps = int(total_steps)
|
||||
|
||||
audio_parts: list[np.ndarray] = []
|
||||
for segment in self._pipeline(
|
||||
request.text,
|
||||
voice=voice,
|
||||
speed=speed,
|
||||
split_pattern=split_pattern,
|
||||
total_steps=total_steps,
|
||||
):
|
||||
audio_parts.append(segment.audio)
|
||||
|
||||
if not audio_parts:
|
||||
return SynthesizedAudio(
|
||||
data=b"",
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
duration=Duration(seconds=0.0),
|
||||
)
|
||||
|
||||
combined = np.concatenate(audio_parts).astype("float32", copy=False)
|
||||
buf = io.BytesIO()
|
||||
sf.write(buf, combined, self._pipeline.sample_rate, format="WAV")
|
||||
audio_bytes = buf.getvalue()
|
||||
duration_seconds = len(combined) / self._pipeline.sample_rate
|
||||
|
||||
return SynthesizedAudio(
|
||||
data=audio_bytes,
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
duration=Duration(seconds=duration_seconds),
|
||||
)
|
||||
except EngineError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise EngineError(f"Synthesis failed: {e}") from e
|
||||
|
||||
def dispose(self) -> None:
|
||||
"""Release session resources. Idempotent."""
|
||||
self._disposed = True
|
||||
|
||||
|
||||
class SuperTonicEngine:
|
||||
"""Engine implementation for SuperTonic.
|
||||
|
||||
Factory for SuperTonicSession instances. Stateless and thread-safe.
|
||||
"""
|
||||
|
||||
def __init__(self, pipeline: Any) -> None:
|
||||
self._pipeline = pipeline
|
||||
self._disposed = False
|
||||
|
||||
def createSession(self) -> SuperTonicSession:
|
||||
"""Create a new SuperTonicSession."""
|
||||
if self._disposed:
|
||||
raise EngineError("Engine disposed")
|
||||
return SuperTonicSession(self._pipeline)
|
||||
|
||||
def dispose(self) -> None:
|
||||
"""Release engine resources. Idempotent."""
|
||||
self._disposed = True
|
||||
|
||||
def listVoices(self, sourceId: str) -> list[VoiceManifest]:
|
||||
"""List available SuperTonic voices. Implements VoiceLister capability.
|
||||
|
||||
Note: Static voice catalog is declared in plugin manifest.
|
||||
This method is retained for VoiceLister interface compliance.
|
||||
"""
|
||||
if self._disposed:
|
||||
raise EngineError("Engine disposed")
|
||||
return []
|
||||
|
||||
+266
-266
@@ -1,266 +1,266 @@
|
||||
"""SuperTonic Pipeline — self-contained TTS pipeline for the plugin.
|
||||
|
||||
This module provides the SuperTonicPipeline class and supporting utilities
|
||||
used by the SuperTonic plugin. It is independent of the legacy
|
||||
abogen.tts_backends module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Iterable, Iterator, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ensure_float32_mono(wav: Any) -> np.ndarray:
|
||||
arr = np.asarray(wav, dtype="float32")
|
||||
if arr.ndim == 2:
|
||||
if arr.shape[0] == 1 and arr.shape[1] > 1:
|
||||
arr = arr.reshape(-1)
|
||||
else:
|
||||
arr = arr[:, 0]
|
||||
return arr.reshape(-1)
|
||||
|
||||
|
||||
def _resample_linear(audio: np.ndarray, src_rate: int, dst_rate: int) -> np.ndarray:
|
||||
if src_rate == dst_rate:
|
||||
return audio
|
||||
if audio.size == 0:
|
||||
return audio
|
||||
ratio = dst_rate / float(src_rate)
|
||||
new_len = int(round(audio.size * ratio))
|
||||
if new_len <= 1:
|
||||
return np.zeros(0, dtype="float32")
|
||||
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)
|
||||
return np.interp(x_new, x_old, audio).astype("float32", copy=False)
|
||||
|
||||
|
||||
def _split_text(
|
||||
text: str, *, split_pattern: Optional[str], max_chunk_length: int
|
||||
) -> list[str]:
|
||||
stripped = (text or "").strip()
|
||||
if not stripped:
|
||||
return []
|
||||
parts: list[str]
|
||||
if split_pattern:
|
||||
try:
|
||||
parts = [p.strip() for p in re.split(split_pattern, stripped) if p.strip()]
|
||||
except re.error:
|
||||
parts = [stripped]
|
||||
else:
|
||||
parts = [stripped]
|
||||
|
||||
result: list[str] = []
|
||||
for part in parts:
|
||||
if len(part) <= max_chunk_length:
|
||||
result.append(part)
|
||||
continue
|
||||
start = 0
|
||||
while start < len(part):
|
||||
end = min(len(part), start + max_chunk_length)
|
||||
if end < len(part):
|
||||
ws = part.rfind(" ", start, end)
|
||||
if ws > start + 40:
|
||||
end = ws
|
||||
chunk = part[start:end].strip()
|
||||
if chunk:
|
||||
result.append(chunk)
|
||||
start = end
|
||||
return result
|
||||
|
||||
|
||||
_UNSUPPORTED_CHARS_RE = re.compile(
|
||||
r"unsupported character\(s\):\s*(\[[^\]]*\])", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def _parse_unsupported_characters(error: BaseException) -> list[str]:
|
||||
"""Best-effort extraction of unsupported characters from SuperTonic errors."""
|
||||
message = " ".join(
|
||||
str(part) for part in getattr(error, "args", ()) if part is not None
|
||||
) or str(error)
|
||||
match = _UNSUPPORTED_CHARS_RE.search(message)
|
||||
if not match:
|
||||
return []
|
||||
|
||||
raw = match.group(1)
|
||||
try:
|
||||
value = ast.literal_eval(raw)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
if isinstance(value, (list, tuple)):
|
||||
out: list[str] = []
|
||||
for item in value:
|
||||
if item is None:
|
||||
continue
|
||||
s = str(item)
|
||||
if s:
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
if isinstance(value, str) and value:
|
||||
return [value]
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def _remove_unsupported_characters(text: str, unsupported: Iterable[str]) -> str:
|
||||
result = text
|
||||
for item in unsupported:
|
||||
if not item:
|
||||
continue
|
||||
result = result.replace(item, "")
|
||||
return result
|
||||
|
||||
|
||||
def _configure_supertonic_gpu() -> None:
|
||||
"""Patch supertonic's config to enable GPU acceleration if available."""
|
||||
try:
|
||||
import onnxruntime as ort
|
||||
|
||||
available = ort.get_available_providers()
|
||||
|
||||
providers = []
|
||||
if "CUDAExecutionProvider" in available:
|
||||
providers.append("CUDAExecutionProvider")
|
||||
providers.append("CPUExecutionProvider")
|
||||
|
||||
import supertonic.config as supertonic_config
|
||||
import supertonic.loader as supertonic_loader
|
||||
|
||||
supertonic_config.DEFAULT_ONNX_PROVIDERS = providers
|
||||
supertonic_loader.DEFAULT_ONNX_PROVIDERS = providers
|
||||
logger.info("Supertonic ONNX providers configured: %s", providers)
|
||||
except Exception as exc:
|
||||
logger.warning("Could not configure supertonic GPU providers: %s", exc)
|
||||
|
||||
|
||||
class SupertonicSegment:
|
||||
"""A single synthesized audio segment."""
|
||||
|
||||
__slots__ = ("graphemes", "audio")
|
||||
|
||||
def __init__(self, graphemes: str, audio: np.ndarray) -> None:
|
||||
self.graphemes = graphemes
|
||||
self.audio = audio
|
||||
|
||||
|
||||
class SupertonicPipeline:
|
||||
"""Minimal adapter that mimics Kokoro's pipeline iteration interface."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
sample_rate: int,
|
||||
auto_download: bool = True,
|
||||
total_steps: int = 5,
|
||||
max_chunk_length: int = 300,
|
||||
) -> None:
|
||||
self.sample_rate = int(sample_rate)
|
||||
self.total_steps = int(total_steps)
|
||||
self.max_chunk_length = int(max_chunk_length)
|
||||
|
||||
_configure_supertonic_gpu()
|
||||
|
||||
try:
|
||||
from supertonic import TTS # type: ignore[import-not-found]
|
||||
except Exception as exc: # pragma: no cover
|
||||
raise RuntimeError(
|
||||
"Supertonic is not installed. Install it with `pip install supertonic`."
|
||||
) from exc
|
||||
|
||||
self._tts = TTS(auto_download=auto_download)
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
text: str,
|
||||
*,
|
||||
voice: str,
|
||||
speed: float,
|
||||
split_pattern: Optional[str] = None,
|
||||
total_steps: Optional[int] = None,
|
||||
) -> Iterator[SupertonicSegment]:
|
||||
voice_name = (voice or "").strip() or "M1"
|
||||
steps = int(total_steps) if total_steps is not None else self.total_steps
|
||||
steps = max(2, min(15, steps))
|
||||
speed_value = float(speed) if speed is not None else 1.0
|
||||
speed_value = max(0.7, min(2.0, speed_value))
|
||||
|
||||
style = self._tts.get_voice_style(voice_name=voice_name)
|
||||
chunks = _split_text(
|
||||
text, split_pattern=split_pattern, max_chunk_length=self.max_chunk_length
|
||||
)
|
||||
for chunk in chunks:
|
||||
chunk_to_speak = chunk
|
||||
removed: set[str] = set()
|
||||
last_exc: Exception | None = None
|
||||
|
||||
for attempt in range(3):
|
||||
try:
|
||||
wav, duration = self._tts.synthesize(
|
||||
text=chunk_to_speak,
|
||||
voice_style=style,
|
||||
total_steps=steps,
|
||||
speed=speed_value,
|
||||
max_chunk_length=self.max_chunk_length,
|
||||
silence_duration=0.0,
|
||||
verbose=False,
|
||||
)
|
||||
break
|
||||
except ValueError as exc:
|
||||
last_exc = exc
|
||||
unsupported = _parse_unsupported_characters(exc)
|
||||
if not unsupported:
|
||||
raise
|
||||
|
||||
removed.update(unsupported)
|
||||
sanitized = _remove_unsupported_characters(
|
||||
chunk_to_speak, unsupported
|
||||
).strip()
|
||||
|
||||
if sanitized == chunk_to_speak.strip():
|
||||
raise
|
||||
|
||||
chunk_to_speak = sanitized
|
||||
if not chunk_to_speak:
|
||||
logger.warning(
|
||||
"SuperTonic: dropped a chunk after removing unsupported characters: %s",
|
||||
sorted(removed),
|
||||
)
|
||||
break
|
||||
|
||||
if attempt == 0:
|
||||
logger.warning(
|
||||
"SuperTonic: removed unsupported characters %s and retried.",
|
||||
sorted(removed),
|
||||
)
|
||||
else:
|
||||
assert last_exc is not None
|
||||
raise last_exc
|
||||
|
||||
if not chunk_to_speak:
|
||||
continue
|
||||
|
||||
audio = _ensure_float32_mono(wav)
|
||||
|
||||
src_rate = self.sample_rate
|
||||
try:
|
||||
dur = float(duration)
|
||||
if dur > 0 and audio.size > 0:
|
||||
inferred = int(round(audio.size / dur))
|
||||
if 8000 <= inferred <= 96000:
|
||||
src_rate = inferred
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if src_rate != self.sample_rate:
|
||||
audio = _resample_linear(audio, src_rate, self.sample_rate)
|
||||
|
||||
yield SupertonicSegment(graphemes=chunk_to_speak, audio=audio)
|
||||
"""SuperTonic Pipeline — self-contained TTS pipeline for the plugin.
|
||||
|
||||
This module provides the SuperTonicPipeline class and supporting utilities
|
||||
used by the SuperTonic plugin. It is independent of the legacy
|
||||
abogen.tts_backends module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Iterable, Iterator, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ensure_float32_mono(wav: Any) -> np.ndarray:
|
||||
arr = np.asarray(wav, dtype="float32")
|
||||
if arr.ndim == 2:
|
||||
if arr.shape[0] == 1 and arr.shape[1] > 1:
|
||||
arr = arr.reshape(-1)
|
||||
else:
|
||||
arr = arr[:, 0]
|
||||
return arr.reshape(-1)
|
||||
|
||||
|
||||
def _resample_linear(audio: np.ndarray, src_rate: int, dst_rate: int) -> np.ndarray:
|
||||
if src_rate == dst_rate:
|
||||
return audio
|
||||
if audio.size == 0:
|
||||
return audio
|
||||
ratio = dst_rate / float(src_rate)
|
||||
new_len = int(round(audio.size * ratio))
|
||||
if new_len <= 1:
|
||||
return np.zeros(0, dtype="float32")
|
||||
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)
|
||||
return np.interp(x_new, x_old, audio).astype("float32", copy=False)
|
||||
|
||||
|
||||
def _split_text(
|
||||
text: str, *, split_pattern: Optional[str], max_chunk_length: int
|
||||
) -> list[str]:
|
||||
stripped = (text or "").strip()
|
||||
if not stripped:
|
||||
return []
|
||||
parts: list[str]
|
||||
if split_pattern:
|
||||
try:
|
||||
parts = [p.strip() for p in re.split(split_pattern, stripped) if p.strip()]
|
||||
except re.error:
|
||||
parts = [stripped]
|
||||
else:
|
||||
parts = [stripped]
|
||||
|
||||
result: list[str] = []
|
||||
for part in parts:
|
||||
if len(part) <= max_chunk_length:
|
||||
result.append(part)
|
||||
continue
|
||||
start = 0
|
||||
while start < len(part):
|
||||
end = min(len(part), start + max_chunk_length)
|
||||
if end < len(part):
|
||||
ws = part.rfind(" ", start, end)
|
||||
if ws > start + 40:
|
||||
end = ws
|
||||
chunk = part[start:end].strip()
|
||||
if chunk:
|
||||
result.append(chunk)
|
||||
start = end
|
||||
return result
|
||||
|
||||
|
||||
_UNSUPPORTED_CHARS_RE = re.compile(
|
||||
r"unsupported character\(s\):\s*(\[[^\]]*\])", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def _parse_unsupported_characters(error: BaseException) -> list[str]:
|
||||
"""Best-effort extraction of unsupported characters from SuperTonic errors."""
|
||||
message = " ".join(
|
||||
str(part) for part in getattr(error, "args", ()) if part is not None
|
||||
) or str(error)
|
||||
match = _UNSUPPORTED_CHARS_RE.search(message)
|
||||
if not match:
|
||||
return []
|
||||
|
||||
raw = match.group(1)
|
||||
try:
|
||||
value = ast.literal_eval(raw)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
if isinstance(value, (list, tuple)):
|
||||
out: list[str] = []
|
||||
for item in value:
|
||||
if item is None:
|
||||
continue
|
||||
s = str(item)
|
||||
if s:
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
if isinstance(value, str) and value:
|
||||
return [value]
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def _remove_unsupported_characters(text: str, unsupported: Iterable[str]) -> str:
|
||||
result = text
|
||||
for item in unsupported:
|
||||
if not item:
|
||||
continue
|
||||
result = result.replace(item, "")
|
||||
return result
|
||||
|
||||
|
||||
def _configure_supertonic_gpu() -> None:
|
||||
"""Patch supertonic's config to enable GPU acceleration if available."""
|
||||
try:
|
||||
import onnxruntime as ort
|
||||
|
||||
available = ort.get_available_providers()
|
||||
|
||||
providers = []
|
||||
if "CUDAExecutionProvider" in available:
|
||||
providers.append("CUDAExecutionProvider")
|
||||
providers.append("CPUExecutionProvider")
|
||||
|
||||
import supertonic.config as supertonic_config
|
||||
import supertonic.loader as supertonic_loader
|
||||
|
||||
supertonic_config.DEFAULT_ONNX_PROVIDERS = providers
|
||||
supertonic_loader.DEFAULT_ONNX_PROVIDERS = providers
|
||||
logger.info("Supertonic ONNX providers configured: %s", providers)
|
||||
except Exception as exc:
|
||||
logger.warning("Could not configure supertonic GPU providers: %s", exc)
|
||||
|
||||
|
||||
class SupertonicSegment:
|
||||
"""A single synthesized audio segment."""
|
||||
|
||||
__slots__ = ("graphemes", "audio")
|
||||
|
||||
def __init__(self, graphemes: str, audio: np.ndarray) -> None:
|
||||
self.graphemes = graphemes
|
||||
self.audio = audio
|
||||
|
||||
|
||||
class SupertonicPipeline:
|
||||
"""Minimal adapter that mimics Kokoro's pipeline iteration interface."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
sample_rate: int,
|
||||
auto_download: bool = True,
|
||||
total_steps: int = 5,
|
||||
max_chunk_length: int = 300,
|
||||
) -> None:
|
||||
self.sample_rate = int(sample_rate)
|
||||
self.total_steps = int(total_steps)
|
||||
self.max_chunk_length = int(max_chunk_length)
|
||||
|
||||
_configure_supertonic_gpu()
|
||||
|
||||
try:
|
||||
from supertonic import TTS # type: ignore[import-not-found]
|
||||
except Exception as exc: # pragma: no cover
|
||||
raise RuntimeError(
|
||||
"Supertonic is not installed. Install it with `pip install supertonic`."
|
||||
) from exc
|
||||
|
||||
self._tts = TTS(auto_download=auto_download)
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
text: str,
|
||||
*,
|
||||
voice: str,
|
||||
speed: float,
|
||||
split_pattern: Optional[str] = None,
|
||||
total_steps: Optional[int] = None,
|
||||
) -> Iterator[SupertonicSegment]:
|
||||
voice_name = (voice or "").strip() or "M1"
|
||||
steps = int(total_steps) if total_steps is not None else self.total_steps
|
||||
steps = max(2, min(15, steps))
|
||||
speed_value = float(speed) if speed is not None else 1.0
|
||||
speed_value = max(0.7, min(2.0, speed_value))
|
||||
|
||||
style = self._tts.get_voice_style(voice_name=voice_name)
|
||||
chunks = _split_text(
|
||||
text, split_pattern=split_pattern, max_chunk_length=self.max_chunk_length
|
||||
)
|
||||
for chunk in chunks:
|
||||
chunk_to_speak = chunk
|
||||
removed: set[str] = set()
|
||||
last_exc: Exception | None = None
|
||||
|
||||
for attempt in range(3):
|
||||
try:
|
||||
wav, duration = self._tts.synthesize(
|
||||
text=chunk_to_speak,
|
||||
voice_style=style,
|
||||
total_steps=steps,
|
||||
speed=speed_value,
|
||||
max_chunk_length=self.max_chunk_length,
|
||||
silence_duration=0.0,
|
||||
verbose=False,
|
||||
)
|
||||
break
|
||||
except ValueError as exc:
|
||||
last_exc = exc
|
||||
unsupported = _parse_unsupported_characters(exc)
|
||||
if not unsupported:
|
||||
raise
|
||||
|
||||
removed.update(unsupported)
|
||||
sanitized = _remove_unsupported_characters(
|
||||
chunk_to_speak, unsupported
|
||||
).strip()
|
||||
|
||||
if sanitized == chunk_to_speak.strip():
|
||||
raise
|
||||
|
||||
chunk_to_speak = sanitized
|
||||
if not chunk_to_speak:
|
||||
logger.warning(
|
||||
"SuperTonic: dropped a chunk after removing unsupported characters: %s",
|
||||
sorted(removed),
|
||||
)
|
||||
break
|
||||
|
||||
if attempt == 0:
|
||||
logger.warning(
|
||||
"SuperTonic: removed unsupported characters %s and retried.",
|
||||
sorted(removed),
|
||||
)
|
||||
else:
|
||||
assert last_exc is not None
|
||||
raise last_exc
|
||||
|
||||
if not chunk_to_speak:
|
||||
continue
|
||||
|
||||
audio = _ensure_float32_mono(wav)
|
||||
|
||||
src_rate = self.sample_rate
|
||||
try:
|
||||
dur = float(duration)
|
||||
if dur > 0 and audio.size > 0:
|
||||
inferred = int(round(audio.size / dur))
|
||||
if 8000 <= inferred <= 96000:
|
||||
src_rate = inferred
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if src_rate != self.sample_rate:
|
||||
audio = _resample_linear(audio, src_rate, self.sample_rate)
|
||||
|
||||
yield SupertonicSegment(graphemes=chunk_to_speak, audio=audio)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Contract tests for the TTS Plugin API.
|
||||
|
||||
This package contains reusable contract tests that any TTS plugin implementation
|
||||
must satisfy. Tests use only the public API and are engine-agnostic.
|
||||
"""
|
||||
"""Contract tests for the TTS Plugin API.
|
||||
|
||||
This package contains reusable contract tests that any TTS plugin implementation
|
||||
must satisfy. Tests use only the public API and are engine-agnostic.
|
||||
"""
|
||||
|
||||
+231
-231
@@ -1,231 +1,231 @@
|
||||
"""Shared fixtures and stubs for contract tests.
|
||||
|
||||
This module provides minimal stub implementations that satisfy the public API
|
||||
for testing purposes. These stubs do NOT contain real business logic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
||||
from abogen.tts_plugin.host_context import HostContext
|
||||
from abogen.tts_plugin.types import (
|
||||
AudioFormat,
|
||||
Duration,
|
||||
EngineConfig,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
SynthesizedAudio,
|
||||
VoiceSelection,
|
||||
)
|
||||
|
||||
|
||||
class FakeHttpClient:
|
||||
"""Stub HTTP client that satisfies the HttpClient protocol."""
|
||||
|
||||
def get(self, url: str, **kwargs: object) -> object:
|
||||
return None
|
||||
|
||||
def post(self, url: str, **kwargs: object) -> object:
|
||||
return None
|
||||
|
||||
|
||||
class FakeEngineSession:
|
||||
"""Stub EngineSession for testing protocol compliance."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._disposed = False
|
||||
|
||||
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
|
||||
if self._disposed:
|
||||
from abogen.tts_plugin.errors import EngineError
|
||||
|
||||
raise EngineError("Session disposed")
|
||||
return SynthesizedAudio(
|
||||
data=b"\x00" * 100,
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
duration=Duration(seconds=1.0),
|
||||
)
|
||||
|
||||
def dispose(self) -> None:
|
||||
self._disposed = True
|
||||
|
||||
|
||||
class FakeStreamingSession:
|
||||
"""Stub EngineSession with StreamingSynthesizer capability."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._disposed = False
|
||||
|
||||
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
|
||||
if self._disposed:
|
||||
from abogen.tts_plugin.errors import EngineError
|
||||
|
||||
raise EngineError("Session disposed")
|
||||
return SynthesizedAudio(
|
||||
data=b"\x00" * 100,
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
duration=Duration(seconds=1.0),
|
||||
)
|
||||
|
||||
def synthesizeStream(self, request: SynthesisRequest) -> Iterator[bytes]:
|
||||
if self._disposed:
|
||||
from abogen.tts_plugin.errors import EngineError
|
||||
|
||||
raise EngineError("Session disposed")
|
||||
for i in range(3):
|
||||
yield b"\x00" * 50
|
||||
|
||||
def dispose(self) -> None:
|
||||
self._disposed = True
|
||||
|
||||
|
||||
class FakeCancelableSession:
|
||||
"""Stub EngineSession with CancelableSession capability."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._disposed = False
|
||||
self._cancelled = False
|
||||
|
||||
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
|
||||
if self._disposed:
|
||||
from abogen.tts_plugin.errors import EngineError
|
||||
|
||||
raise EngineError("Session disposed")
|
||||
if self._cancelled:
|
||||
from abogen.tts_plugin.errors import CancelledError
|
||||
|
||||
raise CancelledError("Cancelled")
|
||||
return SynthesizedAudio(
|
||||
data=b"\x00" * 100,
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
duration=Duration(seconds=1.0),
|
||||
)
|
||||
|
||||
def cancel(self) -> None:
|
||||
if self._disposed:
|
||||
from abogen.tts_plugin.errors import EngineError
|
||||
|
||||
raise EngineError("Session disposed")
|
||||
self._cancelled = True
|
||||
|
||||
def dispose(self) -> None:
|
||||
self._disposed = True
|
||||
|
||||
|
||||
class FakeEngine:
|
||||
"""Stub Engine for testing protocol compliance."""
|
||||
|
||||
def __init__(self, session_class: type = FakeEngineSession) -> None:
|
||||
self._disposed = False
|
||||
self._session_class = session_class
|
||||
|
||||
def createSession(self) -> EngineSession:
|
||||
if self._disposed:
|
||||
from abogen.tts_plugin.errors import EngineError
|
||||
|
||||
raise EngineError("Engine disposed")
|
||||
return self._session_class()
|
||||
|
||||
def dispose(self) -> None:
|
||||
self._disposed = True
|
||||
|
||||
|
||||
class FakeVoiceListerEngine:
|
||||
"""Stub Engine that also implements VoiceLister."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._disposed = False
|
||||
|
||||
def createSession(self) -> EngineSession:
|
||||
if self._disposed:
|
||||
from abogen.tts_plugin.errors import EngineError
|
||||
|
||||
raise EngineError("Engine disposed")
|
||||
return FakeEngineSession()
|
||||
|
||||
def listVoices(self, sourceId: str) -> list:
|
||||
from abogen.tts_plugin.manifest import VoiceManifest
|
||||
|
||||
return [
|
||||
VoiceManifest(id="voice1", name="Voice 1", tags=("en",)),
|
||||
VoiceManifest(id="voice2", name="Voice 2", tags=("es",)),
|
||||
]
|
||||
|
||||
def dispose(self) -> None:
|
||||
self._disposed = True
|
||||
|
||||
|
||||
class FakePreviewEngine:
|
||||
"""Stub Engine that also implements PreviewGenerator."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._disposed = False
|
||||
|
||||
def createSession(self) -> EngineSession:
|
||||
if self._disposed:
|
||||
from abogen.tts_plugin.errors import EngineError
|
||||
|
||||
raise EngineError("Engine disposed")
|
||||
return FakeEngineSession()
|
||||
|
||||
def generatePreview(self, voice: VoiceSelection, text: str) -> SynthesizedAudio:
|
||||
return SynthesizedAudio(
|
||||
data=b"\x00" * 50,
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
duration=Duration(seconds=0.5),
|
||||
)
|
||||
|
||||
def dispose(self) -> None:
|
||||
self._disposed = True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_http_client() -> FakeHttpClient:
|
||||
return FakeHttpClient()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def host_context(tmp_path: Path, fake_http_client: FakeHttpClient) -> HostContext:
|
||||
return HostContext(
|
||||
config_dir=tmp_path,
|
||||
logger=logging.getLogger("test"),
|
||||
http_client=fake_http_client,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_engine() -> FakeEngine:
|
||||
return FakeEngine()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_session() -> FakeEngineSession:
|
||||
return FakeEngineSession()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def default_voice() -> VoiceSelection:
|
||||
return VoiceSelection(source="builtin", key="af_nova")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def default_format() -> AudioFormat:
|
||||
return AudioFormat(mime="audio/wav", extension="wav")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def default_request(
|
||||
default_voice: VoiceSelection, default_format: AudioFormat
|
||||
) -> SynthesisRequest:
|
||||
return SynthesisRequest(
|
||||
text="Hello, world!",
|
||||
voice=default_voice,
|
||||
parameters=ParameterValues(values={}),
|
||||
format=default_format,
|
||||
)
|
||||
"""Shared fixtures and stubs for contract tests.
|
||||
|
||||
This module provides minimal stub implementations that satisfy the public API
|
||||
for testing purposes. These stubs do NOT contain real business logic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
||||
from abogen.tts_plugin.host_context import HostContext
|
||||
from abogen.tts_plugin.types import (
|
||||
AudioFormat,
|
||||
Duration,
|
||||
EngineConfig,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
SynthesizedAudio,
|
||||
VoiceSelection,
|
||||
)
|
||||
|
||||
|
||||
class FakeHttpClient:
|
||||
"""Stub HTTP client that satisfies the HttpClient protocol."""
|
||||
|
||||
def get(self, url: str, **kwargs: object) -> object:
|
||||
return None
|
||||
|
||||
def post(self, url: str, **kwargs: object) -> object:
|
||||
return None
|
||||
|
||||
|
||||
class FakeEngineSession:
|
||||
"""Stub EngineSession for testing protocol compliance."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._disposed = False
|
||||
|
||||
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
|
||||
if self._disposed:
|
||||
from abogen.tts_plugin.errors import EngineError
|
||||
|
||||
raise EngineError("Session disposed")
|
||||
return SynthesizedAudio(
|
||||
data=b"\x00" * 100,
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
duration=Duration(seconds=1.0),
|
||||
)
|
||||
|
||||
def dispose(self) -> None:
|
||||
self._disposed = True
|
||||
|
||||
|
||||
class FakeStreamingSession:
|
||||
"""Stub EngineSession with StreamingSynthesizer capability."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._disposed = False
|
||||
|
||||
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
|
||||
if self._disposed:
|
||||
from abogen.tts_plugin.errors import EngineError
|
||||
|
||||
raise EngineError("Session disposed")
|
||||
return SynthesizedAudio(
|
||||
data=b"\x00" * 100,
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
duration=Duration(seconds=1.0),
|
||||
)
|
||||
|
||||
def synthesizeStream(self, request: SynthesisRequest) -> Iterator[bytes]:
|
||||
if self._disposed:
|
||||
from abogen.tts_plugin.errors import EngineError
|
||||
|
||||
raise EngineError("Session disposed")
|
||||
for i in range(3):
|
||||
yield b"\x00" * 50
|
||||
|
||||
def dispose(self) -> None:
|
||||
self._disposed = True
|
||||
|
||||
|
||||
class FakeCancelableSession:
|
||||
"""Stub EngineSession with CancelableSession capability."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._disposed = False
|
||||
self._cancelled = False
|
||||
|
||||
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
|
||||
if self._disposed:
|
||||
from abogen.tts_plugin.errors import EngineError
|
||||
|
||||
raise EngineError("Session disposed")
|
||||
if self._cancelled:
|
||||
from abogen.tts_plugin.errors import CancelledError
|
||||
|
||||
raise CancelledError("Cancelled")
|
||||
return SynthesizedAudio(
|
||||
data=b"\x00" * 100,
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
duration=Duration(seconds=1.0),
|
||||
)
|
||||
|
||||
def cancel(self) -> None:
|
||||
if self._disposed:
|
||||
from abogen.tts_plugin.errors import EngineError
|
||||
|
||||
raise EngineError("Session disposed")
|
||||
self._cancelled = True
|
||||
|
||||
def dispose(self) -> None:
|
||||
self._disposed = True
|
||||
|
||||
|
||||
class FakeEngine:
|
||||
"""Stub Engine for testing protocol compliance."""
|
||||
|
||||
def __init__(self, session_class: type = FakeEngineSession) -> None:
|
||||
self._disposed = False
|
||||
self._session_class = session_class
|
||||
|
||||
def createSession(self) -> EngineSession:
|
||||
if self._disposed:
|
||||
from abogen.tts_plugin.errors import EngineError
|
||||
|
||||
raise EngineError("Engine disposed")
|
||||
return self._session_class()
|
||||
|
||||
def dispose(self) -> None:
|
||||
self._disposed = True
|
||||
|
||||
|
||||
class FakeVoiceListerEngine:
|
||||
"""Stub Engine that also implements VoiceLister."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._disposed = False
|
||||
|
||||
def createSession(self) -> EngineSession:
|
||||
if self._disposed:
|
||||
from abogen.tts_plugin.errors import EngineError
|
||||
|
||||
raise EngineError("Engine disposed")
|
||||
return FakeEngineSession()
|
||||
|
||||
def listVoices(self, sourceId: str) -> list:
|
||||
from abogen.tts_plugin.manifest import VoiceManifest
|
||||
|
||||
return [
|
||||
VoiceManifest(id="voice1", name="Voice 1", tags=("en",)),
|
||||
VoiceManifest(id="voice2", name="Voice 2", tags=("es",)),
|
||||
]
|
||||
|
||||
def dispose(self) -> None:
|
||||
self._disposed = True
|
||||
|
||||
|
||||
class FakePreviewEngine:
|
||||
"""Stub Engine that also implements PreviewGenerator."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._disposed = False
|
||||
|
||||
def createSession(self) -> EngineSession:
|
||||
if self._disposed:
|
||||
from abogen.tts_plugin.errors import EngineError
|
||||
|
||||
raise EngineError("Engine disposed")
|
||||
return FakeEngineSession()
|
||||
|
||||
def generatePreview(self, voice: VoiceSelection, text: str) -> SynthesizedAudio:
|
||||
return SynthesizedAudio(
|
||||
data=b"\x00" * 50,
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
duration=Duration(seconds=0.5),
|
||||
)
|
||||
|
||||
def dispose(self) -> None:
|
||||
self._disposed = True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_http_client() -> FakeHttpClient:
|
||||
return FakeHttpClient()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def host_context(tmp_path: Path, fake_http_client: FakeHttpClient) -> HostContext:
|
||||
return HostContext(
|
||||
config_dir=tmp_path,
|
||||
logger=logging.getLogger("test"),
|
||||
http_client=fake_http_client,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_engine() -> FakeEngine:
|
||||
return FakeEngine()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_session() -> FakeEngineSession:
|
||||
return FakeEngineSession()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def default_voice() -> VoiceSelection:
|
||||
return VoiceSelection(source="builtin", key="af_nova")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def default_format() -> AudioFormat:
|
||||
return AudioFormat(mime="audio/wav", extension="wav")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def default_request(
|
||||
default_voice: VoiceSelection, default_format: AudioFormat
|
||||
) -> SynthesisRequest:
|
||||
return SynthesisRequest(
|
||||
text="Hello, world!",
|
||||
voice=default_voice,
|
||||
parameters=ParameterValues(values={}),
|
||||
format=default_format,
|
||||
)
|
||||
|
||||
+120
-120
@@ -1,120 +1,120 @@
|
||||
"""Base contract tests for Engine implementations.
|
||||
|
||||
Any new TTS plugin must inherit from these classes to verify
|
||||
it satisfies the Engine/EngineSession protocol.
|
||||
|
||||
Usage:
|
||||
from tests.contracts.engine_contract import EngineContractMixin
|
||||
|
||||
class TestMyEngine(EngineContractMixin):
|
||||
@pytest.fixture
|
||||
def engine(self):
|
||||
return create_my_engine()
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
||||
from abogen.tts_plugin.errors import EngineError
|
||||
from abogen.tts_plugin.types import (
|
||||
AudioFormat,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
SynthesizedAudio,
|
||||
VoiceSelection,
|
||||
)
|
||||
|
||||
|
||||
class EngineContractMixin:
|
||||
"""Base contract tests for Engine implementations.
|
||||
|
||||
Subclasses must define a module-level ``engine`` fixture returning
|
||||
a fully initialized Engine instance. The tests below will use it
|
||||
via pytest's standard fixture resolution.
|
||||
"""
|
||||
|
||||
def _req(self, text: str = "Hello", voice: str | None = None) -> SynthesisRequest:
|
||||
return SynthesisRequest(
|
||||
text=text,
|
||||
voice=VoiceSelection(source="builtin", key=voice or "default"),
|
||||
parameters=ParameterValues(values={}),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
|
||||
# ── Engine protocol ──────────────────────────────────────
|
||||
|
||||
def test_engine_satisfies_protocol(self, engine: Engine) -> None:
|
||||
assert isinstance(engine, Engine)
|
||||
|
||||
def test_create_session_returns_session(self, engine: Engine) -> None:
|
||||
session = engine.createSession()
|
||||
assert isinstance(session, EngineSession)
|
||||
session.dispose()
|
||||
|
||||
def test_create_session_returns_new_instances(self, engine: Engine) -> None:
|
||||
s1 = engine.createSession()
|
||||
s2 = engine.createSession()
|
||||
assert s1 is not s2
|
||||
s1.dispose()
|
||||
s2.dispose()
|
||||
|
||||
def test_dispose_is_idempotent(self, engine: Engine) -> None:
|
||||
engine.dispose()
|
||||
engine.dispose()
|
||||
|
||||
def test_create_session_after_dispose_raises(self, engine: Engine) -> None:
|
||||
engine.dispose()
|
||||
with pytest.raises(EngineError):
|
||||
engine.createSession()
|
||||
|
||||
# ── Session protocol ─────────────────────────────────────
|
||||
|
||||
def test_session_satisfies_protocol(self, engine: Engine) -> None:
|
||||
session = engine.createSession()
|
||||
assert isinstance(session, EngineSession)
|
||||
session.dispose()
|
||||
engine.dispose()
|
||||
|
||||
def test_session_synthesize_returns_audio(self, engine: Engine) -> None:
|
||||
session = engine.createSession()
|
||||
result = session.synthesize(self._req())
|
||||
assert isinstance(result, SynthesizedAudio)
|
||||
assert isinstance(result.data, bytes)
|
||||
assert len(result.data) > 0
|
||||
session.dispose()
|
||||
engine.dispose()
|
||||
|
||||
def test_session_dispose_is_idempotent(self, engine: Engine) -> None:
|
||||
session = engine.createSession()
|
||||
session.dispose()
|
||||
session.dispose()
|
||||
engine.dispose()
|
||||
|
||||
def test_session_synthesize_after_dispose_raises(self, engine: Engine) -> None:
|
||||
session = engine.createSession()
|
||||
session.dispose()
|
||||
with pytest.raises(EngineError):
|
||||
session.synthesize(self._req())
|
||||
engine.dispose()
|
||||
|
||||
def test_session_multiple_synthesize(self, engine: Engine) -> None:
|
||||
session = engine.createSession()
|
||||
r1 = session.synthesize(self._req())
|
||||
r2 = session.synthesize(self._req())
|
||||
assert isinstance(r1.data, bytes)
|
||||
assert isinstance(r2.data, bytes)
|
||||
session.dispose()
|
||||
engine.dispose()
|
||||
|
||||
# ── Lifecycle ────────────────────────────────────────────
|
||||
|
||||
def test_full_lifecycle(self, engine: Engine) -> None:
|
||||
s1 = engine.createSession()
|
||||
s2 = engine.createSession()
|
||||
s1.synthesize(self._req())
|
||||
s2.synthesize(self._req())
|
||||
s1.dispose()
|
||||
s2.dispose()
|
||||
engine.dispose()
|
||||
"""Base contract tests for Engine implementations.
|
||||
|
||||
Any new TTS plugin must inherit from these classes to verify
|
||||
it satisfies the Engine/EngineSession protocol.
|
||||
|
||||
Usage:
|
||||
from tests.contracts.engine_contract import EngineContractMixin
|
||||
|
||||
class TestMyEngine(EngineContractMixin):
|
||||
@pytest.fixture
|
||||
def engine(self):
|
||||
return create_my_engine()
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
||||
from abogen.tts_plugin.errors import EngineError
|
||||
from abogen.tts_plugin.types import (
|
||||
AudioFormat,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
SynthesizedAudio,
|
||||
VoiceSelection,
|
||||
)
|
||||
|
||||
|
||||
class EngineContractMixin:
|
||||
"""Base contract tests for Engine implementations.
|
||||
|
||||
Subclasses must define a module-level ``engine`` fixture returning
|
||||
a fully initialized Engine instance. The tests below will use it
|
||||
via pytest's standard fixture resolution.
|
||||
"""
|
||||
|
||||
def _req(self, text: str = "Hello", voice: str | None = None) -> SynthesisRequest:
|
||||
return SynthesisRequest(
|
||||
text=text,
|
||||
voice=VoiceSelection(source="builtin", key=voice or "default"),
|
||||
parameters=ParameterValues(values={}),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
|
||||
# ── Engine protocol ──────────────────────────────────────
|
||||
|
||||
def test_engine_satisfies_protocol(self, engine: Engine) -> None:
|
||||
assert isinstance(engine, Engine)
|
||||
|
||||
def test_create_session_returns_session(self, engine: Engine) -> None:
|
||||
session = engine.createSession()
|
||||
assert isinstance(session, EngineSession)
|
||||
session.dispose()
|
||||
|
||||
def test_create_session_returns_new_instances(self, engine: Engine) -> None:
|
||||
s1 = engine.createSession()
|
||||
s2 = engine.createSession()
|
||||
assert s1 is not s2
|
||||
s1.dispose()
|
||||
s2.dispose()
|
||||
|
||||
def test_dispose_is_idempotent(self, engine: Engine) -> None:
|
||||
engine.dispose()
|
||||
engine.dispose()
|
||||
|
||||
def test_create_session_after_dispose_raises(self, engine: Engine) -> None:
|
||||
engine.dispose()
|
||||
with pytest.raises(EngineError):
|
||||
engine.createSession()
|
||||
|
||||
# ── Session protocol ─────────────────────────────────────
|
||||
|
||||
def test_session_satisfies_protocol(self, engine: Engine) -> None:
|
||||
session = engine.createSession()
|
||||
assert isinstance(session, EngineSession)
|
||||
session.dispose()
|
||||
engine.dispose()
|
||||
|
||||
def test_session_synthesize_returns_audio(self, engine: Engine) -> None:
|
||||
session = engine.createSession()
|
||||
result = session.synthesize(self._req())
|
||||
assert isinstance(result, SynthesizedAudio)
|
||||
assert isinstance(result.data, bytes)
|
||||
assert len(result.data) > 0
|
||||
session.dispose()
|
||||
engine.dispose()
|
||||
|
||||
def test_session_dispose_is_idempotent(self, engine: Engine) -> None:
|
||||
session = engine.createSession()
|
||||
session.dispose()
|
||||
session.dispose()
|
||||
engine.dispose()
|
||||
|
||||
def test_session_synthesize_after_dispose_raises(self, engine: Engine) -> None:
|
||||
session = engine.createSession()
|
||||
session.dispose()
|
||||
with pytest.raises(EngineError):
|
||||
session.synthesize(self._req())
|
||||
engine.dispose()
|
||||
|
||||
def test_session_multiple_synthesize(self, engine: Engine) -> None:
|
||||
session = engine.createSession()
|
||||
r1 = session.synthesize(self._req())
|
||||
r2 = session.synthesize(self._req())
|
||||
assert isinstance(r1.data, bytes)
|
||||
assert isinstance(r2.data, bytes)
|
||||
session.dispose()
|
||||
engine.dispose()
|
||||
|
||||
# ── Lifecycle ────────────────────────────────────────────
|
||||
|
||||
def test_full_lifecycle(self, engine: Engine) -> None:
|
||||
s1 = engine.createSession()
|
||||
s2 = engine.createSession()
|
||||
s1.synthesize(self._req())
|
||||
s2.synthesize(self._req())
|
||||
s1.dispose()
|
||||
s2.dispose()
|
||||
engine.dispose()
|
||||
|
||||
@@ -1,183 +1,183 @@
|
||||
"""Contract tests for capability interfaces.
|
||||
|
||||
These tests verify that capability interfaces satisfy the architectural requirements:
|
||||
- VoiceLister: lists voices for a source
|
||||
- PreviewGenerator: generates preview audio
|
||||
- StreamingSynthesizer: yields audio chunks
|
||||
- CancelableSession: cancels in-progress synthesis
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.capabilities import (
|
||||
CancelableSession,
|
||||
PreviewGenerator,
|
||||
StreamingSynthesizer,
|
||||
VoiceLister,
|
||||
)
|
||||
from abogen.tts_plugin.errors import CancelledError, EngineError
|
||||
from abogen.tts_plugin.manifest import VoiceManifest
|
||||
from abogen.tts_plugin.types import (
|
||||
AudioFormat,
|
||||
Duration,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
SynthesizedAudio,
|
||||
VoiceSelection,
|
||||
)
|
||||
|
||||
from .conftest import FakeCancelableSession, FakeStreamingSession, FakeVoiceListerEngine
|
||||
|
||||
|
||||
class TestVoiceListerProtocolContract:
|
||||
"""Contract tests for VoiceLister protocol."""
|
||||
|
||||
def test_voice_lister_is_protocol(self) -> None:
|
||||
assert hasattr(VoiceLister, "__protocol_attrs__")
|
||||
|
||||
def test_voice_lister_satisfied_by_engine(self) -> None:
|
||||
engine = FakeVoiceListerEngine()
|
||||
assert isinstance(engine, VoiceLister)
|
||||
|
||||
def test_list_voices_returns_list(self) -> None:
|
||||
engine = FakeVoiceListerEngine()
|
||||
voices = engine.listVoices("builtin")
|
||||
assert isinstance(voices, list)
|
||||
|
||||
def test_list_voices_returns_voice_manifests(self) -> None:
|
||||
engine = FakeVoiceListerEngine()
|
||||
voices = engine.listVoices("builtin")
|
||||
for voice in voices:
|
||||
assert isinstance(voice, VoiceManifest)
|
||||
|
||||
def test_list_voices_has_required_fields(self) -> None:
|
||||
engine = FakeVoiceListerEngine()
|
||||
voices = engine.listVoices("builtin")
|
||||
for voice in voices:
|
||||
assert hasattr(voice, "id")
|
||||
assert hasattr(voice, "name")
|
||||
assert hasattr(voice, "tags")
|
||||
|
||||
|
||||
class TestPreviewGeneratorProtocolContract:
|
||||
"""Contract tests for PreviewGenerator protocol."""
|
||||
|
||||
def test_preview_generator_is_protocol(self) -> None:
|
||||
assert hasattr(PreviewGenerator, "__protocol_attrs__")
|
||||
|
||||
def test_preview_generator_satisfied_by_engine(self) -> None:
|
||||
from .conftest import FakePreviewEngine
|
||||
|
||||
engine = FakePreviewEngine()
|
||||
assert isinstance(engine, PreviewGenerator)
|
||||
|
||||
def test_generate_preview_returns_synthesized_audio(self) -> None:
|
||||
from .conftest import FakePreviewEngine
|
||||
|
||||
engine = FakePreviewEngine()
|
||||
voice = VoiceSelection(source="builtin", key="af_nova")
|
||||
result = engine.generatePreview(voice, "Hello")
|
||||
assert isinstance(result, SynthesizedAudio)
|
||||
|
||||
def test_generate_preview_has_valid_data(self) -> None:
|
||||
from .conftest import FakePreviewEngine
|
||||
|
||||
engine = FakePreviewEngine()
|
||||
voice = VoiceSelection(source="builtin", key="af_nova")
|
||||
result = engine.generatePreview(voice, "Hello")
|
||||
assert isinstance(result.data, bytes)
|
||||
assert len(result.data) > 0
|
||||
|
||||
|
||||
class TestStreamingSynthesizerProtocolContract:
|
||||
"""Contract tests for StreamingSynthesizer protocol."""
|
||||
|
||||
def test_streaming_synthesizer_is_protocol(self) -> None:
|
||||
assert hasattr(StreamingSynthesizer, "__protocol_attrs__")
|
||||
|
||||
def test_streaming_session_satisfies_protocol(self) -> None:
|
||||
session = FakeStreamingSession()
|
||||
assert isinstance(session, StreamingSynthesizer)
|
||||
|
||||
def test_synthesize_stream_yields_bytes(self) -> None:
|
||||
session = FakeStreamingSession()
|
||||
request = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
chunks = list(session.synthesizeStream(request))
|
||||
assert len(chunks) > 0
|
||||
for chunk in chunks:
|
||||
assert isinstance(chunk, bytes)
|
||||
|
||||
def test_streaming_iterator_exhaustion(self) -> None:
|
||||
"""Architecture spec: Iterator exhaustion = synthesis complete."""
|
||||
session = FakeStreamingSession()
|
||||
request = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
chunks = list(session.synthesizeStream(request))
|
||||
assert len(chunks) == 3
|
||||
|
||||
def test_streaming_after_dispose_raises(self) -> None:
|
||||
"""Architecture spec: After dispose(), methods raise EngineError."""
|
||||
session = FakeStreamingSession()
|
||||
session.dispose()
|
||||
request = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
with pytest.raises(EngineError):
|
||||
list(session.synthesizeStream(request))
|
||||
|
||||
|
||||
class TestCancelableSessionProtocolContract:
|
||||
"""Contract tests for CancelableSession protocol."""
|
||||
|
||||
def test_cancelable_session_is_protocol(self) -> None:
|
||||
assert hasattr(CancelableSession, "__protocol_attrs__")
|
||||
|
||||
def test_cancelable_session_satisfies_protocol(self) -> None:
|
||||
session = FakeCancelableSession()
|
||||
assert isinstance(session, CancelableSession)
|
||||
|
||||
def test_cancel_causes_synthesize_to_raise_cancelled(self) -> None:
|
||||
"""Architecture spec: cancel() causes synthesize() to raise CancelledError."""
|
||||
session = FakeCancelableSession()
|
||||
request = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
|
||||
# Cancel
|
||||
session.cancel()
|
||||
|
||||
# synthesize should raise CancelledError
|
||||
with pytest.raises(CancelledError):
|
||||
session.synthesize(request)
|
||||
|
||||
def test_cancel_after_dispose_raises(self) -> None:
|
||||
"""Architecture spec: cancel() raises EngineError if called after dispose()."""
|
||||
session = FakeCancelableSession()
|
||||
session.dispose()
|
||||
with pytest.raises(EngineError):
|
||||
session.cancel()
|
||||
|
||||
def test_session_usable_after_cancel(self) -> None:
|
||||
"""Architecture spec: EngineSession remains usable after cancellation."""
|
||||
session = FakeCancelableSession()
|
||||
|
||||
# Cancel
|
||||
session.cancel()
|
||||
|
||||
# Dispose and create new session for synthesis
|
||||
session.dispose()
|
||||
"""Contract tests for capability interfaces.
|
||||
|
||||
These tests verify that capability interfaces satisfy the architectural requirements:
|
||||
- VoiceLister: lists voices for a source
|
||||
- PreviewGenerator: generates preview audio
|
||||
- StreamingSynthesizer: yields audio chunks
|
||||
- CancelableSession: cancels in-progress synthesis
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.capabilities import (
|
||||
CancelableSession,
|
||||
PreviewGenerator,
|
||||
StreamingSynthesizer,
|
||||
VoiceLister,
|
||||
)
|
||||
from abogen.tts_plugin.errors import CancelledError, EngineError
|
||||
from abogen.tts_plugin.manifest import VoiceManifest
|
||||
from abogen.tts_plugin.types import (
|
||||
AudioFormat,
|
||||
Duration,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
SynthesizedAudio,
|
||||
VoiceSelection,
|
||||
)
|
||||
|
||||
from .conftest import FakeCancelableSession, FakeStreamingSession, FakeVoiceListerEngine
|
||||
|
||||
|
||||
class TestVoiceListerProtocolContract:
|
||||
"""Contract tests for VoiceLister protocol."""
|
||||
|
||||
def test_voice_lister_is_protocol(self) -> None:
|
||||
assert hasattr(VoiceLister, "__protocol_attrs__")
|
||||
|
||||
def test_voice_lister_satisfied_by_engine(self) -> None:
|
||||
engine = FakeVoiceListerEngine()
|
||||
assert isinstance(engine, VoiceLister)
|
||||
|
||||
def test_list_voices_returns_list(self) -> None:
|
||||
engine = FakeVoiceListerEngine()
|
||||
voices = engine.listVoices("builtin")
|
||||
assert isinstance(voices, list)
|
||||
|
||||
def test_list_voices_returns_voice_manifests(self) -> None:
|
||||
engine = FakeVoiceListerEngine()
|
||||
voices = engine.listVoices("builtin")
|
||||
for voice in voices:
|
||||
assert isinstance(voice, VoiceManifest)
|
||||
|
||||
def test_list_voices_has_required_fields(self) -> None:
|
||||
engine = FakeVoiceListerEngine()
|
||||
voices = engine.listVoices("builtin")
|
||||
for voice in voices:
|
||||
assert hasattr(voice, "id")
|
||||
assert hasattr(voice, "name")
|
||||
assert hasattr(voice, "tags")
|
||||
|
||||
|
||||
class TestPreviewGeneratorProtocolContract:
|
||||
"""Contract tests for PreviewGenerator protocol."""
|
||||
|
||||
def test_preview_generator_is_protocol(self) -> None:
|
||||
assert hasattr(PreviewGenerator, "__protocol_attrs__")
|
||||
|
||||
def test_preview_generator_satisfied_by_engine(self) -> None:
|
||||
from .conftest import FakePreviewEngine
|
||||
|
||||
engine = FakePreviewEngine()
|
||||
assert isinstance(engine, PreviewGenerator)
|
||||
|
||||
def test_generate_preview_returns_synthesized_audio(self) -> None:
|
||||
from .conftest import FakePreviewEngine
|
||||
|
||||
engine = FakePreviewEngine()
|
||||
voice = VoiceSelection(source="builtin", key="af_nova")
|
||||
result = engine.generatePreview(voice, "Hello")
|
||||
assert isinstance(result, SynthesizedAudio)
|
||||
|
||||
def test_generate_preview_has_valid_data(self) -> None:
|
||||
from .conftest import FakePreviewEngine
|
||||
|
||||
engine = FakePreviewEngine()
|
||||
voice = VoiceSelection(source="builtin", key="af_nova")
|
||||
result = engine.generatePreview(voice, "Hello")
|
||||
assert isinstance(result.data, bytes)
|
||||
assert len(result.data) > 0
|
||||
|
||||
|
||||
class TestStreamingSynthesizerProtocolContract:
|
||||
"""Contract tests for StreamingSynthesizer protocol."""
|
||||
|
||||
def test_streaming_synthesizer_is_protocol(self) -> None:
|
||||
assert hasattr(StreamingSynthesizer, "__protocol_attrs__")
|
||||
|
||||
def test_streaming_session_satisfies_protocol(self) -> None:
|
||||
session = FakeStreamingSession()
|
||||
assert isinstance(session, StreamingSynthesizer)
|
||||
|
||||
def test_synthesize_stream_yields_bytes(self) -> None:
|
||||
session = FakeStreamingSession()
|
||||
request = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
chunks = list(session.synthesizeStream(request))
|
||||
assert len(chunks) > 0
|
||||
for chunk in chunks:
|
||||
assert isinstance(chunk, bytes)
|
||||
|
||||
def test_streaming_iterator_exhaustion(self) -> None:
|
||||
"""Architecture spec: Iterator exhaustion = synthesis complete."""
|
||||
session = FakeStreamingSession()
|
||||
request = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
chunks = list(session.synthesizeStream(request))
|
||||
assert len(chunks) == 3
|
||||
|
||||
def test_streaming_after_dispose_raises(self) -> None:
|
||||
"""Architecture spec: After dispose(), methods raise EngineError."""
|
||||
session = FakeStreamingSession()
|
||||
session.dispose()
|
||||
request = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
with pytest.raises(EngineError):
|
||||
list(session.synthesizeStream(request))
|
||||
|
||||
|
||||
class TestCancelableSessionProtocolContract:
|
||||
"""Contract tests for CancelableSession protocol."""
|
||||
|
||||
def test_cancelable_session_is_protocol(self) -> None:
|
||||
assert hasattr(CancelableSession, "__protocol_attrs__")
|
||||
|
||||
def test_cancelable_session_satisfies_protocol(self) -> None:
|
||||
session = FakeCancelableSession()
|
||||
assert isinstance(session, CancelableSession)
|
||||
|
||||
def test_cancel_causes_synthesize_to_raise_cancelled(self) -> None:
|
||||
"""Architecture spec: cancel() causes synthesize() to raise CancelledError."""
|
||||
session = FakeCancelableSession()
|
||||
request = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
|
||||
# Cancel
|
||||
session.cancel()
|
||||
|
||||
# synthesize should raise CancelledError
|
||||
with pytest.raises(CancelledError):
|
||||
session.synthesize(request)
|
||||
|
||||
def test_cancel_after_dispose_raises(self) -> None:
|
||||
"""Architecture spec: cancel() raises EngineError if called after dispose()."""
|
||||
session = FakeCancelableSession()
|
||||
session.dispose()
|
||||
with pytest.raises(EngineError):
|
||||
session.cancel()
|
||||
|
||||
def test_session_usable_after_cancel(self) -> None:
|
||||
"""Architecture spec: EngineSession remains usable after cancellation."""
|
||||
session = FakeCancelableSession()
|
||||
|
||||
# Cancel
|
||||
session.cancel()
|
||||
|
||||
# Dispose and create new session for synthesis
|
||||
session.dispose()
|
||||
|
||||
@@ -1,106 +1,106 @@
|
||||
"""Contract tests for Engine protocol.
|
||||
|
||||
These tests verify that Engine implementations satisfy the architectural requirements:
|
||||
- createSession() returns EngineSession
|
||||
- dispose() is idempotent
|
||||
- After dispose(), createSession() raises EngineError
|
||||
- Engine is thread-safe for createSession()
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
||||
from abogen.tts_plugin.errors import EngineError
|
||||
|
||||
from .conftest import FakeEngine, FakeEngineSession
|
||||
|
||||
|
||||
class TestEngineProtocolContract:
|
||||
"""Contract tests for the Engine protocol itself."""
|
||||
|
||||
def test_engine_is_protocol(self) -> None:
|
||||
assert hasattr(Engine, "__protocol_attrs__")
|
||||
|
||||
def test_engine_session_is_protocol(self) -> None:
|
||||
assert hasattr(EngineSession, "__protocol_attrs__")
|
||||
|
||||
def test_fake_engine_satisfies_protocol(self) -> None:
|
||||
engine = FakeEngine()
|
||||
assert isinstance(engine, Engine)
|
||||
|
||||
def test_fake_session_satisfies_protocol(self) -> None:
|
||||
session = FakeEngineSession()
|
||||
assert isinstance(session, EngineSession)
|
||||
|
||||
|
||||
class TestEngineCreateSessionContract:
|
||||
"""Contract tests for Engine.createSession()."""
|
||||
|
||||
def test_create_session_returns_engine_session(self) -> None:
|
||||
engine = FakeEngine()
|
||||
session = engine.createSession()
|
||||
assert isinstance(session, EngineSession)
|
||||
|
||||
def test_create_session_returns_new_instance(self) -> None:
|
||||
engine = FakeEngine()
|
||||
session1 = engine.createSession()
|
||||
session2 = engine.createSession()
|
||||
assert session1 is not session2
|
||||
|
||||
def test_create_session_ownership_transfers(self) -> None:
|
||||
"""Architecture spec: Ownership transfers to caller."""
|
||||
engine = FakeEngine()
|
||||
session = engine.createSession()
|
||||
assert isinstance(session, EngineSession)
|
||||
|
||||
|
||||
class TestEngineDisposeContract:
|
||||
"""Contract tests for Engine.dispose()."""
|
||||
|
||||
def test_dispose_is_idempotent(self) -> None:
|
||||
"""Architecture spec: dispose() is idempotent."""
|
||||
engine = FakeEngine()
|
||||
engine.dispose()
|
||||
engine.dispose() # Should not raise
|
||||
|
||||
def test_dispose_never_raises(self) -> None:
|
||||
"""Architecture spec: dispose() never raises exceptions."""
|
||||
engine = FakeEngine()
|
||||
engine.dispose() # Should not raise
|
||||
|
||||
def test_create_session_after_dispose_raises(self) -> None:
|
||||
"""Architecture spec: After dispose(), all methods except dispose() raise EngineError."""
|
||||
engine = FakeEngine()
|
||||
engine.dispose()
|
||||
with pytest.raises(EngineError):
|
||||
engine.createSession()
|
||||
|
||||
|
||||
class TestEngineLifecycleContract:
|
||||
"""Contract tests for Engine lifecycle."""
|
||||
|
||||
def test_full_lifecycle(self) -> None:
|
||||
"""Test complete engine lifecycle: create -> sessions -> dispose."""
|
||||
engine = FakeEngine()
|
||||
|
||||
# Create sessions
|
||||
session1 = engine.createSession()
|
||||
session2 = engine.createSession()
|
||||
|
||||
# Use sessions
|
||||
assert isinstance(session1, EngineSession)
|
||||
assert isinstance(session2, EngineSession)
|
||||
|
||||
# Dispose sessions
|
||||
session1.dispose()
|
||||
session2.dispose()
|
||||
|
||||
# Dispose engine
|
||||
engine.dispose()
|
||||
|
||||
def test_engine_disposed_session_raises(self) -> None:
|
||||
"""Architecture spec: After dispose(), all methods except dispose() raise EngineError."""
|
||||
engine = FakeEngine()
|
||||
engine.dispose()
|
||||
with pytest.raises(EngineError):
|
||||
engine.createSession()
|
||||
"""Contract tests for Engine protocol.
|
||||
|
||||
These tests verify that Engine implementations satisfy the architectural requirements:
|
||||
- createSession() returns EngineSession
|
||||
- dispose() is idempotent
|
||||
- After dispose(), createSession() raises EngineError
|
||||
- Engine is thread-safe for createSession()
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
||||
from abogen.tts_plugin.errors import EngineError
|
||||
|
||||
from .conftest import FakeEngine, FakeEngineSession
|
||||
|
||||
|
||||
class TestEngineProtocolContract:
|
||||
"""Contract tests for the Engine protocol itself."""
|
||||
|
||||
def test_engine_is_protocol(self) -> None:
|
||||
assert hasattr(Engine, "__protocol_attrs__")
|
||||
|
||||
def test_engine_session_is_protocol(self) -> None:
|
||||
assert hasattr(EngineSession, "__protocol_attrs__")
|
||||
|
||||
def test_fake_engine_satisfies_protocol(self) -> None:
|
||||
engine = FakeEngine()
|
||||
assert isinstance(engine, Engine)
|
||||
|
||||
def test_fake_session_satisfies_protocol(self) -> None:
|
||||
session = FakeEngineSession()
|
||||
assert isinstance(session, EngineSession)
|
||||
|
||||
|
||||
class TestEngineCreateSessionContract:
|
||||
"""Contract tests for Engine.createSession()."""
|
||||
|
||||
def test_create_session_returns_engine_session(self) -> None:
|
||||
engine = FakeEngine()
|
||||
session = engine.createSession()
|
||||
assert isinstance(session, EngineSession)
|
||||
|
||||
def test_create_session_returns_new_instance(self) -> None:
|
||||
engine = FakeEngine()
|
||||
session1 = engine.createSession()
|
||||
session2 = engine.createSession()
|
||||
assert session1 is not session2
|
||||
|
||||
def test_create_session_ownership_transfers(self) -> None:
|
||||
"""Architecture spec: Ownership transfers to caller."""
|
||||
engine = FakeEngine()
|
||||
session = engine.createSession()
|
||||
assert isinstance(session, EngineSession)
|
||||
|
||||
|
||||
class TestEngineDisposeContract:
|
||||
"""Contract tests for Engine.dispose()."""
|
||||
|
||||
def test_dispose_is_idempotent(self) -> None:
|
||||
"""Architecture spec: dispose() is idempotent."""
|
||||
engine = FakeEngine()
|
||||
engine.dispose()
|
||||
engine.dispose() # Should not raise
|
||||
|
||||
def test_dispose_never_raises(self) -> None:
|
||||
"""Architecture spec: dispose() never raises exceptions."""
|
||||
engine = FakeEngine()
|
||||
engine.dispose() # Should not raise
|
||||
|
||||
def test_create_session_after_dispose_raises(self) -> None:
|
||||
"""Architecture spec: After dispose(), all methods except dispose() raise EngineError."""
|
||||
engine = FakeEngine()
|
||||
engine.dispose()
|
||||
with pytest.raises(EngineError):
|
||||
engine.createSession()
|
||||
|
||||
|
||||
class TestEngineLifecycleContract:
|
||||
"""Contract tests for Engine lifecycle."""
|
||||
|
||||
def test_full_lifecycle(self) -> None:
|
||||
"""Test complete engine lifecycle: create -> sessions -> dispose."""
|
||||
engine = FakeEngine()
|
||||
|
||||
# Create sessions
|
||||
session1 = engine.createSession()
|
||||
session2 = engine.createSession()
|
||||
|
||||
# Use sessions
|
||||
assert isinstance(session1, EngineSession)
|
||||
assert isinstance(session2, EngineSession)
|
||||
|
||||
# Dispose sessions
|
||||
session1.dispose()
|
||||
session2.dispose()
|
||||
|
||||
# Dispose engine
|
||||
engine.dispose()
|
||||
|
||||
def test_engine_disposed_session_raises(self) -> None:
|
||||
"""Architecture spec: After dispose(), all methods except dispose() raise EngineError."""
|
||||
engine = FakeEngine()
|
||||
engine.dispose()
|
||||
with pytest.raises(EngineError):
|
||||
engine.createSession()
|
||||
|
||||
@@ -1,85 +1,85 @@
|
||||
"""Contract tests for error hierarchy.
|
||||
|
||||
These tests verify that the error hierarchy satisfies the architectural requirements:
|
||||
- All errors inherit from EngineError
|
||||
- EngineError inherits from Exception
|
||||
- Each error type is properly classified
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.errors import (
|
||||
CancelledError,
|
||||
ConfigurationError,
|
||||
EngineError,
|
||||
InternalError,
|
||||
InvalidInputError,
|
||||
ModelLoadError,
|
||||
ModelNotFoundError,
|
||||
NetworkError,
|
||||
)
|
||||
|
||||
|
||||
class TestErrorHierarchyContract:
|
||||
"""Contract tests for the error hierarchy."""
|
||||
|
||||
def test_engine_error_is_exception(self) -> None:
|
||||
assert issubclass(EngineError, Exception)
|
||||
|
||||
def test_all_errors_inherit_from_engine_error(self) -> None:
|
||||
error_classes = [
|
||||
ModelNotFoundError,
|
||||
ModelLoadError,
|
||||
NetworkError,
|
||||
InvalidInputError,
|
||||
ConfigurationError,
|
||||
CancelledError,
|
||||
InternalError,
|
||||
]
|
||||
for error_class in error_classes:
|
||||
assert issubclass(error_class, EngineError), (
|
||||
f"{error_class.__name__} must inherit from EngineError"
|
||||
)
|
||||
|
||||
def test_all_errors_are_catchable(self) -> None:
|
||||
error_classes = [
|
||||
EngineError,
|
||||
ModelNotFoundError,
|
||||
ModelLoadError,
|
||||
NetworkError,
|
||||
InvalidInputError,
|
||||
ConfigurationError,
|
||||
CancelledError,
|
||||
InternalError,
|
||||
]
|
||||
for error_class in error_classes:
|
||||
with pytest.raises(EngineError):
|
||||
raise error_class("test message")
|
||||
|
||||
def test_error_message_preserved(self) -> None:
|
||||
msg = "Model not found: bert-base"
|
||||
with pytest.raises(ModelNotFoundError, match=msg):
|
||||
raise ModelNotFoundError(msg)
|
||||
|
||||
def test_error_can_be_caught_as_engine_error(self) -> None:
|
||||
with pytest.raises(EngineError):
|
||||
raise ModelNotFoundError("test")
|
||||
|
||||
def test_cancelled_error_is_engine_error(self) -> None:
|
||||
"""CancelledError is a subtype of EngineError per architecture spec."""
|
||||
assert issubclass(CancelledError, EngineError)
|
||||
|
||||
def test_error_hierarchy_no_cycles(self) -> None:
|
||||
"""Verify no circular inheritance."""
|
||||
error_classes = [
|
||||
EngineError,
|
||||
ModelNotFoundError,
|
||||
ModelLoadError,
|
||||
NetworkError,
|
||||
InvalidInputError,
|
||||
ConfigurationError,
|
||||
CancelledError,
|
||||
InternalError,
|
||||
]
|
||||
for cls in error_classes:
|
||||
assert cls not in cls.__bases__
|
||||
"""Contract tests for error hierarchy.
|
||||
|
||||
These tests verify that the error hierarchy satisfies the architectural requirements:
|
||||
- All errors inherit from EngineError
|
||||
- EngineError inherits from Exception
|
||||
- Each error type is properly classified
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.errors import (
|
||||
CancelledError,
|
||||
ConfigurationError,
|
||||
EngineError,
|
||||
InternalError,
|
||||
InvalidInputError,
|
||||
ModelLoadError,
|
||||
ModelNotFoundError,
|
||||
NetworkError,
|
||||
)
|
||||
|
||||
|
||||
class TestErrorHierarchyContract:
|
||||
"""Contract tests for the error hierarchy."""
|
||||
|
||||
def test_engine_error_is_exception(self) -> None:
|
||||
assert issubclass(EngineError, Exception)
|
||||
|
||||
def test_all_errors_inherit_from_engine_error(self) -> None:
|
||||
error_classes = [
|
||||
ModelNotFoundError,
|
||||
ModelLoadError,
|
||||
NetworkError,
|
||||
InvalidInputError,
|
||||
ConfigurationError,
|
||||
CancelledError,
|
||||
InternalError,
|
||||
]
|
||||
for error_class in error_classes:
|
||||
assert issubclass(error_class, EngineError), (
|
||||
f"{error_class.__name__} must inherit from EngineError"
|
||||
)
|
||||
|
||||
def test_all_errors_are_catchable(self) -> None:
|
||||
error_classes = [
|
||||
EngineError,
|
||||
ModelNotFoundError,
|
||||
ModelLoadError,
|
||||
NetworkError,
|
||||
InvalidInputError,
|
||||
ConfigurationError,
|
||||
CancelledError,
|
||||
InternalError,
|
||||
]
|
||||
for error_class in error_classes:
|
||||
with pytest.raises(EngineError):
|
||||
raise error_class("test message")
|
||||
|
||||
def test_error_message_preserved(self) -> None:
|
||||
msg = "Model not found: bert-base"
|
||||
with pytest.raises(ModelNotFoundError, match=msg):
|
||||
raise ModelNotFoundError(msg)
|
||||
|
||||
def test_error_can_be_caught_as_engine_error(self) -> None:
|
||||
with pytest.raises(EngineError):
|
||||
raise ModelNotFoundError("test")
|
||||
|
||||
def test_cancelled_error_is_engine_error(self) -> None:
|
||||
"""CancelledError is a subtype of EngineError per architecture spec."""
|
||||
assert issubclass(CancelledError, EngineError)
|
||||
|
||||
def test_error_hierarchy_no_cycles(self) -> None:
|
||||
"""Verify no circular inheritance."""
|
||||
error_classes = [
|
||||
EngineError,
|
||||
ModelNotFoundError,
|
||||
ModelLoadError,
|
||||
NetworkError,
|
||||
InvalidInputError,
|
||||
ConfigurationError,
|
||||
CancelledError,
|
||||
InternalError,
|
||||
]
|
||||
for cls in error_classes:
|
||||
assert cls not in cls.__bases__
|
||||
|
||||
@@ -1,89 +1,89 @@
|
||||
"""Contract tests for HostContext.
|
||||
|
||||
These tests verify that HostContext satisfies the architectural requirements:
|
||||
- Minimal (3 fields maximum)
|
||||
- Frozen dataclass
|
||||
- config_dir: Path
|
||||
- logger: Logger
|
||||
- http_client: HttpClient protocol
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.host_context import HttpClient, HostContext
|
||||
|
||||
|
||||
class TestHostContextContract:
|
||||
"""Contract tests for HostContext dataclass."""
|
||||
|
||||
def test_is_frozen_dataclass(self) -> None:
|
||||
assert hasattr(HostContext, "__dataclass_params__")
|
||||
assert HostContext.__dataclass_params__.frozen is True
|
||||
|
||||
def test_required_fields(self, tmp_path: Path) -> None:
|
||||
logger = logging.getLogger("test")
|
||||
|
||||
class FakeClient:
|
||||
def get(self, url: str, **kwargs: object) -> object:
|
||||
return None
|
||||
|
||||
def post(self, url: str, **kwargs: object) -> object:
|
||||
return None
|
||||
|
||||
ctx = HostContext(
|
||||
config_dir=tmp_path,
|
||||
logger=logger,
|
||||
http_client=FakeClient(),
|
||||
)
|
||||
assert ctx.config_dir == tmp_path
|
||||
assert ctx.logger is logger
|
||||
|
||||
def test_immutability(self, tmp_path: Path) -> None:
|
||||
class FakeClient:
|
||||
def get(self, url: str, **kwargs: object) -> object:
|
||||
return None
|
||||
|
||||
def post(self, url: str, **kwargs: object) -> object:
|
||||
return None
|
||||
|
||||
ctx = HostContext(
|
||||
config_dir=tmp_path,
|
||||
logger=logging.getLogger("test"),
|
||||
http_client=FakeClient(),
|
||||
)
|
||||
with pytest.raises(AttributeError):
|
||||
ctx.config_dir = Path("/other") # type: ignore[misc]
|
||||
|
||||
def test_max_three_fields(self) -> None:
|
||||
"""Architecture spec: HostContext is minimal (3 fields max)."""
|
||||
import dataclasses
|
||||
|
||||
fields = dataclasses.fields(HostContext)
|
||||
assert len(fields) <= 3
|
||||
|
||||
|
||||
class TestHttpClientProtocolContract:
|
||||
"""Contract tests for HttpClient protocol."""
|
||||
|
||||
def test_http_client_is_protocol(self) -> None:
|
||||
assert hasattr(HttpClient, "__protocol_attrs__")
|
||||
|
||||
def test_http_client_has_get(self) -> None:
|
||||
assert hasattr(HttpClient, "get")
|
||||
|
||||
def test_http_client_has_post(self) -> None:
|
||||
assert hasattr(HttpClient, "post")
|
||||
|
||||
def test_http_client_satisfied(self) -> None:
|
||||
class FakeClient:
|
||||
def get(self, url: str, **kwargs: object) -> object:
|
||||
return None
|
||||
|
||||
def post(self, url: str, **kwargs: object) -> object:
|
||||
return None
|
||||
|
||||
client = FakeClient()
|
||||
assert isinstance(client, HttpClient)
|
||||
"""Contract tests for HostContext.
|
||||
|
||||
These tests verify that HostContext satisfies the architectural requirements:
|
||||
- Minimal (3 fields maximum)
|
||||
- Frozen dataclass
|
||||
- config_dir: Path
|
||||
- logger: Logger
|
||||
- http_client: HttpClient protocol
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.host_context import HttpClient, HostContext
|
||||
|
||||
|
||||
class TestHostContextContract:
|
||||
"""Contract tests for HostContext dataclass."""
|
||||
|
||||
def test_is_frozen_dataclass(self) -> None:
|
||||
assert hasattr(HostContext, "__dataclass_params__")
|
||||
assert HostContext.__dataclass_params__.frozen is True
|
||||
|
||||
def test_required_fields(self, tmp_path: Path) -> None:
|
||||
logger = logging.getLogger("test")
|
||||
|
||||
class FakeClient:
|
||||
def get(self, url: str, **kwargs: object) -> object:
|
||||
return None
|
||||
|
||||
def post(self, url: str, **kwargs: object) -> object:
|
||||
return None
|
||||
|
||||
ctx = HostContext(
|
||||
config_dir=tmp_path,
|
||||
logger=logger,
|
||||
http_client=FakeClient(),
|
||||
)
|
||||
assert ctx.config_dir == tmp_path
|
||||
assert ctx.logger is logger
|
||||
|
||||
def test_immutability(self, tmp_path: Path) -> None:
|
||||
class FakeClient:
|
||||
def get(self, url: str, **kwargs: object) -> object:
|
||||
return None
|
||||
|
||||
def post(self, url: str, **kwargs: object) -> object:
|
||||
return None
|
||||
|
||||
ctx = HostContext(
|
||||
config_dir=tmp_path,
|
||||
logger=logging.getLogger("test"),
|
||||
http_client=FakeClient(),
|
||||
)
|
||||
with pytest.raises(AttributeError):
|
||||
ctx.config_dir = Path("/other") # type: ignore[misc]
|
||||
|
||||
def test_max_three_fields(self) -> None:
|
||||
"""Architecture spec: HostContext is minimal (3 fields max)."""
|
||||
import dataclasses
|
||||
|
||||
fields = dataclasses.fields(HostContext)
|
||||
assert len(fields) <= 3
|
||||
|
||||
|
||||
class TestHttpClientProtocolContract:
|
||||
"""Contract tests for HttpClient protocol."""
|
||||
|
||||
def test_http_client_is_protocol(self) -> None:
|
||||
assert hasattr(HttpClient, "__protocol_attrs__")
|
||||
|
||||
def test_http_client_has_get(self) -> None:
|
||||
assert hasattr(HttpClient, "get")
|
||||
|
||||
def test_http_client_has_post(self) -> None:
|
||||
assert hasattr(HttpClient, "post")
|
||||
|
||||
def test_http_client_satisfied(self) -> None:
|
||||
class FakeClient:
|
||||
def get(self, url: str, **kwargs: object) -> object:
|
||||
return None
|
||||
|
||||
def post(self, url: str, **kwargs: object) -> object:
|
||||
return None
|
||||
|
||||
client = FakeClient()
|
||||
assert isinstance(client, HttpClient)
|
||||
|
||||
+420
-420
@@ -1,420 +1,420 @@
|
||||
"""Integration tests for the TTS Plugin Architecture.
|
||||
|
||||
These tests verify:
|
||||
1. Consumer Flow: consumer → plugin → engine → session → synthesis → result
|
||||
2. Lifecycle: dispose, no leaks, error handling
|
||||
3. Regression: old path vs new path equivalence
|
||||
|
||||
Tests use mock plugins to avoid requiring real TTS dependencies.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from typing import Any, Iterator
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
|
||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
||||
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.utils import Pipeline, create_pipeline
|
||||
from abogen.tts_plugin.types import (
|
||||
AudioFormat,
|
||||
Duration,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
SynthesizedAudio,
|
||||
VoiceSelection,
|
||||
)
|
||||
|
||||
|
||||
class MockEngineSession:
|
||||
"""Mock EngineSession that records calls for verification."""
|
||||
|
||||
def __init__(self):
|
||||
self._disposed = False
|
||||
self.synthesize_calls = []
|
||||
|
||||
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
|
||||
if self._disposed:
|
||||
raise EngineError("Session disposed")
|
||||
|
||||
self.synthesize_calls.append(request)
|
||||
|
||||
# Return fake audio
|
||||
audio = np.ones(1000, dtype=np.float32) * 0.5
|
||||
return SynthesizedAudio(
|
||||
data=audio.tobytes(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
duration=Duration(seconds=1000 / 24000),
|
||||
)
|
||||
|
||||
def dispose(self) -> None:
|
||||
self._disposed = True
|
||||
|
||||
|
||||
class MockEngine:
|
||||
"""Mock Engine that creates MockEngineSessions."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
self._disposed = False
|
||||
self.sessions_created = []
|
||||
|
||||
def createSession(self) -> MockEngineSession:
|
||||
if self._disposed:
|
||||
raise EngineError("Engine disposed")
|
||||
session = MockEngineSession()
|
||||
self.sessions_created.append(session)
|
||||
return session
|
||||
|
||||
def dispose(self) -> None:
|
||||
self._disposed = True
|
||||
|
||||
|
||||
def create_mock_plugin(create_engine_func=None):
|
||||
"""Helper to create a mock plugin module."""
|
||||
if create_engine_func is None:
|
||||
create_engine_func = lambda **kwargs: MockEngine(**kwargs)
|
||||
|
||||
from abogen.tts_plugin.manifest import PluginManifest, EngineManifest
|
||||
|
||||
manifest = PluginManifest(
|
||||
id="mock_tts",
|
||||
name="Mock TTS",
|
||||
version="1.0.0",
|
||||
api_version="1.0",
|
||||
description="Mock TTS for testing",
|
||||
author="Test",
|
||||
capabilities=(),
|
||||
requires=None,
|
||||
engine=EngineManifest(
|
||||
voiceSources=(),
|
||||
parameters=(),
|
||||
audioFormats=(),
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
"PLUGIN_MANIFEST": manifest,
|
||||
"MODEL_REQUIREMENTS": [],
|
||||
"create_engine": create_mock_plugin_engine if create_engine_func is None else create_engine_func,
|
||||
}
|
||||
|
||||
|
||||
def create_mock_plugin_engine(**kwargs):
|
||||
"""Default mock plugin engine factory."""
|
||||
return MockEngine(**kwargs)
|
||||
|
||||
|
||||
class TestConsumerFlow:
|
||||
"""Consumer Flow Test: consumer → plugin → engine → session → synthesis → result"""
|
||||
|
||||
def test_full_consumer_flow(self):
|
||||
"""Verify complete flow from consumer to audio output."""
|
||||
manager = PluginManager()
|
||||
|
||||
# Register mock plugin
|
||||
mock_plugin = create_mock_plugin()
|
||||
manager._plugins["mock_tts"] = mock_plugin
|
||||
manager._loaded = True
|
||||
|
||||
# Step 1: Consumer gets plugin
|
||||
assert manager.has_plugin("mock_tts") is True
|
||||
|
||||
# Step 2: Plugin creates engine
|
||||
engine = manager.create_engine("mock_tts")
|
||||
assert engine is not None
|
||||
assert isinstance(engine, MockEngine)
|
||||
|
||||
# Step 3: Engine creates session
|
||||
session = engine.createSession()
|
||||
assert session is not None
|
||||
assert isinstance(session, MockEngineSession)
|
||||
|
||||
# Step 4: Session synthesizes
|
||||
request = SynthesisRequest(
|
||||
text="Hello world",
|
||||
voice=VoiceSelection(source="builtin", key="default"),
|
||||
parameters=ParameterValues(values={"speed": 1.0}),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
result = session.synthesize(request)
|
||||
|
||||
# Step 5: Result returned
|
||||
assert result is not None
|
||||
assert isinstance(result, SynthesizedAudio)
|
||||
assert len(result.data) > 0
|
||||
assert result.format.mime == "audio/wav"
|
||||
assert result.duration.seconds > 0
|
||||
|
||||
def test_consumer_flow_via_pipeline(self):
|
||||
"""Verify flow through Pipeline utility matches direct flow."""
|
||||
manager = PluginManager()
|
||||
|
||||
# Register mock plugin
|
||||
mock_plugin = create_mock_plugin()
|
||||
manager._plugins["mock_tts"] = mock_plugin
|
||||
manager._loaded = True
|
||||
|
||||
# Use Pipeline utility
|
||||
with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
|
||||
backend = create_pipeline("mock_tts")
|
||||
|
||||
# Call like old TTSBackend
|
||||
segments = list(backend("Hello world", voice="default", speed=1.0))
|
||||
|
||||
# Verify result
|
||||
assert len(segments) >= 1
|
||||
segment = segments[0]
|
||||
assert hasattr(segment, "graphemes")
|
||||
assert hasattr(segment, "audio")
|
||||
assert segment.graphemes == "Hello world"
|
||||
|
||||
|
||||
class TestLifecycle:
|
||||
"""Lifecycle Test: dispose, no leaks, error handling"""
|
||||
|
||||
def test_session_dispose_is_idempotent(self):
|
||||
"""dispose() can be called multiple times safely."""
|
||||
session = MockEngineSession()
|
||||
|
||||
session.dispose()
|
||||
session.dispose() # Should not raise
|
||||
assert session._disposed is True
|
||||
|
||||
def test_session_synthesize_after_dispose_raises(self):
|
||||
"""synthesize() after dispose() raises EngineError."""
|
||||
session = MockEngineSession()
|
||||
session.dispose()
|
||||
|
||||
request = SynthesisRequest(
|
||||
text="test",
|
||||
voice=VoiceSelection(source="builtin", key="default"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
|
||||
with pytest.raises(EngineError):
|
||||
session.synthesize(request)
|
||||
|
||||
def test_engine_dispose_is_idempotent(self):
|
||||
"""Engine dispose() can be called multiple times safely."""
|
||||
engine = MockEngine()
|
||||
|
||||
engine.dispose()
|
||||
engine.dispose() # Should not raise
|
||||
assert engine._disposed is True
|
||||
|
||||
def test_engine_create_session_after_dispose_raises(self):
|
||||
"""createSession() after dispose() raises EngineError."""
|
||||
engine = MockEngine()
|
||||
engine.dispose()
|
||||
|
||||
with pytest.raises(EngineError):
|
||||
engine.createSession()
|
||||
|
||||
def test_full_lifecycle(self):
|
||||
"""Test complete lifecycle: create → use → dispose."""
|
||||
engine = MockEngine()
|
||||
|
||||
# Create and use session
|
||||
session = engine.createSession()
|
||||
request = SynthesisRequest(
|
||||
text="test",
|
||||
voice=VoiceSelection(source="builtin", key="default"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
result = session.synthesize(request)
|
||||
assert len(result.data) > 0
|
||||
|
||||
# Dispose session
|
||||
session.dispose()
|
||||
assert session._disposed is True
|
||||
|
||||
# Dispose engine
|
||||
engine.dispose()
|
||||
assert engine._disposed is True
|
||||
|
||||
def test_no_session_leak_on_engine_dispose(self):
|
||||
"""Engine can be disposed even if sessions were created."""
|
||||
engine = MockEngine()
|
||||
|
||||
# Create multiple sessions
|
||||
session1 = engine.createSession()
|
||||
session2 = engine.createSession()
|
||||
|
||||
# Use sessions
|
||||
request = SynthesisRequest(
|
||||
text="test",
|
||||
voice=VoiceSelection(source="builtin", key="default"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
session1.synthesize(request)
|
||||
session2.synthesize(request)
|
||||
|
||||
# Dispose engine (sessions still exist but engine is disposed)
|
||||
engine.dispose()
|
||||
assert engine._disposed is True
|
||||
|
||||
# Sessions can still be used (they hold reference to pipeline)
|
||||
result = session1.synthesize(request)
|
||||
assert len(result.data) > 0
|
||||
|
||||
def test_error_handling_in_synthesis(self):
|
||||
"""Error during synthesis is handled correctly."""
|
||||
class FailingSession:
|
||||
def synthesize(self, request):
|
||||
raise EngineError("Synthesis failed")
|
||||
|
||||
def dispose(self):
|
||||
pass
|
||||
|
||||
session = FailingSession()
|
||||
request = SynthesisRequest(
|
||||
text="test",
|
||||
voice=VoiceSelection(source="builtin", key="default"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
|
||||
with pytest.raises(EngineError, match="Synthesis failed"):
|
||||
session.synthesize(request)
|
||||
|
||||
|
||||
class TestRegression:
|
||||
"""Regression Test: old path vs new path equivalence"""
|
||||
|
||||
def test_old_path_vs_new_path_same_result(self):
|
||||
"""Both paths should produce equivalent results."""
|
||||
# Setup mock plugin
|
||||
manager = PluginManager()
|
||||
mock_plugin = create_mock_plugin()
|
||||
manager._plugins["mock_tts"] = mock_plugin
|
||||
manager._loaded = True
|
||||
|
||||
# New path: Plugin Manager → Engine → Session → Synthesis
|
||||
with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
|
||||
new_backend = create_pipeline("mock_tts")
|
||||
new_segments = list(new_backend("Hello world", voice="default", speed=1.0))
|
||||
|
||||
# Old path: Direct MockEngine (simulating old registry)
|
||||
old_engine = MockEngine()
|
||||
old_session = old_engine.createSession()
|
||||
request = SynthesisRequest(
|
||||
text="Hello world",
|
||||
voice=VoiceSelection(source="builtin", key="default"),
|
||||
parameters=ParameterValues(values={"speed": 1.0}),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
old_result = old_session.synthesize(request)
|
||||
|
||||
# Compare results
|
||||
# New path returns segments, old path returns SynthesizedAudio
|
||||
# But both should have valid audio data
|
||||
assert len(new_segments) >= 1
|
||||
assert len(old_result.data) > 0
|
||||
|
||||
# Both should have same format
|
||||
assert new_segments[0].audio.dtype == np.float32
|
||||
|
||||
def test_pipeline_matches_old_interface(self):
|
||||
"""Pipeline utility should match old TTSBackend interface."""
|
||||
manager = PluginManager()
|
||||
mock_plugin = create_mock_plugin()
|
||||
manager._plugins["mock_tts"] = mock_plugin
|
||||
manager._loaded = True
|
||||
|
||||
with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
|
||||
backend = create_pipeline("mock_tts", lang_code="a", device="cpu")
|
||||
|
||||
# Old interface: pipeline(text, voice=..., speed=..., split_pattern=...)
|
||||
segments = list(backend(
|
||||
"Hello world",
|
||||
voice="af_heart",
|
||||
speed=1.0,
|
||||
split_pattern=r"\n+"
|
||||
))
|
||||
|
||||
# Should return segments with graphemes and audio
|
||||
assert len(segments) >= 1
|
||||
segment = segments[0]
|
||||
assert segment.graphemes == "Hello world"
|
||||
assert isinstance(segment.audio, np.ndarray)
|
||||
assert segment.audio.dtype == np.float32
|
||||
assert len(segment.audio) > 0
|
||||
|
||||
|
||||
class TestPluginManagerIntegration:
|
||||
"""Integration tests for PluginManager."""
|
||||
|
||||
def test_plugin_manager_singleton_pattern(self):
|
||||
"""Global plugin manager follows singleton pattern."""
|
||||
reset_plugin_manager()
|
||||
|
||||
manager1 = get_plugin_manager()
|
||||
manager2 = get_plugin_manager()
|
||||
|
||||
assert manager1 is manager2
|
||||
|
||||
reset_plugin_manager()
|
||||
|
||||
manager3 = get_plugin_manager()
|
||||
assert manager1 is not manager3
|
||||
|
||||
def test_plugin_manager_discover_plugins(self):
|
||||
"""Plugin manager can discover plugins from directory."""
|
||||
manager = PluginManager()
|
||||
|
||||
# Discover from test plugins directory
|
||||
manager.discover("tests/plugins")
|
||||
|
||||
# Should find valid_plugin
|
||||
# (This depends on test plugins existing)
|
||||
plugins = manager.list_plugins()
|
||||
assert isinstance(plugins, list)
|
||||
|
||||
def test_plugin_manager_dispose_all(self):
|
||||
"""Plugin manager can dispose all cached engines."""
|
||||
manager = PluginManager()
|
||||
|
||||
# Register mock plugin
|
||||
mock_plugin = create_mock_plugin()
|
||||
manager._plugins["mock_tts"] = mock_plugin
|
||||
manager._loaded = True
|
||||
|
||||
# Create engines
|
||||
engine1 = manager.get_or_create_engine("mock_tts")
|
||||
engine2 = manager.get_or_create_engine("mock_tts")
|
||||
|
||||
# Dispose all
|
||||
manager.dispose_all()
|
||||
|
||||
# Engines should be disposed
|
||||
assert engine1._disposed is True
|
||||
assert engine2._disposed is True
|
||||
|
||||
# Cache should be empty
|
||||
assert len(manager._engines) == 0
|
||||
|
||||
|
||||
class TestNoCompatLayer:
|
||||
"""Regression: confirm the compatibility layer has been removed."""
|
||||
|
||||
def test_compat_module_does_not_exist(self):
|
||||
"""abogen.tts_plugin.compat must not be importable."""
|
||||
import importlib
|
||||
with pytest.raises((ImportError, ModuleNotFoundError)):
|
||||
importlib.import_module("abogen.tts_plugin.compat")
|
||||
|
||||
def test_consumers_use_plugin_architecture_directly(self):
|
||||
"""Key consumers import from abogen.tts_plugin.utils, not compat."""
|
||||
import inspect, 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)
|
||||
assert "tts_plugin.compat" not in source, (
|
||||
f"{mod.__name__} still references tts_plugin.compat"
|
||||
)
|
||||
"""Integration tests for the TTS Plugin Architecture.
|
||||
|
||||
These tests verify:
|
||||
1. Consumer Flow: consumer → plugin → engine → session → synthesis → result
|
||||
2. Lifecycle: dispose, no leaks, error handling
|
||||
3. Regression: old path vs new path equivalence
|
||||
|
||||
Tests use mock plugins to avoid requiring real TTS dependencies.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from typing import Any, Iterator
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
|
||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
||||
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.utils import Pipeline, create_pipeline
|
||||
from abogen.tts_plugin.types import (
|
||||
AudioFormat,
|
||||
Duration,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
SynthesizedAudio,
|
||||
VoiceSelection,
|
||||
)
|
||||
|
||||
|
||||
class MockEngineSession:
|
||||
"""Mock EngineSession that records calls for verification."""
|
||||
|
||||
def __init__(self):
|
||||
self._disposed = False
|
||||
self.synthesize_calls = []
|
||||
|
||||
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
|
||||
if self._disposed:
|
||||
raise EngineError("Session disposed")
|
||||
|
||||
self.synthesize_calls.append(request)
|
||||
|
||||
# Return fake audio
|
||||
audio = np.ones(1000, dtype=np.float32) * 0.5
|
||||
return SynthesizedAudio(
|
||||
data=audio.tobytes(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
duration=Duration(seconds=1000 / 24000),
|
||||
)
|
||||
|
||||
def dispose(self) -> None:
|
||||
self._disposed = True
|
||||
|
||||
|
||||
class MockEngine:
|
||||
"""Mock Engine that creates MockEngineSessions."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
self._disposed = False
|
||||
self.sessions_created = []
|
||||
|
||||
def createSession(self) -> MockEngineSession:
|
||||
if self._disposed:
|
||||
raise EngineError("Engine disposed")
|
||||
session = MockEngineSession()
|
||||
self.sessions_created.append(session)
|
||||
return session
|
||||
|
||||
def dispose(self) -> None:
|
||||
self._disposed = True
|
||||
|
||||
|
||||
def create_mock_plugin(create_engine_func=None):
|
||||
"""Helper to create a mock plugin module."""
|
||||
if create_engine_func is None:
|
||||
create_engine_func = lambda **kwargs: MockEngine(**kwargs)
|
||||
|
||||
from abogen.tts_plugin.manifest import PluginManifest, EngineManifest
|
||||
|
||||
manifest = PluginManifest(
|
||||
id="mock_tts",
|
||||
name="Mock TTS",
|
||||
version="1.0.0",
|
||||
api_version="1.0",
|
||||
description="Mock TTS for testing",
|
||||
author="Test",
|
||||
capabilities=(),
|
||||
requires=None,
|
||||
engine=EngineManifest(
|
||||
voiceSources=(),
|
||||
parameters=(),
|
||||
audioFormats=(),
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
"PLUGIN_MANIFEST": manifest,
|
||||
"MODEL_REQUIREMENTS": [],
|
||||
"create_engine": create_mock_plugin_engine if create_engine_func is None else create_engine_func,
|
||||
}
|
||||
|
||||
|
||||
def create_mock_plugin_engine(**kwargs):
|
||||
"""Default mock plugin engine factory."""
|
||||
return MockEngine(**kwargs)
|
||||
|
||||
|
||||
class TestConsumerFlow:
|
||||
"""Consumer Flow Test: consumer → plugin → engine → session → synthesis → result"""
|
||||
|
||||
def test_full_consumer_flow(self):
|
||||
"""Verify complete flow from consumer to audio output."""
|
||||
manager = PluginManager()
|
||||
|
||||
# Register mock plugin
|
||||
mock_plugin = create_mock_plugin()
|
||||
manager._plugins["mock_tts"] = mock_plugin
|
||||
manager._loaded = True
|
||||
|
||||
# Step 1: Consumer gets plugin
|
||||
assert manager.has_plugin("mock_tts") is True
|
||||
|
||||
# Step 2: Plugin creates engine
|
||||
engine = manager.create_engine("mock_tts")
|
||||
assert engine is not None
|
||||
assert isinstance(engine, MockEngine)
|
||||
|
||||
# Step 3: Engine creates session
|
||||
session = engine.createSession()
|
||||
assert session is not None
|
||||
assert isinstance(session, MockEngineSession)
|
||||
|
||||
# Step 4: Session synthesizes
|
||||
request = SynthesisRequest(
|
||||
text="Hello world",
|
||||
voice=VoiceSelection(source="builtin", key="default"),
|
||||
parameters=ParameterValues(values={"speed": 1.0}),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
result = session.synthesize(request)
|
||||
|
||||
# Step 5: Result returned
|
||||
assert result is not None
|
||||
assert isinstance(result, SynthesizedAudio)
|
||||
assert len(result.data) > 0
|
||||
assert result.format.mime == "audio/wav"
|
||||
assert result.duration.seconds > 0
|
||||
|
||||
def test_consumer_flow_via_pipeline(self):
|
||||
"""Verify flow through Pipeline utility matches direct flow."""
|
||||
manager = PluginManager()
|
||||
|
||||
# Register mock plugin
|
||||
mock_plugin = create_mock_plugin()
|
||||
manager._plugins["mock_tts"] = mock_plugin
|
||||
manager._loaded = True
|
||||
|
||||
# Use Pipeline utility
|
||||
with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
|
||||
backend = create_pipeline("mock_tts")
|
||||
|
||||
# Call like old TTSBackend
|
||||
segments = list(backend("Hello world", voice="default", speed=1.0))
|
||||
|
||||
# Verify result
|
||||
assert len(segments) >= 1
|
||||
segment = segments[0]
|
||||
assert hasattr(segment, "graphemes")
|
||||
assert hasattr(segment, "audio")
|
||||
assert segment.graphemes == "Hello world"
|
||||
|
||||
|
||||
class TestLifecycle:
|
||||
"""Lifecycle Test: dispose, no leaks, error handling"""
|
||||
|
||||
def test_session_dispose_is_idempotent(self):
|
||||
"""dispose() can be called multiple times safely."""
|
||||
session = MockEngineSession()
|
||||
|
||||
session.dispose()
|
||||
session.dispose() # Should not raise
|
||||
assert session._disposed is True
|
||||
|
||||
def test_session_synthesize_after_dispose_raises(self):
|
||||
"""synthesize() after dispose() raises EngineError."""
|
||||
session = MockEngineSession()
|
||||
session.dispose()
|
||||
|
||||
request = SynthesisRequest(
|
||||
text="test",
|
||||
voice=VoiceSelection(source="builtin", key="default"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
|
||||
with pytest.raises(EngineError):
|
||||
session.synthesize(request)
|
||||
|
||||
def test_engine_dispose_is_idempotent(self):
|
||||
"""Engine dispose() can be called multiple times safely."""
|
||||
engine = MockEngine()
|
||||
|
||||
engine.dispose()
|
||||
engine.dispose() # Should not raise
|
||||
assert engine._disposed is True
|
||||
|
||||
def test_engine_create_session_after_dispose_raises(self):
|
||||
"""createSession() after dispose() raises EngineError."""
|
||||
engine = MockEngine()
|
||||
engine.dispose()
|
||||
|
||||
with pytest.raises(EngineError):
|
||||
engine.createSession()
|
||||
|
||||
def test_full_lifecycle(self):
|
||||
"""Test complete lifecycle: create → use → dispose."""
|
||||
engine = MockEngine()
|
||||
|
||||
# Create and use session
|
||||
session = engine.createSession()
|
||||
request = SynthesisRequest(
|
||||
text="test",
|
||||
voice=VoiceSelection(source="builtin", key="default"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
result = session.synthesize(request)
|
||||
assert len(result.data) > 0
|
||||
|
||||
# Dispose session
|
||||
session.dispose()
|
||||
assert session._disposed is True
|
||||
|
||||
# Dispose engine
|
||||
engine.dispose()
|
||||
assert engine._disposed is True
|
||||
|
||||
def test_no_session_leak_on_engine_dispose(self):
|
||||
"""Engine can be disposed even if sessions were created."""
|
||||
engine = MockEngine()
|
||||
|
||||
# Create multiple sessions
|
||||
session1 = engine.createSession()
|
||||
session2 = engine.createSession()
|
||||
|
||||
# Use sessions
|
||||
request = SynthesisRequest(
|
||||
text="test",
|
||||
voice=VoiceSelection(source="builtin", key="default"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
session1.synthesize(request)
|
||||
session2.synthesize(request)
|
||||
|
||||
# Dispose engine (sessions still exist but engine is disposed)
|
||||
engine.dispose()
|
||||
assert engine._disposed is True
|
||||
|
||||
# Sessions can still be used (they hold reference to pipeline)
|
||||
result = session1.synthesize(request)
|
||||
assert len(result.data) > 0
|
||||
|
||||
def test_error_handling_in_synthesis(self):
|
||||
"""Error during synthesis is handled correctly."""
|
||||
class FailingSession:
|
||||
def synthesize(self, request):
|
||||
raise EngineError("Synthesis failed")
|
||||
|
||||
def dispose(self):
|
||||
pass
|
||||
|
||||
session = FailingSession()
|
||||
request = SynthesisRequest(
|
||||
text="test",
|
||||
voice=VoiceSelection(source="builtin", key="default"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
|
||||
with pytest.raises(EngineError, match="Synthesis failed"):
|
||||
session.synthesize(request)
|
||||
|
||||
|
||||
class TestRegression:
|
||||
"""Regression Test: old path vs new path equivalence"""
|
||||
|
||||
def test_old_path_vs_new_path_same_result(self):
|
||||
"""Both paths should produce equivalent results."""
|
||||
# Setup mock plugin
|
||||
manager = PluginManager()
|
||||
mock_plugin = create_mock_plugin()
|
||||
manager._plugins["mock_tts"] = mock_plugin
|
||||
manager._loaded = True
|
||||
|
||||
# New path: Plugin Manager → Engine → Session → Synthesis
|
||||
with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
|
||||
new_backend = create_pipeline("mock_tts")
|
||||
new_segments = list(new_backend("Hello world", voice="default", speed=1.0))
|
||||
|
||||
# Old path: Direct MockEngine (simulating old registry)
|
||||
old_engine = MockEngine()
|
||||
old_session = old_engine.createSession()
|
||||
request = SynthesisRequest(
|
||||
text="Hello world",
|
||||
voice=VoiceSelection(source="builtin", key="default"),
|
||||
parameters=ParameterValues(values={"speed": 1.0}),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
old_result = old_session.synthesize(request)
|
||||
|
||||
# Compare results
|
||||
# New path returns segments, old path returns SynthesizedAudio
|
||||
# But both should have valid audio data
|
||||
assert len(new_segments) >= 1
|
||||
assert len(old_result.data) > 0
|
||||
|
||||
# Both should have same format
|
||||
assert new_segments[0].audio.dtype == np.float32
|
||||
|
||||
def test_pipeline_matches_old_interface(self):
|
||||
"""Pipeline utility should match old TTSBackend interface."""
|
||||
manager = PluginManager()
|
||||
mock_plugin = create_mock_plugin()
|
||||
manager._plugins["mock_tts"] = mock_plugin
|
||||
manager._loaded = True
|
||||
|
||||
with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
|
||||
backend = create_pipeline("mock_tts", lang_code="a", device="cpu")
|
||||
|
||||
# Old interface: pipeline(text, voice=..., speed=..., split_pattern=...)
|
||||
segments = list(backend(
|
||||
"Hello world",
|
||||
voice="af_heart",
|
||||
speed=1.0,
|
||||
split_pattern=r"\n+"
|
||||
))
|
||||
|
||||
# Should return segments with graphemes and audio
|
||||
assert len(segments) >= 1
|
||||
segment = segments[0]
|
||||
assert segment.graphemes == "Hello world"
|
||||
assert isinstance(segment.audio, np.ndarray)
|
||||
assert segment.audio.dtype == np.float32
|
||||
assert len(segment.audio) > 0
|
||||
|
||||
|
||||
class TestPluginManagerIntegration:
|
||||
"""Integration tests for PluginManager."""
|
||||
|
||||
def test_plugin_manager_singleton_pattern(self):
|
||||
"""Global plugin manager follows singleton pattern."""
|
||||
reset_plugin_manager()
|
||||
|
||||
manager1 = get_plugin_manager()
|
||||
manager2 = get_plugin_manager()
|
||||
|
||||
assert manager1 is manager2
|
||||
|
||||
reset_plugin_manager()
|
||||
|
||||
manager3 = get_plugin_manager()
|
||||
assert manager1 is not manager3
|
||||
|
||||
def test_plugin_manager_discover_plugins(self):
|
||||
"""Plugin manager can discover plugins from directory."""
|
||||
manager = PluginManager()
|
||||
|
||||
# Discover from test plugins directory
|
||||
manager.discover("tests/plugins")
|
||||
|
||||
# Should find valid_plugin
|
||||
# (This depends on test plugins existing)
|
||||
plugins = manager.list_plugins()
|
||||
assert isinstance(plugins, list)
|
||||
|
||||
def test_plugin_manager_dispose_all(self):
|
||||
"""Plugin manager can dispose all cached engines."""
|
||||
manager = PluginManager()
|
||||
|
||||
# Register mock plugin
|
||||
mock_plugin = create_mock_plugin()
|
||||
manager._plugins["mock_tts"] = mock_plugin
|
||||
manager._loaded = True
|
||||
|
||||
# Create engines
|
||||
engine1 = manager.get_or_create_engine("mock_tts")
|
||||
engine2 = manager.get_or_create_engine("mock_tts")
|
||||
|
||||
# Dispose all
|
||||
manager.dispose_all()
|
||||
|
||||
# Engines should be disposed
|
||||
assert engine1._disposed is True
|
||||
assert engine2._disposed is True
|
||||
|
||||
# Cache should be empty
|
||||
assert len(manager._engines) == 0
|
||||
|
||||
|
||||
class TestNoCompatLayer:
|
||||
"""Regression: confirm the compatibility layer has been removed."""
|
||||
|
||||
def test_compat_module_does_not_exist(self):
|
||||
"""abogen.tts_plugin.compat must not be importable."""
|
||||
import importlib
|
||||
with pytest.raises((ImportError, ModuleNotFoundError)):
|
||||
importlib.import_module("abogen.tts_plugin.compat")
|
||||
|
||||
def test_consumers_use_plugin_architecture_directly(self):
|
||||
"""Key consumers import from abogen.tts_plugin.utils, not compat."""
|
||||
import inspect, 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)
|
||||
assert "tts_plugin.compat" not in source, (
|
||||
f"{mod.__name__} still references tts_plugin.compat"
|
||||
)
|
||||
|
||||
@@ -1,436 +1,436 @@
|
||||
"""Comprehensive tests for the plugin loader infrastructure.
|
||||
|
||||
These tests verify that the loader correctly:
|
||||
- Discovers plugins in directories
|
||||
- Imports plugin modules
|
||||
- Validates PLUGIN_MANIFEST, MODEL_REQUIREMENTS, create_engine
|
||||
- Validates api_version compatibility
|
||||
- Validates capabilities
|
||||
- Provides diagnostic messages for errors
|
||||
- Rejects invalid plugins
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.loader import (
|
||||
HOST_API_VERSION,
|
||||
PluginLoadError,
|
||||
PluginLoadResult,
|
||||
_check_api_version_compatibility,
|
||||
_parse_api_version,
|
||||
_validate_api_version,
|
||||
_validate_capabilities,
|
||||
_validate_manifest,
|
||||
discover_plugins,
|
||||
load_plugin,
|
||||
load_plugin_from_dir,
|
||||
)
|
||||
from abogen.tts_plugin.manifest import (
|
||||
EngineManifest,
|
||||
ModelManifest,
|
||||
PluginManifest,
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Path fixtures
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def plugins_dir() -> Path:
|
||||
return Path(__file__).parent.parent / "plugins"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_plugin_dir(plugins_dir: Path) -> Path:
|
||||
return plugins_dir / "fake_plugin"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def missing_manifest_dir(plugins_dir: Path) -> Path:
|
||||
return plugins_dir / "missing_manifest"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def invalid_api_version_dir(plugins_dir: Path) -> Path:
|
||||
return plugins_dir / "invalid_api_version"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def invalid_capabilities_dir(plugins_dir: Path) -> Path:
|
||||
return plugins_dir / "invalid_capabilities"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def missing_create_engine_dir(plugins_dir: Path) -> Path:
|
||||
return plugins_dir / "missing_create_engine"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def import_error_dir(plugins_dir: Path) -> Path:
|
||||
return plugins_dir / "import_error"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def missing_model_requirements_dir(plugins_dir: Path) -> Path:
|
||||
return plugins_dir / "missing_model_requirements"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Unit tests: _parse_api_version
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestParseApiVersion:
|
||||
def test_valid_version(self) -> None:
|
||||
assert _parse_api_version("1.0") == (1, 0)
|
||||
assert _parse_api_version("2.5") == (2, 5)
|
||||
assert _parse_api_version("10.20") == (10, 20)
|
||||
|
||||
def test_invalid_format(self) -> None:
|
||||
assert _parse_api_version("1") is None
|
||||
assert _parse_api_version("1.0.0") is None
|
||||
assert _parse_api_version("abc") is None
|
||||
assert _parse_api_version("") is None
|
||||
assert _parse_api_version("1.x") is None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Unit tests: _check_api_version_compatibility
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestCheckApiVersionCompatibility:
|
||||
def test_compatible_version(self) -> None:
|
||||
assert _check_api_version_compatibility("1.0") is None
|
||||
assert _check_api_version_compatibility("1.5") is None
|
||||
|
||||
def test_major_mismatch(self) -> None:
|
||||
error = _check_api_version_compatibility("2.0")
|
||||
assert error is not None
|
||||
assert "major mismatch" in error
|
||||
|
||||
def test_invalid_format(self) -> None:
|
||||
error = _check_api_version_compatibility("invalid")
|
||||
assert error is not None
|
||||
assert "Invalid api_version format" in error
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Unit tests: _validate_manifest
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestValidateManifest:
|
||||
def test_valid_manifest(self) -> None:
|
||||
class FakeModule:
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="1.0", description="Test", author="Test",
|
||||
)
|
||||
MODEL_REQUIREMENTS: list = []
|
||||
create_engine = lambda *a, **kw: None
|
||||
|
||||
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
||||
assert errors == []
|
||||
|
||||
def test_missing_manifest(self) -> None:
|
||||
class FakeModule:
|
||||
MODEL_REQUIREMENTS: list = []
|
||||
create_engine = lambda *a, **kw: None
|
||||
|
||||
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
||||
assert any("Missing PLUGIN_MANIFEST" in e for e in errors)
|
||||
|
||||
def test_wrong_manifest_type(self) -> None:
|
||||
class FakeModule:
|
||||
PLUGIN_MANIFEST = "not a manifest"
|
||||
MODEL_REQUIREMENTS: list = []
|
||||
create_engine = lambda *a, **kw: None
|
||||
|
||||
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
||||
assert any("PluginManifest instance" in e for e in errors)
|
||||
|
||||
def test_missing_model_requirements(self) -> None:
|
||||
class FakeModule:
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="1.0", description="Test", author="Test",
|
||||
)
|
||||
create_engine = lambda *a, **kw: None
|
||||
|
||||
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
||||
assert any("Missing MODEL_REQUIREMENTS" in e for e in errors)
|
||||
|
||||
def test_wrong_model_requirements_type(self) -> None:
|
||||
class FakeModule:
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="1.0", description="Test", author="Test",
|
||||
)
|
||||
MODEL_REQUIREMENTS = "not a list"
|
||||
create_engine = lambda *a, **kw: None
|
||||
|
||||
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
||||
assert any("must be a list" in e for e in errors)
|
||||
|
||||
def test_invalid_model_requirements_item(self) -> None:
|
||||
class FakeModule:
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="1.0", description="Test", author="Test",
|
||||
)
|
||||
MODEL_REQUIREMENTS = ["not a model manifest"]
|
||||
create_engine = lambda *a, **kw: None
|
||||
|
||||
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
||||
assert any("ModelManifest instance" in e for e in errors)
|
||||
|
||||
def test_missing_create_engine(self) -> None:
|
||||
class FakeModule:
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="1.0", description="Test", author="Test",
|
||||
)
|
||||
MODEL_REQUIREMENTS: list = []
|
||||
|
||||
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
||||
assert any("Missing create_engine" in e for e in errors)
|
||||
|
||||
def test_create_engine_not_callable(self) -> None:
|
||||
class FakeModule:
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="1.0", description="Test", author="Test",
|
||||
)
|
||||
MODEL_REQUIREMENTS: list = []
|
||||
create_engine = "not callable"
|
||||
|
||||
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
||||
assert any("must be callable" in e for e in errors)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Unit tests: _validate_capabilities
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestValidateCapabilities:
|
||||
def test_valid_capabilities(self) -> None:
|
||||
manifest = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="1.0", description="Test", author="Test",
|
||||
capabilities=("voice_list", "preview"),
|
||||
)
|
||||
errors = _validate_capabilities(manifest)
|
||||
assert errors == []
|
||||
|
||||
def test_unknown_capability(self) -> None:
|
||||
manifest = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="1.0", description="Test", author="Test",
|
||||
capabilities=("voice_list", "unknown_cap"),
|
||||
)
|
||||
errors = _validate_capabilities(manifest)
|
||||
assert any("unknown_cap" in e for e in errors)
|
||||
|
||||
def test_empty_capabilities(self) -> None:
|
||||
manifest = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="1.0", description="Test", author="Test",
|
||||
capabilities=(),
|
||||
)
|
||||
errors = _validate_capabilities(manifest)
|
||||
assert errors == []
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Unit tests: _validate_api_version
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestValidateApiVersion:
|
||||
def test_compatible(self) -> None:
|
||||
manifest = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="1.0", description="Test", author="Test",
|
||||
)
|
||||
errors = _validate_api_version(manifest)
|
||||
assert errors == []
|
||||
|
||||
def test_incompatible(self) -> None:
|
||||
manifest = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="2.0", description="Test", author="Test",
|
||||
)
|
||||
errors = _validate_api_version(manifest)
|
||||
assert len(errors) > 0
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Integration tests: load_plugin_from_dir
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestLoadPluginFromDir:
|
||||
def test_load_valid_plugin(self, fake_plugin_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(fake_plugin_dir)
|
||||
assert result.success is True
|
||||
assert result.manifest is not None
|
||||
assert result.manifest.id == "fake_plugin"
|
||||
assert result.model_requirements is not None
|
||||
assert result.create_engine is not None
|
||||
assert result.module is not None
|
||||
assert result.error is None
|
||||
|
||||
def test_plugin_satisfies_protocol(self, fake_plugin_dir: Path) -> None:
|
||||
from abogen.tts_plugin.engine import Engine
|
||||
from abogen.tts_plugin.host_context import HostContext
|
||||
import logging
|
||||
|
||||
result = load_plugin_from_dir(fake_plugin_dir)
|
||||
assert result.success is True
|
||||
|
||||
# Create engine using the loaded create_engine function
|
||||
ctx = HostContext(
|
||||
config_dir=Path("/tmp/test"),
|
||||
logger=logging.getLogger("test"),
|
||||
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())
|
||||
assert isinstance(engine, Engine)
|
||||
engine.dispose()
|
||||
|
||||
def test_nonexistent_directory(self, tmp_path: Path) -> None:
|
||||
result = load_plugin_from_dir(tmp_path / "nonexistent")
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
assert "does not exist" in result.error.errors[0]
|
||||
|
||||
def test_missing_init_file(self, tmp_path: Path) -> None:
|
||||
plugin_dir = tmp_path / "no_init"
|
||||
plugin_dir.mkdir()
|
||||
result = load_plugin_from_dir(plugin_dir)
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
assert "__init__.py" in result.error.errors[0]
|
||||
|
||||
def test_import_error(self, import_error_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(import_error_dir)
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
assert "Failed to import" in result.error.errors[0]
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Integration tests: invalid plugins
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestInvalidPlugins:
|
||||
def test_missing_manifest(self, missing_manifest_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(missing_manifest_dir)
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
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:
|
||||
result = load_plugin_from_dir(invalid_api_version_dir)
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
assert any("major mismatch" in e for e in result.error.errors)
|
||||
|
||||
def test_invalid_capabilities(self, invalid_capabilities_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(invalid_capabilities_dir)
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
assert any("Unknown capability" in e for e in result.error.errors)
|
||||
|
||||
def test_missing_create_engine(self, missing_create_engine_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(missing_create_engine_dir)
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
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:
|
||||
result = load_plugin_from_dir(missing_model_requirements_dir)
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
assert any("Missing MODEL_REQUIREMENTS" in e for e in result.error.errors)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Integration tests: discover_plugins
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestDiscoverPlugins:
|
||||
def test_discover_from_valid_dir(self, plugins_dir: Path) -> None:
|
||||
results = discover_plugins([plugins_dir])
|
||||
# Should find multiple plugins (valid and invalid)
|
||||
assert len(results) > 0
|
||||
|
||||
def test_discover_includes_valid_plugin(self, plugins_dir: Path) -> None:
|
||||
results = discover_plugins([plugins_dir])
|
||||
valid = [r for r in results if r.success]
|
||||
assert len(valid) >= 1
|
||||
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:
|
||||
results = discover_plugins([plugins_dir])
|
||||
invalid = [r for r in results if not r.success]
|
||||
assert len(invalid) >= 1
|
||||
|
||||
def test_discover_nonexistent_dir(self, tmp_path: Path) -> None:
|
||||
results = discover_plugins([tmp_path / "nonexistent"])
|
||||
assert results == []
|
||||
|
||||
def test_discover_multiple_dirs(self, plugins_dir: Path, tmp_path: Path) -> None:
|
||||
results = discover_plugins([plugins_dir, tmp_path / "nonexistent"])
|
||||
assert len(results) > 0
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Diagnostic messages tests
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestDiagnosticMessages:
|
||||
def test_error_contains_plugin_id(self, missing_manifest_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(missing_manifest_dir)
|
||||
assert result.error is not None
|
||||
assert result.error.plugin_id == "missing_manifest"
|
||||
|
||||
def test_error_contains_path(self, missing_manifest_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(missing_manifest_dir)
|
||||
assert result.error is not None
|
||||
assert result.error.path == missing_manifest_dir
|
||||
|
||||
def test_error_contains_messages(self, missing_manifest_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(missing_manifest_dir)
|
||||
assert result.error is not None
|
||||
assert len(result.error.errors) > 0
|
||||
|
||||
def test_multiple_errors(self, invalid_api_version_dir: Path) -> None:
|
||||
# This plugin has multiple issues
|
||||
result = load_plugin_from_dir(invalid_api_version_dir)
|
||||
assert result.error is not None
|
||||
# Should have at least the api_version error
|
||||
assert len(result.error.errors) >= 1
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# No partial registration tests
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestNoPartialRegistration:
|
||||
def test_invalid_plugin_no_manifest_attr(self, missing_manifest_dir: Path) -> None:
|
||||
"""After failed load, module should not remain in sys.modules."""
|
||||
result = load_plugin_from_dir(missing_manifest_dir)
|
||||
assert result.success is False
|
||||
# Module should not be registered
|
||||
module_name = f"abogen.tts_plugin._loaded.missing_manifest"
|
||||
assert module_name not in sys.modules
|
||||
|
||||
def test_import_error_no_registration(self, import_error_dir: Path) -> None:
|
||||
"""After import error, module should not remain in sys.modules."""
|
||||
result = load_plugin_from_dir(import_error_dir)
|
||||
assert result.success is False
|
||||
module_name = f"abogen.tts_plugin._loaded.import_error"
|
||||
assert module_name not in sys.modules
|
||||
"""Comprehensive tests for the plugin loader infrastructure.
|
||||
|
||||
These tests verify that the loader correctly:
|
||||
- Discovers plugins in directories
|
||||
- Imports plugin modules
|
||||
- Validates PLUGIN_MANIFEST, MODEL_REQUIREMENTS, create_engine
|
||||
- Validates api_version compatibility
|
||||
- Validates capabilities
|
||||
- Provides diagnostic messages for errors
|
||||
- Rejects invalid plugins
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.loader import (
|
||||
HOST_API_VERSION,
|
||||
PluginLoadError,
|
||||
PluginLoadResult,
|
||||
_check_api_version_compatibility,
|
||||
_parse_api_version,
|
||||
_validate_api_version,
|
||||
_validate_capabilities,
|
||||
_validate_manifest,
|
||||
discover_plugins,
|
||||
load_plugin,
|
||||
load_plugin_from_dir,
|
||||
)
|
||||
from abogen.tts_plugin.manifest import (
|
||||
EngineManifest,
|
||||
ModelManifest,
|
||||
PluginManifest,
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Path fixtures
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def plugins_dir() -> Path:
|
||||
return Path(__file__).parent.parent / "plugins"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_plugin_dir(plugins_dir: Path) -> Path:
|
||||
return plugins_dir / "fake_plugin"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def missing_manifest_dir(plugins_dir: Path) -> Path:
|
||||
return plugins_dir / "missing_manifest"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def invalid_api_version_dir(plugins_dir: Path) -> Path:
|
||||
return plugins_dir / "invalid_api_version"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def invalid_capabilities_dir(plugins_dir: Path) -> Path:
|
||||
return plugins_dir / "invalid_capabilities"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def missing_create_engine_dir(plugins_dir: Path) -> Path:
|
||||
return plugins_dir / "missing_create_engine"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def import_error_dir(plugins_dir: Path) -> Path:
|
||||
return plugins_dir / "import_error"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def missing_model_requirements_dir(plugins_dir: Path) -> Path:
|
||||
return plugins_dir / "missing_model_requirements"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Unit tests: _parse_api_version
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestParseApiVersion:
|
||||
def test_valid_version(self) -> None:
|
||||
assert _parse_api_version("1.0") == (1, 0)
|
||||
assert _parse_api_version("2.5") == (2, 5)
|
||||
assert _parse_api_version("10.20") == (10, 20)
|
||||
|
||||
def test_invalid_format(self) -> None:
|
||||
assert _parse_api_version("1") is None
|
||||
assert _parse_api_version("1.0.0") is None
|
||||
assert _parse_api_version("abc") is None
|
||||
assert _parse_api_version("") is None
|
||||
assert _parse_api_version("1.x") is None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Unit tests: _check_api_version_compatibility
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestCheckApiVersionCompatibility:
|
||||
def test_compatible_version(self) -> None:
|
||||
assert _check_api_version_compatibility("1.0") is None
|
||||
assert _check_api_version_compatibility("1.5") is None
|
||||
|
||||
def test_major_mismatch(self) -> None:
|
||||
error = _check_api_version_compatibility("2.0")
|
||||
assert error is not None
|
||||
assert "major mismatch" in error
|
||||
|
||||
def test_invalid_format(self) -> None:
|
||||
error = _check_api_version_compatibility("invalid")
|
||||
assert error is not None
|
||||
assert "Invalid api_version format" in error
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Unit tests: _validate_manifest
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestValidateManifest:
|
||||
def test_valid_manifest(self) -> None:
|
||||
class FakeModule:
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="1.0", description="Test", author="Test",
|
||||
)
|
||||
MODEL_REQUIREMENTS: list = []
|
||||
create_engine = lambda *a, **kw: None
|
||||
|
||||
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
||||
assert errors == []
|
||||
|
||||
def test_missing_manifest(self) -> None:
|
||||
class FakeModule:
|
||||
MODEL_REQUIREMENTS: list = []
|
||||
create_engine = lambda *a, **kw: None
|
||||
|
||||
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
||||
assert any("Missing PLUGIN_MANIFEST" in e for e in errors)
|
||||
|
||||
def test_wrong_manifest_type(self) -> None:
|
||||
class FakeModule:
|
||||
PLUGIN_MANIFEST = "not a manifest"
|
||||
MODEL_REQUIREMENTS: list = []
|
||||
create_engine = lambda *a, **kw: None
|
||||
|
||||
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
||||
assert any("PluginManifest instance" in e for e in errors)
|
||||
|
||||
def test_missing_model_requirements(self) -> None:
|
||||
class FakeModule:
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="1.0", description="Test", author="Test",
|
||||
)
|
||||
create_engine = lambda *a, **kw: None
|
||||
|
||||
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
||||
assert any("Missing MODEL_REQUIREMENTS" in e for e in errors)
|
||||
|
||||
def test_wrong_model_requirements_type(self) -> None:
|
||||
class FakeModule:
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="1.0", description="Test", author="Test",
|
||||
)
|
||||
MODEL_REQUIREMENTS = "not a list"
|
||||
create_engine = lambda *a, **kw: None
|
||||
|
||||
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
||||
assert any("must be a list" in e for e in errors)
|
||||
|
||||
def test_invalid_model_requirements_item(self) -> None:
|
||||
class FakeModule:
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="1.0", description="Test", author="Test",
|
||||
)
|
||||
MODEL_REQUIREMENTS = ["not a model manifest"]
|
||||
create_engine = lambda *a, **kw: None
|
||||
|
||||
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
||||
assert any("ModelManifest instance" in e for e in errors)
|
||||
|
||||
def test_missing_create_engine(self) -> None:
|
||||
class FakeModule:
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="1.0", description="Test", author="Test",
|
||||
)
|
||||
MODEL_REQUIREMENTS: list = []
|
||||
|
||||
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
||||
assert any("Missing create_engine" in e for e in errors)
|
||||
|
||||
def test_create_engine_not_callable(self) -> None:
|
||||
class FakeModule:
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="1.0", description="Test", author="Test",
|
||||
)
|
||||
MODEL_REQUIREMENTS: list = []
|
||||
create_engine = "not callable"
|
||||
|
||||
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
||||
assert any("must be callable" in e for e in errors)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Unit tests: _validate_capabilities
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestValidateCapabilities:
|
||||
def test_valid_capabilities(self) -> None:
|
||||
manifest = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="1.0", description="Test", author="Test",
|
||||
capabilities=("voice_list", "preview"),
|
||||
)
|
||||
errors = _validate_capabilities(manifest)
|
||||
assert errors == []
|
||||
|
||||
def test_unknown_capability(self) -> None:
|
||||
manifest = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="1.0", description="Test", author="Test",
|
||||
capabilities=("voice_list", "unknown_cap"),
|
||||
)
|
||||
errors = _validate_capabilities(manifest)
|
||||
assert any("unknown_cap" in e for e in errors)
|
||||
|
||||
def test_empty_capabilities(self) -> None:
|
||||
manifest = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="1.0", description="Test", author="Test",
|
||||
capabilities=(),
|
||||
)
|
||||
errors = _validate_capabilities(manifest)
|
||||
assert errors == []
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Unit tests: _validate_api_version
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestValidateApiVersion:
|
||||
def test_compatible(self) -> None:
|
||||
manifest = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="1.0", description="Test", author="Test",
|
||||
)
|
||||
errors = _validate_api_version(manifest)
|
||||
assert errors == []
|
||||
|
||||
def test_incompatible(self) -> None:
|
||||
manifest = PluginManifest(
|
||||
id="test", name="Test", version="1.0.0",
|
||||
api_version="2.0", description="Test", author="Test",
|
||||
)
|
||||
errors = _validate_api_version(manifest)
|
||||
assert len(errors) > 0
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Integration tests: load_plugin_from_dir
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestLoadPluginFromDir:
|
||||
def test_load_valid_plugin(self, fake_plugin_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(fake_plugin_dir)
|
||||
assert result.success is True
|
||||
assert result.manifest is not None
|
||||
assert result.manifest.id == "fake_plugin"
|
||||
assert result.model_requirements is not None
|
||||
assert result.create_engine is not None
|
||||
assert result.module is not None
|
||||
assert result.error is None
|
||||
|
||||
def test_plugin_satisfies_protocol(self, fake_plugin_dir: Path) -> None:
|
||||
from abogen.tts_plugin.engine import Engine
|
||||
from abogen.tts_plugin.host_context import HostContext
|
||||
import logging
|
||||
|
||||
result = load_plugin_from_dir(fake_plugin_dir)
|
||||
assert result.success is True
|
||||
|
||||
# Create engine using the loaded create_engine function
|
||||
ctx = HostContext(
|
||||
config_dir=Path("/tmp/test"),
|
||||
logger=logging.getLogger("test"),
|
||||
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())
|
||||
assert isinstance(engine, Engine)
|
||||
engine.dispose()
|
||||
|
||||
def test_nonexistent_directory(self, tmp_path: Path) -> None:
|
||||
result = load_plugin_from_dir(tmp_path / "nonexistent")
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
assert "does not exist" in result.error.errors[0]
|
||||
|
||||
def test_missing_init_file(self, tmp_path: Path) -> None:
|
||||
plugin_dir = tmp_path / "no_init"
|
||||
plugin_dir.mkdir()
|
||||
result = load_plugin_from_dir(plugin_dir)
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
assert "__init__.py" in result.error.errors[0]
|
||||
|
||||
def test_import_error(self, import_error_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(import_error_dir)
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
assert "Failed to import" in result.error.errors[0]
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Integration tests: invalid plugins
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestInvalidPlugins:
|
||||
def test_missing_manifest(self, missing_manifest_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(missing_manifest_dir)
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
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:
|
||||
result = load_plugin_from_dir(invalid_api_version_dir)
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
assert any("major mismatch" in e for e in result.error.errors)
|
||||
|
||||
def test_invalid_capabilities(self, invalid_capabilities_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(invalid_capabilities_dir)
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
assert any("Unknown capability" in e for e in result.error.errors)
|
||||
|
||||
def test_missing_create_engine(self, missing_create_engine_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(missing_create_engine_dir)
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
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:
|
||||
result = load_plugin_from_dir(missing_model_requirements_dir)
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
assert any("Missing MODEL_REQUIREMENTS" in e for e in result.error.errors)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Integration tests: discover_plugins
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestDiscoverPlugins:
|
||||
def test_discover_from_valid_dir(self, plugins_dir: Path) -> None:
|
||||
results = discover_plugins([plugins_dir])
|
||||
# Should find multiple plugins (valid and invalid)
|
||||
assert len(results) > 0
|
||||
|
||||
def test_discover_includes_valid_plugin(self, plugins_dir: Path) -> None:
|
||||
results = discover_plugins([plugins_dir])
|
||||
valid = [r for r in results if r.success]
|
||||
assert len(valid) >= 1
|
||||
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:
|
||||
results = discover_plugins([plugins_dir])
|
||||
invalid = [r for r in results if not r.success]
|
||||
assert len(invalid) >= 1
|
||||
|
||||
def test_discover_nonexistent_dir(self, tmp_path: Path) -> None:
|
||||
results = discover_plugins([tmp_path / "nonexistent"])
|
||||
assert results == []
|
||||
|
||||
def test_discover_multiple_dirs(self, plugins_dir: Path, tmp_path: Path) -> None:
|
||||
results = discover_plugins([plugins_dir, tmp_path / "nonexistent"])
|
||||
assert len(results) > 0
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Diagnostic messages tests
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestDiagnosticMessages:
|
||||
def test_error_contains_plugin_id(self, missing_manifest_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(missing_manifest_dir)
|
||||
assert result.error is not None
|
||||
assert result.error.plugin_id == "missing_manifest"
|
||||
|
||||
def test_error_contains_path(self, missing_manifest_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(missing_manifest_dir)
|
||||
assert result.error is not None
|
||||
assert result.error.path == missing_manifest_dir
|
||||
|
||||
def test_error_contains_messages(self, missing_manifest_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(missing_manifest_dir)
|
||||
assert result.error is not None
|
||||
assert len(result.error.errors) > 0
|
||||
|
||||
def test_multiple_errors(self, invalid_api_version_dir: Path) -> None:
|
||||
# This plugin has multiple issues
|
||||
result = load_plugin_from_dir(invalid_api_version_dir)
|
||||
assert result.error is not None
|
||||
# Should have at least the api_version error
|
||||
assert len(result.error.errors) >= 1
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# No partial registration tests
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestNoPartialRegistration:
|
||||
def test_invalid_plugin_no_manifest_attr(self, missing_manifest_dir: Path) -> None:
|
||||
"""After failed load, module should not remain in sys.modules."""
|
||||
result = load_plugin_from_dir(missing_manifest_dir)
|
||||
assert result.success is False
|
||||
# Module should not be registered
|
||||
module_name = f"abogen.tts_plugin._loaded.missing_manifest"
|
||||
assert module_name not in sys.modules
|
||||
|
||||
def test_import_error_no_registration(self, import_error_dir: Path) -> None:
|
||||
"""After import error, module should not remain in sys.modules."""
|
||||
result = load_plugin_from_dir(import_error_dir)
|
||||
assert result.success is False
|
||||
module_name = f"abogen.tts_plugin._loaded.import_error"
|
||||
assert module_name not in sys.modules
|
||||
|
||||
@@ -1,290 +1,290 @@
|
||||
"""Contract tests for plugin manifest types.
|
||||
|
||||
These tests verify that manifest types satisfy the architectural requirements:
|
||||
- All required fields are present
|
||||
- api_version follows semver format
|
||||
- capabilities are properly defined
|
||||
- engine manifest describes the engine correctly
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.manifest import (
|
||||
AudioFormatManifest,
|
||||
EngineManifest,
|
||||
EnumOption,
|
||||
GpuRequirement,
|
||||
ModelManifest,
|
||||
ParameterManifest,
|
||||
PluginManifest,
|
||||
RequirementManifest,
|
||||
VoiceManifest,
|
||||
VoiceSourceManifest,
|
||||
)
|
||||
|
||||
|
||||
class TestPluginManifestContract:
|
||||
"""Contract tests for PluginManifest."""
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
manifest = PluginManifest(
|
||||
id="test-plugin",
|
||||
name="Test Plugin",
|
||||
version="1.0.0",
|
||||
api_version="1.0",
|
||||
description="A test plugin",
|
||||
author="Test Author",
|
||||
)
|
||||
assert manifest.id == "test-plugin"
|
||||
assert manifest.name == "Test Plugin"
|
||||
assert manifest.version == "1.0.0"
|
||||
assert manifest.api_version == "1.0"
|
||||
assert manifest.description == "A test plugin"
|
||||
assert manifest.author == "Test Author"
|
||||
|
||||
def test_api_version_semver_format(self) -> None:
|
||||
"""Architecture spec: api_version format is semver (MAJOR.MINOR)."""
|
||||
valid_versions = ["1.0", "2.1", "10.5"]
|
||||
for version in valid_versions:
|
||||
manifest = PluginManifest(
|
||||
id="test",
|
||||
name="Test",
|
||||
version="1.0.0",
|
||||
api_version=version,
|
||||
description="Test",
|
||||
author="Test",
|
||||
)
|
||||
assert re.match(r"^\d+\.\d+$", manifest.api_version)
|
||||
|
||||
def test_capabilities_default_empty(self) -> None:
|
||||
manifest = PluginManifest(
|
||||
id="test",
|
||||
name="Test",
|
||||
version="1.0.0",
|
||||
api_version="1.0",
|
||||
description="Test",
|
||||
author="Test",
|
||||
)
|
||||
assert manifest.capabilities == ()
|
||||
|
||||
def test_capabilities_tuple(self) -> None:
|
||||
manifest = PluginManifest(
|
||||
id="test",
|
||||
name="Test",
|
||||
version="1.0.0",
|
||||
api_version="1.0",
|
||||
description="Test",
|
||||
author="Test",
|
||||
capabilities=("voice_list", "preview"),
|
||||
)
|
||||
assert "voice_list" in manifest.capabilities
|
||||
assert "preview" in manifest.capabilities
|
||||
|
||||
def test_requires_default(self) -> None:
|
||||
manifest = PluginManifest(
|
||||
id="test",
|
||||
name="Test",
|
||||
version="1.0.0",
|
||||
api_version="1.0",
|
||||
description="Test",
|
||||
author="Test",
|
||||
)
|
||||
assert isinstance(manifest.requires, RequirementManifest)
|
||||
|
||||
def test_engine_default(self) -> None:
|
||||
manifest = PluginManifest(
|
||||
id="test",
|
||||
name="Test",
|
||||
version="1.0.0",
|
||||
api_version="1.0",
|
||||
description="Test",
|
||||
author="Test",
|
||||
)
|
||||
assert isinstance(manifest.engine, EngineManifest)
|
||||
|
||||
|
||||
class TestEngineManifestContract:
|
||||
"""Contract tests for EngineManifest."""
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
manifest = EngineManifest(
|
||||
voiceSources=(
|
||||
VoiceSourceManifest(id="builtin", name="Builtin", type="list"),
|
||||
),
|
||||
parameters=(
|
||||
ParameterManifest(
|
||||
id="speed", name="Speed", description="Speed", type="float", default=1.0
|
||||
),
|
||||
),
|
||||
audioFormats=(AudioFormatManifest(mime="audio/wav", extension="wav"),),
|
||||
)
|
||||
assert len(manifest.voiceSources) == 1
|
||||
assert len(manifest.parameters) == 1
|
||||
assert len(manifest.audioFormats) == 1
|
||||
|
||||
def test_defaults_empty(self) -> None:
|
||||
manifest = EngineManifest()
|
||||
assert manifest.voiceSources == ()
|
||||
assert manifest.parameters == ()
|
||||
assert manifest.audioFormats == ()
|
||||
|
||||
|
||||
class TestVoiceSourceManifestContract:
|
||||
"""Contract tests for VoiceSourceManifest."""
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
vs = VoiceSourceManifest(id="builtin", name="Builtin", type="list")
|
||||
assert vs.id == "builtin"
|
||||
assert vs.name == "Builtin"
|
||||
assert vs.type == "list"
|
||||
|
||||
def test_valid_types(self) -> None:
|
||||
valid_types = ["list", "speaker_id", "clone", "blend", "generate", "none"]
|
||||
for vtype in valid_types:
|
||||
vs = VoiceSourceManifest(id="test", name="Test", type=vtype)
|
||||
assert vs.type == vtype
|
||||
|
||||
def test_config_optional(self) -> None:
|
||||
vs = VoiceSourceManifest(id="test", name="Test", type="list")
|
||||
assert vs.config is None
|
||||
|
||||
def test_config_any(self) -> None:
|
||||
config = {"voices": ["af_nova", "af_sky"]}
|
||||
vs = VoiceSourceManifest(id="test", name="Test", type="list", config=config)
|
||||
assert vs.config == config
|
||||
|
||||
|
||||
class TestVoiceManifestContract:
|
||||
"""Contract tests for VoiceManifest."""
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
v = VoiceManifest(id="af_nova", name="Nova")
|
||||
assert v.id == "af_nova"
|
||||
assert v.name == "Nova"
|
||||
|
||||
def test_tags_default_empty(self) -> None:
|
||||
v = VoiceManifest(id="af_nova", name="Nova")
|
||||
assert v.tags == ()
|
||||
|
||||
def test_tags_tuple(self) -> None:
|
||||
v = VoiceManifest(id="af_nova", name="Nova", tags=("en", "female"))
|
||||
assert "en" in v.tags
|
||||
assert "female" in v.tags
|
||||
|
||||
|
||||
class TestParameterManifestContract:
|
||||
"""Contract tests for ParameterManifest."""
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
p = ParameterManifest(
|
||||
id="speed", name="Speed", description="Speech speed", type="float", default=1.0
|
||||
)
|
||||
assert p.id == "speed"
|
||||
assert p.name == "Speed"
|
||||
assert p.description == "Speech speed"
|
||||
assert p.type == "float"
|
||||
assert p.default == 1.0
|
||||
|
||||
def test_valid_types(self) -> None:
|
||||
valid_types = ["float", "int", "string", "boolean", "enum"]
|
||||
for ptype in valid_types:
|
||||
p = ParameterManifest(
|
||||
id="test", name="Test", description="Test", type=ptype, default=None
|
||||
)
|
||||
assert p.type == ptype
|
||||
|
||||
def test_optional_numeric_bounds(self) -> None:
|
||||
p = ParameterManifest(
|
||||
id="speed",
|
||||
name="Speed",
|
||||
description="Speed",
|
||||
type="float",
|
||||
default=1.0,
|
||||
min=0.5,
|
||||
max=2.0,
|
||||
step=0.1,
|
||||
)
|
||||
assert p.min == 0.5
|
||||
assert p.max == 2.0
|
||||
assert p.step == 0.1
|
||||
|
||||
def test_enum_options(self) -> None:
|
||||
options = (
|
||||
EnumOption(value="low", label="Low"),
|
||||
EnumOption(value="high", label="High"),
|
||||
)
|
||||
p = ParameterManifest(
|
||||
id="quality",
|
||||
name="Quality",
|
||||
description="Quality",
|
||||
type="enum",
|
||||
default="low",
|
||||
options=options,
|
||||
)
|
||||
assert len(p.options) == 2
|
||||
assert p.options[0].value == "low"
|
||||
|
||||
|
||||
class TestAudioFormatManifestContract:
|
||||
"""Contract tests for AudioFormatManifest."""
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
af = AudioFormatManifest(mime="audio/wav", extension="wav")
|
||||
assert af.mime == "audio/wav"
|
||||
assert af.extension == "wav"
|
||||
|
||||
|
||||
class TestEnumOptionContract:
|
||||
"""Contract tests for EnumOption."""
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
opt = EnumOption(value="low", label="Low Quality")
|
||||
assert opt.value == "low"
|
||||
assert opt.label == "Low Quality"
|
||||
|
||||
|
||||
class TestRequirementManifestContract:
|
||||
"""Contract tests for RequirementManifest."""
|
||||
|
||||
def test_defaults(self) -> None:
|
||||
req = RequirementManifest()
|
||||
assert req.gpu is None
|
||||
assert req.memory is None
|
||||
assert req.internet is None
|
||||
|
||||
def test_with_gpu(self) -> None:
|
||||
gpu = GpuRequirement(required=True, type="cuda", memory=8.0)
|
||||
req = RequirementManifest(gpu=gpu)
|
||||
assert req.gpu.required is True
|
||||
assert req.gpu.type == "cuda"
|
||||
assert req.gpu.memory == 8.0
|
||||
|
||||
def test_with_internet(self) -> None:
|
||||
req = RequirementManifest(internet=True)
|
||||
assert req.internet is True
|
||||
|
||||
|
||||
class TestGpuRequirementContract:
|
||||
"""Contract tests for GpuRequirement."""
|
||||
|
||||
def test_defaults(self) -> None:
|
||||
gpu = GpuRequirement()
|
||||
assert gpu.required is False
|
||||
assert gpu.type is None
|
||||
assert gpu.memory is None
|
||||
|
||||
def test_required_gpu(self) -> None:
|
||||
gpu = GpuRequirement(required=True, type="cuda", memory=8.0)
|
||||
assert gpu.required is True
|
||||
|
||||
|
||||
class TestModelManifestContract:
|
||||
"""Contract tests for ModelManifest."""
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
m = ModelManifest(id="xtts_v2", name="XTTS v2", size="2GB")
|
||||
assert m.id == "xtts_v2"
|
||||
assert m.name == "XTTS v2"
|
||||
assert m.size == "2GB"
|
||||
"""Contract tests for plugin manifest types.
|
||||
|
||||
These tests verify that manifest types satisfy the architectural requirements:
|
||||
- All required fields are present
|
||||
- api_version follows semver format
|
||||
- capabilities are properly defined
|
||||
- engine manifest describes the engine correctly
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.manifest import (
|
||||
AudioFormatManifest,
|
||||
EngineManifest,
|
||||
EnumOption,
|
||||
GpuRequirement,
|
||||
ModelManifest,
|
||||
ParameterManifest,
|
||||
PluginManifest,
|
||||
RequirementManifest,
|
||||
VoiceManifest,
|
||||
VoiceSourceManifest,
|
||||
)
|
||||
|
||||
|
||||
class TestPluginManifestContract:
|
||||
"""Contract tests for PluginManifest."""
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
manifest = PluginManifest(
|
||||
id="test-plugin",
|
||||
name="Test Plugin",
|
||||
version="1.0.0",
|
||||
api_version="1.0",
|
||||
description="A test plugin",
|
||||
author="Test Author",
|
||||
)
|
||||
assert manifest.id == "test-plugin"
|
||||
assert manifest.name == "Test Plugin"
|
||||
assert manifest.version == "1.0.0"
|
||||
assert manifest.api_version == "1.0"
|
||||
assert manifest.description == "A test plugin"
|
||||
assert manifest.author == "Test Author"
|
||||
|
||||
def test_api_version_semver_format(self) -> None:
|
||||
"""Architecture spec: api_version format is semver (MAJOR.MINOR)."""
|
||||
valid_versions = ["1.0", "2.1", "10.5"]
|
||||
for version in valid_versions:
|
||||
manifest = PluginManifest(
|
||||
id="test",
|
||||
name="Test",
|
||||
version="1.0.0",
|
||||
api_version=version,
|
||||
description="Test",
|
||||
author="Test",
|
||||
)
|
||||
assert re.match(r"^\d+\.\d+$", manifest.api_version)
|
||||
|
||||
def test_capabilities_default_empty(self) -> None:
|
||||
manifest = PluginManifest(
|
||||
id="test",
|
||||
name="Test",
|
||||
version="1.0.0",
|
||||
api_version="1.0",
|
||||
description="Test",
|
||||
author="Test",
|
||||
)
|
||||
assert manifest.capabilities == ()
|
||||
|
||||
def test_capabilities_tuple(self) -> None:
|
||||
manifest = PluginManifest(
|
||||
id="test",
|
||||
name="Test",
|
||||
version="1.0.0",
|
||||
api_version="1.0",
|
||||
description="Test",
|
||||
author="Test",
|
||||
capabilities=("voice_list", "preview"),
|
||||
)
|
||||
assert "voice_list" in manifest.capabilities
|
||||
assert "preview" in manifest.capabilities
|
||||
|
||||
def test_requires_default(self) -> None:
|
||||
manifest = PluginManifest(
|
||||
id="test",
|
||||
name="Test",
|
||||
version="1.0.0",
|
||||
api_version="1.0",
|
||||
description="Test",
|
||||
author="Test",
|
||||
)
|
||||
assert isinstance(manifest.requires, RequirementManifest)
|
||||
|
||||
def test_engine_default(self) -> None:
|
||||
manifest = PluginManifest(
|
||||
id="test",
|
||||
name="Test",
|
||||
version="1.0.0",
|
||||
api_version="1.0",
|
||||
description="Test",
|
||||
author="Test",
|
||||
)
|
||||
assert isinstance(manifest.engine, EngineManifest)
|
||||
|
||||
|
||||
class TestEngineManifestContract:
|
||||
"""Contract tests for EngineManifest."""
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
manifest = EngineManifest(
|
||||
voiceSources=(
|
||||
VoiceSourceManifest(id="builtin", name="Builtin", type="list"),
|
||||
),
|
||||
parameters=(
|
||||
ParameterManifest(
|
||||
id="speed", name="Speed", description="Speed", type="float", default=1.0
|
||||
),
|
||||
),
|
||||
audioFormats=(AudioFormatManifest(mime="audio/wav", extension="wav"),),
|
||||
)
|
||||
assert len(manifest.voiceSources) == 1
|
||||
assert len(manifest.parameters) == 1
|
||||
assert len(manifest.audioFormats) == 1
|
||||
|
||||
def test_defaults_empty(self) -> None:
|
||||
manifest = EngineManifest()
|
||||
assert manifest.voiceSources == ()
|
||||
assert manifest.parameters == ()
|
||||
assert manifest.audioFormats == ()
|
||||
|
||||
|
||||
class TestVoiceSourceManifestContract:
|
||||
"""Contract tests for VoiceSourceManifest."""
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
vs = VoiceSourceManifest(id="builtin", name="Builtin", type="list")
|
||||
assert vs.id == "builtin"
|
||||
assert vs.name == "Builtin"
|
||||
assert vs.type == "list"
|
||||
|
||||
def test_valid_types(self) -> None:
|
||||
valid_types = ["list", "speaker_id", "clone", "blend", "generate", "none"]
|
||||
for vtype in valid_types:
|
||||
vs = VoiceSourceManifest(id="test", name="Test", type=vtype)
|
||||
assert vs.type == vtype
|
||||
|
||||
def test_config_optional(self) -> None:
|
||||
vs = VoiceSourceManifest(id="test", name="Test", type="list")
|
||||
assert vs.config is None
|
||||
|
||||
def test_config_any(self) -> None:
|
||||
config = {"voices": ["af_nova", "af_sky"]}
|
||||
vs = VoiceSourceManifest(id="test", name="Test", type="list", config=config)
|
||||
assert vs.config == config
|
||||
|
||||
|
||||
class TestVoiceManifestContract:
|
||||
"""Contract tests for VoiceManifest."""
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
v = VoiceManifest(id="af_nova", name="Nova")
|
||||
assert v.id == "af_nova"
|
||||
assert v.name == "Nova"
|
||||
|
||||
def test_tags_default_empty(self) -> None:
|
||||
v = VoiceManifest(id="af_nova", name="Nova")
|
||||
assert v.tags == ()
|
||||
|
||||
def test_tags_tuple(self) -> None:
|
||||
v = VoiceManifest(id="af_nova", name="Nova", tags=("en", "female"))
|
||||
assert "en" in v.tags
|
||||
assert "female" in v.tags
|
||||
|
||||
|
||||
class TestParameterManifestContract:
|
||||
"""Contract tests for ParameterManifest."""
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
p = ParameterManifest(
|
||||
id="speed", name="Speed", description="Speech speed", type="float", default=1.0
|
||||
)
|
||||
assert p.id == "speed"
|
||||
assert p.name == "Speed"
|
||||
assert p.description == "Speech speed"
|
||||
assert p.type == "float"
|
||||
assert p.default == 1.0
|
||||
|
||||
def test_valid_types(self) -> None:
|
||||
valid_types = ["float", "int", "string", "boolean", "enum"]
|
||||
for ptype in valid_types:
|
||||
p = ParameterManifest(
|
||||
id="test", name="Test", description="Test", type=ptype, default=None
|
||||
)
|
||||
assert p.type == ptype
|
||||
|
||||
def test_optional_numeric_bounds(self) -> None:
|
||||
p = ParameterManifest(
|
||||
id="speed",
|
||||
name="Speed",
|
||||
description="Speed",
|
||||
type="float",
|
||||
default=1.0,
|
||||
min=0.5,
|
||||
max=2.0,
|
||||
step=0.1,
|
||||
)
|
||||
assert p.min == 0.5
|
||||
assert p.max == 2.0
|
||||
assert p.step == 0.1
|
||||
|
||||
def test_enum_options(self) -> None:
|
||||
options = (
|
||||
EnumOption(value="low", label="Low"),
|
||||
EnumOption(value="high", label="High"),
|
||||
)
|
||||
p = ParameterManifest(
|
||||
id="quality",
|
||||
name="Quality",
|
||||
description="Quality",
|
||||
type="enum",
|
||||
default="low",
|
||||
options=options,
|
||||
)
|
||||
assert len(p.options) == 2
|
||||
assert p.options[0].value == "low"
|
||||
|
||||
|
||||
class TestAudioFormatManifestContract:
|
||||
"""Contract tests for AudioFormatManifest."""
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
af = AudioFormatManifest(mime="audio/wav", extension="wav")
|
||||
assert af.mime == "audio/wav"
|
||||
assert af.extension == "wav"
|
||||
|
||||
|
||||
class TestEnumOptionContract:
|
||||
"""Contract tests for EnumOption."""
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
opt = EnumOption(value="low", label="Low Quality")
|
||||
assert opt.value == "low"
|
||||
assert opt.label == "Low Quality"
|
||||
|
||||
|
||||
class TestRequirementManifestContract:
|
||||
"""Contract tests for RequirementManifest."""
|
||||
|
||||
def test_defaults(self) -> None:
|
||||
req = RequirementManifest()
|
||||
assert req.gpu is None
|
||||
assert req.memory is None
|
||||
assert req.internet is None
|
||||
|
||||
def test_with_gpu(self) -> None:
|
||||
gpu = GpuRequirement(required=True, type="cuda", memory=8.0)
|
||||
req = RequirementManifest(gpu=gpu)
|
||||
assert req.gpu.required is True
|
||||
assert req.gpu.type == "cuda"
|
||||
assert req.gpu.memory == 8.0
|
||||
|
||||
def test_with_internet(self) -> None:
|
||||
req = RequirementManifest(internet=True)
|
||||
assert req.internet is True
|
||||
|
||||
|
||||
class TestGpuRequirementContract:
|
||||
"""Contract tests for GpuRequirement."""
|
||||
|
||||
def test_defaults(self) -> None:
|
||||
gpu = GpuRequirement()
|
||||
assert gpu.required is False
|
||||
assert gpu.type is None
|
||||
assert gpu.memory is None
|
||||
|
||||
def test_required_gpu(self) -> None:
|
||||
gpu = GpuRequirement(required=True, type="cuda", memory=8.0)
|
||||
assert gpu.required is True
|
||||
|
||||
|
||||
class TestModelManifestContract:
|
||||
"""Contract tests for ModelManifest."""
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
m = ModelManifest(id="xtts_v2", name="XTTS v2", size="2GB")
|
||||
assert m.id == "xtts_v2"
|
||||
assert m.name == "XTTS v2"
|
||||
assert m.size == "2GB"
|
||||
|
||||
@@ -1,146 +1,146 @@
|
||||
"""Contract tests for plugin contract.
|
||||
|
||||
These tests verify that plugin modules satisfy the architectural requirements:
|
||||
- Must export PLUGIN_MANIFEST: PluginManifest
|
||||
- Must export MODEL_REQUIREMENTS: list[ModelManifest]
|
||||
- Must export create_engine: Callable[[HostContext, Path | None, EngineConfig], Engine]
|
||||
- create_engine() must be atomic
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.engine import Engine
|
||||
from abogen.tts_plugin.host_context import HostContext
|
||||
from abogen.tts_plugin.manifest import EngineManifest, ModelManifest, PluginManifest
|
||||
from abogen.tts_plugin.plugin import Plugin
|
||||
from abogen.tts_plugin.types import EngineConfig
|
||||
|
||||
from .conftest import FakeEngine
|
||||
|
||||
|
||||
class FakePluginModule:
|
||||
"""Stub plugin module that satisfies the plugin contract."""
|
||||
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="fake-plugin",
|
||||
name="Fake Plugin",
|
||||
version="1.0.0",
|
||||
api_version="1.0",
|
||||
description="A fake plugin for testing",
|
||||
author="Test Author",
|
||||
capabilities=(),
|
||||
engine=EngineManifest(),
|
||||
)
|
||||
|
||||
MODEL_REQUIREMENTS: list[ModelManifest] = []
|
||||
|
||||
@staticmethod
|
||||
def create_engine(
|
||||
context: HostContext,
|
||||
model_path: Path | None,
|
||||
config: EngineConfig,
|
||||
) -> Engine:
|
||||
return FakeEngine()
|
||||
|
||||
|
||||
class TestPluginProtocolContract:
|
||||
"""Contract tests for the Plugin protocol."""
|
||||
|
||||
def test_plugin_is_protocol(self) -> None:
|
||||
assert hasattr(Plugin, "__protocol_attrs__")
|
||||
|
||||
|
||||
class TestPluginExportsContract:
|
||||
"""Contract tests for required plugin exports."""
|
||||
|
||||
def test_plugin_has_plugin_manifest(self) -> None:
|
||||
"""Architecture spec: Plugin must export PLUGIN_MANIFEST."""
|
||||
assert hasattr(FakePluginModule, "PLUGIN_MANIFEST")
|
||||
assert isinstance(FakePluginModule.PLUGIN_MANIFEST, PluginManifest)
|
||||
|
||||
def test_plugin_has_model_requirements(self) -> None:
|
||||
"""Architecture spec: Plugin must export MODEL_REQUIREMENTS."""
|
||||
assert hasattr(FakePluginModule, "MODEL_REQUIREMENTS")
|
||||
assert isinstance(FakePluginModule.MODEL_REQUIREMENTS, list)
|
||||
|
||||
def test_plugin_has_create_engine(self) -> None:
|
||||
"""Architecture spec: Plugin must export create_engine."""
|
||||
assert hasattr(FakePluginModule, "create_engine")
|
||||
assert callable(FakePluginModule.create_engine)
|
||||
|
||||
def test_plugin_manifest_required_fields(self) -> None:
|
||||
"""Architecture spec: PluginManifest has required fields."""
|
||||
manifest = FakePluginModule.PLUGIN_MANIFEST
|
||||
assert manifest.id
|
||||
assert manifest.name
|
||||
assert manifest.version
|
||||
assert manifest.api_version
|
||||
assert manifest.description
|
||||
assert manifest.author
|
||||
|
||||
def test_plugin_manifest_capabilities_is_tuple(self) -> None:
|
||||
manifest = FakePluginModule.PLUGIN_MANIFEST
|
||||
assert isinstance(manifest.capabilities, tuple)
|
||||
|
||||
|
||||
class TestCreateEngineContract:
|
||||
"""Contract tests for create_engine() function."""
|
||||
|
||||
def test_create_engine_returns_engine(self) -> None:
|
||||
"""Architecture spec: create_engine() returns Engine."""
|
||||
ctx = HostContext(
|
||||
config_dir=Path("/tmp/test"),
|
||||
logger=logging.getLogger("test"),
|
||||
http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(),
|
||||
)
|
||||
engine = FakePluginModule.create_engine(ctx, None, EngineConfig())
|
||||
assert isinstance(engine, Engine)
|
||||
|
||||
def test_create_engine_atomic(self) -> None:
|
||||
"""Architecture spec: create_engine() is atomic (all-or-nothing)."""
|
||||
ctx = HostContext(
|
||||
config_dir=Path("/tmp/test"),
|
||||
logger=logging.getLogger("test"),
|
||||
http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(),
|
||||
)
|
||||
engine = FakePluginModule.create_engine(ctx, None, EngineConfig())
|
||||
assert isinstance(engine, Engine)
|
||||
engine.dispose()
|
||||
|
||||
def test_create_engine_with_none_model_path(self) -> None:
|
||||
"""Architecture spec: model_path can be None for cloud/no-model engines."""
|
||||
ctx = HostContext(
|
||||
config_dir=Path("/tmp/test"),
|
||||
logger=logging.getLogger("test"),
|
||||
http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(),
|
||||
)
|
||||
engine = FakePluginModule.create_engine(ctx, None, EngineConfig())
|
||||
assert isinstance(engine, Engine)
|
||||
engine.dispose()
|
||||
|
||||
def test_create_engine_with_model_path(self) -> None:
|
||||
"""Architecture spec: model_path is Path | None."""
|
||||
ctx = HostContext(
|
||||
config_dir=Path("/tmp/test"),
|
||||
logger=logging.getLogger("test"),
|
||||
http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(),
|
||||
)
|
||||
engine = FakePluginModule.create_engine(ctx, Path("/models/test"), EngineConfig())
|
||||
assert isinstance(engine, Engine)
|
||||
engine.dispose()
|
||||
|
||||
|
||||
class TestModelRequirementsContract:
|
||||
"""Contract tests for MODEL_REQUIREMENTS."""
|
||||
|
||||
def test_model_requirements_is_list(self) -> None:
|
||||
assert isinstance(FakePluginModule.MODEL_REQUIREMENTS, list)
|
||||
|
||||
def test_model_requirements_contains_model_manifests(self) -> None:
|
||||
"""If non-empty, each item must be a ModelManifest."""
|
||||
for req in FakePluginModule.MODEL_REQUIREMENTS:
|
||||
assert isinstance(req, ModelManifest)
|
||||
"""Contract tests for plugin contract.
|
||||
|
||||
These tests verify that plugin modules satisfy the architectural requirements:
|
||||
- Must export PLUGIN_MANIFEST: PluginManifest
|
||||
- Must export MODEL_REQUIREMENTS: list[ModelManifest]
|
||||
- Must export create_engine: Callable[[HostContext, Path | None, EngineConfig], Engine]
|
||||
- create_engine() must be atomic
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.engine import Engine
|
||||
from abogen.tts_plugin.host_context import HostContext
|
||||
from abogen.tts_plugin.manifest import EngineManifest, ModelManifest, PluginManifest
|
||||
from abogen.tts_plugin.plugin import Plugin
|
||||
from abogen.tts_plugin.types import EngineConfig
|
||||
|
||||
from .conftest import FakeEngine
|
||||
|
||||
|
||||
class FakePluginModule:
|
||||
"""Stub plugin module that satisfies the plugin contract."""
|
||||
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="fake-plugin",
|
||||
name="Fake Plugin",
|
||||
version="1.0.0",
|
||||
api_version="1.0",
|
||||
description="A fake plugin for testing",
|
||||
author="Test Author",
|
||||
capabilities=(),
|
||||
engine=EngineManifest(),
|
||||
)
|
||||
|
||||
MODEL_REQUIREMENTS: list[ModelManifest] = []
|
||||
|
||||
@staticmethod
|
||||
def create_engine(
|
||||
context: HostContext,
|
||||
model_path: Path | None,
|
||||
config: EngineConfig,
|
||||
) -> Engine:
|
||||
return FakeEngine()
|
||||
|
||||
|
||||
class TestPluginProtocolContract:
|
||||
"""Contract tests for the Plugin protocol."""
|
||||
|
||||
def test_plugin_is_protocol(self) -> None:
|
||||
assert hasattr(Plugin, "__protocol_attrs__")
|
||||
|
||||
|
||||
class TestPluginExportsContract:
|
||||
"""Contract tests for required plugin exports."""
|
||||
|
||||
def test_plugin_has_plugin_manifest(self) -> None:
|
||||
"""Architecture spec: Plugin must export PLUGIN_MANIFEST."""
|
||||
assert hasattr(FakePluginModule, "PLUGIN_MANIFEST")
|
||||
assert isinstance(FakePluginModule.PLUGIN_MANIFEST, PluginManifest)
|
||||
|
||||
def test_plugin_has_model_requirements(self) -> None:
|
||||
"""Architecture spec: Plugin must export MODEL_REQUIREMENTS."""
|
||||
assert hasattr(FakePluginModule, "MODEL_REQUIREMENTS")
|
||||
assert isinstance(FakePluginModule.MODEL_REQUIREMENTS, list)
|
||||
|
||||
def test_plugin_has_create_engine(self) -> None:
|
||||
"""Architecture spec: Plugin must export create_engine."""
|
||||
assert hasattr(FakePluginModule, "create_engine")
|
||||
assert callable(FakePluginModule.create_engine)
|
||||
|
||||
def test_plugin_manifest_required_fields(self) -> None:
|
||||
"""Architecture spec: PluginManifest has required fields."""
|
||||
manifest = FakePluginModule.PLUGIN_MANIFEST
|
||||
assert manifest.id
|
||||
assert manifest.name
|
||||
assert manifest.version
|
||||
assert manifest.api_version
|
||||
assert manifest.description
|
||||
assert manifest.author
|
||||
|
||||
def test_plugin_manifest_capabilities_is_tuple(self) -> None:
|
||||
manifest = FakePluginModule.PLUGIN_MANIFEST
|
||||
assert isinstance(manifest.capabilities, tuple)
|
||||
|
||||
|
||||
class TestCreateEngineContract:
|
||||
"""Contract tests for create_engine() function."""
|
||||
|
||||
def test_create_engine_returns_engine(self) -> None:
|
||||
"""Architecture spec: create_engine() returns Engine."""
|
||||
ctx = HostContext(
|
||||
config_dir=Path("/tmp/test"),
|
||||
logger=logging.getLogger("test"),
|
||||
http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(),
|
||||
)
|
||||
engine = FakePluginModule.create_engine(ctx, None, EngineConfig())
|
||||
assert isinstance(engine, Engine)
|
||||
|
||||
def test_create_engine_atomic(self) -> None:
|
||||
"""Architecture spec: create_engine() is atomic (all-or-nothing)."""
|
||||
ctx = HostContext(
|
||||
config_dir=Path("/tmp/test"),
|
||||
logger=logging.getLogger("test"),
|
||||
http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(),
|
||||
)
|
||||
engine = FakePluginModule.create_engine(ctx, None, EngineConfig())
|
||||
assert isinstance(engine, Engine)
|
||||
engine.dispose()
|
||||
|
||||
def test_create_engine_with_none_model_path(self) -> None:
|
||||
"""Architecture spec: model_path can be None for cloud/no-model engines."""
|
||||
ctx = HostContext(
|
||||
config_dir=Path("/tmp/test"),
|
||||
logger=logging.getLogger("test"),
|
||||
http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(),
|
||||
)
|
||||
engine = FakePluginModule.create_engine(ctx, None, EngineConfig())
|
||||
assert isinstance(engine, Engine)
|
||||
engine.dispose()
|
||||
|
||||
def test_create_engine_with_model_path(self) -> None:
|
||||
"""Architecture spec: model_path is Path | None."""
|
||||
ctx = HostContext(
|
||||
config_dir=Path("/tmp/test"),
|
||||
logger=logging.getLogger("test"),
|
||||
http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(),
|
||||
)
|
||||
engine = FakePluginModule.create_engine(ctx, Path("/models/test"), EngineConfig())
|
||||
assert isinstance(engine, Engine)
|
||||
engine.dispose()
|
||||
|
||||
|
||||
class TestModelRequirementsContract:
|
||||
"""Contract tests for MODEL_REQUIREMENTS."""
|
||||
|
||||
def test_model_requirements_is_list(self) -> None:
|
||||
assert isinstance(FakePluginModule.MODEL_REQUIREMENTS, list)
|
||||
|
||||
def test_model_requirements_contains_model_manifests(self) -> None:
|
||||
"""If non-empty, each item must be a ModelManifest."""
|
||||
for req in FakePluginModule.MODEL_REQUIREMENTS:
|
||||
assert isinstance(req, ModelManifest)
|
||||
|
||||
@@ -1,274 +1,274 @@
|
||||
"""Integration tests for Plugin Manager and direct utility functions."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
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.engine import Engine, EngineSession
|
||||
from abogen.tts_plugin.types import SynthesisRequest, SynthesizedAudio, AudioFormat
|
||||
|
||||
|
||||
class FakeEngine:
|
||||
"""Fake Engine for testing."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
self._disposed = False
|
||||
|
||||
def createSession(self):
|
||||
return FakeEngineSession()
|
||||
|
||||
def dispose(self):
|
||||
self._disposed = True
|
||||
|
||||
@property
|
||||
def manifest(self):
|
||||
return MagicMock()
|
||||
|
||||
|
||||
class FakeEngineSession:
|
||||
"""Fake EngineSession for testing."""
|
||||
|
||||
def __init__(self):
|
||||
self._disposed = False
|
||||
|
||||
def synthesize(self, request):
|
||||
# Return fake audio
|
||||
import numpy as np
|
||||
from abogen.tts_plugin.types import AudioFormat, Duration, SynthesizedAudio
|
||||
|
||||
audio = np.zeros(1000, dtype=np.float32)
|
||||
return SynthesizedAudio(
|
||||
data=audio.tobytes(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
duration=Duration(seconds=0.04167), # 1000 samples / 24000 Hz
|
||||
)
|
||||
|
||||
def dispose(self):
|
||||
self._disposed = True
|
||||
|
||||
|
||||
class TestPluginManager:
|
||||
"""Test PluginManager functionality."""
|
||||
|
||||
def test_plugin_manager_creation(self):
|
||||
"""PluginManager can be created."""
|
||||
manager = PluginManager()
|
||||
assert manager is not None
|
||||
|
||||
def test_plugin_manager_list_discovers_plugins(self):
|
||||
"""PluginManager discovers plugins from plugins directory."""
|
||||
manager = PluginManager()
|
||||
manager.discover("plugins")
|
||||
plugins = manager.list_plugins()
|
||||
# Should discover kokoro plugin if it exists
|
||||
assert isinstance(plugins, list)
|
||||
|
||||
def test_plugin_manager_has_plugin_after_discover(self):
|
||||
"""PluginManager reports plugins after discovery."""
|
||||
manager = PluginManager()
|
||||
manager.discover("plugins")
|
||||
# kokoro plugin should be discovered if plugins/kokoro exists
|
||||
# This is expected behavior
|
||||
assert isinstance(manager._plugins, dict)
|
||||
|
||||
def test_plugin_manager_get_plugin_after_discover(self):
|
||||
"""PluginManager returns plugin info after discovery."""
|
||||
manager = PluginManager()
|
||||
manager.discover("plugins")
|
||||
# kokoro plugin should be discovered if plugins/kokoro exists
|
||||
assert isinstance(manager._plugins, dict)
|
||||
|
||||
def test_plugin_manager_create_engine_not_found(self):
|
||||
"""PluginManager raises KeyError for unknown plugins."""
|
||||
manager = PluginManager()
|
||||
with pytest.raises(KeyError, match="Plugin not found"):
|
||||
manager.create_engine("nonexistent")
|
||||
|
||||
def test_plugin_manager_discover_with_empty_dir(self):
|
||||
"""PluginManager handles missing plugins directory."""
|
||||
manager = PluginManager()
|
||||
manager.discover("/nonexistent/path")
|
||||
plugins = manager.list_plugins()
|
||||
assert plugins == []
|
||||
|
||||
def test_global_plugin_manager_singleton(self):
|
||||
"""Global PluginManager is a singleton."""
|
||||
reset_plugin_manager()
|
||||
manager1 = get_plugin_manager()
|
||||
manager2 = get_plugin_manager()
|
||||
assert manager1 is manager2
|
||||
reset_plugin_manager()
|
||||
|
||||
def test_reset_plugin_manager(self):
|
||||
"""reset_plugin_manager clears the singleton."""
|
||||
manager1 = get_plugin_manager()
|
||||
reset_plugin_manager()
|
||||
manager2 = get_plugin_manager()
|
||||
assert manager1 is not manager2
|
||||
reset_plugin_manager()
|
||||
|
||||
|
||||
class TestPipeline:
|
||||
"""Test Pipeline functionality."""
|
||||
|
||||
def test_pipeline_creation(self):
|
||||
"""Pipeline can be created."""
|
||||
engine = FakeEngine()
|
||||
backend = Pipeline(engine)
|
||||
assert backend is not None
|
||||
|
||||
def test_pipeline_callable(self):
|
||||
"""Pipeline is callable like old TTSBackend."""
|
||||
engine = FakeEngine()
|
||||
backend = Pipeline(engine)
|
||||
|
||||
# Should be callable
|
||||
assert callable(backend)
|
||||
|
||||
def test_pipeline_synthesize(self):
|
||||
"""Pipeline can synthesize text."""
|
||||
engine = FakeEngine()
|
||||
backend = Pipeline(engine)
|
||||
|
||||
# Call the backend
|
||||
segments = list(backend("Hello world", voice="default", speed=1.0))
|
||||
|
||||
# Should return at least one segment
|
||||
assert len(segments) >= 1
|
||||
|
||||
# Segment should have graphemes and audio
|
||||
segment = segments[0]
|
||||
assert hasattr(segment, "graphemes")
|
||||
assert hasattr(segment, "audio")
|
||||
assert segment.graphemes == "Hello world"
|
||||
|
||||
def test_pipeline_dispose(self):
|
||||
"""Pipeline can be disposed."""
|
||||
engine = FakeEngine()
|
||||
backend = Pipeline(engine)
|
||||
|
||||
# Create a session by calling
|
||||
list(backend("test"))
|
||||
|
||||
# Dispose should not raise
|
||||
backend.dispose()
|
||||
|
||||
# Double dispose should be safe
|
||||
backend.dispose()
|
||||
|
||||
|
||||
class TestCreatePipelineCompat:
|
||||
"""Test create_pipeline utility function."""
|
||||
|
||||
def test_create_pipeline_returns_callable(self):
|
||||
"""create_pipeline returns a callable backend."""
|
||||
from abogen.tts_plugin.host_context import HostContext
|
||||
from abogen.tts_plugin.types import EngineConfig
|
||||
|
||||
# Mock the plugin manager
|
||||
with patch("abogen.tts_plugin.utils.get_plugin_manager") as mock_get_manager:
|
||||
mock_manager = MagicMock()
|
||||
mock_get_manager.return_value = mock_manager
|
||||
|
||||
mock_engine = FakeEngine()
|
||||
mock_manager.create_engine.return_value = mock_engine
|
||||
|
||||
backend = create_pipeline("kokoro", lang_code="a", device="cpu")
|
||||
|
||||
assert callable(backend)
|
||||
mock_manager.create_engine.assert_called_once()
|
||||
call_args = mock_manager.create_engine.call_args
|
||||
assert call_args.args[0] == "kokoro"
|
||||
assert isinstance(call_args.kwargs["context"], HostContext)
|
||||
assert call_args.kwargs["model_path"] is None
|
||||
assert isinstance(call_args.kwargs["config"], EngineConfig)
|
||||
assert call_args.kwargs["config"].device == "cpu"
|
||||
assert call_args.kwargs["config"].lang_code == "a"
|
||||
|
||||
def test_create_pipeline_raises_for_unknown_plugin(self):
|
||||
"""create_pipeline raises KeyError for unknown plugins."""
|
||||
with patch("abogen.tts_plugin.utils.get_plugin_manager") as mock_get_manager:
|
||||
mock_manager = MagicMock()
|
||||
mock_get_manager.return_value = mock_manager
|
||||
mock_manager.create_engine.side_effect = KeyError("Plugin not found")
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
create_pipeline("nonexistent")
|
||||
|
||||
|
||||
class TestPluginManagerWithFakePlugins:
|
||||
"""Test PluginManager with fake plugin loading."""
|
||||
|
||||
def test_plugin_manager_create_engine_from_plugin(self):
|
||||
"""PluginManager creates engine from loaded plugin."""
|
||||
manager = PluginManager()
|
||||
|
||||
# Manually add a fake plugin
|
||||
def fake_create_engine(**kwargs):
|
||||
return FakeEngine(**kwargs)
|
||||
|
||||
manager._plugins["fake"] = {
|
||||
"manifest": MagicMock(),
|
||||
"create_engine": fake_create_engine,
|
||||
}
|
||||
manager._loaded = True
|
||||
|
||||
# Create engine
|
||||
engine = manager.create_engine("fake", param="value")
|
||||
|
||||
assert isinstance(engine, FakeEngine)
|
||||
assert engine.kwargs == {"param": "value"}
|
||||
|
||||
def test_plugin_manager_get_or_create_engine(self):
|
||||
"""PluginManager caches engines."""
|
||||
manager = PluginManager()
|
||||
|
||||
call_count = 0
|
||||
|
||||
def fake_create_engine(**kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return FakeEngine(**kwargs)
|
||||
|
||||
manager._plugins["fake"] = {
|
||||
"manifest": MagicMock(),
|
||||
"create_engine": fake_create_engine,
|
||||
}
|
||||
manager._loaded = True
|
||||
|
||||
# Get engine twice
|
||||
engine1 = manager.get_or_create_engine("fake")
|
||||
engine2 = manager.get_or_create_engine("fake")
|
||||
|
||||
# Should be same instance
|
||||
assert engine1 is engine2
|
||||
assert call_count == 1
|
||||
|
||||
def test_plugin_manager_dispose_all(self):
|
||||
"""PluginManager disposes all cached engines."""
|
||||
manager = PluginManager()
|
||||
|
||||
def fake_create_engine(**kwargs):
|
||||
return FakeEngine(**kwargs)
|
||||
|
||||
manager._plugins["fake"] = {
|
||||
"manifest": MagicMock(),
|
||||
"create_engine": fake_create_engine,
|
||||
}
|
||||
manager._loaded = True
|
||||
|
||||
# Create and cache engines
|
||||
engine1 = manager.get_or_create_engine("fake")
|
||||
engine2 = manager.get_or_create_engine("fake")
|
||||
|
||||
# Dispose all
|
||||
manager.dispose_all()
|
||||
|
||||
# Engines should be disposed
|
||||
assert engine1._disposed is True
|
||||
assert engine2._disposed is True
|
||||
|
||||
# Cache should be empty
|
||||
assert len(manager._engines) == 0
|
||||
"""Integration tests for Plugin Manager and direct utility functions."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
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.engine import Engine, EngineSession
|
||||
from abogen.tts_plugin.types import SynthesisRequest, SynthesizedAudio, AudioFormat
|
||||
|
||||
|
||||
class FakeEngine:
|
||||
"""Fake Engine for testing."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
self._disposed = False
|
||||
|
||||
def createSession(self):
|
||||
return FakeEngineSession()
|
||||
|
||||
def dispose(self):
|
||||
self._disposed = True
|
||||
|
||||
@property
|
||||
def manifest(self):
|
||||
return MagicMock()
|
||||
|
||||
|
||||
class FakeEngineSession:
|
||||
"""Fake EngineSession for testing."""
|
||||
|
||||
def __init__(self):
|
||||
self._disposed = False
|
||||
|
||||
def synthesize(self, request):
|
||||
# Return fake audio
|
||||
import numpy as np
|
||||
from abogen.tts_plugin.types import AudioFormat, Duration, SynthesizedAudio
|
||||
|
||||
audio = np.zeros(1000, dtype=np.float32)
|
||||
return SynthesizedAudio(
|
||||
data=audio.tobytes(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
duration=Duration(seconds=0.04167), # 1000 samples / 24000 Hz
|
||||
)
|
||||
|
||||
def dispose(self):
|
||||
self._disposed = True
|
||||
|
||||
|
||||
class TestPluginManager:
|
||||
"""Test PluginManager functionality."""
|
||||
|
||||
def test_plugin_manager_creation(self):
|
||||
"""PluginManager can be created."""
|
||||
manager = PluginManager()
|
||||
assert manager is not None
|
||||
|
||||
def test_plugin_manager_list_discovers_plugins(self):
|
||||
"""PluginManager discovers plugins from plugins directory."""
|
||||
manager = PluginManager()
|
||||
manager.discover("plugins")
|
||||
plugins = manager.list_plugins()
|
||||
# Should discover kokoro plugin if it exists
|
||||
assert isinstance(plugins, list)
|
||||
|
||||
def test_plugin_manager_has_plugin_after_discover(self):
|
||||
"""PluginManager reports plugins after discovery."""
|
||||
manager = PluginManager()
|
||||
manager.discover("plugins")
|
||||
# kokoro plugin should be discovered if plugins/kokoro exists
|
||||
# This is expected behavior
|
||||
assert isinstance(manager._plugins, dict)
|
||||
|
||||
def test_plugin_manager_get_plugin_after_discover(self):
|
||||
"""PluginManager returns plugin info after discovery."""
|
||||
manager = PluginManager()
|
||||
manager.discover("plugins")
|
||||
# kokoro plugin should be discovered if plugins/kokoro exists
|
||||
assert isinstance(manager._plugins, dict)
|
||||
|
||||
def test_plugin_manager_create_engine_not_found(self):
|
||||
"""PluginManager raises KeyError for unknown plugins."""
|
||||
manager = PluginManager()
|
||||
with pytest.raises(KeyError, match="Plugin not found"):
|
||||
manager.create_engine("nonexistent")
|
||||
|
||||
def test_plugin_manager_discover_with_empty_dir(self):
|
||||
"""PluginManager handles missing plugins directory."""
|
||||
manager = PluginManager()
|
||||
manager.discover("/nonexistent/path")
|
||||
plugins = manager.list_plugins()
|
||||
assert plugins == []
|
||||
|
||||
def test_global_plugin_manager_singleton(self):
|
||||
"""Global PluginManager is a singleton."""
|
||||
reset_plugin_manager()
|
||||
manager1 = get_plugin_manager()
|
||||
manager2 = get_plugin_manager()
|
||||
assert manager1 is manager2
|
||||
reset_plugin_manager()
|
||||
|
||||
def test_reset_plugin_manager(self):
|
||||
"""reset_plugin_manager clears the singleton."""
|
||||
manager1 = get_plugin_manager()
|
||||
reset_plugin_manager()
|
||||
manager2 = get_plugin_manager()
|
||||
assert manager1 is not manager2
|
||||
reset_plugin_manager()
|
||||
|
||||
|
||||
class TestPipeline:
|
||||
"""Test Pipeline functionality."""
|
||||
|
||||
def test_pipeline_creation(self):
|
||||
"""Pipeline can be created."""
|
||||
engine = FakeEngine()
|
||||
backend = Pipeline(engine)
|
||||
assert backend is not None
|
||||
|
||||
def test_pipeline_callable(self):
|
||||
"""Pipeline is callable like old TTSBackend."""
|
||||
engine = FakeEngine()
|
||||
backend = Pipeline(engine)
|
||||
|
||||
# Should be callable
|
||||
assert callable(backend)
|
||||
|
||||
def test_pipeline_synthesize(self):
|
||||
"""Pipeline can synthesize text."""
|
||||
engine = FakeEngine()
|
||||
backend = Pipeline(engine)
|
||||
|
||||
# Call the backend
|
||||
segments = list(backend("Hello world", voice="default", speed=1.0))
|
||||
|
||||
# Should return at least one segment
|
||||
assert len(segments) >= 1
|
||||
|
||||
# Segment should have graphemes and audio
|
||||
segment = segments[0]
|
||||
assert hasattr(segment, "graphemes")
|
||||
assert hasattr(segment, "audio")
|
||||
assert segment.graphemes == "Hello world"
|
||||
|
||||
def test_pipeline_dispose(self):
|
||||
"""Pipeline can be disposed."""
|
||||
engine = FakeEngine()
|
||||
backend = Pipeline(engine)
|
||||
|
||||
# Create a session by calling
|
||||
list(backend("test"))
|
||||
|
||||
# Dispose should not raise
|
||||
backend.dispose()
|
||||
|
||||
# Double dispose should be safe
|
||||
backend.dispose()
|
||||
|
||||
|
||||
class TestCreatePipelineCompat:
|
||||
"""Test create_pipeline utility function."""
|
||||
|
||||
def test_create_pipeline_returns_callable(self):
|
||||
"""create_pipeline returns a callable backend."""
|
||||
from abogen.tts_plugin.host_context import HostContext
|
||||
from abogen.tts_plugin.types import EngineConfig
|
||||
|
||||
# Mock the plugin manager
|
||||
with patch("abogen.tts_plugin.utils.get_plugin_manager") as mock_get_manager:
|
||||
mock_manager = MagicMock()
|
||||
mock_get_manager.return_value = mock_manager
|
||||
|
||||
mock_engine = FakeEngine()
|
||||
mock_manager.create_engine.return_value = mock_engine
|
||||
|
||||
backend = create_pipeline("kokoro", lang_code="a", device="cpu")
|
||||
|
||||
assert callable(backend)
|
||||
mock_manager.create_engine.assert_called_once()
|
||||
call_args = mock_manager.create_engine.call_args
|
||||
assert call_args.args[0] == "kokoro"
|
||||
assert isinstance(call_args.kwargs["context"], HostContext)
|
||||
assert call_args.kwargs["model_path"] is None
|
||||
assert isinstance(call_args.kwargs["config"], EngineConfig)
|
||||
assert call_args.kwargs["config"].device == "cpu"
|
||||
assert call_args.kwargs["config"].lang_code == "a"
|
||||
|
||||
def test_create_pipeline_raises_for_unknown_plugin(self):
|
||||
"""create_pipeline raises KeyError for unknown plugins."""
|
||||
with patch("abogen.tts_plugin.utils.get_plugin_manager") as mock_get_manager:
|
||||
mock_manager = MagicMock()
|
||||
mock_get_manager.return_value = mock_manager
|
||||
mock_manager.create_engine.side_effect = KeyError("Plugin not found")
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
create_pipeline("nonexistent")
|
||||
|
||||
|
||||
class TestPluginManagerWithFakePlugins:
|
||||
"""Test PluginManager with fake plugin loading."""
|
||||
|
||||
def test_plugin_manager_create_engine_from_plugin(self):
|
||||
"""PluginManager creates engine from loaded plugin."""
|
||||
manager = PluginManager()
|
||||
|
||||
# Manually add a fake plugin
|
||||
def fake_create_engine(**kwargs):
|
||||
return FakeEngine(**kwargs)
|
||||
|
||||
manager._plugins["fake"] = {
|
||||
"manifest": MagicMock(),
|
||||
"create_engine": fake_create_engine,
|
||||
}
|
||||
manager._loaded = True
|
||||
|
||||
# Create engine
|
||||
engine = manager.create_engine("fake", param="value")
|
||||
|
||||
assert isinstance(engine, FakeEngine)
|
||||
assert engine.kwargs == {"param": "value"}
|
||||
|
||||
def test_plugin_manager_get_or_create_engine(self):
|
||||
"""PluginManager caches engines."""
|
||||
manager = PluginManager()
|
||||
|
||||
call_count = 0
|
||||
|
||||
def fake_create_engine(**kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return FakeEngine(**kwargs)
|
||||
|
||||
manager._plugins["fake"] = {
|
||||
"manifest": MagicMock(),
|
||||
"create_engine": fake_create_engine,
|
||||
}
|
||||
manager._loaded = True
|
||||
|
||||
# Get engine twice
|
||||
engine1 = manager.get_or_create_engine("fake")
|
||||
engine2 = manager.get_or_create_engine("fake")
|
||||
|
||||
# Should be same instance
|
||||
assert engine1 is engine2
|
||||
assert call_count == 1
|
||||
|
||||
def test_plugin_manager_dispose_all(self):
|
||||
"""PluginManager disposes all cached engines."""
|
||||
manager = PluginManager()
|
||||
|
||||
def fake_create_engine(**kwargs):
|
||||
return FakeEngine(**kwargs)
|
||||
|
||||
manager._plugins["fake"] = {
|
||||
"manifest": MagicMock(),
|
||||
"create_engine": fake_create_engine,
|
||||
}
|
||||
manager._loaded = True
|
||||
|
||||
# Create and cache engines
|
||||
engine1 = manager.get_or_create_engine("fake")
|
||||
engine2 = manager.get_or_create_engine("fake")
|
||||
|
||||
# Dispose all
|
||||
manager.dispose_all()
|
||||
|
||||
# Engines should be disposed
|
||||
assert engine1._disposed is True
|
||||
assert engine2._disposed is True
|
||||
|
||||
# Cache should be empty
|
||||
assert len(manager._engines) == 0
|
||||
|
||||
@@ -1,135 +1,135 @@
|
||||
"""Contract tests for EngineSession protocol.
|
||||
|
||||
These tests verify that EngineSession implementations satisfy the architectural requirements:
|
||||
- synthesize() returns SynthesizedAudio
|
||||
- dispose() is idempotent
|
||||
- After dispose(), synthesize() raises EngineError
|
||||
- Session remains usable after synthesize() failure
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.engine import EngineSession
|
||||
from abogen.tts_plugin.errors import EngineError
|
||||
from abogen.tts_plugin.types import (
|
||||
AudioFormat,
|
||||
Duration,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
SynthesizedAudio,
|
||||
VoiceSelection,
|
||||
)
|
||||
|
||||
from .conftest import FakeEngineSession
|
||||
|
||||
|
||||
class TestEngineSessionProtocolContract:
|
||||
"""Contract tests for the EngineSession protocol itself."""
|
||||
|
||||
def test_engine_session_is_protocol(self) -> None:
|
||||
assert hasattr(EngineSession, "__protocol_attrs__")
|
||||
|
||||
def test_fake_session_satisfies_protocol(self) -> None:
|
||||
session = FakeEngineSession()
|
||||
assert isinstance(session, EngineSession)
|
||||
|
||||
|
||||
class TestSessionSynthesizeContract:
|
||||
"""Contract tests for EngineSession.synthesize()."""
|
||||
|
||||
def test_synthesize_returns_synthesized_audio(self) -> None:
|
||||
session = FakeEngineSession()
|
||||
request = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
result = session.synthesize(request)
|
||||
assert isinstance(result, SynthesizedAudio)
|
||||
|
||||
def test_synthesize_returns_valid_audio_data(self) -> None:
|
||||
session = FakeEngineSession()
|
||||
request = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
result = session.synthesize(request)
|
||||
assert isinstance(result.data, bytes)
|
||||
assert len(result.data) > 0
|
||||
assert isinstance(result.format, AudioFormat)
|
||||
assert isinstance(result.duration, Duration)
|
||||
|
||||
|
||||
class TestSessionDisposeContract:
|
||||
"""Contract tests for EngineSession.dispose()."""
|
||||
|
||||
def test_dispose_is_idempotent(self) -> None:
|
||||
"""Architecture spec: dispose() is idempotent."""
|
||||
session = FakeEngineSession()
|
||||
session.dispose()
|
||||
session.dispose() # Should not raise
|
||||
|
||||
def test_dispose_never_raises(self) -> None:
|
||||
"""Architecture spec: dispose() never raises exceptions."""
|
||||
session = FakeEngineSession()
|
||||
session.dispose() # Should not raise
|
||||
|
||||
|
||||
class TestSessionAfterDisposeContract:
|
||||
"""Contract tests for behavior after dispose()."""
|
||||
|
||||
def test_synthesize_after_dispose_raises(self) -> None:
|
||||
"""Architecture spec: After dispose(), all methods except dispose() raise EngineError."""
|
||||
session = FakeEngineSession()
|
||||
session.dispose()
|
||||
request = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
with pytest.raises(EngineError):
|
||||
session.synthesize(request)
|
||||
|
||||
|
||||
class TestSessionLifecycleContract:
|
||||
"""Contract tests for EngineSession lifecycle."""
|
||||
|
||||
def test_full_lifecycle(self) -> None:
|
||||
"""Test complete session lifecycle: create -> synthesize -> dispose."""
|
||||
session = FakeEngineSession()
|
||||
request = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
|
||||
# Synthesize
|
||||
result = session.synthesize(request)
|
||||
assert isinstance(result, SynthesizedAudio)
|
||||
|
||||
# Dispose
|
||||
session.dispose()
|
||||
|
||||
def test_multiple_synthesize_before_dispose(self) -> None:
|
||||
"""Architecture spec: Session remains usable after synthesize() failure."""
|
||||
session = FakeEngineSession()
|
||||
request = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
|
||||
# Multiple synthesize calls
|
||||
result1 = session.synthesize(request)
|
||||
result2 = session.synthesize(request)
|
||||
assert isinstance(result1, SynthesizedAudio)
|
||||
assert isinstance(result2, SynthesizedAudio)
|
||||
|
||||
# Dispose
|
||||
session.dispose()
|
||||
"""Contract tests for EngineSession protocol.
|
||||
|
||||
These tests verify that EngineSession implementations satisfy the architectural requirements:
|
||||
- synthesize() returns SynthesizedAudio
|
||||
- dispose() is idempotent
|
||||
- After dispose(), synthesize() raises EngineError
|
||||
- Session remains usable after synthesize() failure
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.engine import EngineSession
|
||||
from abogen.tts_plugin.errors import EngineError
|
||||
from abogen.tts_plugin.types import (
|
||||
AudioFormat,
|
||||
Duration,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
SynthesizedAudio,
|
||||
VoiceSelection,
|
||||
)
|
||||
|
||||
from .conftest import FakeEngineSession
|
||||
|
||||
|
||||
class TestEngineSessionProtocolContract:
|
||||
"""Contract tests for the EngineSession protocol itself."""
|
||||
|
||||
def test_engine_session_is_protocol(self) -> None:
|
||||
assert hasattr(EngineSession, "__protocol_attrs__")
|
||||
|
||||
def test_fake_session_satisfies_protocol(self) -> None:
|
||||
session = FakeEngineSession()
|
||||
assert isinstance(session, EngineSession)
|
||||
|
||||
|
||||
class TestSessionSynthesizeContract:
|
||||
"""Contract tests for EngineSession.synthesize()."""
|
||||
|
||||
def test_synthesize_returns_synthesized_audio(self) -> None:
|
||||
session = FakeEngineSession()
|
||||
request = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
result = session.synthesize(request)
|
||||
assert isinstance(result, SynthesizedAudio)
|
||||
|
||||
def test_synthesize_returns_valid_audio_data(self) -> None:
|
||||
session = FakeEngineSession()
|
||||
request = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
result = session.synthesize(request)
|
||||
assert isinstance(result.data, bytes)
|
||||
assert len(result.data) > 0
|
||||
assert isinstance(result.format, AudioFormat)
|
||||
assert isinstance(result.duration, Duration)
|
||||
|
||||
|
||||
class TestSessionDisposeContract:
|
||||
"""Contract tests for EngineSession.dispose()."""
|
||||
|
||||
def test_dispose_is_idempotent(self) -> None:
|
||||
"""Architecture spec: dispose() is idempotent."""
|
||||
session = FakeEngineSession()
|
||||
session.dispose()
|
||||
session.dispose() # Should not raise
|
||||
|
||||
def test_dispose_never_raises(self) -> None:
|
||||
"""Architecture spec: dispose() never raises exceptions."""
|
||||
session = FakeEngineSession()
|
||||
session.dispose() # Should not raise
|
||||
|
||||
|
||||
class TestSessionAfterDisposeContract:
|
||||
"""Contract tests for behavior after dispose()."""
|
||||
|
||||
def test_synthesize_after_dispose_raises(self) -> None:
|
||||
"""Architecture spec: After dispose(), all methods except dispose() raise EngineError."""
|
||||
session = FakeEngineSession()
|
||||
session.dispose()
|
||||
request = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
with pytest.raises(EngineError):
|
||||
session.synthesize(request)
|
||||
|
||||
|
||||
class TestSessionLifecycleContract:
|
||||
"""Contract tests for EngineSession lifecycle."""
|
||||
|
||||
def test_full_lifecycle(self) -> None:
|
||||
"""Test complete session lifecycle: create -> synthesize -> dispose."""
|
||||
session = FakeEngineSession()
|
||||
request = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
|
||||
# Synthesize
|
||||
result = session.synthesize(request)
|
||||
assert isinstance(result, SynthesizedAudio)
|
||||
|
||||
# Dispose
|
||||
session.dispose()
|
||||
|
||||
def test_multiple_synthesize_before_dispose(self) -> None:
|
||||
"""Architecture spec: Session remains usable after synthesize() failure."""
|
||||
session = FakeEngineSession()
|
||||
request = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
|
||||
# Multiple synthesize calls
|
||||
result1 = session.synthesize(request)
|
||||
result2 = session.synthesize(request)
|
||||
assert isinstance(result1, SynthesizedAudio)
|
||||
assert isinstance(result2, SynthesizedAudio)
|
||||
|
||||
# Dispose
|
||||
session.dispose()
|
||||
|
||||
@@ -1,244 +1,244 @@
|
||||
"""Contract tests for core domain value objects.
|
||||
|
||||
These tests verify that value objects satisfy the architectural requirements:
|
||||
- Frozen (immutable) dataclasses
|
||||
- Correct field definitions
|
||||
- Proper equality behavior
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.types import (
|
||||
AudioFormat,
|
||||
Duration,
|
||||
EngineConfig,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
SynthesizedAudio,
|
||||
VoiceSelection,
|
||||
)
|
||||
|
||||
|
||||
class TestAudioFormatContract:
|
||||
"""Contract tests for AudioFormat value object."""
|
||||
|
||||
def test_is_frozen_dataclass(self) -> None:
|
||||
assert hasattr(AudioFormat, "__dataclass_params__")
|
||||
assert AudioFormat.__dataclass_params__.frozen is True
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
af = AudioFormat(mime="audio/wav", extension="wav")
|
||||
assert af.mime == "audio/wav"
|
||||
assert af.extension == "wav"
|
||||
|
||||
def test_immutability(self) -> None:
|
||||
af = AudioFormat(mime="audio/wav", extension="wav")
|
||||
with pytest.raises(AttributeError):
|
||||
af.mime = "audio/mpeg" # type: ignore[misc]
|
||||
|
||||
def test_equality(self) -> None:
|
||||
af1 = AudioFormat(mime="audio/wav", extension="wav")
|
||||
af2 = AudioFormat(mime="audio/wav", extension="wav")
|
||||
assert af1 == af2
|
||||
|
||||
def test_inequality(self) -> None:
|
||||
af1 = AudioFormat(mime="audio/wav", extension="wav")
|
||||
af2 = AudioFormat(mime="audio/mpeg", extension="mp3")
|
||||
assert af1 != af2
|
||||
|
||||
def test_hashable(self) -> None:
|
||||
af = AudioFormat(mime="audio/wav", extension="wav")
|
||||
assert hash(af) == hash(AudioFormat(mime="audio/wav", extension="wav"))
|
||||
|
||||
|
||||
class TestDurationContract:
|
||||
"""Contract tests for Duration value object."""
|
||||
|
||||
def test_is_frozen_dataclass(self) -> None:
|
||||
assert hasattr(Duration, "__dataclass_params__")
|
||||
assert Duration.__dataclass_params__.frozen is True
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
d = Duration(seconds=1.5)
|
||||
assert d.seconds == 1.5
|
||||
|
||||
def test_immutability(self) -> None:
|
||||
d = Duration(seconds=1.0)
|
||||
with pytest.raises(AttributeError):
|
||||
d.seconds = 2.0 # type: ignore[misc]
|
||||
|
||||
def test_equality(self) -> None:
|
||||
d1 = Duration(seconds=1.0)
|
||||
d2 = Duration(seconds=1.0)
|
||||
assert d1 == d2
|
||||
|
||||
|
||||
class TestVoiceSelectionContract:
|
||||
"""Contract tests for VoiceSelection value object."""
|
||||
|
||||
def test_is_frozen_dataclass(self) -> None:
|
||||
assert hasattr(VoiceSelection, "__dataclass_params__")
|
||||
assert VoiceSelection.__dataclass_params__.frozen is True
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
vs = VoiceSelection(source="builtin", key="af_nova")
|
||||
assert vs.source == "builtin"
|
||||
assert vs.key == "af_nova"
|
||||
|
||||
def test_payload_default_none(self) -> None:
|
||||
vs = VoiceSelection(source="builtin", key="af_nova")
|
||||
assert vs.payload is None
|
||||
|
||||
def test_payload_optional(self) -> None:
|
||||
vs = VoiceSelection(source="clone", key="my_voice", payload=b"audio_data")
|
||||
assert vs.payload == b"audio_data"
|
||||
|
||||
def test_immutability(self) -> None:
|
||||
vs = VoiceSelection(source="builtin", key="af_nova")
|
||||
with pytest.raises(AttributeError):
|
||||
vs.source = "other" # type: ignore[misc]
|
||||
|
||||
|
||||
class TestParameterValuesContract:
|
||||
"""Contract tests for ParameterValues value object."""
|
||||
|
||||
def test_is_frozen_dataclass(self) -> None:
|
||||
assert hasattr(ParameterValues, "__dataclass_params__")
|
||||
assert ParameterValues.__dataclass_params__.frozen is True
|
||||
|
||||
def test_default_empty(self) -> None:
|
||||
pv = ParameterValues()
|
||||
assert pv.values == {}
|
||||
|
||||
def test_with_values(self) -> None:
|
||||
pv = ParameterValues(values={"speed": 1.0, "pitch": 0.5})
|
||||
assert pv.values["speed"] == 1.0
|
||||
assert pv.values["pitch"] == 0.5
|
||||
|
||||
def test_immutability(self) -> None:
|
||||
pv = ParameterValues(values={"speed": 1.0})
|
||||
with pytest.raises(AttributeError):
|
||||
pv.values = {} # type: ignore[misc]
|
||||
|
||||
|
||||
class TestSynthesisRequestContract:
|
||||
"""Contract tests for SynthesisRequest value object."""
|
||||
|
||||
def test_is_frozen_dataclass(self) -> None:
|
||||
assert hasattr(SynthesisRequest, "__dataclass_params__")
|
||||
assert SynthesisRequest.__dataclass_params__.frozen is True
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
req = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
assert req.text == "Hello"
|
||||
assert req.voice.source == "builtin"
|
||||
assert req.format.mime == "audio/wav"
|
||||
|
||||
def test_immutability(self) -> None:
|
||||
req = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
with pytest.raises(AttributeError):
|
||||
req.text = "World" # type: ignore[misc]
|
||||
|
||||
|
||||
class TestSynthesizedAudioContract:
|
||||
"""Contract tests for SynthesizedAudio value object."""
|
||||
|
||||
def test_is_frozen_dataclass(self) -> None:
|
||||
assert hasattr(SynthesizedAudio, "__dataclass_params__")
|
||||
assert SynthesizedAudio.__dataclass_params__.frozen is True
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
audio = SynthesizedAudio(
|
||||
data=b"\x00" * 100,
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
duration=Duration(seconds=1.0),
|
||||
)
|
||||
assert audio.data == b"\x00" * 100
|
||||
assert audio.format.mime == "audio/wav"
|
||||
assert audio.duration.seconds == 1.0
|
||||
|
||||
def test_immutability(self) -> None:
|
||||
audio = SynthesizedAudio(
|
||||
data=b"\x00" * 100,
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
duration=Duration(seconds=1.0),
|
||||
)
|
||||
with pytest.raises(AttributeError):
|
||||
audio.data = b"\x00" # type: ignore[misc]
|
||||
|
||||
|
||||
class TestEngineConfigContract:
|
||||
"""Contract tests for EngineConfig value object."""
|
||||
|
||||
def test_is_frozen_dataclass(self) -> None:
|
||||
assert hasattr(EngineConfig, "__dataclass_params__")
|
||||
assert EngineConfig.__dataclass_params__.frozen is True
|
||||
|
||||
def test_default_device(self) -> None:
|
||||
config = EngineConfig()
|
||||
assert config.device == "cpu"
|
||||
|
||||
def test_custom_device(self) -> None:
|
||||
config = EngineConfig(device="cuda:0")
|
||||
assert config.device == "cuda:0"
|
||||
|
||||
def test_default_lang_code(self) -> None:
|
||||
config = EngineConfig()
|
||||
assert config.lang_code == "a"
|
||||
|
||||
def test_custom_lang_code(self) -> None:
|
||||
config = EngineConfig(lang_code="j")
|
||||
assert config.lang_code == "j"
|
||||
|
||||
def test_immutability(self) -> None:
|
||||
config = EngineConfig()
|
||||
with pytest.raises(AttributeError):
|
||||
config.device = "cuda:0" # type: ignore[misc]
|
||||
|
||||
def test_immutability_lang_code(self) -> None:
|
||||
config = EngineConfig()
|
||||
with pytest.raises(AttributeError):
|
||||
config.lang_code = "j" # type: ignore[misc]
|
||||
|
||||
def test_unknown_keys_ignored_per_spec(self) -> None:
|
||||
"""Architecture spec: Unknown keys are ignored (no error).
|
||||
|
||||
EngineConfig is frozen, so unknown keys cannot be set after creation.
|
||||
This test verifies the default behavior matches the spec.
|
||||
"""
|
||||
config = EngineConfig()
|
||||
assert config.device == "cpu"
|
||||
|
||||
def test_plugins_may_ignore_irrelevant_fields(self) -> None:
|
||||
"""Architecture Amendment #1: Plugins ignore unsupported fields.
|
||||
|
||||
EngineConfig may contain fields that are not relevant to every plugin.
|
||||
Plugins MUST ignore fields they do not need, not raise on them.
|
||||
"""
|
||||
config = EngineConfig(device="cuda:0", lang_code="j")
|
||||
assert config.device == "cuda:0"
|
||||
assert config.lang_code == "j"
|
||||
# A plugin that only needs device simply reads config.device
|
||||
# and ignores config.lang_code — this must not raise.
|
||||
|
||||
def test_engine_config_contains_engine_instance_configuration(self) -> None:
|
||||
"""Architecture Amendment #1: EngineConfig definition.
|
||||
|
||||
EngineConfig contains parameters that define how a particular
|
||||
Engine instance is created and that remain constant throughout
|
||||
the lifetime of that Engine.
|
||||
"""
|
||||
config = EngineConfig(device="cpu", lang_code="a")
|
||||
# Both fields are init-time, immutable, engine-scoped.
|
||||
assert config.device == "cpu"
|
||||
assert config.lang_code == "a"
|
||||
"""Contract tests for core domain value objects.
|
||||
|
||||
These tests verify that value objects satisfy the architectural requirements:
|
||||
- Frozen (immutable) dataclasses
|
||||
- Correct field definitions
|
||||
- Proper equality behavior
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.types import (
|
||||
AudioFormat,
|
||||
Duration,
|
||||
EngineConfig,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
SynthesizedAudio,
|
||||
VoiceSelection,
|
||||
)
|
||||
|
||||
|
||||
class TestAudioFormatContract:
|
||||
"""Contract tests for AudioFormat value object."""
|
||||
|
||||
def test_is_frozen_dataclass(self) -> None:
|
||||
assert hasattr(AudioFormat, "__dataclass_params__")
|
||||
assert AudioFormat.__dataclass_params__.frozen is True
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
af = AudioFormat(mime="audio/wav", extension="wav")
|
||||
assert af.mime == "audio/wav"
|
||||
assert af.extension == "wav"
|
||||
|
||||
def test_immutability(self) -> None:
|
||||
af = AudioFormat(mime="audio/wav", extension="wav")
|
||||
with pytest.raises(AttributeError):
|
||||
af.mime = "audio/mpeg" # type: ignore[misc]
|
||||
|
||||
def test_equality(self) -> None:
|
||||
af1 = AudioFormat(mime="audio/wav", extension="wav")
|
||||
af2 = AudioFormat(mime="audio/wav", extension="wav")
|
||||
assert af1 == af2
|
||||
|
||||
def test_inequality(self) -> None:
|
||||
af1 = AudioFormat(mime="audio/wav", extension="wav")
|
||||
af2 = AudioFormat(mime="audio/mpeg", extension="mp3")
|
||||
assert af1 != af2
|
||||
|
||||
def test_hashable(self) -> None:
|
||||
af = AudioFormat(mime="audio/wav", extension="wav")
|
||||
assert hash(af) == hash(AudioFormat(mime="audio/wav", extension="wav"))
|
||||
|
||||
|
||||
class TestDurationContract:
|
||||
"""Contract tests for Duration value object."""
|
||||
|
||||
def test_is_frozen_dataclass(self) -> None:
|
||||
assert hasattr(Duration, "__dataclass_params__")
|
||||
assert Duration.__dataclass_params__.frozen is True
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
d = Duration(seconds=1.5)
|
||||
assert d.seconds == 1.5
|
||||
|
||||
def test_immutability(self) -> None:
|
||||
d = Duration(seconds=1.0)
|
||||
with pytest.raises(AttributeError):
|
||||
d.seconds = 2.0 # type: ignore[misc]
|
||||
|
||||
def test_equality(self) -> None:
|
||||
d1 = Duration(seconds=1.0)
|
||||
d2 = Duration(seconds=1.0)
|
||||
assert d1 == d2
|
||||
|
||||
|
||||
class TestVoiceSelectionContract:
|
||||
"""Contract tests for VoiceSelection value object."""
|
||||
|
||||
def test_is_frozen_dataclass(self) -> None:
|
||||
assert hasattr(VoiceSelection, "__dataclass_params__")
|
||||
assert VoiceSelection.__dataclass_params__.frozen is True
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
vs = VoiceSelection(source="builtin", key="af_nova")
|
||||
assert vs.source == "builtin"
|
||||
assert vs.key == "af_nova"
|
||||
|
||||
def test_payload_default_none(self) -> None:
|
||||
vs = VoiceSelection(source="builtin", key="af_nova")
|
||||
assert vs.payload is None
|
||||
|
||||
def test_payload_optional(self) -> None:
|
||||
vs = VoiceSelection(source="clone", key="my_voice", payload=b"audio_data")
|
||||
assert vs.payload == b"audio_data"
|
||||
|
||||
def test_immutability(self) -> None:
|
||||
vs = VoiceSelection(source="builtin", key="af_nova")
|
||||
with pytest.raises(AttributeError):
|
||||
vs.source = "other" # type: ignore[misc]
|
||||
|
||||
|
||||
class TestParameterValuesContract:
|
||||
"""Contract tests for ParameterValues value object."""
|
||||
|
||||
def test_is_frozen_dataclass(self) -> None:
|
||||
assert hasattr(ParameterValues, "__dataclass_params__")
|
||||
assert ParameterValues.__dataclass_params__.frozen is True
|
||||
|
||||
def test_default_empty(self) -> None:
|
||||
pv = ParameterValues()
|
||||
assert pv.values == {}
|
||||
|
||||
def test_with_values(self) -> None:
|
||||
pv = ParameterValues(values={"speed": 1.0, "pitch": 0.5})
|
||||
assert pv.values["speed"] == 1.0
|
||||
assert pv.values["pitch"] == 0.5
|
||||
|
||||
def test_immutability(self) -> None:
|
||||
pv = ParameterValues(values={"speed": 1.0})
|
||||
with pytest.raises(AttributeError):
|
||||
pv.values = {} # type: ignore[misc]
|
||||
|
||||
|
||||
class TestSynthesisRequestContract:
|
||||
"""Contract tests for SynthesisRequest value object."""
|
||||
|
||||
def test_is_frozen_dataclass(self) -> None:
|
||||
assert hasattr(SynthesisRequest, "__dataclass_params__")
|
||||
assert SynthesisRequest.__dataclass_params__.frozen is True
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
req = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
assert req.text == "Hello"
|
||||
assert req.voice.source == "builtin"
|
||||
assert req.format.mime == "audio/wav"
|
||||
|
||||
def test_immutability(self) -> None:
|
||||
req = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
with pytest.raises(AttributeError):
|
||||
req.text = "World" # type: ignore[misc]
|
||||
|
||||
|
||||
class TestSynthesizedAudioContract:
|
||||
"""Contract tests for SynthesizedAudio value object."""
|
||||
|
||||
def test_is_frozen_dataclass(self) -> None:
|
||||
assert hasattr(SynthesizedAudio, "__dataclass_params__")
|
||||
assert SynthesizedAudio.__dataclass_params__.frozen is True
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
audio = SynthesizedAudio(
|
||||
data=b"\x00" * 100,
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
duration=Duration(seconds=1.0),
|
||||
)
|
||||
assert audio.data == b"\x00" * 100
|
||||
assert audio.format.mime == "audio/wav"
|
||||
assert audio.duration.seconds == 1.0
|
||||
|
||||
def test_immutability(self) -> None:
|
||||
audio = SynthesizedAudio(
|
||||
data=b"\x00" * 100,
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
duration=Duration(seconds=1.0),
|
||||
)
|
||||
with pytest.raises(AttributeError):
|
||||
audio.data = b"\x00" # type: ignore[misc]
|
||||
|
||||
|
||||
class TestEngineConfigContract:
|
||||
"""Contract tests for EngineConfig value object."""
|
||||
|
||||
def test_is_frozen_dataclass(self) -> None:
|
||||
assert hasattr(EngineConfig, "__dataclass_params__")
|
||||
assert EngineConfig.__dataclass_params__.frozen is True
|
||||
|
||||
def test_default_device(self) -> None:
|
||||
config = EngineConfig()
|
||||
assert config.device == "cpu"
|
||||
|
||||
def test_custom_device(self) -> None:
|
||||
config = EngineConfig(device="cuda:0")
|
||||
assert config.device == "cuda:0"
|
||||
|
||||
def test_default_lang_code(self) -> None:
|
||||
config = EngineConfig()
|
||||
assert config.lang_code == "a"
|
||||
|
||||
def test_custom_lang_code(self) -> None:
|
||||
config = EngineConfig(lang_code="j")
|
||||
assert config.lang_code == "j"
|
||||
|
||||
def test_immutability(self) -> None:
|
||||
config = EngineConfig()
|
||||
with pytest.raises(AttributeError):
|
||||
config.device = "cuda:0" # type: ignore[misc]
|
||||
|
||||
def test_immutability_lang_code(self) -> None:
|
||||
config = EngineConfig()
|
||||
with pytest.raises(AttributeError):
|
||||
config.lang_code = "j" # type: ignore[misc]
|
||||
|
||||
def test_unknown_keys_ignored_per_spec(self) -> None:
|
||||
"""Architecture spec: Unknown keys are ignored (no error).
|
||||
|
||||
EngineConfig is frozen, so unknown keys cannot be set after creation.
|
||||
This test verifies the default behavior matches the spec.
|
||||
"""
|
||||
config = EngineConfig()
|
||||
assert config.device == "cpu"
|
||||
|
||||
def test_plugins_may_ignore_irrelevant_fields(self) -> None:
|
||||
"""Architecture Amendment #1: Plugins ignore unsupported fields.
|
||||
|
||||
EngineConfig may contain fields that are not relevant to every plugin.
|
||||
Plugins MUST ignore fields they do not need, not raise on them.
|
||||
"""
|
||||
config = EngineConfig(device="cuda:0", lang_code="j")
|
||||
assert config.device == "cuda:0"
|
||||
assert config.lang_code == "j"
|
||||
# A plugin that only needs device simply reads config.device
|
||||
# and ignores config.lang_code — this must not raise.
|
||||
|
||||
def test_engine_config_contains_engine_instance_configuration(self) -> None:
|
||||
"""Architecture Amendment #1: EngineConfig definition.
|
||||
|
||||
EngineConfig contains parameters that define how a particular
|
||||
Engine instance is created and that remain constant throughout
|
||||
the lifetime of that Engine.
|
||||
"""
|
||||
config = EngineConfig(device="cpu", lang_code="a")
|
||||
# Both fields are init-time, immutable, engine-scoped.
|
||||
assert config.device == "cpu"
|
||||
assert config.lang_code == "a"
|
||||
|
||||
@@ -1,92 +1,92 @@
|
||||
"""Fake plugin for testing the plugin loader.
|
||||
|
||||
This is a minimal valid plugin that satisfies the Plugin API contract.
|
||||
It does NOT perform any real TTS synthesis.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
||||
from abogen.tts_plugin.errors import EngineError
|
||||
from abogen.tts_plugin.host_context import HostContext
|
||||
from abogen.tts_plugin.manifest import (
|
||||
AudioFormatManifest,
|
||||
EngineManifest,
|
||||
PluginManifest,
|
||||
VoiceSourceManifest,
|
||||
)
|
||||
from abogen.tts_plugin.types import (
|
||||
AudioFormat,
|
||||
Duration,
|
||||
EngineConfig,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
SynthesizedAudio,
|
||||
)
|
||||
|
||||
|
||||
class FakeSession:
|
||||
"""Minimal EngineSession implementation for testing."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._disposed = False
|
||||
|
||||
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
|
||||
if self._disposed:
|
||||
raise EngineError("Session disposed")
|
||||
return SynthesizedAudio(
|
||||
data=b"\x00" * 100,
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
duration=Duration(seconds=1.0),
|
||||
)
|
||||
|
||||
def dispose(self) -> None:
|
||||
self._disposed = True
|
||||
|
||||
|
||||
class FakeEngine:
|
||||
"""Minimal Engine implementation for testing."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._disposed = False
|
||||
|
||||
def createSession(self) -> EngineSession:
|
||||
if self._disposed:
|
||||
raise EngineError("Engine disposed")
|
||||
return FakeSession()
|
||||
|
||||
def dispose(self) -> None:
|
||||
self._disposed = True
|
||||
|
||||
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="fake_plugin",
|
||||
name="Fake Plugin",
|
||||
version="1.0.0",
|
||||
api_version="1.0",
|
||||
description="A fake plugin for testing",
|
||||
author="Test Author",
|
||||
capabilities=(),
|
||||
engine=EngineManifest(
|
||||
voiceSources=(
|
||||
VoiceSourceManifest(id="builtin", name="Builtin", type="list"),
|
||||
),
|
||||
audioFormats=(
|
||||
AudioFormatManifest(mime="audio/wav", extension="wav"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
MODEL_REQUIREMENTS: list[Any] = []
|
||||
|
||||
|
||||
def create_engine(
|
||||
context: HostContext,
|
||||
model_path: Path | None,
|
||||
config: EngineConfig,
|
||||
) -> Engine:
|
||||
"""Create a fake engine instance."""
|
||||
return FakeEngine()
|
||||
"""Fake plugin for testing the plugin loader.
|
||||
|
||||
This is a minimal valid plugin that satisfies the Plugin API contract.
|
||||
It does NOT perform any real TTS synthesis.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
||||
from abogen.tts_plugin.errors import EngineError
|
||||
from abogen.tts_plugin.host_context import HostContext
|
||||
from abogen.tts_plugin.manifest import (
|
||||
AudioFormatManifest,
|
||||
EngineManifest,
|
||||
PluginManifest,
|
||||
VoiceSourceManifest,
|
||||
)
|
||||
from abogen.tts_plugin.types import (
|
||||
AudioFormat,
|
||||
Duration,
|
||||
EngineConfig,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
SynthesizedAudio,
|
||||
)
|
||||
|
||||
|
||||
class FakeSession:
|
||||
"""Minimal EngineSession implementation for testing."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._disposed = False
|
||||
|
||||
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
|
||||
if self._disposed:
|
||||
raise EngineError("Session disposed")
|
||||
return SynthesizedAudio(
|
||||
data=b"\x00" * 100,
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
duration=Duration(seconds=1.0),
|
||||
)
|
||||
|
||||
def dispose(self) -> None:
|
||||
self._disposed = True
|
||||
|
||||
|
||||
class FakeEngine:
|
||||
"""Minimal Engine implementation for testing."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._disposed = False
|
||||
|
||||
def createSession(self) -> EngineSession:
|
||||
if self._disposed:
|
||||
raise EngineError("Engine disposed")
|
||||
return FakeSession()
|
||||
|
||||
def dispose(self) -> None:
|
||||
self._disposed = True
|
||||
|
||||
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="fake_plugin",
|
||||
name="Fake Plugin",
|
||||
version="1.0.0",
|
||||
api_version="1.0",
|
||||
description="A fake plugin for testing",
|
||||
author="Test Author",
|
||||
capabilities=(),
|
||||
engine=EngineManifest(
|
||||
voiceSources=(
|
||||
VoiceSourceManifest(id="builtin", name="Builtin", type="list"),
|
||||
),
|
||||
audioFormats=(
|
||||
AudioFormatManifest(mime="audio/wav", extension="wav"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
MODEL_REQUIREMENTS: list[Any] = []
|
||||
|
||||
|
||||
def create_engine(
|
||||
context: HostContext,
|
||||
model_path: Path | None,
|
||||
config: EngineConfig,
|
||||
) -> Engine:
|
||||
"""Create a fake engine instance."""
|
||||
return FakeEngine()
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
"""Invalid plugin: raises ImportError during import."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# This plugin intentionally raises an ImportError
|
||||
raise ImportError("Simulated import error for testing")
|
||||
|
||||
# The following code will never be reached, but is here for documentation
|
||||
from abogen.tts_plugin.manifest import PluginManifest
|
||||
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="import_error",
|
||||
name="Import Error Plugin",
|
||||
version="1.0.0",
|
||||
api_version="1.0",
|
||||
description="Plugin that fails to import",
|
||||
author="Test Author",
|
||||
)
|
||||
"""Invalid plugin: raises ImportError during import."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# This plugin intentionally raises an ImportError
|
||||
raise ImportError("Simulated import error for testing")
|
||||
|
||||
# The following code will never be reached, but is here for documentation
|
||||
from abogen.tts_plugin.manifest import PluginManifest
|
||||
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="import_error",
|
||||
name="Import Error Plugin",
|
||||
version="1.0.0",
|
||||
api_version="1.0",
|
||||
description="Plugin that fails to import",
|
||||
author="Test Author",
|
||||
)
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
"""Invalid plugin: incompatible api_version (major version mismatch)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from abogen.tts_plugin.manifest import PluginManifest
|
||||
from abogen.tts_plugin.types import EngineConfig
|
||||
|
||||
# api_version "2.0" has major version 2, but host expects major version 1
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="invalid_api_version",
|
||||
name="Invalid API Version Plugin",
|
||||
version="1.0.0",
|
||||
api_version="2.0", # Major version mismatch!
|
||||
description="Plugin with incompatible api_version",
|
||||
author="Test Author",
|
||||
)
|
||||
|
||||
MODEL_REQUIREMENTS: list[Any] = []
|
||||
|
||||
|
||||
def create_engine(
|
||||
context: Any,
|
||||
model_path: Path | None,
|
||||
config: EngineConfig,
|
||||
) -> Any:
|
||||
raise NotImplementedError("This plugin is invalid")
|
||||
"""Invalid plugin: incompatible api_version (major version mismatch)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from abogen.tts_plugin.manifest import PluginManifest
|
||||
from abogen.tts_plugin.types import EngineConfig
|
||||
|
||||
# api_version "2.0" has major version 2, but host expects major version 1
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="invalid_api_version",
|
||||
name="Invalid API Version Plugin",
|
||||
version="1.0.0",
|
||||
api_version="2.0", # Major version mismatch!
|
||||
description="Plugin with incompatible api_version",
|
||||
author="Test Author",
|
||||
)
|
||||
|
||||
MODEL_REQUIREMENTS: list[Any] = []
|
||||
|
||||
|
||||
def create_engine(
|
||||
context: Any,
|
||||
model_path: Path | None,
|
||||
config: EngineConfig,
|
||||
) -> Any:
|
||||
raise NotImplementedError("This plugin is invalid")
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
"""Invalid plugin: unknown capabilities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from abogen.tts_plugin.manifest import PluginManifest
|
||||
from abogen.tts_plugin.types import EngineConfig
|
||||
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="invalid_capabilities",
|
||||
name="Invalid Capabilities Plugin",
|
||||
version="1.0.0",
|
||||
api_version="1.0",
|
||||
description="Plugin with unknown capabilities",
|
||||
author="Test Author",
|
||||
capabilities=("voice_list", "unknown_capability", "another_unknown"),
|
||||
)
|
||||
|
||||
MODEL_REQUIREMENTS: list[Any] = []
|
||||
|
||||
|
||||
def create_engine(
|
||||
context: Any,
|
||||
model_path: Path | None,
|
||||
config: EngineConfig,
|
||||
) -> Any:
|
||||
raise NotImplementedError("This plugin is invalid")
|
||||
"""Invalid plugin: unknown capabilities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from abogen.tts_plugin.manifest import PluginManifest
|
||||
from abogen.tts_plugin.types import EngineConfig
|
||||
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="invalid_capabilities",
|
||||
name="Invalid Capabilities Plugin",
|
||||
version="1.0.0",
|
||||
api_version="1.0",
|
||||
description="Plugin with unknown capabilities",
|
||||
author="Test Author",
|
||||
capabilities=("voice_list", "unknown_capability", "another_unknown"),
|
||||
)
|
||||
|
||||
MODEL_REQUIREMENTS: list[Any] = []
|
||||
|
||||
|
||||
def create_engine(
|
||||
context: Any,
|
||||
model_path: Path | None,
|
||||
config: EngineConfig,
|
||||
) -> Any:
|
||||
raise NotImplementedError("This plugin is invalid")
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
"""Invalid plugin: missing create_engine function."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abogen.tts_plugin.manifest import PluginManifest
|
||||
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="missing_create_engine",
|
||||
name="Missing Create Engine Plugin",
|
||||
version="1.0.0",
|
||||
api_version="1.0",
|
||||
description="Plugin missing create_engine",
|
||||
author="Test Author",
|
||||
)
|
||||
|
||||
MODEL_REQUIREMENTS: list = []
|
||||
|
||||
# This plugin intentionally does NOT export create_engine
|
||||
"""Invalid plugin: missing create_engine function."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abogen.tts_plugin.manifest import PluginManifest
|
||||
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="missing_create_engine",
|
||||
name="Missing Create Engine Plugin",
|
||||
version="1.0.0",
|
||||
api_version="1.0",
|
||||
description="Plugin missing create_engine",
|
||||
author="Test Author",
|
||||
)
|
||||
|
||||
MODEL_REQUIREMENTS: list = []
|
||||
|
||||
# This plugin intentionally does NOT export create_engine
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Invalid plugin: missing PLUGIN_MANIFEST."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# This plugin intentionally does NOT export PLUGIN_MANIFEST
|
||||
MODEL_REQUIREMENTS: list = []
|
||||
|
||||
|
||||
def create_engine(context, model_path, config):
|
||||
raise NotImplementedError("This plugin is invalid")
|
||||
"""Invalid plugin: missing PLUGIN_MANIFEST."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# This plugin intentionally does NOT export PLUGIN_MANIFEST
|
||||
MODEL_REQUIREMENTS: list = []
|
||||
|
||||
|
||||
def create_engine(context, model_path, config):
|
||||
raise NotImplementedError("This plugin is invalid")
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
"""Invalid plugin: missing MODEL_REQUIREMENTS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from abogen.tts_plugin.manifest import PluginManifest
|
||||
from abogen.tts_plugin.types import EngineConfig
|
||||
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="missing_model_requirements",
|
||||
name="Missing Model Requirements Plugin",
|
||||
version="1.0.0",
|
||||
api_version="1.0",
|
||||
description="Plugin missing MODEL_REQUIREMENTS",
|
||||
author="Test Author",
|
||||
)
|
||||
|
||||
# This plugin intentionally does NOT export MODEL_REQUIREMENTS
|
||||
|
||||
|
||||
def create_engine(
|
||||
context: Any,
|
||||
model_path: Path | None,
|
||||
config: EngineConfig,
|
||||
) -> Any:
|
||||
raise NotImplementedError("This plugin is invalid")
|
||||
"""Invalid plugin: missing MODEL_REQUIREMENTS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from abogen.tts_plugin.manifest import PluginManifest
|
||||
from abogen.tts_plugin.types import EngineConfig
|
||||
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="missing_model_requirements",
|
||||
name="Missing Model Requirements Plugin",
|
||||
version="1.0.0",
|
||||
api_version="1.0",
|
||||
description="Plugin missing MODEL_REQUIREMENTS",
|
||||
author="Test Author",
|
||||
)
|
||||
|
||||
# This plugin intentionally does NOT export MODEL_REQUIREMENTS
|
||||
|
||||
|
||||
def create_engine(
|
||||
context: Any,
|
||||
model_path: Path | None,
|
||||
config: EngineConfig,
|
||||
) -> Any:
|
||||
raise NotImplementedError("This plugin is invalid")
|
||||
|
||||
+1088
-1088
File diff suppressed because it is too large
Load Diff
@@ -1,52 +1,52 @@
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
|
||||
from abogen.tts_plugin.utils import get_voices
|
||||
from abogen.webui.conversion_runner import (
|
||||
_chapter_voice_spec,
|
||||
_chunk_voice_spec,
|
||||
_collect_required_voice_ids,
|
||||
)
|
||||
from abogen.webui.service import Job
|
||||
|
||||
|
||||
def _sample_job(formula: str) -> Job:
|
||||
return cast(
|
||||
Job,
|
||||
SimpleNamespace(
|
||||
voice="__custom_mix",
|
||||
speakers={
|
||||
"narrator": {
|
||||
"resolved_voice": formula,
|
||||
}
|
||||
},
|
||||
chapters=[],
|
||||
chunks=[{}],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_chapter_voice_spec_uses_resolved_formula():
|
||||
formula = "af_nova*0.7+am_liam*0.3"
|
||||
job = _sample_job(formula)
|
||||
|
||||
assert _chapter_voice_spec(job, None) == formula
|
||||
|
||||
|
||||
def test_chunk_voice_fallback_uses_resolved_formula():
|
||||
formula = "af_nova*0.7+am_liam*0.3"
|
||||
job = _sample_job(formula)
|
||||
|
||||
result = _chunk_voice_spec(job, {}, "")
|
||||
|
||||
assert result == formula
|
||||
|
||||
|
||||
def test_voice_collection_includes_formula_components():
|
||||
formula = "af_nova*0.7+am_liam*0.3"
|
||||
job = _sample_job(formula)
|
||||
|
||||
voices = _collect_required_voice_ids(job)
|
||||
|
||||
assert {"af_nova", "am_liam"}.issubset(voices)
|
||||
assert voices.issuperset(get_voices("kokoro"))
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
|
||||
from abogen.tts_plugin.utils import get_voices
|
||||
from abogen.webui.conversion_runner import (
|
||||
_chapter_voice_spec,
|
||||
_chunk_voice_spec,
|
||||
_collect_required_voice_ids,
|
||||
)
|
||||
from abogen.webui.service import Job
|
||||
|
||||
|
||||
def _sample_job(formula: str) -> Job:
|
||||
return cast(
|
||||
Job,
|
||||
SimpleNamespace(
|
||||
voice="__custom_mix",
|
||||
speakers={
|
||||
"narrator": {
|
||||
"resolved_voice": formula,
|
||||
}
|
||||
},
|
||||
chapters=[],
|
||||
chunks=[{}],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_chapter_voice_spec_uses_resolved_formula():
|
||||
formula = "af_nova*0.7+am_liam*0.3"
|
||||
job = _sample_job(formula)
|
||||
|
||||
assert _chapter_voice_spec(job, None) == formula
|
||||
|
||||
|
||||
def test_chunk_voice_fallback_uses_resolved_formula():
|
||||
formula = "af_nova*0.7+am_liam*0.3"
|
||||
job = _sample_job(formula)
|
||||
|
||||
result = _chunk_voice_spec(job, {}, "")
|
||||
|
||||
assert result == formula
|
||||
|
||||
|
||||
def test_voice_collection_includes_formula_components():
|
||||
formula = "af_nova*0.7+am_liam*0.3"
|
||||
job = _sample_job(formula)
|
||||
|
||||
voices = _collect_required_voice_ids(job)
|
||||
|
||||
assert {"af_nova", "am_liam"}.issubset(voices)
|
||||
assert voices.issuperset(get_voices("kokoro"))
|
||||
|
||||
+196
-196
@@ -1,196 +1,196 @@
|
||||
"""Tests for the Kokoro TTS Plugin.
|
||||
|
||||
These tests verify that the Kokoro plugin:
|
||||
- Loads correctly through the Plugin Loader
|
||||
- Has a valid manifest
|
||||
- Creates a valid Engine
|
||||
- Satisfies the Engine/EngineSession contract (via EngineContractMixin)
|
||||
- Implements VoiceLister capability
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
||||
from abogen.tts_plugin.host_context import HostContext
|
||||
from abogen.tts_plugin.loader import load_plugin_from_dir
|
||||
from abogen.tts_plugin.manifest import PluginManifest
|
||||
from abogen.tts_plugin.types import (
|
||||
AudioFormat,
|
||||
EngineConfig,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
VoiceSelection,
|
||||
)
|
||||
|
||||
from tests.contracts.engine_contract import EngineContractMixin
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _kokoro_available() -> bool:
|
||||
try:
|
||||
from kokoro import KPipeline # type: ignore[import-not-found]
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
def _make_mock_engine() -> Any:
|
||||
from plugins.kokoro.engine import KokoroEngine
|
||||
|
||||
class MockPipeline:
|
||||
def __call__(self, text, voice, speed, split_pattern=None):
|
||||
class MockSegment:
|
||||
def __init__(self):
|
||||
self.audio = MockAudio()
|
||||
class MockAudio:
|
||||
def numpy(self):
|
||||
import numpy as np
|
||||
return np.zeros(24000, dtype="float32")
|
||||
return [MockSegment()]
|
||||
|
||||
engine = KokoroEngine(MockPipeline())
|
||||
|
||||
# Override listVoices for testing (real engine reads from manifest)
|
||||
from abogen.tts_plugin.manifest import VoiceManifest
|
||||
engine.listVoices = lambda source_id: [
|
||||
VoiceManifest(id="test_voice_1", name="Test Voice 1", tags=("en",)),
|
||||
VoiceManifest(id="test_voice_2", name="Test Voice 2", tags=("es",)),
|
||||
]
|
||||
return engine
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Fixtures
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def kokoro_plugin_dir() -> Path:
|
||||
return Path(__file__).parent.parent / "plugins" / "kokoro"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def host_context(tmp_path: Path) -> HostContext:
|
||||
class FakeHttpClient:
|
||||
def get(self, url: str, **kwargs: object) -> object:
|
||||
return None
|
||||
def post(self, url: str, **kwargs: object) -> object:
|
||||
return None
|
||||
|
||||
return HostContext(
|
||||
config_dir=tmp_path,
|
||||
logger=logging.getLogger("test"),
|
||||
http_client=FakeHttpClient(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def engine() -> Engine:
|
||||
return _make_mock_engine()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Plugin Loading Tests
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestKokoroPluginLoading:
|
||||
|
||||
def test_plugin_loads_successfully(self, kokoro_plugin_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(kokoro_plugin_dir)
|
||||
assert result.success is True
|
||||
assert result.manifest is not None
|
||||
assert result.create_engine is not None
|
||||
|
||||
def test_plugin_has_valid_manifest(self, kokoro_plugin_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(kokoro_plugin_dir)
|
||||
assert result.success is True
|
||||
manifest = result.manifest
|
||||
assert isinstance(manifest, PluginManifest)
|
||||
assert manifest.id == "kokoro"
|
||||
assert manifest.name == "Kokoro"
|
||||
assert manifest.api_version == "1.0"
|
||||
|
||||
def test_plugin_has_model_requirements(self, kokoro_plugin_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(kokoro_plugin_dir)
|
||||
assert result.success is True
|
||||
assert result.model_requirements is not None
|
||||
assert isinstance(result.model_requirements, tuple)
|
||||
|
||||
def test_plugin_manifest_capabilities(self, kokoro_plugin_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(kokoro_plugin_dir)
|
||||
assert result.success is True
|
||||
assert "voice_list" in result.manifest.capabilities
|
||||
|
||||
def test_plugin_manifest_engine(self, kokoro_plugin_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(kokoro_plugin_dir)
|
||||
assert result.success is True
|
||||
engine_manifest = result.manifest.engine
|
||||
assert len(engine_manifest.voiceSources) > 0
|
||||
assert len(engine_manifest.audioFormats) > 0
|
||||
assert len(engine_manifest.parameters) > 0
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Engine Creation (real backend, skipped if not installed)
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestKokoroEngineCreation:
|
||||
|
||||
@pytest.mark.skipif(not _kokoro_available(), reason="Kokoro not installed")
|
||||
def test_create_engine(self, kokoro_plugin_dir: Path, host_context: HostContext) -> None:
|
||||
result = load_plugin_from_dir(kokoro_plugin_dir)
|
||||
assert result.success is True
|
||||
engine = result.create_engine(host_context, None, EngineConfig())
|
||||
assert isinstance(engine, Engine)
|
||||
engine.dispose()
|
||||
|
||||
@pytest.mark.skipif(not _kokoro_available(), reason="Kokoro not installed")
|
||||
def test_engine_satisfies_protocol(self, kokoro_plugin_dir: Path, host_context: HostContext) -> None:
|
||||
result = load_plugin_from_dir(kokoro_plugin_dir)
|
||||
assert result.success is True
|
||||
engine = result.create_engine(host_context, None, EngineConfig())
|
||||
assert isinstance(engine, Engine)
|
||||
engine.dispose()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Engine / Session Contract (inherited from base)
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestKokoroEngineContract(EngineContractMixin):
|
||||
"""Every test from EngineContractMixin runs against KokoroEngine."""
|
||||
|
||||
@pytest.fixture
|
||||
def default_voice(self) -> str:
|
||||
return "af_nova"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# VoiceLister Tests
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestKokoroVoiceLister:
|
||||
|
||||
def test_list_voices(self) -> None:
|
||||
engine = _make_mock_engine()
|
||||
voices = engine.listVoices("builtin")
|
||||
assert len(voices) > 0
|
||||
assert all(hasattr(v, "id") for v in voices)
|
||||
assert all(hasattr(v, "name") for v in voices)
|
||||
engine.dispose()
|
||||
|
||||
def test_voices_have_tags(self) -> None:
|
||||
engine = _make_mock_engine()
|
||||
voices = engine.listVoices("builtin")
|
||||
for voice in voices:
|
||||
assert isinstance(voice.tags, tuple)
|
||||
assert len(voice.tags) > 0
|
||||
engine.dispose()
|
||||
"""Tests for the Kokoro TTS Plugin.
|
||||
|
||||
These tests verify that the Kokoro plugin:
|
||||
- Loads correctly through the Plugin Loader
|
||||
- Has a valid manifest
|
||||
- Creates a valid Engine
|
||||
- Satisfies the Engine/EngineSession contract (via EngineContractMixin)
|
||||
- Implements VoiceLister capability
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
||||
from abogen.tts_plugin.host_context import HostContext
|
||||
from abogen.tts_plugin.loader import load_plugin_from_dir
|
||||
from abogen.tts_plugin.manifest import PluginManifest
|
||||
from abogen.tts_plugin.types import (
|
||||
AudioFormat,
|
||||
EngineConfig,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
VoiceSelection,
|
||||
)
|
||||
|
||||
from tests.contracts.engine_contract import EngineContractMixin
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _kokoro_available() -> bool:
|
||||
try:
|
||||
from kokoro import KPipeline # type: ignore[import-not-found]
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
def _make_mock_engine() -> Any:
|
||||
from plugins.kokoro.engine import KokoroEngine
|
||||
|
||||
class MockPipeline:
|
||||
def __call__(self, text, voice, speed, split_pattern=None):
|
||||
class MockSegment:
|
||||
def __init__(self):
|
||||
self.audio = MockAudio()
|
||||
class MockAudio:
|
||||
def numpy(self):
|
||||
import numpy as np
|
||||
return np.zeros(24000, dtype="float32")
|
||||
return [MockSegment()]
|
||||
|
||||
engine = KokoroEngine(MockPipeline())
|
||||
|
||||
# Override listVoices for testing (real engine reads from manifest)
|
||||
from abogen.tts_plugin.manifest import VoiceManifest
|
||||
engine.listVoices = lambda source_id: [
|
||||
VoiceManifest(id="test_voice_1", name="Test Voice 1", tags=("en",)),
|
||||
VoiceManifest(id="test_voice_2", name="Test Voice 2", tags=("es",)),
|
||||
]
|
||||
return engine
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Fixtures
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def kokoro_plugin_dir() -> Path:
|
||||
return Path(__file__).parent.parent / "plugins" / "kokoro"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def host_context(tmp_path: Path) -> HostContext:
|
||||
class FakeHttpClient:
|
||||
def get(self, url: str, **kwargs: object) -> object:
|
||||
return None
|
||||
def post(self, url: str, **kwargs: object) -> object:
|
||||
return None
|
||||
|
||||
return HostContext(
|
||||
config_dir=tmp_path,
|
||||
logger=logging.getLogger("test"),
|
||||
http_client=FakeHttpClient(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def engine() -> Engine:
|
||||
return _make_mock_engine()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Plugin Loading Tests
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestKokoroPluginLoading:
|
||||
|
||||
def test_plugin_loads_successfully(self, kokoro_plugin_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(kokoro_plugin_dir)
|
||||
assert result.success is True
|
||||
assert result.manifest is not None
|
||||
assert result.create_engine is not None
|
||||
|
||||
def test_plugin_has_valid_manifest(self, kokoro_plugin_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(kokoro_plugin_dir)
|
||||
assert result.success is True
|
||||
manifest = result.manifest
|
||||
assert isinstance(manifest, PluginManifest)
|
||||
assert manifest.id == "kokoro"
|
||||
assert manifest.name == "Kokoro"
|
||||
assert manifest.api_version == "1.0"
|
||||
|
||||
def test_plugin_has_model_requirements(self, kokoro_plugin_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(kokoro_plugin_dir)
|
||||
assert result.success is True
|
||||
assert result.model_requirements is not None
|
||||
assert isinstance(result.model_requirements, tuple)
|
||||
|
||||
def test_plugin_manifest_capabilities(self, kokoro_plugin_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(kokoro_plugin_dir)
|
||||
assert result.success is True
|
||||
assert "voice_list" in result.manifest.capabilities
|
||||
|
||||
def test_plugin_manifest_engine(self, kokoro_plugin_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(kokoro_plugin_dir)
|
||||
assert result.success is True
|
||||
engine_manifest = result.manifest.engine
|
||||
assert len(engine_manifest.voiceSources) > 0
|
||||
assert len(engine_manifest.audioFormats) > 0
|
||||
assert len(engine_manifest.parameters) > 0
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Engine Creation (real backend, skipped if not installed)
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestKokoroEngineCreation:
|
||||
|
||||
@pytest.mark.skipif(not _kokoro_available(), reason="Kokoro not installed")
|
||||
def test_create_engine(self, kokoro_plugin_dir: Path, host_context: HostContext) -> None:
|
||||
result = load_plugin_from_dir(kokoro_plugin_dir)
|
||||
assert result.success is True
|
||||
engine = result.create_engine(host_context, None, EngineConfig())
|
||||
assert isinstance(engine, Engine)
|
||||
engine.dispose()
|
||||
|
||||
@pytest.mark.skipif(not _kokoro_available(), reason="Kokoro not installed")
|
||||
def test_engine_satisfies_protocol(self, kokoro_plugin_dir: Path, host_context: HostContext) -> None:
|
||||
result = load_plugin_from_dir(kokoro_plugin_dir)
|
||||
assert result.success is True
|
||||
engine = result.create_engine(host_context, None, EngineConfig())
|
||||
assert isinstance(engine, Engine)
|
||||
engine.dispose()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Engine / Session Contract (inherited from base)
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestKokoroEngineContract(EngineContractMixin):
|
||||
"""Every test from EngineContractMixin runs against KokoroEngine."""
|
||||
|
||||
@pytest.fixture
|
||||
def default_voice(self) -> str:
|
||||
return "af_nova"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# VoiceLister Tests
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestKokoroVoiceLister:
|
||||
|
||||
def test_list_voices(self) -> None:
|
||||
engine = _make_mock_engine()
|
||||
voices = engine.listVoices("builtin")
|
||||
assert len(voices) > 0
|
||||
assert all(hasattr(v, "id") for v in voices)
|
||||
assert all(hasattr(v, "name") for v in voices)
|
||||
engine.dispose()
|
||||
|
||||
def test_voices_have_tags(self) -> None:
|
||||
engine = _make_mock_engine()
|
||||
voices = engine.listVoices("builtin")
|
||||
for voice in voices:
|
||||
assert isinstance(voice.tags, tuple)
|
||||
assert len(voice.tags) > 0
|
||||
engine.dispose()
|
||||
|
||||
@@ -1,60 +1,60 @@
|
||||
from abogen.webui.routes.utils import preview
|
||||
|
||||
|
||||
def test_preview_applies_manual_override_before_normalization(monkeypatch):
|
||||
# Don't run real TTS/normalization; just exercise the override stage by
|
||||
# forcing provider=kokoro and then stubbing normalize_for_pipeline.
|
||||
|
||||
monkeypatch.setattr(preview, "get_preview_pipeline", lambda language, device: None)
|
||||
|
||||
# Stub normalize_for_pipeline to be identity; we only care that overrides run.
|
||||
class _Norm:
|
||||
@staticmethod
|
||||
def normalize_for_pipeline(text):
|
||||
return text
|
||||
|
||||
monkeypatch.setitem(
|
||||
__import__("sys").modules, "abogen.kokoro_text_normalization", _Norm
|
||||
)
|
||||
|
||||
# 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
|
||||
# with provider=supertonic and stub create_backend to capture input.
|
||||
captured = {}
|
||||
|
||||
class DummyPipeline:
|
||||
def __init__(self, **kwargs):
|
||||
pass
|
||||
|
||||
def __call__(self, text, **kwargs):
|
||||
captured["text"] = text
|
||||
return iter(())
|
||||
|
||||
from abogen.tts_plugin import utils
|
||||
|
||||
original_create_pipeline = utils.create_pipeline
|
||||
|
||||
def _mock_create_pipeline(backend_id, **kwargs):
|
||||
if backend_id == "supertonic":
|
||||
return DummyPipeline(**kwargs)
|
||||
return original_create_pipeline(backend_id, **kwargs)
|
||||
|
||||
monkeypatch.setattr(utils, "create_pipeline", _mock_create_pipeline)
|
||||
|
||||
try:
|
||||
preview.generate_preview_audio(
|
||||
text="He said Unfu*k loudly.",
|
||||
voice_spec="M1",
|
||||
language="en",
|
||||
speed=1.0,
|
||||
use_gpu=False,
|
||||
tts_provider="supertonic",
|
||||
manual_overrides=[{"token": "Unfu*k", "pronunciation": "Unfuck"}],
|
||||
)
|
||||
except Exception:
|
||||
# generate_preview_audio will raise because no audio chunks; that's fine.
|
||||
pass
|
||||
|
||||
assert "text" in captured
|
||||
assert "Unfuck" in captured["text"]
|
||||
assert "Unfu*k" not in captured["text"]
|
||||
from abogen.webui.routes.utils import preview
|
||||
|
||||
|
||||
def test_preview_applies_manual_override_before_normalization(monkeypatch):
|
||||
# Don't run real TTS/normalization; just exercise the override stage by
|
||||
# forcing provider=kokoro and then stubbing normalize_for_pipeline.
|
||||
|
||||
monkeypatch.setattr(preview, "get_preview_pipeline", lambda language, device: None)
|
||||
|
||||
# Stub normalize_for_pipeline to be identity; we only care that overrides run.
|
||||
class _Norm:
|
||||
@staticmethod
|
||||
def normalize_for_pipeline(text):
|
||||
return text
|
||||
|
||||
monkeypatch.setitem(
|
||||
__import__("sys").modules, "abogen.kokoro_text_normalization", _Norm
|
||||
)
|
||||
|
||||
# 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
|
||||
# with provider=supertonic and stub create_backend to capture input.
|
||||
captured = {}
|
||||
|
||||
class DummyPipeline:
|
||||
def __init__(self, **kwargs):
|
||||
pass
|
||||
|
||||
def __call__(self, text, **kwargs):
|
||||
captured["text"] = text
|
||||
return iter(())
|
||||
|
||||
from abogen.tts_plugin import utils
|
||||
|
||||
original_create_pipeline = utils.create_pipeline
|
||||
|
||||
def _mock_create_pipeline(backend_id, **kwargs):
|
||||
if backend_id == "supertonic":
|
||||
return DummyPipeline(**kwargs)
|
||||
return original_create_pipeline(backend_id, **kwargs)
|
||||
|
||||
monkeypatch.setattr(utils, "create_pipeline", _mock_create_pipeline)
|
||||
|
||||
try:
|
||||
preview.generate_preview_audio(
|
||||
text="He said Unfu*k loudly.",
|
||||
voice_spec="M1",
|
||||
language="en",
|
||||
speed=1.0,
|
||||
use_gpu=False,
|
||||
tts_provider="supertonic",
|
||||
manual_overrides=[{"token": "Unfu*k", "pronunciation": "Unfuck"}],
|
||||
)
|
||||
except Exception:
|
||||
# generate_preview_audio will raise because no audio chunks; that's fine.
|
||||
pass
|
||||
|
||||
assert "text" in captured
|
||||
assert "Unfuck" in captured["text"]
|
||||
assert "Unfu*k" not in captured["text"]
|
||||
|
||||
+281
-281
@@ -1,281 +1,281 @@
|
||||
"""Tests for the SuperTonic TTS Plugin.
|
||||
|
||||
These tests verify that the SuperTonic plugin:
|
||||
- Loads correctly through the Plugin Loader
|
||||
- Has a valid manifest
|
||||
- Creates a valid Engine
|
||||
- Satisfies the Engine/EngineSession contract (via EngineContractMixin)
|
||||
- Implements VoiceLister capability
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
||||
from abogen.tts_plugin.host_context import HostContext
|
||||
from abogen.tts_plugin.loader import load_plugin_from_dir
|
||||
from abogen.tts_plugin.manifest import PluginManifest
|
||||
from abogen.tts_plugin.types import (
|
||||
AudioFormat,
|
||||
EngineConfig,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
VoiceSelection,
|
||||
)
|
||||
|
||||
from tests.contracts.engine_contract import EngineContractMixin
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _supertonic_available() -> bool:
|
||||
try:
|
||||
from supertonic import TTS # type: ignore[import-not-found]
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
def _make_mock_engine() -> Any:
|
||||
from plugins.supertonic.engine import SuperTonicEngine
|
||||
|
||||
class MockSegment:
|
||||
def __init__(self):
|
||||
import numpy as np
|
||||
self.audio = np.zeros(24000, dtype="float32")
|
||||
|
||||
class MockPipeline:
|
||||
sample_rate = 24000
|
||||
|
||||
def __call__(self, text, voice, speed, split_pattern=None, total_steps=None):
|
||||
return [MockSegment()]
|
||||
|
||||
engine = SuperTonicEngine(MockPipeline())
|
||||
|
||||
# Override listVoices for testing (real engine reads from manifest)
|
||||
from abogen.tts_plugin.manifest import VoiceManifest
|
||||
original_list_voices = engine.listVoices
|
||||
|
||||
def mock_list_voices(source_id):
|
||||
if engine._disposed:
|
||||
from abogen.tts_plugin.errors import EngineError
|
||||
raise EngineError("Engine disposed")
|
||||
return [
|
||||
VoiceManifest(id="M1", name="Male 1", tags=("male",)),
|
||||
VoiceManifest(id="M2", name="Male 2", tags=("male",)),
|
||||
VoiceManifest(id="M3", name="Male 3", tags=("male",)),
|
||||
VoiceManifest(id="M4", name="Male 4", tags=("male",)),
|
||||
VoiceManifest(id="M5", name="Male 5", tags=("male",)),
|
||||
VoiceManifest(id="F1", name="Female 1", tags=("female",)),
|
||||
VoiceManifest(id="F2", name="Female 2", tags=("female",)),
|
||||
VoiceManifest(id="F3", name="Female 3", tags=("female",)),
|
||||
VoiceManifest(id="F4", name="Female 4", tags=("female",)),
|
||||
VoiceManifest(id="F5", name="Female 5", tags=("female",)),
|
||||
]
|
||||
|
||||
engine.listVoices = mock_list_voices
|
||||
return engine
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Fixtures
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def supertonic_plugin_dir() -> Path:
|
||||
return Path(__file__).parent.parent / "plugins" / "supertonic"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def host_context(tmp_path: Path) -> HostContext:
|
||||
class FakeHttpClient:
|
||||
def get(self, url: str, **kwargs: object) -> object:
|
||||
return None
|
||||
def post(self, url: str, **kwargs: object) -> object:
|
||||
return None
|
||||
|
||||
return HostContext(
|
||||
config_dir=tmp_path,
|
||||
logger=logging.getLogger("test"),
|
||||
http_client=FakeHttpClient(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def engine() -> Engine:
|
||||
return _make_mock_engine()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Plugin Loading Tests
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestSuperTonicPluginLoading:
|
||||
|
||||
def test_plugin_loads_successfully(self, supertonic_plugin_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(supertonic_plugin_dir)
|
||||
assert result.success is True
|
||||
assert result.manifest is not None
|
||||
assert result.create_engine is not None
|
||||
|
||||
def test_plugin_has_valid_manifest(self, supertonic_plugin_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(supertonic_plugin_dir)
|
||||
assert result.success is True
|
||||
manifest = result.manifest
|
||||
assert isinstance(manifest, PluginManifest)
|
||||
assert manifest.id == "supertonic"
|
||||
assert manifest.name == "SuperTonic"
|
||||
assert manifest.api_version == "1.0"
|
||||
|
||||
def test_plugin_has_model_requirements(self, supertonic_plugin_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(supertonic_plugin_dir)
|
||||
assert result.success is True
|
||||
assert result.model_requirements is not None
|
||||
assert isinstance(result.model_requirements, tuple)
|
||||
|
||||
def test_plugin_manifest_capabilities(self, supertonic_plugin_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(supertonic_plugin_dir)
|
||||
assert result.success is True
|
||||
assert "voice_list" in result.manifest.capabilities
|
||||
|
||||
def test_plugin_manifest_engine(self, supertonic_plugin_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(supertonic_plugin_dir)
|
||||
assert result.success is True
|
||||
engine_manifest = result.manifest.engine
|
||||
assert len(engine_manifest.voiceSources) > 0
|
||||
assert len(engine_manifest.audioFormats) > 0
|
||||
assert len(engine_manifest.parameters) > 0
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Engine Creation (real backend, skipped if not installed)
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestSuperTonicEngineCreation:
|
||||
|
||||
@pytest.mark.skipif(not _supertonic_available(), reason="SuperTonic not installed")
|
||||
def test_create_engine(self, supertonic_plugin_dir: Path, host_context: HostContext) -> None:
|
||||
result = load_plugin_from_dir(supertonic_plugin_dir)
|
||||
assert result.success is True
|
||||
engine = result.create_engine(host_context, None, EngineConfig())
|
||||
assert isinstance(engine, Engine)
|
||||
engine.dispose()
|
||||
|
||||
@pytest.mark.skipif(not _supertonic_available(), reason="SuperTonic not installed")
|
||||
def test_engine_satisfies_protocol(self, supertonic_plugin_dir: Path, host_context: HostContext) -> None:
|
||||
result = load_plugin_from_dir(supertonic_plugin_dir)
|
||||
assert result.success is True
|
||||
engine = result.create_engine(host_context, None, EngineConfig())
|
||||
assert isinstance(engine, Engine)
|
||||
engine.dispose()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Engine / Session Contract (inherited from base)
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestSuperTonicEngineContract(EngineContractMixin):
|
||||
"""Every test from EngineContractMixin runs against SuperTonicEngine."""
|
||||
|
||||
@pytest.fixture
|
||||
def default_voice(self) -> str:
|
||||
return "M1"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# VoiceLister Tests
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestSuperTonicVoiceLister:
|
||||
|
||||
def test_list_voices(self) -> None:
|
||||
engine = _make_mock_engine()
|
||||
voices = engine.listVoices("builtin")
|
||||
assert len(voices) == 10
|
||||
assert all(hasattr(v, "id") for v in voices)
|
||||
assert all(hasattr(v, "name") for v in voices)
|
||||
engine.dispose()
|
||||
|
||||
def test_voices_have_tags(self) -> None:
|
||||
engine = _make_mock_engine()
|
||||
for voice in engine.listVoices("builtin"):
|
||||
assert isinstance(voice.tags, tuple)
|
||||
assert len(voice.tags) > 0
|
||||
engine.dispose()
|
||||
|
||||
def test_male_voices_have_male_tag(self) -> None:
|
||||
engine = _make_mock_engine()
|
||||
for v in engine.listVoices("builtin"):
|
||||
if v.id.startswith("M"):
|
||||
assert "male" in v.tags
|
||||
engine.dispose()
|
||||
|
||||
def test_female_voices_have_female_tag(self) -> None:
|
||||
engine = _make_mock_engine()
|
||||
for v in engine.listVoices("builtin"):
|
||||
if v.id.startswith("F"):
|
||||
assert "female" in v.tags
|
||||
engine.dispose()
|
||||
|
||||
def test_list_voices_after_dispose_raises(self) -> None:
|
||||
from abogen.tts_plugin.errors import EngineError
|
||||
engine = _make_mock_engine()
|
||||
engine.dispose()
|
||||
with pytest.raises(EngineError):
|
||||
engine.listVoices("builtin")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# SuperTonic-specific parameter tests
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestSuperTonicParameters:
|
||||
|
||||
def test_speed_parameter(self) -> None:
|
||||
engine = _make_mock_engine()
|
||||
session = engine.createSession()
|
||||
request = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="M1"),
|
||||
parameters=ParameterValues(values={"speed": 1.5}),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
result = session.synthesize(request)
|
||||
assert isinstance(result.data, bytes)
|
||||
session.dispose()
|
||||
engine.dispose()
|
||||
|
||||
def test_total_steps_parameter(self) -> None:
|
||||
engine = _make_mock_engine()
|
||||
session = engine.createSession()
|
||||
request = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="M1"),
|
||||
parameters=ParameterValues(values={"total_steps": 10}),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
result = session.synthesize(request)
|
||||
assert isinstance(result.data, bytes)
|
||||
session.dispose()
|
||||
engine.dispose()
|
||||
|
||||
def test_default_parameters(self) -> None:
|
||||
engine = _make_mock_engine()
|
||||
session = engine.createSession()
|
||||
request = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="M1"),
|
||||
parameters=ParameterValues(values={}),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
result = session.synthesize(request)
|
||||
assert isinstance(result.data, bytes)
|
||||
session.dispose()
|
||||
engine.dispose()
|
||||
"""Tests for the SuperTonic TTS Plugin.
|
||||
|
||||
These tests verify that the SuperTonic plugin:
|
||||
- Loads correctly through the Plugin Loader
|
||||
- Has a valid manifest
|
||||
- Creates a valid Engine
|
||||
- Satisfies the Engine/EngineSession contract (via EngineContractMixin)
|
||||
- Implements VoiceLister capability
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
||||
from abogen.tts_plugin.host_context import HostContext
|
||||
from abogen.tts_plugin.loader import load_plugin_from_dir
|
||||
from abogen.tts_plugin.manifest import PluginManifest
|
||||
from abogen.tts_plugin.types import (
|
||||
AudioFormat,
|
||||
EngineConfig,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
VoiceSelection,
|
||||
)
|
||||
|
||||
from tests.contracts.engine_contract import EngineContractMixin
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _supertonic_available() -> bool:
|
||||
try:
|
||||
from supertonic import TTS # type: ignore[import-not-found]
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
def _make_mock_engine() -> Any:
|
||||
from plugins.supertonic.engine import SuperTonicEngine
|
||||
|
||||
class MockSegment:
|
||||
def __init__(self):
|
||||
import numpy as np
|
||||
self.audio = np.zeros(24000, dtype="float32")
|
||||
|
||||
class MockPipeline:
|
||||
sample_rate = 24000
|
||||
|
||||
def __call__(self, text, voice, speed, split_pattern=None, total_steps=None):
|
||||
return [MockSegment()]
|
||||
|
||||
engine = SuperTonicEngine(MockPipeline())
|
||||
|
||||
# Override listVoices for testing (real engine reads from manifest)
|
||||
from abogen.tts_plugin.manifest import VoiceManifest
|
||||
original_list_voices = engine.listVoices
|
||||
|
||||
def mock_list_voices(source_id):
|
||||
if engine._disposed:
|
||||
from abogen.tts_plugin.errors import EngineError
|
||||
raise EngineError("Engine disposed")
|
||||
return [
|
||||
VoiceManifest(id="M1", name="Male 1", tags=("male",)),
|
||||
VoiceManifest(id="M2", name="Male 2", tags=("male",)),
|
||||
VoiceManifest(id="M3", name="Male 3", tags=("male",)),
|
||||
VoiceManifest(id="M4", name="Male 4", tags=("male",)),
|
||||
VoiceManifest(id="M5", name="Male 5", tags=("male",)),
|
||||
VoiceManifest(id="F1", name="Female 1", tags=("female",)),
|
||||
VoiceManifest(id="F2", name="Female 2", tags=("female",)),
|
||||
VoiceManifest(id="F3", name="Female 3", tags=("female",)),
|
||||
VoiceManifest(id="F4", name="Female 4", tags=("female",)),
|
||||
VoiceManifest(id="F5", name="Female 5", tags=("female",)),
|
||||
]
|
||||
|
||||
engine.listVoices = mock_list_voices
|
||||
return engine
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Fixtures
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def supertonic_plugin_dir() -> Path:
|
||||
return Path(__file__).parent.parent / "plugins" / "supertonic"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def host_context(tmp_path: Path) -> HostContext:
|
||||
class FakeHttpClient:
|
||||
def get(self, url: str, **kwargs: object) -> object:
|
||||
return None
|
||||
def post(self, url: str, **kwargs: object) -> object:
|
||||
return None
|
||||
|
||||
return HostContext(
|
||||
config_dir=tmp_path,
|
||||
logger=logging.getLogger("test"),
|
||||
http_client=FakeHttpClient(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def engine() -> Engine:
|
||||
return _make_mock_engine()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Plugin Loading Tests
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestSuperTonicPluginLoading:
|
||||
|
||||
def test_plugin_loads_successfully(self, supertonic_plugin_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(supertonic_plugin_dir)
|
||||
assert result.success is True
|
||||
assert result.manifest is not None
|
||||
assert result.create_engine is not None
|
||||
|
||||
def test_plugin_has_valid_manifest(self, supertonic_plugin_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(supertonic_plugin_dir)
|
||||
assert result.success is True
|
||||
manifest = result.manifest
|
||||
assert isinstance(manifest, PluginManifest)
|
||||
assert manifest.id == "supertonic"
|
||||
assert manifest.name == "SuperTonic"
|
||||
assert manifest.api_version == "1.0"
|
||||
|
||||
def test_plugin_has_model_requirements(self, supertonic_plugin_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(supertonic_plugin_dir)
|
||||
assert result.success is True
|
||||
assert result.model_requirements is not None
|
||||
assert isinstance(result.model_requirements, tuple)
|
||||
|
||||
def test_plugin_manifest_capabilities(self, supertonic_plugin_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(supertonic_plugin_dir)
|
||||
assert result.success is True
|
||||
assert "voice_list" in result.manifest.capabilities
|
||||
|
||||
def test_plugin_manifest_engine(self, supertonic_plugin_dir: Path) -> None:
|
||||
result = load_plugin_from_dir(supertonic_plugin_dir)
|
||||
assert result.success is True
|
||||
engine_manifest = result.manifest.engine
|
||||
assert len(engine_manifest.voiceSources) > 0
|
||||
assert len(engine_manifest.audioFormats) > 0
|
||||
assert len(engine_manifest.parameters) > 0
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Engine Creation (real backend, skipped if not installed)
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestSuperTonicEngineCreation:
|
||||
|
||||
@pytest.mark.skipif(not _supertonic_available(), reason="SuperTonic not installed")
|
||||
def test_create_engine(self, supertonic_plugin_dir: Path, host_context: HostContext) -> None:
|
||||
result = load_plugin_from_dir(supertonic_plugin_dir)
|
||||
assert result.success is True
|
||||
engine = result.create_engine(host_context, None, EngineConfig())
|
||||
assert isinstance(engine, Engine)
|
||||
engine.dispose()
|
||||
|
||||
@pytest.mark.skipif(not _supertonic_available(), reason="SuperTonic not installed")
|
||||
def test_engine_satisfies_protocol(self, supertonic_plugin_dir: Path, host_context: HostContext) -> None:
|
||||
result = load_plugin_from_dir(supertonic_plugin_dir)
|
||||
assert result.success is True
|
||||
engine = result.create_engine(host_context, None, EngineConfig())
|
||||
assert isinstance(engine, Engine)
|
||||
engine.dispose()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Engine / Session Contract (inherited from base)
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestSuperTonicEngineContract(EngineContractMixin):
|
||||
"""Every test from EngineContractMixin runs against SuperTonicEngine."""
|
||||
|
||||
@pytest.fixture
|
||||
def default_voice(self) -> str:
|
||||
return "M1"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# VoiceLister Tests
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestSuperTonicVoiceLister:
|
||||
|
||||
def test_list_voices(self) -> None:
|
||||
engine = _make_mock_engine()
|
||||
voices = engine.listVoices("builtin")
|
||||
assert len(voices) == 10
|
||||
assert all(hasattr(v, "id") for v in voices)
|
||||
assert all(hasattr(v, "name") for v in voices)
|
||||
engine.dispose()
|
||||
|
||||
def test_voices_have_tags(self) -> None:
|
||||
engine = _make_mock_engine()
|
||||
for voice in engine.listVoices("builtin"):
|
||||
assert isinstance(voice.tags, tuple)
|
||||
assert len(voice.tags) > 0
|
||||
engine.dispose()
|
||||
|
||||
def test_male_voices_have_male_tag(self) -> None:
|
||||
engine = _make_mock_engine()
|
||||
for v in engine.listVoices("builtin"):
|
||||
if v.id.startswith("M"):
|
||||
assert "male" in v.tags
|
||||
engine.dispose()
|
||||
|
||||
def test_female_voices_have_female_tag(self) -> None:
|
||||
engine = _make_mock_engine()
|
||||
for v in engine.listVoices("builtin"):
|
||||
if v.id.startswith("F"):
|
||||
assert "female" in v.tags
|
||||
engine.dispose()
|
||||
|
||||
def test_list_voices_after_dispose_raises(self) -> None:
|
||||
from abogen.tts_plugin.errors import EngineError
|
||||
engine = _make_mock_engine()
|
||||
engine.dispose()
|
||||
with pytest.raises(EngineError):
|
||||
engine.listVoices("builtin")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# SuperTonic-specific parameter tests
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestSuperTonicParameters:
|
||||
|
||||
def test_speed_parameter(self) -> None:
|
||||
engine = _make_mock_engine()
|
||||
session = engine.createSession()
|
||||
request = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="M1"),
|
||||
parameters=ParameterValues(values={"speed": 1.5}),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
result = session.synthesize(request)
|
||||
assert isinstance(result.data, bytes)
|
||||
session.dispose()
|
||||
engine.dispose()
|
||||
|
||||
def test_total_steps_parameter(self) -> None:
|
||||
engine = _make_mock_engine()
|
||||
session = engine.createSession()
|
||||
request = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="M1"),
|
||||
parameters=ParameterValues(values={"total_steps": 10}),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
result = session.synthesize(request)
|
||||
assert isinstance(result.data, bytes)
|
||||
session.dispose()
|
||||
engine.dispose()
|
||||
|
||||
def test_default_parameters(self) -> None:
|
||||
engine = _make_mock_engine()
|
||||
session = engine.createSession()
|
||||
request = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="M1"),
|
||||
parameters=ParameterValues(values={}),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
result = session.synthesize(request)
|
||||
assert isinstance(result.data, bytes)
|
||||
session.dispose()
|
||||
engine.dispose()
|
||||
|
||||
+69
-69
@@ -1,69 +1,69 @@
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.utils import get_voices
|
||||
from abogen.voice_cache import (
|
||||
LocalEntryNotFoundError,
|
||||
_CACHED_VOICES,
|
||||
ensure_voice_assets,
|
||||
)
|
||||
from abogen.webui.conversion_runner import _collect_required_voice_ids
|
||||
from abogen.webui.service import Job
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_voice_cache():
|
||||
_CACHED_VOICES.clear()
|
||||
yield
|
||||
_CACHED_VOICES.clear()
|
||||
|
||||
|
||||
def test_ensure_voice_assets_downloads_missing(monkeypatch):
|
||||
recorded = []
|
||||
|
||||
cached = set()
|
||||
|
||||
def fake_download(**kwargs):
|
||||
filename = kwargs["filename"]
|
||||
if kwargs.get("local_files_only"):
|
||||
if filename in cached:
|
||||
return f"/tmp/{filename}"
|
||||
raise LocalEntryNotFoundError(f"{filename} missing")
|
||||
|
||||
recorded.append(filename)
|
||||
cached.add(filename)
|
||||
return f"/tmp/{filename}"
|
||||
|
||||
monkeypatch.setattr("abogen.voice_cache.hf_hub_download", fake_download)
|
||||
|
||||
downloaded, errors = ensure_voice_assets(["af_nova", "am_liam"])
|
||||
|
||||
assert downloaded == {"af_nova", "am_liam"}
|
||||
assert errors == {}
|
||||
assert set(recorded) == {"voices/af_nova.pt", "voices/am_liam.pt"}
|
||||
|
||||
recorded.clear()
|
||||
downloaded_again, errors_again = ensure_voice_assets(["af_nova"])
|
||||
|
||||
assert downloaded_again == set()
|
||||
assert errors_again == {}
|
||||
assert recorded == []
|
||||
|
||||
|
||||
def test_collect_required_voice_ids_includes_all():
|
||||
job = SimpleNamespace(
|
||||
voice="af_nova",
|
||||
chapters=[{"voice_formula": "af_nova*0.7+am_liam*0.3"}],
|
||||
chunks=[{"voice": "am_michael"}],
|
||||
speakers={
|
||||
"hero": {"voice_formula": "af_nova*0.6+am_liam*0.4"},
|
||||
"narrator": {"voice": "af_nova"},
|
||||
},
|
||||
)
|
||||
|
||||
voices = _collect_required_voice_ids(cast(Job, job))
|
||||
|
||||
assert {"af_nova", "am_liam", "am_michael"}.issubset(voices)
|
||||
assert voices.issuperset(get_voices("kokoro"))
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.utils import get_voices
|
||||
from abogen.voice_cache import (
|
||||
LocalEntryNotFoundError,
|
||||
_CACHED_VOICES,
|
||||
ensure_voice_assets,
|
||||
)
|
||||
from abogen.webui.conversion_runner import _collect_required_voice_ids
|
||||
from abogen.webui.service import Job
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_voice_cache():
|
||||
_CACHED_VOICES.clear()
|
||||
yield
|
||||
_CACHED_VOICES.clear()
|
||||
|
||||
|
||||
def test_ensure_voice_assets_downloads_missing(monkeypatch):
|
||||
recorded = []
|
||||
|
||||
cached = set()
|
||||
|
||||
def fake_download(**kwargs):
|
||||
filename = kwargs["filename"]
|
||||
if kwargs.get("local_files_only"):
|
||||
if filename in cached:
|
||||
return f"/tmp/{filename}"
|
||||
raise LocalEntryNotFoundError(f"{filename} missing")
|
||||
|
||||
recorded.append(filename)
|
||||
cached.add(filename)
|
||||
return f"/tmp/{filename}"
|
||||
|
||||
monkeypatch.setattr("abogen.voice_cache.hf_hub_download", fake_download)
|
||||
|
||||
downloaded, errors = ensure_voice_assets(["af_nova", "am_liam"])
|
||||
|
||||
assert downloaded == {"af_nova", "am_liam"}
|
||||
assert errors == {}
|
||||
assert set(recorded) == {"voices/af_nova.pt", "voices/am_liam.pt"}
|
||||
|
||||
recorded.clear()
|
||||
downloaded_again, errors_again = ensure_voice_assets(["af_nova"])
|
||||
|
||||
assert downloaded_again == set()
|
||||
assert errors_again == {}
|
||||
assert recorded == []
|
||||
|
||||
|
||||
def test_collect_required_voice_ids_includes_all():
|
||||
job = SimpleNamespace(
|
||||
voice="af_nova",
|
||||
chapters=[{"voice_formula": "af_nova*0.7+am_liam*0.3"}],
|
||||
chunks=[{"voice": "am_michael"}],
|
||||
speakers={
|
||||
"hero": {"voice_formula": "af_nova*0.6+am_liam*0.4"},
|
||||
"narrator": {"voice": "af_nova"},
|
||||
},
|
||||
)
|
||||
|
||||
voices = _collect_required_voice_ids(cast(Job, job))
|
||||
|
||||
assert {"af_nova", "am_liam", "am_michael"}.issubset(voices)
|
||||
assert voices.issuperset(get_voices("kokoro"))
|
||||
|
||||
Reference in New Issue
Block a user