From e47536e2abd53c65697601870800a99f955ff0e9 Mon Sep 17 00:00:00 2001 From: JB Date: Sat, 20 Dec 2025 09:09:37 -0800 Subject: [PATCH] feat: Enhance voice resolution handling and support mixed-provider conversions --- abogen/web/conversion_runner.py | 37 ++++-- abogen/web/routes/utils/form.py | 140 ++++++++--------------- abogen/web/routes/utils/voice.py | 27 ++++- tests/test_book_step_speaker_provider.py | 83 ++++++++++++++ 4 files changed, 183 insertions(+), 104 deletions(-) create mode 100644 tests/test_book_step_speaker_provider.py diff --git a/abogen/web/conversion_runner.py b/abogen/web/conversion_runner.py index b104268..2d72e4e 100644 --- a/abogen/web/conversion_runner.py +++ b/abogen/web/conversion_runner.py @@ -1511,6 +1511,28 @@ def run_conversion_job(job: Job) -> None: return "supertonic", _supertonic_voice_from_spec(spec, getattr(job, "voice", "M1")), None, None return "kokoro", spec, None, None + def resolve_voice_choice(raw_spec: str) -> tuple[str, str, Any, Optional[float], Optional[int]]: + """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. + """ + + provider, resolved, speed, steps = resolve_voice_target(raw_spec) + cache_key = f"{provider}:{resolved}" if resolved else provider + cached = voice_cache.get(cache_key) + if cached is not None: + return provider, resolved, cached, speed, steps + + if provider == "kokoro": + kokoro_pipeline = get_pipeline("kokoro") + choice = _resolve_voice(kokoro_pipeline, resolved, job.use_gpu) + else: + choice = resolved + + voice_cache[cache_key] = choice + return provider, resolved, choice, speed, steps + extraction = extract_from_path(job.stored_path) file_type = _infer_file_type(job.stored_path) pronunciation_rules = _compile_pronunciation_rules(job.pronunciation_overrides) @@ -2074,9 +2096,10 @@ def run_conversion_job(job: Job) -> None: if outro_voice_spec == "__custom_mix": outro_voice_spec = base_voice_spec or "" if not outro_voice_spec: - fallback_voice = next(iter(voice_cache.keys()), "") - if fallback_voice and fallback_voice != "__custom_mix": - outro_voice_spec = fallback_voice + fallback_key = next(iter(voice_cache.keys()), "") + if fallback_key and fallback_key != "__custom_mix": + # `voice_cache` keys are internal and include provider prefixes. + outro_voice_spec = fallback_key.split(":", 1)[-1] if not outro_voice_spec and VOICES_INTERNAL: outro_voice_spec = VOICES_INTERNAL[0] @@ -2085,10 +2108,7 @@ def run_conversion_job(job: Job) -> None: outro_audio_path: Optional[Path] = None outro_segments = 0 outro_index = total_chapters + 1 - outro_voice_choice = voice_cache.get(outro_voice_spec) - if outro_voice_choice is None: - outro_voice_choice = _resolve_voice(pipeline, outro_voice_spec, job.use_gpu) - voice_cache[outro_voice_spec] = outro_voice_choice + outro_provider, _, outro_voice_choice, outro_speed, outro_steps = resolve_voice_choice(outro_voice_spec) with ExitStack() as outro_sink_stack: chapter_sink: Optional[AudioSink] = None @@ -2110,6 +2130,9 @@ def run_conversion_job(job: Job) -> None: voice_choice=outro_voice_choice, chapter_sink=chapter_sink, preview_prefix="Outro", + tts_provider=outro_provider, + speed_override=outro_speed, + supertonic_steps_override=outro_steps, ) outro_end_time = current_time diff --git a/abogen/web/routes/utils/form.py b/abogen/web/routes/utils/form.py index 95e1ee2..7fd33cc 100644 --- a/abogen/web/routes/utils/form.py +++ b/abogen/web/routes/utils/form.py @@ -574,11 +574,15 @@ def apply_book_step_form( except ValueError: pass - provider_value = str(form.get("tts_provider") or getattr(pending, "tts_provider", None) or "").strip().lower() - if provider_value not in {"kokoro", "supertonic"}: - provider_value = "" + # 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). + provider_value = str(form.get("tts_provider") or "").strip().lower() + if provider_value in {"kokoro", "supertonic"}: + pending.tts_provider = provider_value - # Determine the base speaker selection (saved speaker or raw voice). + # Determine the base speaker selection (saved speaker ref or raw voice). narrator_voice_raw = ( form.get("voice") or pending.voice @@ -588,102 +592,54 @@ def apply_book_step_form( ).strip() profiles_map = dict(profiles) if isinstance(profiles, Mapping) else dict(profiles or {}) - base_spec, selected_speaker_name = split_profile_spec(narrator_voice_raw) - selected_speaker_entry = None - if selected_speaker_name: - selected_speaker_entry = normalize_profile_entry(profiles_map.get(selected_speaker_name)) + base_spec, _selected_speaker_name = split_profile_spec(narrator_voice_raw) - # If a saved speaker is selected, prefer its provider. - if selected_speaker_entry and selected_speaker_entry.get("provider") in {"kokoro", "supertonic"}: - provider_value = str(selected_speaker_entry.get("provider")) + profile_selection = (form.get("voice_profile") or pending.voice_profile or "__standard").strip() + custom_formula_raw = (form.get("voice_formula") or "").strip() + narrator_voice_raw = (base_spec or narrator_voice_raw or settings.get("default_voice") or "").strip() + resolved_default_voice, inferred_profile, _ = resolve_voice_setting( + narrator_voice_raw, + profiles=profiles_map, + ) - if not provider_value: - # Fall back to inferring provider from the raw voice spec. - provider_value = "supertonic" if base_spec in {"M1","M2","M3","M4","M5","F1","F2","F3","F4","F5"} else "kokoro" + if profile_selection in {"__standard", "", None} and inferred_profile: + profile_selection = inferred_profile - pending.tts_provider = provider_value - - try: - pending.supertonic_total_steps = int( - form.get("supertonic_total_steps") - or getattr(pending, "supertonic_total_steps", None) - or (selected_speaker_entry or {}).get("total_steps") - or settings.get("supertonic_total_steps") - or 5 - ) - except (TypeError, ValueError): - pending.supertonic_total_steps = int(settings.get("supertonic_total_steps") or 5) - - if provider_value == "supertonic": - # If the user picked a saved Supertonic speaker, use its config. - if selected_speaker_entry and selected_speaker_entry.get("provider") == "supertonic": - pending.voice_profile = None - pending.voice = str(selected_speaker_entry.get("voice") or "M1") - pending.supertonic_total_steps = int(selected_speaker_entry.get("total_steps") or pending.supertonic_total_steps) - if speed_value is None: - try: - pending.speed = float(selected_speaker_entry.get("speed") or settings.get("supertonic_speed") or 1.0) - except (TypeError, ValueError): - pending.speed = 1.0 - pending.language = str(selected_speaker_entry.get("language") or "a") - else: - # Supertonic does not support Abogen voice mixing. - pending.voice_profile = None - pending.voice = (base_spec or "M1").strip() or "M1" - # Provider-specific speed default. - if speed_value is None: - try: - pending.speed = float(settings.get("supertonic_speed") or 1.0) - except (TypeError, ValueError): - pending.speed = 1.0 - pending.language = "a" + if profile_selection == "__formula": + profile_name = "" + custom_formula = custom_formula_raw + elif profile_selection in {"__standard", "", None}: + profile_name = "" + custom_formula = "" else: - profile_selection = (form.get("voice_profile") or pending.voice_profile or "__standard").strip() - custom_formula_raw = (form.get("voice_formula") or "").strip() - narrator_voice_raw = (base_spec or narrator_voice_raw or settings.get("default_voice") or "").strip() - resolved_default_voice, inferred_profile, _ = resolve_voice_setting( - narrator_voice_raw, - profiles=profiles_map, - ) + profile_name = profile_selection + custom_formula = "" - if profile_selection in {"__standard", "", None} and inferred_profile: - profile_selection = inferred_profile + base_voice_spec = resolved_default_voice or narrator_voice_raw + if not base_voice_spec and VOICES_INTERNAL: + base_voice_spec = VOICES_INTERNAL[0] - if profile_selection == "__formula": - profile_name = "" - custom_formula = custom_formula_raw - elif profile_selection in {"__standard", "", None}: - profile_name = "" - custom_formula = "" - else: - profile_name = profile_selection - custom_formula = "" + voice_choice, resolved_language, selected_profile = resolve_voice_choice( + pending.language, + base_voice_spec, + profile_name, + custom_formula, + profiles_map, + ) - base_voice_spec = resolved_default_voice or narrator_voice_raw - if not base_voice_spec and VOICES_INTERNAL: - base_voice_spec = VOICES_INTERNAL[0] + if resolved_language: + pending.language = resolved_language - voice_choice, resolved_language, selected_profile = resolve_voice_choice( - pending.language, - base_voice_spec, - profile_name, - custom_formula, - profiles_map, - ) - - if resolved_language: - pending.language = resolved_language - - if profile_selection == "__formula" and custom_formula_raw: - pending.voice = custom_formula_raw - pending.voice_profile = None - elif profile_selection not in {"__standard", "", None, "__formula"}: - pending.voice_profile = selected_profile or profile_selection - pending.voice = voice_choice - else: - pending.voice_profile = None - fallback_voice = base_voice_spec or narrator_voice_raw - pending.voice = voice_choice or fallback_voice + if profile_selection == "__formula" and custom_formula_raw: + pending.voice = custom_formula_raw + pending.voice_profile = None + elif profile_selection not in {"__standard", "", None, "__formula"}: + pending.voice_profile = selected_profile or profile_selection + pending.voice = voice_choice + else: + pending.voice_profile = None + fallback_voice = base_voice_spec or narrator_voice_raw + pending.voice = voice_choice or fallback_voice pending.applied_speaker_config = (form.get("speaker_config") or "").strip() or None diff --git a/abogen/web/routes/utils/voice.py b/abogen/web/routes/utils/voice.py index b3f56fe..110a7c9 100644 --- a/abogen/web/routes/utils/voice.py +++ b/abogen/web/routes/utils/voice.py @@ -658,14 +658,31 @@ def resolve_voice_choice( selected_profile = None if profile_name: - entry = profiles.get(profile_name) - formula = formula_from_profile(entry or {}) if entry else None - if formula: - resolved_voice = formula + from abogen.voice_profiles import normalize_profile_entry + + entry_raw = profiles.get(profile_name) + entry = normalize_profile_entry(entry_raw) + provider = str((entry or {}).get("provider") or "").strip().lower() + + # Provider-aware behavior: + # - Kokoro profiles typically represent mixes (formula strings). + # - 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": + resolved_voice = f"speaker:{profile_name}" selected_profile = profile_name profile_language = (entry or {}).get("language") if profile_language: - resolved_language = profile_language + resolved_language = str(profile_language) + else: + formula = formula_from_profile(entry or {}) if entry else None + if formula: + resolved_voice = formula + selected_profile = profile_name + profile_language = (entry or {}).get("language") + if profile_language: + resolved_language = profile_language if custom_formula: resolved_voice = custom_formula diff --git a/tests/test_book_step_speaker_provider.py b/tests/test_book_step_speaker_provider.py new file mode 100644 index 0000000..f137b66 --- /dev/null +++ b/tests/test_book_step_speaker_provider.py @@ -0,0 +1,83 @@ +from pathlib import Path + +from werkzeug.datastructures import MultiDict + +from abogen.web.routes.utils.form import apply_book_step_form +from abogen.web.service import PendingJob + + +def _make_pending_job() -> PendingJob: + pending = PendingJob( + id="pending", + original_filename="example.epub", + stored_path=Path("example.epub"), + language="a", + voice="af_nova", + speed=1.0, + use_gpu=False, + subtitle_mode="none", + output_format="mp3", + save_mode="save_next_to_input", + output_folder=None, + replace_single_newlines=False, + subtitle_format="srt", + total_characters=0, + save_chapters_separately=False, + merge_chapters_at_end=True, + separate_chapters_format="wav", + silence_between_chapters=2.0, + save_as_project=False, + voice_profile=None, + max_subtitle_words=50, + metadata_tags={}, + chapters=[], + normalization_overrides={}, + created_at=0.0, + read_title_intro=False, + normalize_chapter_opening_caps=True, + ) + pending.tts_provider = "kokoro" + return pending + + +def test_book_step_supertonic_profile_becomes_speaker_reference() -> None: + pending = _make_pending_job() + + settings = { + "language": "a", + "chunk_level": "paragraph", + "speaker_analysis_threshold": 3, + "default_voice": "af_nova", + "default_speaker": "", + "default_speed": 1.0, + "read_title_intro": False, + "read_closing_outro": True, + "normalize_chapter_opening_caps": True, + } + + profiles = { + "Female HQ": { + "provider": "supertonic", + "voice": "F3", + "speed": 1.0, + "total_steps": 5, + "language": "a", + } + } + + form = MultiDict( + { + "language": "a", + "voice_profile": "Female HQ", + "speed": "1.0", + } + ) + + apply_book_step_form(pending, form, settings=settings, profiles=profiles) + + # Voice is stored as a speaker reference so provider can be resolved per-speaker. + assert pending.voice == "speaker:Female HQ" + assert pending.voice_profile == "Female HQ" + + # Book-level provider should not be overridden by narrator defaults. + assert pending.tts_provider == "kokoro"