diff --git a/abogen/constants.py b/abogen/constants.py index 28b7b2a..4d73942 100644 --- a/abogen/constants.py +++ b/abogen/constants.py @@ -43,8 +43,8 @@ KOKORO_LANG_TO_COUNTRY = { "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 +# 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", diff --git a/abogen/pyqt/conversion.py b/abogen/pyqt/conversion.py index c62f9e8..1a1a221 100644 --- a/abogen/pyqt/conversion.py +++ b/abogen/pyqt/conversion.py @@ -269,7 +269,7 @@ class ConversionThread(QThread): save_base_path=None, tts_provider="kokoro", supertonic_language="en", - supertonic_total_steps=5, + supertonic_total_steps=8, ): super().__init__() self._chapter_options_event = threading.Event() @@ -283,6 +283,7 @@ class ConversionThread(QThread): 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.output_folder = output_folder self.subtitle_mode = subtitle_mode @@ -512,7 +513,7 @@ class ConversionThread(QThread): if self.tts_provider == "supertonic": tts = SupertonicPipeline( - sample_rate=24000, + sample_rate=self.sample_rate, lang=self.supertonic_language, total_steps=self.supertonic_total_steps, ) @@ -765,7 +766,7 @@ class ConversionThread(QThread): merged_out_path = f"{base_filepath_no_ext}.{self.output_format}" subtitle_entries = [] current_time = 0.0 - rate = 24000 + rate = self.sample_rate subtitle_mode = self.subtitle_mode self.etr_start_time = time.time() self.processed_char_count = 0 @@ -781,7 +782,7 @@ class ConversionThread(QThread): merged_out_file = sf.SoundFile( merged_out_path, "w", - samplerate=24000, + samplerate=self.sample_rate, channels=1, format=self.output_format, ) @@ -797,62 +798,18 @@ class ConversionThread(QThread): # Prepare ffmpeg command for m4b output cmd = [ "ffmpeg", - "-y", - "-thread_queue_size", - "32768", - "-f", - "f32le", - "-ar", - "24000", - "-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", + "-y", + "-thread_queue_size", + "32768", + "-f", + "f32le", + "-ar", + str(self.sample_rate), + "-ac", + "1", + "-i", + "pipe:0", ] - ) - 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", - "1", - "-i", - "pipe:0", - ] cmd.extend(["-c:a", "libopus", "-b:a", "24000"]) cmd.append(merged_out_path) ffmpeg_proc = create_process(cmd, stdin=subprocess.PIPE, text=False) @@ -934,7 +891,7 @@ class ConversionThread(QThread): merged_out_path = None subtitle_entries = [] current_time = 0.0 - rate = 24000 + rate = self.sample_rate subtitle_mode = self.subtitle_mode self.etr_start_time = time.time() self.processed_char_count = 0 @@ -987,7 +944,7 @@ class ConversionThread(QThread): chapter_out_file = sf.SoundFile( chapter_out_path, "w", - samplerate=24000, + samplerate=self.sample_rate, channels=1, format=separate_chapters_format, ) @@ -1002,7 +959,7 @@ class ConversionThread(QThread): "-f", "f32le", "-ar", - "24000", + str(self.sample_rate), "-ac", "1", "-i", @@ -1384,7 +1341,7 @@ class ConversionThread(QThread): # Add silence between chapters for merged output (except after the last chapter) if merge_chapters_at_end and chapter_idx < total_chapters: silence_samples = int( - self.silence_duration * 24000 + self.silence_duration * self.sample_rate ) # Silence duration at 24,000 Hz silence_audio = self.np.zeros(silence_samples, dtype="float32") silence_bytes = silence_audio.tobytes() @@ -1615,7 +1572,7 @@ class ConversionThread(QThread): parent_dir, f"{sanitized_base_name}{suffix}" ) merged_out_path = f"{base_filepath_no_ext}.{self.output_format}" - rate = 24000 + rate = self.sample_rate # Setup audio output merged_out_file, ffmpeg_proc = None, None @@ -2467,7 +2424,7 @@ class VoicePreviewThread(QThread): parent=None, tts_provider="kokoro", supertonic_language="en", - supertonic_total_steps=5, + supertonic_total_steps=8, ): super().__init__(parent) self.np_module = np_module @@ -2479,6 +2436,7 @@ class VoicePreviewThread(QThread): 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 self.cache_dir = get_user_cache_path("preview_cache") @@ -2516,7 +2474,7 @@ class VoicePreviewThread(QThread): from abogen.tts_supertonic import SupertonicPipeline tts = SupertonicPipeline( - sample_rate=24000, + sample_rate=self.sample_rate, lang=self.supertonic_language, total_steps=self.supertonic_total_steps, ) @@ -2556,7 +2514,7 @@ class VoicePreviewThread(QThread): if 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.finished.emit() except Exception as e: diff --git a/abogen/pyqt/gui.py b/abogen/pyqt/gui.py index 578e8b0..4a725f3 100644 --- a/abogen/pyqt/gui.py +++ b/abogen/pyqt/gui.py @@ -975,7 +975,7 @@ class abogen(QWidget): ) 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.supertonic_total_steps_config = self.config.get("supertonic_total_steps", 8) self._pending_close_event = None self.gpu_ok = False # Initialize GPU availability status @@ -1025,12 +1025,14 @@ class abogen(QWidget): 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" + 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) + 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: self.save_path_label.setText(self.selected_output_folder) @@ -1129,13 +1131,13 @@ class abogen(QWidget): 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.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 + # 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) @@ -1156,13 +1158,13 @@ class abogen(QWidget): 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) + 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() @@ -1896,7 +1898,7 @@ class abogen(QWidget): save_config(self.config) is_supertonic = provider == "supertonic" - # Show/hide SuperTonic controls + # Show/hide Supertonic controls self.supertonic_row.setVisible(is_supertonic) # Update subtitles availability @@ -1910,7 +1912,7 @@ class abogen(QWidget): self.mixed_voice_state = None self.btn_voice_formula_mixer.setEnabled(False) self.voice_combo.setToolTip( - "SuperTonic voices:\n" + "Supertonic voices:\n" "M1-M5 = Male voices\n" "F1-F5 = Female voices" ) @@ -1928,8 +1930,8 @@ class abogen(QWidget): self.selected_lang = lang self.update_subtitle_options_availability() - def _on_st_steps_changed(self, steps): - self.config["supertonic_total_steps"] = steps + 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): @@ -1943,7 +1945,7 @@ class abogen(QWidget): if isinstance(entry, dict): entry_provider = str(entry.get("provider", "")).strip().lower() if entry_provider == "supertonic": - # Switch provider to SuperTonic if not already + # Switch provider to Supertonic if not already if self.provider_combo.currentData() != "supertonic": self.provider_combo.setCurrentIndex(1) self.mixed_voice_state = None @@ -1951,7 +1953,9 @@ class abogen(QWidget): # 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)) + 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) @@ -2012,7 +2016,14 @@ class abogen(QWidget): # re-add voices if provider == "supertonic": 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: for v in VOICES_INTERNAL: icon = QIcon() @@ -2213,7 +2224,7 @@ class abogen(QWidget): 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(), + supertonic_total_steps=self.st_steps_combo.currentData(), ) # Prevent adding duplicate items to the queue @@ -2359,9 +2370,12 @@ class abogen(QWidget): # TTS provider settings 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_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 self.config["selected_voice"] = self.selected_voice @@ -2390,8 +2404,11 @@ class abogen(QWidget): 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" + return str(entry.get("voice", "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: formula_components = [ f"{name}*{weight}" for name, weight in self.mixed_voice_state @@ -2494,7 +2511,7 @@ class abogen(QWidget): tts_provider = self.provider_combo.currentData() 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.selected_file, @@ -2594,8 +2611,9 @@ class abogen(QWidget): 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) + # Supertonic doesn't need KPipeline, call callback directly + import numpy as np + pipeline_loaded_callback(np, None, None) else: self.update_log("Loading modules...") load_thread = LoadPipelineThread(pipeline_loaded_callback) @@ -2918,9 +2936,13 @@ class abogen(QWidget): provider = self.provider_combo.currentData() 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() - steps = self.st_steps_spin.value() + 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) @@ -3029,10 +3051,11 @@ 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 + # 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(None, None, None) + self._on_pipeline_loaded_for_preview(np, None, None) return # Start loading animation - ensure signal connection is always active @@ -3093,11 +3116,17 @@ class abogen(QWidget): else None ) else: - voice = self.selected_voice or "" + 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: + 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() + supertonic_total_steps = self.st_steps_combo.currentData() if tts_provider == "supertonic": lang = supertonic_language diff --git a/abogen/pyqt/queued_item.py b/abogen/pyqt/queued_item.py index 859d069..455a0ac 100644 --- a/abogen/pyqt/queued_item.py +++ b/abogen/pyqt/queued_item.py @@ -29,4 +29,4 @@ class QueuedItem: # TTS Provider fields tts_provider: str = "kokoro" supertonic_language: str = "en" - supertonic_total_steps: int = 5 + supertonic_total_steps: int = 8 diff --git a/abogen/tts_supertonic.py b/abogen/tts_supertonic.py index c83ab11..ec94047 100644 --- a/abogen/tts_supertonic.py +++ b/abogen/tts_supertonic.py @@ -97,7 +97,7 @@ _UNSUPPORTED_CHARS_RE = re.compile( 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( str(part) for part in getattr(error, "args", ()) if part is not None @@ -221,7 +221,7 @@ class SupertonicPipeline: removed: set[str] = set() 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): try: wav, duration = self._tts.synthesize( @@ -253,14 +253,14 @@ class SupertonicPipeline: chunk_to_speak = sanitized if not chunk_to_speak: logger.warning( - "SuperTonic: dropped a chunk after removing unsupported characters: %s", + "Supertonic: dropped a chunk after removing unsupported characters: %s", sorted(removed), ) break if attempt == 0: logger.warning( - "SuperTonic: removed unsupported characters %s and retried.", + "Supertonic: removed unsupported characters %s and retried.", sorted(removed), ) else: diff --git a/abogen/webui/conversion_runner.py b/abogen/webui/conversion_runner.py index f41e624..f2644bd 100644 --- a/abogen/webui/conversion_runner.py +++ b/abogen/webui/conversion_runner.py @@ -59,7 +59,7 @@ def _supertonic_voice_from_spec(spec: Any, fallback: str) -> str: raw = str(spec 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. if not raw or "*" in raw or "+" in raw: raw = fallback_raw @@ -1584,7 +1584,7 @@ def run_conversion_job(job: Job) -> None: pipelines[provider_norm] = SupertonicPipeline( sample_rate=SAMPLE_RATE, 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] @@ -1618,7 +1618,7 @@ def run_conversion_job(job: Job) -> None: provider = str(entry.get("provider") or "kokoro").strip().lower() or "kokoro" if provider == "supertonic": 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) return "supertonic", _supertonic_voice_from_spec(voice, getattr(job, "voice", "M1")), speed, steps 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). 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) @@ -1857,7 +1857,7 @@ def run_conversion_job(job: Job) -> None: voice=voice_name, speed=float(speed_override if speed_override is not None else job.speed), 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: kokoro_pipeline = get_pipeline("kokoro") @@ -2448,7 +2448,7 @@ def _load_pipeline(job: Job): return SupertonicPipeline( sample_rate=SAMPLE_RATE, 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" @@ -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): if "*" in voice_spec: # 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 # allow downstream provider-specific resolution to choose a safe fallback. if pipeline is None or not hasattr(pipeline, "load_single_voice"): diff --git a/abogen/webui/routes/api.py b/abogen/webui/routes/api.py index f02ab9d..7dc749e 100644 --- a/abogen/webui/routes/api.py +++ b/abogen/webui/routes/api.py @@ -162,7 +162,7 @@ def api_voice_profiles_preview() -> ResponseReturnValue: formula = str(payload.get("formula") 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 - 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 = "" resolved_provider = provider or "kokoro" @@ -224,7 +224,7 @@ def api_speaker_preview() -> ResponseReturnValue: speed_value = payload.get("speed") speed = coerce_float(speed_value, 1.0) 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() use_gpu = settings.get("use_gpu", False) @@ -269,7 +269,7 @@ def api_speaker_preview() -> ResponseReturnValue: use_gpu=use_gpu , 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, manual_overrides=manual_overrides, speakers=speakers, diff --git a/abogen/webui/routes/settings.py b/abogen/webui/routes/settings.py index d0bb991..9e5a525 100644 --- a/abogen/webui/routes/settings.py +++ b/abogen/webui/routes/settings.py @@ -43,8 +43,12 @@ def update_settings() -> ResponseReturnValue: current["language"] = (form.get("language") or "en").strip() current["default_speaker"] = (form.get("default_speaker") 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: - 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): pass try: diff --git a/abogen/webui/routes/utils/form.py b/abogen/webui/routes/utils/form.py index 41d605e..47b8889 100644 --- a/abogen/webui/routes/utils/form.py +++ b/abogen/webui/routes/utils/form.py @@ -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 # 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). - # 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() if provider_value in {"kokoro", "supertonic"}: pending.tts_provider = provider_value @@ -913,6 +913,15 @@ def build_pending_job_from_extraction( else: 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( id=uuid.uuid4().hex, original_filename=original_name, @@ -928,6 +937,8 @@ def build_pending_job_from_extraction( replace_single_newlines=replace_single_newlines, subtitle_format=subtitle_format, total_characters=total_chars, + tts_provider=provider_value, + supertonic_total_steps=supertonic_steps, save_chapters_separately=save_chapters_separately, merge_chapters_at_end=merge_chapters_at_end, separate_chapters_format=separate_chapters_format, diff --git a/abogen/webui/routes/utils/preview.py b/abogen/webui/routes/utils/preview.py index 58b2391..89c754d 100644 --- a/abogen/webui/routes/utils/preview.py +++ b/abogen/webui/routes/utils/preview.py @@ -92,7 +92,7 @@ def generate_preview_audio( speed: float, use_gpu: bool, tts_provider: str = "kokoro", - supertonic_total_steps: int = 5, + supertonic_total_steps: int = 8, max_seconds: float = 8.0, pronunciation_overrides: Optional[Iterable[Mapping[str, Any]]] = None, manual_overrides: Optional[Iterable[Mapping[str, Any]]] = None, @@ -201,7 +201,7 @@ def synthesize_preview( speed: float, use_gpu: bool, tts_provider: str = "kokoro", - supertonic_total_steps: int = 5, + supertonic_total_steps: int = 8, max_seconds: float = 8.0, pronunciation_overrides: Optional[Iterable[Mapping[str, Any]]] = None, manual_overrides: Optional[Iterable[Mapping[str, Any]]] = None, diff --git a/abogen/webui/routes/utils/service.py b/abogen/webui/routes/utils/service.py index b48f94a..beb9ee0 100644 --- a/abogen/webui/routes/utils/service.py +++ b/abogen/webui/routes/utils/service.py @@ -25,7 +25,7 @@ def submit_job(pending: PendingJob) -> str: tts_provider=getattr(pending, "tts_provider", "kokoro"), voice=pending.voice, 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, subtitle_mode=pending.subtitle_mode, output_format=pending.output_format, diff --git a/abogen/webui/routes/utils/settings.py b/abogen/webui/routes/utils/settings.py index c96a66c..0786be3 100644 --- a/abogen/webui/routes/utils/settings.py +++ b/abogen/webui/routes/utils/settings.py @@ -175,7 +175,8 @@ def settings_defaults() -> Dict[str, Any]: "save_mode": "default_output" if has_output_override() else "save_next_to_input", "default_speaker": "", "default_voice": VOICES_INTERNAL[0] if VOICES_INTERNAL else "", - "supertonic_total_steps": 5, + "tts_provider": "kokoro", + "supertonic_total_steps": 8, "supertonic_speed": 1.0, "replace_single_newlines": False, "use_gpu": True, diff --git a/abogen/webui/routes/utils/voice.py b/abogen/webui/routes/utils/voice.py index 3d9081a..c15172e 100644 --- a/abogen/webui/routes/utils/voice.py +++ b/abogen/webui/routes/utils/voice.py @@ -666,7 +666,7 @@ def resolve_voice_choice( # Provider-aware behavior: # - 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 # resolve provider per-speaker and allow mixed-provider casting. if provider == "supertonic": diff --git a/abogen/webui/service.py b/abogen/webui/service.py index a04e9ce..05afab6 100644 --- a/abogen/webui/service.py +++ b/abogen/webui/service.py @@ -111,7 +111,7 @@ class Job: subtitle_format: str created_at: float tts_provider: str = "kokoro" - supertonic_total_steps: int = 5 + supertonic_total_steps: int = 8 save_chapters_separately: bool = False merge_chapters_at_end: bool = True separate_chapters_format: str = "wav" @@ -204,7 +204,7 @@ class Job: "queue_position": self.queue_position, "options": { "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, "merge_chapters_at_end": self.merge_chapters_at_end, "separate_chapters_format": self.separate_chapters_format, @@ -552,7 +552,7 @@ class PendingJob: normalization_overrides: Dict[str, Any] created_at: float tts_provider: str = "kokoro" - supertonic_total_steps: int = 5 + supertonic_total_steps: int = 8 cover_image_path: Optional[Path] = None cover_image_mime: Optional[str] = None chapter_intro_delay: float = 0.5 @@ -621,7 +621,7 @@ class ConversionService: voice: str, speed: float, tts_provider: str = "kokoro", - supertonic_total_steps: int = 5, + supertonic_total_steps: int = 8, use_gpu: bool, subtitle_mode: str, output_format: str, @@ -674,7 +674,7 @@ class ConversionService: voice=voice, speed=speed, 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, subtitle_mode=subtitle_mode, output_format=output_format, @@ -1147,7 +1147,7 @@ class ConversionService: "tts_provider": getattr(job, "tts_provider", "kokoro"), "voice": job.voice, "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, "subtitle_mode": job.subtitle_mode, "output_format": job.output_format, @@ -1275,7 +1275,7 @@ class ConversionService: replace_single_newlines=bool(payload.get("replace_single_newlines", False)), subtitle_format=payload.get("subtitle_format", "srt"), 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)), merge_chapters_at_end=bool(payload.get("merge_chapters_at_end", True)), separate_chapters_format=payload.get("separate_chapters_format", "wav"), diff --git a/tests/test_voice_formula_resolution.py b/tests/test_voice_formula_resolution.py index 2bc3430..99b161f 100644 --- a/tests/test_voice_formula_resolution.py +++ b/tests/test_voice_formula_resolution.py @@ -6,13 +6,13 @@ from abogen.tts_supertonic import DEFAULT_SUPERTONIC_VOICES def test_resolve_voice_formula_without_pipeline_does_not_crash() -> None: # 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" resolved = _resolve_voice(None, formula, use_gpu=False) assert resolved == formula 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") assert chosen in DEFAULT_SUPERTONIC_VOICES