Add Supertonic TTS provider support and configuration options

This commit is contained in:
Juraj Borza
2026-05-23 21:12:05 +02:00
parent 9fa81fbe1e
commit 81937b7a40
4 changed files with 307 additions and 49 deletions
+52 -6
View File
@@ -20,6 +20,7 @@ from abogen.constants import (
SUPPORTED_SOUND_FORMATS,
SUPPORTED_SUBTITLE_FORMATS,
)
from abogen.tts_supertonic import SupertonicPipeline, SUPERTONIC_AVAILABLE_LANGS, DEFAULT_SUPERTONIC_VOICES
from abogen.voice_formulas import get_new_voice
import abogen.hf_tracker as hf_tracker
import static_ffmpeg
@@ -266,7 +267,10 @@ class ConversionThread(QThread):
use_gpu=True,
from_queue=False,
save_base_path=None,
): # Add use_gpu parameter
tts_provider="kokoro",
supertonic_language="en",
supertonic_total_steps=5,
):
super().__init__()
self._chapter_options_event = threading.Event()
self._timestamp_response_event = threading.Event()
@@ -276,6 +280,9 @@ class ConversionThread(QThread):
self.lang_code = lang_code
self.speed = speed
self.voice = voice
self.tts_provider = tts_provider
self.supertonic_language = supertonic_language
self.supertonic_total_steps = supertonic_total_steps
self.save_option = save_option
self.output_folder = output_folder
self.subtitle_mode = subtitle_mode
@@ -427,6 +434,10 @@ class ConversionThread(QThread):
)
self.log_updated.emit(f"- Voice: {self.voice}")
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"- Output format: {self.output_format}")
self.log_updated.emit(
@@ -499,6 +510,13 @@ class ConversionThread(QThread):
else:
device = "cpu"
if self.tts_provider == "supertonic":
tts = SupertonicPipeline(
sample_rate=24000,
lang=self.supertonic_language,
total_steps=self.supertonic_total_steps,
)
else:
tts = self.KPipeline(
lang_code=self.lang_code, repo_id="hexgrad/Kokoro-82M", device=device
)
@@ -2447,6 +2465,9 @@ class VoicePreviewThread(QThread):
speed,
use_gpu=False,
parent=None,
tts_provider="kokoro",
supertonic_language="en",
supertonic_total_steps=5,
):
super().__init__(parent)
self.np_module = np_module
@@ -2455,6 +2476,9 @@ class VoicePreviewThread(QThread):
self.voice = voice
self.speed = speed
self.use_gpu = use_gpu
self.tts_provider = tts_provider
self.supertonic_language = supertonic_language
self.supertonic_total_steps = supertonic_total_steps
# Cache location for preview audio
self.cache_dir = get_user_cache_path("preview_cache")
@@ -2464,6 +2488,11 @@ class VoicePreviewThread(QThread):
def _get_cache_path(self):
"""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
if "*" in self.voice:
voice_id = (
@@ -2478,25 +2507,42 @@ class VoicePreviewThread(QThread):
def run(self):
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
try:
if self.tts_provider == "supertonic":
from abogen.tts_supertonic import SupertonicPipeline
tts = SupertonicPipeline(
sample_rate=24000,
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
if self.use_gpu:
if platform.system() == "Darwin" and platform.processor() == "arm":
device = "mps" # Use MPS for Apple Silicon
device = "mps"
else:
device = "cuda" # Use CUDA for other platforms
device = "cuda"
else:
device = "cpu"
tts = self.kpipeline_class(
lang_code=self.lang_code, repo_id="hexgrad/Kokoro-82M", device=device
)
# Enable voice formula support for preview
if "*" in self.voice:
loaded_voice = get_new_voice(tts, self.voice, self.use_gpu)
else:
@@ -2507,9 +2553,9 @@ class VoicePreviewThread(QThread):
sample_text, voice=loaded_voice, speed=self.speed, split_pattern=None
):
audio_segments.append(result.audio)
if audio_segments:
audio = self.np_module.concatenate(audio_segments)
# Save directly to the cache path
sf.write(self.cache_path, audio, 24000)
self.temp_wav = self.cache_path
self.finished.emit()
+202 -9
View File
@@ -9,6 +9,7 @@ from abogen.pyqt.queue_manager_gui import QueueManager
from abogen.pyqt.queued_item import QueuedItem
import abogen.hf_tracker as hf_tracker
import hashlib # Added for cache path generation
from abogen.tts_supertonic import SUPERTONIC_AVAILABLE_LANGS, DEFAULT_SUPERTONIC_VOICES
from PyQt6.QtWidgets import (
QApplication,
QWidget,
@@ -970,6 +971,9 @@ class abogen(QWidget):
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", 5)
self._pending_close_event = None
self.gpu_ok = False # Initialize GPU availability status
@@ -1018,6 +1022,14 @@ class abogen(QWidget):
else:
self.mixed_voice_state = entry
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)
self.st_steps_spin.setValue(self.supertonic_total_steps_config)
if self.save_option == "Choose output folder" and self.selected_output_folder:
self.save_path_label.setText(self.selected_output_folder)
self.save_path_row_widget.show()
@@ -1107,6 +1119,50 @@ class abogen(QWidget):
speed_layout.addWidget(self.speed_label)
controls_layout.addLayout(speed_layout)
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:
self.st_lang_combo.addItem(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_spin = QSpinBox(self)
self.st_steps_spin.setMinimum(1)
self.st_steps_spin.setMaximum(10)
self.st_steps_spin.setValue(5)
self.st_steps_spin.setFixedWidth(60)
self.st_steps_spin.valueChanged.connect(self._on_st_steps_changed)
supertonic_row_layout.addWidget(self.st_steps_spin)
supertonic_row_layout.addStretch()
self.supertonic_row.hide()
controls_layout.addWidget(self.supertonic_row)
# Voice selection
voice_layout = QHBoxLayout()
voice_layout.setSpacing(7)
@@ -1764,6 +1820,12 @@ class abogen(QWidget):
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.
"""
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
is_subtitle_input = False
if self.selected_file and self.selected_file.lower().endswith(
@@ -1823,6 +1885,48 @@ class abogen(QWidget):
# Enable/disable subtitle options based on language
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, steps):
self.config["supertonic_total_steps"] = steps
save_config(self.config)
def on_voice_combo_changed(self, index):
data = self.voice_combo.itemData(index)
if isinstance(data, str) and data.startswith("profile:"):
@@ -1831,8 +1935,22 @@ class abogen(QWidget):
from abogen.voice_profiles import load_profiles
entry = load_profiles().get(pname, {})
# set mixed voices and language
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:
self.st_steps_spin.setValue(int(profile_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.selected_lang = entry.get("language")
else:
@@ -1847,7 +1965,12 @@ class abogen(QWidget):
else:
self.mixed_voice_state = 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
if "selected_profile_name" in self.config:
del self.config["selected_profile_name"]
@@ -1866,13 +1989,26 @@ class abogen(QWidget):
def populate_profiles_in_voice_combo(self):
# preserve current voice or profile
current = self.voice_combo.currentData()
provider = self.provider_combo.currentData()
self.voice_combo.blockSignals(True)
self.voice_combo.clear()
# re-add profiles
# re-add profiles matching current provider
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}")
# re-add voices
if provider == "supertonic":
for v in DEFAULT_SUPERTONIC_VOICES:
self.voice_combo.addItem(f"{v}", v)
else:
for v in VOICES_INTERNAL:
icon = QIcon()
flag_path = get_resource_path("abogen.assets.flags", f"{v[0]}.png")
@@ -2069,6 +2205,9 @@ class abogen(QWidget):
save_base_path=save_base_path,
save_chapters_separately=getattr(self, "save_chapters_separately", 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_spin.value(),
)
# Prevent adding duplicate items to the queue
@@ -2212,6 +2351,12 @@ class abogen(QWidget):
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"))
self.st_steps_spin.setValue(getattr(queued_item, "supertonic_total_steps", 5))
# Sync Voice/Profile in config
self.config["selected_voice"] = self.selected_voice
if "selected_profile_name" in self.config:
@@ -2234,6 +2379,13 @@ class abogen(QWidget):
self.current_queue_index = 0 # Reset for next time
def get_voice_formula(self) -> str:
if self.provider_combo.currentData() == "supertonic":
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", self.selected_voice or "M1"))
return self.selected_voice or "M1"
if self.mixed_voice_state:
formula_components = [
f"{name}*{weight}" for name, weight in self.mixed_voice_state
@@ -2243,6 +2395,8 @@ class abogen(QWidget):
return self.selected_voice
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:
from abogen.voice_profiles import load_profiles
@@ -2332,6 +2486,10 @@ class abogen(QWidget):
# determine selected language: use profile setting if profile selected, else voice code
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_spin.value()
self.conversion_thread = ConversionThread(
self.selected_file,
selected_lang,
@@ -2347,8 +2505,11 @@ class abogen(QWidget):
total_char_count=self.char_count,
use_gpu=self.gpu_ok,
from_queue=from_queue,
save_base_path=self.displayed_file_path, # Pass the save base path (original file for EPUB)
) # Use gpu_ok status
save_base_path=self.displayed_file_path,
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
self.conversion_thread.display_path = display_path
# Pass the file size string
@@ -2425,6 +2586,11 @@ class abogen(QWidget):
# Store gpu_ok status to use when creating the conversion thread
self.gpu_ok = 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
pipeline_loaded_callback(None, None, None)
else:
self.update_log("Loading modules...")
load_thread = LoadPipelineThread(pipeline_loaded_callback)
load_thread.start()
@@ -2743,6 +2909,16 @@ class abogen(QWidget):
def _get_preview_cache_path(self):
"""Generate the expected cache path for the current voice settings."""
speed = self.speed_slider.value() / 100.0
provider = self.provider_combo.currentData()
if provider == "supertonic":
voice_to_cache = self.selected_voice or "M1"
lang_to_cache = self.st_lang_combo.currentText()
steps = self.st_steps_spin.value()
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 = ""
lang_to_cache = ""
@@ -2847,6 +3023,12 @@ class abogen(QWidget):
self.btn_voice_formula_mixer.setEnabled(False) # Disable mixer button
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":
self.loading_movie.start()
self._on_pipeline_loaded_for_preview(None, None, None)
return
# Start loading animation - ensure signal connection is always active
if hasattr(self, "loading_movie"):
# Disconnect previous connections to avoid multiple connections
@@ -2905,14 +3087,25 @@ class abogen(QWidget):
else None
)
else:
lang = self.selected_voice[0]
voice = self.selected_voice
voice = self.selected_voice or ""
tts_provider = self.provider_combo.currentData()
supertonic_language = self.st_lang_combo.currentText()
supertonic_total_steps = self.st_steps_spin.value()
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
gpu_msg, gpu_ok = get_gpu_acceleration(self.use_gpu)
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.error.connect(self._preview_error)
+4
View File
@@ -26,3 +26,7 @@ class QueuedItem:
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 = 5
+16 -1
View File
@@ -4,6 +4,7 @@ import ast
from dataclasses import dataclass
import logging
import math
import os
import re
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")
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
class SupertonicSegment:
@@ -166,10 +174,13 @@ class SupertonicPipeline:
auto_download: bool = True,
total_steps: int = 5,
max_chunk_length: int = 300,
lang: str = "en",
intra_op_num_threads: Optional[int] = None,
) -> None:
self.sample_rate = int(sample_rate)
self.total_steps = int(total_steps)
self.max_chunk_length = int(max_chunk_length)
self.lang = str(lang or "en")
# Configure GPU providers before importing TTS
_configure_supertonic_gpu()
@@ -181,7 +192,8 @@ class SupertonicPipeline:
"Supertonic is not installed. Install it with `pip install supertonic`."
) 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__(
self,
@@ -191,12 +203,14 @@ class SupertonicPipeline:
speed: float,
split_pattern: Optional[str] = None,
total_steps: Optional[int] = None,
lang: Optional[str] = None,
) -> Iterator[SupertonicSegment]:
voice_name = (voice or "").strip() or "M1"
steps = int(total_steps) if total_steps is not None else self.total_steps
steps = max(2, min(15, steps))
speed_value = float(speed) if speed is not None else 1.0
speed_value = max(0.7, min(2.0, speed_value))
language = str(lang or self.lang or "en")
style = self._tts.get_voice_style(voice_name=voice_name)
chunks = _split_text(
@@ -213,6 +227,7 @@ class SupertonicPipeline:
wav, duration = self._tts.synthesize(
text=chunk_to_speak,
voice_style=style,
lang=language,
total_steps=steps,
speed=speed_value,
max_chunk_length=self.max_chunk_length,