Spaces:
Running on Zero
Running on Zero
| <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> |