mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
Compare commits
58
Commits
webui
..
6f25fc06d0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f25fc06d0 | ||
|
|
32c4d533c9 | ||
|
|
146000886d | ||
|
|
31f95137dd | ||
|
|
6f02fda41c | ||
|
|
a3c3462348 | ||
|
|
79332204d3 | ||
|
|
6deec3b9b6 | ||
|
|
c4d14112d4 | ||
|
|
f4cb2c2329 | ||
|
|
783738882f | ||
|
|
e94ba5257e | ||
|
|
49d66839dc | ||
|
|
d0e316ea7b | ||
|
|
bb96ae502c | ||
|
|
a4d25accc1 | ||
|
|
66964bfd0b | ||
|
|
f8f72624f8 | ||
|
|
e7a88a513a | ||
|
|
2277f16d0a | ||
|
|
1d50429b87 | ||
|
|
29681a5fbb | ||
|
|
50fa2e5b9e | ||
|
|
5816feb6da | ||
|
|
b95df8f217 | ||
|
|
245e67284e | ||
|
|
e2557d961b | ||
|
|
9c6b3774b4 | ||
|
|
fd9fe5579a | ||
|
|
f079373821 | ||
|
|
fbb5d4e368 | ||
|
|
57fec453e2 | ||
|
|
58fe22e3d5 | ||
|
|
ab8cbc4911 | ||
|
|
5e2048072a | ||
|
|
66ed2a202d | ||
|
|
45e859dac4 | ||
|
|
56d3e414b3 | ||
|
|
b942bcb820 | ||
|
|
47efcb4420 | ||
|
|
7b3f9d8615 | ||
|
|
9833bb0843 | ||
|
|
cbc05ead42 | ||
|
|
50b4d6872a | ||
|
|
9fa81fbe1e | ||
|
|
9fd9fad238 | ||
|
|
ca5c5ee62d | ||
|
|
e51be95bc1 | ||
|
|
2223f46c9e | ||
|
|
8322f7f416 | ||
|
|
2c15d2f78a | ||
|
|
cc9c2a22ba | ||
|
|
d1366b445d | ||
|
|
c224cdbb56 | ||
|
|
d30415ffe7 | ||
|
|
083f1eb09b | ||
|
|
30929e8f4e | ||
|
|
ded73843c9 |
@@ -0,0 +1,15 @@
|
|||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
github: [jborza, jeremiahsb, mohangk]
|
||||||
|
patreon: # Replace with a single Patreon username
|
||||||
|
open_collective: # Replace with a single Open Collective username
|
||||||
|
ko_fi: # Replace with a single Ko-fi username
|
||||||
|
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||||
|
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||||
|
liberapay: # Replace with a single Liberapay username
|
||||||
|
issuehunt: # Replace with a single IssueHunt username
|
||||||
|
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||||
|
polar: # Replace with a single Polar username
|
||||||
|
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||||
|
thanks_dev: # Replace with a single thanks.dev username
|
||||||
|
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
name: pip install
|
name: CI
|
||||||
run-name: pip install
|
run-name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- '**.py'
|
- '**.py'
|
||||||
- 'pyproject.toml'
|
- 'pyproject.toml'
|
||||||
@@ -11,23 +13,41 @@ on:
|
|||||||
- 'pyproject.toml'
|
- 'pyproject.toml'
|
||||||
- '.github/workflows/**'
|
- '.github/workflows/**'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
install-and-run:
|
test:
|
||||||
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 }}
|
||||||
- name: Install from repository
|
|
||||||
run: python -m pip install .
|
- name: Install uv
|
||||||
#- name: Run abogen
|
uses: astral-sh/setup-uv@v8.3.1
|
||||||
# run: abogen
|
with:
|
||||||
|
enable-cache: true
|
||||||
|
prune-cache: false
|
||||||
|
cache-dependency-glob: pyproject.toml
|
||||||
|
|
||||||
|
- name: Install system dependencies (Ubuntu)
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y libegl1
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv pip install --system .[dev]
|
||||||
|
env:
|
||||||
|
UV_LINK_MODE: copy
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
env:
|
||||||
|
QT_QPA_PLATFORM: offscreen
|
||||||
|
run: pytest tests/ -v --tb=short
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ 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
|
||||||
|
|||||||
@@ -38,3 +38,4 @@ dist/
|
|||||||
.old/
|
.old/
|
||||||
test_assets/
|
test_assets/
|
||||||
dev_notes/
|
dev_notes/
|
||||||
|
.claude/
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
1.3.0
|
1.3.1
|
||||||
@@ -915,7 +915,11 @@ class EpubParser(BaseBookParser):
|
|||||||
|
|
||||||
if slice_html.strip():
|
if slice_html.strip():
|
||||||
slice_soup = BeautifulSoup(slice_html, "html.parser")
|
slice_soup = BeautifulSoup(slice_html, "html.parser")
|
||||||
for tag in slice_soup.find_all(["p", "div"]):
|
|
||||||
|
# Add line breaks after block-level elements to ensure pauses in speech
|
||||||
|
for tag in slice_soup.find_all(
|
||||||
|
["p", "div", "h1", "h2", "h3", "h4", "h5", "h6", "li", "blockquote"]
|
||||||
|
):
|
||||||
tag.append("\n\n")
|
tag.append("\n\n")
|
||||||
|
|
||||||
for ol in slice_soup.find_all("ol"):
|
for ol in slice_soup.find_all("ol"):
|
||||||
|
|||||||
@@ -63,64 +63,6 @@ SUPPORTED_INPUT_FORMATS = [
|
|||||||
# 384 if self.lang_code in 'ab':
|
# 384 if self.lang_code in 'ab':
|
||||||
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION = list(LANGUAGE_DESCRIPTIONS.keys())
|
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION = list(LANGUAGE_DESCRIPTIONS.keys())
|
||||||
|
|
||||||
# Voice and sample text constants
|
|
||||||
VOICES_INTERNAL = [
|
|
||||||
"af_alloy",
|
|
||||||
"af_aoede",
|
|
||||||
"af_bella",
|
|
||||||
"af_heart",
|
|
||||||
"af_jessica",
|
|
||||||
"af_kore",
|
|
||||||
"af_nicole",
|
|
||||||
"af_nova",
|
|
||||||
"af_river",
|
|
||||||
"af_sarah",
|
|
||||||
"af_sky",
|
|
||||||
"am_adam",
|
|
||||||
"am_echo",
|
|
||||||
"am_eric",
|
|
||||||
"am_fenrir",
|
|
||||||
"am_liam",
|
|
||||||
"am_michael",
|
|
||||||
"am_onyx",
|
|
||||||
"am_puck",
|
|
||||||
"am_santa",
|
|
||||||
"bf_alice",
|
|
||||||
"bf_emma",
|
|
||||||
"bf_isabella",
|
|
||||||
"bf_lily",
|
|
||||||
"bm_daniel",
|
|
||||||
"bm_fable",
|
|
||||||
"bm_george",
|
|
||||||
"bm_lewis",
|
|
||||||
"ef_dora",
|
|
||||||
"em_alex",
|
|
||||||
"em_santa",
|
|
||||||
"ff_siwis",
|
|
||||||
"hf_alpha",
|
|
||||||
"hf_beta",
|
|
||||||
"hm_omega",
|
|
||||||
"hm_psi",
|
|
||||||
"if_sara",
|
|
||||||
"im_nicola",
|
|
||||||
"jf_alpha",
|
|
||||||
"jf_gongitsune",
|
|
||||||
"jf_nezumi",
|
|
||||||
"jf_tebukuro",
|
|
||||||
"jm_kumo",
|
|
||||||
"pf_dora",
|
|
||||||
"pm_alex",
|
|
||||||
"pm_santa",
|
|
||||||
"zf_xiaobei",
|
|
||||||
"zf_xiaoni",
|
|
||||||
"zf_xiaoxiao",
|
|
||||||
"zf_xiaoyi",
|
|
||||||
"zm_yunjian",
|
|
||||||
"zm_yunxi",
|
|
||||||
"zm_yunxia",
|
|
||||||
"zm_yunyang",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Voice and sample text mapping
|
# Voice and sample text mapping
|
||||||
SAMPLE_VOICE_TEXTS = {
|
SAMPLE_VOICE_TEXTS = {
|
||||||
"a": "This is a sample of the selected voice.",
|
"a": "This is a sample of the selected voice.",
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
"""Backwards-compatible re-export of conversion module.
|
|
||||||
|
|
||||||
The PyQt-based implementation lives in abogen.pyqt.conversion.
|
|
||||||
The web-based implementation is in abogen.webui.conversion_runner.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
# Re-export PyQt conversion classes for backwards compatibility
|
|
||||||
from abogen.pyqt.conversion import ( # noqa: F401
|
|
||||||
ConversionThread,
|
|
||||||
VoicePreviewThread,
|
|
||||||
PlayAudioThread,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = ["ConversionThread", "VoicePreviewThread", "PlayAudioThread"]
|
|
||||||
@@ -21,7 +21,8 @@ from PyQt6.QtWidgets import (
|
|||||||
)
|
)
|
||||||
from PyQt6.QtCore import QThread, pyqtSignal
|
from PyQt6.QtCore import QThread, pyqtSignal
|
||||||
|
|
||||||
from abogen.constants import COLORS, VOICES_INTERNAL
|
from abogen.constants import COLORS
|
||||||
|
from abogen.tts_backend_registry import get_metadata
|
||||||
from abogen.spacy_utils import SPACY_MODELS
|
from abogen.spacy_utils import SPACY_MODELS
|
||||||
import abogen.hf_tracker
|
import abogen.hf_tracker
|
||||||
|
|
||||||
@@ -114,7 +115,7 @@ class PreDownloadWorker(QThread):
|
|||||||
self._voices_success = False
|
self._voices_success = False
|
||||||
return
|
return
|
||||||
|
|
||||||
voice_list = VOICES_INTERNAL
|
voice_list = get_metadata("kokoro").voices
|
||||||
for idx, voice in enumerate(voice_list, start=1):
|
for idx, voice in enumerate(voice_list, start=1):
|
||||||
if self._cancelled:
|
if self._cancelled:
|
||||||
self._voices_success = False
|
self._voices_success = False
|
||||||
@@ -462,14 +463,14 @@ class PreDownloadDialog(QDialog):
|
|||||||
try:
|
try:
|
||||||
from huggingface_hub import try_to_load_from_cache
|
from huggingface_hub import try_to_load_from_cache
|
||||||
|
|
||||||
for voice in VOICES_INTERNAL:
|
for voice in get_metadata("kokoro").voices:
|
||||||
if not try_to_load_from_cache(
|
if not try_to_load_from_cache(
|
||||||
repo_id="hexgrad/Kokoro-82M", filename=f"voices/{voice}.pt"
|
repo_id="hexgrad/Kokoro-82M", filename=f"voices/{voice}.pt"
|
||||||
):
|
):
|
||||||
missing.append(voice)
|
missing.append(voice)
|
||||||
except Exception:
|
except Exception:
|
||||||
# If HF missing, report all as missing
|
# If HF missing, report all as missing
|
||||||
return False, list(VOICES_INTERNAL)
|
return False, list(get_metadata("kokoro").voices)
|
||||||
return (len(missing) == 0), missing
|
return (len(missing) == 0), missing
|
||||||
|
|
||||||
def _check_kokoro_model(self) -> bool:
|
def _check_kokoro_model(self) -> bool:
|
||||||
|
|||||||
+381
-313
@@ -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,
|
||||||
@@ -42,6 +43,10 @@ from abogen.subtitle_utils import (
|
|||||||
get_sample_voice_text,
|
get_sample_voice_text,
|
||||||
sanitize_name_for_os,
|
sanitize_name_for_os,
|
||||||
_CHAPTER_MARKER_SEARCH_PATTERN,
|
_CHAPTER_MARKER_SEARCH_PATTERN,
|
||||||
|
_VOICE_MARKER_PATTERN,
|
||||||
|
_VOICE_MARKER_SEARCH_PATTERN,
|
||||||
|
split_text_by_voice_markers,
|
||||||
|
validate_voice_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
class CountdownDialog(QDialog):
|
class CountdownDialog(QDialog):
|
||||||
@@ -255,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,
|
||||||
@@ -266,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
|
||||||
@@ -296,6 +299,31 @@ class ConversionThread(QThread):
|
|||||||
self.use_spacy_segmentation = True # Default, will be overridden from GUI
|
self.use_spacy_segmentation = True # Default, will be overridden from GUI
|
||||||
# Set split pattern based on language and subtitle mode
|
# Set split pattern based on language and subtitle mode
|
||||||
self.split_pattern = self._get_split_pattern(lang_code, subtitle_mode)
|
self.split_pattern = self._get_split_pattern(lang_code, subtitle_mode)
|
||||||
|
self.voice_cache = {} # Cache for loaded voices
|
||||||
|
|
||||||
|
def load_voice_cached(self, voice_name, tts):
|
||||||
|
"""Load voice with caching to avoid reloading same voice.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
voice_name: Voice name or formula string
|
||||||
|
tts: TTS pipeline instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Loaded voice tensor or voice name string
|
||||||
|
"""
|
||||||
|
# Check cache first
|
||||||
|
if voice_name in self.voice_cache:
|
||||||
|
return self.voice_cache[voice_name]
|
||||||
|
|
||||||
|
# Load voice
|
||||||
|
if "*" in voice_name:
|
||||||
|
loaded_voice = get_new_voice(tts, voice_name, self.use_gpu)
|
||||||
|
else:
|
||||||
|
loaded_voice = voice_name
|
||||||
|
|
||||||
|
# Cache it
|
||||||
|
self.voice_cache[voice_name] = loaded_voice
|
||||||
|
return loaded_voice
|
||||||
|
|
||||||
def _stream_audio_in_chunks(
|
def _stream_audio_in_chunks(
|
||||||
self, segments, process_func, progress_prefix="Processing"
|
self, segments, process_func, progress_prefix="Processing"
|
||||||
@@ -461,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
|
||||||
@@ -509,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:
|
||||||
@@ -524,6 +539,26 @@ class ConversionThread(QThread):
|
|||||||
# Clean up text using utility function
|
# Clean up text using utility function
|
||||||
text = clean_text(text)
|
text = clean_text(text)
|
||||||
|
|
||||||
|
# Apply word substitutions if enabled
|
||||||
|
if getattr(self, "word_substitutions_enabled", False):
|
||||||
|
from abogen.word_substitution import apply_word_substitutions
|
||||||
|
|
||||||
|
self.log_updated.emit("Applying word substitutions...")
|
||||||
|
|
||||||
|
substitutions_list = getattr(self, "word_substitutions_list", "")
|
||||||
|
case_sensitive = getattr(self, "case_sensitive_substitutions", False)
|
||||||
|
replace_caps = getattr(self, "replace_all_caps", False)
|
||||||
|
replace_nums = getattr(self, "replace_numerals", False)
|
||||||
|
fix_punct = getattr(self, "fix_nonstandard_punctuation", False)
|
||||||
|
|
||||||
|
text = apply_word_substitutions(
|
||||||
|
text,
|
||||||
|
substitutions_list,
|
||||||
|
case_sensitive,
|
||||||
|
replace_caps,
|
||||||
|
replace_nums,
|
||||||
|
fix_punct,
|
||||||
|
)
|
||||||
|
|
||||||
# --- Chapter splitting logic ---
|
# --- Chapter splitting logic ---
|
||||||
# Use pre-compiled pattern for better performance
|
# Use pre-compiled pattern for better performance
|
||||||
@@ -550,6 +585,42 @@ class ConversionThread(QThread):
|
|||||||
chapters = [("text", text)]
|
chapters = [("text", text)]
|
||||||
total_chapters = len(chapters)
|
total_chapters = len(chapters)
|
||||||
|
|
||||||
|
# --- Voice marker splitting logic ---
|
||||||
|
# Split each chapter by voice markers, preserving voice state across chapters
|
||||||
|
chapters_with_voices = []
|
||||||
|
current_voice = self.voice # Start with default voice
|
||||||
|
total_valid_markers = 0
|
||||||
|
total_invalid_markers = 0
|
||||||
|
|
||||||
|
for chapter_name, chapter_text in chapters:
|
||||||
|
# Use current_voice as the starting voice for this chapter
|
||||||
|
voice_segments, last_voice, valid_count, invalid_count = split_text_by_voice_markers(chapter_text, current_voice)
|
||||||
|
chapters_with_voices.append((chapter_name, voice_segments))
|
||||||
|
|
||||||
|
# Update current_voice so next chapter continues with this voice
|
||||||
|
current_voice = last_voice
|
||||||
|
|
||||||
|
# Track total valid/invalid markers
|
||||||
|
total_valid_markers += valid_count
|
||||||
|
total_invalid_markers += invalid_count
|
||||||
|
|
||||||
|
# Log voice marker information with accurate counts
|
||||||
|
total_markers = total_valid_markers + total_invalid_markers
|
||||||
|
if total_markers > 0:
|
||||||
|
if total_invalid_markers == 0:
|
||||||
|
# All markers were valid
|
||||||
|
self.log_updated.emit(
|
||||||
|
(f"\nDetected {total_markers} voice marker(s) - all valid", "grey")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Some markers were invalid
|
||||||
|
self.log_updated.emit(
|
||||||
|
(f"\nDetected {total_markers} voice marker(s) - {total_valid_markers} valid, {total_invalid_markers} invalid (using previous voice)", "orange")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Replace chapters with the new structure
|
||||||
|
chapters = chapters_with_voices
|
||||||
|
|
||||||
# For text files with chapters, prompt user for options if not already set
|
# For text files with chapters, prompt user for options if not already set
|
||||||
is_txt_file = not self.is_direct_text and (
|
is_txt_file = not self.is_direct_text and (
|
||||||
self.file_name.lower().endswith(".txt")
|
self.file_name.lower().endswith(".txt")
|
||||||
@@ -842,7 +913,7 @@ class ConversionThread(QThread):
|
|||||||
]
|
]
|
||||||
srt_index = 1 # SRT numbering fix for chapter-only mode
|
srt_index = 1 # SRT numbering fix for chapter-only mode
|
||||||
# Instead of processing the whole text, process by chapter
|
# Instead of processing the whole text, process by chapter
|
||||||
for chapter_idx, (chapter_name, chapter_text) in enumerate(chapters, 1):
|
for chapter_idx, (chapter_name, voice_segments) in enumerate(chapters, 1):
|
||||||
chapter_out_path = None
|
chapter_out_path = None
|
||||||
chapter_out_file = None
|
chapter_out_file = None
|
||||||
chapter_ffmpeg_proc = None
|
chapter_ffmpeg_proc = None
|
||||||
@@ -862,11 +933,6 @@ class ConversionThread(QThread):
|
|||||||
if merge_chapters_at_end:
|
if merge_chapters_at_end:
|
||||||
chapter_time["start"] = current_time
|
chapter_time["start"] = current_time
|
||||||
|
|
||||||
# Check if the voice is a formula and load it if necessary
|
|
||||||
if "*" in self.voice:
|
|
||||||
loaded_voice = get_new_voice(tts, self.voice, self.use_gpu)
|
|
||||||
else:
|
|
||||||
loaded_voice = self.voice
|
|
||||||
# Prepare per-chapter output file if needed
|
# Prepare per-chapter output file if needed
|
||||||
if save_chapters_separately and total_chapters > 1:
|
if save_chapters_separately and total_chapters > 1:
|
||||||
# First pass: keep alphanumeric, spaces, hyphens, and underscores
|
# First pass: keep alphanumeric, spaces, hyphens, and underscores
|
||||||
@@ -986,293 +1052,309 @@ class ConversionThread(QThread):
|
|||||||
chapter_subtitle_path = None
|
chapter_subtitle_path = None
|
||||||
chapter_subtitle_file = None
|
chapter_subtitle_file = None
|
||||||
|
|
||||||
# Determine if spaCy segmentation should be used for PRE-TTS segmentation
|
|
||||||
# Only non-English languages use spaCy for pre-segmentation
|
|
||||||
# English uses spaCy only for subtitle generation (post-TTS)
|
|
||||||
# spaCy is disabled when subtitle mode is "Disabled" or "Line"
|
|
||||||
# spaCy is also disabled when input is a subtitle file
|
|
||||||
is_subtitle_input = (
|
|
||||||
not self.is_direct_text
|
|
||||||
and self.file_name
|
|
||||||
and os.path.splitext(self.file_name)[1].lower()
|
|
||||||
in [".srt", ".ass", ".vtt"]
|
|
||||||
)
|
|
||||||
use_spacy = (
|
|
||||||
getattr(self, "use_spacy_segmentation", False)
|
|
||||||
and self.subtitle_mode not in ["Disabled", "Line"]
|
|
||||||
and not is_subtitle_input
|
|
||||||
)
|
|
||||||
spacy_sentences = None
|
|
||||||
active_split_pattern = self.split_pattern
|
|
||||||
spacing_pattern = r"\s*" if self.lang_code in ["z", "j"] else r"\s+"
|
|
||||||
|
|
||||||
# Pre-load spaCy model for English if it will be needed for subtitle generation
|
# Process each voice segment within the chapter
|
||||||
if (
|
for segment_idx, (voice_name, segment_text) in enumerate(voice_segments):
|
||||||
use_spacy
|
# Load voice for this segment (with caching)
|
||||||
and self.lang_code in ["a", "b"]
|
try:
|
||||||
and self.subtitle_mode in ["Sentence", "Sentence + Comma"]
|
loaded_voice = self.load_voice_cached(voice_name, self.backend)
|
||||||
):
|
if segment_idx > 0:
|
||||||
from abogen.spacy_utils import get_spacy_model
|
voice_display = voice_name if len(voice_name) < 50 else voice_name[:47] + "..."
|
||||||
|
self.log_updated.emit((f" → Voice: {voice_display}", "grey"))
|
||||||
nlp = get_spacy_model(
|
except Exception:
|
||||||
self.lang_code,
|
|
||||||
log_callback=lambda msg: self.log_updated.emit(msg),
|
|
||||||
)
|
|
||||||
if nlp:
|
|
||||||
self.log_updated.emit(
|
self.log_updated.emit(
|
||||||
(
|
(f"⚠ Voice loading error for '{voice_name}', continuing with previous", "orange")
|
||||||
"\nUsing spaCy for sentence segmentation (only for subtitles)...",
|
|
||||||
"grey",
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
if segment_idx == 0:
|
||||||
|
loaded_voice = self.load_voice_cached(self.voice, self.backend)
|
||||||
|
|
||||||
if use_spacy and self.lang_code not in ["a", "b"]:
|
# Determine if spaCy segmentation should be used for PRE-TTS segmentation
|
||||||
# Non-English: use spaCy for pre-TTS segmentation
|
# Only non-English languages use spaCy for pre-segmentation
|
||||||
self.log_updated.emit(
|
# English uses spaCy only for subtitle generation (post-TTS)
|
||||||
("\nUsing spaCy for sentence segmentation (pre-TTS)...", "grey")
|
# spaCy is disabled when subtitle mode is "Disabled" or "Line"
|
||||||
|
# spaCy is also disabled when input is a subtitle file
|
||||||
|
is_subtitle_input = (
|
||||||
|
not self.is_direct_text
|
||||||
|
and self.file_name
|
||||||
|
and os.path.splitext(self.file_name)[1].lower()
|
||||||
|
in [".srt", ".ass", ".vtt"]
|
||||||
)
|
)
|
||||||
from abogen.spacy_utils import segment_sentences
|
use_spacy = (
|
||||||
|
getattr(self, "use_spacy_segmentation", False)
|
||||||
spacy_sentences = segment_sentences(
|
and self.subtitle_mode not in ["Disabled", "Line"]
|
||||||
chapter_text,
|
and not is_subtitle_input
|
||||||
self.lang_code,
|
|
||||||
log_callback=lambda msg: self.log_updated.emit(msg),
|
|
||||||
)
|
)
|
||||||
if spacy_sentences:
|
spacy_sentences = None
|
||||||
self.log_updated.emit(
|
active_split_pattern = self.split_pattern
|
||||||
(
|
spacing_pattern = r"\s*" if self.lang_code in ["z", "j"] else r"\s+"
|
||||||
f"\nspaCy: Text segmented into {len(spacy_sentences)} sentences...",
|
|
||||||
"grey",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
# For Sentence + Comma mode, still split on commas within spaCy sentences
|
|
||||||
if self.subtitle_mode == "Sentence + Comma":
|
|
||||||
active_split_pattern = r"(?<=[{}]){}|\n+".format(
|
|
||||||
self.PUNCTUATION_COMMAS, spacing_pattern
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
active_split_pattern = (
|
|
||||||
"\n" # Use newline splitting for Sentence mode
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.log_updated.emit(
|
|
||||||
("\nspaCy: Fallback to default segmentation...", "grey")
|
|
||||||
)
|
|
||||||
|
|
||||||
# Process text - either as spaCy sentences or as single text
|
# Pre-load spaCy model for English if it will be needed for subtitle generation
|
||||||
text_segments = spacy_sentences if spacy_sentences else [chapter_text]
|
if (
|
||||||
|
use_spacy
|
||||||
# Print active split pattern used by the TTS engine once for this batch
|
and self.lang_code in ["a", "b"]
|
||||||
try:
|
and self.subtitle_mode in ["Sentence", "Sentence + Comma"]
|
||||||
print(f"Using split pattern: {active_split_pattern!r}")
|
|
||||||
except Exception:
|
|
||||||
# Print must never break processing
|
|
||||||
print("Using split pattern: (unprintable)")
|
|
||||||
|
|
||||||
for text_segment in text_segments:
|
|
||||||
for result in tts(
|
|
||||||
text_segment,
|
|
||||||
voice=loaded_voice,
|
|
||||||
speed=self.speed,
|
|
||||||
split_pattern=active_split_pattern,
|
|
||||||
):
|
):
|
||||||
# Print the result for debugging
|
from abogen.spacy_utils import get_spacy_model
|
||||||
# print(f"Result: {result}")
|
|
||||||
if self.cancel_requested:
|
nlp = get_spacy_model(
|
||||||
if chapter_out_file:
|
self.lang_code,
|
||||||
chapter_out_file.close()
|
log_callback=lambda msg: self.log_updated.emit(msg),
|
||||||
if merged_out_file:
|
|
||||||
merged_out_file.close()
|
|
||||||
self.conversion_finished.emit("Cancelled", None)
|
|
||||||
return
|
|
||||||
current_segment += 1
|
|
||||||
grapheme_len = len(result.graphemes)
|
|
||||||
self.processed_char_count += grapheme_len
|
|
||||||
# Log progress with both character counts and the graphemes content
|
|
||||||
self.log_updated.emit(
|
|
||||||
f"\n{self.processed_char_count:,}/{self.total_char_count:,}: {result.graphemes}"
|
|
||||||
)
|
)
|
||||||
|
if nlp:
|
||||||
|
self.log_updated.emit(
|
||||||
|
(
|
||||||
|
"\nUsing spaCy for sentence segmentation (only for subtitles)...",
|
||||||
|
"grey",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
chunk_dur = len(result.audio) / rate
|
if use_spacy and self.lang_code not in ["a", "b"]:
|
||||||
chunk_start = current_time
|
# Non-English: use spaCy for pre-TTS segmentation
|
||||||
# Write audio directly to merged file ONLY if merging
|
self.log_updated.emit(
|
||||||
if merge_chapters_at_end and merged_out_file:
|
("\nUsing spaCy for sentence segmentation (pre-TTS)...", "grey")
|
||||||
merged_out_file.write(result.audio)
|
)
|
||||||
elif merge_chapters_at_end and ffmpeg_proc:
|
from abogen.spacy_utils import segment_sentences
|
||||||
if hasattr(result.audio, "numpy"):
|
|
||||||
audio_bytes = (
|
spacy_sentences = segment_sentences(
|
||||||
result.audio.numpy().astype("float32").tobytes()
|
segment_text,
|
||||||
|
self.lang_code,
|
||||||
|
log_callback=lambda msg: self.log_updated.emit(msg),
|
||||||
|
)
|
||||||
|
if spacy_sentences:
|
||||||
|
self.log_updated.emit(
|
||||||
|
(
|
||||||
|
f"\nspaCy: Text segmented into {len(spacy_sentences)} sentences...",
|
||||||
|
"grey",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# For Sentence + Comma mode, still split on commas within spaCy sentences
|
||||||
|
if self.subtitle_mode == "Sentence + Comma":
|
||||||
|
active_split_pattern = r"(?<=[{}]){}|\n+".format(
|
||||||
|
self.PUNCTUATION_COMMAS, spacing_pattern
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
audio_bytes = result.audio.astype("float32").tobytes()
|
active_split_pattern = (
|
||||||
ffmpeg_proc.stdin.write(audio_bytes)
|
"\n" # Use newline splitting for Sentence mode
|
||||||
if chapter_out_file:
|
|
||||||
chapter_out_file.write(result.audio)
|
|
||||||
elif chapter_ffmpeg_proc:
|
|
||||||
if hasattr(result.audio, "numpy"):
|
|
||||||
audio_bytes = (
|
|
||||||
result.audio.numpy().astype("float32").tobytes()
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
audio_bytes = result.audio.astype("float32").tobytes()
|
self.log_updated.emit(
|
||||||
chapter_ffmpeg_proc.stdin.write(audio_bytes)
|
("\nspaCy: Fallback to default segmentation...", "grey")
|
||||||
# Subtitle logic
|
)
|
||||||
if self.subtitle_mode != "Disabled":
|
|
||||||
tokens_list = getattr(result, "tokens", [])
|
|
||||||
|
|
||||||
# Fallback for languages without token support (non-English)
|
# Process text - either as spaCy sentences or as single text
|
||||||
# Create a single token representing the entire segment duration
|
text_segments = spacy_sentences if spacy_sentences else [segment_text]
|
||||||
if not tokens_list and result.graphemes:
|
|
||||||
|
|
||||||
class FakeToken:
|
# Print active split pattern used by the TTS engine once for this batch
|
||||||
def __init__(self, text, start, end):
|
try:
|
||||||
self.text = text
|
print(f"Using split pattern: {active_split_pattern!r}")
|
||||||
self.start_ts = start
|
except Exception:
|
||||||
self.end_ts = end
|
# Print must never break processing
|
||||||
self.whitespace = ""
|
print("Using split pattern: (unprintable)")
|
||||||
|
|
||||||
tokens_list = [
|
for text_segment in text_segments:
|
||||||
FakeToken(result.graphemes, 0, chunk_dur)
|
for result in self.backend(
|
||||||
]
|
text_segment,
|
||||||
|
voice=loaded_voice,
|
||||||
|
speed=self.speed,
|
||||||
|
split_pattern=active_split_pattern,
|
||||||
|
):
|
||||||
|
# Print the result for debugging
|
||||||
|
# print(f"Result: {result}")
|
||||||
|
if self.cancel_requested:
|
||||||
|
if chapter_out_file:
|
||||||
|
chapter_out_file.close()
|
||||||
|
if merged_out_file:
|
||||||
|
merged_out_file.close()
|
||||||
|
self.conversion_finished.emit("Cancelled", None)
|
||||||
|
return
|
||||||
|
current_segment += 1
|
||||||
|
grapheme_len = len(result.graphemes)
|
||||||
|
self.processed_char_count += grapheme_len
|
||||||
|
# Log progress with both character counts and the graphemes content
|
||||||
|
self.log_updated.emit(
|
||||||
|
f"\n{self.processed_char_count:,}/{self.total_char_count:,}: {result.graphemes}"
|
||||||
|
)
|
||||||
|
|
||||||
tokens_with_timestamps = []
|
chunk_dur = len(result.audio) / rate
|
||||||
chapter_tokens_with_timestamps = []
|
chunk_start = current_time
|
||||||
|
# Write audio directly to merged file ONLY if merging
|
||||||
|
if merge_chapters_at_end and merged_out_file:
|
||||||
|
merged_out_file.write(result.audio)
|
||||||
|
elif merge_chapters_at_end and ffmpeg_proc:
|
||||||
|
if hasattr(result.audio, "numpy"):
|
||||||
|
audio_bytes = (
|
||||||
|
result.audio.numpy().astype("float32").tobytes()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
audio_bytes = result.audio.astype("float32").tobytes()
|
||||||
|
ffmpeg_proc.stdin.write(audio_bytes)
|
||||||
|
if chapter_out_file:
|
||||||
|
chapter_out_file.write(result.audio)
|
||||||
|
elif chapter_ffmpeg_proc:
|
||||||
|
if hasattr(result.audio, "numpy"):
|
||||||
|
audio_bytes = (
|
||||||
|
result.audio.numpy().astype("float32").tobytes()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
audio_bytes = result.audio.astype("float32").tobytes()
|
||||||
|
chapter_ffmpeg_proc.stdin.write(audio_bytes)
|
||||||
|
# Subtitle logic
|
||||||
|
if self.subtitle_mode != "Disabled":
|
||||||
|
tokens_list = getattr(result, "tokens", [])
|
||||||
|
|
||||||
# Process every token, regardless of text or timestamps
|
# Fallback for languages without token support (non-English)
|
||||||
for tok in tokens_list:
|
# Create a single token representing the entire segment duration
|
||||||
tokens_with_timestamps.append(
|
if not tokens_list and result.graphemes:
|
||||||
{
|
|
||||||
"start": chunk_start + (tok.start_ts or 0),
|
class FakeToken:
|
||||||
"end": chunk_start + (tok.end_ts or 0),
|
def __init__(self, text, start, end):
|
||||||
"text": tok.text,
|
self.text = text
|
||||||
"whitespace": tok.whitespace,
|
self.start_ts = start
|
||||||
}
|
self.end_ts = end
|
||||||
)
|
self.whitespace = ""
|
||||||
if chapter_out_file or chapter_ffmpeg_proc:
|
|
||||||
chapter_tokens_with_timestamps.append(
|
tokens_list = [
|
||||||
|
FakeToken(result.graphemes, 0, chunk_dur)
|
||||||
|
]
|
||||||
|
|
||||||
|
tokens_with_timestamps = []
|
||||||
|
chapter_tokens_with_timestamps = []
|
||||||
|
|
||||||
|
# Process every token, regardless of text or timestamps
|
||||||
|
for tok in tokens_list:
|
||||||
|
tokens_with_timestamps.append(
|
||||||
{
|
{
|
||||||
"start": chapter_current_time
|
"start": chunk_start + (tok.start_ts or 0),
|
||||||
+ (tok.start_ts or 0),
|
"end": chunk_start + (tok.end_ts or 0),
|
||||||
"end": chapter_current_time
|
|
||||||
+ (tok.end_ts or 0),
|
|
||||||
"text": tok.text,
|
"text": tok.text,
|
||||||
"whitespace": tok.whitespace,
|
"whitespace": tok.whitespace,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
# Process tokens according to subtitle mode
|
if chapter_out_file or chapter_ffmpeg_proc:
|
||||||
# Global subtitle processing ONLY if merging
|
chapter_tokens_with_timestamps.append(
|
||||||
|
{
|
||||||
|
"start": chapter_current_time
|
||||||
|
+ (tok.start_ts or 0),
|
||||||
|
"end": chapter_current_time
|
||||||
|
+ (tok.end_ts or 0),
|
||||||
|
"text": tok.text,
|
||||||
|
"whitespace": tok.whitespace,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# Process tokens according to subtitle mode
|
||||||
|
# Global subtitle processing ONLY if merging
|
||||||
|
if merge_chapters_at_end:
|
||||||
|
# Incremental subtitle writing for merged output
|
||||||
|
new_entries = []
|
||||||
|
self._process_subtitle_tokens(
|
||||||
|
tokens_with_timestamps,
|
||||||
|
new_entries,
|
||||||
|
self.max_subtitle_words,
|
||||||
|
fallback_end_time=chunk_start + chunk_dur,
|
||||||
|
)
|
||||||
|
if merged_subtitle_file:
|
||||||
|
subtitle_format = getattr(
|
||||||
|
self, "subtitle_format", "srt"
|
||||||
|
)
|
||||||
|
if "ass" in subtitle_format:
|
||||||
|
for start, end, text in new_entries:
|
||||||
|
start_time = self._ass_time(start)
|
||||||
|
end_time = self._ass_time(end)
|
||||||
|
# Use karaoke effect for highlighting mode
|
||||||
|
effect = (
|
||||||
|
"karaoke"
|
||||||
|
if self.subtitle_mode
|
||||||
|
== "Sentence + Highlighting"
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
merged_subtitle_file.write(
|
||||||
|
f"Dialogue: 0,{start_time},{end_time},Default,,{merged_subtitle_margin},{merged_subtitle_margin},0,{effect},{merged_subtitle_alignment_tag}{text}\n"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
for entry in new_entries:
|
||||||
|
start, end, text = entry
|
||||||
|
merged_subtitle_file.write(
|
||||||
|
f"{merged_srt_index}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n"
|
||||||
|
)
|
||||||
|
merged_srt_index += 1
|
||||||
|
# Per-chapter subtitle processing for both file and ffmpeg_proc
|
||||||
|
if chapter_out_file or chapter_ffmpeg_proc:
|
||||||
|
new_chapter_entries = []
|
||||||
|
self._process_subtitle_tokens(
|
||||||
|
chapter_tokens_with_timestamps,
|
||||||
|
new_chapter_entries,
|
||||||
|
self.max_subtitle_words,
|
||||||
|
fallback_end_time=chapter_current_time + chunk_dur,
|
||||||
|
)
|
||||||
|
if chapter_subtitle_file:
|
||||||
|
subtitle_format = getattr(
|
||||||
|
self, "subtitle_format", "srt"
|
||||||
|
)
|
||||||
|
if "ass" in subtitle_format:
|
||||||
|
for start, end, text in new_chapter_entries:
|
||||||
|
start_time = self._ass_time(start)
|
||||||
|
end_time = self._ass_time(end)
|
||||||
|
# Use karaoke effect for highlighting mode
|
||||||
|
effect = (
|
||||||
|
"karaoke"
|
||||||
|
if self.subtitle_mode
|
||||||
|
== "Sentence + Highlighting"
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
chapter_subtitle_file.write(
|
||||||
|
f"Dialogue: 0,{start_time},{end_time},Default,,{chapter_subtitle_margin},{chapter_subtitle_margin},0,{effect},{chapter_subtitle_alignment_tag}{text}\n"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
for entry in new_chapter_entries:
|
||||||
|
start, end, text = entry
|
||||||
|
chapter_subtitle_file.write(
|
||||||
|
f"{chapter_srt_index}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n"
|
||||||
|
)
|
||||||
|
chapter_srt_index += 1
|
||||||
if merge_chapters_at_end:
|
if merge_chapters_at_end:
|
||||||
# Incremental subtitle writing for merged output
|
current_time += chunk_dur
|
||||||
new_entries = []
|
if chapter_out_file or chapter_ffmpeg_proc:
|
||||||
self._process_subtitle_tokens(
|
chapter_current_time += chunk_dur
|
||||||
tokens_with_timestamps,
|
else:
|
||||||
new_entries,
|
if chapter_out_file or chapter_ffmpeg_proc:
|
||||||
self.max_subtitle_words,
|
chapter_current_time += chunk_dur
|
||||||
fallback_end_time=chunk_start + chunk_dur,
|
# Calculate percentage based on characters processed
|
||||||
)
|
percent = min(
|
||||||
if merged_subtitle_file:
|
int(
|
||||||
subtitle_format = getattr(
|
self.processed_char_count / self.total_char_count * 100
|
||||||
self, "subtitle_format", "srt"
|
),
|
||||||
)
|
99,
|
||||||
if "ass" in subtitle_format:
|
|
||||||
for start, end, text in new_entries:
|
|
||||||
start_time = self._ass_time(start)
|
|
||||||
end_time = self._ass_time(end)
|
|
||||||
# Use karaoke effect for highlighting mode
|
|
||||||
effect = (
|
|
||||||
"karaoke"
|
|
||||||
if self.subtitle_mode
|
|
||||||
== "Sentence + Highlighting"
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
merged_subtitle_file.write(
|
|
||||||
f"Dialogue: 0,{start_time},{end_time},Default,,{merged_subtitle_margin},{merged_subtitle_margin},0,{effect},{merged_subtitle_alignment_tag}{text}\n"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
for entry in new_entries:
|
|
||||||
start, end, text = entry
|
|
||||||
merged_subtitle_file.write(
|
|
||||||
f"{merged_srt_index}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n"
|
|
||||||
)
|
|
||||||
merged_srt_index += 1
|
|
||||||
# Per-chapter subtitle processing for both file and ffmpeg_proc
|
|
||||||
if chapter_out_file or chapter_ffmpeg_proc:
|
|
||||||
new_chapter_entries = []
|
|
||||||
self._process_subtitle_tokens(
|
|
||||||
chapter_tokens_with_timestamps,
|
|
||||||
new_chapter_entries,
|
|
||||||
self.max_subtitle_words,
|
|
||||||
fallback_end_time=chapter_current_time + chunk_dur,
|
|
||||||
)
|
|
||||||
if chapter_subtitle_file:
|
|
||||||
subtitle_format = getattr(
|
|
||||||
self, "subtitle_format", "srt"
|
|
||||||
)
|
|
||||||
if "ass" in subtitle_format:
|
|
||||||
for start, end, text in new_chapter_entries:
|
|
||||||
start_time = self._ass_time(start)
|
|
||||||
end_time = self._ass_time(end)
|
|
||||||
# Use karaoke effect for highlighting mode
|
|
||||||
effect = (
|
|
||||||
"karaoke"
|
|
||||||
if self.subtitle_mode
|
|
||||||
== "Sentence + Highlighting"
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
chapter_subtitle_file.write(
|
|
||||||
f"Dialogue: 0,{start_time},{end_time},Default,,{chapter_subtitle_margin},{chapter_subtitle_margin},0,{effect},{chapter_subtitle_alignment_tag}{text}\n"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
for entry in new_chapter_entries:
|
|
||||||
start, end, text = entry
|
|
||||||
chapter_subtitle_file.write(
|
|
||||||
f"{chapter_srt_index}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n"
|
|
||||||
)
|
|
||||||
chapter_srt_index += 1
|
|
||||||
if merge_chapters_at_end:
|
|
||||||
current_time += chunk_dur
|
|
||||||
if chapter_out_file or chapter_ffmpeg_proc:
|
|
||||||
chapter_current_time += chunk_dur
|
|
||||||
else:
|
|
||||||
if chapter_out_file or chapter_ffmpeg_proc:
|
|
||||||
chapter_current_time += chunk_dur
|
|
||||||
# Calculate percentage based on characters processed
|
|
||||||
percent = min(
|
|
||||||
int(
|
|
||||||
self.processed_char_count / self.total_char_count * 100
|
|
||||||
),
|
|
||||||
99,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Calculate ETR based on characters processed
|
|
||||||
etr_str = "Processing..."
|
|
||||||
chars_done = self.processed_char_count
|
|
||||||
elapsed = time.time() - self.etr_start_time
|
|
||||||
|
|
||||||
# Calculate ETR if enough data is available
|
|
||||||
if (
|
|
||||||
chars_done > 0 and elapsed > 0.5
|
|
||||||
): # Check elapsed > 0.5 to avoid instability
|
|
||||||
avg_time_per_char = elapsed / chars_done
|
|
||||||
remaining = (
|
|
||||||
self.total_char_count - self.processed_char_count
|
|
||||||
)
|
)
|
||||||
if remaining > 0:
|
|
||||||
secs = avg_time_per_char * remaining
|
|
||||||
h = int(secs // 3600)
|
|
||||||
m = int((secs % 3600) // 60)
|
|
||||||
s = int(secs % 60)
|
|
||||||
etr_str = f"{h:02d}:{m:02d}:{s:02d}"
|
|
||||||
|
|
||||||
# Update progress more frequently (after each result)
|
# Calculate ETR based on characters processed
|
||||||
self.progress_updated.emit(percent, etr_str)
|
etr_str = "Processing..."
|
||||||
|
chars_done = self.processed_char_count
|
||||||
|
elapsed = time.time() - self.etr_start_time
|
||||||
|
|
||||||
|
# Calculate ETR if enough data is available
|
||||||
|
if (
|
||||||
|
chars_done > 0 and elapsed > 0.5
|
||||||
|
): # Check elapsed > 0.5 to avoid instability
|
||||||
|
avg_time_per_char = elapsed / chars_done
|
||||||
|
remaining = (
|
||||||
|
self.total_char_count - self.processed_char_count
|
||||||
|
)
|
||||||
|
if remaining > 0:
|
||||||
|
secs = avg_time_per_char * remaining
|
||||||
|
h = int(secs // 3600)
|
||||||
|
m = int((secs % 3600) // 60)
|
||||||
|
s = int(secs % 60)
|
||||||
|
etr_str = f"{h:02d}:{m:02d}:{s:02d}"
|
||||||
|
|
||||||
|
# Update progress more frequently (after each result)
|
||||||
|
self.progress_updated.emit(percent, etr_str)
|
||||||
|
|
||||||
# Add silence between chapters for merged output (except after the last chapter)
|
# Add silence between chapters for merged output (except after the last chapter)
|
||||||
if merge_chapters_at_end and chapter_idx < total_chapters:
|
if merge_chapters_at_end and chapter_idx < total_chapters:
|
||||||
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:
|
||||||
@@ -1611,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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1675,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,
|
||||||
@@ -1693,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"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -1731,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)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -1765,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",
|
||||||
)
|
)
|
||||||
@@ -1779,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,
|
||||||
@@ -1790,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"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -1814,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"
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@@ -1830,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"
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@@ -1875,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})"
|
||||||
@@ -2344,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,
|
||||||
@@ -2353,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
|
||||||
@@ -2388,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
|
||||||
|
|||||||
+273
-18
@@ -74,7 +74,7 @@ from abogen.subtitle_utils import (
|
|||||||
calculate_text_length,
|
calculate_text_length,
|
||||||
)
|
)
|
||||||
|
|
||||||
from abogen.conversion import ConversionThread, VoicePreviewThread, PlayAudioThread
|
from abogen.pyqt.conversion import ConversionThread, VoicePreviewThread, PlayAudioThread, ChapterOptionsDialog, TimestampDetectionDialog
|
||||||
from abogen.pyqt.book_handler import HandlerDialog
|
from abogen.pyqt.book_handler import HandlerDialog
|
||||||
from abogen.constants import (
|
from abogen.constants import (
|
||||||
PROGRAM_NAME,
|
PROGRAM_NAME,
|
||||||
@@ -82,11 +82,11 @@ from abogen.constants import (
|
|||||||
GITHUB_URL,
|
GITHUB_URL,
|
||||||
PROGRAM_DESCRIPTION,
|
PROGRAM_DESCRIPTION,
|
||||||
LANGUAGE_DESCRIPTIONS,
|
LANGUAGE_DESCRIPTIONS,
|
||||||
VOICES_INTERNAL,
|
|
||||||
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
||||||
COLORS,
|
COLORS,
|
||||||
SUBTITLE_FORMATS,
|
SUBTITLE_FORMATS,
|
||||||
)
|
)
|
||||||
|
from abogen.tts_backend_registry import get_metadata
|
||||||
import threading
|
import threading
|
||||||
from abogen.pyqt.voice_formula_gui import VoiceFormulaDialog
|
from abogen.pyqt.voice_formula_gui import VoiceFormulaDialog
|
||||||
from abogen.voice_profiles import load_profiles
|
from abogen.voice_profiles import load_profiles
|
||||||
@@ -665,6 +665,11 @@ class TextboxDialog(QDialog):
|
|||||||
self.insert_chapter_btn.clicked.connect(self.insert_chapter_marker)
|
self.insert_chapter_btn.clicked.connect(self.insert_chapter_marker)
|
||||||
button_layout.addWidget(self.insert_chapter_btn)
|
button_layout.addWidget(self.insert_chapter_btn)
|
||||||
|
|
||||||
|
self.insert_voice_btn = QPushButton("Insert Voice Marker", self)
|
||||||
|
self.insert_voice_btn.setToolTip("Insert a voice change marker at the cursor position")
|
||||||
|
self.insert_voice_btn.clicked.connect(self.insert_voice_marker)
|
||||||
|
button_layout.addWidget(self.insert_voice_btn)
|
||||||
|
|
||||||
self.cancel_button = QPushButton("Cancel", self)
|
self.cancel_button = QPushButton("Cancel", self)
|
||||||
self.cancel_button.clicked.connect(self.reject)
|
self.cancel_button.clicked.connect(self.reject)
|
||||||
|
|
||||||
@@ -767,6 +772,23 @@ class TextboxDialog(QDialog):
|
|||||||
self.update_char_count()
|
self.update_char_count()
|
||||||
self.text_edit.setFocus()
|
self.text_edit.setFocus()
|
||||||
|
|
||||||
|
def insert_voice_marker(self):
|
||||||
|
"""Insert a voice marker template at cursor position."""
|
||||||
|
cursor = self.text_edit.textCursor()
|
||||||
|
# Use the currently selected voice as the default
|
||||||
|
try:
|
||||||
|
parent_window = self.parent()
|
||||||
|
if parent_window and hasattr(parent_window, 'selected_voice'):
|
||||||
|
default_voice = parent_window.selected_voice or "af_heart"
|
||||||
|
else:
|
||||||
|
default_voice = "af_heart"
|
||||||
|
except Exception:
|
||||||
|
default_voice = "af_heart"
|
||||||
|
cursor.insertText(f"\n<<VOICE:{default_voice}>>\n")
|
||||||
|
self.text_edit.setTextCursor(cursor)
|
||||||
|
self.update_char_count()
|
||||||
|
self.text_edit.setFocus()
|
||||||
|
|
||||||
|
|
||||||
def migrate_subtitle_format(config):
|
def migrate_subtitle_format(config):
|
||||||
"""Convert old subtitle_format values to new internal keys."""
|
"""Convert old subtitle_format values to new internal keys."""
|
||||||
@@ -783,6 +805,108 @@ def migrate_subtitle_format(config):
|
|||||||
save_config(config)
|
save_config(config)
|
||||||
|
|
||||||
|
|
||||||
|
class WordSubstitutionsDialog(QDialog):
|
||||||
|
"""Dialog for configuring word substitutions and text preprocessing options."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent=None,
|
||||||
|
initial_list="",
|
||||||
|
initial_case_sensitive=False,
|
||||||
|
initial_caps=False,
|
||||||
|
initial_numerals=False,
|
||||||
|
initial_punctuation=False,
|
||||||
|
):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("Word Substitutions Settings")
|
||||||
|
self.setWindowFlags(
|
||||||
|
Qt.WindowType.Window
|
||||||
|
| Qt.WindowType.WindowCloseButtonHint
|
||||||
|
| Qt.WindowType.WindowMaximizeButtonHint
|
||||||
|
)
|
||||||
|
self.resize(600, 500)
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
# Instructions
|
||||||
|
instructions = QLabel(
|
||||||
|
"Enter word substitutions (one per line) in format: Word|NewWord\n"
|
||||||
|
" - If nothing after |, the word will be erased completely\n"
|
||||||
|
" - Substitutions match whole words only (e.g., \"tree\" won't match \"trees\" but will match \"tree's\")\n"
|
||||||
|
" - By default, matching is case-insensitive (e.g., \"gonna\" matches \"Gonna\", \"GONNA\", etc.)",
|
||||||
|
self,
|
||||||
|
)
|
||||||
|
instructions.setStyleSheet(
|
||||||
|
"padding: 10px; background-color: #f0f0f0; border-radius: 5px;"
|
||||||
|
)
|
||||||
|
instructions.setWordWrap(True)
|
||||||
|
layout.addWidget(instructions)
|
||||||
|
|
||||||
|
# Text edit area
|
||||||
|
self.text_edit = QTextEdit(self)
|
||||||
|
self.text_edit.setAcceptRichText(False)
|
||||||
|
self.text_edit.setPlaceholderText("Word|NewWord")
|
||||||
|
self.text_edit.setPlainText(initial_list)
|
||||||
|
layout.addWidget(self.text_edit)
|
||||||
|
|
||||||
|
# Checkboxes
|
||||||
|
self.case_sensitive_checkbox = QCheckBox(
|
||||||
|
"Case-sensitive word matching", self
|
||||||
|
)
|
||||||
|
self.case_sensitive_checkbox.setChecked(initial_case_sensitive)
|
||||||
|
layout.addWidget(self.case_sensitive_checkbox)
|
||||||
|
|
||||||
|
self.caps_checkbox = QCheckBox("Replace ALL CAPS with lowercase", self)
|
||||||
|
self.caps_checkbox.setChecked(initial_caps)
|
||||||
|
layout.addWidget(self.caps_checkbox)
|
||||||
|
|
||||||
|
self.numerals_checkbox = QCheckBox(
|
||||||
|
"Replace Numerals with Words (e.g., 309 \u2192 three hundred and nine)", self
|
||||||
|
)
|
||||||
|
self.numerals_checkbox.setChecked(initial_numerals)
|
||||||
|
layout.addWidget(self.numerals_checkbox)
|
||||||
|
|
||||||
|
self.punctuation_checkbox = QCheckBox(
|
||||||
|
"Fix Nonstandard Punctuation (curly quotes and other Unicode punctuation that may affect how words sound)",
|
||||||
|
self,
|
||||||
|
)
|
||||||
|
self.punctuation_checkbox.setChecked(initial_punctuation)
|
||||||
|
layout.addWidget(self.punctuation_checkbox)
|
||||||
|
|
||||||
|
# Buttons
|
||||||
|
button_layout = QHBoxLayout()
|
||||||
|
self.cancel_button = QPushButton("Cancel", self)
|
||||||
|
self.cancel_button.clicked.connect(self.reject)
|
||||||
|
self.ok_button = QPushButton("OK", self)
|
||||||
|
self.ok_button.setDefault(True)
|
||||||
|
self.ok_button.clicked.connect(self.accept)
|
||||||
|
|
||||||
|
button_layout.addStretch()
|
||||||
|
button_layout.addWidget(self.cancel_button)
|
||||||
|
button_layout.addWidget(self.ok_button)
|
||||||
|
layout.addLayout(button_layout)
|
||||||
|
|
||||||
|
def get_substitutions_list(self):
|
||||||
|
"""Get the substitutions list as plain text."""
|
||||||
|
return self.text_edit.toPlainText()
|
||||||
|
|
||||||
|
def get_case_sensitive(self):
|
||||||
|
"""Get whether case-sensitive matching is enabled."""
|
||||||
|
return self.case_sensitive_checkbox.isChecked()
|
||||||
|
|
||||||
|
def get_replace_all_caps(self):
|
||||||
|
"""Get whether ALL CAPS replacement is enabled."""
|
||||||
|
return self.caps_checkbox.isChecked()
|
||||||
|
|
||||||
|
def get_replace_numerals(self):
|
||||||
|
"""Get whether numeral-to-word conversion is enabled."""
|
||||||
|
return self.numerals_checkbox.isChecked()
|
||||||
|
|
||||||
|
def get_fix_nonstandard_punctuation(self):
|
||||||
|
"""Get whether nonstandard punctuation fixing is enabled."""
|
||||||
|
return self.punctuation_checkbox.isChecked()
|
||||||
|
|
||||||
|
|
||||||
class abogen(QWidget):
|
class abogen(QWidget):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -833,6 +957,19 @@ class abogen(QWidget):
|
|||||||
self.use_silent_gaps = self.config.get("use_silent_gaps", True)
|
self.use_silent_gaps = self.config.get("use_silent_gaps", True)
|
||||||
self.subtitle_speed_method = self.config.get("subtitle_speed_method", "tts")
|
self.subtitle_speed_method = self.config.get("subtitle_speed_method", "tts")
|
||||||
self.use_spacy_segmentation = self.config.get("use_spacy_segmentation", True)
|
self.use_spacy_segmentation = self.config.get("use_spacy_segmentation", True)
|
||||||
|
# Word substitution settings
|
||||||
|
self.word_substitutions_enabled = self.config.get(
|
||||||
|
"word_substitutions_enabled", False
|
||||||
|
)
|
||||||
|
self.word_substitutions_list = self.config.get("word_substitutions_list", "")
|
||||||
|
self.case_sensitive_substitutions = self.config.get(
|
||||||
|
"case_sensitive_substitutions", False
|
||||||
|
)
|
||||||
|
self.replace_all_caps = self.config.get("replace_all_caps", False)
|
||||||
|
self.replace_numerals = self.config.get("replace_numerals", False)
|
||||||
|
self.fix_nonstandard_punctuation = self.config.get(
|
||||||
|
"fix_nonstandard_punctuation", False
|
||||||
|
)
|
||||||
self._pending_close_event = None
|
self._pending_close_event = None
|
||||||
self.gpu_ok = False # Initialize GPU availability status
|
self.gpu_ok = False # Initialize GPU availability status
|
||||||
|
|
||||||
@@ -1071,6 +1208,35 @@ class abogen(QWidget):
|
|||||||
subtitle_layout.addWidget(self.subtitle_combo)
|
subtitle_layout.addWidget(self.subtitle_combo)
|
||||||
controls_layout.addLayout(subtitle_layout)
|
controls_layout.addLayout(subtitle_layout)
|
||||||
|
|
||||||
|
# Word Substitutions section
|
||||||
|
word_sub_layout = QHBoxLayout()
|
||||||
|
word_sub_layout.setSpacing(7)
|
||||||
|
word_sub_label = QLabel("Word Substitutions:", self)
|
||||||
|
word_sub_layout.addWidget(word_sub_label)
|
||||||
|
|
||||||
|
self.word_sub_combo = QComboBox(self)
|
||||||
|
self.word_sub_combo.addItems(["Disabled", "Enabled"])
|
||||||
|
self.word_sub_combo.setStyleSheet(
|
||||||
|
"QComboBox { min-height: 20px; padding: 6px 12px; }"
|
||||||
|
)
|
||||||
|
self.word_sub_combo.setSizePolicy(
|
||||||
|
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
|
||||||
|
)
|
||||||
|
self.word_sub_combo.setCurrentText(
|
||||||
|
"Enabled" if self.word_substitutions_enabled else "Disabled"
|
||||||
|
)
|
||||||
|
self.word_sub_combo.currentTextChanged.connect(self.on_word_sub_changed)
|
||||||
|
word_sub_layout.addWidget(self.word_sub_combo)
|
||||||
|
|
||||||
|
self.btn_word_sub_settings = QPushButton("Settings", self)
|
||||||
|
self.btn_word_sub_settings.setFixedSize(80, 36)
|
||||||
|
self.btn_word_sub_settings.setStyleSheet("QPushButton { padding: 6px 12px; }")
|
||||||
|
self.btn_word_sub_settings.clicked.connect(self.show_word_sub_dialog)
|
||||||
|
self.btn_word_sub_settings.setEnabled(self.word_substitutions_enabled)
|
||||||
|
word_sub_layout.addWidget(self.btn_word_sub_settings)
|
||||||
|
|
||||||
|
controls_layout.addLayout(word_sub_layout)
|
||||||
|
|
||||||
# Output voice format
|
# Output voice format
|
||||||
format_layout = QHBoxLayout()
|
format_layout = QHBoxLayout()
|
||||||
format_layout.setSpacing(7)
|
format_layout.setSpacing(7)
|
||||||
@@ -1707,7 +1873,7 @@ class abogen(QWidget):
|
|||||||
for pname in load_profiles().keys():
|
for pname in load_profiles().keys():
|
||||||
self.voice_combo.addItem(profile_icon, pname, f"profile:{pname}")
|
self.voice_combo.addItem(profile_icon, pname, f"profile:{pname}")
|
||||||
# re-add voices
|
# re-add voices
|
||||||
for v in VOICES_INTERNAL:
|
for v in get_metadata("kokoro").voices:
|
||||||
icon = QIcon()
|
icon = QIcon()
|
||||||
flag_path = get_resource_path("abogen.assets.flags", f"{v[0]}.png")
|
flag_path = get_resource_path("abogen.assets.flags", f"{v[0]}.png")
|
||||||
if flag_path and os.path.exists(flag_path):
|
if flag_path and os.path.exists(flag_path):
|
||||||
@@ -2015,6 +2181,21 @@ class abogen(QWidget):
|
|||||||
self.subtitle_speed_method = getattr(
|
self.subtitle_speed_method = getattr(
|
||||||
queued_item, "subtitle_speed_method", "tts"
|
queued_item, "subtitle_speed_method", "tts"
|
||||||
)
|
)
|
||||||
|
# Word substitution settings
|
||||||
|
self.word_substitutions_enabled = getattr(
|
||||||
|
queued_item, "word_substitutions_enabled", False
|
||||||
|
)
|
||||||
|
self.word_substitutions_list = getattr(
|
||||||
|
queued_item, "word_substitutions_list", ""
|
||||||
|
)
|
||||||
|
self.case_sensitive_substitutions = getattr(
|
||||||
|
queued_item, "case_sensitive_substitutions", False
|
||||||
|
)
|
||||||
|
self.replace_all_caps = getattr(queued_item, "replace_all_caps", False)
|
||||||
|
self.replace_numerals = getattr(queued_item, "replace_numerals", False)
|
||||||
|
self.fix_nonstandard_punctuation = getattr(
|
||||||
|
queued_item, "fix_nonstandard_punctuation", False
|
||||||
|
)
|
||||||
|
|
||||||
# This ensures that if conversion.py (or utils) reads from config/disk
|
# This ensures that if conversion.py (or utils) reads from config/disk
|
||||||
# instead of using passed arguments, it sees the correct queue values.
|
# instead of using passed arguments, it sees the correct queue values.
|
||||||
@@ -2023,6 +2204,13 @@ class abogen(QWidget):
|
|||||||
self.config["selected_format"] = self.selected_format
|
self.config["selected_format"] = self.selected_format
|
||||||
self.config["use_silent_gaps"] = self.use_silent_gaps
|
self.config["use_silent_gaps"] = self.use_silent_gaps
|
||||||
self.config["subtitle_speed_method"] = self.subtitle_speed_method
|
self.config["subtitle_speed_method"] = self.subtitle_speed_method
|
||||||
|
# Word substitution settings
|
||||||
|
self.config["word_substitutions_enabled"] = self.word_substitutions_enabled
|
||||||
|
self.config["word_substitutions_list"] = self.word_substitutions_list
|
||||||
|
self.config["case_sensitive_substitutions"] = self.case_sensitive_substitutions
|
||||||
|
self.config["replace_all_caps"] = self.replace_all_caps
|
||||||
|
self.config["replace_numerals"] = self.replace_numerals
|
||||||
|
self.config["fix_nonstandard_punctuation"] = self.fix_nonstandard_punctuation
|
||||||
|
|
||||||
# Sync Voice/Profile in config
|
# Sync Voice/Profile in config
|
||||||
self.config["selected_voice"] = self.selected_voice
|
self.config["selected_voice"] = self.selected_voice
|
||||||
@@ -2128,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
|
||||||
|
|
||||||
@@ -2153,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,
|
||||||
@@ -2179,6 +2366,21 @@ class abogen(QWidget):
|
|||||||
self.conversion_thread.subtitle_speed_method = self.subtitle_speed_method
|
self.conversion_thread.subtitle_speed_method = self.subtitle_speed_method
|
||||||
# Pass use_spacy_segmentation setting
|
# Pass use_spacy_segmentation setting
|
||||||
self.conversion_thread.use_spacy_segmentation = self.use_spacy_segmentation
|
self.conversion_thread.use_spacy_segmentation = self.use_spacy_segmentation
|
||||||
|
# Pass word substitution settings
|
||||||
|
self.conversion_thread.word_substitutions_enabled = (
|
||||||
|
self.word_substitutions_enabled
|
||||||
|
)
|
||||||
|
self.conversion_thread.word_substitutions_list = (
|
||||||
|
self.word_substitutions_list
|
||||||
|
)
|
||||||
|
self.conversion_thread.case_sensitive_substitutions = (
|
||||||
|
self.case_sensitive_substitutions
|
||||||
|
)
|
||||||
|
self.conversion_thread.replace_all_caps = self.replace_all_caps
|
||||||
|
self.conversion_thread.replace_numerals = self.replace_numerals
|
||||||
|
self.conversion_thread.fix_nonstandard_punctuation = (
|
||||||
|
self.fix_nonstandard_punctuation
|
||||||
|
)
|
||||||
# Pass separate_chapters_format setting
|
# Pass separate_chapters_format setting
|
||||||
self.conversion_thread.separate_chapters_format = (
|
self.conversion_thread.separate_chapters_format = (
|
||||||
self.separate_chapters_format
|
self.separate_chapters_format
|
||||||
@@ -2223,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()
|
||||||
@@ -2660,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)
|
||||||
@@ -2709,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)
|
||||||
@@ -2927,6 +3151,41 @@ class abogen(QWidget):
|
|||||||
self.config["use_gpu"] = self.use_gpu
|
self.config["use_gpu"] = self.use_gpu
|
||||||
save_config(self.config)
|
save_config(self.config)
|
||||||
|
|
||||||
|
def on_word_sub_changed(self, text):
|
||||||
|
"""Handle word substitution dropdown change."""
|
||||||
|
self.word_substitutions_enabled = text == "Enabled"
|
||||||
|
self.btn_word_sub_settings.setEnabled(self.word_substitutions_enabled)
|
||||||
|
|
||||||
|
# Save to config
|
||||||
|
self.config["word_substitutions_enabled"] = self.word_substitutions_enabled
|
||||||
|
save_config(self.config)
|
||||||
|
|
||||||
|
def show_word_sub_dialog(self):
|
||||||
|
"""Show word substitutions settings dialog."""
|
||||||
|
dialog = WordSubstitutionsDialog(
|
||||||
|
self,
|
||||||
|
initial_list=self.word_substitutions_list,
|
||||||
|
initial_case_sensitive=self.case_sensitive_substitutions,
|
||||||
|
initial_caps=self.replace_all_caps,
|
||||||
|
initial_numerals=self.replace_numerals,
|
||||||
|
initial_punctuation=self.fix_nonstandard_punctuation,
|
||||||
|
)
|
||||||
|
|
||||||
|
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||||
|
self.word_substitutions_list = dialog.get_substitutions_list()
|
||||||
|
self.case_sensitive_substitutions = dialog.get_case_sensitive()
|
||||||
|
self.replace_all_caps = dialog.get_replace_all_caps()
|
||||||
|
self.replace_numerals = dialog.get_replace_numerals()
|
||||||
|
self.fix_nonstandard_punctuation = dialog.get_fix_nonstandard_punctuation()
|
||||||
|
|
||||||
|
# Save all settings to config
|
||||||
|
self.config["word_substitutions_list"] = self.word_substitutions_list
|
||||||
|
self.config["case_sensitive_substitutions"] = self.case_sensitive_substitutions
|
||||||
|
self.config["replace_all_caps"] = self.replace_all_caps
|
||||||
|
self.config["replace_numerals"] = self.replace_numerals
|
||||||
|
self.config["fix_nonstandard_punctuation"] = self.fix_nonstandard_punctuation
|
||||||
|
save_config(self.config)
|
||||||
|
|
||||||
def cleanup_conversion_thread(self):
|
def cleanup_conversion_thread(self):
|
||||||
# Stop conversion thread
|
# Stop conversion thread
|
||||||
if (
|
if (
|
||||||
@@ -2991,8 +3250,6 @@ class abogen(QWidget):
|
|||||||
"""Show dialog to ask user about chapter processing options when chapters are detected in a .txt file"""
|
"""Show dialog to ask user about chapter processing options when chapters are detected in a .txt file"""
|
||||||
# Check if this is a timestamp detection (-1) or chapter detection
|
# Check if this is a timestamp detection (-1) or chapter detection
|
||||||
if chapter_count == -1:
|
if chapter_count == -1:
|
||||||
from abogen.conversion import TimestampDetectionDialog
|
|
||||||
|
|
||||||
dialog = TimestampDetectionDialog(parent=self)
|
dialog = TimestampDetectionDialog(parent=self)
|
||||||
dialog.setWindowModality(Qt.WindowModality.ApplicationModal)
|
dialog.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||||
|
|
||||||
@@ -3007,8 +3264,6 @@ class abogen(QWidget):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Normal chapter detection
|
# Normal chapter detection
|
||||||
from abogen.conversion import ChapterOptionsDialog
|
|
||||||
|
|
||||||
dialog = ChapterOptionsDialog(chapter_count, parent=self)
|
dialog = ChapterOptionsDialog(chapter_count, parent=self)
|
||||||
dialog.setWindowModality(Qt.WindowModality.ApplicationModal)
|
dialog.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ from PyQt6.QtWidgets import (
|
|||||||
)
|
)
|
||||||
from PyQt6.QtCore import QThread, pyqtSignal
|
from PyQt6.QtCore import QThread, pyqtSignal
|
||||||
|
|
||||||
from abogen.constants import COLORS, VOICES_INTERNAL
|
from abogen.constants import COLORS
|
||||||
|
from abogen.tts_backend_registry import get_metadata
|
||||||
from abogen.spacy_utils import SPACY_MODELS
|
from abogen.spacy_utils import SPACY_MODELS
|
||||||
import abogen.hf_tracker
|
import abogen.hf_tracker
|
||||||
|
|
||||||
@@ -114,7 +115,7 @@ class PreDownloadWorker(QThread):
|
|||||||
self._voices_success = False
|
self._voices_success = False
|
||||||
return
|
return
|
||||||
|
|
||||||
voice_list = VOICES_INTERNAL
|
voice_list = get_metadata("kokoro").voices
|
||||||
for idx, voice in enumerate(voice_list, start=1):
|
for idx, voice in enumerate(voice_list, start=1):
|
||||||
if self._cancelled:
|
if self._cancelled:
|
||||||
self._voices_success = False
|
self._voices_success = False
|
||||||
@@ -462,14 +463,14 @@ class PreDownloadDialog(QDialog):
|
|||||||
try:
|
try:
|
||||||
from huggingface_hub import try_to_load_from_cache
|
from huggingface_hub import try_to_load_from_cache
|
||||||
|
|
||||||
for voice in VOICES_INTERNAL:
|
for voice in get_metadata("kokoro").voices:
|
||||||
if not try_to_load_from_cache(
|
if not try_to_load_from_cache(
|
||||||
repo_id="hexgrad/Kokoro-82M", filename=f"voices/{voice}.pt"
|
repo_id="hexgrad/Kokoro-82M", filename=f"voices/{voice}.pt"
|
||||||
):
|
):
|
||||||
missing.append(voice)
|
missing.append(voice)
|
||||||
except Exception:
|
except Exception:
|
||||||
# If HF missing, report all as missing
|
# If HF missing, report all as missing
|
||||||
return False, list(VOICES_INTERNAL)
|
return False, list(get_metadata("kokoro").voices)
|
||||||
return (len(missing) == 0), missing
|
return (len(missing) == 0), missing
|
||||||
|
|
||||||
def _check_kokoro_model(self) -> bool:
|
def _check_kokoro_model(self) -> bool:
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ OVERRIDE_FIELDS = [
|
|||||||
"replace_single_newlines",
|
"replace_single_newlines",
|
||||||
"use_silent_gaps",
|
"use_silent_gaps",
|
||||||
"subtitle_speed_method",
|
"subtitle_speed_method",
|
||||||
|
"word_substitutions_enabled",
|
||||||
|
"word_substitutions_list",
|
||||||
|
"case_sensitive_substitutions",
|
||||||
|
"replace_all_caps",
|
||||||
|
"replace_numerals",
|
||||||
|
"fix_nonstandard_punctuation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -474,6 +480,21 @@ class QueueManager(QDialog):
|
|||||||
attrs["subtitle_speed_method"] = getattr(
|
attrs["subtitle_speed_method"] = getattr(
|
||||||
parent, "subtitle_speed_method", "tts"
|
parent, "subtitle_speed_method", "tts"
|
||||||
)
|
)
|
||||||
|
# word substitutions
|
||||||
|
attrs["word_substitutions_enabled"] = getattr(
|
||||||
|
parent, "word_substitutions_enabled", False
|
||||||
|
)
|
||||||
|
attrs["word_substitutions_list"] = getattr(
|
||||||
|
parent, "word_substitutions_list", ""
|
||||||
|
)
|
||||||
|
attrs["case_sensitive_substitutions"] = getattr(
|
||||||
|
parent, "case_sensitive_substitutions", False
|
||||||
|
)
|
||||||
|
attrs["replace_all_caps"] = getattr(parent, "replace_all_caps", False)
|
||||||
|
attrs["replace_numerals"] = getattr(parent, "replace_numerals", False)
|
||||||
|
attrs["fix_nonstandard_punctuation"] = getattr(
|
||||||
|
parent, "fix_nonstandard_punctuation", False
|
||||||
|
)
|
||||||
# book handler options
|
# book handler options
|
||||||
attrs["save_chapters_separately"] = getattr(
|
attrs["save_chapters_separately"] = getattr(
|
||||||
parent, "save_chapters_separately", None
|
parent, "save_chapters_separately", None
|
||||||
|
|||||||
@@ -19,3 +19,10 @@ class QueuedItem:
|
|||||||
save_base_path: str = None
|
save_base_path: str = None
|
||||||
save_chapters_separately: bool = None
|
save_chapters_separately: bool = None
|
||||||
merge_chapters_at_end: bool = None
|
merge_chapters_at_end: bool = None
|
||||||
|
# Word Substitution fields
|
||||||
|
word_substitutions_enabled: bool = False
|
||||||
|
word_substitutions_list: str = ""
|
||||||
|
case_sensitive_substitutions: bool = False
|
||||||
|
replace_all_caps: bool = False
|
||||||
|
replace_numerals: bool = False
|
||||||
|
fix_nonstandard_punctuation: bool = False
|
||||||
|
|||||||
@@ -28,11 +28,11 @@ from PyQt6.QtWidgets import (
|
|||||||
from PyQt6.QtCore import Qt, QTimer, QPoint, QRect, QSize
|
from PyQt6.QtCore import Qt, QTimer, QPoint, QRect, QSize
|
||||||
from PyQt6.QtGui import QPixmap, QIcon, QAction
|
from PyQt6.QtGui import QPixmap, QIcon, QAction
|
||||||
from abogen.constants import (
|
from abogen.constants import (
|
||||||
VOICES_INTERNAL,
|
|
||||||
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
||||||
LANGUAGE_DESCRIPTIONS,
|
LANGUAGE_DESCRIPTIONS,
|
||||||
COLORS,
|
COLORS,
|
||||||
)
|
)
|
||||||
|
from abogen.tts_backend_registry import get_metadata
|
||||||
import re
|
import re
|
||||||
import platform
|
import platform
|
||||||
from abogen.utils import get_resource_path
|
from abogen.utils import get_resource_path
|
||||||
@@ -179,7 +179,7 @@ class VoiceMixer(QWidget):
|
|||||||
layout.addWidget(QLabel(name), alignment=Qt.AlignmentFlag.AlignCenter)
|
layout.addWidget(QLabel(name), alignment=Qt.AlignmentFlag.AlignCenter)
|
||||||
|
|
||||||
# Voice name label with gender icon
|
# Voice name label with gender icon
|
||||||
is_female = self.voice_name in VOICES_INTERNAL and self.voice_name[1] == "f"
|
is_female = self.voice_name in get_metadata("kokoro").voices and self.voice_name[1] == "f"
|
||||||
|
|
||||||
# Icons layout (flag and gender)
|
# Icons layout (flag and gender)
|
||||||
icons_layout = QHBoxLayout()
|
icons_layout = QHBoxLayout()
|
||||||
@@ -772,7 +772,7 @@ class VoiceFormulaDialog(QDialog):
|
|||||||
|
|
||||||
def add_voices(self, initial_state):
|
def add_voices(self, initial_state):
|
||||||
first_enabled_voice = None
|
first_enabled_voice = None
|
||||||
for voice in VOICES_INTERNAL:
|
for voice in get_metadata("kokoro").voices:
|
||||||
language_code = voice[0] # First character is the language code
|
language_code = voice[0] # First character is the language code
|
||||||
matching_voice = next(
|
matching_voice = next(
|
||||||
(item for item in initial_state if item[0] == voice), None
|
(item for item in initial_state if item[0] == voice), None
|
||||||
|
|||||||
+125
-2
@@ -15,6 +15,8 @@ _ASS_STYLING_PATTERN = re.compile(r"\{[^}]+\}")
|
|||||||
_ASS_NEWLINE_N_PATTERN = re.compile(r"\\N")
|
_ASS_NEWLINE_N_PATTERN = re.compile(r"\\N")
|
||||||
_ASS_NEWLINE_LOWER_N_PATTERN = re.compile(r"\\n")
|
_ASS_NEWLINE_LOWER_N_PATTERN = re.compile(r"\\n")
|
||||||
_CHAPTER_MARKER_SEARCH_PATTERN = re.compile(r"<<CHAPTER_MARKER:(.*?)>>")
|
_CHAPTER_MARKER_SEARCH_PATTERN = re.compile(r"<<CHAPTER_MARKER:(.*?)>>")
|
||||||
|
_VOICE_MARKER_PATTERN = re.compile(r"<<VOICE:[^>]*>>")
|
||||||
|
_VOICE_MARKER_SEARCH_PATTERN = re.compile(r"<<VOICE:(.*?)>>")
|
||||||
_WEBVTT_HEADER_PATTERN = re.compile(r"^WEBVTT.*?\n", re.MULTILINE)
|
_WEBVTT_HEADER_PATTERN = re.compile(r"^WEBVTT.*?\n", re.MULTILINE)
|
||||||
_VTT_STYLE_PATTERN = re.compile(r"STYLE\s*\n.*?(?=\n\n|$)", re.DOTALL)
|
_VTT_STYLE_PATTERN = re.compile(r"STYLE\s*\n.*?(?=\n\n|$)", re.DOTALL)
|
||||||
_VTT_NOTE_PATTERN = re.compile(r"NOTE\s*\n.*?(?=\n\n|$)", re.DOTALL)
|
_VTT_NOTE_PATTERN = re.compile(r"NOTE\s*\n.*?(?=\n\n|$)", re.DOTALL)
|
||||||
@@ -31,17 +33,19 @@ _LINUX_ILLEGAL_CHARS_PATTERN = re.compile(r"[/\x00]")
|
|||||||
|
|
||||||
|
|
||||||
def clean_subtitle_text(text):
|
def clean_subtitle_text(text):
|
||||||
"""Remove chapter markers and metadata tags from subtitle text."""
|
"""Remove chapter markers, voice markers, and metadata tags from subtitle text."""
|
||||||
# Use pre-compiled patterns for better performance
|
# Use pre-compiled patterns for better performance
|
||||||
text = _METADATA_TAG_PATTERN.sub("", text)
|
text = _METADATA_TAG_PATTERN.sub("", text)
|
||||||
text = _CHAPTER_MARKER_PATTERN.sub("", text)
|
text = _CHAPTER_MARKER_PATTERN.sub("", text)
|
||||||
|
text = _VOICE_MARKER_PATTERN.sub("", text)
|
||||||
return text.strip()
|
return text.strip()
|
||||||
|
|
||||||
|
|
||||||
def calculate_text_length(text):
|
def calculate_text_length(text):
|
||||||
# Use pre-compiled patterns for better performance
|
# Use pre-compiled patterns for better performance
|
||||||
# Ignore chapter markers and metadata patterns in a single pass
|
# Ignore chapter markers, voice markers, and metadata patterns in a single pass
|
||||||
text = _CHAPTER_MARKER_PATTERN.sub("", text)
|
text = _CHAPTER_MARKER_PATTERN.sub("", text)
|
||||||
|
text = _VOICE_MARKER_PATTERN.sub("", text)
|
||||||
text = _METADATA_TAG_PATTERN.sub("", text)
|
text = _METADATA_TAG_PATTERN.sub("", text)
|
||||||
# Ignore newlines and leading/trailing spaces
|
# Ignore newlines and leading/trailing spaces
|
||||||
text = text.replace("\n", "").strip()
|
text = text.replace("\n", "").strip()
|
||||||
@@ -459,3 +463,122 @@ def sanitize_name_for_os(name, is_folder=True):
|
|||||||
sanitized = sanitized[:255].rstrip(". ")
|
sanitized = sanitized[:255].rstrip(". ")
|
||||||
|
|
||||||
return sanitized
|
return sanitized
|
||||||
|
|
||||||
|
|
||||||
|
def validate_voice_name(voice_name):
|
||||||
|
"""Validate voice name against available voices (case-insensitive).
|
||||||
|
Handles both single voices and formulas like 'af_heart*0.5 + am_echo*0.5'.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
voice_name: Voice name or formula string to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid, invalid_voice_name):
|
||||||
|
- is_valid: True if all voices in the name/formula are valid
|
||||||
|
- invalid_voice_name: The first invalid voice found, or None if all valid
|
||||||
|
"""
|
||||||
|
from abogen.tts_backend_registry import get_metadata
|
||||||
|
|
||||||
|
# Create case-insensitive lookup set (done once per call)
|
||||||
|
voice_lookup_lower = {v.lower() for v in get_metadata("kokoro").voices}
|
||||||
|
voice_name = voice_name.strip()
|
||||||
|
|
||||||
|
# Check if it's a formula (contains *)
|
||||||
|
if "*" in voice_name:
|
||||||
|
# Extract voice names from formula
|
||||||
|
voices = voice_name.split("+")
|
||||||
|
for term in voices:
|
||||||
|
if "*" in term:
|
||||||
|
base_voice = term.split("*")[0].strip()
|
||||||
|
# Case-insensitive comparison
|
||||||
|
if base_voice.lower() not in voice_lookup_lower:
|
||||||
|
return False, base_voice
|
||||||
|
return True, None
|
||||||
|
else:
|
||||||
|
# Single voice - case-insensitive comparison
|
||||||
|
if voice_name.lower() not in voice_lookup_lower:
|
||||||
|
return False, voice_name
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
|
||||||
|
def split_text_by_voice_markers(text, default_voice):
|
||||||
|
"""Split text by voice markers, returning list of (voice, text) tuples.
|
||||||
|
|
||||||
|
IMPORTANT: Returns the last voice used so it can persist across chapters.
|
||||||
|
Voice names are normalized to lowercase to match canonical voice names.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Text potentially containing <<VOICE:name>> markers
|
||||||
|
default_voice: Voice to use if no markers found or before first marker
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (segments_list, last_voice_used, valid_count, invalid_count):
|
||||||
|
- segments_list: List of (voice_name, segment_text) tuples
|
||||||
|
- last_voice_used: The voice that should continue into next chapter
|
||||||
|
- valid_count: Number of valid voice markers processed
|
||||||
|
- invalid_count: Number of invalid voice markers skipped
|
||||||
|
"""
|
||||||
|
from abogen.tts_backend_registry import get_metadata
|
||||||
|
|
||||||
|
voice_splits = list(_VOICE_MARKER_SEARCH_PATTERN.finditer(text))
|
||||||
|
|
||||||
|
if not voice_splits:
|
||||||
|
# No voice markers, return entire text with default voice
|
||||||
|
return [(default_voice, text)], default_voice, 0, 0
|
||||||
|
|
||||||
|
segments = []
|
||||||
|
current_voice = default_voice
|
||||||
|
valid_markers = 0
|
||||||
|
invalid_markers = 0
|
||||||
|
|
||||||
|
# Text before first marker uses default voice
|
||||||
|
first_start = voice_splits[0].start()
|
||||||
|
if first_start > 0:
|
||||||
|
intro_text = text[:first_start].strip()
|
||||||
|
if intro_text:
|
||||||
|
segments.append((current_voice, intro_text))
|
||||||
|
|
||||||
|
# Process each voice marker
|
||||||
|
for idx, match in enumerate(voice_splits):
|
||||||
|
voice_name = match.group(1).strip()
|
||||||
|
start = match.end()
|
||||||
|
end = voice_splits[idx + 1].start() if idx + 1 < len(voice_splits) else len(text)
|
||||||
|
segment_text = text[start:end].strip()
|
||||||
|
|
||||||
|
# Validate voice name
|
||||||
|
is_valid, invalid_voice = validate_voice_name(voice_name)
|
||||||
|
if is_valid:
|
||||||
|
# Normalize to lowercase to match canonical form
|
||||||
|
# Handle both single voices and formulas
|
||||||
|
if "*" in voice_name:
|
||||||
|
# Normalize each voice in the formula
|
||||||
|
normalized_parts = []
|
||||||
|
for part in voice_name.split("+"):
|
||||||
|
part = part.strip()
|
||||||
|
if "*" in part:
|
||||||
|
voice_part, weight = part.split("*", 1)
|
||||||
|
# Find the canonical (lowercase) voice name
|
||||||
|
voice_part_lower = voice_part.strip().lower()
|
||||||
|
canonical_voice = next(
|
||||||
|
(v for v in get_metadata("kokoro").voices if v.lower() == voice_part_lower),
|
||||||
|
voice_part.strip()
|
||||||
|
)
|
||||||
|
normalized_parts.append(f"{canonical_voice}*{weight.strip()}")
|
||||||
|
current_voice = " + ".join(normalized_parts)
|
||||||
|
else:
|
||||||
|
# Find the canonical (lowercase) voice name
|
||||||
|
voice_name_lower = voice_name.lower()
|
||||||
|
current_voice = next(
|
||||||
|
(v for v in get_metadata("kokoro").voices if v.lower() == voice_name_lower),
|
||||||
|
voice_name
|
||||||
|
)
|
||||||
|
valid_markers += 1
|
||||||
|
else:
|
||||||
|
# Invalid voice - stay with previous voice
|
||||||
|
invalid_markers += 1
|
||||||
|
|
||||||
|
if segment_text:
|
||||||
|
segments.append((current_voice, segment_text))
|
||||||
|
|
||||||
|
# Return segments, last voice, and counts
|
||||||
|
return segments, current_voice, valid_markers, invalid_markers
|
||||||
|
|||||||
@@ -1023,8 +1023,13 @@ class EpubExtractor:
|
|||||||
if not html:
|
if not html:
|
||||||
return ""
|
return ""
|
||||||
soup = BeautifulSoup(html, "html.parser")
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
for tag in soup.find_all(["p", "div"]):
|
|
||||||
|
# Add line breaks after block-level elements to ensure pauses in speech
|
||||||
|
for tag in soup.find_all(
|
||||||
|
["p", "div", "h1", "h2", "h3", "h4", "h5", "h6", "li", "blockquote"]
|
||||||
|
):
|
||||||
tag.append("\n\n")
|
tag.append("\n\n")
|
||||||
|
|
||||||
for ol in soup.find_all("ol"):
|
for ol in soup.find_all("ol"):
|
||||||
start_attr = ol.get("start")
|
start_attr = ol.get("start")
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""
|
||||||
|
TTS Backend Interface
|
||||||
|
|
||||||
|
This module defines the protocol for TTS backends and the
|
||||||
|
metadata model that describes a backend implementation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Protocol, List, Dict, Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TTSBackendMetadata:
|
||||||
|
"""
|
||||||
|
Immutable metadata describing a TTS backend implementation.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
voices: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
class TTSBackend(Protocol):
|
||||||
|
"""
|
||||||
|
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:
|
||||||
|
"""
|
||||||
|
Initialize the TTS backend.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
**kwargs: Backend-specific configuration parameters
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def synthesize(self, text: str, **kwargs) -> bytes:
|
||||||
|
"""
|
||||||
|
Synthesize speech from text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Text to synthesize
|
||||||
|
**kwargs: Additional parameters for synthesis
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Audio data as bytes
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_available_voices(self) -> List[str]:
|
||||||
|
"""
|
||||||
|
Get list of available voices.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of voice identifiers
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_supported_formats(self) -> List[str]:
|
||||||
|
"""
|
||||||
|
Get list of supported audio formats.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of supported audio formats
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_info(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get backend information.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with backend information
|
||||||
|
"""
|
||||||
|
...
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"""
|
||||||
|
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 is_registered(self, backend_id: str) -> bool:
|
||||||
|
"""Return True if a backend with the given id is registered."""
|
||||||
|
return backend_id in self._backends
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
def resolve_backend_for_voice(
|
||||||
|
self,
|
||||||
|
spec: str,
|
||||||
|
fallback: str = "kokoro",
|
||||||
|
) -> str:
|
||||||
|
"""Determine which backend owns the given voice specification.
|
||||||
|
|
||||||
|
Resolution rules:
|
||||||
|
1. Empty spec -> fallback
|
||||||
|
2. Kokoro formula (contains '*' or '+') -> "kokoro"
|
||||||
|
3. Exact voice ID match against registered backends -> backend 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()
|
||||||
|
for metadata in self._backends.values():
|
||||||
|
if upper in metadata.voices:
|
||||||
|
return metadata.id
|
||||||
|
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
_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)
|
||||||
|
|
||||||
|
|
||||||
|
def is_registered_backend(backend_id: str) -> bool:
|
||||||
|
"""Return True if *backend_id* is a registered TTS backend."""
|
||||||
|
import abogen.tts_backends # noqa: F401 — triggers backend registration
|
||||||
|
return _registry.is_registered(backend_id)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_backend_for_voice(
|
||||||
|
spec: str,
|
||||||
|
fallback: str = "kokoro",
|
||||||
|
) -> str:
|
||||||
|
"""Determine which backend owns the given voice specification.
|
||||||
|
|
||||||
|
Ensures all backends are registered by importing the tts_backends
|
||||||
|
package on first access.
|
||||||
|
|
||||||
|
Resolution rules:
|
||||||
|
1. Empty spec -> fallback
|
||||||
|
2. Kokoro formula (contains '*' or '+') -> "kokoro"
|
||||||
|
3. Exact voice ID match against registered backends -> backend id
|
||||||
|
4. Unknown voice -> fallback
|
||||||
|
"""
|
||||||
|
import abogen.tts_backends # noqa: F401 — triggers backend registration
|
||||||
|
return _registry.resolve_backend_for_voice(spec, fallback=fallback)
|
||||||
@@ -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()
|
||||||
|
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
"""
|
||||||
|
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.tts_backend import TTSBackendMetadata
|
||||||
|
|
||||||
|
# Internal voice list — source of truth for Kokoro voices.
|
||||||
|
# The rest of the project accesses voices via get_metadata("kokoro").voices.
|
||||||
|
_VOICES_INTERNAL = [
|
||||||
|
"af_alloy",
|
||||||
|
"af_aoede",
|
||||||
|
"af_bella",
|
||||||
|
"af_heart",
|
||||||
|
"af_jessica",
|
||||||
|
"af_kore",
|
||||||
|
"af_nicole",
|
||||||
|
"af_nova",
|
||||||
|
"af_river",
|
||||||
|
"af_sarah",
|
||||||
|
"af_sky",
|
||||||
|
"am_adam",
|
||||||
|
"am_echo",
|
||||||
|
"am_eric",
|
||||||
|
"am_fenrir",
|
||||||
|
"am_liam",
|
||||||
|
"am_michael",
|
||||||
|
"am_onyx",
|
||||||
|
"am_puck",
|
||||||
|
"am_santa",
|
||||||
|
"bf_alice",
|
||||||
|
"bf_emma",
|
||||||
|
"bf_isabella",
|
||||||
|
"bf_lily",
|
||||||
|
"bm_daniel",
|
||||||
|
"bm_fable",
|
||||||
|
"bm_george",
|
||||||
|
"bm_lewis",
|
||||||
|
"ef_dora",
|
||||||
|
"em_alex",
|
||||||
|
"em_santa",
|
||||||
|
"ff_siwis",
|
||||||
|
"hf_alpha",
|
||||||
|
"hf_beta",
|
||||||
|
"hm_omega",
|
||||||
|
"hm_psi",
|
||||||
|
"if_sara",
|
||||||
|
"im_nicola",
|
||||||
|
"jf_alpha",
|
||||||
|
"jf_gongitsune",
|
||||||
|
"jf_nezumi",
|
||||||
|
"jf_tebukuro",
|
||||||
|
"jm_kumo",
|
||||||
|
"pf_dora",
|
||||||
|
"pm_alex",
|
||||||
|
"pm_santa",
|
||||||
|
"zf_xiaobei",
|
||||||
|
"zf_xiaoni",
|
||||||
|
"zf_xiaoxiao",
|
||||||
|
"zf_xiaoyi",
|
||||||
|
"zm_yunjian",
|
||||||
|
"zm_yunxi",
|
||||||
|
"zm_yunxia",
|
||||||
|
"zm_yunyang",
|
||||||
|
]
|
||||||
|
|
||||||
|
_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
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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.
|
||||||
|
"""
|
||||||
@@ -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, is_registered_backend
|
||||||
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:
|
||||||
@@ -101,7 +101,7 @@ def normalize_profile_entry(entry: Any) -> Dict[str, Any]:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
provider = str(entry.get("provider") or "kokoro").strip().lower()
|
provider = str(entry.get("provider") or "kokoro").strip().lower()
|
||||||
if provider not in {"kokoro", "supertonic"}:
|
if not is_registered_backend(provider):
|
||||||
provider = "kokoro"
|
provider = "kokoro"
|
||||||
|
|
||||||
language = str(entry.get("language") or "a").strip().lower() or "a"
|
language = str(entry.get("language") or "a").strip().lower() or "a"
|
||||||
@@ -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,7 +2,6 @@ FROM nvidia/cuda:12.6.3-cudnn-runtime-ubuntu22.04
|
|||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
PIP_NO_CACHE_DIR=1 \
|
|
||||||
VIRTUAL_ENV=/opt/venv \
|
VIRTUAL_ENV=/opt/venv \
|
||||||
PATH=/opt/venv/bin:$PATH
|
PATH=/opt/venv/bin:$PATH
|
||||||
|
|
||||||
@@ -27,22 +26,22 @@ 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 uv \
|
||||||
|
|
||||||
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"; \
|
uv pip install --system torch=="$TORCH_VERSION" torchvision=="$TORCH_VERSION" torchaudio=="$TORCH_VERSION" --index-url "$TORCH_INDEX_URL"; \
|
||||||
else \
|
else \
|
||||||
pip install torch torchvision torchaudio --index-url "$TORCH_INDEX_URL"; \
|
uv pip install --system torch torchvision torchaudio --index-url "$TORCH_INDEX_URL"; \
|
||||||
fi \
|
fi \
|
||||||
&& pip install --no-cache-dir . \
|
&& uv pip install --system . \
|
||||||
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"
|
&& uv pip install --system "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 \
|
||||||
pip install --no-cache-dir onnxruntime-gpu; \
|
uv pip install --system onnxruntime-gpu; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
ENV ABOGEN_HOST=0.0.0.0 \
|
ENV ABOGEN_HOST=0.0.0.0 \
|
||||||
|
|||||||
@@ -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, is_registered_backend, resolve_backend_for_voice
|
||||||
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,14 +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_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
|
||||||
|
|
||||||
@@ -56,25 +57,26 @@ SAMPLE_RATE = 24000
|
|||||||
|
|
||||||
|
|
||||||
def _supertonic_voice_from_spec(spec: Any, fallback: str) -> str:
|
def _supertonic_voice_from_spec(spec: Any, fallback: str) -> str:
|
||||||
|
"""Normalize a voice specification for Supertonic.
|
||||||
|
|
||||||
|
This function only performs Supertonic-specific normalization (uppercase conversion
|
||||||
|
and fallback handling). Backend resolution is handled by the registry.
|
||||||
|
"""
|
||||||
raw = str(spec or "").strip()
|
raw = str(spec or "").strip()
|
||||||
fallback_raw = str(fallback or "").strip()
|
fallback_raw = str(fallback or "").strip()
|
||||||
|
|
||||||
# SuperTonic voices are discrete IDs (M1/F3/...). If we see a Kokoro mix
|
# Normalize to uppercase for Supertonic voice IDs
|
||||||
# formula (contains '*' or '+'), ignore it and fall back to a safe voice.
|
upper = raw.upper() if raw else ""
|
||||||
if not raw or "*" in raw or "+" in raw:
|
|
||||||
raw = fallback_raw
|
|
||||||
if not raw or "*" in raw or "+" in raw:
|
|
||||||
raw = "M1"
|
|
||||||
|
|
||||||
upper = raw.upper()
|
# If empty or contains formula characters, use fallback
|
||||||
if upper in DEFAULT_SUPERTONIC_VOICES:
|
if not upper or "*" in upper or "+" in upper:
|
||||||
return upper
|
upper = fallback_raw.upper() if fallback_raw else ""
|
||||||
|
|
||||||
fallback_upper = fallback_raw.upper() if fallback_raw else ""
|
# If still empty, use default Supertonic voice
|
||||||
if fallback_upper in DEFAULT_SUPERTONIC_VOICES:
|
if not upper or "*" in upper or "+" in upper:
|
||||||
return fallback_upper
|
upper = "M1"
|
||||||
|
|
||||||
return "M1"
|
return upper
|
||||||
|
|
||||||
|
|
||||||
def _split_speaker_reference(value: Any) -> tuple[Optional[str], str]:
|
def _split_speaker_reference(value: Any) -> tuple[Optional[str], str]:
|
||||||
@@ -118,15 +120,7 @@ def _formula_from_kokoro_entry(entry: Mapping[str, Any]) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _infer_provider_from_spec(value: Any, fallback: str = "kokoro") -> str:
|
def _infer_provider_from_spec(value: Any, fallback: str = "kokoro") -> str:
|
||||||
raw = str(value or "").strip()
|
return resolve_backend_for_voice(str(value or ""), fallback=fallback)
|
||||||
if not raw:
|
|
||||||
return fallback
|
|
||||||
upper = raw.upper()
|
|
||||||
if upper in DEFAULT_SUPERTONIC_VOICES:
|
|
||||||
return "supertonic"
|
|
||||||
if "*" in raw or "+" in raw:
|
|
||||||
return "kokoro"
|
|
||||||
return fallback
|
|
||||||
|
|
||||||
|
|
||||||
class _JobCancelled(Exception):
|
class _JobCancelled(Exception):
|
||||||
@@ -575,7 +569,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()
|
||||||
|
|
||||||
@@ -639,7 +633,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
|
||||||
|
|
||||||
|
|
||||||
@@ -1573,7 +1567,7 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
def get_pipeline(provider: str) -> Any:
|
def get_pipeline(provider: str) -> Any:
|
||||||
nonlocal kokoro_cache_ready
|
nonlocal kokoro_cache_ready
|
||||||
provider_norm = str(provider or "kokoro").strip().lower() or "kokoro"
|
provider_norm = str(provider or "kokoro").strip().lower() or "kokoro"
|
||||||
if provider_norm not in {"kokoro", "supertonic"}:
|
if not is_registered_backend(provider_norm):
|
||||||
provider_norm = "kokoro"
|
provider_norm = "kokoro"
|
||||||
|
|
||||||
existing = pipelines.get(provider_norm)
|
existing = pipelines.get(provider_norm)
|
||||||
@@ -1581,7 +1575,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),
|
||||||
@@ -1594,16 +1589,12 @@ 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()
|
||||||
_np, KPipeline = load_numpy_kpipeline()
|
# Create KPipeline instance directly (conforms to TTSBackend protocol)
|
||||||
# Try to initialize with the selected device; fall back to CPU if CUDA fails
|
pipelines[provider_norm] = create_backend(
|
||||||
try:
|
"kokoro",
|
||||||
pipelines[provider_norm] = KPipeline(lang_code=job.language, repo_id="hexgrad/Kokoro-82M", device=device)
|
lang_code=job.language,
|
||||||
except RuntimeError as e:
|
device=device
|
||||||
if "CUDA" in str(e) and device != "cpu":
|
)
|
||||||
job.add_log(f"CUDA initialization failed, falling back to CPU: {e}", level="warning")
|
|
||||||
pipelines[provider_norm] = KPipeline(lang_code=job.language, repo_id="hexgrad/Kokoro-82M", device="cpu")
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
if not kokoro_cache_ready:
|
if not kokoro_cache_ready:
|
||||||
_initialize_voice_cache(job)
|
_initialize_voice_cache(job)
|
||||||
kokoro_cache_ready = True
|
kokoro_cache_ready = True
|
||||||
@@ -1644,8 +1635,8 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
return provider, resolved, cached, speed, steps
|
return provider, resolved, cached, speed, steps
|
||||||
|
|
||||||
if provider == "kokoro":
|
if provider == "kokoro":
|
||||||
kokoro_pipeline = get_pipeline("kokoro")
|
kokoro_backend = get_pipeline("kokoro")
|
||||||
choice = _resolve_voice(kokoro_pipeline, resolved, job.use_gpu)
|
choice = _resolve_voice(kokoro_backend, resolved, job.use_gpu)
|
||||||
else:
|
else:
|
||||||
choice = resolved
|
choice = resolved
|
||||||
|
|
||||||
@@ -1774,8 +1765,8 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
voice_cache: Dict[str, Any] = {}
|
voice_cache: Dict[str, Any] = {}
|
||||||
base_provider, base_voice_resolved, _, _ = resolve_voice_target(base_voice_spec)
|
base_provider, base_voice_resolved, _, _ = resolve_voice_target(base_voice_spec)
|
||||||
if base_provider == "kokoro" and base_voice_resolved and "*" not in base_voice_resolved:
|
if base_provider == "kokoro" and base_voice_resolved and "*" not in base_voice_resolved:
|
||||||
kokoro_pipeline = get_pipeline("kokoro")
|
kokoro_backend = get_pipeline("kokoro")
|
||||||
voice_cache[f"kokoro:{base_voice_resolved}"] = _resolve_voice(kokoro_pipeline, base_voice_resolved, job.use_gpu)
|
voice_cache[f"kokoro:{base_voice_resolved}"] = _resolve_voice(kokoro_backend, base_voice_resolved, job.use_gpu)
|
||||||
processed_chars = 0
|
processed_chars = 0
|
||||||
subtitle_index = 1
|
subtitle_index = 1
|
||||||
current_time = 0.0
|
current_time = 0.0
|
||||||
@@ -1805,8 +1796,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(
|
||||||
@@ -1860,8 +1851,8 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
total_steps=int(supertonic_steps_override if supertonic_steps_override is not None else getattr(job, "supertonic_total_steps", 5)),
|
total_steps=int(supertonic_steps_override if supertonic_steps_override is not None else getattr(job, "supertonic_total_steps", 5)),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
kokoro_pipeline = get_pipeline("kokoro")
|
kokoro_backend = get_pipeline("kokoro")
|
||||||
segment_iter = kokoro_pipeline(
|
segment_iter = kokoro_backend(
|
||||||
normalized,
|
normalized,
|
||||||
voice=voice_choice,
|
voice=voice_choice,
|
||||||
speed=float(speed_override if speed_override is not None else job.speed),
|
speed=float(speed_override if speed_override is not None else job.speed),
|
||||||
@@ -1950,8 +1941,8 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
if chapter_provider == "kokoro":
|
if chapter_provider == "kokoro":
|
||||||
voice_choice = voice_cache.get(chapter_cache_key)
|
voice_choice = voice_cache.get(chapter_cache_key)
|
||||||
if voice_choice is None:
|
if voice_choice is None:
|
||||||
kokoro_pipeline = get_pipeline("kokoro")
|
kokoro_backend = get_pipeline("kokoro")
|
||||||
voice_choice = _resolve_voice(kokoro_pipeline, chapter_voice_resolved, job.use_gpu)
|
voice_choice = _resolve_voice(kokoro_backend, chapter_voice_resolved, job.use_gpu)
|
||||||
voice_cache[chapter_cache_key] = voice_choice
|
voice_cache[chapter_cache_key] = voice_choice
|
||||||
else:
|
else:
|
||||||
voice_choice = chapter_voice_resolved
|
voice_choice = chapter_voice_resolved
|
||||||
@@ -2095,9 +2086,9 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
if chunk_provider == "kokoro":
|
if chunk_provider == "kokoro":
|
||||||
chunk_voice_choice = voice_cache.get(chunk_cache_key)
|
chunk_voice_choice = voice_cache.get(chunk_cache_key)
|
||||||
if chunk_voice_choice is None:
|
if chunk_voice_choice is None:
|
||||||
kokoro_pipeline = get_pipeline("kokoro")
|
kokoro_backend = get_pipeline("kokoro")
|
||||||
chunk_voice_choice = _resolve_voice(
|
chunk_voice_choice = _resolve_voice(
|
||||||
kokoro_pipeline,
|
kokoro_backend,
|
||||||
chunk_voice_resolved,
|
chunk_voice_resolved,
|
||||||
job.use_gpu,
|
job.use_gpu,
|
||||||
)
|
)
|
||||||
@@ -2239,8 +2230,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
|
||||||
@@ -2445,7 +2436,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),
|
||||||
@@ -2454,8 +2446,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:
|
||||||
|
|||||||
@@ -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]]:
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ from abogen.normalization_settings import (
|
|||||||
)
|
)
|
||||||
from abogen.llm_client import list_models, LLMClientError
|
from abogen.llm_client import list_models, LLMClientError
|
||||||
from abogen.kokoro_text_normalization import normalize_for_pipeline
|
from abogen.kokoro_text_normalization import normalize_for_pipeline
|
||||||
|
from abogen.tts_backend_registry import is_registered_backend
|
||||||
from abogen.integrations.audiobookshelf import AudiobookshelfClient, AudiobookshelfConfig
|
from abogen.integrations.audiobookshelf import AudiobookshelfClient, AudiobookshelfConfig
|
||||||
from abogen.integrations.calibre_opds import (
|
from abogen.integrations.calibre_opds import (
|
||||||
CalibreOPDSClient,
|
CalibreOPDSClient,
|
||||||
@@ -63,7 +64,7 @@ def api_save_voice_profile() -> ResponseReturnValue:
|
|||||||
if profile is None:
|
if profile is None:
|
||||||
# Speaker Studio payload format
|
# Speaker Studio payload format
|
||||||
provider = str(payload.get("provider") or "kokoro").strip().lower()
|
provider = str(payload.get("provider") or "kokoro").strip().lower()
|
||||||
if provider not in {"kokoro", "supertonic"}:
|
if not is_registered_backend(provider):
|
||||||
provider = "kokoro"
|
provider = "kokoro"
|
||||||
if provider == "supertonic":
|
if provider == "supertonic":
|
||||||
profile = {
|
profile = {
|
||||||
@@ -230,7 +231,7 @@ def api_speaker_preview() -> ResponseReturnValue:
|
|||||||
use_gpu = settings.get("use_gpu", False)
|
use_gpu = settings.get("use_gpu", False)
|
||||||
|
|
||||||
base_spec, speaker_name = split_profile_spec(voice)
|
base_spec, speaker_name = split_profile_spec(voice)
|
||||||
resolved_provider = tts_provider if tts_provider in {"kokoro", "supertonic"} else ""
|
resolved_provider = tts_provider if is_registered_backend(tts_provider) else ""
|
||||||
|
|
||||||
if speaker_name:
|
if speaker_name:
|
||||||
entry = normalize_profile_entry(load_profiles().get(speaker_name))
|
entry = normalize_profile_entry(load_profiles().get(speaker_name))
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from flask.typing import ResponseReturnValue
|
|||||||
|
|
||||||
from abogen.webui.service import PendingJob, JobStatus
|
from abogen.webui.service import PendingJob, JobStatus
|
||||||
from abogen.webui.routes.utils.service import get_service
|
from abogen.webui.routes.utils.service import get_service
|
||||||
|
from abogen.tts_backend_registry import is_registered_backend
|
||||||
from abogen.webui.routes.utils.settings import (
|
from abogen.webui.routes.utils.settings import (
|
||||||
load_settings,
|
load_settings,
|
||||||
coerce_bool,
|
coerce_bool,
|
||||||
@@ -32,7 +33,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
|
||||||
@@ -579,7 +580,7 @@ def apply_book_step_form(
|
|||||||
# spec (e.g. "speaker:Name" for saved speakers, or a Kokoro mix formula).
|
# spec (e.g. "speaker:Name" for saved speakers, or a Kokoro mix formula).
|
||||||
# This enables mixed-provider conversions (e.g. narrator=SuperTonic, characters=Kokoro).
|
# This enables mixed-provider conversions (e.g. narrator=SuperTonic, characters=Kokoro).
|
||||||
provider_value = str(form.get("tts_provider") or "").strip().lower()
|
provider_value = str(form.get("tts_provider") or "").strip().lower()
|
||||||
if provider_value in {"kokoro", "supertonic"}:
|
if is_registered_backend(provider_value):
|
||||||
pending.tts_provider = provider_value
|
pending.tts_provider = provider_value
|
||||||
|
|
||||||
# Determine the base speaker selection (saved speaker ref or raw voice).
|
# Determine the base speaker selection (saved speaker ref or raw voice).
|
||||||
@@ -616,8 +617,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 +797,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
|
||||||
|
|
||||||
|
|||||||
@@ -17,10 +17,43 @@ _preview_pipeline_lock = threading.Lock()
|
|||||||
def _select_device() -> str:
|
def _select_device() -> str:
|
||||||
import platform
|
import platform
|
||||||
|
|
||||||
|
try:
|
||||||
|
import torch # type: ignore[import-not-found]
|
||||||
|
except Exception:
|
||||||
|
return "cpu"
|
||||||
|
|
||||||
system = platform.system()
|
system = platform.system()
|
||||||
if system == "Darwin" and platform.processor() == "arm":
|
if system == "Darwin" and platform.processor() == "arm":
|
||||||
return "mps"
|
try:
|
||||||
return "cuda"
|
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:
|
def _to_float32(audio_segment) -> np.ndarray:
|
||||||
@@ -45,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
|
||||||
|
|
||||||
@@ -104,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,
|
||||||
@@ -115,15 +147,7 @@ def generate_preview_audio(
|
|||||||
total_steps=supertonic_total_steps,
|
total_steps=supertonic_total_steps,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
device = "cpu"
|
pipeline, pipeline_uses_gpu = _resolve_pipeline(language, use_gpu)
|
||||||
if use_gpu:
|
|
||||||
try:
|
|
||||||
device = _select_device()
|
|
||||||
except Exception:
|
|
||||||
device = "cpu"
|
|
||||||
use_gpu = False
|
|
||||||
|
|
||||||
pipeline = get_preview_pipeline(language, device)
|
|
||||||
if pipeline is None:
|
if pipeline is None:
|
||||||
raise RuntimeError("Preview pipeline is unavailable")
|
raise RuntimeError("Preview pipeline is unavailable")
|
||||||
|
|
||||||
@@ -131,7 +155,7 @@ def generate_preview_audio(
|
|||||||
if voice_spec and "*" in voice_spec:
|
if voice_spec and "*" in voice_spec:
|
||||||
from abogen.voice_formulas import get_new_voice
|
from abogen.voice_formulas import get_new_voice
|
||||||
|
|
||||||
voice_choice = get_new_voice(pipeline, voice_spec, use_gpu)
|
voice_choice = get_new_voice(pipeline, voice_spec, pipeline_uses_gpu)
|
||||||
|
|
||||||
segments = pipeline(
|
segments = pipeline(
|
||||||
normalized_text,
|
normalized_text,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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__)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
"""
|
||||||
|
Word substitution module for text-to-speech preprocessing.
|
||||||
|
|
||||||
|
This module provides functionality to:
|
||||||
|
- Replace words/phrases with custom text
|
||||||
|
- Convert ALL CAPS to lowercase
|
||||||
|
- Convert numerals to words
|
||||||
|
- Fix nonstandard punctuation for TTS compatibility
|
||||||
|
|
||||||
|
All substitutions preserve special markers (chapter, voice, metadata, timestamps).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from abogen.subtitle_utils import (
|
||||||
|
_CHAPTER_MARKER_PATTERN,
|
||||||
|
_VOICE_MARKER_PATTERN,
|
||||||
|
_METADATA_TAG_PATTERN,
|
||||||
|
_TIMESTAMP_ONLY_PATTERN,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_word_substitutions(
|
||||||
|
text,
|
||||||
|
substitutions_list_str,
|
||||||
|
case_sensitive=False,
|
||||||
|
replace_all_caps=False,
|
||||||
|
replace_numerals=False,
|
||||||
|
fix_nonstandard_punctuation=False,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Apply word substitutions to text while preserving markers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Input text
|
||||||
|
substitutions_list_str: Newline-separated "Word|NewWord" pairs
|
||||||
|
case_sensitive: If True, match words case-sensitively
|
||||||
|
replace_all_caps: Convert ALL CAPS words to lowercase
|
||||||
|
replace_numerals: Convert numbers to words
|
||||||
|
fix_nonstandard_punctuation: Fix curly quotes, em/en dashes, etc.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Modified text
|
||||||
|
"""
|
||||||
|
# Apply nonstandard punctuation fixes FIRST (if enabled)
|
||||||
|
if fix_nonstandard_punctuation:
|
||||||
|
text = fix_punctuation(text)
|
||||||
|
|
||||||
|
# Parse substitutions list
|
||||||
|
substitutions = parse_substitutions_list(substitutions_list_str)
|
||||||
|
|
||||||
|
# Split text into segments (markers vs content)
|
||||||
|
segments = split_text_preserving_markers(text)
|
||||||
|
|
||||||
|
# Process each segment
|
||||||
|
processed_segments = []
|
||||||
|
for segment_type, segment_text in segments:
|
||||||
|
if segment_type == "marker":
|
||||||
|
# Preserve markers unchanged
|
||||||
|
processed_segments.append(segment_text)
|
||||||
|
else:
|
||||||
|
# Apply substitutions to content
|
||||||
|
processed_text = segment_text
|
||||||
|
|
||||||
|
# Apply word substitutions
|
||||||
|
if substitutions:
|
||||||
|
processed_text = apply_word_replacements(
|
||||||
|
processed_text, substitutions, case_sensitive
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply ALL CAPS conversion
|
||||||
|
if replace_all_caps:
|
||||||
|
processed_text = convert_all_caps_to_lowercase(processed_text)
|
||||||
|
|
||||||
|
# Apply numeral conversion
|
||||||
|
if replace_numerals:
|
||||||
|
processed_text = convert_numerals_to_words(processed_text)
|
||||||
|
|
||||||
|
processed_segments.append(processed_text)
|
||||||
|
|
||||||
|
return "".join(processed_segments)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_substitutions_list(substitutions_str):
|
||||||
|
"""
|
||||||
|
Parse newline-separated "Word|NewWord" format.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
substitutions_str: String with substitutions, one per line
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of tuples: [(word, replacement), ...]
|
||||||
|
"""
|
||||||
|
substitutions = []
|
||||||
|
for line in substitutions_str.strip().split("\n"):
|
||||||
|
line = line.strip()
|
||||||
|
if not line or "|" not in line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
parts = line.split("|", 1)
|
||||||
|
if len(parts) == 2:
|
||||||
|
word = parts[0].strip()
|
||||||
|
replacement = parts[1].strip()
|
||||||
|
if word: # Only add if word is not empty
|
||||||
|
substitutions.append((word, replacement))
|
||||||
|
|
||||||
|
return substitutions
|
||||||
|
|
||||||
|
|
||||||
|
def split_text_preserving_markers(text):
|
||||||
|
"""
|
||||||
|
Split text into segments alternating between markers and content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Input text with potential markers
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of tuples: [("marker"|"content", text), ...]
|
||||||
|
"""
|
||||||
|
# Combined pattern for all markers and timestamps
|
||||||
|
marker_pattern = re.compile(
|
||||||
|
r"(<<CHAPTER_MARKER:[^>]*>>|<<VOICE:[^>]*>>|<<METADATA_[^:]+:[^>]*>>|\d{1,2}:\d{2}:\d{2}(?:[.,]\d{1,3})?)"
|
||||||
|
)
|
||||||
|
|
||||||
|
segments = []
|
||||||
|
last_end = 0
|
||||||
|
|
||||||
|
for match in marker_pattern.finditer(text):
|
||||||
|
# Content before marker
|
||||||
|
if match.start() > last_end:
|
||||||
|
segments.append(("content", text[last_end : match.start()]))
|
||||||
|
|
||||||
|
# Marker itself
|
||||||
|
segments.append(("marker", match.group(0)))
|
||||||
|
last_end = match.end()
|
||||||
|
|
||||||
|
# Remaining content after last marker
|
||||||
|
if last_end < len(text):
|
||||||
|
segments.append(("content", text[last_end:]))
|
||||||
|
|
||||||
|
return segments
|
||||||
|
|
||||||
|
|
||||||
|
def apply_word_replacements(text, substitutions, case_sensitive=False):
|
||||||
|
"""
|
||||||
|
Apply word substitutions using whole-word matching.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Input text
|
||||||
|
substitutions: List of (word, replacement) tuples
|
||||||
|
case_sensitive: If True, match case-sensitively
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Text with substitutions applied
|
||||||
|
"""
|
||||||
|
for word, replacement in substitutions:
|
||||||
|
# Use word boundaries for exact matching
|
||||||
|
# Escape special regex characters
|
||||||
|
escaped_word = re.escape(word)
|
||||||
|
pattern = re.compile(
|
||||||
|
r"\b" + escaped_word + r"\b",
|
||||||
|
0 if case_sensitive else re.IGNORECASE,
|
||||||
|
)
|
||||||
|
text = pattern.sub(replacement, text)
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def convert_all_caps_to_lowercase(text):
|
||||||
|
"""
|
||||||
|
Convert ALL CAPS words to lowercase.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Input text
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Text with ALL CAPS converted to lowercase
|
||||||
|
"""
|
||||||
|
|
||||||
|
def replace_caps(match):
|
||||||
|
word = match.group(0)
|
||||||
|
# Convert to lowercase
|
||||||
|
return word.lower()
|
||||||
|
|
||||||
|
# Match words that are ALL CAPS (2+ letters)
|
||||||
|
pattern = re.compile(r"\b[A-Z]{2,}\b")
|
||||||
|
return pattern.sub(replace_caps, text)
|
||||||
|
|
||||||
|
|
||||||
|
def convert_numerals_to_words(text):
|
||||||
|
"""
|
||||||
|
Convert numerals to words using num2words library.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Input text
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Text with numerals converted to words
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from num2words import num2words
|
||||||
|
except ImportError:
|
||||||
|
# If num2words not available, return unchanged
|
||||||
|
return text
|
||||||
|
|
||||||
|
def replace_number(match):
|
||||||
|
try:
|
||||||
|
number = int(match.group(0))
|
||||||
|
# Convert to words in English
|
||||||
|
return num2words(number)
|
||||||
|
except Exception:
|
||||||
|
# If conversion fails, return original
|
||||||
|
return match.group(0)
|
||||||
|
|
||||||
|
# Match integers (but not timestamps or other patterns)
|
||||||
|
# Negative lookbehind/ahead to avoid timestamps
|
||||||
|
pattern = re.compile(r"(?<!\d:)\b\d+\b(?!:\d)")
|
||||||
|
return pattern.sub(replace_number, text)
|
||||||
|
|
||||||
|
|
||||||
|
def fix_punctuation(text):
|
||||||
|
"""
|
||||||
|
Convert nonstandard punctuation to standard equivalents.
|
||||||
|
|
||||||
|
This helps TTS engines pronounce words correctly by converting:
|
||||||
|
- Curly quotes to straight quotes
|
||||||
|
- Ellipsis to three periods
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Input text
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Text with nonstandard punctuation fixed
|
||||||
|
"""
|
||||||
|
# Define replacements
|
||||||
|
replacements = {
|
||||||
|
# Curly double quotes
|
||||||
|
"\u201c": '"', # Left double quotation mark
|
||||||
|
"\u201d": '"', # Right double quotation mark
|
||||||
|
"\u201e": '"', # Double low-9 quotation mark
|
||||||
|
# Curly single quotes
|
||||||
|
"\u2018": "'", # Left single quotation mark
|
||||||
|
"\u2019": "'", # Right single quotation mark
|
||||||
|
"\u201a": "'", # Single low-9 quotation mark
|
||||||
|
"\u201b": "'", # Single high-reversed-9 quotation mark
|
||||||
|
# Other punctuation
|
||||||
|
"\u2026": "...", # Ellipsis
|
||||||
|
}
|
||||||
|
|
||||||
|
# Apply all replacements
|
||||||
|
for old_char, new_char in replacements.items():
|
||||||
|
text = text.replace(old_char, new_char)
|
||||||
|
|
||||||
|
return text
|
||||||
@@ -96,8 +96,6 @@ exclude = [
|
|||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["abogen"]
|
packages = ["abogen"]
|
||||||
|
|
||||||
[tool.hatch.build]
|
|
||||||
include = ["abogen/webui/templates/**", "abogen/webui/static/**"]
|
|
||||||
|
|
||||||
[tool.hatch.version]
|
[tool.hatch.version]
|
||||||
path = "abogen/VERSION"
|
path = "abogen/VERSION"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
from abogen.constants import VOICES_INTERNAL
|
from abogen.tts_backend_registry import get_metadata
|
||||||
from abogen.webui.conversion_runner import (
|
from abogen.webui.conversion_runner import (
|
||||||
_chapter_voice_spec,
|
_chapter_voice_spec,
|
||||||
_chunk_voice_spec,
|
_chunk_voice_spec,
|
||||||
@@ -49,4 +49,4 @@ def test_voice_collection_includes_formula_components():
|
|||||||
voices = _collect_required_voice_ids(job)
|
voices = _collect_required_voice_ids(job)
|
||||||
|
|
||||||
assert {"af_nova", "am_liam"}.issubset(voices)
|
assert {"af_nova", "am_liam"}.issubset(voices)
|
||||||
assert voices.issuperset(VOICES_INTERNAL)
|
assert voices.issuperset(get_metadata("kokoro").voices)
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ def test_epub3_preserves_original_whitespace(tmp_path) -> None:
|
|||||||
)
|
)
|
||||||
assert match is not None
|
assert match is not None
|
||||||
original_text = html.unescape(match.group(1))
|
original_text = html.unescape(match.group(1))
|
||||||
assert "Second line\n\nThird paragraph." in original_text
|
assert "Second line\n\nThird paragraph." in original_text.replace("\r\n", "\n")
|
||||||
|
|
||||||
|
|
||||||
def test_epub3_sentence_chunks_render_as_paragraphs(tmp_path) -> None:
|
def test_epub3_sentence_chunks_render_as_paragraphs(tmp_path) -> None:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveBackendForVoice:
|
||||||
|
"""Tests for the resolve_backend_for_voice method."""
|
||||||
|
|
||||||
|
def test_empty_spec_returns_fallback(self):
|
||||||
|
registry = TTSBackendRegistry()
|
||||||
|
assert registry.resolve_backend_for_voice("", fallback="kokoro") == "kokoro"
|
||||||
|
assert registry.resolve_backend_for_voice("", fallback="supertonic") == "supertonic"
|
||||||
|
|
||||||
|
def test_none_spec_returns_fallback(self):
|
||||||
|
registry = TTSBackendRegistry()
|
||||||
|
assert registry.resolve_backend_for_voice(None, fallback="kokoro") == "kokoro"
|
||||||
|
|
||||||
|
def test_kokoro_formula_with_star_returns_kokoro(self):
|
||||||
|
registry = TTSBackendRegistry()
|
||||||
|
assert registry.resolve_backend_for_voice("af_nova*0.7") == "kokoro"
|
||||||
|
|
||||||
|
def test_kokoro_formula_with_plus_returns_kokoro(self):
|
||||||
|
registry = TTSBackendRegistry()
|
||||||
|
assert registry.resolve_backend_for_voice("af_nova*0.7+am_liam*0.3") == "kokoro"
|
||||||
|
|
||||||
|
def test_kokoro_voice_id_resolves_to_kokoro(self):
|
||||||
|
registry = TTSBackendRegistry()
|
||||||
|
meta = TTSBackendMetadata(
|
||||||
|
id="kokoro",
|
||||||
|
name="Kokoro",
|
||||||
|
description="Kokoro TTS",
|
||||||
|
voices=("af_nova", "am_liam"),
|
||||||
|
)
|
||||||
|
registry.register(metadata=meta, factory=lambda: None)
|
||||||
|
|
||||||
|
assert registry.resolve_backend_for_voice("af_nova") == "kokoro"
|
||||||
|
assert registry.resolve_backend_for_voice("am_liam") == "kokoro"
|
||||||
|
|
||||||
|
def test_supertonic_voice_id_resolves_to_supertonic(self):
|
||||||
|
registry = TTSBackendRegistry()
|
||||||
|
meta = TTSBackendMetadata(
|
||||||
|
id="supertonic",
|
||||||
|
name="SuperTonic",
|
||||||
|
description="SuperTonic TTS",
|
||||||
|
voices=("M1", "M2", "F1", "F2"),
|
||||||
|
)
|
||||||
|
registry.register(metadata=meta, factory=lambda: None)
|
||||||
|
|
||||||
|
assert registry.resolve_backend_for_voice("M1") == "supertonic"
|
||||||
|
assert registry.resolve_backend_for_voice("F2") == "supertonic"
|
||||||
|
|
||||||
|
def test_unknown_voice_returns_fallback(self):
|
||||||
|
registry = TTSBackendRegistry()
|
||||||
|
meta = TTSBackendMetadata(
|
||||||
|
id="kokoro",
|
||||||
|
name="Kokoro",
|
||||||
|
description="Kokoro TTS",
|
||||||
|
voices=("af_nova",),
|
||||||
|
)
|
||||||
|
registry.register(metadata=meta, factory=lambda: None)
|
||||||
|
|
||||||
|
assert registry.resolve_backend_for_voice("unknown_voice") == "kokoro"
|
||||||
|
assert registry.resolve_backend_for_voice("unknown_voice", fallback="supertonic") == "supertonic"
|
||||||
|
|
||||||
|
def test_case_insensitive_matching(self):
|
||||||
|
registry = TTSBackendRegistry()
|
||||||
|
meta = TTSBackendMetadata(
|
||||||
|
id="supertonic",
|
||||||
|
name="SuperTonic",
|
||||||
|
description="SuperTonic TTS",
|
||||||
|
voices=("M1", "F1"),
|
||||||
|
)
|
||||||
|
registry.register(metadata=meta, factory=lambda: None)
|
||||||
|
|
||||||
|
assert registry.resolve_backend_for_voice("m1") == "supertonic"
|
||||||
|
assert registry.resolve_backend_for_voice("f1") == "supertonic"
|
||||||
|
|
||||||
|
def test_default_fallback_is_kokoro(self):
|
||||||
|
registry = TTSBackendRegistry()
|
||||||
|
assert registry.resolve_backend_for_voice("unknown") == "kokoro"
|
||||||
|
|
||||||
|
def test_multiple_backends_resolution(self):
|
||||||
|
registry = TTSBackendRegistry()
|
||||||
|
kokoro_meta = TTSBackendMetadata(
|
||||||
|
id="kokoro",
|
||||||
|
name="Kokoro",
|
||||||
|
description="Kokoro TTS",
|
||||||
|
voices=("af_nova",),
|
||||||
|
)
|
||||||
|
supertonic_meta = TTSBackendMetadata(
|
||||||
|
id="supertonic",
|
||||||
|
name="SuperTonic",
|
||||||
|
description="SuperTonic TTS",
|
||||||
|
voices=("M1",),
|
||||||
|
)
|
||||||
|
registry.register(metadata=kokoro_meta, factory=lambda: None)
|
||||||
|
registry.register(metadata=supertonic_meta, factory=lambda: None)
|
||||||
|
|
||||||
|
assert registry.resolve_backend_for_voice("af_nova") == "kokoro"
|
||||||
|
assert registry.resolve_backend_for_voice("M1") == "supertonic"
|
||||||
|
|
||||||
|
def test_global_wrapper_resolve_backend_for_voice(self):
|
||||||
|
from abogen.tts_backend_registry import resolve_backend_for_voice
|
||||||
|
|
||||||
|
# Test with empty spec
|
||||||
|
assert resolve_backend_for_voice("") == "kokoro"
|
||||||
|
|
||||||
|
# Test with formula
|
||||||
|
assert resolve_backend_for_voice("af_nova*0.7") == "kokoro"
|
||||||
|
|
||||||
|
# Test with a registered voice
|
||||||
|
assert resolve_backend_for_voice("af_nova") == "kokoro"
|
||||||
|
assert resolve_backend_for_voice("M1") == "supertonic"
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from typing import cast
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from abogen.constants import VOICES_INTERNAL
|
from abogen.tts_backend_registry import get_metadata
|
||||||
from abogen.voice_cache import (
|
from abogen.voice_cache import (
|
||||||
LocalEntryNotFoundError,
|
LocalEntryNotFoundError,
|
||||||
_CACHED_VOICES,
|
_CACHED_VOICES,
|
||||||
@@ -66,4 +66,4 @@ def test_collect_required_voice_ids_includes_all():
|
|||||||
voices = _collect_required_voice_ids(cast(Job, job))
|
voices = _collect_required_voice_ids(cast(Job, job))
|
||||||
|
|
||||||
assert {"af_nova", "am_liam", "am_michael"}.issubset(voices)
|
assert {"af_nova", "am_liam", "am_michael"}.issubset(voices)
|
||||||
assert voices.issuperset(VOICES_INTERNAL)
|
assert voices.issuperset(get_metadata("kokoro").voices)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user