The persona-gallery surface already exists (VoiceGallery Community zone + community.py manifest + marketplace .omnivoice bundles). The blocker for §R3's 'synthetic-only' gate was data integrity: a *designed* persona lost its kind='design' (and vd_states) when imported from the community gallery or round-tripped through a bundle — silently demoting it to a clone. - community.py /use: a 'preset' (rendered from instruct) imports as kind='design'; a 'voice' (real reference clip) as 'clone'. - marketplace.py: extract a pure _bundle_metadata() (dedupes export+publish) that captures kind + vd_states; import restores them. Old bundles without the keys import as 'clone' (backward-compatible). This makes 'accept only designed/synthetic voices' enforceable instead of everything defaulting to clone. No new persona-gallery feature was built — that would duplicate the existing community/marketplace surface. 4 torch-free tests (isolated DB): _bundle_metadata captures design + defaults to clone; import round-trip preserves design kind+vd_states; legacy bundle → clone. docs §R3 status updated. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
113 lines
3.8 KiB
Python
113 lines
3.8 KiB
Python
"""Persona-gallery kind preservation (parity §R3).
|
|
|
|
A *designed* (synthetic) voice persona must keep ``kind='design'`` when it
|
|
travels through a marketplace ``.omnivoice`` bundle — otherwise it silently
|
|
becomes a clone and the gallery's synthetic-only gating can't work. These
|
|
tests run torch-free (marketplace imports no model) against an isolated data
|
|
dir; the round-trip exercises the real export-metadata + import-INSERT paths.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import importlib
|
|
import io
|
|
import json
|
|
import os
|
|
import zipfile
|
|
|
|
import pytest
|
|
|
|
os.environ.setdefault("OMNIVOICE_MODEL", "test")
|
|
os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1")
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def iso(tmp_path_factory):
|
|
"""Isolated data dir + reloaded config/db/marketplace (no main, no torch)."""
|
|
mp = pytest.MonkeyPatch()
|
|
tmp = tmp_path_factory.mktemp("persona-kind-data")
|
|
mp.setenv("OMNIVOICE_DATA_DIR", str(tmp))
|
|
import core.config as cfg
|
|
importlib.reload(cfg)
|
|
import core.db as db
|
|
importlib.reload(db)
|
|
from api.routers import marketplace as mk
|
|
importlib.reload(mk)
|
|
db.init_db()
|
|
try:
|
|
yield cfg, db, mk
|
|
finally:
|
|
mp.undo()
|
|
|
|
|
|
# ── Pure metadata helper (export + publish) ──────────────────────────────────
|
|
|
|
def test_bundle_metadata_captures_design_kind_and_vd_states(iso):
|
|
_, _, mk = iso
|
|
profile = {
|
|
"name": "Aria", "kind": "design",
|
|
"vd_states": json.dumps({"Gender": "female", "Pitch": "high pitch"}),
|
|
"instruct": "female, high pitch", "seed": 7,
|
|
}
|
|
meta = mk._bundle_metadata(profile, exported_at=123.0)
|
|
assert meta["kind"] == "design"
|
|
assert json.loads(meta["vd_states"])["Gender"] == "female"
|
|
assert meta["exported_at"] == 123.0 # extras pass through
|
|
|
|
|
|
def test_bundle_metadata_defaults_to_clone(iso):
|
|
_, _, mk = iso
|
|
meta = mk._bundle_metadata({"name": "Rec"}) # no kind
|
|
assert meta["kind"] == "clone"
|
|
assert meta["vd_states"] is None
|
|
|
|
|
|
# ── Import round-trip preserves kind + vd_states ─────────────────────────────
|
|
|
|
def _make_bundle(metadata: dict) -> bytes:
|
|
buf = io.BytesIO()
|
|
with zipfile.ZipFile(buf, "w") as zf:
|
|
zf.writestr("metadata.json", json.dumps(metadata))
|
|
zf.writestr("ref_audio.wav", b"RIFF" + b"\x00" * 512)
|
|
return buf.getvalue()
|
|
|
|
|
|
def test_import_preserves_design_kind(iso):
|
|
cfg, db, mk = iso
|
|
from fastapi import UploadFile
|
|
|
|
data = _make_bundle({
|
|
"bundle_version": 1, "profile_name": "Imported Aria",
|
|
"kind": "design",
|
|
"vd_states": json.dumps({"Gender": "female"}),
|
|
"instruct": "female, high pitch", "seed": 9,
|
|
})
|
|
file = UploadFile(filename="aria.omnivoice", file=io.BytesIO(data))
|
|
result = asyncio.run(mk.import_profile(file))
|
|
|
|
with db.db_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT kind, vd_states FROM voice_profiles WHERE id=?",
|
|
(result["profile_id"],),
|
|
).fetchone()
|
|
assert row["kind"] == "design"
|
|
assert json.loads(row["vd_states"])["Gender"] == "female"
|
|
|
|
|
|
def test_import_legacy_bundle_defaults_to_clone(iso):
|
|
cfg, db, mk = iso
|
|
from fastapi import UploadFile
|
|
|
|
# An old bundle with no kind/vd_states keys must import as a clone.
|
|
data = _make_bundle({"bundle_version": 1, "profile_name": "Legacy"})
|
|
file = UploadFile(filename="legacy.omnivoice", file=io.BytesIO(data))
|
|
result = asyncio.run(mk.import_profile(file))
|
|
|
|
with db.db_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT kind, vd_states FROM voice_profiles WHERE id=?",
|
|
(result["profile_id"],),
|
|
).fetchone()
|
|
assert row["kind"] == "clone"
|
|
assert row["vd_states"] is None
|