diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..6bdeadb9 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "omnivoice-gallery"] + path = omnivoice-gallery + url = https://github.com/debpalash/omnivoice-gallery.git diff --git a/backend/api/routers/community.py b/backend/api/routers/community.py new file mode 100644 index 00000000..ec3800e8 --- /dev/null +++ b/backend/api/routers/community.py @@ -0,0 +1,292 @@ +"""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: + 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 (?, ?, ?, ?, ?, ?, ?, ?, ?)", + (profile_id, profile_name, audio_filename, ref_text, instruct, + item.get("language", "Auto"), None, item["id"], time.time()), + ) + 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) diff --git a/backend/core/archetypes.py b/backend/core/archetypes.py index f9800314..284388bf 100644 --- a/backend/core/archetypes.py +++ b/backend/core/archetypes.py @@ -82,14 +82,17 @@ _DIALECTS_SORTED = sorted(_CAT[5]) # 12 Chinese dialects # ── Use-case categories (replaces the named-real-person buckets) ────────────── +# `icon` values are lucide-react component names; the frontend maps them to SVG +# components (see frontend/src/utils/archetypeIcons.jsx). No emoji — they render +# inconsistently across OSes. USE_CASES = [ - {"id": "narration", "name": "Narration & Story", "icon": "📖"}, - {"id": "conversational", "name": "Conversational", "icon": "💬"}, - {"id": "characters", "name": "Characters & Animation", "icon": "🎭"}, - {"id": "social", "name": "Social Media", "icon": "📱"}, - {"id": "entertainment", "name": "Entertainment & TV", "icon": "📺"}, - {"id": "advertisement", "name": "Advertisement", "icon": "📣"}, - {"id": "informative", "name": "Informative & Educational", "icon": "🎓"}, + {"id": "narration", "name": "Narration & Story", "icon": "BookOpen"}, + {"id": "conversational", "name": "Conversational", "icon": "MessagesSquare"}, + {"id": "characters", "name": "Characters & Animation", "icon": "Drama"}, + {"id": "social", "name": "Social Media", "icon": "Smartphone"}, + {"id": "entertainment", "name": "Entertainment & TV", "icon": "Tv"}, + {"id": "advertisement", "name": "Advertisement", "icon": "Megaphone"}, + {"id": "informative", "name": "Informative & Educational", "icon": "GraduationCap"}, ] _USE_ICON = {c["id"]: c["icon"] for c in USE_CASES} @@ -260,36 +263,36 @@ def _build(gender, age, pitch, *, accent=None, dialect=None, whisper=False, # (gender, age, pitch, accent, whisper, use_case, name, icon) _FEATURED_SPEC = [ # Narration & Story - ("female", "middle-aged", "low pitch", "british accent", False, "narration", "The Librarian", "📚"), - ("male", "middle-aged", "low pitch", "american accent", False, "narration", "The Documentarian", "🎙️"), - ("female", "middle-aged", "low pitch", None, True, "narration", "The Calm Guide", "🌙"), - ("male", "elderly", "low pitch", "british accent", False, "narration", "The Storyteller", "🧙"), + ("female", "middle-aged", "low pitch", "british accent", False, "narration", "The Librarian", "Library"), + ("male", "middle-aged", "low pitch", "american accent", False, "narration", "The Documentarian", "Mic"), + ("female", "middle-aged", "low pitch", None, True, "narration", "The Calm Guide", "Moon"), + ("male", "elderly", "low pitch", "british accent", False, "narration", "The Storyteller", "Wand2"), # Conversational - ("female", "young adult", "moderate pitch", "american accent", False, "conversational", "The Neighbor", "😊"), - ("female", "young adult", "moderate pitch", "indian accent", False, "conversational", "The Helpdesk", "🎧"), - ("male", "young adult", "moderate pitch", "australian accent", False, "conversational", "The Mate", "🙂"), - ("female", "middle-aged", "moderate pitch", "canadian accent", False, "conversational", "The Companion", "☕"), + ("female", "young adult", "moderate pitch", "american accent", False, "conversational", "The Neighbor", "Smile"), + ("female", "young adult", "moderate pitch", "indian accent", False, "conversational", "The Helpdesk", "Headphones"), + ("male", "young adult", "moderate pitch", "australian accent", False, "conversational", "The Mate", "MessageSquare"), + ("female", "middle-aged", "moderate pitch", "canadian accent", False, "conversational", "The Companion", "Coffee"), # Characters & Animation - ("male", "elderly", "very low pitch", None, False, "characters", "Captain Crusty", "☠️"), - (None, "teenager", "high pitch", None, False, "characters", "Junior Quacks", "🦆"), - ("male", "young adult", "high pitch", "american accent", False, "characters", "The Champion", "🦸"), - ("female", "child", "very high pitch", None, False, "characters", "The Pixie", "🧚"), - ("male", "middle-aged", "very low pitch", None, False, "characters", "The Ogre", "👹"), + ("male", "elderly", "very low pitch", None, False, "characters", "Captain Crusty", "Skull"), + (None, "teenager", "high pitch", None, False, "characters", "Junior Quacks", "Bird"), + ("male", "young adult", "high pitch", "american accent", False, "characters", "The Champion", "Shield"), + ("female", "child", "very high pitch", None, False, "characters", "The Pixie", "Sparkles"), + ("male", "middle-aged", "very low pitch", None, False, "characters", "The Ogre", "Ghost"), # Social Media - ("female", "young adult", "high pitch", "australian accent", False, "social", "The Podcaster", "🎤"), - ("male", "young adult", "very high pitch", "american accent", False, "social", "The Hype Host", "⚡"), - ("female", "young adult", "high pitch", None, False, "social", "The Vlogger", "📱"), + ("female", "young adult", "high pitch", "australian accent", False, "social", "The Podcaster", "Radio"), + ("male", "young adult", "very high pitch", "american accent", False, "social", "The Hype Host", "Zap"), + ("female", "young adult", "high pitch", None, False, "social", "The Vlogger", "Video"), # Entertainment & TV - ("male", "middle-aged", "moderate pitch", "american accent", False, "entertainment", "The Anchor", "📺"), - ("male", "middle-aged", "high pitch", "british accent", False, "entertainment", "The Commentator", "🏟️"), - ("male", "middle-aged", "moderate pitch", None, False, "entertainment", "The Game Host", "🎬"), + ("male", "middle-aged", "moderate pitch", "american accent", False, "entertainment", "The Anchor", "Tv"), + ("male", "middle-aged", "high pitch", "british accent", False, "entertainment", "The Commentator", "Trophy"), + ("male", "middle-aged", "moderate pitch", None, False, "entertainment", "The Game Host", "Clapperboard"), # Advertisement - ("male", "middle-aged", "low pitch", None, False, "advertisement", "The Promo Voice", "📣"), - ("female", "middle-aged", "moderate pitch", "british accent", False, "advertisement", "The Luxe", "💎"), - ("female", "young adult", "high pitch", "american accent", False, "advertisement", "The Upbeat", "✨"), + ("male", "middle-aged", "low pitch", None, False, "advertisement", "The Promo Voice", "Megaphone"), + ("female", "middle-aged", "moderate pitch", "british accent", False, "advertisement", "The Luxe", "Gem"), + ("female", "young adult", "high pitch", "american accent", False, "advertisement", "The Upbeat", "Music"), # Informative & Educational - ("female", "middle-aged", "moderate pitch", "american accent", False, "informative", "The Teacher", "👩‍🏫"), - ("male", "young adult", "moderate pitch", "british accent", False, "informative", "The Explainer", "💡"), + ("female", "middle-aged", "moderate pitch", "american accent", False, "informative", "The Teacher", "GraduationCap"), + ("male", "young adult", "moderate pitch", "british accent", False, "informative", "The Explainer", "Lightbulb"), ] diff --git a/backend/main.py b/backend/main.py index 77df7b66..450eace9 100644 --- a/backend/main.py +++ b/backend/main.py @@ -299,6 +299,7 @@ from api.routers import ( setup, gallery, archetypes, + community, batch, watermark, events, @@ -626,6 +627,7 @@ app.include_router(stories.router) app.include_router(setup.router) app.include_router(gallery.router) app.include_router(archetypes.router) +app.include_router(community.router) app.include_router(batch.router) app.include_router(watermark.router) app.include_router(events.router) diff --git a/backend/tests/test_community.py b/backend/tests/test_community.py new file mode 100644 index 00000000..0443a837 --- /dev/null +++ b/backend/tests/test_community.py @@ -0,0 +1,129 @@ +"""Tests for the community gallery (marketplace) loader. + +Covers the no-network surface: strict item validation (invalid presets and +unsafe audio URLs are dropped so they can never crash synthesis or fetch from +an arbitrary host), manifest merge/dedup, offline cache reads, filtering, and +the prefilled submit URL. The render/download paths need the model/network and +are exercised at runtime. +""" +from __future__ import annotations + +import json +import os +import sys +import tempfile +import types +from pathlib import Path + +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +_TMP = tempfile.mkdtemp(prefix="omnivoice_community_test_") +_config = types.ModuleType("core.config") +_config.DATA_DIR = _TMP +_config.VOICES_DIR = str(Path(_TMP) / "voices") +_config.OUTPUTS_DIR = str(Path(_TMP) / "outputs") +sys.modules["core.config"] = _config + +from fastapi import FastAPI # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 + +from api.routers import community # noqa: E402 + +_FIXTURE = { + "schema_version": 1, + "items": [ + {"id": "p1", "type": "preset", "name": "Test Narrator", "use_case": "narration", + "facets": {"gender": "female", "age": "middle-aged", "pitch": "low pitch", "lang": "English"}, + "instruct": "female, middle-aged, low pitch", "language": "English", "source": "community"}, + # invalid instruct token -> dropped + {"id": "p_bad", "type": "preset", "name": "Bad", "use_case": "narration", + "instruct": "female, raspy, smoky"}, + # unsafe audio host -> dropped + {"id": "v_bad", "type": "voice", "name": "Sketchy", "use_case": "narration", + "audio": {"url": "http://evil.example.com/x.wav"}}, + # valid voice (allow-listed host) + {"id": "v1", "type": "voice", "name": "Recorded One", "use_case": "narration", + "facets": {"gender": "male", "lang": "English"}, + "audio": {"url": "https://github.com/debpalash/omnivoice-gallery/releases/download/voices-v1/v1.wav"}}, + # unknown use_case -> dropped + {"id": "u1", "type": "preset", "name": "Mystery", "use_case": "banana", "instruct": "male"}, + ], + "packs": [{"id": "starter", "name": "Starter", "item_ids": ["p1"]}], +} + + +@pytest.fixture(scope="module", autouse=True) +def seed_cache(): + cache = community._cache_path("debpalash/omnivoice-gallery") + cache.parent.mkdir(parents=True, exist_ok=True) + cache.write_text(json.dumps(_FIXTURE), encoding="utf-8") + yield + + +@pytest.fixture(scope="module") +def client(): + app = FastAPI() + app.include_router(community.router) + return TestClient(app) + + +# ── pure validation ─────────────────────────────────────────────────────────── +def test_valid_preset_kept(): + assert community.validate_item(_FIXTURE["items"][0]) is not None + + +def test_invalid_instruct_dropped(): + assert community.validate_item(_FIXTURE["items"][1]) is None + + +def test_unsafe_audio_url_dropped(): + assert community.validate_item(_FIXTURE["items"][2]) is None + + +def test_unknown_use_case_dropped(): + assert community.validate_item(_FIXTURE["items"][4]) is None + + +def test_is_valid_instruct(): + assert community.is_valid_instruct("male, elderly, very low pitch") + assert not community.is_valid_instruct("male, sultry") + assert not community.is_valid_instruct("") + + +# ── merge keeps only valid items ────────────────────────────────────────────── +def test_merge_drops_invalid_and_dedups(): + items, packs = community._merge([("debpalash/omnivoice-gallery", _FIXTURE)]) + ids = {i["id"] for i in items} + assert ids == {"p1", "v1"} + assert packs and packs[0]["id"] == "starter" + + +# ── endpoints (served from cache, no network) ───────────────────────────────── +def test_manifest_endpoint_from_cache(client): + body = client.get("/community/manifest").json() + assert body["count"] == 2 + assert {i["id"] for i in body["items"]} == {"p1", "v1"} + assert "debpalash/omnivoice-gallery" in body["sources"] + + +def test_items_filter_by_type(client): + body = client.get("/community/items", params={"type": "voice"}).json() + assert [i["id"] for i in body["items"]] == ["v1"] + + +def test_items_filter_by_use_case(client): + body = client.get("/community/items", params={"use_case": "narration"}).json() + assert body["total"] == 2 + + +def test_sources_endpoint(client): + assert client.get("/community/sources").json()["sources"] + + +def test_submit_url(client): + preset = client.get("/community/submit-url", params={"type": "preset"}).json()["url"] + voice = client.get("/community/submit-url", params={"type": "voice"}).json()["url"] + assert "preset-submission.yml" in preset and "omnivoice-gallery" in preset + assert "voice-submission.yml" in voice diff --git a/bun.lock b/bun.lock index 3c45e4a7..feac1c29 100644 --- a/bun.lock +++ b/bun.lock @@ -38,6 +38,7 @@ "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "^2.10.1", "@tauri-apps/plugin-window-state": "^2.4.1", + "country-flag-icons": "^1.6.17", "i18next": "^26.0.8", "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^1.14.0", @@ -500,6 +501,8 @@ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "country-flag-icons": ["country-flag-icons@1.6.17", "", {}, "sha512-Nmik0289ZVZSI3c7mJR/amg6DyY7Z59b0sTFSKayeX72mHfPzCPJygwJs2pYgQULzuAyWeCUgwAJ+Dq8OR+JFw=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], diff --git a/frontend/package.json b/frontend/package.json index 79de234a..a278a47c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -38,6 +38,7 @@ "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "^2.10.1", "@tauri-apps/plugin-window-state": "^2.4.1", + "country-flag-icons": "^1.6.17", "i18next": "^26.0.8", "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^1.14.0", diff --git a/frontend/src/api/community.ts b/frontend/src/api/community.ts new file mode 100644 index 00000000..505c1112 --- /dev/null +++ b/frontend/src/api/community.ts @@ -0,0 +1,71 @@ +/** + * Community gallery (marketplace) API — designed presets + recorded voices + * loaded from the omnivoice-gallery content repo via the backend + * (CDN-fetched, cached, validated). See backend/api/routers/community.py. + */ +import { apiJson } from './client'; + +export interface CommunityItem { + id: string; + type: 'preset' | 'voice'; + name: string; + icon: string; + use_case: string; + facets: Record; + instruct?: string; + language: string; + sample_script?: string; + audio?: { url: string; ref_text?: string; duration?: number; sha256?: string }; + author?: string; + license?: string; + source?: string; + is_community?: boolean; +} + +export interface CommunityPage { + total: number; + limit: number; + offset: number; + items: CommunityItem[]; +} + +export interface CommunityManifest { + sources: string[]; + packs: any[]; + items: CommunityItem[]; + count: number; + offline: boolean; +} + +export interface CommunityFilters { + use_case?: string | null; + gender?: string | null; + type?: string | null; + lang?: string | null; + q?: string | null; + limit?: number; + offset?: number; + refresh?: boolean; +} + +export const listCommunityItems = (filters: CommunityFilters = {}): Promise => { + const qs = new URLSearchParams(); + Object.entries(filters).forEach(([k, v]) => { + if (v !== undefined && v !== null && v !== '') qs.set(k, String(v)); + }); + const q = qs.toString(); + return apiJson(`/community/items${q ? `?${q}` : ''}`); +}; + +export const communityManifest = (refresh = false): Promise => + apiJson(`/community/manifest?refresh=${refresh}`); + +export const communitySources = (): Promise<{ sources: string[] }> => apiJson('/community/sources'); + +export const communitySubmitUrl = (type: 'preset' | 'voice'): Promise<{ url: string }> => + apiJson(`/community/submit-url?type=${type}`); + +export const addCommunityItem = (id: string, name?: string): Promise<{ profile_id: string; name: string }> => { + const q = name ? `?name=${encodeURIComponent(name)}` : ''; + return apiJson(`/community/items/${encodeURIComponent(id)}/use${q}`, { method: 'POST' }); +}; diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index 06cc6a07..008405be 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -10,6 +10,8 @@ import * as setupApi from './setup'; import * as galleryApi from './gallery'; import * as archetypesApi from './archetypes'; import type { ArchetypeFilters } from './archetypes'; +import * as communityApi from './community'; +import type { CommunityFilters } from './community'; // ── Keys (prevents typos, enables targeted invalidation) ───────────────── export const queryKeys = { @@ -26,6 +28,8 @@ export const queryKeys = { galleryCategories: ['gallery-categories'] as const, archetypeCategories: ['archetype-categories'] as const, archetypes: (filters?: any) => ['archetypes', filters] as const, + communityItems: (filters?: any) => ['community-items', filters] as const, + communityManifest: (refresh?: boolean) => ['community-manifest', !!refresh] as const, }; // ── Polling queries (sysinfo, model status, logs) ──────────────────────── @@ -158,6 +162,24 @@ export function useArchetypes(filters: ArchetypeFilters = {}) { }); } +// ── Community gallery (marketplace) ─────────────────────────────────────── +export function useCommunityItems(filters: CommunityFilters = {}) { + return useQuery({ + queryKey: queryKeys.communityItems(filters), + queryFn: () => communityApi.listCommunityItems(filters), + staleTime: 5 * 60_000, + placeholderData: keepPreviousData, + }); +} + +export function useCommunityManifest(refresh = false) { + return useQuery({ + queryKey: queryKeys.communityManifest(refresh), + queryFn: () => communityApi.communityManifest(refresh), + staleTime: 5 * 60_000, + }); +} + // ── Mutations ──────────────────────────────────────────────────────────── export function useInstallModel() { diff --git a/frontend/src/pages/VoiceGallery.css b/frontend/src/pages/VoiceGallery.css index efd14501..096ac1ec 100644 --- a/frontend/src/pages/VoiceGallery.css +++ b/frontend/src/pages/VoiceGallery.css @@ -372,29 +372,68 @@ .gallery-content.gallery-scroll { overflow-y: auto; } .archetype-section { margin-bottom: 14px; } .count-badge { margin-left: 6px; padding: 1px 7px; border-radius: 10px; background: var(--bg-tertiary); color: var(--text-secondary); font-size: 0.65rem; font-weight: 400; } -.archetype-grid.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 8px; } -.archetype-grid.list { display: flex; flex-direction: column; gap: 5px; } +.archetype-grid.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(248px, 1fr)); gap: 10px; } +.archetype-grid.list { display: flex; flex-direction: column; gap: 6px; } -.archetype-card { position: relative; display: flex; flex-direction: column; gap: 8px; padding: 12px; background: var(--bg-tertiary); border: 1px solid var(--border-color); border-radius: 10px; transition: border-color 0.15s ease, background 0.15s ease; } -.archetype-card:hover { border-color: var(--border-hover); } -.archetype-card.list { flex-direction: row; align-items: center; padding: 8px 10px; } +/* Card surface — a real, elevated tile so cards read as cards on the dark bg. */ +.archetype-card { + position: relative; + display: flex; + flex-direction: column; + gap: 10px; + padding: 13px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.038), rgba(255, 255, 255, 0.012)); + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 13px; + transition: transform 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease; +} +.archetype-card:hover { transform: translateY(-2px); border-color: rgba(255, 255, 255, 0.16); box-shadow: 0 6px 22px rgba(0, 0, 0, 0.4); } +.archetype-card.playing { border-color: var(--card-accent, var(--accent)); box-shadow: 0 0 0 1px var(--card-accent, var(--accent)), 0 6px 22px rgba(0, 0, 0, 0.4); } -.fav-btn { position: absolute; top: 8px; right: 8px; display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border: none; background: transparent; color: var(--text-secondary); border-radius: 6px; cursor: pointer; } -.fav-btn:hover { color: var(--accent); } +/* Header: avatar tile + title + favorite */ +.arch-head { display: flex; align-items: center; gap: 10px; } +.arch-avatar { position: relative; flex-shrink: 0; display: flex; align-items: center; justify-content: center; border: 1px solid transparent; border-radius: 11px; } +.arch-avatar-flag { position: absolute; right: -4px; bottom: -4px; display: flex; line-height: 0; border-radius: 3px; overflow: hidden; box-shadow: 0 0 0 2px var(--bg-tertiary, #1d2021); } +.accent-flag { display: block; border-radius: 2px; } +.flag-globe { color: var(--text-secondary); } +.arch-title { flex: 1; min-width: 0; } +.archetype-name { font-size: 0.84rem; font-weight: 600; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.archetype-sub { font-size: 0.68rem; color: var(--text-secondary); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +.fav-btn { flex-shrink: 0; display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; border: none; background: transparent; color: var(--text-secondary); border-radius: 7px; cursor: pointer; transition: color 0.15s ease, background 0.15s ease; } +.fav-btn:hover { color: #fabd2f; background: rgba(255, 255, 255, 0.05); } .fav-btn.on { color: #fabd2f; } -.archetype-card.list .fav-btn { position: static; order: 3; } -.archetype-play { display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; flex-shrink: 0; border-radius: 50%; border: 1px solid var(--border-color); background: var(--bg-secondary); color: var(--text-primary); cursor: pointer; } -.archetype-play:hover { background: var(--accent); border-color: var(--accent); color: #fff; } -.archetype-card.list .archetype-play { width: 30px; height: 30px; } +/* Facet chips (accent chip carries its flag) */ +.archetype-chips { display: flex; flex-wrap: wrap; gap: 5px; } +.facet-chip { display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px; border-radius: 7px; background: rgba(255, 255, 255, 0.05); color: var(--text-secondary); font-size: 0.64rem; line-height: 1.6; } +.facet-chip.with-flag { padding-left: 5px; } -.archetype-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; } -.archetype-name { display: flex; align-items: center; gap: 6px; font-size: 0.8rem; font-weight: 600; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.archetype-icon { font-size: 0.95rem; line-height: 1; } -.archetype-chips { display: flex; flex-wrap: wrap; gap: 4px; } -.facet-chip { padding: 1px 7px; border-radius: 8px; background: var(--bg-secondary); color: var(--text-secondary); font-size: 0.62rem; } -.archetype-actions { display: flex; gap: 5px; } -.archetype-card.list .archetype-actions { margin-left: auto; } +/* Footer actions */ +.arch-foot { display: flex; align-items: center; gap: 6px; margin-top: auto; } +.preview-btn { display: inline-flex; align-items: center; gap: 6px; padding: 6px 11px; border: 1px solid rgba(255, 255, 255, 0.09); background: rgba(255, 255, 255, 0.03); color: var(--text-primary); border-radius: 8px; font-size: 0.7rem; cursor: pointer; transition: border-color 0.15s ease, color 0.15s ease; } +.preview-btn:hover { border-color: var(--card-accent, var(--accent)); color: var(--card-accent, var(--accent)); } +.use-btn { flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 6px 10px; border: none; border-radius: 8px; background: var(--card-accent, var(--accent)); color: #1d2021; font-size: 0.72rem; font-weight: 600; cursor: pointer; transition: filter 0.15s ease; } +.use-btn:hover { filter: brightness(1.08); } +.designer-btn { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; flex-shrink: 0; border: 1px solid rgba(255, 255, 255, 0.09); background: rgba(255, 255, 255, 0.03); color: var(--text-secondary); border-radius: 8px; cursor: pointer; transition: border-color 0.15s ease, color 0.15s ease; } +.designer-btn:hover { color: var(--card-accent, var(--accent)); border-color: var(--card-accent, var(--accent)); } + +/* Animated "now playing" equalizer */ +.now-playing { display: inline-flex; align-items: flex-end; gap: 2px; width: 15px; height: 14px; } +.now-playing i { width: 2.5px; background: currentcolor; border-radius: 1px; transform-origin: bottom; animation: eq 0.9s ease-in-out infinite; } +.now-playing i:nth-child(1) { height: 45%; animation-delay: 0s; } +.now-playing i:nth-child(2) { height: 80%; animation-delay: 0.15s; } +.now-playing i:nth-child(3) { height: 60%; animation-delay: 0.3s; } +.now-playing i:nth-child(4) { height: 95%; animation-delay: 0.45s; } +@keyframes eq { 0%, 100% { transform: scaleY(0.4); } 50% { transform: scaleY(1); } } +@media (prefers-reduced-motion: reduce) { .now-playing i { animation: none; } .archetype-card { transition: none; } .archetype-card:hover { transform: none; } } + +/* Category chips: lucide icon + label */ +.use-case-chips .category-chip svg { flex-shrink: 0; } .load-more { display: flex; justify-content: center; padding: 12px 0; } -.import-explainer { flex-shrink: 0; padding: 8px 10px; margin-bottom: 8px; background: var(--bg-tertiary); border-radius: 8px; font-size: 0.72rem; color: var(--text-secondary); line-height: 1.4; } \ No newline at end of file +.import-explainer { flex-shrink: 0; padding: 8px 10px; margin-bottom: 8px; background: var(--bg-tertiary); border-radius: 8px; font-size: 0.72rem; color: var(--text-secondary); line-height: 1.4; } +.community-explainer { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } +.submit-actions { display: flex; gap: 6px; flex-shrink: 0; } +.submit-btn { display: inline-flex; align-items: center; gap: 5px; padding: 6px 10px; border: 1px solid rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.03); color: var(--text-primary); border-radius: 8px; font-size: 0.7rem; cursor: pointer; transition: border-color 0.15s ease, color 0.15s ease; } +.submit-btn:hover { border-color: var(--accent); color: var(--accent); } \ No newline at end of file diff --git a/frontend/src/pages/VoiceGallery.jsx b/frontend/src/pages/VoiceGallery.jsx index 85c1aca0..18b25564 100644 --- a/frontend/src/pages/VoiceGallery.jsx +++ b/frontend/src/pages/VoiceGallery.jsx @@ -8,11 +8,13 @@ import React, { useState, useMemo, useRef, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Search, Download, Play, Pause, Trash2, X, Loader, Star, - Wand2, UserPlus, Sparkles, RotateCcw, Grid, List, Upload, Scissors, + Wand2, UserPlus, Sparkles, RotateCcw, Grid, List, Upload, Scissors, Store, Send, } from 'lucide-react'; import { Button, Input } from '../ui'; -import { useArchetypeCategories, useArchetypes, useGalleryVoices } from '../api/hooks'; +import { useArchetypeCategories, useArchetypes, useGalleryVoices, useCommunityItems } from '../api/hooks'; import { archetypePreviewUrl, useArchetypeAsProfile } from '../api/archetypes'; +import { addCommunityItem, communitySubmitUrl } from '../api/community'; +import { openExternal } from '../api/external'; import { searchYoutube, downloadYoutubeClip, deleteGalleryVoice, saveVoiceAsProfile, uploadVoiceClip, previewVoiceUrl, @@ -22,6 +24,7 @@ import { useAppStore } from '../store'; import { apiUrl } from '../api/client'; import { isTauri } from '../utils/media'; import { askConfirm } from '../utils/dialog'; +import { ArchetypeAvatar, ArchetypeIcon, AccentFlag, NowPlaying, USE_CASE_COLOR } from '../utils/archetypeIcons'; import './VoiceGallery.css'; const BROWSE_PAGE = 60; @@ -131,6 +134,9 @@ export default function VoiceGallery() { + @@ -167,6 +173,17 @@ export default function VoiceGallery() { setMode('design'); }} /> + ) : zone === 'community' ? ( + playUrl(url, id)} + flash={flash} + onDesign={(instruct) => { setVdStates({ ...vdStates }); setInstruct(instruct); setMode('design'); }} + /> ) : ( setFilter('use_case', filters.use_case === c.id ? null : c.id)} title={c.name} > - {c.icon} + {t(`archetypes.use_${c.id}`, { defaultValue: c.name })} ))} @@ -315,37 +332,126 @@ function ArchetypeCard({ a, t, viewMode, isFavorite, isPlaying, isLoadingPreview, onPreview, onUse, onDesign, onToggleFavorite, }) { - const chips = [a.facets.gender, a.facets.age, a.facets.pitch, a.facets.accent] - .filter(Boolean) - .map((x) => facetLabel(x)); - if (a.facets.whisper) chips.push('Whisper'); + const color = USE_CASE_COLOR[a.use_case] || '#83a598'; + const sub = [a.facets.gender, a.facets.age, a.facets.pitch] + .filter(Boolean).map(facetLabel).join(' · '); + const dialect = a.attrs?.ChineseDialect && a.attrs.ChineseDialect !== 'Auto' ? a.attrs.ChineseDialect : null; + const accentLabel = a.facets.accent + ? facetLabel(a.facets.accent) + : (dialect || (a.language === 'Chinese' ? 'Chinese' : null)); return ( -
- - -
-
{a.icon}{a.name}
+
+
+ +
+
{a.name}
+ {sub &&
{sub}
} +
+ +
+ + {(accentLabel || a.facets.whisper) && (
- {chips.map((c, i) => {c})} + {accentLabel && ( + + + {accentLabel} + + )} + {a.facets.whisper && ( + {t('archetypes.facet_whisper', { defaultValue: 'Whisper' })} + )} +
+ )} + +
+ + + +
+
+ ); +} + +// ── Community zone (marketplace) ───────────────────────────────────────────── +function CommunityZone({ t, playingId, loadingPreviewId, favorites, toggleFavorite, onPlayAudio, flash, onDesign }) { + const itemsQ = useCommunityItems({ limit: 100 }); + const items = itemsQ.data?.items || []; + const favSet = useMemo(() => new Set(favorites), [favorites]); + + const submit = async (type) => { + try { + const { url } = await communitySubmitUrl(type); + await openExternal(url); + } catch { + flash(t('gallery.submit_failed', { defaultValue: 'Could not open the submission form.' })); + } + }; + + return ( +
+
+ {t('gallery.community_explainer', { defaultValue: 'Designed presets and recorded voices shared by the community, loaded from the omnivoice-gallery.' })} +
+ +
-
- - -
+ + {itemsQ.isLoading ? ( +
+ ) : items.length === 0 ? ( +
+ {t('gallery.community_empty', { defaultValue: 'No community voices loaded yet — connect to the internet and reopen, or be the first to submit one.' })} +
+ ) : ( +
+ {items.map((it) => ( + (item.audio?.url + ? onPlayAudio(item.audio.url, item.id) + : flash(t('gallery.no_preview', { defaultValue: 'No preview — add it with "Use voice" to hear it.' })))} + onUse={async (item) => { + try { + const r = await addCommunityItem(item.id, item.name); + flash(t('gallery.saved_as_profile', { defaultValue: 'Added "{{name}}" to your voices.', name: r.name })); + } catch { + flash(t('gallery.use_failed', { defaultValue: 'Could not add that voice.' })); + } + }} + onDesign={(item) => (item.instruct + ? onDesign(item.instruct) + : flash(t('gallery.no_designer', { defaultValue: 'Recorded voice — use "Use voice" instead.' })))} + /> + ))} +
+ )}
); } diff --git a/frontend/src/store/gallerySlice.ts b/frontend/src/store/gallerySlice.ts index 4dd9a3bd..81564e6e 100644 --- a/frontend/src/store/gallerySlice.ts +++ b/frontend/src/store/gallerySlice.ts @@ -10,7 +10,7 @@ */ import type { StateCreator } from 'zustand'; -export type GalleryZone = 'archetypes' | 'imports'; +export type GalleryZone = 'archetypes' | 'imports' | 'community'; export interface ArchetypeFilterState { use_case: string | null; diff --git a/frontend/src/utils/archetypeIcons.jsx b/frontend/src/utils/archetypeIcons.jsx new file mode 100644 index 00000000..5e549d06 --- /dev/null +++ b/frontend/src/utils/archetypeIcons.jsx @@ -0,0 +1,95 @@ +// Icon system for the Voice Gallery — lucide SVGs (not emoji, which render +// inconsistently across OSes), country flags for accents, a per-category color +// scale, and a CSS-animated "now playing" equalizer. +import React from 'react'; +import { + BookOpen, MessagesSquare, MessageSquare, Drama, Smartphone, Tv, Megaphone, + GraduationCap, Library, Mic, Moon, Wand2, Smile, Headphones, Coffee, Skull, + Bird, Shield, Sparkles, Ghost, Radio, Zap, Video, Trophy, Clapperboard, Gem, + Music, Lightbulb, Globe, +} from 'lucide-react'; +import US from 'country-flag-icons/react/3x2/US'; +import GB from 'country-flag-icons/react/3x2/GB'; +import AU from 'country-flag-icons/react/3x2/AU'; +import CA from 'country-flag-icons/react/3x2/CA'; +import IN from 'country-flag-icons/react/3x2/IN'; +import CN from 'country-flag-icons/react/3x2/CN'; +import JP from 'country-flag-icons/react/3x2/JP'; +import KR from 'country-flag-icons/react/3x2/KR'; +import PT from 'country-flag-icons/react/3x2/PT'; +import RU from 'country-flag-icons/react/3x2/RU'; + +// lucide component name → component (icon identity comes from the backend). +const ICONS = { + BookOpen, MessagesSquare, MessageSquare, Drama, Smartphone, Tv, Megaphone, + GraduationCap, Library, Mic, Moon, Wand2, Smile, Headphones, Coffee, Skull, + Bird, Shield, Sparkles, Ghost, Radio, Zap, Video, Trophy, Clapperboard, Gem, + Music, Lightbulb, Globe, +}; + +// One accent color per use-case (gruvbox palette, matches the app theme). +export const USE_CASE_COLOR = { + narration: '#83a598', + conversational: '#8ec07c', + characters: '#d3869b', + social: '#fe8019', + entertainment: '#fabd2f', + advertisement: '#fb4934', + informative: '#b8bb26', +}; + +const FLAGS = { + 'american accent': US, + 'british accent': GB, + 'australian accent': AU, + 'canadian accent': CA, + 'indian accent': IN, + 'chinese accent': CN, + 'japanese accent': JP, + 'korean accent': KR, + 'portuguese accent': PT, + 'russian accent': RU, +}; + +function tint(hex, alpha) { + const n = parseInt(hex.slice(1), 16); + return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`; +} + +export function ArchetypeIcon({ name, size = 18, color }) { + const Cmp = ICONS[name] || Sparkles; + return ; +} + +/** Country flag for an accent (or the Chinese flag for dialect voices, or a globe). */ +export function AccentFlag({ accent, lang, size = 14 }) { + let Flag = accent ? FLAGS[accent] : null; + if (!Flag && lang === 'Chinese') Flag = CN; + if (!Flag) return ; + return ; +} + +/** Animated equalizer shown on the card whose preview is playing (bars in CSS). */ +export function NowPlaying({ color }) { + return ( + + ); +} + +/** Color-coded icon tile with a small flag badge — the visual anchor of a card. */ +export function ArchetypeAvatar({ item, size = 44 }) { + const color = USE_CASE_COLOR[item.use_case] || '#83a598'; + return ( +
+ + + + +
+ ); +} diff --git a/omnivoice-gallery b/omnivoice-gallery new file mode 160000 index 00000000..22e8e6da --- /dev/null +++ b/omnivoice-gallery @@ -0,0 +1 @@ +Subproject commit 22e8e6da806fb1904a75db99adb1759832b6ebd0