Files
VoiceStudio/backend/api/routers/community.py
T
Palash DebnathandClaude Opus 4.8 6704d062fc 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>
2026-06-13 02:21:43 +05:30

299 lines
12 KiB
Python

"""Community gallery (marketplace) API.
Loads designed *presets* and recorded *voices* from configured content repos
(default: ``debpalash/omnivoice-gallery``) over the jsDelivr CDN, caches them
locally, validates strictly, and exposes them to the gallery.
Design / safety
===============
* **Local-first.** The network is touched only when the user opens the
marketplace or hits refresh. Everything is cached under
``DATA_DIR/gallery_cache`` and served offline from cache; the app's built-in
generated archetypes need no network, so the gallery is never empty.
* **Data only, never code.** Remote content is JSON + audio. Presets are
validated against the engine's taxonomy and *dropped* if invalid (so a bad
community entry can't reproduce issue #89). Audio URLs are restricted to an
allow-list of hosts (jsDelivr / GitHub) — no arbitrary SSRF target.
* **Reuse.** "Use a preset" renders through the same path as archetypes
(one TTS code path).
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import re
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse
from fastapi import APIRouter, HTTPException, Query
from core import archetypes
from core.config import DATA_DIR
logger = logging.getLogger("omnivoice.community")
router = APIRouter()
_CACHE_DIR = Path(DATA_DIR) / "gallery_cache"
_DEFAULT_SOURCES = ["debpalash/omnivoice-gallery"]
_ALLOWED_AUDIO_HOSTS = {
"cdn.jsdelivr.net", "github.com", "raw.githubusercontent.com",
"objects.githubusercontent.com", "release-assets.githubusercontent.com",
}
_VALID_TOKENS = set(archetypes._VD._INSTRUCT_ALL_VALID)
_USE_CASE_IDS = {c["id"] for c in archetypes.USE_CASES}
_SOURCE_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$") # owner/repo only
# ── Config: which content repos to load ───────────────────────────────────────
def configured_sources() -> list[str]:
"""Gallery sources, in priority order. Env var > config file > default."""
env = os.environ.get("OMNIVOICE_GALLERY_SOURCES")
if env:
return [s.strip() for s in env.split(",") if s.strip()]
cfg = Path(DATA_DIR) / "gallery_sources.json"
if cfg.exists():
try:
data = json.loads(cfg.read_text(encoding="utf-8"))
srcs = data.get("sources")
if isinstance(srcs, list) and srcs:
return [str(s) for s in srcs]
except Exception:
logger.warning("gallery_sources.json unreadable; using default")
return list(_DEFAULT_SOURCES)
def _manifest_url(source: str) -> str:
return f"https://cdn.jsdelivr.net/gh/{source}@main/manifest.json"
def _cache_path(source: str) -> Path:
return _CACHE_DIR / source.replace("/", "__") / "manifest.json"
def _safe_audio_url(url: str) -> bool:
try:
u = urlparse(url or "")
return u.scheme == "https" and (u.hostname in _ALLOWED_AUDIO_HOSTS)
except Exception:
return False
def is_valid_instruct(instruct: str) -> bool:
toks = [t.strip() for t in (instruct or "").split(",") if t.strip()]
return bool(toks) and all(t in _VALID_TOKENS for t in toks)
def validate_item(raw: dict) -> Optional[dict]:
"""Return a normalized item, or None if it must be dropped."""
if not isinstance(raw, dict):
return None
it = dict(raw)
if it.get("type") not in ("preset", "voice"):
return None
if not it.get("id") or not it.get("name"):
return None
if it.get("use_case") not in _USE_CASE_IDS:
return None
if it["type"] == "preset" and not is_valid_instruct(it.get("instruct", "")):
return None # would crash synthesis — drop it
if it["type"] == "voice" and not _safe_audio_url((it.get("audio") or {}).get("url", "")):
return None
it.setdefault("facets", {})
it.setdefault("icon", archetypes._USE_ICON.get(it["use_case"], "Sparkles"))
it.setdefault("language", it.get("facets", {}).get("lang", "English"))
it["is_community"] = it.get("source") != "starter"
return it
def _merge(manifests: list[tuple[str, Optional[dict]]]) -> tuple[list, list]:
items, packs, seen = [], [], set()
for src, m in manifests:
if not m:
continue
for raw in (m.get("items") or []):
v = validate_item(raw)
if v and v["id"] not in seen:
v["_source_repo"] = src
seen.add(v["id"])
items.append(v)
for p in (m.get("packs") or []):
if isinstance(p, dict):
packs.append({**p, "_source_repo": src})
return items, packs
def _fetch_manifest(source: str, refresh: bool) -> Optional[dict]:
"""Return a source's manifest from cache, or fetch + cache it. None if both fail."""
cache = _cache_path(source)
if not refresh and cache.exists():
try:
return json.loads(cache.read_text(encoding="utf-8"))
except Exception:
pass
try:
import httpx
with httpx.Client(timeout=15.0, follow_redirects=True) as client:
resp = client.get(_manifest_url(source))
resp.raise_for_status()
data = resp.json()
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text(json.dumps(data), encoding="utf-8")
return data
except Exception as e: # offline / 404 / bad json
logger.warning("manifest fetch failed for %s: %s", source, e)
if cache.exists():
try:
return json.loads(cache.read_text(encoding="utf-8"))
except Exception:
pass
return None
def _load(refresh: bool) -> tuple[list[str], list, list, bool]:
srcs = configured_sources()
manifests = [(s, _fetch_manifest(s, refresh)) for s in srcs]
items, packs = _merge(manifests)
offline = all(m is None for _, m in manifests)
return srcs, items, packs, offline
# ── Endpoints ─────────────────────────────────────────────────────────────────
@router.get("/community/sources")
def community_sources():
"""The content repos the gallery loads from (default: omnivoice-gallery)."""
return {"sources": configured_sources()}
@router.get("/community/manifest")
def community_manifest(refresh: bool = Query(False)):
srcs, items, packs, offline = _load(refresh)
return {"sources": srcs, "packs": packs, "items": items, "count": len(items), "offline": offline}
@router.get("/community/items")
def community_items(
use_case: Optional[str] = None,
gender: Optional[str] = None,
item_type: Optional[str] = Query(None, alias="type"),
lang: Optional[str] = None,
q: Optional[str] = None,
limit: int = Query(60, ge=1, le=500),
offset: int = Query(0, ge=0),
refresh: bool = Query(False),
):
_, items, _, _ = _load(refresh)
def keep(it: dict) -> bool:
f = it.get("facets", {})
if use_case and it.get("use_case") != use_case:
return False
if gender and f.get("gender") != gender:
return False
if item_type and it.get("type") != item_type:
return False
if lang and it.get("language") != lang:
return False
if q and q.lower() not in (it.get("name", "").lower()):
return False
return True
items = [it for it in items if keep(it)]
return {"total": len(items), "limit": limit, "offset": offset, "items": items[offset:offset + limit]}
@router.get("/community/submit-url")
def community_submit_url(item_type: str = Query("preset", alias="type"), source: Optional[str] = Query(None)):
"""Build the prefilled GitHub submission URL (server-free, local-first)."""
src = source or configured_sources()[0]
if not _SOURCE_RE.match(src or ""):
src = configured_sources()[0] # ignore a malformed/untrusted source override
template = "preset-submission.yml" if item_type == "preset" else "voice-submission.yml"
return {"url": f"https://github.com/{src}/issues/new?template={template}"}
@router.post("/community/items/{item_id}/use")
async def community_use(item_id: str, name: Optional[str] = Query(None)):
"""Materialize a community item into a reusable voice profile.
Preset → render through the archetype engine. Voice → download the
(host-allow-listed, SHA-256-verified) reference clip. Both create a
``voice_profiles`` row usable everywhere voices are picked.
"""
_, items, _, _ = await asyncio.to_thread(_load, False)
item = next((it for it in items if it["id"] == item_id), None)
if item is None:
raise HTTPException(status_code=404, detail="Item not found in the gallery.")
import time
import uuid
from core import event_bus
from core.db import db_conn
from core.config import VOICES_DIR
profile_id = str(uuid.uuid4())[:8]
audio_filename = f"{profile_id}.wav"
audio_path = Path(VOICES_DIR) / audio_filename
profile_name = (name or item["name"]).strip() or item["name"]
instruct = item.get("instruct", "") if item["type"] == "preset" else ""
ref_text = item.get("sample_script") or (item.get("audio") or {}).get("ref_text", "")
try:
if item["type"] == "preset":
from api.routers.archetypes import _render_archetype_wav
pseudo = {
"instruct": instruct,
"language": item.get("language", "English"),
"sample_script": ref_text or "Hello — this is a preview of this voice.",
}
await _render_archetype_wav(pseudo, audio_path)
else: # voice — download the reference clip (off the event loop)
await asyncio.to_thread(_download_voice_audio, item, audio_path)
except HTTPException:
raise
except Exception as e:
logger.error("Community 'use' failed", exc_info=True)
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, kind) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(profile_id, profile_name, audio_filename, ref_text, instruct,
item.get("language", "Auto"), None, item["id"], time.time(), kind),
)
except Exception:
with __import__("contextlib").suppress(OSError):
os.remove(audio_path)
raise
event_bus.emit("profiles", {"action": "created", "id": profile_id})
return {"profile_id": profile_id, "name": profile_name}
def _download_voice_audio(item: dict, out_path: Path) -> None:
import hashlib
audio = item.get("audio") or {}
url = audio.get("url", "")
if not _safe_audio_url(url):
raise HTTPException(status_code=400, detail="Voice audio URL is not from an allowed host.")
import httpx
with httpx.Client(timeout=30.0, follow_redirects=True) as client:
resp = client.get(url)
resp.raise_for_status()
data = resp.content
expected = audio.get("sha256")
if expected and hashlib.sha256(data).hexdigest() != expected:
raise HTTPException(status_code=502, detail="Downloaded voice failed its integrity check.")
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_bytes(data)