feat: Enhance voice resolution handling and support mixed-provider conversions

This commit is contained in:
JB
2025-12-20 09:09:37 -08:00
parent 32f616e90b
commit e47536e2ab
4 changed files with 183 additions and 104 deletions
+30 -7
View File
@@ -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
+48 -92
View File
@@ -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
+22 -5
View File
@@ -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