feat: onboarding demo profile, voice personalities, i18n framework
- Onboarding: seed 'OmniVoice Demo' profile on first run (empty DB) with bundled reference audio so Launchpad isn't empty - Voice Personalities: 6 built-in presets (Narrator, Casual, News Anchor, Storyteller, Corporate, Energetic) with instruct text auto-fill in Voice Design mode - i18n: react-i18next with English locale, browser language detection, Launchpad & CloneDesignTab strings extracted to en.json - DB migration v4: personality TEXT column on voice_profiles - New API: GET /personalities returns preset list - CSS: demo callout banner + personality picker strip
This commit is contained in:
@@ -10,6 +10,7 @@ from pydantic import BaseModel
|
||||
from core.db import get_db, db_conn
|
||||
from core.config import VOICES_DIR, OUTPUTS_DIR
|
||||
from core import event_bus
|
||||
from core.personalities import get_personalities
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -19,6 +20,13 @@ class ProfileUpdate(BaseModel):
|
||||
ref_text: Optional[str] = None
|
||||
instruct: Optional[str] = None
|
||||
language: Optional[str] = None
|
||||
personality: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/personalities")
|
||||
def list_personalities():
|
||||
"""Return built-in voice personality presets."""
|
||||
return get_personalities()
|
||||
|
||||
@router.get("/profiles")
|
||||
def list_profiles():
|
||||
@@ -35,6 +43,7 @@ async def create_profile(
|
||||
instruct: str = Form(""),
|
||||
language: str = Form("Auto"),
|
||||
seed: Optional[int] = Form(None),
|
||||
personality: str = Form(""),
|
||||
):
|
||||
profile_id = str(uuid.uuid4())[:8]
|
||||
ext = os.path.splitext(ref_audio.filename or ".wav")[1]
|
||||
@@ -46,8 +55,8 @@ async def create_profile(
|
||||
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles (id, name, ref_audio_path, ref_text, instruct, language, seed, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(profile_id, name, audio_filename, ref_text, instruct, language, seed, time.time())
|
||||
"INSERT INTO voice_profiles (id, name, ref_audio_path, ref_text, instruct, language, seed, personality, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(profile_id, name, audio_filename, ref_text, instruct, language, seed, personality, time.time())
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -74,7 +83,7 @@ def update_profile(profile_id: str, patch: ProfileUpdate):
|
||||
"""Partial update — only fields set on the payload are changed."""
|
||||
fields = []
|
||||
params = []
|
||||
for col in ("name", "ref_text", "instruct", "language"):
|
||||
for col in ("name", "ref_text", "instruct", "language", "personality"):
|
||||
val = getattr(patch, col)
|
||||
if val is None:
|
||||
continue
|
||||
|
||||
Binary file not shown.
@@ -46,6 +46,7 @@ _BASE_SCHEMA = """
|
||||
locked_audio_path TEXT DEFAULT '',
|
||||
seed INTEGER DEFAULT NULL,
|
||||
is_locked INTEGER DEFAULT 0,
|
||||
personality TEXT DEFAULT '',
|
||||
created_at REAL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS generation_history (
|
||||
@@ -133,6 +134,7 @@ _ALLOWED_MIGRATIONS = {
|
||||
("voice_profiles", "locked_audio_path"),
|
||||
("voice_profiles", "seed"),
|
||||
("voice_profiles", "is_locked"),
|
||||
("voice_profiles", "personality"),
|
||||
("generation_history", "seed"),
|
||||
("dub_history", "content_hash"),
|
||||
}
|
||||
@@ -167,6 +169,9 @@ def _migrate(conn, current: int) -> int:
|
||||
# DB simply picks it up on the next init — no ALTER needed.
|
||||
if current < 3:
|
||||
current = 3
|
||||
if current < 4:
|
||||
_add_column_if_missing(conn, "voice_profiles", "personality", "TEXT DEFAULT ''")
|
||||
current = 4
|
||||
return current
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""First-run onboarding — seeds a demo voice profile so the Launchpad
|
||||
isn't empty on initial launch. Runs once; skips silently if any
|
||||
profiles already exist.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
import logging
|
||||
|
||||
from core.db import get_db
|
||||
from core.config import VOICES_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Bundled demo clip — a short reference audio for the sample profile.
|
||||
_DEMO_AUDIO = os.path.join(
|
||||
os.path.dirname(__file__), os.pardir, "assets", "samples", "demo_voice.wav"
|
||||
)
|
||||
|
||||
DEMO_PROFILE_ID = "demo0001"
|
||||
DEMO_PROFILE_NAME = "OmniVoice Demo"
|
||||
DEMO_REF_TEXT = "Welcome to OmniVoice Studio. Clone any voice, design new ones, or dub videos into hundreds of languages."
|
||||
|
||||
|
||||
def seed_sample_project():
|
||||
"""Create the demo voice profile if no profiles exist yet."""
|
||||
conn = get_db()
|
||||
try:
|
||||
count = conn.execute("SELECT COUNT(*) FROM voice_profiles").fetchone()[0]
|
||||
if count > 0:
|
||||
return # Not first run — skip
|
||||
|
||||
# Check if demo audio exists
|
||||
if not os.path.isfile(_DEMO_AUDIO):
|
||||
logger.warning("Demo audio not found at %s — skipping onboarding seed", _DEMO_AUDIO)
|
||||
return
|
||||
|
||||
# Copy demo audio to voices directory
|
||||
os.makedirs(VOICES_DIR, exist_ok=True)
|
||||
dest = os.path.join(VOICES_DIR, f"{DEMO_PROFILE_ID}.wav")
|
||||
shutil.copy2(_DEMO_AUDIO, dest)
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO voice_profiles "
|
||||
"(id, name, ref_audio_path, ref_text, instruct, language, personality, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
DEMO_PROFILE_ID,
|
||||
DEMO_PROFILE_NAME,
|
||||
f"{DEMO_PROFILE_ID}.wav",
|
||||
DEMO_REF_TEXT,
|
||||
"",
|
||||
"English",
|
||||
"",
|
||||
time.time(),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
logger.info("🎉 Seeded demo voice profile '%s'", DEMO_PROFILE_NAME)
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Built-in voice personality presets.
|
||||
|
||||
Each personality is a named set of TTS parameters (instruct text, style
|
||||
hints) that users can pick from a strip in Voice Design. The instruct
|
||||
string is treated as a starting point — users can edit it after applying.
|
||||
"""
|
||||
|
||||
PERSONALITIES = [
|
||||
{
|
||||
"id": "narrator",
|
||||
"name": "Narrator",
|
||||
"instruct": "Speak as a calm, authoritative documentary narrator with measured pacing",
|
||||
"icon": "📖",
|
||||
},
|
||||
{
|
||||
"id": "casual",
|
||||
"name": "Casual",
|
||||
"instruct": "Speak in a relaxed, conversational tone like talking to a friend",
|
||||
"icon": "😊",
|
||||
},
|
||||
{
|
||||
"id": "news_anchor",
|
||||
"name": "News Anchor",
|
||||
"instruct": "Speak clearly and professionally like a television news presenter",
|
||||
"icon": "📺",
|
||||
},
|
||||
{
|
||||
"id": "storyteller",
|
||||
"name": "Storyteller",
|
||||
"instruct": "Speak with dramatic flair and engaging pacing like reading a bedtime story",
|
||||
"icon": "🧙",
|
||||
},
|
||||
{
|
||||
"id": "corporate",
|
||||
"name": "Corporate",
|
||||
"instruct": "Speak in a polished, professional tone suitable for business presentations",
|
||||
"icon": "💼",
|
||||
},
|
||||
{
|
||||
"id": "energetic",
|
||||
"name": "Energetic",
|
||||
"instruct": "Speak with high energy and enthusiasm like a podcast host",
|
||||
"icon": "⚡",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def get_personalities():
|
||||
"""Return the full list of built-in personality presets."""
|
||||
return PERSONALITIES
|
||||
|
||||
|
||||
def get_personality(personality_id: str):
|
||||
"""Look up a single personality by ID, or None."""
|
||||
for p in PERSONALITIES:
|
||||
if p["id"] == personality_id:
|
||||
return p
|
||||
return None
|
||||
@@ -202,6 +202,9 @@ async def lifespan(app: FastAPI):
|
||||
from api.routers.gallery import _init_gallery_db
|
||||
|
||||
_init_gallery_db()
|
||||
# Seed a demo voice profile on first run (empty DB only).
|
||||
from core.onboarding import seed_sample_project
|
||||
seed_sample_project()
|
||||
# Any job still in pending/running at startup is orphaned — a previous
|
||||
# process didn't finish it. Flip to failed with a clear message so the
|
||||
# UI doesn't show a fake spinner.
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
},
|
||||
"frontend": {
|
||||
"name": "omnivoice-studio",
|
||||
"version": "0.2.3",
|
||||
"version": "0.2.4",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/source-serif-4": "^5.2.9",
|
||||
@@ -38,11 +38,14 @@
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"@tauri-apps/plugin-window-state": "^2.4.1",
|
||||
"i18next": "^26.0.8",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-i18next": "^17.0.6",
|
||||
"react-window": "^2.2.7",
|
||||
"tailwindcss": "4",
|
||||
"wavesurfer.js": "^7.12.6",
|
||||
@@ -91,6 +94,8 @@
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
|
||||
|
||||
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
|
||||
|
||||
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
|
||||
@@ -545,8 +550,14 @@
|
||||
|
||||
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
|
||||
|
||||
"html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
|
||||
|
||||
"human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
|
||||
|
||||
"i18next": ["i18next@26.0.8", "", { "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-BRzLom0mhDhV9v0QhgUUHWQJuwFmnr1194xEcNLYD6ym8y8s542n4jXUvRLnhNTbh9PmpU6kGZamyuGHQMsGjw=="],
|
||||
|
||||
"i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.1", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw=="],
|
||||
|
||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||
@@ -683,6 +694,8 @@
|
||||
|
||||
"react-hot-toast": ["react-hot-toast@2.6.0", "", { "dependencies": { "csstype": "^3.1.3", "goober": "^2.1.16" }, "peerDependencies": { "react": ">=16", "react-dom": ">=16" } }, "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg=="],
|
||||
|
||||
"react-i18next": ["react-i18next@17.0.6", "", { "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.0.1", "react": ">= 16.8.0", "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-WzJ6SMKF+GTD7JZZqxSR1AKKmXjaSu39sClUrNlwxS4Tl7a99O+ltFy6yhPMO+wgZuxpQjJ2PZkfrQKmAqrLhw=="],
|
||||
|
||||
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
|
||||
|
||||
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
|
||||
@@ -745,8 +758,12 @@
|
||||
|
||||
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
|
||||
|
||||
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
||||
|
||||
"vite": ["vite@8.0.9", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.10", "rolldown": "1.0.0-rc.16", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw=="],
|
||||
|
||||
"void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
|
||||
|
||||
"wait-on": ["wait-on@9.0.5", "", { "dependencies": { "axios": "^1.15.0", "joi": "^18.1.2", "lodash": "^4.18.1", "minimist": "^1.2.8", "rxjs": "^7.8.2" }, "bin": { "wait-on": "bin/wait-on" } }, "sha512-qgnbHDfDTRIp73ANEJNRW/7kn8CrDUcvZz18xotJQku/P4saTGkbIzvnMZebPmVvVNUiRq1qWAPyqCH+W4H8KA=="],
|
||||
|
||||
"wavesurfer.js": ["wavesurfer.js@7.12.6", "", {}, "sha512-zSxPgOFprtyJ31ppHQF0+E9jAmjAhi1rR36yIW6h1GOYdpRxDe6mbkYtlChqLK0Iz8ROBweiEFw2zus7tDFibA=="],
|
||||
|
||||
@@ -35,11 +35,14 @@
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"@tauri-apps/plugin-window-state": "^2.4.1",
|
||||
"i18next": "^26.0.8",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-i18next": "^17.0.6",
|
||||
"react-window": "^2.2.7",
|
||||
"tailwindcss": "4",
|
||||
"wavesurfer.js": "^7.12.6",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
import en from './locales/en.json';
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources: { en: { translation: en } },
|
||||
fallbackLng: 'en',
|
||||
interpolation: { escapeValue: false },
|
||||
detection: {
|
||||
order: ['querystring', 'navigator', 'htmlTag'],
|
||||
lookupQuerystring: 'lng',
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"common": {
|
||||
"open": "Open",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"delete": "Delete",
|
||||
"loading": "Loading…",
|
||||
"error": "Something went wrong",
|
||||
"languages_count": "646 languages"
|
||||
},
|
||||
"launchpad": {
|
||||
"greeting": "hello there",
|
||||
"hero_title": "Make voices that <1>sound like you</1>.",
|
||||
"hero_desc": "Clone a voice, design a new one, or dub a video into any of <1>{{count}} languages</1>. Built for creators who care how it sounds.",
|
||||
"clone_title": "Voice Clone",
|
||||
"clone_desc": "Drop in a short clip — we'll mirror it. One sample is usually enough.",
|
||||
"design_title": "Voice Design",
|
||||
"design_desc": "Build a new voice from a sentence. Gender, age, accent, mood — your call.",
|
||||
"dub_title": "Video Dubbing",
|
||||
"dub_desc": "Transcribe, translate, re-voice. Keep each speaker, line up the timing, ship it.",
|
||||
"ab_compare": "A/B Compare",
|
||||
"cloned_voices": "Cloned Voices",
|
||||
"designed_voices": "Designed Voices",
|
||||
"dubbing_projects": "Dubbing Projects",
|
||||
"empty_hint": "Nothing here yet — pick a card above.",
|
||||
"demo_callout": "👋 Try the demo voice — hit Generate to hear it.",
|
||||
"locked": "LOCKED"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"models": "Models",
|
||||
"logs": "Logs",
|
||||
"general": "General",
|
||||
"privacy": "Privacy",
|
||||
"about": "About",
|
||||
"ui_scale": "UI Scale",
|
||||
"theme": "Theme"
|
||||
},
|
||||
"dub": {
|
||||
"transcribe": "Transcribe",
|
||||
"translate": "Translate",
|
||||
"generate": "Generate",
|
||||
"export": "Export",
|
||||
"no_video": "No video loaded",
|
||||
"segments": "segments",
|
||||
"speakers": "speakers"
|
||||
},
|
||||
"voice": {
|
||||
"personality": "Personality",
|
||||
"pick_personality": "Pick a personality preset…",
|
||||
"instruct": "Instruct",
|
||||
"reference_text": "Reference Text",
|
||||
"language": "Language",
|
||||
"generate": "Generate",
|
||||
"name": "Name"
|
||||
}
|
||||
}
|
||||
@@ -1290,6 +1290,65 @@ button:focus:not(:focus-visible) { outline: none; }
|
||||
border-radius: inherit; display: block;
|
||||
}
|
||||
|
||||
/* ── Demo-profile callout ────────────────────────────────── */
|
||||
.lp-demo-callout {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 18px; margin: 8px 44px 0;
|
||||
background: color-mix(in srgb, var(--chrome-accent) 8%, var(--chrome-bg));
|
||||
border: 1px solid var(--chrome-accent-border);
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
font-size: 0.76rem; color: var(--chrome-fg);
|
||||
position: relative; z-index: 1;
|
||||
animation: lpFadeUp 0.5s cubic-bezier(0.4,0,0.2,1) both;
|
||||
}
|
||||
.lp-demo-callout__icon { font-size: 1.1rem; }
|
||||
.lp-demo-callout__btn {
|
||||
margin-left: auto; padding: 4px 14px;
|
||||
font-family: var(--font-sans); font-size: 0.7rem; font-weight: 600;
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
background: var(--chrome-accent-bg);
|
||||
border: 1px solid var(--chrome-accent-border);
|
||||
color: var(--chrome-accent); cursor: pointer;
|
||||
transition: background var(--dur-fast);
|
||||
}
|
||||
.lp-demo-callout__btn:hover {
|
||||
background: color-mix(in srgb, var(--chrome-accent) 22%, transparent);
|
||||
}
|
||||
|
||||
/* ── Personality picker strip ────────────────────────────── */
|
||||
.personality-strip {
|
||||
display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px;
|
||||
}
|
||||
.personality-chip {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
padding: 5px 12px;
|
||||
font-family: var(--font-sans); font-size: 0.72rem; font-weight: 500;
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
background: transparent;
|
||||
border: 1px solid var(--chrome-border);
|
||||
color: var(--chrome-fg-muted); cursor: pointer;
|
||||
transition: background var(--dur-fast), border-color var(--dur-fast), color var(--dur-fast);
|
||||
}
|
||||
.personality-chip:hover {
|
||||
background: var(--chrome-hover-bg);
|
||||
border-color: var(--chrome-border-strong);
|
||||
color: var(--chrome-fg);
|
||||
}
|
||||
.personality-chip.active {
|
||||
background: var(--chrome-accent-bg);
|
||||
border-color: var(--chrome-accent-border);
|
||||
color: var(--chrome-accent);
|
||||
}
|
||||
.personality-chip__icon { font-size: 0.9rem; }
|
||||
.personality-label {
|
||||
font-family: var(--chrome-font-mono);
|
||||
font-size: var(--chrome-label-size);
|
||||
font-weight: 600; text-transform: uppercase;
|
||||
letter-spacing: var(--chrome-label-track);
|
||||
color: var(--chrome-fg-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* Project rows — chrome-radius pills so the launchpad project list
|
||||
rhymes with the Projects page cards. Dropped the squircle corners,
|
||||
the translate-X hover, and the icon rotation/scale micro-animation
|
||||
|
||||
@@ -9,6 +9,7 @@ import '@fontsource/ibm-plex-mono/400.css';
|
||||
import '@fontsource/ibm-plex-mono/500.css';
|
||||
import '@fontsource/ibm-plex-mono/600.css';
|
||||
import '@fontsource-variable/source-serif-4';
|
||||
import './i18n'; // ← initialise i18next before any component renders
|
||||
import './ui';
|
||||
import './index.css';
|
||||
import App from './App.jsx';
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
PanelLeftOpen, PanelLeftClose, Command, Globe, SlidersHorizontal, Volume2, User,
|
||||
UploadCloud, Square, Mic, Save, UserSquare2, Settings2, ChevronUp, ChevronDown,
|
||||
Sparkles, Play, Trash2, X,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import SearchableSelect from '../components/SearchableSelect';
|
||||
import ALL_LANGUAGES from '../languages.json';
|
||||
import { POPULAR_LANGS, PRESETS, TAGS, CATEGORIES } from '../utils/constants';
|
||||
import { Button, Input, Slider, Progress } from '../ui';
|
||||
import { API } from '../api/client';
|
||||
import './CloneDesignTab.css';
|
||||
|
||||
export default function CloneDesignTab(props) {
|
||||
@@ -45,6 +48,25 @@ export default function CloneDesignTab(props) {
|
||||
ingestRefAudio,
|
||||
} = props;
|
||||
|
||||
const { t } = useTranslation();
|
||||
const [activePersonality, setActivePersonality] = useState('');
|
||||
|
||||
// Fetch personality presets from backend
|
||||
const { data: personalities = [] } = useQuery({
|
||||
queryKey: ['personalities'],
|
||||
queryFn: () => fetch(`${API}/personalities`).then(r => r.json()),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const applyPersonality = (p) => {
|
||||
if (activePersonality === p.id) {
|
||||
setActivePersonality('');
|
||||
return;
|
||||
}
|
||||
setActivePersonality(p.id);
|
||||
setInstruct(p.instruct);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="clone-split-grid">
|
||||
|
||||
@@ -239,7 +261,27 @@ export default function CloneDesignTab(props) {
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="label-row"><UserSquare2 className="label-icon" size={14} /> Voice Profile</div>
|
||||
<div className="label-row"><UserSquare2 className="label-icon" size={14} /> {t('voice.personality')}</div>
|
||||
|
||||
{/* Personality presets */}
|
||||
{personalities.length > 0 && (
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<div className="personality-label">{t('voice.pick_personality')}</div>
|
||||
<div className="personality-strip">
|
||||
{personalities.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
className={`personality-chip ${activePersonality === p.id ? 'active' : ''}`}
|
||||
onClick={() => applyPersonality(p)}
|
||||
>
|
||||
<span className="personality-chip__icon">{p.icon}</span>
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="clone-sliders-col">
|
||||
{Object.entries(CATEGORIES).map(([key, options]) => {
|
||||
const many = options.length > 6;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Scale, Fingerprint, Wand2, Film, Lock,
|
||||
} from 'lucide-react';
|
||||
@@ -61,8 +62,10 @@ export default function Launchpad({
|
||||
profiles, studioProjects, dubHistory,
|
||||
setMode, setIsCompareModalOpen, handleSelectProfile, loadProject,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const cloneProfiles = profiles.filter(p => !p.instruct);
|
||||
const designProfiles = profiles.filter(p => !!p.instruct);
|
||||
const demoProfile = profiles.find(p => p.id === 'demo0001');
|
||||
|
||||
return (
|
||||
<div className="launchpad">
|
||||
@@ -96,7 +99,7 @@ export default function Launchpad({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="lp-kicker">hello there</span>
|
||||
<span className="lp-kicker">{t('launchpad.greeting')}</span>
|
||||
</div>
|
||||
<h1 className="lp-hero__title">
|
||||
<span className="lp-hero__halo" aria-hidden="true" />
|
||||
@@ -104,7 +107,7 @@ export default function Launchpad({
|
||||
<span className="lp-hero__sweep" aria-hidden="true" />
|
||||
</h1>
|
||||
<p>
|
||||
Clone a voice, design a new one, or dub a video into any of <span className="lp-pill">646 languages</span>.
|
||||
Clone a voice, design a new one, or dub a video into any of <span className="lp-pill">{t('common.languages_count')}</span>.
|
||||
Built for creators who care how it sounds.
|
||||
</p>
|
||||
</div>
|
||||
@@ -113,7 +116,7 @@ export default function Launchpad({
|
||||
className="lp-ab-compare"
|
||||
title="Try two voices side by side"
|
||||
>
|
||||
<Scale size={12} /> A/B Compare
|
||||
<Scale size={12} /> {t('launchpad.ab_compare')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -121,17 +124,31 @@ export default function Launchpad({
|
||||
|
||||
{/* Action Cards */}
|
||||
<div className="lp-actions">
|
||||
<ActionCard hue="#d3869b" Icon={Fingerprint} title="Voice Clone" accent="✨" count={cloneProfiles.length} onClick={() => setMode('clone')}>
|
||||
Drop in a short clip — we'll mirror it. One sample is usually enough.
|
||||
<ActionCard hue="#d3869b" Icon={Fingerprint} title={t('launchpad.clone_title')} accent="✨" count={cloneProfiles.length} onClick={() => setMode('clone')}>
|
||||
{t('launchpad.clone_desc')}
|
||||
</ActionCard>
|
||||
<ActionCard hue="#8ec07c" Icon={Wand2} title="Voice Design" accent="🧪" count={designProfiles.length} onClick={() => setMode('design')}>
|
||||
Build a new voice from a sentence. Gender, age, accent, mood — your call.
|
||||
<ActionCard hue="#8ec07c" Icon={Wand2} title={t('launchpad.design_title')} accent="🧪" count={designProfiles.length} onClick={() => setMode('design')}>
|
||||
{t('launchpad.design_desc')}
|
||||
</ActionCard>
|
||||
<ActionCard hue="#fe8019" Icon={Film} title="Video Dubbing" accent="🎬" count={studioProjects.length} onClick={() => setMode('dub')}>
|
||||
Transcribe, translate, re-voice. Keep each speaker, line up the timing, ship it.
|
||||
<ActionCard hue="#fe8019" Icon={Film} title={t('launchpad.dub_title')} accent="🎬" count={studioProjects.length} onClick={() => setMode('dub')}>
|
||||
{t('launchpad.dub_desc')}
|
||||
</ActionCard>
|
||||
</div>
|
||||
|
||||
{/* Demo profile callout */}
|
||||
{demoProfile && profiles.length === 1 && studioProjects.length === 0 && (
|
||||
<div className="lp-demo-callout">
|
||||
<span className="lp-demo-callout__icon">👋</span>
|
||||
<span>{t('launchpad.demo_callout')}</span>
|
||||
<button
|
||||
className="lp-demo-callout__btn"
|
||||
onClick={() => { setMode('clone'); handleSelectProfile(demoProfile); }}
|
||||
>
|
||||
Try it
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Projects */}
|
||||
{(profiles.length > 0 || studioProjects.length > 0) && (
|
||||
<div className="lp-section">
|
||||
@@ -220,7 +237,7 @@ export default function Launchpad({
|
||||
))}
|
||||
</div>
|
||||
<p className="lp-empty__hint">
|
||||
Nothing here yet — pick a card above.
|
||||
{t('launchpad.empty_hint')}
|
||||
</p>
|
||||
</div>
|
||||
<ReadinessChecklist showWhenAllPass />
|
||||
|
||||
Reference in New Issue
Block a user