Files
VoiceStudio/tests/test_dub_batch_engine_selection.py
T
8281b7c798 fix(engines): dub and batch TTS honor the active-engine selection (#987)
* fix(engines): dub and batch TTS honor the active-engine selection — with a real capability gate, not a silent OmniVoice fallback

Dub generation and batch TTS hardcoded services.model_manager.get_model()
(OmniVoice) regardless of the engine picked in Settings → Engines. A user
selecting VoxCPM2 (or any other engine) still got OmniVoice output with no
error — the silent fallback IS the bug class, not just the one report.

Root-caused and fixed for the whole class:
  - New `TTSBackend.supports_cloning` capability flag (default True) marks
    engines that can only offer fixed preset voices — kittentts,
    supertonic3, sherpa-onnx set it False. MLXAudioBackend exposes it as an
    instance property (Kokoro doesn't clone, CSM does) since the adapter
    multiplexes multiple models with different capabilities.
  - `cloning_capable_engine_ids()` and a shared `resolve_generation_backend()`
    helper in services/tts_backend.py centralize engine resolution
    (id → is_available() → routing gate → optional cloning gate), mirroring
    generation.py's /generate resolution instead of inventing a third
    parallel mechanism. Both routers now standardize on the existing
    get_active_tts_backend() cache (unload-on-switch already handled).
  - dub_generate.py's two TTS-generate call sites (main run + OOM retry) and
    the /dub/preview-segment route resolve once, up front, with
    require_cloning=True — dub's ref_audio is populated for essentially
    every real job, so an engine that can't clone fails the whole job with
    one actionable message instead of mis-cloning per segment.
  - batch.py resolves once per job, require_cloning only when voice_id is
    pinned — an unpinned batch job runs fine on any engine.
  - Applied the three pre-existing TODO(#312) comments: mastering now skips
    via `applies_own_mastering` for both pipelines, matching generation.py.

Regression tests cover the capability-id list, the fail-fast gate (proving
no OmniVoice fallback), the success path on a selected non-OmniVoice
engine, batch's pinned-vs-unpinned voice_id behavior, and the mastering
skip for both pipelines. Three existing dub tests that mocked get_model()
directly were updated to mock the new resolver instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(engines): exclude model-dependent adapters from cloning_capable_engine_ids()

getattr(cls, "supports_cloning", True) at the CLASS level returns a
property descriptor object (always truthy) when the flag is an instance
@property, not a plain attribute — MLXAudioBackend uses exactly this
pattern because its cloning capability depends on which of its 7+ curated
models is loaded (only CSM clones; Kokoro etc. don't). Without this fix,
the dub/batch capability-gate error message would always recommend
'switch to mlx-audio' even when the user's configured MLX model can't
clone, sending them in a circle back to the same error.

isinstance(value, bool) distinguishes a resolved boolean from a
descriptor object, so mlx-audio is excluded from the suggestion list
until its actual per-instance capability can be checked (already handled
correctly by resolve_generation_backend()'s per-call instance check).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(changelog): engine-aware dub/batch entry (#987)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 03:42:14 +05:30

346 lines
13 KiB
Python

"""Issue #312 class — dub generation and batch TTS must honor the active
engine selection (Settings → Engines) instead of silently falling back to
OmniVoice via services.model_manager.get_model(), and must refuse — with an
actionable error naming alternatives — instead of mis-cloning when the
active engine can't do reference-audio voice cloning.
Covers:
- `cloning_capable_engine_ids()` excludes the fixed-preset-voice engines
(kittentts, supertonic3, sherpa-onnx) and includes the cloning ones.
- /dub/generate: a non-cloning active engine fails the whole job with one
actionable message (never falls back to OmniVoice, never mis-clones
per segment).
- /dub/generate: a cloning-capable non-OmniVoice active engine actually
runs the request (proves the engine selection is honored, not ignored).
- batch: an unpinned voice_id runs fine on a non-cloning active engine;
a pinned voice_id on the same engine fails fast, before any TTS runs.
- `applies_own_mastering` still skips the shared mastering chain for both
pipelines (mirrors test_generate_engine.py's coverage of the same knob
for /generate).
"""
from __future__ import annotations
import asyncio
import importlib
import os
os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1")
import pytest
import torch
from fastapi import HTTPException
from schemas.requests import DubRequest, DubSegment
def _tts_mod():
"""Resolve services.tts_backend at RUN time — see test_generate_engine.py's
docstring for why (sys.modules pre-pollution across the collected suite)."""
return importlib.import_module("services.tts_backend")
def _make_fake_engine(engine_id, *, supports_cloning=True, available=True,
own_mastering=False, gpu_compat=("cpu",)):
tb = _tts_mod()
# Class-body assignment can't read the same name from the enclosing
# function scope (class bodies don't close over locals) — alias first,
# matching test_generate_engine.py's _make_fake_engine convention.
_cloning, _mastering, _compat = supports_cloning, own_mastering, gpu_compat
class _FakeEngine(tb.TTSBackend):
id = engine_id
display_name = f"Fake {engine_id} (test)"
supports_cloning = _cloning
applies_own_mastering = _mastering
gpu_compat = _compat
calls: list = []
@property
def sample_rate(self) -> int:
return 24000
@property
def supported_languages(self) -> list[str]:
return ["multi"]
@classmethod
def is_available(cls):
if available:
return True, "ready"
return False, "fake engine deliberately unavailable (test)"
def generate(self, text, **kw) -> torch.Tensor:
type(self).calls.append((text, kw))
return torch.zeros(1, 24000)
return _FakeEngine
@pytest.fixture
def fake_registry(monkeypatch):
"""Register a fake engine in the REAL registry so resolve_generation_backend
exercises the actual lookup/is_available/routing/cloning chain, not a stub.
Resets the MM2-01 active-backend cache before/after (see
tests/test_mm2_lifecycle.py's convention) so one test's cached instance
can't leak into the next."""
tb = _tts_mod()
tb.reset_active_backend()
registered: list[str] = []
def _register(engine_id, **kw):
cls = _make_fake_engine(engine_id, **kw)
tb._REGISTRY[engine_id] = cls
registered.append(engine_id)
return cls
yield _register
tb.reset_active_backend()
for engine_id in registered:
tb._REGISTRY.pop(engine_id, None)
@pytest.fixture
def no_omnivoice_model_manager(monkeypatch):
"""Fail loudly if resolution falls back to OmniVoice's get_model() path."""
import services.model_manager as mm
async def _boom():
raise AssertionError(
"services.model_manager.get_model() was called — engine "
"selection was silently ignored (#312 class)"
)
monkeypatch.setattr(mm, "get_model", _boom)
# ── cloning_capable_engine_ids() ────────────────────────────────────────────
def test_cloning_capable_engine_ids_excludes_fixed_voice_engines():
tb = _tts_mod()
ids = set(tb.cloning_capable_engine_ids())
assert ids.isdisjoint({"kittentts", "supertonic3", "sherpa-onnx"})
assert {"omnivoice", "voxcpm2", "cosyvoice", "gpt-sovits"}.issubset(ids)
def test_cloning_capable_engine_ids_excludes_model_dependent_adapters():
# MLXAudioBackend.supports_cloning is an instance @property (only some of
# its curated models can clone) — a class-level getattr() returns the
# property descriptor itself, which is truthy, so a naive check would
# always recommend "mlx-audio" even when the configured model is Kokoro
# (can't clone). Must be excluded from the suggestion list rather than
# falsely recommended.
tb = _tts_mod()
assert isinstance(
vars(tb.MLXAudioBackend).get("supports_cloning"), property
), "this test assumes MLXAudioBackend.supports_cloning is a property"
assert "mlx-audio" not in set(tb.cloning_capable_engine_ids())
# ── /dub/generate/{job_id} ──────────────────────────────────────────────────
@pytest.fixture
def dub_job_env(monkeypatch, tmp_path):
"""Minimal hermetic environment for `dg.dub_generate()` — same stub set as
test_smart_fit_generate.py's fixture, but WITHOUT patching
resolve_generation_backend, so the real registry + capability gate run."""
import api.routers.dub_generate as dg
job = {"duration": 2.0, "dubbed_tracks": {}, "speaker_clones": {}}
job_dir = tmp_path / "jobX"
job_dir.mkdir()
monkeypatch.setattr(dg, "_get_job", lambda job_id: job)
monkeypatch.setattr(dg, "_save_job", lambda job_id, j: None)
monkeypatch.setattr(dg, "DUB_DIR", str(tmp_path))
monkeypatch.setattr(
dg, "dub_seg_path",
lambda job_id, seg_id: str(job_dir / f"seg_{seg_id}.wav"),
)
monkeypatch.setattr(dg, "rvc_is_enabled", lambda: False)
monkeypatch.setattr(dg, "embed_watermark", lambda wav, sr: wav)
monkeypatch.setattr(dg, "apply_mastering", lambda a, sample_rate=None: a)
monkeypatch.setattr(dg, "get_effect_chain", lambda preset: None)
monkeypatch.setattr(dg, "apply_effects_chain", lambda a, **k: a)
monkeypatch.setattr(dg, "normalize_audio", lambda a, target_dBFS=None: a)
class _StubTaskManager:
def is_cancelled(self, task_id):
return False
async def add_task(self, task_id, task_type, func, *args, **kwargs):
async for _ in func(*args):
pass
monkeypatch.setattr(dg, "task_manager", _StubTaskManager())
return dg, job
def _one_seg_request():
return DubRequest(
segments=[DubSegment(start=0.0, end=1.0, text="hola")],
segment_ids=["0"], language="Auto", language_code="es", num_step=4,
)
def test_dub_generate_fails_fast_for_non_cloning_engine(
dub_job_env, fake_registry, no_omnivoice_model_manager, monkeypatch,
):
"""Active engine can't clone → the job fails once, up front, with an
actionable message naming alternatives — never a silent OmniVoice run."""
dg, job = dub_job_env
fake_registry("fake-nonclone", supports_cloning=False)
monkeypatch.setenv("OMNIVOICE_TTS_BACKEND", "fake-nonclone")
with pytest.raises(HTTPException) as exc_info:
asyncio.run(dg.dub_generate("jobX", _one_seg_request()))
assert exc_info.value.status_code == 400
detail = exc_info.value.detail
assert "fake-nonclone" in detail
assert "voice cloning" in detail
assert "omnivoice" in detail # names a real alternative
def test_dub_generate_uses_selected_cloning_engine_not_omnivoice(
dub_job_env, fake_registry, no_omnivoice_model_manager, monkeypatch,
):
"""A cloning-capable non-OmniVoice engine actually runs the segment."""
dg, job = dub_job_env
fake = fake_registry("fake-clone", supports_cloning=True)
monkeypatch.setenv("OMNIVOICE_TTS_BACKEND", "fake-clone")
asyncio.run(dg.dub_generate("jobX", _one_seg_request()))
assert len(fake.calls) == 1
assert fake.calls[0][0] == "hola"
assert "es" in job["dubbed_tracks"]
def test_dub_generate_respects_applies_own_mastering(
dub_job_env, fake_registry, no_omnivoice_model_manager, monkeypatch,
):
dg, job = dub_job_env
fake = fake_registry("fake-studio", supports_cloning=True, own_mastering=True)
monkeypatch.setenv("OMNIVOICE_TTS_BACKEND", "fake-studio")
mastering_calls = []
monkeypatch.setattr(
dg, "apply_mastering",
lambda a, sample_rate=None: mastering_calls.append(1) or a,
)
asyncio.run(dg.dub_generate("jobX", _one_seg_request()))
assert len(fake.calls) == 1
assert mastering_calls == [] # studio engine's own mastering is not double-applied
# ── batch ────────────────────────────────────────────────────────────────
@pytest.fixture
def batch_job_env(monkeypatch, tmp_path):
import api.routers.batch as b
monkeypatch.setattr(b, "DATA_DIR", str(tmp_path))
async def _fake_run_transcribe_guarded(pool, fn, what=None):
# Bypass real ASR entirely — the engine-selection gate under test
# runs right after transcription, before translate/generate.
return (
[{"id": "s0", "start": 0.0, "end": 1.0, "text": "hola",
"text_original": "hola"}],
"en",
)
monkeypatch.setattr(
"services.asr_backend.run_transcribe_guarded",
_fake_run_transcribe_guarded,
)
def _fake_subprocess_run(cmd, *a, **kw):
class _Result:
stdout = b""
stderr = b"Duration: 00:00:02.00, start: 0.000000, bitrate: 1000 kb/s\n"
return _Result()
monkeypatch.setattr("subprocess.run", _fake_subprocess_run)
monkeypatch.setattr("services.ffmpeg_utils.find_ffmpeg", lambda: "ffmpeg")
def _make_job(job_id, *, voice_id=None):
return {
"id": job_id,
"status": "running",
"filename": "in.mp4",
"video_path": str(tmp_path / "in.mp4"),
"langs": ["en"], # == source_lang → translation stage is a no-op
"voice_id": voice_id,
"preserve_bg": True,
"created_at": 0.0,
"started_at": None,
"finished_at": None,
"error": None,
"progress": None,
}
return b, _make_job
def test_batch_unpinned_voice_succeeds_on_noncloning_engine(
batch_job_env, fake_registry, no_omnivoice_model_manager, monkeypatch,
):
"""No voice_id pinned → any active engine (cloning-capable or not) is fine."""
b, make_job = batch_job_env
fake = fake_registry("fake-batch-nonclone", supports_cloning=False)
monkeypatch.setenv("OMNIVOICE_TTS_BACKEND", "fake-batch-nonclone")
job = make_job("jobA", voice_id=None)
asyncio.run(b._run_batch_pipeline("jobA", job))
assert len(fake.calls) == 1
assert "en" in job.get("outputs", {})
def test_batch_pinned_voice_fails_fast_on_noncloning_engine(
batch_job_env, fake_registry, monkeypatch,
):
"""voice_id pinned + a non-cloning active engine → fail before any TTS
runs, with the same actionable message shape as the dub gate."""
b, make_job = batch_job_env
fake = fake_registry("fake-batch-nonclone2", supports_cloning=False)
monkeypatch.setenv("OMNIVOICE_TTS_BACKEND", "fake-batch-nonclone2")
job = make_job("jobB", voice_id="some-voice-id")
with pytest.raises(ValueError) as exc_info:
asyncio.run(b._run_batch_pipeline("jobB", job))
detail = str(exc_info.value)
assert "fake-batch-nonclone2" in detail
assert "voice cloning" in detail
assert not fake.calls # never reached generate
def test_batch_respects_applies_own_mastering(
batch_job_env, fake_registry, no_omnivoice_model_manager, monkeypatch,
):
b, make_job = batch_job_env
fake = fake_registry("fake-batch-studio", supports_cloning=True, own_mastering=True)
monkeypatch.setenv("OMNIVOICE_TTS_BACKEND", "fake-batch-studio")
mastering_calls = []
import services.audio_dsp as audio_dsp
monkeypatch.setattr(
audio_dsp, "apply_mastering",
lambda a, sample_rate=None: mastering_calls.append(1) or a,
)
job = make_job("jobC", voice_id=None)
asyncio.run(b._run_batch_pipeline("jobC", job))
assert len(fake.calls) == 1
assert mastering_calls == []