Files
VoiceStudio/tests/test_profile_language_propagation.py
T
8ed76b40a6 fix(lang): propagate profile/request language into generation + longform (#533/#505/#502) (#565)
Non-English voices drifted to English/wrong-language because the request's or
profile's language wasn't reaching the model:

- #533: generate_speech() read instruct/ref_text/seed from a resolved profile row
  but never row['language'] (and collapsed Auto→None), so a German archetype
  previewed in German yet generated in English on the user's own call (and via
  Docker/API). Fall back to the profile's stored language when the request didn't
  pin one; an explicit non-Auto request language still wins. (Frontend already
  sets the dropdown on profile-select; this is the authoritative backend fix.)
- #505 (B2): the audiobook/longform synth hardcoded language=None, so the engine
  re-autodetected per chunk and a non-English clone flipped language mid-render.
  Add _resolve_default_language (request → profile → autodetect) and thread the
  resolved language through _build_synth/_prepare_synth/_render_longform_sse, the
  three longform request models, the preview path, and the resume manifest.
  Genuine Auto/unset behavior is unchanged.
- #502 (partial): the duration estimator weights combining marks (U+0300–036F)
  at 0.0, so NFD/decomposed text under-allocated frames → rushed audio. NFC-
  normalize text at the estimator entry — fixes the whole diacritic-script class
  (no-op for precomposed text). (The residual "distorted" core still needs the
  reporter's sample; tracked separately.)

Tests (fail-before/pass-after): profile language reaches the engine (German→de;
explicit/Auto override semantics); longform synth gets the resolved language
(→ja), not None; NFD vs NFC duration parity (Korean Hangul diverges ~3x pre-fix).
Full suite: tests/ 1740 passed, backend/tests/ 114 passed.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 14:45:58 +05:30

201 lines
6.9 KiB
Python

"""Non-English correctness: a voice profile's stored language must drive
generation, and the longform path must not hardcode language=None.
* #533 — POST /generate with a profile_id but no request language must thread
the *profile's* language into the engine (German archetype → German output,
not English). An explicit non-Auto request language still wins.
* #505 (B2) — the audiobook/longform synth callable hardcoded language=None,
so each chunk re-autodetected and a non-English clone drifted. The synth
must now carry the resolved language.
The engine layer is stubbed (no real model loads), matching
``tests/test_generate_engine.py``.
"""
import importlib
import os
import uuid
import pytest
import torch
os.environ.setdefault("OMNIVOICE_MODEL", "test")
os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1")
def _tts_mod():
return importlib.import_module("services.tts_backend")
def _make_fake_engine(engine_id="fake-lang-engine", gpu_compat=("cpu",)):
_compat = gpu_compat
class _FakeEngine(_tts_mod().TTSBackend):
id = engine_id
display_name = "Fake Lang Engine (test)"
applies_own_mastering = False
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):
return True, "ready"
def generate(self, text, **kw) -> torch.Tensor:
type(self).calls.append((text, kw))
return torch.zeros(1, 24000)
return _FakeEngine
@pytest.fixture()
def client():
from fastapi.testclient import TestClient
from main import app
return TestClient(app, client=("127.0.0.1", 50000))
@pytest.fixture()
def _init_db():
from core.db import init_db
init_db()
@pytest.fixture()
def german_profile(_init_db):
"""Insert a clone profile whose stored language is 'German', then remove it.
No ref_audio_path on disk → the resolver leaves ref_audio None, so the
engine still runs (the test asserts on the threaded `language`, not audio)."""
from core.db import db_conn
pid = f"vp-de-{uuid.uuid4().hex[:8]}"
with db_conn() as conn:
conn.execute(
"INSERT INTO voice_profiles (id, name, language, kind, created_at) "
"VALUES (?,?,?,?,?)",
(pid, "German Narrator", "German", "clone", 0.0),
)
yield pid
with db_conn() as conn:
# A successful /generate inserts a generation_history row FK-referencing
# the profile — clear dependents before the profile itself.
conn.execute("DELETE FROM generation_history WHERE profile_id=?", (pid,))
conn.execute("DELETE FROM voice_profiles WHERE id=?", (pid,))
# ── #533: profile language drives /generate ──────────────────────────────────
def test_generate_uses_profile_language_when_request_unset(client, monkeypatch, german_profile):
"""Request omits language → the profile's stored 'German' reaches the engine
(not None). Before the fix generation.py never read row['language']."""
fake = _make_fake_engine()
monkeypatch.setitem(_tts_mod()._REGISTRY, fake.id, fake)
fake.calls.clear()
res = client.post("/generate", data={
"text": "Guten Tag", "profile_id": german_profile, "engine": fake.id,
})
assert res.status_code == 200, res.text
assert len(fake.calls) == 1
_, kw = fake.calls[0]
# The engine receives the profile's language string; the model's
# _resolve_language maps 'German' → 'de'. Critically: NOT None.
assert kw.get("language") == "German"
def test_generate_explicit_language_overrides_profile(client, monkeypatch, german_profile):
"""An explicit non-Auto request language wins over the profile language."""
fake = _make_fake_engine()
monkeypatch.setitem(_tts_mod()._REGISTRY, fake.id, fake)
fake.calls.clear()
res = client.post("/generate", data={
"text": "Hello", "profile_id": german_profile, "engine": fake.id,
"language": "en",
})
assert res.status_code == 200, res.text
assert len(fake.calls) == 1
_, kw = fake.calls[0]
assert kw.get("language") == "en" # request wins, not 'German'
def test_generate_explicit_auto_falls_back_to_profile(client, monkeypatch, german_profile):
"""Explicit 'Auto' is request-unset → profile language still wins."""
fake = _make_fake_engine()
monkeypatch.setitem(_tts_mod()._REGISTRY, fake.id, fake)
fake.calls.clear()
res = client.post("/generate", data={
"text": "Guten Tag", "profile_id": german_profile, "engine": fake.id,
"language": "Auto",
})
assert res.status_code == 200, res.text
_, kw = fake.calls[0]
assert kw.get("language") == "German"
# ── #505 (B2): longform synth carries the language, never hardcoded None ──────
def test_resolve_default_language_request_wins():
from api.routers.audiobook import _resolve_default_language
assert _resolve_default_language("ja", None) == "ja"
assert _resolve_default_language("ja", "anything") == "ja"
def test_resolve_default_language_falls_back_to_profile(german_profile):
from api.routers.audiobook import _resolve_default_language
# No request language → the profile's stored language drives it.
assert _resolve_default_language(None, german_profile) == "German"
assert _resolve_default_language("Auto", german_profile) == "German"
def test_resolve_default_language_none_when_nothing(_init_db):
from api.routers.audiobook import _resolve_default_language
assert _resolve_default_language(None, None) is None
assert _resolve_default_language("Auto", None) is None
def test_build_synth_threads_language_into_generic_engine(monkeypatch):
"""The generic-engine synth callable must pass the resolved language to
backend.generate — before the fix it hardcoded language=None (#505 B2)."""
import api.routers.audiobook as ab
captured = {}
class _Backend:
sample_rate = 24000
def generate(self, text, **kw):
captured["language"] = kw.get("language")
return torch.zeros(1, 24000)
monkeypatch.setattr(ab, "_resolve_voice",
lambda pid: {"ref_audio": None, "ref_text": None,
"instruct": None, "seed": None})
monkeypatch.setattr("services.tts_backend.active_backend_id", lambda: "fake")
# _Backend is not OmniVoiceBackend → _build_synth takes the generic path.
monkeypatch.setattr("services.tts_backend.get_backend_class", lambda eid: _Backend)
info = ab._build_synth(default_voice="vp1", language="ja")
assert info["mode"] == "generic"
info["synth"]("こんにちは", "vp1")
assert captured["language"] == "ja" # NOT None