Files
VoiceStudio/tests/test_profile_unification.py
T
1575baca36 fix(design): heal validator-rejecting instruct on design voices (#594/#571/#596) (#600)
* fix(design): heal validator-rejecting instruct on design voices (#594/#571/#596)

A designed voice could persist an `instruct` the engine validator rejects —
either the literal "[object Object]" from a pre-fix build (#550) or freeform
prose typed into the style field — so every Generate/Dub that used the voice
failed with `Unsupported instruct items found in …` (400/500, and "Can't reach
the local backend" when it tore down mid-render). Migration 0006 only *blanked*
"[object Object]", which silently discarded the design — an Indonesian female
voice then rendered male (#594).

Fix the whole class by healing at every seam and rebuilding from the
authoritative source (the design's saved `vd_states` category picks):

- omnivoice/utils/voice_design.py: add sanitize_instruct / instruct_from_vd_states
  / heal_design_instruct — forgiving (never raise), drop poison/prose to valid
  tags, and rebuild tags from vd_states when the stored value is unusable.
- profiles.py: sanitize + rebuild at save (POST) and sanitize at edit (PUT), so
  no poisoned instruct can ever be persisted again.
- generation.py + dub_generate.py: heal whenever a profile drives synthesis, so
  legacy poisoned rows resolve to valid tags instead of 400-ing.
- migration 0007: heal existing profiles in place (recovers gender/age/pitch
  from vd_states), self-contained (frozen vocab snapshot) so it never drags
  torch into startup; supersedes 0006's blanking. Backward-compatible.

Tests: unit coverage for the healer, a migration test driving 0006->0007 on the
real schema, a parity guard so the frozen snapshot can't drift, and two API
guards. Corrected one existing test that had encoded the #594 behaviour.

Resolves #571, #594, #596; removes a major driver of the "Can't reach backend"
reports.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(cjk): allowlist migration 0007's frozen dialect-tag snapshot (#564)

The 0007 instruct-heal migration carries a frozen copy of the design-tag
whitelist (incl. Chinese dialect tags) so it stays self-contained; add it to
the hardcoded-CJK allowlist like omnivoice/utils/voice_design.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

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

251 lines
9.1 KiB
Python

"""Unified profiles (spec: docs/specs/voice-studio-unification.md §3/§5).
Endpoint validation tests run against an isolated tmp data dir; the design
render path is monkeypatched (no model in CI). Migration tests drive alembic
programmatically. Patterns mirror tests/test_profile_consent.py.
"""
import json
import os
import sqlite3
import pytest
os.environ.setdefault("OMNIVOICE_MODEL", "test")
os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1")
_FAKE_AUDIO = b"RIFF" + b"\x00" * 2000
_VD = {"Gender": "female", "Age": "young adult", "Pitch": "high pitch"}
@pytest.fixture(scope="module")
def app_client(tmp_path_factory):
"""TestClient with an isolated data dir (no lifespan — schema only)."""
mp = pytest.MonkeyPatch()
tmp_path = tmp_path_factory.mktemp("unified-profiles-data")
mp.setenv("OMNIVOICE_DATA_DIR", str(tmp_path))
import importlib
import core.config as _cfg
importlib.reload(_cfg)
import core.db as _db
importlib.reload(_db)
from api.routers import profiles as _profiles
importlib.reload(_profiles)
import main as _main
importlib.reload(_main)
_db.init_db()
from fastapi.testclient import TestClient
try:
yield TestClient(_main.app, client=("127.0.0.1", 50001)), _cfg
finally:
mp.undo()
@pytest.fixture()
def fake_render(monkeypatch):
"""Stub the design sample renderer — CI has no TTS engine."""
async def _fake(a, out_path):
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_bytes(_FAKE_AUDIO)
from api.routers import archetypes as _arch
monkeypatch.setattr(_arch, "_render_archetype_wav", _fake)
return _fake
# ── Create validation ────────────────────────────────────────────────────────
def test_clone_requires_ref_audio(app_client):
client, _ = app_client
r = client.post("/profiles", data={"name": "NoAudio"})
assert r.status_code == 422
def test_design_requires_vd_states(app_client):
client, _ = app_client
r = client.post("/profiles", data={"name": "D", "kind": "design", "instruct": "female, calm"})
assert r.status_code == 422
def test_design_saveable_without_instruct(app_client, fake_render):
"""An all-Auto design (no picks → empty instruct) is still saveable (#476).
Saving must not gate on a non-empty instruct — synthesis falls back to
neutral instruct-only conditioning.
"""
client, _ = app_client
all_auto = {"Gender": "Auto", "Age": "Auto", "Pitch": "Auto"}
r = client.post(
"/profiles",
data={"name": "AllAuto", "kind": "design", "vd_states": json.dumps(all_auto)},
)
assert r.status_code == 200, r.text
profile = client.get(f"/profiles/{r.json()['id']}").json()
assert profile["kind"] == "design"
assert (profile["instruct"] or "") == ""
def test_design_derives_instruct_from_picks_when_unset(app_client, fake_render):
"""A design saved with category picks but no explicit instruct must persist
a matching instruct derived from vd_states — otherwise the designed voice
renders neutral/wrong-gender (#594). Guards the save-time heal."""
client, _ = app_client
r = client.post(
"/profiles",
data={"name": "Designed", "kind": "design", "vd_states": json.dumps(_VD)},
)
assert r.status_code == 200, r.text
profile = client.get(f"/profiles/{r.json()['id']}").json()
assert profile["instruct"] == "female, young adult, high pitch"
def test_design_save_drops_object_object_instruct(app_client, fake_render):
"""The "[object Object]" poison can never be persisted — it's sanitized and
the design is rebuilt from vd_states at save time (#550 #571 #594)."""
client, _ = app_client
r = client.post(
"/profiles",
data={
"name": "Poisoned", "kind": "design",
"vd_states": json.dumps(_VD), "instruct": "[object Object]",
},
)
assert r.status_code == 200, r.text
profile = client.get(f"/profiles/{r.json()['id']}").json()
assert profile["instruct"] == "female, young adult, high pitch"
def test_design_rejects_malformed_vd_states(app_client):
client, _ = app_client
r = client.post(
"/profiles",
data={"name": "D", "kind": "design", "vd_states": "[1,2]", "instruct": "female"},
)
assert r.status_code == 422
def test_unknown_kind_rejected(app_client):
client, _ = app_client
r = client.post(
"/profiles",
data={"name": "X", "kind": "mystery"},
files={"ref_audio": ("x.wav", _FAKE_AUDIO, "audio/wav")},
)
assert r.status_code == 422
# ── Create happy paths ───────────────────────────────────────────────────────
def test_clone_create_defaults_kind(app_client):
client, _ = app_client
r = client.post(
"/profiles",
data={"name": "Clone Me"},
files={"ref_audio": ("me.wav", _FAKE_AUDIO, "audio/wav")},
)
assert r.status_code == 200, r.text
pid = r.json()["id"]
profile = client.get(f"/profiles/{pid}").json()
assert profile["kind"] == "clone"
assert profile["vd_states"] is None
def test_design_create_renders_sample_and_stores_params(app_client, fake_render):
client, cfg = app_client
r = client.post(
"/profiles",
data={
"name": "Designed",
"kind": "design",
"vd_states": json.dumps(_VD),
"instruct": "female, young adult, high pitch",
"language": "English",
},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["kind"] == "design"
profile = client.get(f"/profiles/{body['id']}").json()
assert profile["kind"] == "design"
assert json.loads(profile["vd_states"]) == _VD
assert profile["seed"] == 42 # deterministic identity sample
wav = os.path.join(cfg.VOICES_DIR, profile["ref_audio_path"])
assert os.path.exists(wav) and os.path.getsize(wav) > 0
# ── Migration 0005 ───────────────────────────────────────────────────────────
def _run_alembic(direction: str, db_path: str, target: str = "head"):
from alembic import command
from alembic.config import Config
here = os.path.abspath(os.path.dirname(__file__))
root = here
while root and root != "/" and not os.path.isfile(os.path.join(root, "alembic.ini")):
root = os.path.dirname(root)
assert os.path.isfile(os.path.join(root, "alembic.ini")), "alembic.ini not found"
cfg = Config(os.path.join(root, "alembic.ini"))
cfg.set_main_option("sqlalchemy.url", f"sqlite:///{db_path}")
if direction == "upgrade":
command.upgrade(cfg, target)
else:
command.downgrade(cfg, target)
def _columns(db, table):
with sqlite3.connect(str(db)) as conn:
return {row[1] for row in conn.execute(f"PRAGMA table_info({table})")}
def _make_0003_db(db):
"""A DB as it exists after 0003 (no kind/vd_states) with one legacy row."""
with sqlite3.connect(str(db)) as conn:
conn.execute(
"""CREATE TABLE voice_profiles (
id TEXT PRIMARY KEY, name TEXT NOT NULL, ref_audio_path TEXT,
ref_text TEXT DEFAULT '', instruct TEXT DEFAULT '',
language TEXT DEFAULT 'Auto', locked_audio_path TEXT DEFAULT '',
seed INTEGER DEFAULT NULL, is_locked INTEGER DEFAULT 0,
personality TEXT DEFAULT '', description TEXT DEFAULT '',
is_demo INTEGER DEFAULT 0, verified_own_voice INTEGER DEFAULT 0,
consent_text TEXT DEFAULT '', consent_audio_path TEXT DEFAULT '',
consent_recorded_at REAL DEFAULT NULL, created_at REAL
)"""
)
conn.execute(
"INSERT INTO voice_profiles (id, name, ref_audio_path, created_at) "
"VALUES ('legacy01', 'Old Voice', 'legacy01.wav', 1.0)"
)
conn.execute("CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)")
conn.execute(
"CREATE TABLE alembic_version (version_num VARCHAR(32) NOT NULL)"
)
conn.execute(
"INSERT INTO alembic_version VALUES ('0003_voice_profile_consent')"
)
def test_migration_0005_adds_columns_and_backfills(tmp_path):
db = tmp_path / "up.db"
_make_0003_db(db)
_run_alembic("upgrade", str(db))
cols = _columns(db, "voice_profiles")
assert {"kind", "vd_states"} <= cols
with sqlite3.connect(str(db)) as conn:
kind = conn.execute(
"SELECT kind FROM voice_profiles WHERE id='legacy01'"
).fetchone()[0]
assert kind == "clone"
def test_migration_0005_downgrade_drops_columns(tmp_path):
db = tmp_path / "down.db"
_make_0003_db(db)
_run_alembic("upgrade", str(db))
_run_alembic("downgrade", str(db), target="0003_voice_profile_consent")
cols = _columns(db, "voice_profiles")
assert "kind" not in cols and "vd_states" not in cols