Compare commits

...
Author SHA1 Message Date
Artem Akymenko f8f72624f8 refactor(webui): replace direct VOICES_INTERNAL/DEFAULT_SUPERTONIC_VOICES with get_metadata API
- Add get_default_voice() helper to tts_backend_registry
- Replace all VOICES_INTERNAL imports in WebUI with get_metadata().voices
- Replace all DEFAULT_SUPERTONIC_VOICES imports in conversion_runner with get_metadata().voices
- Remove unused VOICES_INTERNAL import from voices.py

Core modules (voice_profiles, voice_formulas, voice_cache) already used
get_metadata(). This completes the WebUI migration.
2026-07-08 15:42:49 +00:00
Artem Akymenko e7a88a513a ci: fix duplicate triggers, pin macos-14 to avoid migration warning 2026-07-08 16:52:26 +03:00
Artem AkymenkoandGitHub 2277f16d0a Merge pull request #184 from denizsafak/refactor/use-backend-metadata-for-voice-lists
refactor: migrate core modules to use TTSBackendMetadata.voices via registry
2026-07-08 16:51:58 +03:00
Artem Akymenko 1d50429b87 refactor: migrate core modules to use TTSBackendMetadata.voices via registry
Replace direct imports of VOICES_INTERNAL and DEFAULT_SUPERTONIC_VOICES
in voice_profiles, voice_formulas, and voice_cache with get_metadata()
from TTSBackendRegistry. Adds get_metadata() top-level function to
tts_backend_registry as symmetric counterpart to register_backend() and
create_backend().
2026-07-08 13:43:52 +00:00
Artem Akymenko 29681a5fbb ci: update actions to v7/v6, add pip caching, optimize Dockerfile layer order 2026-07-08 16:22:52 +03:00
Artem AkymenkoandGitHub 50fa2e5b9e Merge pull request #183 from denizsafak/refactor/store-supported-voices-in-backend-metadata
feat: store supported voices in TTSBackendMetadata
2026-07-08 16:02:57 +03:00
Artem Akymenko 5816feb6da feat: store supported voices in TTSBackendMetadata
Add voices field to TTSBackendMetadata so each backend's supported
voice list is part of its metadata rather than external constants.

- Add voices: tuple[str, ...] = () to TTSBackendMetadata
- Create _KOKORO_METADATA / _SUPERTONIC_METADATA as single source
  of truth for both metadata property and registry registration
- Update KokoroBackend.get_available_voices() to use self.metadata.voices
- Update SupertonicBackend.get_available_voices() to use self.metadata.voices
- Add tests for voices field, metadata voice content, and unified instance identity
2026-07-06 17:40:49 +00:00
Artem AkymenkoandGitHub b95df8f217 Merge pull request #182 from denizsafak/refactor/add-kokoro-backend
feat: add KokoroBackend implementing TTSBackend protocol
2026-07-06 17:29:49 +03:00
Artem AkymenkoandGitHub 245e67284e Merge pull request #181 from denizsafak/refactor/add-supertonic-backend
feat: add SupertonicBackend implementing TTSBackend protocol
2026-07-06 17:29:22 +03:00
Artem Akymenko e2557d961b feat: add KokoroBackend implementing TTSBackend protocol
- Create KokoroBackend class implementing TTSBackend protocol
- Move all KPipeline interaction inside KokoroBackend
- Update LoadPipelineThread to create backend via create_backend()
- Update ConversionThread and VoicePreviewThread to accept backend
- Replace np_module/kpipeline_class parameters with single backend
- Add 24 unit tests for KokoroBackend
- KPipeline is now an internal implementation detail of KokoroBackend
2026-07-06 14:10:54 +00:00
Artem Akymenko 9c6b3774b4 feat: add SupertonicBackend implementing TTSBackend protocol
Encapsulate SupertonicPipeline as an internal detail of
SupertonicBackend. The factory create_supertonic_backend() now
returns a SupertonicBackend instance instead of a raw
SupertonicPipeline, satisfying the TTSBackend protocol with
metadata, synthesize, get_available_voices, get_supported_formats,
and get_info methods. Backward-compatible __call__ delegates to
the internal pipeline.
2026-07-06 14:09:30 +00:00
Artem AkymenkoandGitHub fd9fe5579a Merge pull request #180 from k0sm0naft/refactor/use-registry-for-preview
refactor: migrate preview and conversion code to use TTSBackendRegistry
2026-07-06 16:53:28 +03:00
Artem Akymenko f079373821 refactor: migrate preview and conversion code to use TTSBackendRegistry
Migrate all preview/debug/conversion pipeline creation to use
TTSBackendRegistry.create_backend() instead of direct imports:

- debug_tts_runner._load_pipeline(): Kokoro via registry
- preview.get_preview_pipeline(): Kokoro via registry
- preview.generate_preview_audio(): Supertonic via registry
- voice.get_preview_pipeline(): Kokoro via registry
- conversion_runner._load_pipeline(): both backends via registry
- conversion_runner inline pipeline creation: both via registry
- test: update mock to target tts_backend_registry.create_backend
2026-07-06 15:59:22 +03:00
Deniz ŞafakandGitHub fbb5d4e368 Merge pull request #179 from k0sm0naft/refactor/backend-registry
Add TTS backend registry and automatic backend registration
2026-07-06 15:14:47 +03:00
Artem Akymenko 57fec453e2 feat: auto-register existing TTS backends
- Add create_kokoro_backend() factory in kokoro.py
- Add create_supertonic_backend() factory in supertonic.py
- Auto-discover backend modules in __init__.py via pkgutil
- Both backends register themselves on import
- Tests verify registration and factory callables
2026-07-06 15:04:49 +03:00
Artem Akymenko 58fe22e3d5 feat: add TTSBackendRegistry for backend registration and creation
- TTSBackendRegistry class with register(), list_backends(), get_metadata(), create_backend()
- Global registry singleton with register_backend() and create_backend() convenience functions
- Unit tests for registry operations
2026-07-06 15:04:49 +03:00
Deniz ŞafakandGitHub ab8cbc4911 Merge pull request #178 from k0sm0naft/refactor/backend-package
refactor: move backend implementations to tts_backends package
2026-07-06 14:50:16 +03:00
Deniz ŞafakandGitHub 5e2048072a Merge pull request #177 from k0sm0naft/refactor/tts-backend-interface
feat: Add TTSBackendMetadata model
2026-07-06 14:49:35 +03:00
Artem Akymenko 66ed2a202d refactor: move backend implementations to tts_backends package
Moved SupertonicPipeline from abogen/tts_supertonic.py to
abogen/tts_backends/supertonic.py and load_numpy_kpipeline from
abogen/utils.py to abogen/tts_backends/kokoro.py.

Git correctly detects the Supertonic file as a rename (R),
preserving full commit history.

- New package: abogen/tts_backends/
  - __init__.py (package marker)
  - supertonic.py (SupertonicPipeline, moved from tts_supertonic.py)
  - kokoro.py (load_numpy_kpipeline, moved from utils.py)
