fix(persona): preserve design kind + vd_states across share/import (Wave 5 §R3) (#405)
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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
151f73f794
commit
6704d062fc
@@ -258,13 +258,19 @@ async def community_use(item_id: str, name: Optional[str] = Query(None)):
|
||||
raise HTTPException(status_code=503, detail=f"Couldn't add this voice right now. Error: {e}")
|
||||
|
||||
try:
|
||||
# A community "preset" is a synthetic designed voice (rendered from an
|
||||
# instruct string) → kind='design'; a "voice" carries a real reference
|
||||
# clip → kind='clone'. Setting kind makes the persona-gallery
|
||||
# synthetic-only gating work (§R3) instead of defaulting all imports to
|
||||
# 'clone'.
|
||||
kind = "design" if item["type"] == "preset" else "clone"
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles "
|
||||
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, created_at, kind) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(profile_id, profile_name, audio_filename, ref_text, instruct,
|
||||
item.get("language", "Auto"), None, item["id"], time.time()),
|
||||
item.get("language", "Auto"), None, item["id"], time.time(), kind),
|
||||
)
|
||||
except Exception:
|
||||
with __import__("contextlib").suppress(OSError):
|
||||
|
||||
@@ -58,6 +58,31 @@ MAX_BUNDLE_BYTES = 100 * 1024 * 1024
|
||||
# ── Export ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _bundle_metadata(profile: dict, **extra) -> dict:
|
||||
"""Common .omnivoice metadata for export + publish.
|
||||
|
||||
Captures ``kind`` and ``vd_states`` so a *designed* persona survives the
|
||||
bundle round-trip as a design (not silently demoted to a clone) — required
|
||||
for the synthetic-only gating of the persona gallery (§R3). Old bundles
|
||||
without these keys import as ``kind='clone'`` (backward-compatible).
|
||||
"""
|
||||
meta = {
|
||||
"bundle_version": BUNDLE_VERSION,
|
||||
"profile_name": profile.get("name", "Unnamed"),
|
||||
"ref_text": profile.get("ref_text", ""),
|
||||
"instruct": profile.get("instruct", ""),
|
||||
"language": profile.get("language", "Auto"),
|
||||
"personality": profile.get("personality", ""),
|
||||
"seed": profile.get("seed"),
|
||||
"kind": profile.get("kind") or "clone",
|
||||
"vd_states": profile.get("vd_states"),
|
||||
"is_locked": bool(profile.get("is_locked")),
|
||||
"omnivoice_version": APP_VERSION,
|
||||
}
|
||||
meta.update(extra)
|
||||
return meta
|
||||
|
||||
|
||||
@router.post("/export/{profile_id}")
|
||||
def export_profile(profile_id: str):
|
||||
"""Export a voice profile as a downloadable .omnivoice bundle (ZIP)."""
|
||||
@@ -75,19 +100,9 @@ def export_profile(profile_id: str):
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
# Metadata
|
||||
metadata = {
|
||||
"bundle_version": BUNDLE_VERSION,
|
||||
"profile_name": profile.get("name", "Unnamed"),
|
||||
"ref_text": profile.get("ref_text", ""),
|
||||
"instruct": profile.get("instruct", ""),
|
||||
"language": profile.get("language", "Auto"),
|
||||
"personality": profile.get("personality", ""),
|
||||
"seed": profile.get("seed"),
|
||||
"is_locked": bool(profile.get("is_locked")),
|
||||
"created_at": profile.get("created_at"),
|
||||
"exported_at": time.time(),
|
||||
"omnivoice_version": APP_VERSION,
|
||||
}
|
||||
metadata = _bundle_metadata(
|
||||
profile, created_at=profile.get("created_at"), exported_at=time.time(),
|
||||
)
|
||||
zf.writestr("metadata.json", json.dumps(metadata, indent=2))
|
||||
|
||||
# Reference audio
|
||||
@@ -191,8 +206,9 @@ async def import_profile(
|
||||
conn.execute(
|
||||
"""INSERT INTO voice_profiles
|
||||
(id, name, ref_audio_path, ref_text, instruct, language,
|
||||
seed, personality, is_locked, locked_audio_path, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
seed, personality, is_locked, locked_audio_path, created_at,
|
||||
kind, vd_states)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
profile_id,
|
||||
metadata.get("profile_name", "Imported Voice"),
|
||||
@@ -205,6 +221,10 @@ async def import_profile(
|
||||
1 if is_locked else 0,
|
||||
locked_audio_filename or "",
|
||||
time.time(),
|
||||
# Preserve the design/clone distinction across the round-trip;
|
||||
# old bundles without these keys import as a clone.
|
||||
metadata.get("kind") or "clone",
|
||||
metadata.get("vd_states"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -253,19 +273,11 @@ def publish_to_marketplace(
|
||||
|
||||
# Build the bundle
|
||||
with zipfile.ZipFile(str(bundle_path), "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
metadata = {
|
||||
"bundle_version": BUNDLE_VERSION,
|
||||
"profile_name": profile.get("name", "Unnamed"),
|
||||
"ref_text": profile.get("ref_text", ""),
|
||||
"instruct": profile.get("instruct", ""),
|
||||
"language": profile.get("language", "Auto"),
|
||||
"personality": profile.get("personality", ""),
|
||||
"seed": profile.get("seed"),
|
||||
"is_locked": bool(profile.get("is_locked")),
|
||||
"tags": [t.strip() for t in tags.split(",") if t.strip()],
|
||||
"published_at": time.time(),
|
||||
"omnivoice_version": APP_VERSION,
|
||||
}
|
||||
metadata = _bundle_metadata(
|
||||
profile,
|
||||
tags=[t.strip() for t in tags.split(",") if t.strip()],
|
||||
published_at=time.time(),
|
||||
)
|
||||
zf.writestr("metadata.json", json.dumps(metadata, indent=2))
|
||||
|
||||
ref_path = profile.get("ref_audio_path")
|
||||
|
||||
@@ -1109,6 +1109,17 @@ PDF/txt/docx (M) → A5 inline tags + per-chapter voices (M) → A6 OCR + Calibr
|
||||
shell-out (M) → A7 book→Stories timeline round-trip (L) — the differentiator no
|
||||
surveyed tool has.
|
||||
|
||||
> **Status (2026-06-13):** the browse-preview-install surface already exists —
|
||||
> `VoiceGallery.jsx`'s Community zone over the `omnivoice-gallery` git manifest
|
||||
> (`backend/api/routers/community.py`), plus `.omnivoice` bundle export/import
|
||||
> (`marketplace.py`). The missing piece for the synthetic-only gate was data
|
||||
> integrity: imported community presets and bundle round-trips silently dropped
|
||||
> `kind`/`vd_states`, demoting designed personas to clones. Fixed — community
|
||||
> "preset" imports as `kind='design'` (a "voice" as `clone`), and bundles now
|
||||
> carry `kind`+`vd_states` (old bundles import as clone). This makes the
|
||||
> "accept only designed/synthetic" gate enforceable. Still to do: the consent
|
||||
> attestation + AudioSeal-on-preview gate and the curation workflow below.
|
||||
|
||||
**Persona gallery — the territory is genuinely unoccupied.** The field splits into
|
||||
consent-heavy commercial (ElevenLabs Voice Library: live-read Voice Captcha
|
||||
verification, human review, sharing limited to professional clones), a consent-free
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user