gepard / index.html
akhaliq's picture
akhaliq HF Staff
Feature parity with original Blocks UI
f12cb60
Raw
History Blame Contribute Delete
41.4 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>GEPARD STUDIO</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<style>
:root {
--bg: #fafafa;
--bg-elev: #ffffff;
--bg-elev-2: #f5f5f7;
--bg-input: #ffffff;
--bg-viewport: #ffffff;
--line: #e5e5ea;
--line-strong: #d1d1d6;
--line-hairline: #ececef;
--text: #1d1d1f;
--text-dim: #6e6e73;
--text-faint: #a1a1a6;
--text-label: #3a3a3c;
--accent: #f97316;
--accent-hover: #ea580c;
--accent-soft: rgba(249, 115, 22, 0.08);
--accent-text: #c2410c;
--red: #dc2626;
--green: #16a34e;
--hl-bg: rgba(249, 115, 22, 0.16);
--hl-text: #c2410c;
--radius-sm: 6px;
--radius: 10px;
--radius-lg: 16px;
--radius-pill: 999px;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.04);
--shadow: 0 1px 3px rgba(0,0,0,0.06), 0 8px 24px rgba(0,0,0,0.04);
--shadow-lg: 0 4px 12px rgba(0,0,0,0.08), 0 24px 48px rgba(0,0,0,0.06);
}
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
background: var(--bg);
color: var(--text);
font-family: "Inter", -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", ui-sans-serif, system-ui, sans-serif;
font-size: 14px;
line-height: 1.5;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
letter-spacing: -0.005em;
}
body { display: flex; flex-direction: column; min-height: 100vh; }
.mono { font-family: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace; font-variant-numeric: tabular-nums; }
/* ===== Top bar ===== */
.topbar {
display: flex; align-items: center; justify-content: space-between;
height: 52px; padding: 0 20px;
background: rgba(255, 255, 255, 0.8);
backdrop-filter: saturate(180%) blur(20px);
-webkit-backdrop-filter: saturate(180%) blur(20px);
border-bottom: 1px solid var(--line-hairline);
position: sticky; top: 0; z-index: 10;
}
.brand { display: flex; align-items: center; gap: 10px; }
.brand .logo {
width: 26px; height: 26px;
background: linear-gradient(135deg, #fbbf24, #f97316, #dc2626);
border-radius: 7px;
display: inline-flex; align-items: center; justify-content: center;
font-size: 13px; color: #fff; font-weight: 700;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
}
.brand .name { font-size: 15px; font-weight: 600; letter-spacing: -0.01em; }
.brand .name .accent {
background: linear-gradient(90deg, #f97316, #dc2626);
-webkit-background-clip: text; background-clip: text; color: transparent;
}
.project-info {
display: flex; align-items: center; gap: 12px; margin-left: 18px;
padding-left: 18px; border-left: 1px solid var(--line-hairline);
}
.project-info .name { font-size: 13px; font-weight: 500; color: var(--text-dim); }
.stage-pill {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px;
background: var(--bg-elev-2);
border-radius: var(--radius-pill);
font-size: 11px; font-weight: 500;
color: var(--text-dim);
}
.stage-pill .dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--text-faint);
}
.stage-pill .dot.live { background: var(--accent); box-shadow: 0 0 6px rgba(249,115,22,0.5); }
.stage-pill .dot.playing { background: var(--green); box-shadow: 0 0 6px rgba(22,163,74,0.5); animation: pulse 1.4s ease-in-out infinite; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
/* ===== Viewport ===== */
.viewport {
margin: 16px 16px 0;
background: var(--bg-viewport);
border: 1px solid var(--line-hairline);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
overflow: hidden;
display: flex; flex-direction: column;
}
.viewport-head {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 18px;
border-bottom: 1px solid var(--line-hairline);
}
.viewport-head .left, .viewport-head .right { display: flex; align-items: center; gap: 12px; }
.viewport-head .label {
font-size: 11px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-dim);
}
/* Lyrics + input combined */
.stage {
min-height: 280px;
background: var(--bg-viewport);
position: relative;
}
.stage .progress {
position: absolute; top: 0; left: 0; right: 0; height: 2px;
background: var(--line-hairline);
z-index: 2;
}
.stage .progress .fill {
height: 100%; width: 0;
background: linear-gradient(90deg, #fbbf24, #f97316, #dc2626);
transition: width 0.05s linear;
}
.stage .text {
padding: 56px 40px 48px;
font-size: 28px;
font-weight: 400;
line-height: 1.55;
color: var(--text);
letter-spacing: -0.015em;
max-width: 880px;
margin: 0 auto;
text-align: center;
outline: none;
min-height: 280px;
display: flex; align-items: center; justify-content: center;
flex-wrap: wrap;
}
.stage .text:focus { outline: none; }
.stage .text[data-empty="true"]::before {
content: attr(data-placeholder);
color: var(--text-faint);
font-weight: 300;
pointer-events: none;
width: 100%;
}
.stage .text .word {
display: inline-block;
padding: 2px 4px;
margin: 0 1px;
border-radius: 6px;
color: rgba(29, 29, 31, 0.32);
transition: color 0.2s ease, background-color 0.2s ease, transform 0.2s ease;
}
.stage .text .word.past { color: rgba(29, 29, 31, 0.55); }
.stage .text .word.current {
color: var(--hl-text);
background: var(--hl-bg);
transform: scale(1.04);
}
/* Transport (compact) */
.transport {
display: flex; align-items: center; gap: 14px;
padding: 10px 18px;
border-top: 1px solid var(--line-hairline);
}
.transport-btns { display: flex; align-items: center; gap: 6px; }
.transport-btns button {
appearance: none; border: 0;
background: transparent; color: var(--text-dim);
cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: all 0.15s;
border-radius: var(--radius-sm);
}
.transport-btns button:hover:not(:disabled) { color: var(--text); background: var(--bg-elev-2); }
.transport-btns button:disabled { color: var(--text-faint); cursor: not-allowed; }
.transport-btns .skip { width: 30px; height: 30px; font-size: 11px; }
.transport-btns .play {
width: 36px; height: 36px; font-size: 13px;
background: var(--text); color: var(--bg-viewport);
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
}
.transport-btns .play:hover:not(:disabled) { background: #000; }
.transport-btns .play:disabled { background: var(--line); color: var(--text-faint); box-shadow: none; }
.timecode {
font-family: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace;
font-variant-numeric: tabular-nums;
font-size: 13px; font-weight: 500;
color: var(--text);
letter-spacing: 0.02em;
}
.timecode .sep { color: var(--text-faint); margin: 0 2px; }
.timecode .dur { color: var(--text-dim); }
.transport .spacer { flex: 1; }
.transport .info {
font-size: 12px;
color: var(--text-dim);
}
.transport .info .k { color: var(--text-faint); margin-right: 4px; }
.transport .info .v { color: var(--text); font-weight: 500; }
/* ===== Console (single panel) ===== */
.console {
margin: 14px 16px 16px;
background: var(--bg-elev);
border: 1px solid var(--line-hairline);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
overflow: hidden;
}
.console-body {
padding: 16px 18px 18px;
display: flex; flex-direction: column; gap: 14px;
}
/* Row 1: Source + voice + advanced */
.row-source {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 10px;
align-items: center;
}
.seg {
display: inline-flex;
background: var(--bg-elev-2);
border-radius: var(--radius-sm);
padding: 2px;
}
.seg button {
appearance: none; border: 0; background: transparent;
color: var(--text-dim);
padding: 6px 12px;
border-radius: 5px;
font: inherit; font-weight: 500; font-size: 13px;
cursor: pointer; transition: all 0.15s;
}
.seg button.active {
background: var(--bg-elev);
color: var(--text);
box-shadow: 0 1px 2px rgba(0,0,0,0.06), 0 0 0 1px var(--line);
}
.seg button:not(.active):hover { color: var(--text); }
select {
width: 100%;
background: var(--bg-elev-2);
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--text);
font: inherit; font-size: 13px;
padding: 7px 12px;
outline: none;
transition: all 0.15s;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23a1a1a6' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
padding-right: 28px;
}
select:focus { border-color: var(--line-strong); background-color: var(--bg-elev); }
.advanced-toggle {
appearance: none; border: 0; background: transparent;
color: var(--text-dim); font: inherit; font-size: 12px;
font-weight: 500;
padding: 6px 10px;
cursor: pointer;
border-radius: var(--radius-sm);
transition: all 0.15s;
display: inline-flex; align-items: center; gap: 6px;
}
.advanced-toggle:hover { color: var(--text); background: var(--bg-elev-2); }
.advanced-toggle .chevron {
width: 10px; height: 10px;
transition: transform 0.2s;
}
.advanced-toggle.open .chevron { transform: rotate(180deg); }
/* Ref uploader (compact inline) */
.ref-inline {
margin-top: 10px;
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
align-items: center;
}
.file-drop {
border: 1px dashed var(--line-strong);
border-radius: var(--radius-sm);
padding: 8px 12px;
text-align: center;
color: var(--text-dim);
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.file-drop:hover, .file-drop.drag { border-color: var(--accent); color: var(--accent-text); background: var(--accent-soft); }
.file-name { font-size: 11px; color: var(--accent-text); margin-top: 4px; }
/* Reference uploader tabs */
.ref-tabs {
display: inline-flex;
background: var(--bg-elev-2);
border-radius: var(--radius-sm);
padding: 2px;
margin-bottom: 8px;
}
.ref-tab {
appearance: none; border: 0; background: transparent;
color: var(--text-dim);
padding: 5px 12px;
border-radius: 5px;
font: inherit; font-weight: 500; font-size: 12px;
cursor: pointer; transition: all 0.15s;
}
.ref-tab.active {
background: var(--bg-elev);
color: var(--text);
box-shadow: 0 1px 2px rgba(0,0,0,0.06), 0 0 0 1px var(--line);
}
.ref-tab:not(.active):hover { color: var(--text); }
.ref-panel.hidden { display: none; }
/* Mic UI */
.mic-ui {
display: flex; align-items: center; gap: 12px;
padding: 12px;
background: var(--bg-elev-2);
border-radius: var(--radius-sm);
}
.mic-btn {
appearance: none; border: 0;
width: 44px; height: 44px;
border-radius: 50%;
background: var(--bg-elev);
color: var(--text-dim);
cursor: pointer;
display: flex; align-items: center; justify-content: center;
border: 1px solid var(--line);
transition: all 0.15s;
flex-shrink: 0;
}
.mic-btn:hover { color: var(--text); border-color: var(--line-strong); }
.mic-btn.recording {
background: var(--red); color: white; border-color: transparent;
animation: micPulse 1.4s ease-in-out infinite;
}
.mic-btn.recording .mic-icon { color: white; }
.mic-icon { font-size: 14px; color: var(--text-dim); }
@keyframes micPulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(220,38,38,0.4); }
50% { box-shadow: 0 0 0 8px rgba(220,38,38,0); }
}
.mic-info { flex: 1; min-width: 0; }
.mic-state { font-size: 12px; color: var(--text-dim); }
.mic-btn.recording + .mic-info .mic-state { color: var(--red); font-weight: 500; }
.mic-time { font-size: 14px; color: var(--text); font-weight: 500; margin-top: 2px; }
/* Advanced panel (collapsed by default) */
.advanced {
overflow: hidden;
max-height: 0;
transition: max-height 0.25s ease;
}
.advanced.open { max-height: 400px; }
.advanced-inner {
padding-top: 12px;
border-top: 1px solid var(--line-hairline);
margin-top: 4px;
}
.advanced-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 8px 16px;
margin-bottom: 12px;
padding: 10px 14px;
background: var(--bg-elev-2);
border-radius: var(--radius-sm);
}
.advanced-summary .item {
display: flex; align-items: baseline; gap: 8px;
font-size: 12px;
}
.advanced-summary .item .k {
color: var(--text-faint);
text-transform: uppercase;
letter-spacing: 0.06em;
font-size: 10px;
font-weight: 600;
}
.advanced-summary .item .v {
color: var(--text);
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 12px;
font-weight: 500;
}
.advanced-sliders {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 4px 20px;
}
.slider {
display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 10px; padding: 4px 0;
}
.slider .name { font-size: 12px; color: var(--text-dim); }
.slider .val {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 12px; color: var(--text);
min-width: 48px; text-align: right;
font-variant-numeric: tabular-nums;
}
input[type="range"] {
-webkit-appearance: none; appearance: none;
width: 100%; height: 16px; background: transparent; cursor: pointer;
}
input[type="range"]::-webkit-slider-runnable-track {
height: 4px; background: var(--line); border-radius: 2px;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: 14px; height: 14px; border-radius: 50%;
background: var(--accent);
margin-top: -5px;
box-shadow: 0 0 0 3px var(--bg-elev), 0 1px 3px rgba(0,0,0,0.15);
cursor: pointer;
}
input[type="range"]::-moz-range-track { height: 4px; background: var(--line); border-radius: 2px; }
input[type="range"]::-moz-range-thumb {
width: 14px; height: 14px; border: 3px solid var(--bg-elev);
border-radius: 50%; background: var(--accent);
box-shadow: 0 1px 3px rgba(0,0,0,0.15);
cursor: pointer;
}
/* Row 2: Examples + Render (compact) */
.row-actions {
display: flex; align-items: center; gap: 14px;
flex-wrap: wrap;
}
.examples {
display: flex; flex-wrap: wrap; gap: 6px;
flex: 1; min-width: 0;
}
.example-chip {
appearance: none;
display: inline-flex; align-items: center; gap: 6px;
padding: 5px 10px;
background: var(--bg-elev-2);
border: 1px solid transparent;
border-radius: var(--radius-pill);
color: var(--text-dim);
font: inherit; font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.example-chip:hover { border-color: var(--line); color: var(--text); background: var(--bg-elev); }
.example-chip .tag {
font-weight: 600;
color: var(--accent-text);
}
.example-chip .txt { max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.example-chip .status { font-size: 10px; color: var(--text-faint); margin-left: 2px; }
/* Compact render button */
.render-btn {
appearance: none; border: 0;
display: inline-flex; align-items: center; gap: 8px;
padding: 8px 16px;
border-radius: var(--radius-sm);
background: var(--text);
color: var(--bg-elev);
font: inherit; font-size: 13px; font-weight: 600;
cursor: pointer;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
transition: all 0.15s;
flex-shrink: 0;
}
.render-btn:hover:not(:disabled) { background: #000; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0,0,0,0.15); }
.render-btn:active:not(:disabled) { transform: translateY(0); }
.render-btn:disabled { background: var(--line); color: var(--text-faint); cursor: not-allowed; box-shadow: none; }
.render-btn .icon {
width: 14px; height: 14px;
display: inline-flex; align-items: center; justify-content: center;
}
.render-btn .spinner {
width: 12px; height: 12px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Toast */
.toast {
padding: 8px 12px;
border-radius: var(--radius-sm);
font-size: 12px;
background: #fef2f2;
border: 1px solid #fecaca;
color: #b91c1c;
}
.toast.hidden { display: none; }
/* ===== Mobile / Tablet ===== */
@media (max-width: 900px) {
.viewport { margin: 12px 12px 0; border-radius: 14px; }
.viewport-head { padding: 10px 14px; }
.console { margin: 12px 12px 12px; border-radius: 14px; }
.advanced-summary { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 600px) {
html, body { font-size: 15px; }
body { padding-bottom: env(safe-area-inset-bottom); }
/* Top bar — drop the project info, keep brand + stage pill */
.topbar { height: 48px; padding: 0 14px; }
.project-info { display: none; }
/* Viewport */
.viewport { margin: 8px 8px 0; border-radius: 12px; }
.viewport-head { padding: 10px 14px; }
.viewport-head .label { font-size: 10px; }
#voice-info { display: none; }
/* Stage — tighter padding, smaller type, more vertical room */
.stage { min-height: 240px; }
.stage .text {
padding: 40px 22px 36px;
font-size: 22px;
line-height: 1.5;
min-height: 240px;
}
/* Transport — full-width layout, info row hidden */
.transport { padding: 10px 14px; gap: 10px; flex-wrap: wrap; }
.transport-btns .skip { width: 36px; height: 36px; font-size: 12px; }
.transport-btns .play { width: 42px; height: 42px; font-size: 14px; }
.transport .spacer { flex: 1; }
.transport .info { display: none; }
.timecode { font-size: 14px; }
.timecode #dur-text { font-size: 12px; }
/* Console */
.console { margin: 10px 8px 12px; border-radius: 12px; }
.console-body { padding: 14px; gap: 12px; }
/* Source row — stack vertically */
.row-source {
grid-template-columns: 1fr;
gap: 8px;
}
.row-source .seg { width: 100%; }
.row-source .seg button { flex: 1; padding: 8px 12px; }
.row-source select { width: 100%; padding: 10px 14px; }
.row-source .advanced-toggle {
width: 100%;
justify-content: center;
padding: 10px 12px;
background: var(--bg-elev-2);
border-radius: var(--radius-sm);
}
/* Reference uploader */
.ref-inline {
grid-template-columns: 1fr;
gap: 6px;
}
.file-drop { padding: 12px; font-size: 13px; }
/* Advanced panel — single column */
.advanced-summary { grid-template-columns: repeat(2, 1fr); gap: 6px 10px; padding: 10px 12px; }
.advanced-summary .item { font-size: 11px; }
.advanced-sliders { grid-template-columns: 1fr; }
/* Actions row — examples wrap above, render full-width */
.row-actions {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.examples { order: 1; }
.example-chip { padding: 6px 11px; font-size: 12px; }
.render-btn {
order: 2;
width: 100%;
padding: 12px 16px;
font-size: 14px;
justify-content: center;
}
.toast { order: 3; }
/* Toast — slide up from bottom on mobile for visibility */
.toast {
position: fixed;
left: 12px; right: 12px; bottom: 12px;
margin: 0;
padding: 12px 16px;
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
z-index: 20;
}
}
@media (max-width: 380px) {
.stage .text { font-size: 19px; padding: 32px 16px 28px; }
.advanced-summary { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="topbar">
<div style="display: flex; align-items: center;">
<div class="brand">
<span class="logo">G</span>
<span class="name">GEPARD<span class="accent">.</span></span>
</div>
<div class="project-info">
<span class="name" id="project-name">Session</span>
</div>
</div>
<div class="stage-pill">
<span class="dot" id="stage-dot"></span>
<span id="stage-text">Ready</span>
</div>
</div>
<section class="viewport">
<div class="viewport-head">
<div class="left">
<span class="label">Script · Audio</span>
</div>
<div class="right">
<span class="mono" id="voice-info" style="font-size: 12px; color: var(--text-dim)"></span>
</div>
</div>
<div class="stage">
<div class="progress"><div class="fill" id="progress-fill"></div></div>
<div class="text" id="text"
contenteditable="true"
spellcheck="false"
data-placeholder="Type or paste your script here, then press Render."
data-empty="true"></div>
</div>
<div class="transport">
<div class="transport-btns">
<button class="skip" id="stop-btn" type="button" title="Return to start" disabled></button>
<button class="play" id="play-btn" type="button" title="Play / Pause" disabled></button>
<button class="skip" id="download-btn" type="button" title="Download WAV" disabled></button>
</div>
<div class="timecode mono">
<span id="tc-s">00</span><span class="sep">:</span><span id="tc-f">00</span>
<span style="margin-left: 8px; font-size: 11px; color: var(--text-faint)" id="dur-text">0:00</span>
</div>
<div class="spacer"></div>
<div class="info">
<span class="k">Engine</span><span class="v">Gepard · 32 FSQ heads</span>
</div>
</div>
</section>
<section class="console">
<div class="console-body">
<div class="row-source">
<div class="seg" id="mode-seg">
<button id="mode-preset" class="active" type="button">Preset</button>
<button id="mode-clone" type="button">Clone</button>
</div>
<select id="speaker"></select>
<button class="advanced-toggle" id="advanced-toggle" type="button">
<span>Advanced</span>
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>
</button>
</div>
<div class="ref-inline hidden" id="ref-section">
<div class="ref-tabs">
<button class="ref-tab active" data-tab="upload" type="button">Upload</button>
<button class="ref-tab" data-tab="mic" type="button">Record</button>
</div>
<div class="ref-panel" data-panel="upload">
<div class="file-drop" id="drop">Drop clip · or click to upload</div>
<input type="file" id="file-input" accept="audio/*" hidden />
</div>
<div class="ref-panel hidden" data-panel="mic">
<div class="mic-ui" id="mic-ui">
<button class="mic-btn" id="mic-btn" type="button">
<span class="mic-icon" id="mic-icon"></span>
</button>
<div class="mic-info">
<div class="mic-state" id="mic-state">Tap to record · ≤ 60s</div>
<div class="mic-time mono" id="mic-time">0:00</div>
</div>
</div>
</div>
<div class="file-name" id="file-name"></div>
</div>
<div class="advanced" id="advanced">
<div class="advanced-inner">
<div class="advanced-summary" id="advanced-summary"></div>
<div class="advanced-sliders" id="sliders"></div>
</div>
</div>
<div class="row-actions">
<div class="examples" id="examples"></div>
<button class="render-btn" id="render-btn" type="button">
<span class="icon" id="render-icon"></span>
<span id="render-label">Render</span>
</button>
</div>
<div class="toast hidden" id="toast"></div>
</div>
</section>
<script type="module">
import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
const $ = (id) => document.getElementById(id);
const els = {
stageDot: $("stage-dot"), stageText: $("stage-text"),
text: $("text"), progressFill: $("progress-fill"),
voiceInfo: $("voice-info"),
playBtn: $("play-btn"), stopBtn: $("stop-btn"),
tcS: $("tc-s"), tcF: $("tc-f"), durText: $("dur-text"),
modePreset: $("mode-preset"), modeClone: $("mode-clone"),
speaker: $("speaker"),
advancedToggle: $("advanced-toggle"), advanced: $("advanced"),
advancedSummary: $("advanced-summary"),
refSection: $("ref-section"),
drop: $("drop"), fileInput: $("file-input"), fileName: $("file-name"),
micBtn: $("mic-btn"), micIcon: $("mic-icon"), micState: $("mic-state"), micTime: $("mic-time"),
sliders: $("sliders"),
examples: $("examples"),
renderBtn: $("render-btn"), renderIcon: $("render-icon"), renderLabel: $("render-label"),
toast: $("toast"),
downloadBtn: $("download-btn"),
};
let cfg = null;
let client = null;
let mode = "preset";
let refFile = null;
let audioEl = new Audio();
let currentSubmission = null;
let wordSpans = [];
let hlRAF = null;
let isPlaying = false;
const sliderSpec = [
{ key: "temperature", label: "Temperature", fmt: v => Number(v).toFixed(2) },
{ key: "top_k", label: "Top-K", fmt: v => String(Math.round(v)) },
{ key: "max_frames", label: "Max Frames", fmt: v => String(Math.round(v)) },
{ key: "repetition_penalty", label: "Rep. Penalty", fmt: v => Number(v).toFixed(2) },
{ key: "repetition_window", label: "Rep. Window", fmt: v => String(Math.round(v)) },
];
const pad = (n, w = 2) => String(Math.floor(n)).padStart(w, "0");
const fmtTime = (s) => { if (!isFinite(s)) return "0:00"; const m = Math.floor(s / 60); const r = Math.floor(s % 60); return `${m}:${pad(s%60)}`; };
const setStage = (state, text) => {
els.stageDot.classList.remove("live", "playing");
if (state) els.stageDot.classList.add(state);
els.stageText.textContent = text;
};
const setBusy = (busy) => {
els.renderBtn.disabled = busy;
if (busy) {
els.renderIcon.innerHTML = '<span class="spinner"></span>';
els.renderLabel.textContent = "Rendering";
} else {
els.renderIcon.textContent = "▶";
els.renderLabel.textContent = "Render";
}
};
const showError = (msg) => { els.toast.textContent = msg; els.toast.classList.remove("hidden"); };
const clearError = () => { els.toast.classList.add("hidden"); els.toast.textContent = ""; };
// ---------- Mode ----------
const setMode = (m) => {
mode = m;
els.modePreset.classList.toggle("active", m === "preset");
els.modeClone.classList.toggle("active", m !== "preset");
els.refSection.classList.toggle("hidden", m !== "clone");
};
els.modePreset.addEventListener("click", () => setMode("preset"));
els.modeClone.addEventListener("click", () => setMode("clone"));
// ---------- Reference: upload tabs ----------
// Tab switching
document.querySelectorAll(".ref-tab").forEach(tab => {
tab.addEventListener("click", () => {
document.querySelectorAll(".ref-tab").forEach(t => t.classList.remove("active"));
tab.classList.add("active");
const target = tab.dataset.tab;
document.querySelectorAll(".ref-panel").forEach(p => {
p.classList.toggle("hidden", p.dataset.panel !== target);
});
});
});
// File drop / upload
els.drop.addEventListener("click", () => els.fileInput.click());
els.fileInput.addEventListener("change", (e) => { const f = e.target.files[0]; if (f) setRefFile(f); });
["dragenter", "dragover"].forEach(ev => els.drop.addEventListener(ev, (e) => { e.preventDefault(); els.drop.classList.add("drag"); }));
["dragleave", "drop"].forEach(ev => els.drop.addEventListener(ev, (e) => { e.preventDefault(); els.drop.classList.remove("drag"); }));
els.drop.addEventListener("drop", (e) => { const f = e.dataTransfer.files[0]; if (f) setRefFile(f); });
const setRefFile = (f) => { refFile = f; els.fileName.textContent = `✓ ${f.name}`; };
// ---------- Microphone recording (MediaRecorder API) ----------
let mediaRecorder = null;
let micStream = null;
let micChunks = [];
let micStartedAt = 0;
let micTimer = null;
const stopMic = () => {
if (mediaRecorder && mediaRecorder.state === "recording") {
mediaRecorder.stop();
}
if (micStream) {
micStream.getTracks().forEach(t => t.stop());
micStream = null;
}
if (micTimer) { clearInterval(micTimer); micTimer = null; }
els.micBtn.classList.remove("recording");
};
const startMic = async () => {
try {
micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch (err) {
showError(`Microphone access denied: ${err.message}`);
return;
}
micChunks = [];
mediaRecorder = new MediaRecorder(micStream);
mediaRecorder.ondataavailable = (e) => { if (e.data.size) micChunks.push(e.data); };
mediaRecorder.onstop = () => {
const blob = new Blob(micChunks, { type: "audio/webm" });
const file = new File([blob], `recording-${Date.now()}.webm`, { type: "audio/webm" });
setRefFile(file);
els.micState.textContent = "Recorded · ready to render";
};
mediaRecorder.start();
micStartedAt = Date.now();
els.micBtn.classList.add("recording");
els.micState.textContent = "Recording · tap to stop";
els.micTime.textContent = "0:00";
micTimer = setInterval(() => {
const elapsed = (Date.now() - micStartedAt) / 1000;
const max = cfg?.max_ref_seconds || 60;
if (elapsed >= max) { stopMic(); return; }
const m = Math.floor(elapsed / 60);
const s = Math.floor(elapsed % 60);
els.micTime.textContent = `${m}:${String(s).padStart(2, "0")}`;
}, 200);
};
els.micBtn.addEventListener("click", () => {
if (els.micBtn.classList.contains("recording")) stopMic();
else startMic();
});
// ---------- Advanced toggle ----------
els.advancedToggle.addEventListener("click", () => {
els.advancedToggle.classList.toggle("open");
els.advanced.classList.toggle("open");
});
// ---------- Script editor (combined input + lyrics) ----------
// Two modes:
// - idle (no audio loaded): editable contenteditable, shows placeholder
// - playing: highlights current word; clicking back into edit clears highlights
const updatePlaceholder = () => {
const txt = els.text.innerText.trim();
els.text.setAttribute("data-empty", txt.length === 0 ? "true" : "false");
};
els.text.addEventListener("input", () => {
updatePlaceholder();
clearHighlight();
});
els.text.addEventListener("focus", () => {
clearHighlight();
isPlaying = false;
});
els.text.addEventListener("blur", () => {
updatePlaceholder();
});
const scriptText = () => {
const t = els.text.innerText.trim();
return t.length === 0 ? "" : t;
};
// Tokenize the current text into word spans for highlighting
const tokenize = (text) => {
els.text.innerHTML = "";
wordSpans = [];
const tokens = text.split(/(\s+)/);
for (const tok of tokens) {
if (/^\s+$/.test(tok)) {
els.text.appendChild(document.createTextNode(tok));
} else if (tok.length) {
const span = document.createElement("span");
span.className = "word";
span.textContent = tok;
els.text.appendChild(span);
wordSpans.push(span);
}
}
updatePlaceholder();
};
const clearHighlight = () => {
wordSpans.forEach(s => s.classList.remove("current", "past"));
};
const setCurrentWord = (idx) => {
for (let i = 0; i < wordSpans.length; i++) {
const s = wordSpans[i];
s.classList.remove("current");
if (i < idx) s.classList.add("past");
else s.classList.remove("past");
}
if (idx >= 0 && idx < wordSpans.length) {
const cur = wordSpans[idx];
cur.classList.add("current");
cur.classList.remove("past");
}
};
// ---------- Sliders + summary ----------
const buildSliders = () => {
els.sliders.innerHTML = "";
els.advancedSummary.innerHTML = "";
sliderSpec.forEach(spec => {
const min = cfg.limits[`min_${spec.key}`];
const max = cfg.limits[`max_${spec.key}`];
const def = cfg.defaults[spec.key];
const step = spec.key === "temperature" || spec.key === "repetition_penalty" ? 0.01 : 1;
// Slider row
const row = document.createElement("div"); row.className = "slider";
row.innerHTML = `<span class="name">${spec.label}</span><span class="val mono" id="sv-${spec.key}">${spec.fmt(def)}</span><input type="range" id="sl-${spec.key}" min="${min}" max="${max}" step="${step}" value="${def}" style="grid-column: 1 / -1" />`;
els.sliders.appendChild(row);
const input = row.querySelector("input"), out = row.querySelector(".val");
input.addEventListener("input", () => {
out.textContent = spec.fmt(input.value);
updateSummary();
});
// Summary chip
const item = document.createElement("div"); item.className = "item";
item.innerHTML = `<span class="k">${spec.label}</span><span class="v" id="sum-${spec.key}">${spec.fmt(def)}</span>`;
els.advancedSummary.appendChild(item);
});
};
const updateSummary = () => {
sliderSpec.forEach(spec => {
const out = $(`sum-${spec.key}`);
if (out) out.textContent = spec.fmt(sliderValue(spec.key));
});
};
const sliderValue = (key) => Number($(`sl-${key}`).value);
// ---------- Player ----------
const loadAudio = (url, script, filename = "gepard.wav") => {
audioEl = new Audio(url);
audioEl.preload = "auto";
audioEl._downloadName = filename;
els.downloadBtn.disabled = false;
els.downloadBtn.onclick = () => {
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
};
audioEl.addEventListener("loadedmetadata", () => {
els.durText.textContent = fmtTime(audioEl.duration);
els.playBtn.disabled = false;
els.stopBtn.disabled = false;
setStage("", "Ready");
tokenize(script);
clearHighlight();
const voiceLabel = els.speaker.options[els.speaker.selectedIndex]?.textContent || "Cloned";
els.voiceInfo.textContent = voiceLabel;
});
audioEl.addEventListener("timeupdate", () => {
const t = audioEl.currentTime;
const d = audioEl.duration;
const fps = 24;
els.tcS.textContent = pad(t);
els.tcF.textContent = pad((t - Math.floor(t)) * fps);
const pct = d ? (t / d) * 100 : 0;
els.progressFill.style.width = `${pct}%`;
});
audioEl.addEventListener("play", () => {
els.playBtn.textContent = "❚❚";
isPlaying = true;
setStage("playing", "Playing");
});
audioEl.addEventListener("pause", () => {
els.playBtn.textContent = "▶";
isPlaying = false;
setStage("", "Paused");
});
audioEl.addEventListener("ended", () => {
els.playBtn.textContent = "▶";
isPlaying = false;
clearHighlight();
setStage("", "Ready");
});
};
els.playBtn.addEventListener("click", () => {
if (!audioEl.src) return;
if (audioEl.paused) audioEl.play();
else audioEl.pause();
});
els.stopBtn.addEventListener("click", () => {
if (!audioEl.src) return;
audioEl.pause();
audioEl.currentTime = 0;
clearHighlight();
});
els.text.addEventListener("click", () => {
// Allow normal editing on click
});
// Highlight loop (runs always, no-ops without audio)
const highlightLoop = () => {
if (isPlaying && audioEl.duration && wordSpans.length) {
const ratio = Math.min(1, audioEl.currentTime / audioEl.duration);
const idx = Math.min(wordSpans.length - 1, Math.floor(ratio * wordSpans.length));
setCurrentWord(idx);
}
hlRAF = requestAnimationFrame(highlightLoop);
};
// ---------- Examples ----------
// Cache: example_label -> { url, blob, script, speaker, filename }
const exampleCache = new Map();
const renderExamples = (items) => {
els.examples.innerHTML = "";
items.forEach(ex => {
const chip = document.createElement("button");
chip.type = "button"; chip.className = "example-chip";
chip.innerHTML = `<span class="tag">${ex.label}</span><span class="txt">${ex.text.slice(0,50)}${ex.text.length > 50 ? "…" : ""}</span><span class="status" data-status="${ex.label}"></span>`;
chip.addEventListener("click", async () => {
// Always pre-fill the script so the user can edit it
tokenize(ex.text);
els.text.focus();
const range = document.createRange();
range.selectNodeContents(els.text);
range.collapse(false);
const sel = window.getSelection();
sel.removeAllRanges(); sel.addRange(range);
setMode("preset");
els.speaker.value = ex.speaker;
const cached = exampleCache.get(ex.label);
if (cached) {
// Play the cached audio
loadAudio(cached.url, cached.script, cached.filename);
audioEl.addEventListener("loadedmetadata", () => audioEl.play(), { once: true });
} else {
// Fallback: synthesize now (slow first time, then becomes cached)
await synthesizeForExample(ex);
}
});
els.examples.appendChild(chip);
});
};
const synthesizeForExample = async (ex) => {
const args = {
mode: cfg.modes.preset,
speaker: ex.speaker,
ref_audio: null,
text: ex.text,
temperature: sliderValue("temperature"),
top_k: sliderValue("top_k"),
max_frames: sliderValue("max_frames"),
repetition_penalty: sliderValue("repetition_penalty"),
repetition_window: sliderValue("repetition_window"),
};
const statusEl = document.querySelector(`[data-status="${ex.label}"]`);
if (statusEl) statusEl.textContent = " · …";
try {
const sub = await client.submit("/synthesize", args);
for await (const event of sub) {
if (event.type === "data") {
const data = event.data?.[0];
if (data?.url) {
const resp = await fetch(data.url); const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const filename = `gepard-${ex.speaker}-${ex.label.toLowerCase().replace(/\s+/g, "-")}.wav`;
exampleCache.set(ex.label, { url, blob, script: ex.text, speaker: ex.speaker, filename });
if (statusEl) statusEl.textContent = " · ✓";
loadAudio(url, ex.text, filename);
audioEl.addEventListener("loadedmetadata", () => audioEl.play(), { once: true });
}
}
}
} catch (err) {
if (statusEl) statusEl.textContent = " · !";
console.error(err);
}
};
// ---------- Submit ----------
els.renderBtn.addEventListener("click", async () => {
clearError();
const text = scriptText();
if (!text) { showError("Add some text to your script first."); els.text.focus(); return; }
if (mode === "preset" && !els.speaker.value) { showError("Choose a voice first."); return; }
if (mode === "clone" && !refFile) { showError("Upload a reference clip first."); return; }
const args = {
mode: mode === "preset" ? cfg.modes.preset : cfg.modes.clone,
speaker: mode === "preset" ? els.speaker.value : "",
ref_audio: mode === "clone" ? handle_file(refFile) : null,
text,
temperature: sliderValue("temperature"),
top_k: sliderValue("top_k"),
max_frames: sliderValue("max_frames"),
repetition_penalty: sliderValue("repetition_penalty"),
repetition_window: sliderValue("repetition_window"),
};
setBusy(true);
setStage("live", "Rendering");
try {
currentSubmission = await client.submit("/synthesize", args);
for await (const event of currentSubmission) {
if (event.type === "data") {
const data = event.data?.[0];
if (data?.url) {
const resp = await fetch(data.url); const blob = await resp.blob();
loadAudio(URL.createObjectURL(blob), text);
// Auto-play once loaded
audioEl.addEventListener("loadedmetadata", () => audioEl.play(), { once: true });
}
}
}
} catch (err) {
console.error(err); showError(err?.message || String(err));
setStage("", "Error");
} finally {
setBusy(false); currentSubmission = null;
}
});
// Enter to render when not editing text
els.text.addEventListener("keydown", (e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
els.renderBtn.click();
}
});
// ---------- Boot ----------
(async () => {
cfg = await fetch("/config.json").then(r => r.json());
cfg.speakers.forEach(s => { const o = document.createElement("option"); o.value = s.key; o.textContent = s.label; els.speaker.appendChild(o); });
buildSliders();
renderExamples(cfg.examples);
client = await Client.connect(window.location.origin);
highlightLoop();
// Lazy cache: warm the first 3 examples in the background so first
// click is instant. Sequential to avoid GPU contention; failures are
// silent (next click will retry).
(async () => {
for (const ex of cfg.examples.slice(0, 3)) {
try { await synthesizeForExample(ex); } catch (e) { /* skip */ }
}
})();
})();
</script>
</body>
</html>