* 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>
251 lines
9.1 KiB
Python
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
|