mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 05:40:26 +02:00
feat: Enhance voice resolution handling and support mixed-provider conversions
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user