set default steps to 8, updated supertonic rate from 24000 to 44100 - #163

This commit is contained in:
Juraj Borza
2026-05-24 11:11:05 +02:00
parent 08bcb3f492
commit 502a2d5a2e
15 changed files with 136 additions and 133 deletions
+2 -2
View File
@@ -43,8 +43,8 @@ KOKORO_LANG_TO_COUNTRY = {
"z": "cn", # Mandarin Chinese -> China "z": "cn", # Mandarin Chinese -> China
} }
# Mapping from SuperTonic ISO 639-1 language codes to ISO 3166-1 alpha-2 country codes # 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 # Used for loading flag icons in the Supertonic language picker
SUPERTONIC_LANG_TO_COUNTRY = { SUPERTONIC_LANG_TO_COUNTRY = {
"en": "gb", "en": "gb",
"ko": "kr", "ko": "kr",
+15 -57
View File
@@ -269,7 +269,7 @@ class ConversionThread(QThread):
save_base_path=None, save_base_path=None,
tts_provider="kokoro", tts_provider="kokoro",
supertonic_language="en", supertonic_language="en",
supertonic_total_steps=5, supertonic_total_steps=8,
): ):
super().__init__() super().__init__()
self._chapter_options_event = threading.Event() self._chapter_options_event = threading.Event()
@@ -283,6 +283,7 @@ class ConversionThread(QThread):
self.tts_provider = tts_provider self.tts_provider = tts_provider
self.supertonic_language = supertonic_language self.supertonic_language = supertonic_language
self.supertonic_total_steps = supertonic_total_steps 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
@@ -512,7 +513,7 @@ class ConversionThread(QThread):
if self.tts_provider == "supertonic": if self.tts_provider == "supertonic":
tts = SupertonicPipeline( tts = SupertonicPipeline(
sample_rate=24000, sample_rate=self.sample_rate,
lang=self.supertonic_language, lang=self.supertonic_language,
total_steps=self.supertonic_total_steps, total_steps=self.supertonic_total_steps,
) )
@@ -765,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
@@ -781,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,
) )
@@ -803,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",
@@ -934,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
@@ -987,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,
) )
@@ -1002,7 +959,7 @@ class ConversionThread(QThread):
"-f", "-f",
"f32le", "f32le",
"-ar", "-ar",
"24000", str(self.sample_rate),
"-ac", "-ac",
"1", "1",
"-i", "-i",
@@ -1384,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()
@@ -1615,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
@@ -2467,7 +2424,7 @@ class VoicePreviewThread(QThread):
parent=None, parent=None,
tts_provider="kokoro", tts_provider="kokoro",
supertonic_language="en", supertonic_language="en",
supertonic_total_steps=5, supertonic_total_steps=8,
): ):
super().__init__(parent) super().__init__(parent)
self.np_module = np_module self.np_module = np_module
@@ -2479,6 +2436,7 @@ class VoicePreviewThread(QThread):
self.tts_provider = tts_provider self.tts_provider = tts_provider
self.supertonic_language = supertonic_language self.supertonic_language = supertonic_language
self.supertonic_total_steps = supertonic_total_steps 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")
@@ -2516,7 +2474,7 @@ class VoicePreviewThread(QThread):
from abogen.tts_supertonic import SupertonicPipeline from abogen.tts_supertonic import SupertonicPipeline
tts = SupertonicPipeline( tts = SupertonicPipeline(
sample_rate=24000, sample_rate=self.sample_rate,
lang=self.supertonic_language, lang=self.supertonic_language,
total_steps=self.supertonic_total_steps, total_steps=self.supertonic_total_steps,
) )
@@ -2556,7 +2514,7 @@ class VoicePreviewThread(QThread):
if audio_segments: if audio_segments:
audio = self.np_module.concatenate(audio_segments) audio = self.np_module.concatenate(audio_segments)
sf.write(self.cache_path, audio, 24000) sf.write(self.cache_path, audio, self.sample_rate)
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:
+61 -32
View File
@@ -975,7 +975,7 @@ class abogen(QWidget):
) )
self.tts_provider_config = self.config.get("tts_provider", "kokoro") self.tts_provider_config = self.config.get("tts_provider", "kokoro")
self.supertonic_language_config = self.config.get("supertonic_language", "en") self.supertonic_language_config = self.config.get("supertonic_language", "en")
self.supertonic_total_steps_config = self.config.get("supertonic_total_steps", 5) 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
@@ -1025,12 +1025,14 @@ class abogen(QWidget):
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 # Restore TTS provider and supertonic settings from config
provider_text = "SuperTonic" if self.tts_provider_config == "supertonic" else "Kokoro" provider_text = "Supertonic" if self.tts_provider_config == "supertonic" else "Kokoro"
idx_st = self.provider_combo.findText(provider_text) idx_st = self.provider_combo.findText(provider_text)
if idx_st >= 0: if idx_st >= 0:
self.provider_combo.setCurrentIndex(idx_st) self.provider_combo.setCurrentIndex(idx_st)
self.st_lang_combo.setCurrentText(self.supertonic_language_config) self.st_lang_combo.setCurrentText(self.supertonic_language_config)
self.st_steps_spin.setValue(self.supertonic_total_steps_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)
@@ -1129,13 +1131,13 @@ class abogen(QWidget):
provider_layout.addWidget(provider_label) provider_layout.addWidget(provider_label)
self.provider_combo = QComboBox(self) self.provider_combo = QComboBox(self)
self.provider_combo.addItem("Kokoro", "kokoro") self.provider_combo.addItem("Kokoro", "kokoro")
self.provider_combo.addItem("SuperTonic", "supertonic") self.provider_combo.addItem("Supertonic", "supertonic")
self.provider_combo.setStyleSheet("QComboBox { min-height: 20px; padding: 6px 12px; }") self.provider_combo.setStyleSheet("QComboBox { min-height: 20px; padding: 6px 12px; }")
self.provider_combo.currentIndexChanged.connect(self.on_provider_changed) self.provider_combo.currentIndexChanged.connect(self.on_provider_changed)
provider_layout.addWidget(self.provider_combo) provider_layout.addWidget(self.provider_combo)
controls_layout.addLayout(provider_layout) controls_layout.addLayout(provider_layout)
# SuperTonic-specific controls (language + steps), hidden by default # Supertonic-specific controls (language + steps), hidden by default
self.supertonic_row = QWidget() self.supertonic_row = QWidget()
supertonic_row_layout = QHBoxLayout(self.supertonic_row) supertonic_row_layout = QHBoxLayout(self.supertonic_row)
supertonic_row_layout.setContentsMargins(0, 0, 0, 0) supertonic_row_layout.setContentsMargins(0, 0, 0, 0)
@@ -1156,13 +1158,13 @@ class abogen(QWidget):
st_steps_label = QLabel("Steps:", self) st_steps_label = QLabel("Steps:", self)
supertonic_row_layout.addWidget(st_steps_label) supertonic_row_layout.addWidget(st_steps_label)
self.st_steps_spin = QSpinBox(self) self.st_steps_combo = QComboBox(self)
self.st_steps_spin.setMinimum(1) for val in range(2, 16):
self.st_steps_spin.setMaximum(10) self.st_steps_combo.addItem(str(val), val)
self.st_steps_spin.setValue(5) self.st_steps_combo.setCurrentIndex(self.st_steps_combo.findData(8))
self.st_steps_spin.setFixedWidth(60) self.st_steps_combo.setStyleSheet("QComboBox { min-height: 20px; padding: 6px 12px; }")
self.st_steps_spin.valueChanged.connect(self._on_st_steps_changed) self.st_steps_combo.currentIndexChanged.connect(self._on_st_steps_changed)
supertonic_row_layout.addWidget(self.st_steps_spin) supertonic_row_layout.addWidget(self.st_steps_combo)
supertonic_row_layout.addStretch() supertonic_row_layout.addStretch()
self.supertonic_row.hide() self.supertonic_row.hide()
@@ -1896,7 +1898,7 @@ class abogen(QWidget):
save_config(self.config) save_config(self.config)
is_supertonic = provider == "supertonic" is_supertonic = provider == "supertonic"
# Show/hide SuperTonic controls # Show/hide Supertonic controls
self.supertonic_row.setVisible(is_supertonic) self.supertonic_row.setVisible(is_supertonic)
# Update subtitles availability # Update subtitles availability
@@ -1910,7 +1912,7 @@ class abogen(QWidget):
self.mixed_voice_state = None self.mixed_voice_state = None
self.btn_voice_formula_mixer.setEnabled(False) self.btn_voice_formula_mixer.setEnabled(False)
self.voice_combo.setToolTip( self.voice_combo.setToolTip(
"SuperTonic voices:\n" "Supertonic voices:\n"
"M1-M5 = Male voices\n" "M1-M5 = Male voices\n"
"F1-F5 = Female voices" "F1-F5 = Female voices"
) )
@@ -1928,8 +1930,8 @@ class abogen(QWidget):
self.selected_lang = lang self.selected_lang = lang
self.update_subtitle_options_availability() self.update_subtitle_options_availability()
def _on_st_steps_changed(self, steps): def _on_st_steps_changed(self):
self.config["supertonic_total_steps"] = steps self.config["supertonic_total_steps"] = self.st_steps_combo.currentData()
save_config(self.config) save_config(self.config)
def on_voice_combo_changed(self, index): def on_voice_combo_changed(self, index):
@@ -1943,7 +1945,7 @@ class abogen(QWidget):
if isinstance(entry, dict): if isinstance(entry, dict):
entry_provider = str(entry.get("provider", "")).strip().lower() entry_provider = str(entry.get("provider", "")).strip().lower()
if entry_provider == "supertonic": if entry_provider == "supertonic":
# Switch provider to SuperTonic if not already # Switch provider to Supertonic if not already
if self.provider_combo.currentData() != "supertonic": if self.provider_combo.currentData() != "supertonic":
self.provider_combo.setCurrentIndex(1) self.provider_combo.setCurrentIndex(1)
self.mixed_voice_state = None self.mixed_voice_state = None
@@ -1951,7 +1953,9 @@ class abogen(QWidget):
# Sync supertonic controls from profile # Sync supertonic controls from profile
profile_steps = entry.get("total_steps") profile_steps = entry.get("total_steps")
if profile_steps is not None: if profile_steps is not None:
self.st_steps_spin.setValue(int(profile_steps)) 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") profile_lang = entry.get("language")
if profile_lang and profile_lang in SUPERTONIC_AVAILABLE_LANGS: if profile_lang and profile_lang in SUPERTONIC_AVAILABLE_LANGS:
self.st_lang_combo.setCurrentText(profile_lang) self.st_lang_combo.setCurrentText(profile_lang)
@@ -2012,7 +2016,14 @@ class abogen(QWidget):
# re-add voices # re-add voices
if provider == "supertonic": if provider == "supertonic":
for v in DEFAULT_SUPERTONIC_VOICES: for v in DEFAULT_SUPERTONIC_VOICES:
self.voice_combo.addItem(f"{v}", v) 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: else:
for v in VOICES_INTERNAL: for v in VOICES_INTERNAL:
icon = QIcon() icon = QIcon()
@@ -2213,7 +2224,7 @@ class abogen(QWidget):
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(), tts_provider=self.provider_combo.currentData(),
supertonic_language=self.st_lang_combo.currentText(), supertonic_language=self.st_lang_combo.currentText(),
supertonic_total_steps=self.st_steps_spin.value(), supertonic_total_steps=self.st_steps_combo.currentData(),
) )
# Prevent adding duplicate items to the queue # Prevent adding duplicate items to the queue
@@ -2359,9 +2370,12 @@ class abogen(QWidget):
# TTS provider settings # TTS provider settings
tts_provider = getattr(queued_item, "tts_provider", "kokoro") tts_provider = getattr(queued_item, "tts_provider", "kokoro")
self.provider_combo.setCurrentText("SuperTonic" if tts_provider == "supertonic" else "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_lang_combo.setCurrentText(getattr(queued_item, "supertonic_language", "en"))
self.st_steps_spin.setValue(getattr(queued_item, "supertonic_total_steps", 5)) 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
@@ -2390,8 +2404,11 @@ class abogen(QWidget):
from abogen.voice_profiles import load_profiles from abogen.voice_profiles import load_profiles
entry = load_profiles().get(self.selected_profile_name, {}) entry = load_profiles().get(self.selected_profile_name, {})
if isinstance(entry, dict): if isinstance(entry, dict):
return str(entry.get("voice", self.selected_voice or "M1")) return str(entry.get("voice", "M1"))
return self.selected_voice or "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"
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
@@ -2494,7 +2511,7 @@ class abogen(QWidget):
tts_provider = self.provider_combo.currentData() tts_provider = self.provider_combo.currentData()
supertonic_language = self.st_lang_combo.currentText() supertonic_language = self.st_lang_combo.currentText()
supertonic_total_steps = self.st_steps_spin.value() supertonic_total_steps = self.st_steps_combo.currentData()
self.conversion_thread = ConversionThread( self.conversion_thread = ConversionThread(
self.selected_file, self.selected_file,
@@ -2594,8 +2611,9 @@ class abogen(QWidget):
self.update_log((gpu_msg, gpu_ok)) self.update_log((gpu_msg, gpu_ok))
tts_provider = self.provider_combo.currentData() tts_provider = self.provider_combo.currentData()
if tts_provider == "supertonic": if tts_provider == "supertonic":
# SuperTonic doesn't need KPipeline, call callback directly # Supertonic doesn't need KPipeline, call callback directly
pipeline_loaded_callback(None, None, None) import numpy as np
pipeline_loaded_callback(np, None, None)
else: else:
self.update_log("Loading modules...") self.update_log("Loading modules...")
load_thread = LoadPipelineThread(pipeline_loaded_callback) load_thread = LoadPipelineThread(pipeline_loaded_callback)
@@ -2918,9 +2936,13 @@ class abogen(QWidget):
provider = self.provider_combo.currentData() provider = self.provider_combo.currentData()
if provider == "supertonic": if provider == "supertonic":
voice_to_cache = self.selected_voice or "M1" current_data = self.voice_combo.currentData()
if current_data and isinstance(current_data, str) and not current_data.startswith("profile:"):
voice_to_cache = current_data
else:
voice_to_cache = "M1"
lang_to_cache = self.st_lang_combo.currentText() lang_to_cache = self.st_lang_combo.currentText()
steps = self.st_steps_spin.value() steps = self.st_steps_combo.currentData()
cache_dir = get_user_cache_path("preview_cache") cache_dir = get_user_cache_path("preview_cache")
filename = f"st_{voice_to_cache}_{lang_to_cache}_steps{steps}_{speed:.2f}.wav" filename = f"st_{voice_to_cache}_{lang_to_cache}_steps{steps}_{speed:.2f}.wav"
return os.path.join(cache_dir, filename) return os.path.join(cache_dir, filename)
@@ -3029,10 +3051,11 @@ 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 # For Supertonic, skip KPipeline loading and use SupertonicPipeline directly
if self.provider_combo.currentData() == "supertonic": if self.provider_combo.currentData() == "supertonic":
import numpy as np
self.loading_movie.start() self.loading_movie.start()
self._on_pipeline_loaded_for_preview(None, None, None) self._on_pipeline_loaded_for_preview(np, None, None)
return return
# Start loading animation - ensure signal connection is always active # Start loading animation - ensure signal connection is always active
@@ -3092,12 +3115,18 @@ class abogen(QWidget):
if self.mixed_voice_state and self.mixed_voice_state[0][0] if self.mixed_voice_state and self.mixed_voice_state[0][0]
else None else None
) )
else:
if self.provider_combo.currentData() == "supertonic":
current_data = self.voice_combo.currentData()
print(f"[DEBUG] voice_combo.currentData() = {current_data!r}")
voice = current_data if (current_data and isinstance(current_data, str) and not current_data.startswith("profile:")) else "M1"
print(f"[DEBUG] resolved voice = {voice!r}")
else: else:
voice = self.selected_voice or "" voice = self.selected_voice or ""
tts_provider = self.provider_combo.currentData() tts_provider = self.provider_combo.currentData()
supertonic_language = self.st_lang_combo.currentText() supertonic_language = self.st_lang_combo.currentText()
supertonic_total_steps = self.st_steps_spin.value() supertonic_total_steps = self.st_steps_combo.currentData()
if tts_provider == "supertonic": if tts_provider == "supertonic":
lang = supertonic_language lang = supertonic_language
+1 -1
View File
@@ -29,4 +29,4 @@ class QueuedItem:
# TTS Provider fields # TTS Provider fields
tts_provider: str = "kokoro" tts_provider: str = "kokoro"
supertonic_language: str = "en" supertonic_language: str = "en"
supertonic_total_steps: int = 5 supertonic_total_steps: int = 8
+4 -4
View File
@@ -97,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
@@ -221,7 +221,7 @@ 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(
@@ -253,14 +253,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:
+7 -7
View File
@@ -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"):
+3 -3
View File
@@ -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,
+5 -1
View File
@@ -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:
+12 -1
View File
@@ -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,
+2 -2
View File
@@ -92,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,
@@ -201,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,
+1 -1
View File
@@ -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,
+2 -1
View File
@@ -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,
+1 -1
View File
@@ -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":
+7 -7
View File
@@ -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"),
+2 -2
View File
@@ -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