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:
debpalash
2026-04-28 11:11:19 +05:30
parent 0612a10aa6
commit c77bf18ac4
14 changed files with 368 additions and 16 deletions
+12 -3
View File
@@ -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.
+5
View File
@@ -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
+62
View File
@@ -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()
+58
View File
@@ -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
+3
View File
@@ -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.
+18 -1
View File
@@ -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=="],
+3
View File
@@ -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",
+19
View File
@@ -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;
+57
View File
@@ -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"
}
}
+59
View File
@@ -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
+1
View File
@@ -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';
+44 -2
View File
@@ -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;
+27 -10
View File
@@ -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 />