* feat(persona): .ovsvoice build/parse core + embed_watermark(force=) (#29 slice A) Extends the merged persona-bundle nucleus (constants, normalize_spdx, build_manifest, build_consent_json) with the model-coupled core that the export/import router (next slice) will sit on: - `build_persona_bundle(profile, *, license_spdx, tags, include_reference, embed_fn, …)` → assembles the .ovsvoice ZIP in memory: a watermarked preview.wav (24 kHz mono 16-bit, downmixed + resampled + trimmed ≤8 s), manifest.json, a legacy-shaped metadata.json (so an older OmniVoice can still import the ref audio), optional consent.json, and the raw ref/locked/consent members unless include_reference=False (privacy / preview-only, A12). Raises NoPreviewSource (router → 503) when no source clip is readable (A2-A5). - `parse_persona_bundle(bytes)` → validates the ZIP, prefers manifest.json and falls back to legacy metadata.json, resolves audio members by prefix (last-wins, B9; member names never build paths — zip-slip safe), normalizes the SPDX id, flags preview-only / future-schema_version. Raises BundleError(400|413) for B1-B11. No DB, no file writes. - `ParsedPersona` dataclass with `extract_member(prefix, dest_path)` — the router derives dest_path from the server-generated id, never the member name. - `embed_watermark(..., *, force=False)`: keyword-only flag that bypasses the user's invisible-watermark preference for the mandatory persona preview, but still no-ops without AudioSeal. All existing positional call sites are unchanged (default force=False) — default cross-platform behaviour identical. All heavy imports (torch/torchaudio/watermark/audio_io) are lazy so the module stays model-free at collection (avoids the local torch/Triton segfault). tests/test_persona_bundle.py: +31 cases — parse validation (manifest/legacy selection, preview-only, future-schema, missing/malformed/no-audio → 400, oversize → 413, bad-SPDX normalize, last-wins dup, advisory consent), build round-trip (identity fields, metadata sibling, no-source → NoPreviewSource, include_reference=False, stereo/off-rate downmix+resample), and the force= unit (D1/D3). 25 pure cases pass locally; the 6 torchaudio-coupled cases run on CI (local torch+pytest segfault is pre-existing). CJK guard green. * feat(persona): /personas export·import·inspect router + wiring (#29 slice B) Thin HTTP layer over the persona_bundle service (slice A), registered in main.py next to the legacy marketplace router: - POST /personas/export/{id} → builds the .ovsvoice off the event loop (run_in_executor) and streams it (application/zip, .ovsvoice filename; empty name → persona_<id>). 404 when the profile is missing; NoPreviewSource → 503 (no readable source audio); any other build error → 503 with a generic message (no raw exception text in the body). - POST /personas/import → parse (BundleError → its HTTP status), extract audio members to server-named files ({id}{ext}/{id}_locked{ext}/{id}_consent{ext} — never the member name, zip-slip safe via profiles._voices_path), 17-column INSERT (legacy 13 + the 4 consent columns), event_bus emit after commit. Verified-own-voice is granted ONLY with a real recording ≥ floor AND non-empty consent_text AND consent.json present (forgery guard, B12-B16). Rollback: every written file is deleted on any extraction/INSERT failure; id-collision retries once (renaming the on-disk files to the new id). Accepts legacy .omnivoice too (case-insensitive extension guard). - POST /personas/inspect → manifest + consent summary with NO DB row and NO file extracted (import-preview UI). backend/tests/test_personas_api.py: 13 cases (config-stub pattern → mounts only the router, no main/torch import) — export 404; import bad-ext/non-zip/missing- manifest 400; round-trip row+file under server name; case-insensitive ext; forgery-unverified; verified-with-recording; short-recording-unverified; preview-only-as-ref; legacy .omnivoice; inspect no-write + consent summary. 13 passed locally. CJK guard green.
328 lines
13 KiB
Python
328 lines
13 KiB
Python
"""HTTP layer for the `.ovsvoice` portable persona format (#29 / parity §R3 G1).
|
|
|
|
Thin router over `services.persona_bundle`:
|
|
|
|
POST /personas/export/{profile_id} → stream a downloadable .ovsvoice
|
|
POST /personas/import → create a profile from a bundle
|
|
POST /personas/inspect → read a bundle's manifest, no writes
|
|
|
|
Mirrors the legacy `.omnivoice` endpoints (`marketplace.py`) and reuses the
|
|
same path-confinement (`_voices_path`) + consent floor. `.ovsvoice` is additive;
|
|
`.omnivoice` import stays a compatible legacy reader.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import functools
|
|
import logging
|
|
import os
|
|
import time
|
|
import uuid
|
|
|
|
from fastapi import APIRouter, File, HTTPException, Query, UploadFile
|
|
from fastapi.responses import StreamingResponse
|
|
|
|
from core import event_bus
|
|
from core.config import VOICES_DIR # noqa: F401 — re-exported for tests/monkeypatch
|
|
from core.db import db_conn
|
|
from core.version import APP_VERSION
|
|
from services import persona_bundle as pb
|
|
|
|
router = APIRouter()
|
|
logger = logging.getLogger("omnivoice.personas")
|
|
|
|
|
|
def _safe_name(name: str, profile_id: str) -> str:
|
|
"""Sanitised download filename stem (marketplace idiom); empty → persona_<id>."""
|
|
cleaned = "".join(
|
|
c if c.isalnum() or c in "-_ " else "" for c in (name or "")
|
|
).strip().replace(" ", "_")[:40]
|
|
return cleaned or f"persona_{profile_id}"
|
|
|
|
|
|
# ── Export ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
@router.post("/personas/export/{profile_id}")
|
|
async def export_persona(
|
|
profile_id: str,
|
|
license_spdx: str = Query(pb.DEFAULT_LICENSE),
|
|
tags: str = Query(""),
|
|
include_reference: bool = Query(True),
|
|
):
|
|
"""Build + stream a `.ovsvoice` bundle for a profile."""
|
|
with db_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT * FROM voice_profiles WHERE id = ?", (profile_id,)
|
|
).fetchone()
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Voice profile not found")
|
|
profile = dict(row)
|
|
|
|
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
|
|
try:
|
|
loop = asyncio.get_running_loop()
|
|
content = await loop.run_in_executor(
|
|
None,
|
|
functools.partial(
|
|
pb.build_persona_bundle,
|
|
profile,
|
|
license_spdx=license_spdx,
|
|
tags=tag_list,
|
|
include_reference=include_reference,
|
|
engine_id=os.environ.get("OMNIVOICE_MODEL", ""),
|
|
omnivoice_version=APP_VERSION,
|
|
),
|
|
)
|
|
except pb.NoPreviewSource:
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="This profile has no readable reference or locked audio to "
|
|
"build a preview from — re-create or re-import it.",
|
|
)
|
|
except Exception:
|
|
logger.exception("persona export failed for %s", profile_id)
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="Could not build the persona bundle — see Settings → Logs.",
|
|
)
|
|
|
|
filename = f"{_safe_name(profile.get('name'), profile_id)}.ovsvoice"
|
|
from io import BytesIO
|
|
return StreamingResponse(
|
|
BytesIO(content),
|
|
media_type="application/zip",
|
|
headers={
|
|
"Content-Disposition": f'attachment; filename="{filename}"',
|
|
"Content-Length": str(len(content)),
|
|
},
|
|
)
|
|
|
|
|
|
# ── Import ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _voices_dest(filename: str) -> str:
|
|
"""Resolve an output filename inside VOICES_DIR; 400 on escape (belt+braces —
|
|
the name is always server-generated `{profile_id}…`)."""
|
|
from api.routers.profiles import _voices_path
|
|
path = _voices_path(filename)
|
|
if path is None:
|
|
raise HTTPException(status_code=400, detail="Invalid profile id")
|
|
return path
|
|
|
|
|
|
def _consent_verified(parsed: pb.ParsedPersona, consent_path: str | None) -> bool:
|
|
"""B12-B16: trust verified-own-voice ONLY with a real recording (≥ floor) AND
|
|
non-empty consent_text AND a consent.json present. The manifest flag alone
|
|
can't forge it."""
|
|
if not parsed.consent or not consent_path:
|
|
return False
|
|
if os.path.getsize(consent_path) < pb._MIN_CONSENT_AUDIO_BYTES:
|
|
return False
|
|
return bool((parsed.consent.get("consent_text") or "").strip())
|
|
|
|
|
|
@router.post("/personas/import")
|
|
async def import_persona(file: UploadFile = File(...)):
|
|
"""Create a new voice profile from a `.ovsvoice` (or legacy `.omnivoice`) bundle."""
|
|
name = (file.filename or "").lower()
|
|
if not name.endswith(".ovsvoice") and not name.endswith(".omnivoice"):
|
|
raise HTTPException(status_code=400, detail="File must be a .ovsvoice or .omnivoice bundle")
|
|
|
|
content = await file.read()
|
|
try:
|
|
parsed = pb.parse_persona_bundle(content)
|
|
except pb.BundleError as e:
|
|
raise HTTPException(status_code=e.status, detail=e.detail)
|
|
|
|
persona = parsed.manifest.get("persona") or {}
|
|
written: list[str] = []
|
|
|
|
def _gen_id() -> str:
|
|
return str(uuid.uuid4())[:8]
|
|
|
|
profile_id = _gen_id()
|
|
try:
|
|
# ── Audio members → server-named files (never the member name). ──
|
|
ref_filename = None
|
|
locked_filename = None
|
|
if "ref_audio" in parsed.members:
|
|
ref_filename = f"{profile_id}{parsed.member_ext('ref_audio')}"
|
|
dest = _voices_dest(ref_filename)
|
|
parsed.extract_member("ref_audio", dest); written.append(dest)
|
|
if "locked_audio" in parsed.members:
|
|
locked_filename = f"{profile_id}_locked{parsed.member_ext('locked_audio')}"
|
|
dest = _voices_dest(locked_filename)
|
|
parsed.extract_member("locked_audio", dest); written.append(dest)
|
|
# Preview-only bundle (A12/B8): use the preview as the usable ref clip.
|
|
if ref_filename is None and locked_filename is None and "preview" in parsed.members:
|
|
ref_filename = f"{profile_id}{parsed.member_ext('preview')}"
|
|
dest = _voices_dest(ref_filename)
|
|
parsed.extract_member("preview", dest); written.append(dest)
|
|
if ref_filename is None and locked_filename is None:
|
|
raise HTTPException(status_code=400, detail="bundle has no usable audio")
|
|
|
|
# ── Consent recording (optional) ──
|
|
consent_filename = None
|
|
consent_path = None
|
|
if "consent_audio" in parsed.members:
|
|
consent_filename = f"{profile_id}_consent{parsed.member_ext('consent_audio')}"
|
|
consent_path = _voices_dest(consent_filename)
|
|
parsed.extract_member("consent_audio", consent_path); written.append(consent_path)
|
|
|
|
verified = _consent_verified(parsed, consent_path)
|
|
consent_text = ((parsed.consent or {}).get("consent_text") or "").strip()
|
|
recorded_at = None
|
|
if verified:
|
|
try:
|
|
recorded_at = float(parsed.consent.get("recorded_at"))
|
|
except (TypeError, ValueError):
|
|
recorded_at = time.time()
|
|
|
|
is_locked = bool(persona.get("is_locked") and locked_filename)
|
|
ref_for_db = ref_filename or locked_filename # at least one is set
|
|
|
|
def _insert(pid: str):
|
|
with db_conn() as conn:
|
|
conn.execute(
|
|
"""INSERT INTO voice_profiles
|
|
(id, name, ref_audio_path, ref_text, instruct, language,
|
|
seed, personality, is_locked, locked_audio_path, created_at,
|
|
kind, vd_states,
|
|
verified_own_voice, consent_text, consent_audio_path, consent_recorded_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
pid,
|
|
persona.get("name") or "Imported Voice",
|
|
ref_for_db,
|
|
persona.get("ref_text", ""),
|
|
persona.get("instruct", ""),
|
|
persona.get("language", "Auto"),
|
|
persona.get("seed"),
|
|
persona.get("personality", ""),
|
|
1 if is_locked else 0,
|
|
locked_filename or "",
|
|
time.time(),
|
|
persona.get("kind") or "clone",
|
|
persona.get("vd_states"),
|
|
1 if verified else 0,
|
|
# Keep the attestation text so the user can re-attest locally,
|
|
# even when imported unverified.
|
|
consent_text,
|
|
consent_filename if verified else "",
|
|
recorded_at if verified else None,
|
|
),
|
|
)
|
|
|
|
import sqlite3
|
|
try:
|
|
_insert(profile_id)
|
|
except sqlite3.IntegrityError:
|
|
profile_id = _gen_id() # one retry on id collision (B20)
|
|
# rename the on-disk files to the new id so they still match the row
|
|
written = _rename_for_new_id(written, profile_id)
|
|
ref_for_db = _retarget(ref_for_db, profile_id)
|
|
locked_filename = _retarget(locked_filename, profile_id)
|
|
consent_filename = _retarget(consent_filename, profile_id)
|
|
_insert(profile_id)
|
|
|
|
except HTTPException:
|
|
_cleanup(written)
|
|
raise
|
|
except Exception:
|
|
_cleanup(written)
|
|
logger.exception("persona import failed")
|
|
raise HTTPException(status_code=500, detail="Import failed; no files were kept.")
|
|
|
|
event_bus.emit("profiles", {"action": "created", "id": profile_id})
|
|
logger.info("Imported persona %r as %s (verified=%s)", persona.get("name"), profile_id, verified)
|
|
|
|
return {
|
|
"success": True,
|
|
"profile_id": profile_id,
|
|
"name": persona.get("name") or "Imported Voice",
|
|
"kind": persona.get("kind") or "clone",
|
|
"verified_own_voice": verified,
|
|
"preview_only": parsed.preview_only,
|
|
"license_spdx": parsed.license_spdx,
|
|
"watermarked_preview": parsed.watermarked_preview,
|
|
"source_bundle": file.filename,
|
|
"schema_version_ahead": parsed.schema_version_ahead,
|
|
}
|
|
|
|
|
|
def _cleanup(paths: list[str]) -> None:
|
|
for p in paths:
|
|
try:
|
|
if p and os.path.exists(p):
|
|
os.remove(p)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _rename_for_new_id(written: list[str], new_id: str) -> list[str]:
|
|
"""After an id-collision retry, rename each written file to carry the new id
|
|
(filenames are `{old_id}…`; swap the leading 8-char stem)."""
|
|
out = []
|
|
for p in written:
|
|
d, base = os.path.split(p)
|
|
# base looks like {id}{ext} | {id}_locked{ext} | {id}_consent{ext}
|
|
new_base = new_id + base[8:]
|
|
new_path = os.path.join(d, new_base)
|
|
try:
|
|
os.replace(p, new_path)
|
|
out.append(new_path)
|
|
except OSError:
|
|
out.append(p)
|
|
return out
|
|
|
|
|
|
def _retarget(filename: str | None, new_id: str) -> str | None:
|
|
return new_id + filename[8:] if filename else filename
|
|
|
|
|
|
# ── Inspect (no-write preview) ──────────────────────────────────────────────
|
|
|
|
|
|
@router.post("/personas/inspect")
|
|
async def inspect_persona(file: UploadFile = File(...)):
|
|
"""Read a bundle's manifest + consent summary WITHOUT writing any file or row."""
|
|
name = (file.filename or "").lower()
|
|
if not name.endswith(".ovsvoice") and not name.endswith(".omnivoice"):
|
|
raise HTTPException(status_code=400, detail="File must be a .ovsvoice or .omnivoice bundle")
|
|
content = await file.read()
|
|
try:
|
|
parsed = pb.parse_persona_bundle(content)
|
|
except pb.BundleError as e:
|
|
raise HTTPException(status_code=e.status, detail=e.detail)
|
|
|
|
persona = parsed.manifest.get("persona") or {}
|
|
consent_summary = None
|
|
if parsed.consent:
|
|
has_recording = "consent_audio" in parsed.members
|
|
consent_summary = {
|
|
"verified_claimed": bool(parsed.consent.get("verified_own_voice")),
|
|
"method": parsed.consent.get("method", ""),
|
|
"has_recording": has_recording,
|
|
# would_verify mirrors import's gate, minus the byte-floor check
|
|
# (inspect never extracts to measure size — advisory only).
|
|
"would_verify": has_recording and bool((parsed.consent.get("consent_text") or "").strip()),
|
|
}
|
|
|
|
return {
|
|
"format": "omnivoice-legacy" if parsed.is_legacy else pb.OVSVOICE_FORMAT,
|
|
"schema_version": parsed.manifest.get("schema_version", pb.OVSVOICE_SCHEMA_VERSION),
|
|
"name": persona.get("name") or "Imported Voice",
|
|
"kind": persona.get("kind") or "clone",
|
|
"language": persona.get("language", "Auto"),
|
|
"personality": persona.get("personality", ""),
|
|
"is_locked": bool(persona.get("is_locked")),
|
|
"license_spdx": parsed.license_spdx,
|
|
"tags": parsed.manifest.get("tags") or [],
|
|
"preview_only": parsed.preview_only,
|
|
"watermarked_preview": parsed.watermarked_preview,
|
|
"consent": consent_summary,
|
|
"schema_version_ahead": parsed.schema_version_ahead,
|
|
}
|