From 0afaaf561b90afad5cca93effbb65f658a4d3ba1 Mon Sep 17 00:00:00 2001 From: JB Date: Mon, 15 Dec 2025 16:28:30 -0800 Subject: [PATCH] feat: Implement voice profile resolution in debug TTS and add corresponding tests --- abogen/web/debug_tts_runner.py | 25 ++++++++++++++++++++++++ tests/test_debug_tts_samples.py | 34 +++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/abogen/web/debug_tts_runner.py b/abogen/web/debug_tts_runner.py index d8996ef..04871e0 100644 --- a/abogen/web/debug_tts_runner.py +++ b/abogen/web/debug_tts_runner.py @@ -28,6 +28,18 @@ class DebugWavArtifact: code: Optional[str] = None +def _resolve_voice_setting(value: str) -> tuple[str, Optional[str], Optional[str]]: + """Resolve settings voice strings into a pipeline-ready voice spec. + + Supports "profile:" by converting it into a concrete voice formula. + Returns (resolved_voice_spec, profile_name, profile_language). + """ + + from abogen.web.routes.utils.voice import resolve_voice_setting + + return resolve_voice_setting(value) + + def _load_pipeline(language: str, use_gpu: bool) -> Any: device = "cpu" if use_gpu: @@ -117,6 +129,19 @@ def run_debug_tts_wavs( use_gpu = bool(settings.get("use_gpu", False)) speed = float(settings.get("default_speed", 1.0) or 1.0) + # Settings may store "profile:" which is not a Kokoro voice ID. + # Resolve it to a concrete voice formula (e.g. "af_heart*0.5+...") so Kokoro + # doesn't attempt to download a non-existent "voices/profile:.pt". + try: + resolved_voice, _profile_name, profile_language = _resolve_voice_setting(voice_spec) + if resolved_voice: + voice_spec = resolved_voice + if profile_language: + language = str(profile_language).strip() or language + except Exception: + # Voice profile resolution is best-effort; fall back to raw voice_spec. + pass + # Best-effort voice caching (only for known Kokoro internal voices). voice_ids = _spec_to_voice_ids(voice_spec) if voice_ids: diff --git a/tests/test_debug_tts_samples.py b/tests/test_debug_tts_samples.py index 3b72b14..cf9c3b9 100644 --- a/tests/test_debug_tts_samples.py +++ b/tests/test_debug_tts_samples.py @@ -119,3 +119,37 @@ def test_debug_samples_have_minimum_per_category(): for prefix, minimum in prefixes.items(): assert counts[prefix] >= minimum + + +def test_debug_runner_resolves_profile_voice_before_pipeline(tmp_path, monkeypatch): + from abogen.web import debug_tts_runner as runner + + # Stub voice setting resolution so we don't depend on the user's profile file. + monkeypatch.setattr(runner, "_resolve_voice_setting", lambda value: ("af_heart", "AM HQ Alt", None)) + + calls = [] + + class _Seg: + def __init__(self, audio): + self.audio = audio + + class DummyPipeline: + def __call__(self, text, **kwargs): + calls.append(kwargs.get("voice")) + audio = np.zeros(int(0.05 * runner.SAMPLE_RATE), dtype="float32") + yield _Seg(audio) + + monkeypatch.setattr(runner, "_load_pipeline", lambda language, use_gpu: DummyPipeline()) + + settings = { + "language": "en", + "default_voice": "profile:AM HQ Alt", + "use_gpu": False, + "default_speed": 1.0, + } + + manifest = runner.run_debug_tts_wavs(output_root=tmp_path, settings=settings) + assert manifest.get("run_id") + assert calls + # Must not pass through the profile:* string. + assert all(isinstance(v, str) and not v.lower().startswith("profile:") for v in calls)