Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
268de365f5 | ||
|
|
e193a686c6 | ||
|
|
e9ce1942fe | ||
|
|
0f0cd86dd4 | ||
|
|
ef0af3e0d7 | ||
|
|
8163666dd9 | ||
|
|
9cfe799a8e | ||
|
|
502a2d5a2e | ||
|
|
08bcb3f492 | ||
|
|
81937b7a40 | ||
|
|
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']
|
||||||
@@ -38,3 +38,4 @@ dist/
|
|||||||
.old/
|
.old/
|
||||||
test_assets/
|
test_assets/
|
||||||
dev_notes/
|
dev_notes/
|
||||||
|
.claude/
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ This method handles everything automatically - installing all dependencies inclu
|
|||||||
#### <b>OPTION 2: Install using uv</b>
|
#### <b>OPTION 2: Install using uv</b>
|
||||||
First, [install uv](https://docs.astral.sh/uv/getting-started/installation/) if you haven't already.
|
First, [install uv](https://docs.astral.sh/uv/getting-started/installation/) if you haven't already.
|
||||||
|
|
||||||
|
The CUDA extras install both GPU-accelerated Kokoro (via PyTorch) and Supertonic (via onnxruntime-gpu).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# For NVIDIA GPUs (CUDA 12.8) - Recommended
|
# For NVIDIA GPUs (CUDA 12.8) - Recommended
|
||||||
uv tool install --python 3.12 abogen[cuda] --extra-index-url https://download.pytorch.org/whl/cu128 --index-strategy unsafe-best-match
|
uv tool install --python 3.12 abogen[cuda] --extra-index-url https://download.pytorch.org/whl/cu128 --index-strategy unsafe-best-match
|
||||||
@@ -65,6 +67,9 @@ venv\Scripts\activate
|
|||||||
# We need to use an older version of PyTorch (2.8.0) until this issue is fixed: https://github.com/pytorch/pytorch/issues/166628
|
# We need to use an older version of PyTorch (2.8.0) until this issue is fixed: https://github.com/pytorch/pytorch/issues/166628
|
||||||
pip install torch==2.8.0+cu128 torchvision==0.23.0+cu128 torchaudio==2.8.0 --index-url https://download.pytorch.org/whl/cu128
|
pip install torch==2.8.0+cu128 torchvision==0.23.0+cu128 torchaudio==2.8.0 --index-url https://download.pytorch.org/whl/cu128
|
||||||
|
|
||||||
|
# Also install onnxruntime-gpu for Supertonic GPU acceleration:
|
||||||
|
pip install onnxruntime-gpu
|
||||||
|
|
||||||
# For AMD GPUs:
|
# For AMD GPUs:
|
||||||
# Not supported yet, because ROCm is not available on Windows. Use Linux if you have AMD GPU.
|
# Not supported yet, because ROCm is not available on Windows. Use Linux if you have AMD GPU.
|
||||||
|
|
||||||
@@ -173,7 +178,7 @@ Abogen offers **two interfaces**, but currently they have different feature sets
|
|||||||
|
|
||||||
| Command | Interface | Features |
|
| Command | Interface | Features |
|
||||||
|---------|-----------|----------|
|
|---------|-----------|----------|
|
||||||
| `abogen` | PyQt6 Desktop GUI | Stable core features |
|
| `abogen` | PyQt6 Desktop GUI | Stable core features + **Supertonic TTS**|
|
||||||
| `abogen-web` | Flask Web UI | Core features + **Supertonic TTS**, **LLM Normalization**, **Audiobookshelf Integration** and more! |
|
| `abogen-web` | Flask Web UI | Core features + **Supertonic TTS**, **LLM Normalization**, **Audiobookshelf Integration** and more! |
|
||||||
|
|
||||||
> **Note:** The Web UI is under active development. We are working to integrate these new features into the PyQt desktop app. until then, the Web UI provides the most feature-rich experience.
|
> **Note:** The Web UI is under active development. We are working to integrate these new features into the PyQt desktop app. until then, the Web UI provides the most feature-rich experience.
|
||||||
|
|||||||
@@ -323,6 +323,13 @@ if /I "%IS_NVIDIA%"=="true" (
|
|||||||
pause
|
pause
|
||||||
exit /b
|
exit /b
|
||||||
)
|
)
|
||||||
|
echo Installing onnxruntime-gpu for Supertonic GPU acceleration...
|
||||||
|
%PYTHON_CONSOLE_PATH% -m uv pip install --system onnxruntime-gpu
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo Failed to install onnxruntime-gpu.
|
||||||
|
pause
|
||||||
|
exit /b
|
||||||
|
)
|
||||||
) else (
|
) else (
|
||||||
echo CUDA is available on NVIDIA GPU.
|
echo CUDA is available on NVIDIA GPU.
|
||||||
)
|
)
|
||||||
@@ -348,6 +355,13 @@ if /I "%IS_NVIDIA%"=="true" (
|
|||||||
pause
|
pause
|
||||||
exit /b
|
exit /b
|
||||||
)
|
)
|
||||||
|
echo Installing onnxruntime-gpu for Supertonic GPU acceleration...
|
||||||
|
%PYTHON_CONSOLE_PATH% -m uv pip install --system onnxruntime-gpu
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo Failed to install onnxruntime-gpu.
|
||||||
|
pause
|
||||||
|
exit /b
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1.3.0
|
1.3.1
|
||||||
|
Before Width: | Height: | Size: 571 B |
|
After Width: | Height: | Size: 992 B |
|
Before Width: | Height: | Size: 786 B |
|
After Width: | Height: | Size: 877 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 832 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 521 B |
|
After Width: | Height: | Size: 868 B |
|
After Width: | Height: | Size: 808 B |
|
Before Width: | Height: | Size: 372 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 883 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 465 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 885 B |
|
Before Width: | Height: | Size: 381 B |
|
After Width: | Height: | Size: 855 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 917 B |
|
Before Width: | Height: | Size: 441 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 856 B |
|
After Width: | Height: | Size: 837 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 875 B |
|
Before Width: | Height: | Size: 617 B |
|
After Width: | Height: | Size: 843 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 875 B |
|
After Width: | Height: | Size: 891 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 851 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 431 B |
@@ -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"):
|
||||||
|
|||||||
@@ -29,6 +29,57 @@ LANGUAGE_DESCRIPTIONS = {
|
|||||||
"z": "Mandarin Chinese",
|
"z": "Mandarin Chinese",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Mapping from Kokoro single-letter language codes to ISO 3166-1 alpha-2 country codes
|
||||||
|
# Used for loading flag icons
|
||||||
|
KOKORO_LANG_TO_COUNTRY = {
|
||||||
|
"a": "us", # American English -> United States
|
||||||
|
"b": "gb", # British English -> United Kingdom
|
||||||
|
"e": "es", # Spanish -> Spain
|
||||||
|
"f": "fr", # French -> France
|
||||||
|
"h": "in", # Hindi -> India
|
||||||
|
"i": "it", # Italian -> Italy
|
||||||
|
"j": "jp", # Japanese -> Japan
|
||||||
|
"p": "br", # Brazilian Portuguese -> Brazil
|
||||||
|
"z": "cn", # Mandarin Chinese -> China
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mapping from Supertonic ISO 639-1 language codes to ISO 3166-1 alpha-2 country codes
|
||||||
|
# Used for loading flag icons in the Supertonic language picker
|
||||||
|
SUPERTONIC_LANG_TO_COUNTRY = {
|
||||||
|
"en": "gb",
|
||||||
|
"ko": "kr",
|
||||||
|
"ja": "jp",
|
||||||
|
"ar": "ae",
|
||||||
|
"bg": "bg",
|
||||||
|
"cs": "cz",
|
||||||
|
"da": "dk",
|
||||||
|
"de": "de",
|
||||||
|
"el": "gr",
|
||||||
|
"es": "es",
|
||||||
|
"et": "ee",
|
||||||
|
"fi": "fi",
|
||||||
|
"fr": "fr",
|
||||||
|
"hi": "in",
|
||||||
|
"hr": "hr",
|
||||||
|
"hu": "hu",
|
||||||
|
"id": "id",
|
||||||
|
"it": "it",
|
||||||
|
"lt": "lt",
|
||||||
|
"lv": "lv",
|
||||||
|
"nl": "nl",
|
||||||
|
"pl": "pl",
|
||||||
|
"pt": "pt",
|
||||||
|
"ro": "ro",
|
||||||
|
"ru": "ru",
|
||||||
|
"sk": "sk",
|
||||||
|
"sl": "si",
|
||||||
|
"sv": "se",
|
||||||
|
"tr": "tr",
|
||||||
|
"uk": "ua",
|
||||||
|
"vi": "vn",
|
||||||
|
"na": "na",
|
||||||
|
}
|
||||||
|
|
||||||
# Supported sound formats
|
# Supported sound formats
|
||||||
SUPPORTED_SOUND_FORMATS = [
|
SUPPORTED_SOUND_FORMATS = [
|
||||||
"wav",
|
"wav",
|
||||||
|
|||||||
@@ -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"]
|
|
||||||
@@ -20,6 +20,7 @@ from abogen.constants import (
|
|||||||
SUPPORTED_SOUND_FORMATS,
|
SUPPORTED_SOUND_FORMATS,
|
||||||
SUPPORTED_SUBTITLE_FORMATS,
|
SUPPORTED_SUBTITLE_FORMATS,
|
||||||
)
|
)
|
||||||
|
from abogen.tts_supertonic import SupertonicPipeline, SUPERTONIC_AVAILABLE_LANGS, DEFAULT_SUPERTONIC_VOICES
|
||||||
from abogen.voice_formulas import get_new_voice
|
from abogen.voice_formulas import get_new_voice
|
||||||
import abogen.hf_tracker as hf_tracker
|
import abogen.hf_tracker as hf_tracker
|
||||||
import static_ffmpeg
|
import static_ffmpeg
|
||||||
@@ -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):
|
||||||
@@ -262,7 +267,10 @@ class ConversionThread(QThread):
|
|||||||
use_gpu=True,
|
use_gpu=True,
|
||||||
from_queue=False,
|
from_queue=False,
|
||||||
save_base_path=None,
|
save_base_path=None,
|
||||||
): # Add use_gpu parameter
|
tts_provider="kokoro",
|
||||||
|
supertonic_language="en",
|
||||||
|
supertonic_total_steps=8,
|
||||||
|
):
|
||||||
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()
|
||||||
@@ -272,6 +280,10 @@ class ConversionThread(QThread):
|
|||||||
self.lang_code = lang_code
|
self.lang_code = lang_code
|
||||||
self.speed = speed
|
self.speed = speed
|
||||||
self.voice = voice
|
self.voice = voice
|
||||||
|
self.tts_provider = tts_provider
|
||||||
|
self.supertonic_language = supertonic_language
|
||||||
|
self.supertonic_total_steps = supertonic_total_steps
|
||||||
|
self.sample_rate = 44100 if tts_provider == "supertonic" else 24000
|
||||||
self.save_option = save_option
|
self.save_option = save_option
|
||||||
self.output_folder = output_folder
|
self.output_folder = output_folder
|
||||||
self.subtitle_mode = subtitle_mode
|
self.subtitle_mode = subtitle_mode
|
||||||
@@ -296,6 +308,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"
|
||||||
@@ -398,6 +435,10 @@ class ConversionThread(QThread):
|
|||||||
)
|
)
|
||||||
self.log_updated.emit(f"- Voice: {self.voice}")
|
self.log_updated.emit(f"- Voice: {self.voice}")
|
||||||
self.log_updated.emit(f"- Speed: {self.speed}")
|
self.log_updated.emit(f"- Speed: {self.speed}")
|
||||||
|
tts_provider_label = self.tts_provider.capitalize()
|
||||||
|
if self.tts_provider == "supertonic":
|
||||||
|
tts_provider_label += f" (lang={self.supertonic_language}, steps={self.supertonic_total_steps})"
|
||||||
|
self.log_updated.emit(f"- TTS Engine: {tts_provider_label}")
|
||||||
self.log_updated.emit(f"- Subtitle mode: {self.subtitle_mode}")
|
self.log_updated.emit(f"- Subtitle mode: {self.subtitle_mode}")
|
||||||
self.log_updated.emit(f"- Output format: {self.output_format}")
|
self.log_updated.emit(f"- Output format: {self.output_format}")
|
||||||
self.log_updated.emit(
|
self.log_updated.emit(
|
||||||
@@ -470,6 +511,13 @@ class ConversionThread(QThread):
|
|||||||
else:
|
else:
|
||||||
device = "cpu"
|
device = "cpu"
|
||||||
|
|
||||||
|
if self.tts_provider == "supertonic":
|
||||||
|
tts = SupertonicPipeline(
|
||||||
|
sample_rate=self.sample_rate,
|
||||||
|
lang=self.supertonic_language,
|
||||||
|
total_steps=self.supertonic_total_steps,
|
||||||
|
)
|
||||||
|
else:
|
||||||
tts = self.KPipeline(
|
tts = self.KPipeline(
|
||||||
lang_code=self.lang_code, repo_id="hexgrad/Kokoro-82M", device=device
|
lang_code=self.lang_code, repo_id="hexgrad/Kokoro-82M", device=device
|
||||||
)
|
)
|
||||||
@@ -524,6 +572,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 +618,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")
|
||||||
@@ -662,7 +766,7 @@ class ConversionThread(QThread):
|
|||||||
merged_out_path = f"{base_filepath_no_ext}.{self.output_format}"
|
merged_out_path = f"{base_filepath_no_ext}.{self.output_format}"
|
||||||
subtitle_entries = []
|
subtitle_entries = []
|
||||||
current_time = 0.0
|
current_time = 0.0
|
||||||
rate = 24000
|
rate = self.sample_rate
|
||||||
subtitle_mode = self.subtitle_mode
|
subtitle_mode = self.subtitle_mode
|
||||||
self.etr_start_time = time.time()
|
self.etr_start_time = time.time()
|
||||||
self.processed_char_count = 0
|
self.processed_char_count = 0
|
||||||
@@ -678,7 +782,7 @@ class ConversionThread(QThread):
|
|||||||
merged_out_file = sf.SoundFile(
|
merged_out_file = sf.SoundFile(
|
||||||
merged_out_path,
|
merged_out_path,
|
||||||
"w",
|
"w",
|
||||||
samplerate=24000,
|
samplerate=self.sample_rate,
|
||||||
channels=1,
|
channels=1,
|
||||||
format=self.output_format,
|
format=self.output_format,
|
||||||
)
|
)
|
||||||
@@ -700,51 +804,7 @@ class ConversionThread(QThread):
|
|||||||
"-f",
|
"-f",
|
||||||
"f32le",
|
"f32le",
|
||||||
"-ar",
|
"-ar",
|
||||||
"24000",
|
str(self.sample_rate),
|
||||||
"-ac",
|
|
||||||
"1",
|
|
||||||
"-i",
|
|
||||||
"pipe:0",
|
|
||||||
]
|
|
||||||
if cover_path and os.path.exists(cover_path):
|
|
||||||
cmd.extend(
|
|
||||||
[
|
|
||||||
"-i",
|
|
||||||
cover_path,
|
|
||||||
"-map",
|
|
||||||
"0:a",
|
|
||||||
"-map",
|
|
||||||
"1",
|
|
||||||
"-c:v",
|
|
||||||
"copy",
|
|
||||||
"-disposition:v",
|
|
||||||
"attached_pic",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
cmd.extend(
|
|
||||||
[
|
|
||||||
"-c:a",
|
|
||||||
"aac",
|
|
||||||
"-q:a",
|
|
||||||
"2",
|
|
||||||
"-movflags",
|
|
||||||
"+faststart+use_metadata_tags",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
cmd += metadata_options
|
|
||||||
cmd.append(merged_out_path)
|
|
||||||
ffmpeg_proc = create_process(cmd, stdin=subprocess.PIPE, text=False)
|
|
||||||
elif self.output_format == "opus":
|
|
||||||
static_ffmpeg.add_paths()
|
|
||||||
cmd = [
|
|
||||||
"ffmpeg",
|
|
||||||
"-y",
|
|
||||||
"-thread_queue_size",
|
|
||||||
"32768",
|
|
||||||
"-f",
|
|
||||||
"f32le",
|
|
||||||
"-ar",
|
|
||||||
"24000",
|
|
||||||
"-ac",
|
"-ac",
|
||||||
"1",
|
"1",
|
||||||
"-i",
|
"-i",
|
||||||
@@ -831,7 +891,7 @@ class ConversionThread(QThread):
|
|||||||
merged_out_path = None
|
merged_out_path = None
|
||||||
subtitle_entries = []
|
subtitle_entries = []
|
||||||
current_time = 0.0
|
current_time = 0.0
|
||||||
rate = 24000
|
rate = self.sample_rate
|
||||||
subtitle_mode = self.subtitle_mode
|
subtitle_mode = self.subtitle_mode
|
||||||
self.etr_start_time = time.time()
|
self.etr_start_time = time.time()
|
||||||
self.processed_char_count = 0
|
self.processed_char_count = 0
|
||||||
@@ -842,7 +902,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 +922,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
|
||||||
@@ -889,7 +944,7 @@ class ConversionThread(QThread):
|
|||||||
chapter_out_file = sf.SoundFile(
|
chapter_out_file = sf.SoundFile(
|
||||||
chapter_out_path,
|
chapter_out_path,
|
||||||
"w",
|
"w",
|
||||||
samplerate=24000,
|
samplerate=self.sample_rate,
|
||||||
channels=1,
|
channels=1,
|
||||||
format=separate_chapters_format,
|
format=separate_chapters_format,
|
||||||
)
|
)
|
||||||
@@ -904,7 +959,7 @@ class ConversionThread(QThread):
|
|||||||
"-f",
|
"-f",
|
||||||
"f32le",
|
"f32le",
|
||||||
"-ar",
|
"-ar",
|
||||||
"24000",
|
str(self.sample_rate),
|
||||||
"-ac",
|
"-ac",
|
||||||
"1",
|
"1",
|
||||||
"-i",
|
"-i",
|
||||||
@@ -986,6 +1041,22 @@ class ConversionThread(QThread):
|
|||||||
chapter_subtitle_path = None
|
chapter_subtitle_path = None
|
||||||
chapter_subtitle_file = None
|
chapter_subtitle_file = None
|
||||||
|
|
||||||
|
|
||||||
|
# Process each voice segment within the chapter
|
||||||
|
for segment_idx, (voice_name, segment_text) in enumerate(voice_segments):
|
||||||
|
# Load voice for this segment (with caching)
|
||||||
|
try:
|
||||||
|
loaded_voice = self.load_voice_cached(voice_name, tts)
|
||||||
|
if segment_idx > 0:
|
||||||
|
voice_display = voice_name if len(voice_name) < 50 else voice_name[:47] + "..."
|
||||||
|
self.log_updated.emit((f" → Voice: {voice_display}", "grey"))
|
||||||
|
except Exception:
|
||||||
|
self.log_updated.emit(
|
||||||
|
(f"⚠ Voice loading error for '{voice_name}', continuing with previous", "orange")
|
||||||
|
)
|
||||||
|
if segment_idx == 0:
|
||||||
|
loaded_voice = self.load_voice_cached(self.voice, tts)
|
||||||
|
|
||||||
# Determine if spaCy segmentation should be used for PRE-TTS segmentation
|
# Determine if spaCy segmentation should be used for PRE-TTS segmentation
|
||||||
# Only non-English languages use spaCy for pre-segmentation
|
# Only non-English languages use spaCy for pre-segmentation
|
||||||
# English uses spaCy only for subtitle generation (post-TTS)
|
# English uses spaCy only for subtitle generation (post-TTS)
|
||||||
@@ -1034,7 +1105,7 @@ class ConversionThread(QThread):
|
|||||||
from abogen.spacy_utils import segment_sentences
|
from abogen.spacy_utils import segment_sentences
|
||||||
|
|
||||||
spacy_sentences = segment_sentences(
|
spacy_sentences = segment_sentences(
|
||||||
chapter_text,
|
segment_text,
|
||||||
self.lang_code,
|
self.lang_code,
|
||||||
log_callback=lambda msg: self.log_updated.emit(msg),
|
log_callback=lambda msg: self.log_updated.emit(msg),
|
||||||
)
|
)
|
||||||
@@ -1060,7 +1131,7 @@ class ConversionThread(QThread):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Process text - either as spaCy sentences or as single text
|
# Process text - either as spaCy sentences or as single text
|
||||||
text_segments = spacy_sentences if spacy_sentences else [chapter_text]
|
text_segments = spacy_sentences if spacy_sentences else [segment_text]
|
||||||
|
|
||||||
# Print active split pattern used by the TTS engine once for this batch
|
# Print active split pattern used by the TTS engine once for this batch
|
||||||
try:
|
try:
|
||||||
@@ -1270,7 +1341,7 @@ class ConversionThread(QThread):
|
|||||||
# 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 * self.sample_rate
|
||||||
) # Silence duration at 24,000 Hz
|
) # Silence duration at 24,000 Hz
|
||||||
silence_audio = self.np.zeros(silence_samples, dtype="float32")
|
silence_audio = self.np.zeros(silence_samples, dtype="float32")
|
||||||
silence_bytes = silence_audio.tobytes()
|
silence_bytes = silence_audio.tobytes()
|
||||||
@@ -1501,7 +1572,7 @@ class ConversionThread(QThread):
|
|||||||
parent_dir, f"{sanitized_base_name}{suffix}"
|
parent_dir, f"{sanitized_base_name}{suffix}"
|
||||||
)
|
)
|
||||||
merged_out_path = f"{base_filepath_no_ext}.{self.output_format}"
|
merged_out_path = f"{base_filepath_no_ext}.{self.output_format}"
|
||||||
rate = 24000
|
rate = self.sample_rate
|
||||||
|
|
||||||
# Setup audio output
|
# Setup audio output
|
||||||
merged_out_file, ffmpeg_proc = None, None
|
merged_out_file, ffmpeg_proc = None, None
|
||||||
@@ -2351,6 +2422,9 @@ class VoicePreviewThread(QThread):
|
|||||||
speed,
|
speed,
|
||||||
use_gpu=False,
|
use_gpu=False,
|
||||||
parent=None,
|
parent=None,
|
||||||
|
tts_provider="kokoro",
|
||||||
|
supertonic_language="en",
|
||||||
|
supertonic_total_steps=8,
|
||||||
):
|
):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.np_module = np_module
|
self.np_module = np_module
|
||||||
@@ -2359,6 +2433,10 @@ class VoicePreviewThread(QThread):
|
|||||||
self.voice = voice
|
self.voice = voice
|
||||||
self.speed = speed
|
self.speed = speed
|
||||||
self.use_gpu = use_gpu
|
self.use_gpu = use_gpu
|
||||||
|
self.tts_provider = tts_provider
|
||||||
|
self.supertonic_language = supertonic_language
|
||||||
|
self.supertonic_total_steps = supertonic_total_steps
|
||||||
|
self.sample_rate = 44100 if tts_provider == "supertonic" else 24000
|
||||||
|
|
||||||
# Cache location for preview audio
|
# Cache location for preview audio
|
||||||
self.cache_dir = get_user_cache_path("preview_cache")
|
self.cache_dir = get_user_cache_path("preview_cache")
|
||||||
@@ -2368,6 +2446,11 @@ class VoicePreviewThread(QThread):
|
|||||||
|
|
||||||
def _get_cache_path(self):
|
def _get_cache_path(self):
|
||||||
"""Generate a unique filename for the voice with its parameters"""
|
"""Generate a unique filename for the voice with its parameters"""
|
||||||
|
if self.tts_provider == "supertonic":
|
||||||
|
voice_id = self.voice or "M1"
|
||||||
|
filename = f"st_{voice_id}_{self.supertonic_language}_steps{self.supertonic_total_steps}_{self.speed:.2f}.wav"
|
||||||
|
return os.path.join(self.cache_dir, filename)
|
||||||
|
|
||||||
# For a voice formula, use a hash of the formula
|
# For a voice formula, use a hash of the formula
|
||||||
if "*" in self.voice:
|
if "*" in self.voice:
|
||||||
voice_id = (
|
voice_id = (
|
||||||
@@ -2382,25 +2465,42 @@ class VoicePreviewThread(QThread):
|
|||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
print(
|
print(
|
||||||
f"\nVoice: {self.voice}\nLanguage: {self.lang_code}\nSpeed: {self.speed}\nGPU: {self.use_gpu}\n"
|
f"\nVoice: {self.voice}\nLanguage: {self.lang_code}\nSpeed: {self.speed}\nGPU: {self.use_gpu}\nTTS Provider: {self.tts_provider}\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Generate the preview and save to cache
|
# Generate the preview and save to cache
|
||||||
try:
|
try:
|
||||||
|
if self.tts_provider == "supertonic":
|
||||||
|
from abogen.tts_supertonic import SupertonicPipeline
|
||||||
|
|
||||||
|
tts = SupertonicPipeline(
|
||||||
|
sample_rate=self.sample_rate,
|
||||||
|
lang=self.supertonic_language,
|
||||||
|
total_steps=self.supertonic_total_steps,
|
||||||
|
)
|
||||||
|
loaded_voice = self.voice or "M1"
|
||||||
|
sample_text = "Hello, this is a sample of the selected voice."
|
||||||
|
audio_segments = []
|
||||||
|
for result in tts(
|
||||||
|
sample_text,
|
||||||
|
voice=loaded_voice,
|
||||||
|
speed=self.speed,
|
||||||
|
split_pattern=None,
|
||||||
|
):
|
||||||
|
audio_segments.append(result.audio)
|
||||||
|
else:
|
||||||
# Set device based on use_gpu setting and platform
|
# Set device based on use_gpu setting and platform
|
||||||
if self.use_gpu:
|
if self.use_gpu:
|
||||||
if platform.system() == "Darwin" and platform.processor() == "arm":
|
if platform.system() == "Darwin" and platform.processor() == "arm":
|
||||||
device = "mps" # Use MPS for Apple Silicon
|
device = "mps"
|
||||||
else:
|
else:
|
||||||
device = "cuda" # Use CUDA for other platforms
|
device = "cuda"
|
||||||
else:
|
else:
|
||||||
device = "cpu"
|
device = "cpu"
|
||||||
|
|
||||||
tts = self.kpipeline_class(
|
tts = self.kpipeline_class(
|
||||||
lang_code=self.lang_code, repo_id="hexgrad/Kokoro-82M", device=device
|
lang_code=self.lang_code, repo_id="hexgrad/Kokoro-82M", device=device
|
||||||
)
|
)
|
||||||
# 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(tts, self.voice, self.use_gpu)
|
||||||
else:
|
else:
|
||||||
@@ -2411,10 +2511,10 @@ class VoicePreviewThread(QThread):
|
|||||||
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 = self.np_module.concatenate(audio_segments)
|
||||||
# Save directly to the cache path
|
sf.write(self.cache_path, audio, self.sample_rate)
|
||||||
sf.write(self.cache_path, audio, 24000)
|
|
||||||
self.temp_wav = self.cache_path
|
self.temp_wav = self.cache_path
|
||||||
self.finished.emit()
|
self.finished.emit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from abogen.pyqt.queue_manager_gui import QueueManager
|
|||||||
from abogen.pyqt.queued_item import QueuedItem
|
from abogen.pyqt.queued_item import QueuedItem
|
||||||
import abogen.hf_tracker as hf_tracker
|
import abogen.hf_tracker as hf_tracker
|
||||||
import hashlib # Added for cache path generation
|
import hashlib # Added for cache path generation
|
||||||
|
from abogen.tts_supertonic import SUPERTONIC_AVAILABLE_LANGS, DEFAULT_SUPERTONIC_VOICES
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QApplication,
|
QApplication,
|
||||||
QWidget,
|
QWidget,
|
||||||
@@ -74,7 +75,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,
|
||||||
@@ -83,6 +84,8 @@ from abogen.constants import (
|
|||||||
PROGRAM_DESCRIPTION,
|
PROGRAM_DESCRIPTION,
|
||||||
LANGUAGE_DESCRIPTIONS,
|
LANGUAGE_DESCRIPTIONS,
|
||||||
VOICES_INTERNAL,
|
VOICES_INTERNAL,
|
||||||
|
KOKORO_LANG_TO_COUNTRY,
|
||||||
|
SUPERTONIC_LANG_TO_COUNTRY,
|
||||||
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
||||||
COLORS,
|
COLORS,
|
||||||
SUBTITLE_FORMATS,
|
SUBTITLE_FORMATS,
|
||||||
@@ -665,6 +668,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 +775,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 +808,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 +960,22 @@ 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.tts_provider_config = self.config.get("tts_provider", "kokoro")
|
||||||
|
self.supertonic_language_config = self.config.get("supertonic_language", "en")
|
||||||
|
self.supertonic_total_steps_config = self.config.get("supertonic_total_steps", 8)
|
||||||
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
|
||||||
|
|
||||||
@@ -881,6 +1024,16 @@ class abogen(QWidget):
|
|||||||
else:
|
else:
|
||||||
self.mixed_voice_state = entry
|
self.mixed_voice_state = entry
|
||||||
self.selected_lang = entry[0][0] if entry and entry[0] else None
|
self.selected_lang = entry[0][0] if entry and entry[0] else None
|
||||||
|
# Restore TTS provider and supertonic settings from config
|
||||||
|
provider_text = "Supertonic" if self.tts_provider_config == "supertonic" else "Kokoro"
|
||||||
|
idx_st = self.provider_combo.findText(provider_text)
|
||||||
|
if idx_st >= 0:
|
||||||
|
self.provider_combo.setCurrentIndex(idx_st)
|
||||||
|
self.st_lang_combo.setCurrentText(self.supertonic_language_config)
|
||||||
|
idx_steps = self.st_steps_combo.findData(self.supertonic_total_steps_config)
|
||||||
|
if idx_steps >= 0:
|
||||||
|
self.st_steps_combo.setCurrentIndex(idx_steps)
|
||||||
|
|
||||||
if self.save_option == "Choose output folder" and self.selected_output_folder:
|
if self.save_option == "Choose output folder" and self.selected_output_folder:
|
||||||
self.save_path_label.setText(self.selected_output_folder)
|
self.save_path_label.setText(self.selected_output_folder)
|
||||||
self.save_path_row_widget.show()
|
self.save_path_row_widget.show()
|
||||||
@@ -970,6 +1123,53 @@ class abogen(QWidget):
|
|||||||
speed_layout.addWidget(self.speed_label)
|
speed_layout.addWidget(self.speed_label)
|
||||||
controls_layout.addLayout(speed_layout)
|
controls_layout.addLayout(speed_layout)
|
||||||
self.speed_slider.valueChanged.connect(self.update_speed_label)
|
self.speed_slider.valueChanged.connect(self.update_speed_label)
|
||||||
|
|
||||||
|
# TTS Provider selection
|
||||||
|
provider_layout = QHBoxLayout()
|
||||||
|
provider_layout.setSpacing(7)
|
||||||
|
provider_label = QLabel("TTS Engine:", self)
|
||||||
|
provider_layout.addWidget(provider_label)
|
||||||
|
self.provider_combo = QComboBox(self)
|
||||||
|
self.provider_combo.addItem("Kokoro", "kokoro")
|
||||||
|
self.provider_combo.addItem("Supertonic", "supertonic")
|
||||||
|
self.provider_combo.setStyleSheet("QComboBox { min-height: 20px; padding: 6px 12px; }")
|
||||||
|
self.provider_combo.currentIndexChanged.connect(self.on_provider_changed)
|
||||||
|
provider_layout.addWidget(self.provider_combo)
|
||||||
|
controls_layout.addLayout(provider_layout)
|
||||||
|
|
||||||
|
# Supertonic-specific controls (language + steps), hidden by default
|
||||||
|
self.supertonic_row = QWidget()
|
||||||
|
supertonic_row_layout = QHBoxLayout(self.supertonic_row)
|
||||||
|
supertonic_row_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
supertonic_row_layout.setSpacing(7)
|
||||||
|
|
||||||
|
st_lang_label = QLabel("Language:", self)
|
||||||
|
supertonic_row_layout.addWidget(st_lang_label)
|
||||||
|
self.st_lang_combo = QComboBox(self)
|
||||||
|
for code in SUPERTONIC_AVAILABLE_LANGS:
|
||||||
|
country_code = SUPERTONIC_LANG_TO_COUNTRY.get(code, code)
|
||||||
|
flag = get_resource_path("abogen.assets.flags", f"{country_code}.png")
|
||||||
|
icon_st = QIcon(flag) if flag and os.path.exists(flag) else QIcon()
|
||||||
|
self.st_lang_combo.addItem(icon_st, code, code)
|
||||||
|
self.st_lang_combo.setCurrentText("en")
|
||||||
|
self.st_lang_combo.setStyleSheet("QComboBox { min-height: 20px; padding: 6px 12px; }")
|
||||||
|
self.st_lang_combo.currentTextChanged.connect(self._on_st_lang_changed)
|
||||||
|
supertonic_row_layout.addWidget(self.st_lang_combo)
|
||||||
|
|
||||||
|
st_steps_label = QLabel("Steps:", self)
|
||||||
|
supertonic_row_layout.addWidget(st_steps_label)
|
||||||
|
self.st_steps_combo = QComboBox(self)
|
||||||
|
for val in range(2, 16):
|
||||||
|
self.st_steps_combo.addItem(str(val), val)
|
||||||
|
self.st_steps_combo.setCurrentIndex(self.st_steps_combo.findData(8))
|
||||||
|
self.st_steps_combo.setStyleSheet("QComboBox { min-height: 20px; padding: 6px 12px; }")
|
||||||
|
self.st_steps_combo.currentIndexChanged.connect(self._on_st_steps_changed)
|
||||||
|
supertonic_row_layout.addWidget(self.st_steps_combo)
|
||||||
|
|
||||||
|
supertonic_row_layout.addStretch()
|
||||||
|
self.supertonic_row.hide()
|
||||||
|
controls_layout.addWidget(self.supertonic_row)
|
||||||
|
|
||||||
# Voice selection
|
# Voice selection
|
||||||
voice_layout = QHBoxLayout()
|
voice_layout = QHBoxLayout()
|
||||||
voice_layout.setSpacing(7)
|
voice_layout.setSpacing(7)
|
||||||
@@ -1071,6 +1271,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)
|
||||||
@@ -1598,6 +1827,12 @@ class abogen(QWidget):
|
|||||||
Update the enabled state of subtitle options based on the selected language.
|
Update the enabled state of subtitle options based on the selected language.
|
||||||
For non-English languages, only sentence-based and line-based modes are supported.
|
For non-English languages, only sentence-based and line-based modes are supported.
|
||||||
"""
|
"""
|
||||||
|
provider = self.provider_combo.currentData()
|
||||||
|
if provider == "supertonic":
|
||||||
|
self.subtitle_combo.setEnabled(False)
|
||||||
|
self.subtitle_format_combo.setEnabled(False)
|
||||||
|
return
|
||||||
|
|
||||||
# Check if current file is a subtitle file
|
# Check if current file is a subtitle file
|
||||||
is_subtitle_input = False
|
is_subtitle_input = False
|
||||||
if self.selected_file and self.selected_file.lower().endswith(
|
if self.selected_file and self.selected_file.lower().endswith(
|
||||||
@@ -1657,6 +1892,48 @@ class abogen(QWidget):
|
|||||||
# Enable/disable subtitle options based on language
|
# Enable/disable subtitle options based on language
|
||||||
self.update_subtitle_options_availability()
|
self.update_subtitle_options_availability()
|
||||||
|
|
||||||
|
def on_provider_changed(self, index):
|
||||||
|
provider = self.provider_combo.itemData(index)
|
||||||
|
self.config["tts_provider"] = provider
|
||||||
|
save_config(self.config)
|
||||||
|
is_supertonic = provider == "supertonic"
|
||||||
|
|
||||||
|
# Show/hide Supertonic controls
|
||||||
|
self.supertonic_row.setVisible(is_supertonic)
|
||||||
|
|
||||||
|
# Update subtitles availability
|
||||||
|
self.update_subtitle_options_availability()
|
||||||
|
|
||||||
|
# Repopulate voice list
|
||||||
|
self.populate_profiles_in_voice_combo()
|
||||||
|
|
||||||
|
# Clear/reset mixed voice state when switching provider
|
||||||
|
if is_supertonic:
|
||||||
|
self.mixed_voice_state = None
|
||||||
|
self.btn_voice_formula_mixer.setEnabled(False)
|
||||||
|
self.voice_combo.setToolTip(
|
||||||
|
"Supertonic voices:\n"
|
||||||
|
"M1-M5 = Male voices\n"
|
||||||
|
"F1-F5 = Female voices"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.btn_voice_formula_mixer.setEnabled(True)
|
||||||
|
self.voice_combo.setToolTip(
|
||||||
|
"The first character represents the language:\n"
|
||||||
|
'"a" => American English\n"b" => British English\n"e" => Spanish\n"f" => French\n"h" => Hindi\n"i" => Italian\n"j" => Japanese\n"p" => Brazilian Portuguese\n"z" => Mandarin Chinese\nThe second character represents the gender:\n"m" => Male\n"f" => Female'
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_st_lang_changed(self, lang):
|
||||||
|
self.config["supertonic_language"] = lang
|
||||||
|
save_config(self.config)
|
||||||
|
if self.provider_combo.currentData() == "supertonic":
|
||||||
|
self.selected_lang = lang
|
||||||
|
self.update_subtitle_options_availability()
|
||||||
|
|
||||||
|
def _on_st_steps_changed(self):
|
||||||
|
self.config["supertonic_total_steps"] = self.st_steps_combo.currentData()
|
||||||
|
save_config(self.config)
|
||||||
|
|
||||||
def on_voice_combo_changed(self, index):
|
def on_voice_combo_changed(self, index):
|
||||||
data = self.voice_combo.itemData(index)
|
data = self.voice_combo.itemData(index)
|
||||||
if isinstance(data, str) and data.startswith("profile:"):
|
if isinstance(data, str) and data.startswith("profile:"):
|
||||||
@@ -1665,8 +1942,24 @@ class abogen(QWidget):
|
|||||||
from abogen.voice_profiles import load_profiles
|
from abogen.voice_profiles import load_profiles
|
||||||
|
|
||||||
entry = load_profiles().get(pname, {})
|
entry = load_profiles().get(pname, {})
|
||||||
# set mixed voices and language
|
|
||||||
if isinstance(entry, dict):
|
if isinstance(entry, dict):
|
||||||
|
entry_provider = str(entry.get("provider", "")).strip().lower()
|
||||||
|
if entry_provider == "supertonic":
|
||||||
|
# Switch provider to Supertonic if not already
|
||||||
|
if self.provider_combo.currentData() != "supertonic":
|
||||||
|
self.provider_combo.setCurrentIndex(1)
|
||||||
|
self.mixed_voice_state = None
|
||||||
|
self.selected_lang = entry.get("language", self.st_lang_combo.currentText())
|
||||||
|
# Sync supertonic controls from profile
|
||||||
|
profile_steps = entry.get("total_steps")
|
||||||
|
if profile_steps is not None:
|
||||||
|
idx_steps = self.st_steps_combo.findData(int(profile_steps))
|
||||||
|
if idx_steps >= 0:
|
||||||
|
self.st_steps_combo.setCurrentIndex(idx_steps)
|
||||||
|
profile_lang = entry.get("language")
|
||||||
|
if profile_lang and profile_lang in SUPERTONIC_AVAILABLE_LANGS:
|
||||||
|
self.st_lang_combo.setCurrentText(profile_lang)
|
||||||
|
else:
|
||||||
self.mixed_voice_state = entry.get("voices", [])
|
self.mixed_voice_state = entry.get("voices", [])
|
||||||
self.selected_lang = entry.get("language")
|
self.selected_lang = entry.get("language")
|
||||||
else:
|
else:
|
||||||
@@ -1681,7 +1974,12 @@ class abogen(QWidget):
|
|||||||
else:
|
else:
|
||||||
self.mixed_voice_state = None
|
self.mixed_voice_state = None
|
||||||
self.selected_profile_name = None
|
self.selected_profile_name = None
|
||||||
self.selected_voice, self.selected_lang = data, data[0]
|
self.selected_voice = data
|
||||||
|
provider = self.provider_combo.currentData()
|
||||||
|
if provider == "supertonic":
|
||||||
|
self.selected_lang = self.st_lang_combo.currentText()
|
||||||
|
else:
|
||||||
|
self.selected_lang = data[0] if data else ""
|
||||||
self.config["selected_voice"] = data
|
self.config["selected_voice"] = data
|
||||||
if "selected_profile_name" in self.config:
|
if "selected_profile_name" in self.config:
|
||||||
del self.config["selected_profile_name"]
|
del self.config["selected_profile_name"]
|
||||||
@@ -1700,16 +1998,37 @@ class abogen(QWidget):
|
|||||||
def populate_profiles_in_voice_combo(self):
|
def populate_profiles_in_voice_combo(self):
|
||||||
# preserve current voice or profile
|
# preserve current voice or profile
|
||||||
current = self.voice_combo.currentData()
|
current = self.voice_combo.currentData()
|
||||||
|
provider = self.provider_combo.currentData()
|
||||||
self.voice_combo.blockSignals(True)
|
self.voice_combo.blockSignals(True)
|
||||||
self.voice_combo.clear()
|
self.voice_combo.clear()
|
||||||
# re-add profiles
|
# re-add profiles matching current provider
|
||||||
profile_icon = QIcon(get_resource_path("abogen.assets", "profile.png"))
|
profile_icon = QIcon(get_resource_path("abogen.assets", "profile.png"))
|
||||||
for pname in load_profiles().keys():
|
for pname, entry in load_profiles().items():
|
||||||
|
entry_provider = ""
|
||||||
|
if isinstance(entry, dict):
|
||||||
|
entry_provider = str(entry.get("provider", "")).strip().lower()
|
||||||
|
if provider == "supertonic":
|
||||||
|
if entry_provider == "supertonic":
|
||||||
|
self.voice_combo.addItem(profile_icon, pname, f"profile:{pname}")
|
||||||
|
else:
|
||||||
|
if entry_provider != "supertonic":
|
||||||
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
|
||||||
|
if provider == "supertonic":
|
||||||
|
for v in DEFAULT_SUPERTONIC_VOICES:
|
||||||
|
icon = QIcon()
|
||||||
|
if v.startswith("F"):
|
||||||
|
icon_path = get_resource_path("abogen.assets", "female.png")
|
||||||
|
else:
|
||||||
|
icon_path = get_resource_path("abogen.assets", "male.png")
|
||||||
|
if icon_path and os.path.exists(icon_path):
|
||||||
|
icon = QIcon(icon_path)
|
||||||
|
self.voice_combo.addItem(icon, f"{v}", v)
|
||||||
|
else:
|
||||||
for v in VOICES_INTERNAL:
|
for v in VOICES_INTERNAL:
|
||||||
icon = QIcon()
|
icon = QIcon()
|
||||||
flag_path = get_resource_path("abogen.assets.flags", f"{v[0]}.png")
|
country_code = KOKORO_LANG_TO_COUNTRY.get(v[0], v[0])
|
||||||
|
flag_path = get_resource_path("abogen.assets.flags", f"{country_code}.png")
|
||||||
if flag_path and os.path.exists(flag_path):
|
if flag_path and os.path.exists(flag_path):
|
||||||
icon = QIcon(flag_path)
|
icon = QIcon(flag_path)
|
||||||
self.voice_combo.addItem(icon, f"{v}", v)
|
self.voice_combo.addItem(icon, f"{v}", v)
|
||||||
@@ -1903,6 +2222,9 @@ class abogen(QWidget):
|
|||||||
save_base_path=save_base_path,
|
save_base_path=save_base_path,
|
||||||
save_chapters_separately=getattr(self, "save_chapters_separately", None),
|
save_chapters_separately=getattr(self, "save_chapters_separately", None),
|
||||||
merge_chapters_at_end=getattr(self, "merge_chapters_at_end", None),
|
merge_chapters_at_end=getattr(self, "merge_chapters_at_end", None),
|
||||||
|
tts_provider=self.provider_combo.currentData(),
|
||||||
|
supertonic_language=self.st_lang_combo.currentText(),
|
||||||
|
supertonic_total_steps=self.st_steps_combo.currentData(),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Prevent adding duplicate items to the queue
|
# Prevent adding duplicate items to the queue
|
||||||
@@ -2015,6 +2337,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 +2360,22 @@ 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
|
||||||
|
|
||||||
|
# TTS provider settings
|
||||||
|
tts_provider = getattr(queued_item, "tts_provider", "kokoro")
|
||||||
|
self.provider_combo.setCurrentText("Supertonic" if tts_provider == "supertonic" else "Kokoro")
|
||||||
|
self.st_lang_combo.setCurrentText(getattr(queued_item, "supertonic_language", "en"))
|
||||||
|
steps_val = getattr(queued_item, "supertonic_total_steps", 8)
|
||||||
|
idx_steps = self.st_steps_combo.findData(steps_val)
|
||||||
|
if idx_steps >= 0:
|
||||||
|
self.st_steps_combo.setCurrentIndex(idx_steps)
|
||||||
|
|
||||||
# Sync Voice/Profile in config
|
# Sync Voice/Profile in config
|
||||||
self.config["selected_voice"] = self.selected_voice
|
self.config["selected_voice"] = self.selected_voice
|
||||||
@@ -2046,6 +2399,8 @@ class abogen(QWidget):
|
|||||||
self.current_queue_index = 0 # Reset for next time
|
self.current_queue_index = 0 # Reset for next time
|
||||||
|
|
||||||
def get_voice_formula(self) -> str:
|
def get_voice_formula(self) -> str:
|
||||||
|
if self.provider_combo.currentData() == "supertonic":
|
||||||
|
return self._get_supertonic_voice()
|
||||||
if self.mixed_voice_state:
|
if self.mixed_voice_state:
|
||||||
formula_components = [
|
formula_components = [
|
||||||
f"{name}*{weight}" for name, weight in self.mixed_voice_state
|
f"{name}*{weight}" for name, weight in self.mixed_voice_state
|
||||||
@@ -2055,6 +2410,8 @@ class abogen(QWidget):
|
|||||||
return self.selected_voice
|
return self.selected_voice
|
||||||
|
|
||||||
def get_selected_lang(self, voice_formula) -> str:
|
def get_selected_lang(self, voice_formula) -> str:
|
||||||
|
if self.provider_combo.currentData() == "supertonic":
|
||||||
|
return self.st_lang_combo.currentText()
|
||||||
if self.selected_profile_name:
|
if self.selected_profile_name:
|
||||||
from abogen.voice_profiles import load_profiles
|
from abogen.voice_profiles import load_profiles
|
||||||
|
|
||||||
@@ -2144,6 +2501,10 @@ class abogen(QWidget):
|
|||||||
# determine selected language: use profile setting if profile selected, else voice code
|
# determine selected language: use profile setting if profile selected, else voice code
|
||||||
selected_lang = self.get_selected_lang(voice_formula)
|
selected_lang = self.get_selected_lang(voice_formula)
|
||||||
|
|
||||||
|
tts_provider = self.provider_combo.currentData()
|
||||||
|
supertonic_language = self.st_lang_combo.currentText()
|
||||||
|
supertonic_total_steps = self.st_steps_combo.currentData()
|
||||||
|
|
||||||
self.conversion_thread = ConversionThread(
|
self.conversion_thread = ConversionThread(
|
||||||
self.selected_file,
|
self.selected_file,
|
||||||
selected_lang,
|
selected_lang,
|
||||||
@@ -2159,8 +2520,11 @@ class abogen(QWidget):
|
|||||||
total_char_count=self.char_count,
|
total_char_count=self.char_count,
|
||||||
use_gpu=self.gpu_ok,
|
use_gpu=self.gpu_ok,
|
||||||
from_queue=from_queue,
|
from_queue=from_queue,
|
||||||
save_base_path=self.displayed_file_path, # Pass the save base path (original file for EPUB)
|
save_base_path=self.displayed_file_path,
|
||||||
) # Use gpu_ok status
|
tts_provider=tts_provider,
|
||||||
|
supertonic_language=supertonic_language,
|
||||||
|
supertonic_total_steps=supertonic_total_steps,
|
||||||
|
)
|
||||||
# Pass the displayed file path to the log_updated signal handler in ConversionThread
|
# Pass the displayed file path to the log_updated signal handler in ConversionThread
|
||||||
self.conversion_thread.display_path = display_path
|
self.conversion_thread.display_path = display_path
|
||||||
# Pass the file size string
|
# Pass the file size string
|
||||||
@@ -2179,6 +2543,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
|
||||||
@@ -2222,6 +2601,12 @@ class abogen(QWidget):
|
|||||||
# Store gpu_ok status to use when creating the conversion thread
|
# Store gpu_ok status to use when creating the conversion thread
|
||||||
self.gpu_ok = gpu_ok
|
self.gpu_ok = gpu_ok
|
||||||
self.update_log((gpu_msg, gpu_ok))
|
self.update_log((gpu_msg, gpu_ok))
|
||||||
|
tts_provider = self.provider_combo.currentData()
|
||||||
|
if tts_provider == "supertonic":
|
||||||
|
# Supertonic doesn't need KPipeline, call callback directly
|
||||||
|
import numpy as np
|
||||||
|
pipeline_loaded_callback(np, None, None)
|
||||||
|
else:
|
||||||
self.update_log("Loading modules...")
|
self.update_log("Loading modules...")
|
||||||
load_thread = LoadPipelineThread(pipeline_loaded_callback)
|
load_thread = LoadPipelineThread(pipeline_loaded_callback)
|
||||||
load_thread.start()
|
load_thread.start()
|
||||||
@@ -2537,9 +2922,32 @@ class abogen(QWidget):
|
|||||||
"Open File Error", f"Could not open file:\n{e}"
|
"Open File Error", f"Could not open file:\n{e}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _get_supertonic_voice(self) -> str:
|
||||||
|
"""Resolve the effective Supertonic voice from the current combo selection."""
|
||||||
|
if self.selected_profile_name:
|
||||||
|
from abogen.voice_profiles import load_profiles
|
||||||
|
entry = load_profiles().get(self.selected_profile_name, {})
|
||||||
|
if isinstance(entry, dict):
|
||||||
|
return str(entry.get("voice", "M1"))
|
||||||
|
return "M1"
|
||||||
|
current_data = self.voice_combo.currentData()
|
||||||
|
if current_data and isinstance(current_data, str) and not current_data.startswith("profile:"):
|
||||||
|
return current_data
|
||||||
|
return "M1"
|
||||||
|
|
||||||
def _get_preview_cache_path(self):
|
def _get_preview_cache_path(self):
|
||||||
"""Generate the expected cache path for the current voice settings."""
|
"""Generate the expected cache path for the current voice settings."""
|
||||||
speed = self.speed_slider.value() / 100.0
|
speed = self.speed_slider.value() / 100.0
|
||||||
|
provider = self.provider_combo.currentData()
|
||||||
|
|
||||||
|
if provider == "supertonic":
|
||||||
|
voice_to_cache = self._get_supertonic_voice()
|
||||||
|
lang_to_cache = self.st_lang_combo.currentText()
|
||||||
|
steps = self.st_steps_combo.currentData()
|
||||||
|
cache_dir = get_user_cache_path("preview_cache")
|
||||||
|
filename = f"st_{voice_to_cache}_{lang_to_cache}_steps{steps}_{speed:.2f}.wav"
|
||||||
|
return os.path.join(cache_dir, filename)
|
||||||
|
|
||||||
voice_to_cache = ""
|
voice_to_cache = ""
|
||||||
lang_to_cache = ""
|
lang_to_cache = ""
|
||||||
|
|
||||||
@@ -2644,6 +3052,13 @@ class abogen(QWidget):
|
|||||||
self.btn_voice_formula_mixer.setEnabled(False) # Disable mixer button
|
self.btn_voice_formula_mixer.setEnabled(False) # Disable mixer button
|
||||||
self.btn_start.setEnabled(False) # Disable start button during preview
|
self.btn_start.setEnabled(False) # Disable start button during preview
|
||||||
|
|
||||||
|
# For Supertonic, skip KPipeline loading and use SupertonicPipeline directly
|
||||||
|
if self.provider_combo.currentData() == "supertonic":
|
||||||
|
import numpy as np
|
||||||
|
self.loading_movie.start()
|
||||||
|
self._on_pipeline_loaded_for_preview(np, None, None)
|
||||||
|
return
|
||||||
|
|
||||||
# Start loading animation - ensure signal connection is always active
|
# Start loading animation - ensure signal connection is always active
|
||||||
if hasattr(self, "loading_movie"):
|
if hasattr(self, "loading_movie"):
|
||||||
# Disconnect previous connections to avoid multiple connections
|
# Disconnect previous connections to avoid multiple connections
|
||||||
@@ -2702,14 +3117,28 @@ class abogen(QWidget):
|
|||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
lang = self.selected_voice[0]
|
if self.provider_combo.currentData() == "supertonic":
|
||||||
voice = self.selected_voice
|
voice = self._get_supertonic_voice()
|
||||||
|
else:
|
||||||
|
voice = self.selected_voice or ""
|
||||||
|
|
||||||
|
tts_provider = self.provider_combo.currentData()
|
||||||
|
supertonic_language = self.st_lang_combo.currentText()
|
||||||
|
supertonic_total_steps = self.st_steps_combo.currentData()
|
||||||
|
|
||||||
|
if tts_provider == "supertonic":
|
||||||
|
lang = supertonic_language
|
||||||
|
else:
|
||||||
|
lang = self.selected_voice[0] if self.selected_voice else ""
|
||||||
|
|
||||||
# use same gpu/cpu logic as in conversion
|
# use same gpu/cpu logic as in conversion
|
||||||
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
|
np_module, kpipeline_class, lang, voice, speed, gpu_ok,
|
||||||
|
tts_provider=tts_provider,
|
||||||
|
supertonic_language=supertonic_language,
|
||||||
|
supertonic_total_steps=supertonic_total_steps,
|
||||||
)
|
)
|
||||||
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 +3356,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 +3455,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 +3469,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)
|
||||||
|
|
||||||
|
|||||||
@@ -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,14 @@ 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
|
||||||
|
# TTS Provider fields
|
||||||
|
tts_provider: str = "kokoro"
|
||||||
|
supertonic_language: str = "en"
|
||||||
|
supertonic_total_steps: int = 8
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from abogen.constants import (
|
|||||||
VOICES_INTERNAL,
|
VOICES_INTERNAL,
|
||||||
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
||||||
LANGUAGE_DESCRIPTIONS,
|
LANGUAGE_DESCRIPTIONS,
|
||||||
|
KOKORO_LANG_TO_COUNTRY,
|
||||||
COLORS,
|
COLORS,
|
||||||
)
|
)
|
||||||
import re
|
import re
|
||||||
@@ -189,8 +190,9 @@ class VoiceMixer(QWidget):
|
|||||||
) # Center the icons horizontally
|
) # Center the icons horizontally
|
||||||
|
|
||||||
# Flag icon
|
# Flag icon
|
||||||
|
country_code = KOKORO_LANG_TO_COUNTRY.get(language_code, language_code)
|
||||||
flag_icon_path = get_resource_path(
|
flag_icon_path = get_resource_path(
|
||||||
"abogen.assets.flags", f"{language_code}.png"
|
"abogen.assets.flags", f"{country_code}.png"
|
||||||
)
|
)
|
||||||
gender_icon_path = get_resource_path(
|
gender_icon_path = get_resource_path(
|
||||||
"abogen.assets", "female.png" if is_female else "male.png"
|
"abogen.assets", "female.png" if is_female else "male.png"
|
||||||
@@ -512,7 +514,8 @@ class VoiceFormulaDialog(QDialog):
|
|||||||
header_row.addWidget(QLabel("Language:"))
|
header_row.addWidget(QLabel("Language:"))
|
||||||
self.language_combo = QComboBox()
|
self.language_combo = QComboBox()
|
||||||
for code, desc in LANGUAGE_OPTIONS:
|
for code, desc in LANGUAGE_OPTIONS:
|
||||||
flag = get_resource_path("abogen.assets.flags", f"{code}.png")
|
country_code = KOKORO_LANG_TO_COUNTRY.get(code, code)
|
||||||
|
flag = get_resource_path("abogen.assets.flags", f"{country_code}.png")
|
||||||
if flag and os.path.exists(flag):
|
if flag and os.path.exists(flag):
|
||||||
self.language_combo.addItem(QIcon(flag), desc, code)
|
self.language_combo.addItem(QIcon(flag), desc, code)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -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 VOICES_INTERNAL list (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.constants import VOICES_INTERNAL
|
||||||
|
|
||||||
|
# Create case-insensitive lookup set (done once per call)
|
||||||
|
voice_lookup_lower = {v.lower() for v in VOICES_INTERNAL}
|
||||||
|
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 VOICES_INTERNAL.
|
||||||
|
|
||||||
|
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.constants import VOICES_INTERNAL
|
||||||
|
|
||||||
|
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 VOICES_INTERNAL 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 VOICES_INTERNAL 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:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import ast
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
from typing import Any, Iterable, Iterator, Optional
|
from typing import Any, Iterable, Iterator, Optional
|
||||||
|
|
||||||
@@ -15,6 +16,13 @@ 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")
|
||||||
|
|
||||||
|
SUPERTONIC_AVAILABLE_LANGS = [
|
||||||
|
"en", "ko", "ja", "ar", "bg", "cs", "da", "de", "el",
|
||||||
|
"es", "et", "fi", "fr", "hi", "hr", "hu", "id", "it",
|
||||||
|
"lt", "lv", "nl", "pl", "pt", "ro", "ru", "sk", "sl",
|
||||||
|
"sv", "tr", "uk", "vi", "na",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SupertonicSegment:
|
class SupertonicSegment:
|
||||||
@@ -89,7 +97,7 @@ _UNSUPPORTED_CHARS_RE = re.compile(
|
|||||||
|
|
||||||
|
|
||||||
def _parse_unsupported_characters(error: BaseException) -> list[str]:
|
def _parse_unsupported_characters(error: BaseException) -> list[str]:
|
||||||
"""Best-effort extraction of unsupported characters from SuperTonic errors."""
|
"""Best-effort extraction of unsupported characters from Supertonic errors."""
|
||||||
|
|
||||||
message = " ".join(
|
message = " ".join(
|
||||||
str(part) for part in getattr(error, "args", ()) if part is not None
|
str(part) for part in getattr(error, "args", ()) if part is not None
|
||||||
@@ -155,6 +163,7 @@ def _configure_supertonic_gpu() -> None:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Could not configure supertonic GPU providers: %s", exc)
|
logger.warning("Could not configure supertonic GPU providers: %s", exc)
|
||||||
|
|
||||||
|
SUPERTONIC_MAX_CHUNK_LENGTH = 500
|
||||||
|
|
||||||
class SupertonicPipeline:
|
class SupertonicPipeline:
|
||||||
"""Minimal adapter that mimics Kokoro's pipeline iteration interface."""
|
"""Minimal adapter that mimics Kokoro's pipeline iteration interface."""
|
||||||
@@ -165,11 +174,14 @@ class SupertonicPipeline:
|
|||||||
sample_rate: int,
|
sample_rate: int,
|
||||||
auto_download: bool = True,
|
auto_download: bool = True,
|
||||||
total_steps: int = 5,
|
total_steps: int = 5,
|
||||||
max_chunk_length: int = 300,
|
max_chunk_length: int = SUPERTONIC_MAX_CHUNK_LENGTH,
|
||||||
|
lang: str = "en",
|
||||||
|
intra_op_num_threads: Optional[int] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.sample_rate = int(sample_rate)
|
self.sample_rate = int(sample_rate)
|
||||||
self.total_steps = int(total_steps)
|
self.total_steps = int(total_steps)
|
||||||
self.max_chunk_length = int(max_chunk_length)
|
self.max_chunk_length = int(max_chunk_length)
|
||||||
|
self.lang = str(lang or "en")
|
||||||
|
|
||||||
# Configure GPU providers before importing TTS
|
# Configure GPU providers before importing TTS
|
||||||
_configure_supertonic_gpu()
|
_configure_supertonic_gpu()
|
||||||
@@ -181,7 +193,8 @@ class SupertonicPipeline:
|
|||||||
"Supertonic is not installed. Install it with `pip install supertonic`."
|
"Supertonic is not installed. Install it with `pip install supertonic`."
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
self._tts = TTS(auto_download=auto_download)
|
threads = intra_op_num_threads if intra_op_num_threads is not None else os.cpu_count()
|
||||||
|
self._tts = TTS(auto_download=auto_download, intra_op_num_threads=threads)
|
||||||
|
|
||||||
def __call__(
|
def __call__(
|
||||||
self,
|
self,
|
||||||
@@ -191,12 +204,14 @@ class SupertonicPipeline:
|
|||||||
speed: float,
|
speed: float,
|
||||||
split_pattern: Optional[str] = None,
|
split_pattern: Optional[str] = None,
|
||||||
total_steps: Optional[int] = None,
|
total_steps: Optional[int] = None,
|
||||||
|
lang: Optional[str] = None,
|
||||||
) -> Iterator[SupertonicSegment]:
|
) -> Iterator[SupertonicSegment]:
|
||||||
voice_name = (voice or "").strip() or "M1"
|
voice_name = (voice or "").strip() or "M1"
|
||||||
steps = int(total_steps) if total_steps is not None else self.total_steps
|
steps = int(total_steps) if total_steps is not None else self.total_steps
|
||||||
steps = max(2, min(15, steps))
|
steps = max(2, min(15, steps))
|
||||||
speed_value = float(speed) if speed is not None else 1.0
|
speed_value = float(speed) if speed is not None else 1.0
|
||||||
speed_value = max(0.7, min(2.0, speed_value))
|
speed_value = max(0.7, min(2.0, speed_value))
|
||||||
|
language = str(lang or self.lang or "en")
|
||||||
|
|
||||||
style = self._tts.get_voice_style(voice_name=voice_name)
|
style = self._tts.get_voice_style(voice_name=voice_name)
|
||||||
chunks = _split_text(
|
chunks = _split_text(
|
||||||
@@ -207,12 +222,13 @@ class SupertonicPipeline:
|
|||||||
removed: set[str] = set()
|
removed: set[str] = set()
|
||||||
last_exc: Exception | None = None
|
last_exc: Exception | None = None
|
||||||
|
|
||||||
# SuperTonic can raise ValueError for unsupported characters; strip and retry.
|
# Supertonic can raise ValueError for unsupported characters; strip and retry.
|
||||||
for attempt in range(3):
|
for attempt in range(3):
|
||||||
try:
|
try:
|
||||||
wav, duration = self._tts.synthesize(
|
wav, duration = self._tts.synthesize(
|
||||||
text=chunk_to_speak,
|
text=chunk_to_speak,
|
||||||
voice_style=style,
|
voice_style=style,
|
||||||
|
lang=language,
|
||||||
total_steps=steps,
|
total_steps=steps,
|
||||||
speed=speed_value,
|
speed=speed_value,
|
||||||
max_chunk_length=self.max_chunk_length,
|
max_chunk_length=self.max_chunk_length,
|
||||||
@@ -238,14 +254,14 @@ class SupertonicPipeline:
|
|||||||
chunk_to_speak = sanitized
|
chunk_to_speak = sanitized
|
||||||
if not chunk_to_speak:
|
if not chunk_to_speak:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"SuperTonic: dropped a chunk after removing unsupported characters: %s",
|
"Supertonic: dropped a chunk after removing unsupported characters: %s",
|
||||||
sorted(removed),
|
sorted(removed),
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
if attempt == 0:
|
if attempt == 0:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"SuperTonic: removed unsupported characters %s and retried.",
|
"Supertonic: removed unsupported characters %s and retried.",
|
||||||
sorted(removed),
|
sorted(removed),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ def _supertonic_voice_from_spec(spec: Any, fallback: str) -> str:
|
|||||||
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
|
# Supertonic voices are discrete IDs (M1/F3/...). If we see a Kokoro mix
|
||||||
# formula (contains '*' or '+'), ignore it and fall back to a safe voice.
|
# formula (contains '*' or '+'), ignore it and fall back to a safe voice.
|
||||||
if not raw or "*" in raw or "+" in raw:
|
if not raw or "*" in raw or "+" in raw:
|
||||||
raw = fallback_raw
|
raw = fallback_raw
|
||||||
@@ -1584,7 +1584,7 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
pipelines[provider_norm] = SupertonicPipeline(
|
pipelines[provider_norm] = SupertonicPipeline(
|
||||||
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", 8) or 8),
|
||||||
)
|
)
|
||||||
return pipelines[provider_norm]
|
return pipelines[provider_norm]
|
||||||
|
|
||||||
@@ -1618,7 +1618,7 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
provider = str(entry.get("provider") or "kokoro").strip().lower() or "kokoro"
|
provider = str(entry.get("provider") or "kokoro").strip().lower() or "kokoro"
|
||||||
if provider == "supertonic":
|
if provider == "supertonic":
|
||||||
voice = str(entry.get("voice") or getattr(job, "voice", "M1") or "M1").strip() or "M1"
|
voice = str(entry.get("voice") or getattr(job, "voice", "M1") or "M1").strip() or "M1"
|
||||||
steps = int(entry.get("total_steps") or getattr(job, "supertonic_total_steps", 5) or 5)
|
steps = int(entry.get("total_steps") or getattr(job, "supertonic_total_steps", 8) or 8)
|
||||||
speed = float(entry.get("speed") or getattr(job, "speed", 1.0) or 1.0)
|
speed = float(entry.get("speed") or getattr(job, "speed", 1.0) or 1.0)
|
||||||
return "supertonic", _supertonic_voice_from_spec(voice, getattr(job, "voice", "M1")), speed, steps
|
return "supertonic", _supertonic_voice_from_spec(voice, getattr(job, "voice", "M1")), speed, steps
|
||||||
formula = _formula_from_kokoro_entry(entry)
|
formula = _formula_from_kokoro_entry(entry)
|
||||||
@@ -1634,7 +1634,7 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
"""Resolve a raw voice spec into (provider, resolved_spec, choice, speed, steps).
|
"""Resolve a raw voice spec into (provider, resolved_spec, choice, speed, steps).
|
||||||
|
|
||||||
For Kokoro formulas, `choice` will be a resolved voice tensor (via `voice_formulas`).
|
For Kokoro formulas, `choice` will be a resolved voice tensor (via `voice_formulas`).
|
||||||
For SuperTonic, `choice` will be a valid SuperTonic voice id.
|
For Supertonic, `choice` will be a valid Supertonic voice id.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
provider, resolved, speed, steps = resolve_voice_target(raw_spec)
|
provider, resolved, speed, steps = resolve_voice_target(raw_spec)
|
||||||
@@ -1857,7 +1857,7 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
voice=voice_name,
|
voice=voice_name,
|
||||||
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),
|
||||||
split_pattern=split_pattern,
|
split_pattern=split_pattern,
|
||||||
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", 8)),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
kokoro_pipeline = get_pipeline("kokoro")
|
kokoro_pipeline = get_pipeline("kokoro")
|
||||||
@@ -2448,7 +2448,7 @@ def _load_pipeline(job: Job):
|
|||||||
return SupertonicPipeline(
|
return SupertonicPipeline(
|
||||||
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", 8) or 8),
|
||||||
)
|
)
|
||||||
|
|
||||||
device = "cpu"
|
device = "cpu"
|
||||||
@@ -2610,7 +2610,7 @@ def _build_ffmpeg_command(path: Path, fmt: str, metadata: Optional[Dict[str, str
|
|||||||
def _resolve_voice(pipeline, voice_spec: str, use_gpu: bool):
|
def _resolve_voice(pipeline, voice_spec: str, use_gpu: bool):
|
||||||
if "*" in voice_spec:
|
if "*" in voice_spec:
|
||||||
# Voice formulas are a Kokoro-only feature (they require a pipeline that can
|
# Voice formulas are a Kokoro-only feature (they require a pipeline that can
|
||||||
# load individual Kokoro voices). When running with SuperTonic (or when the
|
# load individual Kokoro voices). When running with Supertonic (or when the
|
||||||
# pipeline is otherwise unavailable), treat the spec as a plain string and
|
# pipeline is otherwise unavailable), treat the spec as a plain string and
|
||||||
# allow downstream provider-specific resolution to choose a safe fallback.
|
# allow downstream provider-specific resolution to choose a safe fallback.
|
||||||
if pipeline is None or not hasattr(pipeline, "load_single_voice"):
|
if pipeline is None or not hasattr(pipeline, "load_single_voice"):
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ def api_voice_profiles_preview() -> ResponseReturnValue:
|
|||||||
formula = str(payload.get("formula") or "").strip()
|
formula = str(payload.get("formula") or "").strip()
|
||||||
profile_name = str(payload.get("profile") or "").strip()
|
profile_name = str(payload.get("profile") or "").strip()
|
||||||
provider = str(payload.get("tts_provider") or payload.get("provider") or "").strip().lower() or None
|
provider = str(payload.get("tts_provider") or payload.get("provider") or "").strip().lower() or None
|
||||||
supertonic_total_steps = int(payload.get("supertonic_total_steps") or payload.get("total_steps") or settings.get("supertonic_total_steps") or 5)
|
supertonic_total_steps = int(payload.get("supertonic_total_steps") or payload.get("total_steps") or settings.get("supertonic_total_steps") or 8)
|
||||||
|
|
||||||
voice_spec = ""
|
voice_spec = ""
|
||||||
resolved_provider = provider or "kokoro"
|
resolved_provider = provider or "kokoro"
|
||||||
@@ -224,7 +224,7 @@ def api_speaker_preview() -> ResponseReturnValue:
|
|||||||
speed_value = payload.get("speed")
|
speed_value = payload.get("speed")
|
||||||
speed = coerce_float(speed_value, 1.0)
|
speed = coerce_float(speed_value, 1.0)
|
||||||
tts_provider = str(payload.get("tts_provider") or "").strip().lower()
|
tts_provider = str(payload.get("tts_provider") or "").strip().lower()
|
||||||
supertonic_total_steps = int(payload.get("supertonic_total_steps") or 5)
|
supertonic_total_steps = int(payload.get("supertonic_total_steps") or 8)
|
||||||
|
|
||||||
settings = load_settings()
|
settings = load_settings()
|
||||||
use_gpu = settings.get("use_gpu", False)
|
use_gpu = settings.get("use_gpu", False)
|
||||||
@@ -269,7 +269,7 @@ def api_speaker_preview() -> ResponseReturnValue:
|
|||||||
use_gpu=use_gpu
|
use_gpu=use_gpu
|
||||||
,
|
,
|
||||||
tts_provider=resolved_provider,
|
tts_provider=resolved_provider,
|
||||||
supertonic_total_steps=supertonic_total_steps or int(settings.get("supertonic_total_steps") or 5),
|
supertonic_total_steps=supertonic_total_steps or int(settings.get("supertonic_total_steps") or 8),
|
||||||
pronunciation_overrides=pronunciation_overrides,
|
pronunciation_overrides=pronunciation_overrides,
|
||||||
manual_overrides=manual_overrides,
|
manual_overrides=manual_overrides,
|
||||||
speakers=speakers,
|
speakers=speakers,
|
||||||
|
|||||||
@@ -43,8 +43,12 @@ def update_settings() -> ResponseReturnValue:
|
|||||||
current["language"] = (form.get("language") or "en").strip()
|
current["language"] = (form.get("language") or "en").strip()
|
||||||
current["default_speaker"] = (form.get("default_speaker") or "").strip()
|
current["default_speaker"] = (form.get("default_speaker") or "").strip()
|
||||||
current["default_voice"] = (form.get("default_voice") or "").strip()
|
current["default_voice"] = (form.get("default_voice") or "").strip()
|
||||||
|
provider = str(form.get("tts_provider") or "kokoro").strip().lower()
|
||||||
|
if provider in {"kokoro", "supertonic"}:
|
||||||
|
current["tts_provider"] = provider
|
||||||
try:
|
try:
|
||||||
current["supertonic_total_steps"] = max(2, min(15, int(form.get("supertonic_total_steps", current.get("supertonic_total_steps", 5)))))
|
total_steps = int(form.get("supertonic_total_steps", current.get("supertonic_total_steps", 8)))
|
||||||
|
current["supertonic_total_steps"] = max(2, min(15, total_steps))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -577,7 +577,7 @@ def apply_book_step_form(
|
|||||||
# NOTE: Do not auto-set a global TTS provider at the book level based on the
|
# NOTE: Do not auto-set a global TTS provider at the book level based on the
|
||||||
# narrator defaults. Provider is resolved per-speaker/per-chunk from the voice
|
# narrator defaults. Provider is resolved per-speaker/per-chunk from the voice
|
||||||
# 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 provider_value in {"kokoro", "supertonic"}:
|
||||||
pending.tts_provider = provider_value
|
pending.tts_provider = provider_value
|
||||||
@@ -913,6 +913,15 @@ def build_pending_job_from_extraction(
|
|||||||
else:
|
else:
|
||||||
normalization_overrides[key] = default_val
|
normalization_overrides[key] = default_val
|
||||||
|
|
||||||
|
provider_value = str(form.get("tts_provider") or "").strip().lower()
|
||||||
|
if provider_value not in {"kokoro", "supertonic"}:
|
||||||
|
provider_value = settings.get("tts_provider", "kokoro")
|
||||||
|
try:
|
||||||
|
total_steps = int(form.get("supertonic_total_steps", settings.get("supertonic_total_steps", 8)))
|
||||||
|
supertonic_steps = max(2, min(15, total_steps))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
supertonic_steps = int(settings.get("supertonic_total_steps", 8))
|
||||||
|
|
||||||
pending = PendingJob(
|
pending = PendingJob(
|
||||||
id=uuid.uuid4().hex,
|
id=uuid.uuid4().hex,
|
||||||
original_filename=original_name,
|
original_filename=original_name,
|
||||||
@@ -928,6 +937,8 @@ def build_pending_job_from_extraction(
|
|||||||
replace_single_newlines=replace_single_newlines,
|
replace_single_newlines=replace_single_newlines,
|
||||||
subtitle_format=subtitle_format,
|
subtitle_format=subtitle_format,
|
||||||
total_characters=total_chars,
|
total_characters=total_chars,
|
||||||
|
tts_provider=provider_value,
|
||||||
|
supertonic_total_steps=supertonic_steps,
|
||||||
save_chapters_separately=save_chapters_separately,
|
save_chapters_separately=save_chapters_separately,
|
||||||
merge_chapters_at_end=merge_chapters_at_end,
|
merge_chapters_at_end=merge_chapters_at_end,
|
||||||
separate_chapters_format=separate_chapters_format,
|
separate_chapters_format=separate_chapters_format,
|
||||||
|
|||||||
@@ -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":
|
||||||
|
try:
|
||||||
|
if torch.backends.mps.is_available():
|
||||||
return "mps"
|
return "mps"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return "cpu"
|
||||||
|
|
||||||
|
try:
|
||||||
|
if torch.cuda.is_available():
|
||||||
return "cuda"
|
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:
|
||||||
@@ -59,7 +92,7 @@ def generate_preview_audio(
|
|||||||
speed: float,
|
speed: float,
|
||||||
use_gpu: bool,
|
use_gpu: bool,
|
||||||
tts_provider: str = "kokoro",
|
tts_provider: str = "kokoro",
|
||||||
supertonic_total_steps: int = 5,
|
supertonic_total_steps: int = 8,
|
||||||
max_seconds: float = 8.0,
|
max_seconds: float = 8.0,
|
||||||
pronunciation_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
pronunciation_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
||||||
manual_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
manual_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
||||||
@@ -115,15 +148,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 +156,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,
|
||||||
@@ -176,7 +201,7 @@ def synthesize_preview(
|
|||||||
speed: float,
|
speed: float,
|
||||||
use_gpu: bool,
|
use_gpu: bool,
|
||||||
tts_provider: str = "kokoro",
|
tts_provider: str = "kokoro",
|
||||||
supertonic_total_steps: int = 5,
|
supertonic_total_steps: int = 8,
|
||||||
max_seconds: float = 8.0,
|
max_seconds: float = 8.0,
|
||||||
pronunciation_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
pronunciation_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
||||||
manual_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
manual_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ def submit_job(pending: PendingJob) -> str:
|
|||||||
tts_provider=getattr(pending, "tts_provider", "kokoro"),
|
tts_provider=getattr(pending, "tts_provider", "kokoro"),
|
||||||
voice=pending.voice,
|
voice=pending.voice,
|
||||||
speed=pending.speed,
|
speed=pending.speed,
|
||||||
supertonic_total_steps=getattr(pending, "supertonic_total_steps", 5),
|
supertonic_total_steps=getattr(pending, "supertonic_total_steps", 8),
|
||||||
use_gpu=pending.use_gpu,
|
use_gpu=pending.use_gpu,
|
||||||
subtitle_mode=pending.subtitle_mode,
|
subtitle_mode=pending.subtitle_mode,
|
||||||
output_format=pending.output_format,
|
output_format=pending.output_format,
|
||||||
|
|||||||
@@ -175,7 +175,8 @@ def settings_defaults() -> Dict[str, Any]:
|
|||||||
"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": VOICES_INTERNAL[0] if VOICES_INTERNAL else "",
|
||||||
"supertonic_total_steps": 5,
|
"tts_provider": "kokoro",
|
||||||
|
"supertonic_total_steps": 8,
|
||||||
"supertonic_speed": 1.0,
|
"supertonic_speed": 1.0,
|
||||||
"replace_single_newlines": False,
|
"replace_single_newlines": False,
|
||||||
"use_gpu": True,
|
"use_gpu": True,
|
||||||
|
|||||||
@@ -666,7 +666,7 @@ def resolve_voice_choice(
|
|||||||
|
|
||||||
# Provider-aware behavior:
|
# Provider-aware behavior:
|
||||||
# - Kokoro profiles typically represent mixes (formula strings).
|
# - Kokoro profiles typically represent mixes (formula strings).
|
||||||
# - SuperTonic profiles represent a discrete voice id + settings.
|
# - Supertonic profiles represent a discrete voice id + settings.
|
||||||
# In that case, we return a speaker reference so downstream can
|
# In that case, we return a speaker reference so downstream can
|
||||||
# resolve provider per-speaker and allow mixed-provider casting.
|
# resolve provider per-speaker and allow mixed-provider casting.
|
||||||
if provider == "supertonic":
|
if provider == "supertonic":
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ class Job:
|
|||||||
subtitle_format: str
|
subtitle_format: str
|
||||||
created_at: float
|
created_at: float
|
||||||
tts_provider: str = "kokoro"
|
tts_provider: str = "kokoro"
|
||||||
supertonic_total_steps: int = 5
|
supertonic_total_steps: int = 8
|
||||||
save_chapters_separately: bool = False
|
save_chapters_separately: bool = False
|
||||||
merge_chapters_at_end: bool = True
|
merge_chapters_at_end: bool = True
|
||||||
separate_chapters_format: str = "wav"
|
separate_chapters_format: str = "wav"
|
||||||
@@ -204,7 +204,7 @@ class Job:
|
|||||||
"queue_position": self.queue_position,
|
"queue_position": self.queue_position,
|
||||||
"options": {
|
"options": {
|
||||||
"tts_provider": getattr(self, "tts_provider", "kokoro"),
|
"tts_provider": getattr(self, "tts_provider", "kokoro"),
|
||||||
"supertonic_total_steps": getattr(self, "supertonic_total_steps", 5),
|
"supertonic_total_steps": getattr(self, "supertonic_total_steps", 8),
|
||||||
"save_chapters_separately": self.save_chapters_separately,
|
"save_chapters_separately": self.save_chapters_separately,
|
||||||
"merge_chapters_at_end": self.merge_chapters_at_end,
|
"merge_chapters_at_end": self.merge_chapters_at_end,
|
||||||
"separate_chapters_format": self.separate_chapters_format,
|
"separate_chapters_format": self.separate_chapters_format,
|
||||||
@@ -552,7 +552,7 @@ class PendingJob:
|
|||||||
normalization_overrides: Dict[str, Any]
|
normalization_overrides: Dict[str, Any]
|
||||||
created_at: float
|
created_at: float
|
||||||
tts_provider: str = "kokoro"
|
tts_provider: str = "kokoro"
|
||||||
supertonic_total_steps: int = 5
|
supertonic_total_steps: int = 8
|
||||||
cover_image_path: Optional[Path] = None
|
cover_image_path: Optional[Path] = None
|
||||||
cover_image_mime: Optional[str] = None
|
cover_image_mime: Optional[str] = None
|
||||||
chapter_intro_delay: float = 0.5
|
chapter_intro_delay: float = 0.5
|
||||||
@@ -621,7 +621,7 @@ class ConversionService:
|
|||||||
voice: str,
|
voice: str,
|
||||||
speed: float,
|
speed: float,
|
||||||
tts_provider: str = "kokoro",
|
tts_provider: str = "kokoro",
|
||||||
supertonic_total_steps: int = 5,
|
supertonic_total_steps: int = 8,
|
||||||
use_gpu: bool,
|
use_gpu: bool,
|
||||||
subtitle_mode: str,
|
subtitle_mode: str,
|
||||||
output_format: str,
|
output_format: str,
|
||||||
@@ -674,7 +674,7 @@ class ConversionService:
|
|||||||
voice=voice,
|
voice=voice,
|
||||||
speed=speed,
|
speed=speed,
|
||||||
tts_provider=tts_provider,
|
tts_provider=tts_provider,
|
||||||
supertonic_total_steps=int(supertonic_total_steps or 5),
|
supertonic_total_steps=int(supertonic_total_steps or 8),
|
||||||
use_gpu=use_gpu,
|
use_gpu=use_gpu,
|
||||||
subtitle_mode=subtitle_mode,
|
subtitle_mode=subtitle_mode,
|
||||||
output_format=output_format,
|
output_format=output_format,
|
||||||
@@ -1147,7 +1147,7 @@ class ConversionService:
|
|||||||
"tts_provider": getattr(job, "tts_provider", "kokoro"),
|
"tts_provider": getattr(job, "tts_provider", "kokoro"),
|
||||||
"voice": job.voice,
|
"voice": job.voice,
|
||||||
"speed": job.speed,
|
"speed": job.speed,
|
||||||
"supertonic_total_steps": getattr(job, "supertonic_total_steps", 5),
|
"supertonic_total_steps": getattr(job, "supertonic_total_steps", 8),
|
||||||
"use_gpu": job.use_gpu,
|
"use_gpu": job.use_gpu,
|
||||||
"subtitle_mode": job.subtitle_mode,
|
"subtitle_mode": job.subtitle_mode,
|
||||||
"output_format": job.output_format,
|
"output_format": job.output_format,
|
||||||
@@ -1275,7 +1275,7 @@ class ConversionService:
|
|||||||
replace_single_newlines=bool(payload.get("replace_single_newlines", False)),
|
replace_single_newlines=bool(payload.get("replace_single_newlines", False)),
|
||||||
subtitle_format=payload.get("subtitle_format", "srt"),
|
subtitle_format=payload.get("subtitle_format", "srt"),
|
||||||
created_at=float(payload.get("created_at", time.time())),
|
created_at=float(payload.get("created_at", time.time())),
|
||||||
supertonic_total_steps=int(payload.get("supertonic_total_steps", 5)),
|
supertonic_total_steps=int(payload.get("supertonic_total_steps", 8)),
|
||||||
save_chapters_separately=bool(payload.get("save_chapters_separately", False)),
|
save_chapters_separately=bool(payload.get("save_chapters_separately", False)),
|
||||||
merge_chapters_at_end=bool(payload.get("merge_chapters_at_end", True)),
|
merge_chapters_at_end=bool(payload.get("merge_chapters_at_end", True)),
|
||||||
separate_chapters_format=payload.get("separate_chapters_format", "wav"),
|
separate_chapters_format=payload.get("separate_chapters_format", "wav"),
|
||||||
|
|||||||
@@ -26,6 +26,26 @@
|
|||||||
{% set subtitle_value = settings_dict.get('subtitle_mode', 'Disabled') %}
|
{% set subtitle_value = settings_dict.get('subtitle_mode', 'Disabled') %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% set tts_provider_value = form_values.get('tts_provider') if form_values else None %}
|
||||||
|
{% if not tts_provider_value %}
|
||||||
|
{% if pending and pending.tts_provider %}
|
||||||
|
{% set tts_provider_value = pending.tts_provider %}
|
||||||
|
{% else %}
|
||||||
|
{% set tts_provider_value = settings_dict.get('tts_provider', 'kokoro') %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% set supertonic_steps_value = form_values.get('supertonic_total_steps') if form_values else None %}
|
||||||
|
{% if supertonic_steps_value is none %}
|
||||||
|
{% if pending and pending.supertonic_total_steps is not none %}
|
||||||
|
{% set supertonic_steps_value = pending.supertonic_total_steps %}
|
||||||
|
{% else %}
|
||||||
|
{% set supertonic_steps_value = settings_dict.get('supertonic_total_steps', 8) %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% if supertonic_steps_value is not none and supertonic_steps_value is string %}
|
||||||
|
{% set supertonic_steps_value = supertonic_steps_value|int %}
|
||||||
|
{% endif %}
|
||||||
|
{% set is_supertonic = tts_provider_value == 'supertonic' %}
|
||||||
{% set generate_flag = form_values.get('generate_epub3') if form_values else None %}
|
{% set generate_flag = form_values.get('generate_epub3') if form_values else None %}
|
||||||
{% if generate_flag is not none %}
|
{% if generate_flag is not none %}
|
||||||
{% set generate_epub3 = True %}
|
{% set generate_epub3 = True %}
|
||||||
@@ -273,6 +293,13 @@
|
|||||||
<div class="form-section__layout form-section__layout--split">
|
<div class="form-section__layout form-section__layout--split">
|
||||||
<div class="form-section__group">
|
<div class="form-section__group">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
|
<label for="tts_provider">TTS Engine</label>
|
||||||
|
<select id="tts_provider" name="tts_provider" data-role="tts-provider" {{ 'disabled' if readonly else '' }}>
|
||||||
|
<option value="kokoro" {% if tts_provider_value == 'kokoro' %}selected{% endif %}>Kokoro</option>
|
||||||
|
<option value="supertonic" {% if tts_provider_value == 'supertonic' %}selected{% endif %}>Supertonic</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field" data-role="voice-profile-field">
|
||||||
<label for="voice_profile">Voice profile</label>
|
<label for="voice_profile">Voice profile</label>
|
||||||
<select id="voice_profile" name="voice_profile" data-role="voice-profile" {{ 'disabled' if readonly else '' }}>
|
<select id="voice_profile" name="voice_profile" data-role="voice-profile" {{ 'disabled' if readonly else '' }}>
|
||||||
<option value="__standard" {% if profile_value == '__standard' %}selected{% endif %}>Standard voice</option>
|
<option value="__standard" {% if profile_value == '__standard' %}selected{% endif %}>Standard voice</option>
|
||||||
@@ -280,13 +307,13 @@
|
|||||||
{% if options.voice_profile_options %}
|
{% if options.voice_profile_options %}
|
||||||
<optgroup label="Saved mixes">
|
<optgroup label="Saved mixes">
|
||||||
{% for profile in options.voice_profile_options %}
|
{% for profile in options.voice_profile_options %}
|
||||||
<option value="{{ profile.name }}" data-language="{{ profile.language }}" data-formula="{{ profile.formula|e }}" {% if profile_value == profile.name %}selected{% endif %}>{{ profile.name }}{% if profile.language %} ({{ profile.language|upper }}){% endif %}</option>
|
<option value="{{ profile.name }}" data-language="{{ profile.language }}" data-formula="{{ profile.formula|e }}" data-provider="{{ profile.provider|default('kokoro')|lower }}" {% if profile_value == profile.name %}selected{% endif %}>{{ profile.name }}{% if profile.language %} ({{ profile.language|upper }}){% endif %}{% if profile.provider and profile.provider|lower != 'kokoro' %} · {{ profile.provider|capitalize }}{% endif %}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</optgroup>
|
</optgroup>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field" data-role="voice-field" {% if profile_value != '__standard' %}hidden aria-hidden="true"{% endif %}>
|
<div class="field" data-role="voice-field" data-provider="kokoro" {% if profile_value != '__standard' or is_supertonic %}hidden aria-hidden="true"{% endif %}>
|
||||||
<label for="voice">Voice</label>
|
<label for="voice">Voice</label>
|
||||||
<select id="voice" name="voice" data-role="voice-select" data-default="{{ narrator_voice or settings_dict.get('default_voice', '') }}" {{ 'disabled' if readonly else '' }}>
|
<select id="voice" name="voice" data-role="voice-select" data-default="{{ narrator_voice or settings_dict.get('default_voice', '') }}" {{ 'disabled' if readonly else '' }}>
|
||||||
{% for voice in options.voices %}
|
{% for voice in options.voices %}
|
||||||
@@ -294,10 +321,23 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field" data-conditional="formula" data-role="formula-field" {% if profile_value != '__formula' %}hidden aria-hidden="true"{% endif %}>
|
<div class="field" data-role="voice-field" data-provider="supertonic" {% if profile_value != '__standard' or not is_supertonic %}hidden aria-hidden="true"{% endif %}>
|
||||||
|
<label for="voice_st">Supertonic voice</label>
|
||||||
|
<select id="voice_st" name="voice" data-role="voice-select" data-default="{{ narrator_voice or 'M1' }}" {{ 'disabled' if readonly else '' }}>
|
||||||
|
{% for voice in ['M1','M2','M3','M4','M5','F1','F2','F3','F4','F5'] %}
|
||||||
|
<option value="{{ voice }}" {% if narrator_voice == voice and profile_value == '__standard' %}selected{% endif %}>{{ voice }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field" data-conditional="formula" data-role="formula-field" data-provider="kokoro" {% if profile_value != '__formula' or is_supertonic %}hidden aria-hidden="true"{% endif %}>
|
||||||
<label for="voice_formula">Custom voice formula</label>
|
<label for="voice_formula">Custom voice formula</label>
|
||||||
<input type="text" id="voice_formula" name="voice_formula" placeholder="af_nova*0.4+am_liam*0.6" data-role="voice-formula" value="{{ voice_formula_value }}" {{ 'disabled' if readonly else '' }}>
|
<input type="text" id="voice_formula" name="voice_formula" placeholder="af_nova*0.4+am_liam*0.6" data-role="voice-formula" value="{{ voice_formula_value }}" {{ 'disabled' if readonly else '' }}>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field" data-role="supertonic-steps-field" {% if not is_supertonic %}hidden aria-hidden="true"{% endif %}>
|
||||||
|
<label for="supertonic_total_steps">Supertonic quality (total steps)</label>
|
||||||
|
<input type="number" id="supertonic_total_steps" name="supertonic_total_steps" min="2" max="15" value="{{ supertonic_steps_value }}" {{ 'disabled' if readonly else '' }}>
|
||||||
|
<p class="hint">2 = fastest/lowest quality, 15 = slowest/highest quality.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-section__group">
|
<div class="form-section__group">
|
||||||
<div class="field field--slider">
|
<div class="field field--slider">
|
||||||
@@ -423,3 +463,54 @@
|
|||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<script nonce="{{ csp_nonce() if csp_nonce else '' }}">
|
||||||
|
(function() {
|
||||||
|
const form = document.querySelector('[data-wizard-form="true"][data-step="book"]');
|
||||||
|
if (!form) return;
|
||||||
|
const providerSelect = form.querySelector('[data-role="tts-provider"]');
|
||||||
|
if (!providerSelect) return;
|
||||||
|
|
||||||
|
function filterProfilesByProvider(provider) {
|
||||||
|
const profileSelect = form.querySelector('[data-role="voice-profile"]');
|
||||||
|
if (!profileSelect) return;
|
||||||
|
const options = profileSelect.querySelectorAll('option[data-provider]');
|
||||||
|
options.forEach(function(opt) {
|
||||||
|
const matches = !opt.dataset.provider || opt.dataset.provider === provider;
|
||||||
|
opt.hidden = !matches;
|
||||||
|
if (opt.selected && opt.hidden) {
|
||||||
|
opt.selected = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!profileSelect.value || profileSelect.selectedOptions[0]?.hidden) {
|
||||||
|
const firstVisible = profileSelect.querySelector('option:not([hidden])');
|
||||||
|
if (firstVisible) profileSelect.value = firstVisible.value;
|
||||||
|
}
|
||||||
|
profileSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncProviderUI(provider) {
|
||||||
|
var isSupertonic = provider === 'supertonic';
|
||||||
|
form.querySelectorAll('[data-role="voice-field"]').forEach(function(el) {
|
||||||
|
el.hidden = el.dataset.provider !== provider;
|
||||||
|
el.setAttribute('aria-hidden', el.hidden ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
var formulaField = form.querySelector('[data-role="formula-field"]');
|
||||||
|
if (formulaField) {
|
||||||
|
formulaField.hidden = isSupertonic;
|
||||||
|
formulaField.setAttribute('aria-hidden', isSupertonic ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
var stepsField = form.querySelector('[data-role="supertonic-steps-field"]');
|
||||||
|
if (stepsField) {
|
||||||
|
stepsField.hidden = !isSupertonic;
|
||||||
|
stepsField.setAttribute('aria-hidden', isSupertonic ? 'false' : 'true');
|
||||||
|
}
|
||||||
|
filterProfilesByProvider(provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
providerSelect.addEventListener('change', function() {
|
||||||
|
syncProviderUI(providerSelect.value);
|
||||||
|
});
|
||||||
|
syncProviderUI(providerSelect.value);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -61,6 +61,15 @@
|
|||||||
<p class="hint">Pick a saved speaker from Speaker Studio to use by default for new jobs.</p>
|
<p class="hint">Pick a saved speaker from Speaker Studio to use by default for new jobs.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="tts_provider">Default TTS Engine</label>
|
||||||
|
<select id="tts_provider" name="tts_provider">
|
||||||
|
<option value="kokoro" {% if settings.tts_provider == 'kokoro' %}selected{% endif %}>Kokoro</option>
|
||||||
|
<option value="supertonic" {% if settings.tts_provider == 'supertonic' %}selected{% endif %}>Supertonic</option>
|
||||||
|
</select>
|
||||||
|
<p class="hint">Select the default TTS engine for new jobs.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="field field--wide">
|
<div class="field field--wide">
|
||||||
<p class="tag">Kokoro settings</p>
|
<p class="tag">Kokoro settings</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -30,7 +30,7 @@ dependencies = [
|
|||||||
"pip",
|
"pip",
|
||||||
"kokoro>=0.9.4",
|
"kokoro>=0.9.4",
|
||||||
"misaki[zh]>=0.9.4",
|
"misaki[zh]>=0.9.4",
|
||||||
"supertonic>=0.1.0",
|
"supertonic>=1.3.1",
|
||||||
"ebooklib>=0.19",
|
"ebooklib>=0.19",
|
||||||
"beautifulsoup4>=4.13.4",
|
"beautifulsoup4>=4.13.4",
|
||||||
"spacy>=3.8.7,<4.0",
|
"spacy>=3.8.7,<4.0",
|
||||||
@@ -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"
|
||||||
@@ -113,11 +111,11 @@ filterwarnings = [
|
|||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
# NVIDIA GPU (Windows) (CUDA 12.6) # uv tool install abogen[cuda126]
|
# NVIDIA GPU (Windows) (CUDA 12.6) # uv tool install abogen[cuda126]
|
||||||
cuda126 = ["torch"]
|
cuda126 = ["torch", "onnxruntime-gpu>=1.26.0"]
|
||||||
# NVIDIA GPU (Windows) (CUDA 12.8) # uv tool install abogen[cuda]
|
# NVIDIA GPU (Windows) (CUDA 12.8) # uv tool install abogen[cuda]
|
||||||
cuda = ["torch"]
|
cuda = ["torch", "onnxruntime-gpu>=1.26.0"]
|
||||||
# NVIDIA GPU (Windows) (CUDA 13.0) # uv tool install abogen[cuda130]
|
# NVIDIA GPU (Windows) (CUDA 13.0) # uv tool install abogen[cuda130]
|
||||||
cuda130 = ["torch"]
|
cuda130 = ["torch", "onnxruntime-gpu>=1.26.0"]
|
||||||
# AMD GPU (Linux) (ROCm 6.4) # uv tool install abogen[rocm]
|
# AMD GPU (Linux) (ROCm 6.4) # uv tool install abogen[rocm]
|
||||||
rocm = ["torch", "pytorch-triton-rocm"]
|
rocm = ["torch", "pytorch-triton-rocm"]
|
||||||
# Development dependencies # uv tool install abogen[dev]
|
# Development dependencies # uv tool install abogen[dev]
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ from abogen.tts_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:
|
||||||
# This can happen when a previously-saved Kokoro mix formula is present
|
# This can happen when a previously-saved Kokoro mix formula is present
|
||||||
# but the active provider is SuperTonic (no Kokoro pipeline object).
|
# but the active provider is Supertonic (no Kokoro pipeline object).
|
||||||
formula = "af_heart*0.5+af_sky*0.5"
|
formula = "af_heart*0.5+af_sky*0.5"
|
||||||
resolved = _resolve_voice(None, formula, use_gpu=False)
|
resolved = _resolve_voice(None, formula, use_gpu=False)
|
||||||
assert resolved == formula
|
assert resolved == formula
|
||||||
|
|
||||||
|
|
||||||
def test_supertonic_voice_from_formula_falls_back_to_valid_voice() -> None:
|
def test_supertonic_voice_from_formula_falls_back_to_valid_voice() -> None:
|
||||||
# When a stale Kokoro mix formula is present, SuperTonic should not receive it.
|
# When a stale Kokoro mix formula is present, Supertonic should not receive it.
|
||||||
chosen = _supertonic_voice_from_spec("af_heart*0.5+af_sky*0.5", "af_heart*1.0")
|
chosen = _supertonic_voice_from_spec("af_heart*0.5+af_sky*0.5", "af_heart*1.0")
|
||||||
assert chosen in DEFAULT_SUPERTONIC_VOICES
|
assert chosen in DEFAULT_SUPERTONIC_VOICES
|
||||||
|
|||||||