From 32f616e90b182d399d57245f82fd96631a0cb125 Mon Sep 17 00:00:00 2001 From: JB Date: Sat, 20 Dec 2025 08:35:33 -0800 Subject: [PATCH] feat: Improve SuperTonic voice resolution and add tests for voice formula handling --- abogen/web/conversion_runner.py | 28 +++++++++++++++++++------- tests/test_voice_formula_resolution.py | 18 +++++++++++++++++ 2 files changed, 39 insertions(+), 7 deletions(-) create mode 100644 tests/test_voice_formula_resolution.py diff --git a/abogen/web/conversion_runner.py b/abogen/web/conversion_runner.py index 27067d9..b104268 100644 --- a/abogen/web/conversion_runner.py +++ b/abogen/web/conversion_runner.py @@ -56,16 +56,24 @@ SAMPLE_RATE = 24000 def _supertonic_voice_from_spec(spec: Any, fallback: str) -> str: raw = str(spec or "").strip() - if not raw: - raw = str(fallback or "").strip() - if not raw: - return "M1" - if "*" in raw or "+" in raw: - raw = str(fallback or "").strip() or "M1" + fallback_raw = str(fallback or "").strip() + + # 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 + if not raw or "*" in raw or "+" in raw: + raw = "M1" + upper = raw.upper() if upper in DEFAULT_SUPERTONIC_VOICES: return upper - return str(fallback or "").strip() or "M1" + + fallback_upper = fallback_raw.upper() if fallback_raw else "" + if fallback_upper in DEFAULT_SUPERTONIC_VOICES: + return fallback_upper + + return "M1" def _split_speaker_reference(value: Any) -> tuple[Optional[str], str]: @@ -2439,6 +2447,12 @@ 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 + # 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"): + return voice_spec return get_new_voice(pipeline, voice_spec, use_gpu) return voice_spec diff --git a/tests/test_voice_formula_resolution.py b/tests/test_voice_formula_resolution.py new file mode 100644 index 0000000..907dac9 --- /dev/null +++ b/tests/test_voice_formula_resolution.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from abogen.web.conversion_runner import _resolve_voice, _supertonic_voice_from_spec +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). + 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. + chosen = _supertonic_voice_from_spec("af_heart*0.5+af_sky*0.5", "af_heart*1.0") + assert chosen in DEFAULT_SUPERTONIC_VOICES