mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +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 "supertonic", _supertonic_voice_from_spec(spec, getattr(job, "voice", "M1")), None, None
|
||||||
return "kokoro", spec, 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)
|
extraction = extract_from_path(job.stored_path)
|
||||||
file_type = _infer_file_type(job.stored_path)
|
file_type = _infer_file_type(job.stored_path)
|
||||||
pronunciation_rules = _compile_pronunciation_rules(job.pronunciation_overrides)
|
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":
|
if outro_voice_spec == "__custom_mix":
|
||||||
outro_voice_spec = base_voice_spec or ""
|
outro_voice_spec = base_voice_spec or ""
|
||||||
if not outro_voice_spec:
|
if not outro_voice_spec:
|
||||||
fallback_voice = next(iter(voice_cache.keys()), "")
|
fallback_key = next(iter(voice_cache.keys()), "")
|
||||||
if fallback_voice and fallback_voice != "__custom_mix":
|
if fallback_key and fallback_key != "__custom_mix":
|
||||||
outro_voice_spec = fallback_voice
|
# `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:
|
if not outro_voice_spec and VOICES_INTERNAL:
|
||||||
outro_voice_spec = VOICES_INTERNAL[0]
|
outro_voice_spec = VOICES_INTERNAL[0]
|
||||||
|
|
||||||
@@ -2085,10 +2108,7 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
outro_audio_path: Optional[Path] = None
|
outro_audio_path: Optional[Path] = None
|
||||||
outro_segments = 0
|
outro_segments = 0
|
||||||
outro_index = total_chapters + 1
|
outro_index = total_chapters + 1
|
||||||
outro_voice_choice = voice_cache.get(outro_voice_spec)
|
outro_provider, _, outro_voice_choice, outro_speed, outro_steps = resolve_voice_choice(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
|
|
||||||
|
|
||||||
with ExitStack() as outro_sink_stack:
|
with ExitStack() as outro_sink_stack:
|
||||||
chapter_sink: Optional[AudioSink] = None
|
chapter_sink: Optional[AudioSink] = None
|
||||||
@@ -2110,6 +2130,9 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
voice_choice=outro_voice_choice,
|
voice_choice=outro_voice_choice,
|
||||||
chapter_sink=chapter_sink,
|
chapter_sink=chapter_sink,
|
||||||
preview_prefix="Outro",
|
preview_prefix="Outro",
|
||||||
|
tts_provider=outro_provider,
|
||||||
|
speed_override=outro_speed,
|
||||||
|
supertonic_steps_override=outro_steps,
|
||||||
)
|
)
|
||||||
outro_end_time = current_time
|
outro_end_time = current_time
|
||||||
|
|
||||||
|
|||||||
@@ -574,11 +574,15 @@ def apply_book_step_form(
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
provider_value = str(form.get("tts_provider") or getattr(pending, "tts_provider", None) or "").strip().lower()
|
# NOTE: Do not auto-set a global TTS provider at the book level based on the
|
||||||
if provider_value not in {"kokoro", "supertonic"}:
|
# narrator defaults. Provider is resolved per-speaker/per-chunk from the voice
|
||||||
provider_value = ""
|
# 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 = (
|
narrator_voice_raw = (
|
||||||
form.get("voice")
|
form.get("voice")
|
||||||
or pending.voice
|
or pending.voice
|
||||||
@@ -588,102 +592,54 @@ def apply_book_step_form(
|
|||||||
).strip()
|
).strip()
|
||||||
|
|
||||||
profiles_map = dict(profiles) if isinstance(profiles, Mapping) else dict(profiles or {})
|
profiles_map = dict(profiles) if isinstance(profiles, Mapping) else dict(profiles or {})
|
||||||
base_spec, selected_speaker_name = split_profile_spec(narrator_voice_raw)
|
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))
|
|
||||||
|
|
||||||
# If a saved speaker is selected, prefer its provider.
|
profile_selection = (form.get("voice_profile") or pending.voice_profile or "__standard").strip()
|
||||||
if selected_speaker_entry and selected_speaker_entry.get("provider") in {"kokoro", "supertonic"}:
|
custom_formula_raw = (form.get("voice_formula") or "").strip()
|
||||||
provider_value = str(selected_speaker_entry.get("provider"))
|
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:
|
if profile_selection in {"__standard", "", None} and inferred_profile:
|
||||||
# Fall back to inferring provider from the raw voice spec.
|
profile_selection = inferred_profile
|
||||||
provider_value = "supertonic" if base_spec in {"M1","M2","M3","M4","M5","F1","F2","F3","F4","F5"} else "kokoro"
|
|
||||||
|
|
||||||
pending.tts_provider = provider_value
|
if profile_selection == "__formula":
|
||||||
|
profile_name = ""
|
||||||
try:
|
custom_formula = custom_formula_raw
|
||||||
pending.supertonic_total_steps = int(
|
elif profile_selection in {"__standard", "", None}:
|
||||||
form.get("supertonic_total_steps")
|
profile_name = ""
|
||||||
or getattr(pending, "supertonic_total_steps", None)
|
custom_formula = ""
|
||||||
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"
|
|
||||||
else:
|
else:
|
||||||
profile_selection = (form.get("voice_profile") or pending.voice_profile or "__standard").strip()
|
profile_name = profile_selection
|
||||||
custom_formula_raw = (form.get("voice_formula") or "").strip()
|
custom_formula = ""
|
||||||
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 profile_selection in {"__standard", "", None} and inferred_profile:
|
base_voice_spec = resolved_default_voice or narrator_voice_raw
|
||||||
profile_selection = inferred_profile
|
if not base_voice_spec and VOICES_INTERNAL:
|
||||||
|
base_voice_spec = VOICES_INTERNAL[0]
|
||||||
|
|
||||||
if profile_selection == "__formula":
|
voice_choice, resolved_language, selected_profile = resolve_voice_choice(
|
||||||
profile_name = ""
|
pending.language,
|
||||||
custom_formula = custom_formula_raw
|
base_voice_spec,
|
||||||
elif profile_selection in {"__standard", "", None}:
|
profile_name,
|
||||||
profile_name = ""
|
custom_formula,
|
||||||
custom_formula = ""
|
profiles_map,
|
||||||
else:
|
)
|
||||||
profile_name = profile_selection
|
|
||||||
custom_formula = ""
|
|
||||||
|
|
||||||
base_voice_spec = resolved_default_voice or narrator_voice_raw
|
if resolved_language:
|
||||||
if not base_voice_spec and VOICES_INTERNAL:
|
pending.language = resolved_language
|
||||||
base_voice_spec = VOICES_INTERNAL[0]
|
|
||||||
|
|
||||||
voice_choice, resolved_language, selected_profile = resolve_voice_choice(
|
if profile_selection == "__formula" and custom_formula_raw:
|
||||||
pending.language,
|
pending.voice = custom_formula_raw
|
||||||
base_voice_spec,
|
pending.voice_profile = None
|
||||||
profile_name,
|
elif profile_selection not in {"__standard", "", None, "__formula"}:
|
||||||
custom_formula,
|
pending.voice_profile = selected_profile or profile_selection
|
||||||
profiles_map,
|
pending.voice = voice_choice
|
||||||
)
|
else:
|
||||||
|
pending.voice_profile = None
|
||||||
if resolved_language:
|
fallback_voice = base_voice_spec or narrator_voice_raw
|
||||||
pending.language = resolved_language
|
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
|
pending.applied_speaker_config = (form.get("speaker_config") or "").strip() or None
|
||||||
|
|
||||||
|
|||||||
@@ -658,14 +658,31 @@ def resolve_voice_choice(
|
|||||||
selected_profile = None
|
selected_profile = None
|
||||||
|
|
||||||
if profile_name:
|
if profile_name:
|
||||||
entry = profiles.get(profile_name)
|
from abogen.voice_profiles import normalize_profile_entry
|
||||||
formula = formula_from_profile(entry or {}) if entry else None
|
|
||||||
if formula:
|
entry_raw = profiles.get(profile_name)
|
||||||
resolved_voice = formula
|
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
|
selected_profile = profile_name
|
||||||
profile_language = (entry or {}).get("language")
|
profile_language = (entry or {}).get("language")
|
||||||
if profile_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:
|
if custom_formula:
|
||||||
resolved_voice = 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