- abogen/utils.py: re-exports load_numpy_kpipeline for backward compat
- All imports updated to new canonical paths
2026-07-06 14:04:51 +03:00
Artem Akymenko 45e859dac4 feat: Add TTSBackendMetadata model 2026-07-06 12:58:06 +03:00
Deniz ŞafakandGitHub 56d3e414b3 Merge pull request #176 from k0sm0naft/feat/voice-metadata
feat: add VoiceMetadata data model for TTS backends
2026-07-06 11:01:22 +03:00
Deniz ŞafakandGitHub b942bcb820 Merge pull request #175 from k0sm0naft/refactor/tts-backend-interface
refactor: Switch TTSBackend from ABC to Protocol
2026-07-06 11:00:46 +03:00
Artem Akymenko 47efcb4420 feat: add VoiceMetadata data model for TTS backends 2026-07-05 19:07:57 +00:00
Artem Akymenko 7b3f9d8615 Merge branch 'main' into refactor/tts-backend-interface 2026-07-05 16:53:40 +03:00
Artem Akymenko 9833bb0843 refactor: Switch TTSBackend from ABC to Protocol 2026-07-05 13:47:31 +00:00
28 changed files with 1373 additions and 313 deletions
+6 -5
View File
@@ -1,7 +1,8 @@
name: pip install name: pip install
run-name: pip install run-name: pip install
on: on:
push: push:
branches: [main]
paths: paths:
- '**.py' - '**.py'
- 'pyproject.toml' - 'pyproject.toml'
@@ -15,18 +16,18 @@ jobs:
install-and-run: install-and-run:
strategy: strategy:
matrix: matrix:
os: [ubuntu-latest, macos-latest, windows-latest] os: [ubuntu-latest, macos-14, windows-latest]
python-version: ['3.12'] python-version: ['3.12']
fail-fast: false fail-fast: false
continue-on-error: true
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v6
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
cache: pip
- name: Install from repository - name: Install from repository
run: python -m pip install . run: python -m pip install .
#- name: Run abogen #- name: Run abogen
+63 -63
View File
@@ -1,63 +1,63 @@
name: Build multi-arch Docker Image name: Build multi-arch Docker Image
on: on:
# Build and push # Build and push
#release: #release:
# types: [published] # types: [published]
# Build only # Build only
#push: it #push: it
# branches: [main] # branches: [main]
# TODO - enable build on pull requests if build times can be reduced # TODO - enable build on pull requests if build times can be reduced
# pull_request: # pull_request:
workflow_dispatch: workflow_dispatch:
env: env:
IMAGE_REPOSITORY: ghcr.io/denizsafak/abogen IMAGE_REPOSITORY: ghcr.io/denizsafak/abogen
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
- name: Login to Github Container Registry - name: Login to Github Container Registry
# Only if we need to push an image # Only if we need to push an image
# if: ${{ github.event_name == 'release' && github.event.action == 'published' }} # if: ${{ github.event_name == 'release' && github.event.action == 'published' }}
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
# Setup for buildx # Setup for buildx
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
id: buildx id: buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
# Debugging information # Debugging information
- name: Docker info - name: Docker info
run: docker info run: docker info
- name: Buildx inspect - name: Buildx inspect
run: docker buildx inspect run: docker buildx inspect
# Build and (optionally) push the image # Build and (optionally) push the image
- name: Build image - name: Build image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: ./abogen context: ./abogen
file: ./abogen/Dockerfile file: ./abogen/Dockerfile
# platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x # platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
# platforms: linux/amd64,linux/arm64 # platforms: linux/amd64,linux/arm64
platforms: linux/amd64 # using the solution mentioned in https://github.com/denizsafak/abogen/issues/46 platforms: linux/amd64 # using the solution mentioned in https://github.com/denizsafak/abogen/issues/46
# Only push if we are publishing a release # Only push if we are publishing a release
# push: ${{ github.event_name == 'release' && github.event.action == 'published' }} # push: ${{ github.event_name == 'release' && github.event.action == 'published' }}
push: true push: true
# Use a 'temp' tag, that won't be pushed, for non-release builds # Use a 'temp' tag, that won't be pushed, for non-release builds
tags: ${{ env.IMAGE_REPOSITORY }}:${{ github.event.release.tag_name || 'latest' }} tags: ${{ env.IMAGE_REPOSITORY }}:${{ github.event.release.tag_name || 'latest' }}
# Use a cache to reduce build times # Use a cache to reduce build times
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
cache-from: type=gha cache-from: type=gha
+28 -56
View File
@@ -5,6 +5,7 @@ import hashlib # For generating unique cache filenames
from platformdirs import user_desktop_dir from platformdirs import user_desktop_dir
from PyQt6.QtCore import QThread, pyqtSignal, Qt, QTimer from PyQt6.QtCore import QThread, pyqtSignal, Qt, QTimer
from PyQt6.QtWidgets import QCheckBox, QVBoxLayout, QDialog, QLabel, QDialogButtonBox from PyQt6.QtWidgets import QCheckBox, QVBoxLayout, QDialog, QLabel, QDialogButtonBox
import numpy as np
import soundfile as sf import soundfile as sf
from abogen.utils import ( from abogen.utils import (
create_process, create_process,
@@ -259,8 +260,7 @@ class ConversionThread(QThread):
output_folder, output_folder,
subtitle_mode, subtitle_mode,
output_format, output_format,
np_module, backend,
kpipeline_class,
start_time, start_time,
total_char_count, total_char_count,
use_gpu=True, use_gpu=True,
@@ -270,8 +270,7 @@ class ConversionThread(QThread):
super().__init__() super().__init__()
self._chapter_options_event = threading.Event() self._chapter_options_event = threading.Event()
self._timestamp_response_event = threading.Event() self._timestamp_response_event = threading.Event()
self.np = np_module self.backend = backend
self.KPipeline = kpipeline_class
self.file_name = file_name self.file_name = file_name
self.lang_code = lang_code self.lang_code = lang_code
self.speed = speed self.speed = speed
@@ -490,19 +489,6 @@ class ConversionThread(QThread):
self.log_updated.emit(("\nInitializing TTS pipeline...", "grey")) self.log_updated.emit(("\nInitializing TTS pipeline...", "grey"))
# Set device based on use_gpu setting and platform
if self.use_gpu:
if platform.system() == "Darwin" and platform.processor() == "arm":
device = "mps" # Use MPS for Apple Silicon
else:
device = "cuda" # Use CUDA for other platforms
else:
device = "cpu"
tts = self.KPipeline(
lang_code=self.lang_code, repo_id="hexgrad/Kokoro-82M", device=device
)
# Check if the input is a subtitle file or timestamp text file # Check if the input is a subtitle file or timestamp text file
is_subtitle_file = False is_subtitle_file = False
is_timestamp_text = False is_timestamp_text = False
@@ -538,7 +524,7 @@ class ConversionThread(QThread):
# Process subtitle files separately # Process subtitle files separately
if is_subtitle_file or is_timestamp_text: if is_subtitle_file or is_timestamp_text:
self._process_subtitle_file(tts, base_path, is_timestamp_text) self._process_subtitle_file(self.backend, base_path, is_timestamp_text)
return return
if self.is_direct_text: if self.is_direct_text:
@@ -1071,7 +1057,7 @@ class ConversionThread(QThread):
for segment_idx, (voice_name, segment_text) in enumerate(voice_segments): for segment_idx, (voice_name, segment_text) in enumerate(voice_segments):
# Load voice for this segment (with caching) # Load voice for this segment (with caching)
try: try:
loaded_voice = self.load_voice_cached(voice_name, tts) loaded_voice = self.load_voice_cached(voice_name, self.backend)
if segment_idx > 0: if segment_idx > 0:
voice_display = voice_name if len(voice_name) < 50 else voice_name[:47] + "..." voice_display = voice_name if len(voice_name) < 50 else voice_name[:47] + "..."
self.log_updated.emit((f" → Voice: {voice_display}", "grey")) self.log_updated.emit((f" → Voice: {voice_display}", "grey"))
@@ -1080,7 +1066,7 @@ class ConversionThread(QThread):
(f"⚠ Voice loading error for '{voice_name}', continuing with previous", "orange") (f"⚠ Voice loading error for '{voice_name}', continuing with previous", "orange")
) )
if segment_idx == 0: if segment_idx == 0:
loaded_voice = self.load_voice_cached(self.voice, tts) loaded_voice = self.load_voice_cached(self.voice, self.backend)
# Determine if spaCy segmentation should be used for PRE-TTS segmentation # Determine if spaCy segmentation should be used for PRE-TTS segmentation
# Only non-English languages use spaCy for pre-segmentation # Only non-English languages use spaCy for pre-segmentation
@@ -1166,7 +1152,7 @@ class ConversionThread(QThread):
print("Using split pattern: (unprintable)") print("Using split pattern: (unprintable)")
for text_segment in text_segments: for text_segment in text_segments:
for result in tts( for result in self.backend(
text_segment, text_segment,
voice=loaded_voice, voice=loaded_voice,
speed=self.speed, speed=self.speed,
@@ -1368,7 +1354,7 @@ class ConversionThread(QThread):
silence_samples = int( silence_samples = int(
self.silence_duration * 24000 self.silence_duration * 24000
) # Silence duration at 24,000 Hz ) # Silence duration at 24,000 Hz
silence_audio = self.np.zeros(silence_samples, dtype="float32") silence_audio = np.zeros(silence_samples, dtype="float32")
silence_bytes = silence_audio.tobytes() silence_bytes = silence_audio.tobytes()
if merged_out_file: if merged_out_file:
@@ -1707,7 +1693,7 @@ class ConversionThread(QThread):
max_end_time = max( max_end_time = max(
(end for _, end, _ in subtitles if end is not None), default=0 (end for _, end, _ in subtitles if end is not None), default=0
) )
audio_buffer = self.np.zeros( audio_buffer = np.zeros(
int(max_end_time * rate) + rate, dtype="float32" int(max_end_time * rate) + rate, dtype="float32"
) )
@@ -1771,7 +1757,7 @@ class ConversionThread(QThread):
# Generate TTS audio # Generate TTS audio
tts_results = [ tts_results = [
r r
for r in tts( for r in self.backend(
processed_text, processed_text,
voice=loaded_voice, voice=loaded_voice,
speed=self.speed, speed=self.speed,
@@ -1789,11 +1775,11 @@ class ConversionThread(QThread):
# Concatenate audio and determine duration # Concatenate audio and determine duration
full_audio = ( full_audio = (
self.np.concatenate( np.concatenate(
[a.numpy() if hasattr(a, "numpy") else a for a in audio_chunks] [a.numpy() if hasattr(a, "numpy") else a for a in audio_chunks]
) )
if audio_chunks if audio_chunks
else self.np.zeros( else np.zeros(
int((subtitle_duration or 0) * rate), dtype="float32" int((subtitle_duration or 0) * rate), dtype="float32"
) )
) )
@@ -1827,8 +1813,8 @@ class ConversionThread(QThread):
num_stages = max( num_stages = max(
1, 1,
int( int(
self.np.ceil( np.ceil(
self.np.log(speed_factor) / self.np.log(2.0) np.log(speed_factor) / np.log(2.0)
) )
), ),
) )
@@ -1861,7 +1847,7 @@ class ConversionThread(QThread):
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
) )
full_audio = self.np.frombuffer( full_audio = np.frombuffer(
speed_proc.communicate(input=full_audio.tobytes())[0], speed_proc.communicate(input=full_audio.tobytes())[0],
dtype="float32", dtype="float32",
) )
@@ -1875,7 +1861,7 @@ class ConversionThread(QThread):
tts_results = [ tts_results = [
r r
for r in tts( for r in self.backend(
processed_text, processed_text,
voice=loaded_voice, voice=loaded_voice,
speed=new_speed, speed=new_speed,
@@ -1886,14 +1872,14 @@ class ConversionThread(QThread):
audio_chunks = [r.audio for r in tts_results] audio_chunks = [r.audio for r in tts_results]
full_audio = ( full_audio = (
self.np.concatenate( np.concatenate(
[ [
a.numpy() if hasattr(a, "numpy") else a a.numpy() if hasattr(a, "numpy") else a
for a in audio_chunks for a in audio_chunks
] ]
) )
if audio_chunks if audio_chunks
else self.np.zeros( else np.zeros(
int(subtitle_duration * rate), dtype="float32" int(subtitle_duration * rate), dtype="float32"
) )
) )
@@ -1910,10 +1896,10 @@ class ConversionThread(QThread):
# Pad or trim to subtitle duration # Pad or trim to subtitle duration
target_samples = int(subtitle_duration * rate) target_samples = int(subtitle_duration * rate)
if len(full_audio) < target_samples: if len(full_audio) < target_samples:
full_audio = self.np.concatenate( full_audio = np.concatenate(
[ [
full_audio, full_audio,
self.np.zeros( np.zeros(
target_samples - len(full_audio), dtype="float32" target_samples - len(full_audio), dtype="float32"
), ),
] ]
@@ -1926,10 +1912,10 @@ class ConversionThread(QThread):
end_sample = start_sample + len(full_audio) end_sample = start_sample + len(full_audio)
if end_sample > len(audio_buffer): if end_sample > len(audio_buffer):
# Extend buffer if needed # Extend buffer if needed
audio_buffer = self.np.concatenate( audio_buffer = np.concatenate(
[ [
audio_buffer, audio_buffer,
self.np.zeros( np.zeros(
end_sample - len(audio_buffer), dtype="float32" end_sample - len(audio_buffer), dtype="float32"
), ),
] ]
@@ -1971,7 +1957,7 @@ class ConversionThread(QThread):
self.progress_updated.emit(percent, etr_str) self.progress_updated.emit(percent, etr_str)
# Normalize audio buffer to prevent clipping from mixed overlaps # Normalize audio buffer to prevent clipping from mixed overlaps
max_amplitude = self.np.abs(audio_buffer).max() max_amplitude = np.abs(audio_buffer).max()
if max_amplitude > 1.0: if max_amplitude > 1.0:
self.log_updated.emit( self.log_updated.emit(
f"\n -> Normalizing audio (peak: {max_amplitude:.2f})" f"\n -> Normalizing audio (peak: {max_amplitude:.2f})"
@@ -2440,8 +2426,7 @@ class VoicePreviewThread(QThread):
def __init__( def __init__(
self, self,
np_module, backend,
kpipeline_class,
lang_code, lang_code,
voice, voice,
speed, speed,
@@ -2449,8 +2434,7 @@ class VoicePreviewThread(QThread):
parent=None, parent=None,
): ):
super().__init__(parent) super().__init__(parent)
self.np_module = np_module self.backend = backend
self.kpipeline_class = kpipeline_class
self.lang_code = lang_code self.lang_code = lang_code
self.voice = voice self.voice = voice
self.speed = speed self.speed = speed
@@ -2484,31 +2468,19 @@ class VoicePreviewThread(QThread):
# Generate the preview and save to cache # Generate the preview and save to cache
try: try:
# Set device based on use_gpu setting and platform
if self.use_gpu:
if platform.system() == "Darwin" and platform.processor() == "arm":
device = "mps" # Use MPS for Apple Silicon
else:
device = "cuda" # Use CUDA for other platforms
else:
device = "cpu"
tts = self.kpipeline_class(
lang_code=self.lang_code, repo_id="hexgrad/Kokoro-82M", device=device
)
# Enable voice formula support for preview # Enable voice formula support for preview
if "*" in self.voice: if "*" in self.voice:
loaded_voice = get_new_voice(tts, self.voice, self.use_gpu) loaded_voice = get_new_voice(self.backend, self.voice, self.use_gpu)
else: else:
loaded_voice = self.voice loaded_voice = self.voice
sample_text = get_sample_voice_text(self.lang_code) sample_text = get_sample_voice_text(self.lang_code)
audio_segments = [] audio_segments = []
for result in tts( for result in self.backend(
sample_text, voice=loaded_voice, speed=self.speed, split_pattern=None sample_text, voice=loaded_voice, speed=self.speed, split_pattern=None
): ):
audio_segments.append(result.audio) audio_segments.append(result.audio)
if audio_segments: if audio_segments:
audio = self.np_module.concatenate(audio_segments) audio = np.concatenate(audio_segments)
# Save directly to the cache path # Save directly to the cache path
sf.write(self.cache_path, audio, 24000) sf.write(self.cache_path, audio, 24000)
self.temp_wav = self.cache_path self.temp_wav = self.cache_path
+32 -11
View File
@@ -2316,9 +2316,9 @@ class abogen(QWidget):
file_size_str = "Unknown" file_size_str = "Unknown"
# pipeline_loaded_callback remains unchanged # pipeline_loaded_callback remains unchanged
def pipeline_loaded_callback(np_module, kpipeline_class, error): def pipeline_loaded_callback(backend, error):
if error: if error:
self.update_log((f"Error loading numpy or KPipeline: {error}", "red")) self.update_log((f"Error loading TTS backend: {error}", "red"))
prevent_sleep_end() prevent_sleep_end()
return return
@@ -2341,8 +2341,7 @@ class abogen(QWidget):
self.selected_output_folder, self.selected_output_folder,
subtitle_mode=actual_subtitle_mode, subtitle_mode=actual_subtitle_mode,
output_format=self.selected_format, output_format=self.selected_format,
np_module=np_module, backend=backend,
kpipeline_class=kpipeline_class,
start_time=self.start_time, start_time=self.start_time,
total_char_count=self.char_count, total_char_count=self.char_count,
use_gpu=self.gpu_ok, use_gpu=self.gpu_ok,
@@ -2426,7 +2425,20 @@ class abogen(QWidget):
self.gpu_ok = gpu_ok self.gpu_ok = gpu_ok
self.update_log((gpu_msg, gpu_ok)) self.update_log((gpu_msg, gpu_ok))
self.update_log("Loading modules...") self.update_log("Loading modules...")
load_thread = LoadPipelineThread(pipeline_loaded_callback)
# Determine device based on GPU availability
if gpu_ok:
if platform.system() == "Darwin" and platform.processor() == "arm":
device = "mps"
else:
device = "cuda"
else:
device = "cpu"
lang_code = self.selected_lang or "a"
load_thread = LoadPipelineThread(
pipeline_loaded_callback, lang_code=lang_code, device=device
)
load_thread.start() load_thread.start()
threading.Thread(target=gpu_and_load, daemon=True).start() threading.Thread(target=gpu_and_load, daemon=True).start()
@@ -2863,18 +2875,27 @@ class abogen(QWidget):
) )
self.loading_movie.start() self.loading_movie.start()
def pipeline_loaded_callback(np_module, kpipeline_class, error): # Determine device based on GPU availability
self._on_pipeline_loaded_for_preview(np_module, kpipeline_class, error) if self.gpu_ok:
if platform.system() == "Darwin" and platform.processor() == "arm":
device = "mps"
else:
device = "cuda"
else:
device = "cpu"
load_thread = LoadPipelineThread(pipeline_loaded_callback) lang = self.selected_lang or "a"
load_thread = LoadPipelineThread(
self._on_pipeline_loaded_for_preview, lang_code=lang, device=device
)
load_thread.start() load_thread.start()
def _on_pipeline_loaded_for_preview(self, np_module, kpipeline_class, error): def _on_pipeline_loaded_for_preview(self, backend, error):
# stop loading animation and restore icon on error # stop loading animation and restore icon on error
if error: if error:
self.loading_movie.stop() self.loading_movie.stop()
self._show_error_message_box( self._show_error_message_box(
"Loading Error", f"Error loading numpy or KPipeline: {error}" "Loading Error", f"Error loading TTS backend: {error}"
) )
self.btn_preview.setIcon(self.play_icon) self.btn_preview.setIcon(self.play_icon)
self.btn_preview.setEnabled(True) self.btn_preview.setEnabled(True)
@@ -2912,7 +2933,7 @@ class abogen(QWidget):
gpu_msg, gpu_ok = get_gpu_acceleration(self.use_gpu) gpu_msg, gpu_ok = get_gpu_acceleration(self.use_gpu)
self.preview_thread = VoicePreviewThread( self.preview_thread = VoicePreviewThread(
np_module, kpipeline_class, lang, voice, speed, gpu_ok backend, lang, voice, speed, gpu_ok
) )
self.preview_thread.finished.connect(self._play_preview_audio) self.preview_thread.finished.connect(self._play_preview_audio)
self.preview_thread.error.connect(self._preview_error) self.preview_thread.error.connect(self._preview_error)
+70 -98
View File
@@ -1,117 +1,89 @@
""" """
Minimal TTS Backend Interface TTS Backend Interface
This module defines a minimal interface for TTS backends to enable future This module defines the protocol for TTS backends and the
extensibility while maintaining backward compatibility with existing Kokoro metadata model that describes a backend implementation.
implementation.
""" """
from abc import ABC, abstractmethod from dataclasses import dataclass
from typing import Any, Iterator, Optional, Union from typing import Protocol, List, Dict, Any
class TTSBackend(ABC): @dataclass(frozen=True)
class TTSBackendMetadata:
""" """
Minimal interface for TTS backends. Immutable metadata describing a TTS backend implementation.
This interface is designed to be minimal and focused on the essential Attributes:
operations needed for text-to-speech conversion. id: Unique backend identifier (e.g. ``"kokoro"``, ``"supertonic"``).
name: Human-readable display name.
description: Short description of the backend.
voices: Tuple of supported voice identifiers.
""" """
@abstractmethod id: str
def __call__( name: str
self, description: str
text: str, voices: tuple[str, ...] = ()
voice: Union[str, Any],
speed: float = 1.0,
**kwargs: Any class TTSBackend(Protocol):
) -> Iterator[Any]: """
Protocol for TTS backends.
All TTS backends must implement this interface to be compatible
with the application.
"""
@property
def metadata(self) -> TTSBackendMetadata:
...
def __init__(self, **kwargs) -> None:
""" """
Generate speech segments from text. Initialize the TTS backend.
Args: Args:
text: Text to convert to speech **kwargs: Backend-specific configuration parameters
voice: Voice specification or object
speed: Speed multiplier for speech
**kwargs: Additional backend-specific parameters
Yields:
Speech segments (audio data, timing info, etc.)
""" """
pass ...
def synthesize(self, text: str, **kwargs) -> bytes:
class KokoroTTSBackend(TTSBackend):
"""
Implementation of TTSBackend using Kokoro.
This class provides the concrete implementation that maintains
the existing behavior while conforming to the TTSBackend interface.
"""
def __init__(self, lang_code: str, repo_id: str = "hexgrad/Kokoro-82M", device: str = "cpu"):
""" """
Initialize Kokoro backend. Synthesize speech from text.
Args: Args:
lang_code: Language code for the model text: Text to synthesize
repo_id: Repository ID for the Kokoro model **kwargs: Additional parameters for synthesis
device: Device to run the model on (cpu, cuda, etc.)
"""
self.lang_code = lang_code
self.repo_id = repo_id
self.device = device
self._pipeline = None
def _get_pipeline(self): Returns:
"""Lazy initialization of the Kokoro pipeline.""" Audio data as bytes
if self._pipeline is None: """
from abogen.utils import load_numpy_kpipeline ...
_, KPipeline = load_numpy_kpipeline()
try:
self._pipeline = KPipeline(
lang_code=self.lang_code,
repo_id=self.repo_id,
device=self.device
)
except RuntimeError as e:
if "CUDA" in str(e) and self.device != "cpu":
# Fall back to CPU if CUDA fails
self._pipeline = KPipeline(
lang_code=self.lang_code,
repo_id=self.repo_id,
device="cpu"
)
else:
raise
return self._pipeline
def __call__( def get_available_voices(self) -> List[str]:
self,
text: str,
voice: Union[str, Any],
speed: float = 1.0,
split_pattern: str = r"\n+",
**kwargs: Any
) -> Iterator[Any]:
""" """
Generate speech segments from text using Kokoro. Get list of available voices.
Args: Returns:
text: Text to convert to speech List of voice identifiers
voice: Voice specification or object
speed: Speed multiplier for speech
split_pattern: Pattern to split text into segments
**kwargs: Additional parameters passed to the pipeline
Yields:
Speech segments
""" """
pipeline = self._get_pipeline() ...
return pipeline(
text, def get_supported_formats(self) -> List[str]:
voice=voice, """
speed=speed, Get list of supported audio formats.
split_pattern=split_pattern,
**kwargs Returns:
) List of supported audio formats
"""
...
def get_info(self) -> Dict[str, Any]:
"""
Get backend information.
Returns:
Dictionary with backend information
"""
...
+90
View File
@@ -0,0 +1,90 @@
"""
TTS Backend Registry
Provides a global registry for TTS backend factories.
Backends register themselves with metadata and a factory callable.
The registry is universal and does not know about backend constructors.
"""
from typing import Callable, Any
from abogen.tts_backend import TTSBackend, TTSBackendMetadata
class TTSBackendRegistry:
"""Registry of TTS backend factories.
Stores metadata and factory callables for registered backends.
"""
def __init__(self) -> None:
self._backends: dict[str, TTSBackendMetadata] = {}
self._factories: dict[str, Callable[..., TTSBackend]] = {}
def register(
self,
metadata: TTSBackendMetadata,
factory: Callable[..., TTSBackend],
) -> None:
"""Register a backend with its metadata and factory callable."""
self._backends[metadata.id] = metadata
self._factories[metadata.id] = factory
def list_backends(self) -> list[TTSBackendMetadata]:
"""Return metadata for all registered backends."""
return list(self._backends.values())
def get_metadata(self, backend_id: str) -> TTSBackendMetadata:
"""Get metadata for a specific backend.
Raises:
KeyError: If backend with given id is not registered.
"""
if backend_id not in self._backends:
raise KeyError(f"Unknown backend: {backend_id}")
return self._backends[backend_id]
def create_backend(self, backend_id: str, **kwargs: Any) -> TTSBackend:
"""Create a backend instance by id.
Raises:
KeyError: If backend with given id is not registered.
"""
if backend_id not in self._factories:
raise KeyError(f"Unknown backend: {backend_id}")
return self._factories[backend_id](**kwargs)
_registry = TTSBackendRegistry()
def register_backend(
metadata: TTSBackendMetadata,
factory: Callable[..., TTSBackend],
) -> None:
"""Register a TTS backend in the global registry."""
_registry.register(metadata, factory)
def get_metadata(backend_id: str) -> TTSBackendMetadata:
"""Get metadata for a specific backend by id.
Ensures all backends are registered by importing the tts_backends
package on first access.
Raises:
KeyError: If backend with given id is not registered.
"""
import abogen.tts_backends # noqa: F401 — triggers backend registration
return _registry.get_metadata(backend_id)
def get_default_voice(backend_id: str, fallback: str = "") -> str:
"""Return the first voice of a backend, or *fallback* if none."""
voices = get_metadata(backend_id).voices
return voices[0] if voices else fallback
def create_backend(backend_id: str, **kwargs: Any) -> TTSBackend:
"""Create a TTS backend instance by provider id."""
return _registry.create_backend(backend_id, **kwargs)
+20
View File
@@ -0,0 +1,20 @@
"""TTS backends package.
Backend modules are auto-discovered and imported here.
Each backend module registers itself with the global registry
when imported.
"""
import importlib
import pkgutil
def _discover_backends():
"""Import all modules in this package to trigger their registration."""
package = __name__
for _importer, modname, _ispkg in pkgutil.iter_modules(path=__path__):
importlib.import_module(f"{package}.{modname}")
_discover_backends()
+121
View File
@@ -0,0 +1,121 @@
"""
Kokoro TTS Backend
Encapsulates the Kokoro KPipeline as a TTSBackend implementation.
"""
from __future__ import annotations
from typing import Any, Dict, Iterator, List, Optional
import numpy as np
from abogen.constants import VOICES_INTERNAL
from abogen.tts_backend import TTSBackendMetadata
_KOKORO_METADATA = TTSBackendMetadata(
id="kokoro",
name="Kokoro",
description="Kokoro TTS engine",
voices=tuple(VOICES_INTERNAL),
)
def _load_kpipeline():
"""Lazy-load Kokoro dependencies."""
from kokoro import KPipeline # type: ignore[import-not-found]
return KPipeline
class KokoroBackend:
"""TTSBackend implementation wrapping the Kokoro KPipeline.
All interaction with KPipeline is encapsulated here.
The rest of the project depends only on this class.
"""
def __init__(self, **kwargs: Any) -> None:
lang_code = kwargs["lang_code"]
repo_id = kwargs.get("repo_id", "hexgrad/Kokoro-82M")
device = kwargs.get("device", "cpu")
KPipeline = _load_kpipeline()
self._pipeline = KPipeline(
lang_code=lang_code,
repo_id=repo_id,
device=device,
)
self._lang_code = lang_code
@property
def metadata(self) -> TTSBackendMetadata:
return _KOKORO_METADATA
def __call__(
self,
text: str,
*,
voice: Any,
speed: float = 1.0,
split_pattern: Optional[str] = None,
) -> Iterator[Any]:
"""Delegate to KPipeline's __call__."""
return self._pipeline(
text,
voice=voice,
speed=speed,
split_pattern=split_pattern,
)
def load_single_voice(self, voice_name: str) -> Any:
"""Load a single voice tensor. Used by voice formula system."""
return self._pipeline.load_single_voice(voice_name)
def synthesize(self, text: str, **kwargs: Any) -> bytes:
"""Synthesize speech from text. Returns raw audio bytes."""
voice = kwargs.get("voice", "")
speed = kwargs.get("speed", 1.0)
split_pattern = kwargs.get("split_pattern", None)
audio_parts: list[np.ndarray] = []
for segment in self(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 b""
combined = np.concatenate(audio_parts).astype("float32", copy=False)
return combined.tobytes()
def get_available_voices(self) -> List[str]:
"""Return known Kokoro voice identifiers."""
return list(self.metadata.voices)
def get_supported_formats(self) -> List[str]:
"""Kokoro outputs raw PCM float32 audio."""
return ["pcm_float32"]
def get_info(self) -> Dict[str, Any]:
return {
"id": "kokoro",
"name": "Kokoro",
"lang_code": self._lang_code,
}
def create_kokoro_backend(**kwargs: Any) -> KokoroBackend:
"""Factory callable registered with TTSBackendRegistry."""
return KokoroBackend(**kwargs)
# --- Registration ---
from abogen.tts_backend_registry import register_backend # noqa: E402
register_backend(
metadata=_KOKORO_METADATA,
factory=create_kokoro_backend,
)
@@ -5,7 +5,7 @@ from dataclasses import dataclass
import logging import logging
import math import math
import re import re
from typing import Any, Iterable, Iterator, Optional from typing import Any, Dict, Iterable, Iterator, List, Optional
import numpy as np import numpy as np
@@ -15,6 +15,15 @@ logger = logging.getLogger(__name__)
DEFAULT_SUPERTONIC_VOICES = ("M1", "M2", "M3", "M4", "M5", "F1", "F2", "F3", "F4", "F5") DEFAULT_SUPERTONIC_VOICES = ("M1", "M2", "M3", "M4", "M5", "F1", "F2", "F3", "F4", "F5")
from abogen.tts_backend import TTSBackendMetadata
_SUPERTONIC_METADATA = TTSBackendMetadata(
id="supertonic",
name="SuperTonic",
description="SuperTonic TTS engine",
voices=DEFAULT_SUPERTONIC_VOICES,
)
@dataclass @dataclass
class SupertonicSegment: class SupertonicSegment:
@@ -273,3 +282,111 @@ class SupertonicPipeline:
audio = _resample_linear(audio, src_rate, self.sample_rate) audio = _resample_linear(audio, src_rate, self.sample_rate)
yield SupertonicSegment(graphemes=chunk_to_speak, audio=audio) yield SupertonicSegment(graphemes=chunk_to_speak, audio=audio)
class SupertonicBackend:
"""Supertonic TTS backend implementing the TTSBackend protocol.
Encapsulates ``SupertonicPipeline`` as an internal implementation detail.
"""
@property
def metadata(self) -> TTSBackendMetadata:
return _SUPERTONIC_METADATA
def __init__(self, **kwargs: Any) -> None:
self._pipeline = SupertonicPipeline(
sample_rate=kwargs.get("sample_rate", 24000),
auto_download=kwargs.get("auto_download", True),
total_steps=kwargs.get("total_steps", 5),
)
def synthesize(self, text: str, **kwargs: Any) -> bytes:
"""Synthesize speech and return raw audio bytes (WAV).
Delegates to the internal :class:`SupertonicPipeline` and concatenates
all produced segments into a single byte buffer.
"""
import io
import soundfile as sf
voice = kwargs.get("voice", "M1")
speed = float(kwargs.get("speed", 1.0))
split_pattern = kwargs.get("split_pattern")
total_steps = kwargs.get("total_steps")
segments = self._pipeline(
text,
voice=voice,
speed=speed,
split_pattern=split_pattern,
total_steps=total_steps,
)
audio_parts: list[np.ndarray] = []
for seg in segments:
audio_parts.append(seg.audio)
if not audio_parts:
return b""
combined = np.concatenate(audio_parts)
buf = io.BytesIO()
sf.write(buf, combined, self._pipeline.sample_rate, format="WAV")
return buf.getvalue()
def get_available_voices(self) -> List[str]:
"""Return the list of built-in SuperTonic voice identifiers."""
return list(self.metadata.voices)
def get_supported_formats(self) -> List[str]:
return ["wav"]
def get_info(self) -> Dict[str, Any]:
return {
"sample_rate": self._pipeline.sample_rate,
"total_steps": self._pipeline.total_steps,
"max_chunk_length": self._pipeline.max_chunk_length,
"voices": list(DEFAULT_SUPERTONIC_VOICES),
}
def __call__(
self,
text: str,
*,
voice: str,
speed: float,
split_pattern: Optional[str] = None,
total_steps: Optional[int] = None,
) -> Iterator[SupertonicSegment]:
"""Backward-compatible call interface, delegates to the pipeline."""
return self._pipeline(
text,
voice=voice,
speed=speed,
split_pattern=split_pattern,
total_steps=total_steps,
)
def create_supertonic_backend(**kwargs: Any) -> SupertonicBackend:
"""Create a SuperTonic TTS backend instance.
Args:
sample_rate: Audio sample rate. Defaults to 24000.
auto_download: Auto-download models. Defaults to True.
total_steps: Inference steps. Defaults to 5.
Returns:
SupertonicBackend instance.
"""
return SupertonicBackend(**kwargs)
from abogen.tts_backend_registry import register_backend # noqa: E402
register_backend(
metadata=_SUPERTONIC_METADATA,
factory=create_supertonic_backend,
)
+10 -11
View File
@@ -529,21 +529,20 @@ def prevent_sleep_end():
_sleep_procs[system] = None _sleep_procs[system] = None
def load_numpy_kpipeline():
import numpy as np
from kokoro import KPipeline # type: ignore[import-not-found]
return np, KPipeline
class LoadPipelineThread(Thread): class LoadPipelineThread(Thread):
def __init__(self, callback): def __init__(self, callback, lang_code="a", device="cpu"):
super().__init__() super().__init__()
self.callback = callback self.callback = callback
self.lang_code = lang_code
self.device = device
def run(self): def run(self):
try: try:
np_module, kpipeline_class = load_numpy_kpipeline() from abogen.tts_backend_registry import create_backend
self.callback(np_module, kpipeline_class, None)
backend = create_backend(
"kokoro", lang_code=self.lang_code, device=self.device
)
self.callback(backend, None)
except Exception as e: except Exception as e:
self.callback(None, None, str(e)) self.callback(None, str(e))
+4 -3
View File
@@ -17,7 +17,7 @@ if LocalEntryNotFoundError is None: # pragma: no cover - fallback for tests
pass pass
from abogen.constants import VOICES_INTERNAL from abogen.tts_backend_registry import get_metadata
_CACHE_LOCK = threading.Lock() _CACHE_LOCK = threading.Lock()
_CACHED_VOICES: Set[str] = set() _CACHED_VOICES: Set[str] = set()
@@ -26,8 +26,9 @@ _BOOTSTRAPPED = False
def _normalize_targets(voices: Optional[Iterable[str]]) -> Set[str]: def _normalize_targets(voices: Optional[Iterable[str]]) -> Set[str]:
kokoro_voices = get_metadata("kokoro").voices
if not voices: if not voices:
return set(VOICES_INTERNAL) return set(kokoro_voices)
normalized: Set[str] = set() normalized: Set[str] = set()
for voice in voices: for voice in voices:
if not voice: if not voice:
@@ -35,7 +36,7 @@ def _normalize_targets(voices: Optional[Iterable[str]]) -> Set[str]:
voice_id = str(voice).strip() voice_id = str(voice).strip()
if not voice_id: if not voice_id:
continue continue
if voice_id in VOICES_INTERNAL: if voice_id in kokoro_voices:
normalized.add(voice_id) normalized.add(voice_id)
return normalized return normalized
+3 -2
View File
@@ -1,7 +1,7 @@
import re import re
from typing import List, Tuple from typing import List, Tuple
from abogen.constants import VOICES_INTERNAL from abogen.tts_backend_registry import get_metadata
# Calls parsing and loads the voice to gpu or cpu # Calls parsing and loads the voice to gpu or cpu
@@ -22,6 +22,7 @@ def parse_formula_terms(formula: str) -> List[Tuple[str, float]]:
raise ValueError("Empty voice formula") raise ValueError("Empty voice formula")
terms: List[Tuple[str, float]] = [] terms: List[Tuple[str, float]] = []
kokoro_voices = get_metadata("kokoro").voices
for segment in formula.split("+"): for segment in formula.split("+"):
part = segment.strip() part = segment.strip()
if not part: if not part:
@@ -30,7 +31,7 @@ def parse_formula_terms(formula: str) -> List[Tuple[str, float]]:
raise ValueError("Each component must be in the form voice*weight") raise ValueError("Each component must be in the form voice*weight")
voice_name, raw_weight = part.split("*", 1) voice_name, raw_weight = part.split("*", 1)
voice_name = voice_name.strip() voice_name = voice_name.strip()
if voice_name not in VOICES_INTERNAL: if voice_name not in kokoro_voices:
raise ValueError(f"Unknown voice: {voice_name}") raise ValueError(f"Unknown voice: {voice_name}")
try: try:
weight = float(raw_weight.strip()) weight = float(raw_weight.strip())
+33
View File
@@ -0,0 +1,33 @@
from dataclasses import dataclass
@dataclass(frozen=True)
class VoiceMetadata:
"""
Immutable metadata describing a voice from a TTS backend.
This model describes a voice independently of any backend implementation.
Backends populate these objects; the application consumes them.
The ``backend_id`` field is set by the backend itself (via
``self.metadata.id``) the application never hardcodes it.
This ensures renaming a backend does not require touching voice definitions.
"""
id: str
"""Unique voice identifier within the backend (e.g. ``"af_alloy"``, ``"M1"``)."""
display_name: str
"""Human-readable display name (e.g. ``"Alloy"``, ``"Male 1"``)."""
language: str
"""Language code — backend-specific format is acceptable (e.g. ``"a"``, ``"en"``)."""
gender: str
"""Gender category: ``"female"``, ``"male"``, or ``"unknown"``."""
backend_id: str
"""Identifier of the backend that owns this voice (e.g. ``"kokoro"``).
Set automatically by the backend never hardcoded in voice definitions.
"""
+5 -4
View File
@@ -2,8 +2,7 @@ import json
import os import os
from typing import Any, Dict, Iterable, List, Tuple from typing import Any, Dict, Iterable, List, Tuple
from abogen.constants import VOICES_INTERNAL from abogen.tts_backend_registry import get_metadata
from abogen.tts_supertonic import DEFAULT_SUPERTONIC_VOICES
from abogen.utils import get_user_config_path from abogen.utils import get_user_config_path
@@ -70,7 +69,8 @@ def serialize_profiles() -> Dict[str, Dict[str, Iterable[Tuple[str, float]]]]:
def _normalize_supertonic_voice(value: Any) -> str: def _normalize_supertonic_voice(value: Any) -> str:
raw = str(value or "").strip().upper() raw = str(value or "").strip().upper()
return raw if raw in DEFAULT_SUPERTONIC_VOICES else "M1" supertonic_voices = get_metadata("supertonic").voices
return raw if raw in supertonic_voices else "M1"
def _coerce_supertonic_steps(value: Any) -> int: def _coerce_supertonic_steps(value: Any) -> int:
@@ -135,6 +135,7 @@ def normalize_profile_entry(entry: Any) -> Dict[str, Any]:
def _normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]: def _normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]:
normalized: List[Tuple[str, float]] = [] normalized: List[Tuple[str, float]] = []
kokoro_voices = get_metadata("kokoro").voices
for item in entries or []: for item in entries or []:
if isinstance(item, dict): if isinstance(item, dict):
voice = item.get("id") or item.get("voice") voice = item.get("id") or item.get("voice")
@@ -143,7 +144,7 @@ def _normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]:
voice, weight = item[0], item[1] voice, weight = item[0], item[1]
else: else:
continue continue
if voice not in VOICES_INTERNAL: if voice not in kokoro_voices:
continue continue
if weight is None: if weight is None:
continue continue
+2 -2
View File
@@ -27,8 +27,6 @@ RUN python3 -m venv "$VIRTUAL_ENV"
WORKDIR /app WORKDIR /app
COPY pyproject.toml README.md ./ COPY pyproject.toml README.md ./
COPY abogen ./abogen
RUN pip install --upgrade pip \ RUN pip install --upgrade pip \
&& if [ -n "$TORCH_VERSION" ]; then \ && if [ -n "$TORCH_VERSION" ]; then \
pip install torch=="$TORCH_VERSION" torchvision=="$TORCH_VERSION" torchaudio=="$TORCH_VERSION" --index-url "$TORCH_INDEX_URL"; \ pip install torch=="$TORCH_VERSION" torchvision=="$TORCH_VERSION" torchaudio=="$TORCH_VERSION" --index-url "$TORCH_INDEX_URL"; \
@@ -39,6 +37,8 @@ RUN pip install --upgrade pip \
https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl \ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl \
&& pip install --no-cache-dir "mutagen>=1.47.0" && pip install --no-cache-dir "mutagen>=1.47.0"
COPY abogen ./abogen
# Install onnxruntime-gpu for CUDA acceleration (supertonic uses ONNX Runtime) # Install onnxruntime-gpu for CUDA acceleration (supertonic uses ONNX Runtime)
# Set USE_GPU=false to skip this for CPU-only deployments # Set USE_GPU=false to skip this for CPU-only deployments
RUN if [ "$USE_GPU" = "true" ]; then \ RUN if [ "$USE_GPU" = "true" ]; then \
+21 -20
View File
@@ -20,7 +20,7 @@ import numpy as np
import soundfile as sf import soundfile as sf
import static_ffmpeg import static_ffmpeg
from abogen.constants import VOICES_INTERNAL from abogen.tts_backend_registry import get_metadata
from abogen.epub3.exporter import build_epub3_package from abogen.epub3.exporter import build_epub3_package
from abogen.kokoro_text_normalization import ApostropheConfig, normalize_for_pipeline, HAS_NUM2WORDS from abogen.kokoro_text_normalization import ApostropheConfig, normalize_for_pipeline, HAS_NUM2WORDS
from abogen.normalization_settings import ( from abogen.normalization_settings import (
@@ -39,15 +39,15 @@ from abogen.utils import (
get_user_cache_path, get_user_cache_path,
get_user_output_path, get_user_output_path,
load_config, load_config,
load_numpy_kpipeline,
) )
from abogen.tts_backend import KokoroTTSBackend from abogen.tts_backend_registry import create_backend
from abogen.tts_backend import TTSBackend
from abogen.voice_cache import ensure_voice_assets from abogen.voice_cache import ensure_voice_assets
from abogen.voice_formulas import extract_voice_ids, get_new_voice from abogen.voice_formulas import extract_voice_ids, get_new_voice
from abogen.voice_profiles import load_profiles, normalize_profile_entry from abogen.voice_profiles import load_profiles, normalize_profile_entry
from abogen.pronunciation_store import increment_usage from abogen.pronunciation_store import increment_usage
from abogen.llm_client import LLMClientError from abogen.llm_client import LLMClientError
from abogen.tts_supertonic import DEFAULT_SUPERTONIC_VOICES, SupertonicPipeline
from .service import Job, JobStatus from .service import Job, JobStatus
@@ -68,11 +68,11 @@ def _supertonic_voice_from_spec(spec: Any, fallback: str) -> str:
raw = "M1" raw = "M1"
upper = raw.upper() upper = raw.upper()
if upper in DEFAULT_SUPERTONIC_VOICES: if upper in get_metadata("supertonic").voices:
return upper return upper
fallback_upper = fallback_raw.upper() if fallback_raw else "" fallback_upper = fallback_raw.upper() if fallback_raw else ""
if fallback_upper in DEFAULT_SUPERTONIC_VOICES: if fallback_upper in get_metadata("supertonic").voices:
return fallback_upper return fallback_upper
return "M1" return "M1"
@@ -123,7 +123,7 @@ def _infer_provider_from_spec(value: Any, fallback: str = "kokoro") -> str:
if not raw: if not raw:
return fallback return fallback
upper = raw.upper() upper = raw.upper()
if upper in DEFAULT_SUPERTONIC_VOICES: if upper in get_metadata("supertonic").voices:
return "supertonic" return "supertonic"
if "*" in raw or "+" in raw: if "*" in raw or "+" in raw:
return "kokoro" return "kokoro"
@@ -576,7 +576,7 @@ def _spec_to_voice_ids(spec: Any) -> Set[str]:
return set(extract_voice_ids(text)) return set(extract_voice_ids(text))
except ValueError: except ValueError:
return set() return set()
if text in VOICES_INTERNAL: if text in get_metadata("kokoro").voices:
return {text} return {text}
return set() return set()
@@ -640,7 +640,7 @@ def _collect_required_voice_ids(job: Job) -> Set[str]:
for key in ("resolved_voice", "voice_formula", "voice"): for key in ("resolved_voice", "voice_formula", "voice"):
voices.update(_spec_to_voice_ids(payload.get(key))) voices.update(_spec_to_voice_ids(payload.get(key)))
voices.update(VOICES_INTERNAL) voices.update(get_metadata("kokoro").voices)
return voices return voices
@@ -1582,7 +1582,8 @@ def run_conversion_job(job: Job) -> None:
return existing return existing
if provider_norm == "supertonic": if provider_norm == "supertonic":
pipelines[provider_norm] = SupertonicPipeline( pipelines[provider_norm] = create_backend(
"supertonic",
sample_rate=SAMPLE_RATE, sample_rate=SAMPLE_RATE,
auto_download=True, auto_download=True,
total_steps=int(getattr(job, "supertonic_total_steps", 5) or 5), total_steps=int(getattr(job, "supertonic_total_steps", 5) or 5),
@@ -1595,10 +1596,10 @@ def run_conversion_job(job: Job) -> None:
device = "cpu" device = "cpu"
if not disable_gpu: if not disable_gpu:
device = _select_device() device = _select_device()
# Create KokoroTTSBackend instance instead of directly instantiating KPipeline # Create KPipeline instance directly (conforms to TTSBackend protocol)
pipelines[provider_norm] = KokoroTTSBackend( pipelines[provider_norm] = create_backend(
"kokoro",
lang_code=job.language, lang_code=job.language,
repo_id="hexgrad/Kokoro-82M",
device=device device=device
) )
if not kokoro_cache_ready: if not kokoro_cache_ready:
@@ -1802,8 +1803,8 @@ def run_conversion_job(job: Job) -> None:
fallback_key = next(iter(voice_cache.keys()), "") fallback_key = next(iter(voice_cache.keys()), "")
if fallback_key and fallback_key != "__custom_mix": if fallback_key and fallback_key != "__custom_mix":
intro_voice_spec = fallback_key.split(":", 1)[-1] intro_voice_spec = fallback_key.split(":", 1)[-1]
if not intro_voice_spec and VOICES_INTERNAL: if not intro_voice_spec:
intro_voice_spec = VOICES_INTERNAL[0] intro_voice_spec = get_default_voice("kokoro")
if intro_voice_spec: if intro_voice_spec:
intro_provider, _, intro_voice_choice, intro_speed, intro_steps = resolve_voice_choice( intro_provider, _, intro_voice_choice, intro_speed, intro_steps = resolve_voice_choice(
@@ -2236,8 +2237,8 @@ def run_conversion_job(job: Job) -> None:
if fallback_key and fallback_key != "__custom_mix": if fallback_key and fallback_key != "__custom_mix":
# `voice_cache` keys are internal and include provider prefixes. # `voice_cache` keys are internal and include provider prefixes.
outro_voice_spec = fallback_key.split(":", 1)[-1] outro_voice_spec = fallback_key.split(":", 1)[-1]
if not outro_voice_spec and VOICES_INTERNAL: if not outro_voice_spec:
outro_voice_spec = VOICES_INTERNAL[0] outro_voice_spec = get_default_voice("kokoro")
if outro_text and outro_voice_spec: if outro_text and outro_voice_spec:
outro_start_time = current_time outro_start_time = current_time
@@ -2442,7 +2443,8 @@ def _load_pipeline(job: Job):
disable_gpu = not job.use_gpu or not cfg.get("use_gpu", True) disable_gpu = not job.use_gpu or not cfg.get("use_gpu", True)
provider = str(getattr(job, "tts_provider", "kokoro") or "kokoro").strip().lower() provider = str(getattr(job, "tts_provider", "kokoro") or "kokoro").strip().lower()
if provider == "supertonic": if provider == "supertonic":
return SupertonicPipeline( return create_backend(
"supertonic",
sample_rate=SAMPLE_RATE, sample_rate=SAMPLE_RATE,
auto_download=True, auto_download=True,
total_steps=int(getattr(job, "supertonic_total_steps", 5) or 5), total_steps=int(getattr(job, "supertonic_total_steps", 5) or 5),
@@ -2451,8 +2453,7 @@ def _load_pipeline(job: Job):
device = "cpu" device = "cpu"
if not disable_gpu: if not disable_gpu:
device = _select_device() device = _select_device()
_np, KPipeline = load_numpy_kpipeline() return create_backend("kokoro", lang_code=job.language, device=device)
return KPipeline(lang_code=job.language, repo_id="hexgrad/Kokoro-82M", device=device)
def _select_device() -> str: def _select_device() -> str:
+2 -3
View File
@@ -15,7 +15,7 @@ from abogen.normalization_settings import build_apostrophe_config
from abogen.text_extractor import extract_from_path from abogen.text_extractor import extract_from_path
from abogen.voice_cache import ensure_voice_assets from abogen.voice_cache import ensure_voice_assets
from abogen.webui.conversion_runner import SAMPLE_RATE, SPLIT_PATTERN, _select_device, _to_float32, _resolve_voice, _spec_to_voice_ids from abogen.webui.conversion_runner import SAMPLE_RATE, SPLIT_PATTERN, _select_device, _to_float32, _resolve_voice, _spec_to_voice_ids
from abogen.utils import load_numpy_kpipeline from abogen.tts_backend_registry import create_backend
_MARKER_RE = re.compile(re.escape(MARKER_PREFIX) + r"(?P<code>[A-Z0-9_]+)" + re.escape(MARKER_SUFFIX)) _MARKER_RE = re.compile(re.escape(MARKER_PREFIX) + r"(?P<code>[A-Z0-9_]+)" + re.escape(MARKER_SUFFIX))
@@ -45,8 +45,7 @@ def _load_pipeline(language: str, use_gpu: bool) -> Any:
device = "cpu" device = "cpu"
if use_gpu: if use_gpu:
device = _select_device() device = _select_device()
_np, KPipeline = load_numpy_kpipeline() return create_backend("kokoro", lang_code=language, device=device)
return KPipeline(lang_code=language, repo_id="hexgrad/Kokoro-82M", device=device)
def _extract_cases_from_text(text: str) -> List[Tuple[str, str]]: def _extract_cases_from_text(text: str) -> List[Tuple[str, str]]:
+5 -5
View File
@@ -32,7 +32,7 @@ from abogen.webui.routes.utils.common import split_profile_spec
from abogen.utils import calculate_text_length from abogen.utils import calculate_text_length
from abogen.voice_profiles import serialize_profiles, normalize_profile_entry from abogen.voice_profiles import serialize_profiles, normalize_profile_entry
from abogen.chunking import ChunkLevel, build_chunks_for_chapters from abogen.chunking import ChunkLevel, build_chunks_for_chapters
from abogen.constants import VOICES_INTERNAL from abogen.tts_backend_registry import get_default_voice
from abogen.speaker_configs import get_config from abogen.speaker_configs import get_config
from abogen.kokoro_text_normalization import normalize_roman_numeral_titles from abogen.kokoro_text_normalization import normalize_roman_numeral_titles
from dataclasses import dataclass from dataclasses import dataclass
@@ -616,8 +616,8 @@ def apply_book_step_form(
custom_formula = "" custom_formula = ""
base_voice_spec = resolved_default_voice or narrator_voice_raw base_voice_spec = resolved_default_voice or narrator_voice_raw
if not base_voice_spec and VOICES_INTERNAL: if not base_voice_spec:
base_voice_spec = VOICES_INTERNAL[0] base_voice_spec = get_default_voice("kokoro")
voice_choice, resolved_language, selected_profile = resolve_voice_choice( voice_choice, resolved_language, selected_profile = resolve_voice_choice(
pending.language, pending.language,
@@ -796,8 +796,8 @@ def build_pending_job_from_extraction(
profile_selection = inferred_profile profile_selection = inferred_profile
base_voice = base_voice_input or resolved_default_voice or str(default_voice_setting).strip() base_voice = base_voice_input or resolved_default_voice or str(default_voice_setting).strip()
if not base_voice and VOICES_INTERNAL: if not base_voice:
base_voice = VOICES_INTERNAL[0] base_voice = get_default_voice("kokoro")
selected_speaker_config = (form.get("speaker_config") or "").strip() selected_speaker_config = (form.get("speaker_config") or "").strip()
speaker_config_payload = get_config(selected_speaker_config) if selected_speaker_config else None speaker_config_payload = get_config(selected_speaker_config) if selected_speaker_config else None
+4 -5
View File
@@ -78,10 +78,9 @@ def get_preview_pipeline(language: str, device: str) -> Any:
pipeline = _preview_pipelines.get(key) pipeline = _preview_pipelines.get(key)
if pipeline is not None: if pipeline is not None:
return pipeline return pipeline
from abogen.utils import load_numpy_kpipeline from abogen.tts_backend_registry import create_backend
_, KPipeline = load_numpy_kpipeline() pipeline = create_backend("kokoro", lang_code=language, device=device)
pipeline = KPipeline(lang_code=language, repo_id="hexgrad/Kokoro-82M", device=device)
_preview_pipelines[key] = pipeline _preview_pipelines[key] = pipeline
return pipeline return pipeline
@@ -137,9 +136,9 @@ def generate_preview_audio(
normalized_text = source_text normalized_text = source_text
if provider == "supertonic": if provider == "supertonic":
from abogen.tts_supertonic import SupertonicPipeline from abogen.tts_backend_registry import create_backend
pipeline = SupertonicPipeline(sample_rate=SAMPLE_RATE, auto_download=True, total_steps=supertonic_total_steps) pipeline = create_backend("supertonic", sample_rate=SAMPLE_RATE, auto_download=True, total_steps=supertonic_total_steps)
segments = pipeline( segments = pipeline(
normalized_text, normalized_text,
voice=voice_spec, voice=voice_spec,
+2 -2
View File
@@ -6,8 +6,8 @@ from abogen.constants import (
LANGUAGE_DESCRIPTIONS, LANGUAGE_DESCRIPTIONS,
SUBTITLE_FORMATS, SUBTITLE_FORMATS,
SUPPORTED_SOUND_FORMATS, SUPPORTED_SOUND_FORMATS,
VOICES_INTERNAL,
) )
from abogen.tts_backend_registry import get_default_voice
from abogen.normalization_settings import ( from abogen.normalization_settings import (
DEFAULT_LLM_PROMPT, DEFAULT_LLM_PROMPT,
environment_llm_defaults, environment_llm_defaults,
@@ -174,7 +174,7 @@ def settings_defaults() -> Dict[str, Any]:
"subtitle_format": "srt", "subtitle_format": "srt",
"save_mode": "default_output" if has_output_override() else "save_next_to_input", "save_mode": "default_output" if has_output_override() else "save_next_to_input",
"default_speaker": "", "default_speaker": "",
"default_voice": VOICES_INTERNAL[0] if VOICES_INTERNAL else "", "default_voice": get_default_voice("kokoro"),
"supertonic_total_steps": 5, "supertonic_total_steps": 5,
"supertonic_speed": 1.0, "supertonic_speed": 1.0,
"replace_single_newlines": False, "replace_single_newlines": False,
+5 -6
View File
@@ -17,10 +17,10 @@ from abogen.constants import (
SUPPORTED_SOUND_FORMATS, SUPPORTED_SOUND_FORMATS,
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION, SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
SAMPLE_VOICE_TEXTS, SAMPLE_VOICE_TEXTS,
VOICES_INTERNAL,
) )
from abogen.tts_backend_registry import get_metadata
from abogen.speaker_configs import list_configs from abogen.speaker_configs import list_configs
from abogen.utils import load_numpy_kpipeline from abogen.tts_backend_registry import create_backend
from abogen.webui.conversion_runner import _select_device, _to_float32, SAMPLE_RATE, SPLIT_PATTERN from abogen.webui.conversion_runner import _select_device, _to_float32, SAMPLE_RATE, SPLIT_PATTERN
_preview_pipeline_lock = threading.RLock() _preview_pipeline_lock = threading.RLock()
@@ -285,7 +285,7 @@ def filter_voice_catalog(
def build_voice_catalog() -> List[Dict[str, str]]: def build_voice_catalog() -> List[Dict[str, str]]:
catalog: List[Dict[str, str]] = [] catalog: List[Dict[str, str]] = []
gender_map = {"f": "Female", "m": "Male"} gender_map = {"f": "Female", "m": "Male"}
for voice_id in VOICES_INTERNAL: for voice_id in get_metadata("kokoro").voices:
prefix, _, rest = voice_id.partition("_") prefix, _, rest = voice_id.partition("_")
language_code = prefix[0] if prefix else "a" language_code = prefix[0] if prefix else "a"
gender_code = prefix[1] if len(prefix) > 1 else "" gender_code = prefix[1] if len(prefix) > 1 else ""
@@ -590,7 +590,7 @@ def template_options() -> Dict[str, Any]:
voice_catalog = build_voice_catalog() voice_catalog = build_voice_catalog()
return { return {
"languages": LANGUAGE_DESCRIPTIONS, "languages": LANGUAGE_DESCRIPTIONS,
"voices": VOICES_INTERNAL, "voices": get_metadata("kokoro").voices,
"subtitle_formats": SUBTITLE_FORMATS, "subtitle_formats": SUBTITLE_FORMATS,
"supported_langs_for_subs": SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION, "supported_langs_for_subs": SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
"output_formats": SUPPORTED_SOUND_FORMATS, "output_formats": SUPPORTED_SOUND_FORMATS,
@@ -741,8 +741,7 @@ def get_preview_pipeline(language: str, device: str):
pipeline = _preview_pipelines.get(key) pipeline = _preview_pipelines.get(key)
if pipeline is not None: if pipeline is not None:
return pipeline return pipeline
_, KPipeline = load_numpy_kpipeline() pipeline = create_backend("kokoro", lang_code=language, device=device)
pipeline = KPipeline(lang_code=language, repo_id="hexgrad/Kokoro-82M", device=device)
_preview_pipelines[key] = pipeline _preview_pipelines[key] = pipeline
return pipeline return pipeline
+1 -1
View File
@@ -17,7 +17,7 @@ from abogen.speaker_configs import (
save_configs, save_configs,
delete_config, delete_config,
) )
from abogen.constants import VOICES_INTERNAL
voices_bp = Blueprint("voices", __name__) voices_bp = Blueprint("voices", __name__)
+216
View File
@@ -0,0 +1,216 @@
"""Tests for KokoroBackend class."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Iterator, List
from unittest.mock import MagicMock, patch
import numpy as np
import pytest
from abogen.tts_backend import TTSBackendMetadata
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@dataclass
class _FakeSegment:
graphemes: str
audio: Any # np.ndarray or torch-like tensor
class _FakePipeline:
"""Minimal mock for kokoro.KPipeline."""
def __init__(self, *, lang_code: str, repo_id: str, device: str):
self.lang_code = lang_code
self.repo_id = repo_id
self.device = device
self._voices: dict[str, np.ndarray] = {}
def __call__(
self,
text: str,
*,
voice: Any = "",
speed: float = 1.0,
split_pattern: str | None = None,
) -> Iterator[_FakeSegment]:
yield _FakeSegment(graphemes=text, audio=np.zeros(100, dtype="float32"))
def load_single_voice(self, name: str) -> np.ndarray:
if name not in self._voices:
self._voices[name] = np.ones((1, 256), dtype="float32") * 0.5
return self._voices[name]
def _make_backend(**kwargs: Any):
"""Create KokoroBackend with mocked KPipeline."""
with patch("abogen.tts_backends.kokoro._load_kpipeline") as load:
load.return_value = _FakePipeline
from abogen.tts_backends.kokoro import KokoroBackend
return KokoroBackend(**kwargs)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestKokoroBackendMetadata:
def test_metadata_returns_tts_backend_metadata(self):
backend = _make_backend(lang_code="a")
meta = backend.metadata
assert isinstance(meta, TTSBackendMetadata)
def test_metadata_fields(self):
backend = _make_backend(lang_code="a")
meta = backend.metadata
assert meta.id == "kokoro"
assert meta.name == "Kokoro"
assert "Kokoro" in meta.description
class TestKokoroBackendInit:
def test_stores_lang_code(self):
backend = _make_backend(lang_code="b")
assert backend._lang_code == "b"
def test_default_repo_id(self):
with patch("abogen.tts_backends.kokoro._load_kpipeline") as load:
load.return_value = _FakePipeline
from abogen.tts_backends.kokoro import KokoroBackend
b = KokoroBackend(lang_code="a")
assert b._pipeline.repo_id == "hexgrad/Kokoro-82M"
def test_custom_repo_id(self):
backend = _make_backend(lang_code="a", repo_id="custom/repo")
assert backend._pipeline.repo_id == "custom/repo"
def test_default_device(self):
backend = _make_backend(lang_code="a")
assert backend._pipeline.device == "cpu"
def test_custom_device(self):
backend = _make_backend(lang_code="a", device="cuda")
assert backend._pipeline.device == "cuda"
class TestKokoroBackendCall:
def test_call_delegates_to_pipeline(self):
backend = _make_backend(lang_code="a")
results = list(backend("hello", voice="af_heart", speed=1.2, split_pattern=r"\n"))
assert len(results) == 1
assert results[0].graphemes == "hello"
def test_call_returns_iterator(self):
backend = _make_backend(lang_code="a")
result = backend("test", voice="af_heart")
assert hasattr(result, "__iter__")
def test_call_with_voice_tensor(self):
backend = _make_backend(lang_code="a")
voice_tensor = np.ones((1, 256), dtype="float32")
results = list(backend("test", voice=voice_tensor))
assert len(results) == 1
def test_call_default_speed(self):
backend = _make_backend(lang_code="a")
# Should not raise with default speed
list(backend("text", voice="af_heart"))
def test_call_default_split_pattern_is_none(self):
backend = _make_backend(lang_code="a")
# split_pattern defaults to None
list(backend("text", voice="af_heart"))
class TestLoadSingleVoice:
def test_load_single_voice_delegates(self):
backend = _make_backend(lang_code="a")
tensor = backend.load_single_voice("af_heart")
assert isinstance(tensor, np.ndarray)
assert tensor.shape == (1, 256)
def test_load_single_voice_caches(self):
backend = _make_backend(lang_code="a")
t1 = backend.load_single_voice("af_heart")
t2 = backend.load_single_voice("af_heart")
assert t1 is t2 # same object
class TestSynthesize:
def test_synthesize_returns_bytes(self):
backend = _make_backend(lang_code="a")
result = backend.synthesize("hello", voice="af_heart")
assert isinstance(result, bytes)
def test_synthesize_nonempty(self):
backend = _make_backend(lang_code="a")
result = backend.synthesize("hello", voice="af_heart")
assert len(result) > 0
def test_synthesize_with_speed(self):
backend = _make_backend(lang_code="a")
result = backend.synthesize("hello", voice="af_heart", speed=1.5)
assert isinstance(result, bytes)
def test_synthesize_empty_text(self):
backend = _make_backend(lang_code="a")
# Empty text produces no segments
result = backend.synthesize("", voice="af_heart")
assert isinstance(result, bytes)
class TestProtocolMethods:
def test_get_available_voices(self):
backend = _make_backend(lang_code="a")
voices = backend.get_available_voices()
assert isinstance(voices, list)
assert len(voices) > 0
assert all(isinstance(v, str) for v in voices)
def test_get_supported_formats(self):
backend = _make_backend(lang_code="a")
formats = backend.get_supported_formats()
assert "pcm_float32" in formats
def test_get_info(self):
backend = _make_backend(lang_code="a")
info = backend.get_info()
assert info["id"] == "kokoro"
assert info["name"] == "Kokoro"
assert info["lang_code"] == "a"
class TestRegistration:
def test_factory_creates_kokoro_backend(self):
from abogen.tts_backends.kokoro import create_kokoro_backend, KokoroBackend
with patch("abogen.tts_backends.kokoro._load_kpipeline") as load:
load.return_value = _FakePipeline
backend = create_kokoro_backend(lang_code="a")
assert isinstance(backend, KokoroBackend)
def test_registry_has_kokoro(self):
import abogen.tts_backends # noqa: F401
from abogen.tts_backend_registry import _registry
meta = _registry.get_metadata("kokoro")
assert meta.id == "kokoro"
assert meta.name == "Kokoro"
def test_registry_factory_returns_kokoro_backend(self):
import abogen.tts_backends # noqa: F401
from abogen.tts_backend_registry import _registry
from abogen.tts_backends.kokoro import KokoroBackend
factory = _registry._factories["kokoro"]
with patch("abogen.tts_backends.kokoro._load_kpipeline") as load:
load.return_value = _FakePipeline
backend = factory(lang_code="a")
assert isinstance(backend, KokoroBackend)
+11 -6
View File
@@ -19,7 +19,7 @@ def test_preview_applies_manual_override_before_normalization(monkeypatch):
# And stub the kokoro pipeline path so generate_preview_audio won't proceed. # And stub the kokoro pipeline path so generate_preview_audio won't proceed.
# We'll instead validate by calling the override logic through generate_preview_audio # We'll instead validate by calling the override logic through generate_preview_audio
# with provider=supertonic and stub SupertonicPipeline to capture input. # with provider=supertonic and stub create_backend to capture input.
captured = {} captured = {}
class DummyPipeline: class DummyPipeline:
@@ -30,11 +30,16 @@ def test_preview_applies_manual_override_before_normalization(monkeypatch):
captured["text"] = text captured["text"] = text
return iter(()) return iter(())
monkeypatch.setitem( from abogen import tts_backend_registry
__import__("sys").modules,
"abogen.tts_supertonic", original_create_backend = tts_backend_registry.create_backend
type("M", (), {"SupertonicPipeline": DummyPipeline}),
) def _mock_create_backend(backend_id, **kwargs):
if backend_id == "supertonic":
return DummyPipeline(**kwargs)
return original_create_backend(backend_id, **kwargs)
monkeypatch.setattr(tts_backend_registry, "create_backend", _mock_create_backend)
try: try:
preview.generate_preview_audio( preview.generate_preview_audio(
+204
View File
@@ -0,0 +1,204 @@
from dataclasses import dataclass
from abogen.tts_backend import TTSBackendMetadata
from abogen.tts_backend_registry import TTSBackendRegistry
class TestTTSBackendMetadata:
def test_is_frozen_dataclass(self):
assert dataclass(TTSBackendMetadata)
def test_fields_are_present(self):
meta = TTSBackendMetadata(
id="test",
name="Test Backend",
description="A test backend",
)
assert meta.id == "test"
assert meta.name == "Test Backend"
assert meta.description == "A test backend"
def test_voices_field_default_empty(self):
meta = TTSBackendMetadata(
id="test",
name="Test",
description="Test backend",
)
assert meta.voices == ()
def test_voices_field_stored(self):
meta = TTSBackendMetadata(
id="test",
name="Test",
description="Test backend",
voices=("v1", "v2"),
)
assert meta.voices == ("v1", "v2")
def test_is_immutable(self):
import pytest
meta = TTSBackendMetadata(
id="kokoro",
name="Kokoro",
description="Test",
)
with pytest.raises(Exception):
meta.id = "changed"
class TestTTSBackendRegistry:
def test_register_and_list(self):
registry = TTSBackendRegistry()
meta = TTSBackendMetadata(id="a", name="A", description="Backend A")
registry.register(metadata=meta, factory=lambda: None)
backends = registry.list_backends()
assert len(backends) == 1
assert backends[0].id == "a"
def test_list_multiple(self):
registry = TTSBackendRegistry()
meta_a = TTSBackendMetadata(id="a", name="A", description="A")
meta_b = TTSBackendMetadata(id="b", name="B", description="B")
registry.register(metadata=meta_a, factory=lambda: None)
registry.register(metadata=meta_b, factory=lambda: None)
backends = registry.list_backends()
ids = [b.id for b in backends]
assert "a" in ids
assert "b" in ids
def test_get_metadata(self):
registry = TTSBackendRegistry()
meta = TTSBackendMetadata(id="x", name="X", description="X backend")
registry.register(metadata=meta, factory=lambda: None)
result = registry.get_metadata("x")
assert result.id == "x"
assert result.name == "X"
def test_get_metadata_unknown_raises(self):
import pytest
registry = TTSBackendRegistry()
with pytest.raises(KeyError, match="Unknown backend: nope"):
registry.get_metadata("nope")
def test_create_backend(self):
registry = TTSBackendRegistry()
meta = TTSBackendMetadata(id="test", name="Test", description="Test backend")
def factory(**kwargs):
return {"created": True, "kwargs": kwargs}
registry.register(metadata=meta, factory=factory)
result = registry.create_backend("test", foo="bar")
assert result == {"created": True, "kwargs": {"foo": "bar"}}
def test_create_backend_unknown_raises(self):
import pytest
registry = TTSBackendRegistry()
with pytest.raises(KeyError, match="Unknown backend: missing"):
registry.create_backend("missing")
def test_register_overwrites(self):
registry = TTSBackendRegistry()
meta1 = TTSBackendMetadata(id="x", name="V1", description="First")
meta2 = TTSBackendMetadata(id="x", name="V2", description="Second")
registry.register(metadata=meta1, factory=lambda: "v1")
registry.register(metadata=meta2, factory=lambda: "v2")
result = registry.get_metadata("x")
assert result.name == "V2"
assert registry.create_backend("x") == "v2"
class TestBackendRegistration:
"""Tests that existing backends are auto-registered."""
def test_import_triggers_registration(self):
import abogen.tts_backends # noqa: F401
from abogen.tts_backend_registry import _registry
backends = _registry.list_backends()
ids = [b.id for b in backends]
assert "kokoro" in ids
assert "supertonic" in ids
def test_kokoro_metadata(self):
import abogen.tts_backends # noqa: F401
from abogen.tts_backend_registry import _registry
meta = _registry.get_metadata("kokoro")
assert meta.id == "kokoro"
assert meta.name == "Kokoro"
assert "Kokoro" in meta.description
def test_supertonic_metadata(self):
import abogen.tts_backends # noqa: F401
from abogen.tts_backend_registry import _registry
meta = _registry.get_metadata("supertonic")
assert meta.id == "supertonic"
assert meta.name == "SuperTonic"
assert "SuperTonic" in meta.description
def test_kokoro_metadata_has_voices(self):
import abogen.tts_backends # noqa: F401
from abogen.tts_backend_registry import _registry
meta = _registry.get_metadata("kokoro")
assert isinstance(meta.voices, tuple)
assert len(meta.voices) > 0
assert all(isinstance(v, str) for v in meta.voices)
def test_supertonic_metadata_has_voices(self):
import abogen.tts_backends # noqa: F401
from abogen.tts_backend_registry import _registry
meta = _registry.get_metadata("supertonic")
assert isinstance(meta.voices, tuple)
assert len(meta.voices) == 10
assert meta.voices == ("M1", "M2", "M3", "M4", "M5", "F1", "F2", "F3", "F4", "F5")
def test_kokoro_factory_callable(self):
import abogen.tts_backends # noqa: F401
from abogen.tts_backend_registry import _registry
factory = _registry._factories["kokoro"]
assert callable(factory)
def test_supertonic_factory_callable(self):
import abogen.tts_backends # noqa: F401
from abogen.tts_backend_registry import _registry
factory = _registry._factories["supertonic"]
assert callable(factory)
def test_kokoro_metadata_voices_match_registry(self):
"""Ensure the metadata property on the instance shares voices with registry."""
from abogen.tts_backends.kokoro import _KOKORO_METADATA
from abogen.tts_backend_registry import _registry
registry_meta = _registry.get_metadata("kokoro")
assert _KOKORO_METADATA is registry_meta
assert _KOKORO_METADATA.voices == registry_meta.voices
def test_supertonic_metadata_voices_match_registry(self):
"""Ensure the metadata property on the instance shares voices with registry."""
from abogen.tts_backends.supertonic import _SUPERTONIC_METADATA
from abogen.tts_backend_registry import _registry
registry_meta = _registry.get_metadata("supertonic")
assert _SUPERTONIC_METADATA is registry_meta
assert _SUPERTONIC_METADATA.voices == registry_meta.voices
+63 -8
View File
@@ -1,6 +1,6 @@
import numpy as np import numpy as np
from abogen.tts_supertonic import SupertonicPipeline from abogen.tts_backends.supertonic import SupertonicBackend, SupertonicPipeline
class _DummyTTS: class _DummyTTS:
@@ -26,13 +26,23 @@ class _DummyTTS:
return audio, 0.05 return audio, 0.05
def test_supertonic_pipeline_strips_unsupported_characters_and_retries(): def _make_pipeline() -> SupertonicPipeline:
# Avoid importing/initializing real supertonic by manually constructing the pipeline.
pipeline = SupertonicPipeline.__new__(SupertonicPipeline) pipeline = SupertonicPipeline.__new__(SupertonicPipeline)
pipeline.sample_rate = 24000 pipeline.sample_rate = 24000
pipeline.total_steps = 5 pipeline.total_steps = 5
pipeline.max_chunk_length = 1000 pipeline.max_chunk_length = 1000
pipeline._tts = _DummyTTS() pipeline._tts = _DummyTTS()
return pipeline
def _make_backend() -> SupertonicBackend:
backend = SupertonicBackend.__new__(SupertonicBackend)
backend._pipeline = _make_pipeline()
return backend
def test_supertonic_pipeline_strips_unsupported_characters_and_retries():
pipeline = _make_pipeline()
segs = list(pipeline("Hello • world", voice="M1", speed=1.0)) segs = list(pipeline("Hello • world", voice="M1", speed=1.0))
assert len(segs) == 1 assert len(segs) == 1
@@ -43,11 +53,56 @@ def test_supertonic_pipeline_strips_unsupported_characters_and_retries():
def test_supertonic_pipeline_drops_chunk_if_only_unsupported_characters(): def test_supertonic_pipeline_drops_chunk_if_only_unsupported_characters():
pipeline = SupertonicPipeline.__new__(SupertonicPipeline) pipeline = _make_pipeline()
pipeline.sample_rate = 24000
pipeline.total_steps = 5
pipeline.max_chunk_length = 1000
pipeline._tts = _DummyTTS()
segs = list(pipeline("", voice="M1", speed=1.0)) segs = list(pipeline("", voice="M1", speed=1.0))
assert segs == [] assert segs == []
# --- SupertonicBackend tests ---
def test_backend_metadata():
backend = _make_backend()
meta = backend.metadata
assert meta.id == "supertonic"
assert meta.name == "SuperTonic"
assert "SuperTonic" in meta.description
def test_backend_get_available_voices():
backend = _make_backend()
voices = backend.get_available_voices()
assert isinstance(voices, list)
assert "M1" in voices
assert "F1" in voices
def test_backend_get_supported_formats():
backend = _make_backend()
formats = backend.get_supported_formats()
assert "wav" in formats
def test_backend_get_info():
backend = _make_backend()
info = backend.get_info()
assert info["sample_rate"] == 24000
assert info["total_steps"] == 5
assert isinstance(info["voices"], list)
def test_backend_call_delegates_to_pipeline():
backend = _make_backend()
segs = list(backend("Hello • world", voice="M1", speed=1.0))
assert len(segs) == 1
assert segs[0].audio.size > 0
def test_backend_synthesize_returns_wav_bytes():
backend = _make_backend()
wav_bytes = backend.synthesize("Hello world", voice="M1", speed=1.0)
assert isinstance(wav_bytes, bytes)
assert len(wav_bytes) > 0
# WAV magic number
assert wav_bytes[:4] == b"RIFF"
+1 -1
View File
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from abogen.webui.conversion_runner import _resolve_voice, _supertonic_voice_from_spec from abogen.webui.conversion_runner import _resolve_voice, _supertonic_voice_from_spec
from abogen.tts_supertonic import DEFAULT_SUPERTONIC_VOICES from abogen.tts_backends.supertonic import DEFAULT_SUPERTONIC_VOICES
def test_resolve_voice_formula_without_pipeline_does_not_crash() -> None: def test_resolve_voice_formula_without_pipeline_does_not_crash() -> None:
+233
View File
@@ -0,0 +1,233 @@
import pytest
from abogen.voice_metadata import VoiceMetadata
class TestVoiceMetadataCreation:
def test_create_with_all_fields(self):
voice = VoiceMetadata(
id="af_alloy",
display_name="Alloy",
language="a",
gender="female",
backend_id="kokoro",
)
assert voice.id == "af_alloy"
assert voice.display_name == "Alloy"
assert voice.language == "a"
assert voice.gender == "female"
assert voice.backend_id == "kokoro"
def test_create_supertonic_voice(self):
voice = VoiceMetadata(
id="M1",
display_name="Male 1",
language="en",
gender="male",
backend_id="supertonic",
)
assert voice.id == "M1"
assert voice.backend_id == "supertonic"
def test_create_with_unknown_gender(self):
voice = VoiceMetadata(
id="custom_voice",
display_name="Custom",
language="en",
gender="unknown",
backend_id="custom_backend",
)
assert voice.gender == "unknown"
class TestVoiceMetadataImmutability:
def test_frozen_dataclass(self):
voice = VoiceMetadata(
id="af_alloy",
display_name="Alloy",
language="a",
gender="female",
backend_id="kokoro",
)
with pytest.raises(AttributeError):
voice.id = "new_id"
def test_cannot_modify_display_name(self):
voice = VoiceMetadata(
id="af_alloy",
display_name="Alloy",
language="a",
gender="female",
backend_id="kokoro",
)
with pytest.raises(AttributeError):
voice.display_name = "New Name"
def test_cannot_modify_backend_id(self):
voice = VoiceMetadata(
id="af_alloy",
display_name="Alloy",
language="a",
gender="female",
backend_id="kokoro",
)
with pytest.raises(AttributeError):
voice.backend_id = "new_backend"
class TestVoiceMetadataEquality:
def test_equal_voices_are_equal(self):
voice1 = VoiceMetadata(
id="af_alloy",
display_name="Alloy",
language="a",
gender="female",
backend_id="kokoro",
)
voice2 = VoiceMetadata(
id="af_alloy",
display_name="Alloy",
language="a",
gender="female",
backend_id="kokoro",
)
assert voice1 == voice2
def test_different_voices_are_not_equal(self):
voice1 = VoiceMetadata(
id="af_alloy",
display_name="Alloy",
language="a",
gender="female",
backend_id="kokoro",
)
voice2 = VoiceMetadata(
id="am_adam",
display_name="Adam",
language="a",
gender="male",
backend_id="kokoro",
)
assert voice1 != voice2
def test_different_backend_id_not_equal(self):
voice1 = VoiceMetadata(
id="custom",
display_name="Custom",
language="en",
gender="unknown",
backend_id="backend_a",
)
voice2 = VoiceMetadata(
id="custom",
display_name="Custom",
language="en",
gender="unknown",
backend_id="backend_b",
)
assert voice1 != voice2
class TestVoiceMetadataHashing:
def test_hashable(self):
voice = VoiceMetadata(
id="af_alloy",
display_name="Alloy",
language="a",
gender="female",
backend_id="kokoro",
)
assert hash(voice) is not None
def test_equal_voices_same_hash(self):
voice1 = VoiceMetadata(
id="af_alloy",
display_name="Alloy",
language="a",
gender="female",
backend_id="kokoro",
)
voice2 = VoiceMetadata(
id="af_alloy",
display_name="Alloy",
language="a",
gender="female",
backend_id="kokoro",
)
assert hash(voice1) == hash(voice2)
def test_usable_in_set(self):
voice1 = VoiceMetadata(
id="af_alloy",
display_name="Alloy",
language="a",
gender="female",
backend_id="kokoro",
)
voice2 = VoiceMetadata(
id="af_alloy",
display_name="Alloy",
language="a",
gender="female",
backend_id="kokoro",
)
voice3 = VoiceMetadata(
id="am_adam",
display_name="Adam",
language="a",
gender="male",
backend_id="kokoro",
)
voice_set = {voice1, voice2, voice3}
assert len(voice_set) == 2
class TestVoiceMetadataUseCases:
def test_backend_populates_backend_id(self):
"""Simulate how a backend would populate backend_id automatically."""
class MockBackend:
def __init__(self):
self._backend_id = "kokoro"
def get_voices(self):
return [
VoiceMetadata(
id="af_alloy",
display_name="Alloy",
language="a",
gender="female",
backend_id=self._backend_id,
),
]
backend = MockBackend()
voices = backend.get_voices()
assert voices[0].backend_id == "kokoro"
def test_filter_by_language(self):
voices = [
VoiceMetadata(id="af_alloy", display_name="Alloy", language="a", gender="female", backend_id="kokoro"),
VoiceMetadata(id="jf_alpha", display_name="Alpha", language="j", gender="female", backend_id="kokoro"),
VoiceMetadata(id="am_adam", display_name="Adam", language="a", gender="male", backend_id="kokoro"),
]
english_voices = [v for v in voices if v.language == "a"]
assert len(english_voices) == 2
def test_filter_by_gender(self):
voices = [
VoiceMetadata(id="af_alloy", display_name="Alloy", language="a", gender="female", backend_id="kokoro"),
VoiceMetadata(id="am_adam", display_name="Adam", language="a", gender="male", backend_id="kokoro"),
VoiceMetadata(id="am_puck", display_name="Puck", language="a", gender="male", backend_id="kokoro"),
]
male_voices = [v for v in voices if v.gender == "male"]
assert len(male_voices) == 2
def test_filter_by_backend(self):
voices = [
VoiceMetadata(id="af_alloy", display_name="Alloy", language="a", gender="female", backend_id="kokoro"),
VoiceMetadata(id="M1", display_name="Male 1", language="en", gender="male", backend_id="supertonic"),
]
kokoro_voices = [v for v in voices if v.backend_id == "kokoro"]
assert len(kokoro_voices) == 1
assert kokoro_voices[0].id == "af_alloy"