ZeroLabs / index.html
apolinario's picture
Tiny pencil icon for rename instead of button
ff50d21
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZeroLabs</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root, [data-theme="dark"] {
--bg: #0f0e0d;
--surface: #1c1917;
--surface2: #292524;
--surface3: #44403c;
--border: #44403c;
--border-hover: #57534e;
--text: #fafaf9;
--text-secondary: #a8a29e;
--text-muted: #78716c;
--accent: #f59e0b;
--accent-hover: #fbbf24;
--accent-subtle: rgba(245,158,11,0.1);
--accent-glow: rgba(245,158,11,0.12);
--danger: #ef4444;
--success: #22c55e;
--warning: #fbbf24;
--btn-text: #1c1917;
--shadow-color: rgba(0,0,0,0.3);
--btn-shadow: #92400e;
--btn-shadow-hf: #b45309;
}
[data-theme="light"] {
--bg: #fafaf9;
--surface: #ffffff;
--surface2: #f5f5f4;
--surface3: #e7e5e4;
--border: #d6d3d1;
--border-hover: #a8a29e;
--text: #1c1917;
--text-secondary: #57534e;
--text-muted: #78716c;
--accent: #d97706;
--accent-hover: #b45309;
--accent-subtle: rgba(217,119,6,0.08);
--accent-glow: rgba(217,119,6,0.1);
--danger: #dc2626;
--success: #16a34a;
--warning: #d97706;
--btn-text: #ffffff;
--shadow-color: rgba(0,0,0,0.1);
--btn-shadow: #92400e;
--btn-shadow-hf: #7c2d12;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
height: 100vh;
overflow: hidden;
}
.app-layout {
display: grid;
grid-template-columns: 260px 1fr;
grid-template-rows: 56px 1fr;
height: 100vh;
}
.header {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
background: var(--surface);
border-bottom: 1px solid var(--border);
z-index: 10;
}
.logo {
font-size: 17px;
font-weight: 700;
letter-spacing: -0.3px;
display: flex;
align-items: center;
gap: 8px;
}
.logo-icon {
width: 28px;
height: 28px;
background: linear-gradient(135deg, var(--accent), #d97706);
border-radius: 7px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 800;
color: var(--btn-text);
font-family: 'Inter', sans-serif;
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.user-pill {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 12px 4px 4px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 20px;
font-size: 13px;
color: var(--text-secondary);
}
.user-pill img {
width: 24px;
height: 24px;
border-radius: 50%;
}
.sidebar {
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-section {
padding: 16px 12px 8px;
}
.sidebar-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-muted);
padding: 0 8px;
margin-bottom: 8px;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: 8px;
font-size: 13px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.1s;
border: none;
background: none;
width: 100%;
text-align: left;
}
.nav-item:hover { background: var(--surface2); color: var(--text); }
.nav-item.active { background: var(--accent-subtle); color: var(--accent-hover); }
.nav-item svg { width: 18px; height: 18px; flex-shrink: 0; }
.sidebar-voices {
flex: 1;
overflow-y: auto;
padding: 0 12px 12px;
}
.voice-card {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
border-radius: 8px;
cursor: pointer;
transition: all 0.1s;
margin-bottom: 2px;
border: 1px solid transparent;
}
.voice-card:hover { background: var(--surface2); }
.voice-card.selected { background: var(--accent-subtle); border-color: var(--accent); }
.voice-avatar {
width: 36px;
height: 36px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
flex-shrink: 0;
color: white;
font-weight: 600;
}
.voice-meta { flex: 1; min-width: 0; }
.voice-name { font-size: 13px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.voice-detail { font-size: 11px; color: var(--text-muted); }
.voice-card .delete-voice {
opacity: 0; padding: 4px; border: none; background: none;
color: var(--text-muted); cursor: pointer; border-radius: 4px; transition: all 0.1s;
}
.voice-card:hover .delete-voice { opacity: 1; }
.voice-card .delete-voice:hover { color: var(--danger); background: rgba(239,68,68,0.1); }
/* ── Usage tracker ── */
.usage-tracker {
padding: 12px;
margin: 0 12px 12px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 10px;
}
.usage-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.usage-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); }
.usage-value { font-size: 11px; color: var(--text-secondary); font-variant-numeric: tabular-nums; }
.usage-bar {
width: 100%;
height: 4px;
background: var(--surface3);
border-radius: 2px;
overflow: hidden;
margin-bottom: 6px;
}
.usage-fill {
height: 100%;
background: var(--accent);
border-radius: 2px;
transition: width 0.5s ease;
}
.usage-fill.warning { background: var(--warning); }
.usage-fill.danger { background: var(--danger); }
.usage-sub { font-size: 10px; color: var(--text-muted); }
/* ── Content ── */
.content {
overflow-y: auto;
padding: 32px;
display: flex;
justify-content: center;
}
.content-inner { width: 100%; max-width: 720px; }
.page { display: none; }
.page.active { display: block; }
.page-header { margin-bottom: 28px; }
.page-title { font-size: 22px; font-weight: 700; letter-spacing: -0.5px; margin-bottom: 6px; }
.page-desc { font-size: 14px; color: var(--text-muted); }
/* Voice selector */
.voice-selector {
display: flex; align-items: center; gap: 12px;
padding: 14px 16px; background: var(--surface); border: 1px solid var(--border);
border-radius: 12px; margin-bottom: 16px; cursor: pointer; transition: border-color 0.15s;
}
.voice-selector:hover { border-color: var(--border-hover); }
.voice-selector .voice-avatar { width: 40px; height: 40px; }
.voice-selector-info { flex: 1; }
.voice-selector-name { font-size: 14px; font-weight: 600; }
.voice-selector-hint { font-size: 12px; color: var(--text-muted); }
.voice-selector-change { font-size: 12px; color: var(--accent); font-weight: 500; }
/* Editor */
.editor {
background: var(--surface); border: 1px solid var(--border);
border-radius: 12px; overflow: hidden; margin-bottom: 16px;
}
.editor textarea {
width: 100%; min-height: 180px; background: transparent; border: none;
padding: 20px; color: var(--text); font-family: 'Inter', sans-serif;
font-size: 15px; line-height: 1.6; resize: vertical;
}
.editor textarea:focus { outline: none; }
.editor textarea::placeholder { color: var(--text-muted); }
.editor-footer {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 20px; border-top: 1px solid var(--border); background: var(--surface2);
}
.editor-left { display: flex; align-items: center; gap: 12px; }
.editor-stat { font-size: 12px; color: var(--text-muted); }
.lang-select {
padding: 4px 8px; background: var(--surface3); border: 1px solid var(--border);
border-radius: 6px; color: var(--text-secondary); font-size: 12px; font-family: 'Inter', sans-serif;
}
.lang-select:focus { outline: none; border-color: var(--accent); }
/* Settings accordion */
.settings-accordion {
margin-bottom: 16px;
}
.settings-toggle {
display: flex; align-items: center; gap: 6px;
padding: 8px 0; font-size: 12px; font-weight: 500;
color: var(--text-muted); cursor: pointer; border: none; background: none;
font-family: 'Inter', sans-serif; transition: color 0.1s;
}
.settings-toggle:hover { color: var(--text-secondary); }
.settings-toggle svg { width: 14px; height: 14px; transition: transform 0.2s; }
.settings-toggle.open svg { transform: rotate(90deg); }
.settings-body { display: none; padding-top: 8px; }
.settings-body.open { display: block; }
.settings-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.setting-group { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; }
.setting-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); margin-bottom: 8px; }
.setting-value { display: flex; align-items: center; gap: 10px; }
.setting-value input[type="range"] { flex: 1; accent-color: var(--accent); height: 4px; }
.setting-num { font-size: 13px; color: var(--text-secondary); min-width: 30px; text-align: right; font-variant-numeric: tabular-nums; }
/* Voice picker dropdown */
.voice-picker-dropdown {
position: absolute; top: 100%; left: 0; right: 0;
background: var(--surface); border: 1px solid var(--border);
border-radius: 10px; margin-top: 4px; z-index: 20;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
max-height: 240px; overflow-y: auto; display: none;
}
.voice-picker-dropdown.visible { display: block; }
.voice-picker-item {
display: flex; align-items: center; gap: 10px;
padding: 10px 14px; cursor: pointer; transition: background 0.1s;
border: none; background: none; width: 100%; text-align: left;
color: var(--text); font-family: 'Inter', sans-serif; font-size: 13px;
}
.voice-picker-item:hover { background: var(--surface2); }
.voice-picker-item .voice-avatar { width: 30px; height: 30px; font-size: 13px; }
.voice-picker-item:last-child { border-top: 1px solid var(--border); color: var(--accent); }
.voice-selector { position: relative; }
/* Generate */
.generate-bar { display: flex; gap: 12px; align-items: center; }
.btn-generate {
flex: 1; padding: 14px 24px;
background: linear-gradient(135deg, #fbbf24, var(--accent));
color: var(--btn-text); border: none; border-radius: 10px;
font-size: 14px; font-weight: 600; font-family: 'Inter', sans-serif;
cursor: pointer; transition: all 0.15s;
display: flex; align-items: center; justify-content: center; gap: 8px;
box-shadow: 0 4px 0 var(--btn-shadow), 0 6px 12px var(--shadow-color);
transform: translateY(0);
}
.btn-generate:hover { filter: brightness(1.05); transform: translateY(-2px); box-shadow: 0 6px 0 var(--btn-shadow), 0 8px 16px var(--shadow-color); }
.btn-generate:active { transform: translateY(2px); box-shadow: 0 1px 0 var(--btn-shadow), 0 2px 4px var(--shadow-color); }
.btn-generate:disabled { opacity: 0.4; cursor: not-allowed; transform: none; filter: none; box-shadow: none; }
.btn-generate .spinner { width: 16px; height: 16px; border: 2px solid rgba(0,0,0,0.2); border-top-color: var(--btn-text); border-radius: 50%; animation: spin 0.6s linear infinite; display: none; }
.btn-generate.loading .spinner { display: block; }
.btn-generate.loading .btn-text { display: none; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Status */
.status-bar {
margin-top: 12px; padding: 10px 14px; border-radius: 8px;
font-size: 13px; display: none; gap: 8px; flex-direction: column;
}
.status-bar.visible { display: flex; }
.status-bar.info { background: rgba(245,158,11,0.08); color: var(--accent-hover); border: 1px solid rgba(245,158,11,0.15); }
.status-bar.success { background: rgba(34,197,94,0.08); color: var(--success); border: 1px solid rgba(34,197,94,0.15); }
.status-bar.error { background: rgba(239,68,68,0.08); color: var(--danger); border: 1px solid rgba(239,68,68,0.15); }
.status-text { line-height: 1.4; }
.status-cta {
display: inline-flex; align-items: center; gap: 6px;
padding: 6px 14px; border-radius: 6px; font-size: 12px; font-weight: 600;
font-family: 'Inter', sans-serif; cursor: pointer; border: none;
transition: all 0.15s; margin-top: 4px; text-decoration: none; width: fit-content;
}
.status-cta.upgrade { background: #ff9d00; color: #000; }
.status-cta.upgrade:hover { background: #ffb340; }
.status-cta.credits { background: var(--accent); color: var(--btn-text); }
.status-cta.credits:hover { background: var(--accent-hover); }
.progress-track { width: 100%; height: 3px; background: var(--surface3); border-radius: 2px; margin-top: 6px; overflow: hidden; display: none; }
.progress-track.visible { display: block; }
.progress-fill { height: 100%; background: linear-gradient(90deg, var(--accent), #fcd34d); border-radius: 2px; transition: width 0.3s; width: 0%; }
/* Player */
.player-card { margin-top: 16px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 16px 20px; display: none; }
.player-card.visible { display: block; }
.player-top { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
.play-btn {
width: 40px; height: 40px; border-radius: 50%; background: var(--accent);
border: none; color: var(--btn-text); cursor: pointer;
display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: all 0.15s;
}
.play-btn:hover { background: var(--accent-hover); transform: scale(1.05); }
.play-btn svg { width: 18px; height: 18px; }
.player-info { flex: 1; }
.player-title { font-size: 14px; font-weight: 500; }
.player-meta { font-size: 12px; color: var(--text-muted); }
.icon-btn {
width: 32px; height: 32px; border-radius: 6px; border: 1px solid var(--border);
background: transparent; color: var(--text-secondary); cursor: pointer;
display: flex; align-items: center; justify-content: center; transition: all 0.1s;
}
.icon-btn:hover { background: var(--surface2); color: var(--text); border-color: var(--border-hover); }
.player-audio { width: 100%; height: 32px; }
/* ── Voices Page ── */
.voices-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
}
.voice-tile {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
transition: all 0.15s;
cursor: default;
position: relative;
}
.voice-tile:hover { border-color: var(--border-hover); }
.voice-tile-avatar {
width: 48px; height: 48px; border-radius: 12px;
display: flex; align-items: center; justify-content: center;
font-size: 22px; font-weight: 700; color: white; margin-bottom: 12px;
}
.voice-tile-name { font-size: 15px; font-weight: 600; margin-bottom: 4px; }
.voice-tile-file { font-size: 12px; color: var(--text-muted); margin-bottom: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.voice-tile-actions { display: flex; gap: 6px; }
.voice-tile-btn {
padding: 6px 12px; border-radius: 6px; font-size: 12px; font-weight: 500;
font-family: 'Inter', sans-serif; cursor: pointer; border: none; transition: all 0.1s;
}
.voice-tile-btn.use { background: var(--accent); color: var(--btn-text); font-weight: 600; }
.voice-tile-btn.use:hover { background: var(--accent-hover); }
.voice-tile-btn.del { background: transparent; color: var(--text-muted); border: 1px solid var(--border); }
.voice-tile-btn.del:hover { color: var(--danger); border-color: var(--danger); }
.voice-tile-date {
position: absolute; top: 12px; right: 12px;
font-size: 10px; color: var(--text-muted);
}
.add-voice-tile {
background: transparent;
border: 2px dashed var(--border);
border-radius: 12px;
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
cursor: pointer;
color: var(--text-muted);
font-size: 13px;
transition: all 0.15s;
min-height: 160px;
}
.add-voice-tile:hover { border-color: var(--accent); color: var(--accent-hover); }
.add-voice-tile svg { opacity: 0.5; }
/* ── History ── */
.history-list { display: flex; flex-direction: column; gap: 8px; }
.history-item {
display: flex; align-items: center; gap: 14px;
padding: 14px 16px; background: var(--surface); border: 1px solid var(--border);
border-radius: 10px; transition: border-color 0.1s;
}
.history-item:hover { border-color: var(--border-hover); }
.history-play {
width: 36px; height: 36px; border-radius: 50%;
background: var(--surface3); border: 1px solid var(--border);
color: var(--text-secondary); cursor: pointer;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0; transition: all 0.1s;
}
.history-play:hover { background: var(--accent); color: white; border-color: var(--accent); }
.history-play svg { width: 14px; height: 14px; }
.history-info { flex: 1; min-width: 0; }
.history-text { font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.history-sub { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
.history-download {
padding: 6px 12px; border-radius: 6px; border: 1px solid var(--border);
background: transparent; color: var(--text-secondary); font-size: 12px;
cursor: pointer; font-family: 'Inter', sans-serif; transition: all 0.1s;
}
.history-download:hover { background: var(--surface2); color: var(--text); }
.empty-state { text-align: center; padding: 60px 20px; color: var(--text-muted); font-size: 14px; }
.empty-state-icon { font-size: 40px; margin-bottom: 12px; opacity: 0.5; }
/* ── API ── */
.api-tabs { display: flex; gap: 2px; background: var(--surface2); border-radius: 8px; padding: 3px; margin-bottom: 16px; }
.api-tab {
padding: 7px 16px; border-radius: 6px; font-size: 13px; cursor: pointer;
color: var(--text-muted); background: transparent; border: none;
font-family: 'Inter', sans-serif; font-weight: 500; transition: all 0.1s;
}
.api-tab.active { background: var(--surface3); color: var(--text); }
.api-tab:hover { color: var(--text-secondary); }
.code-block { position: relative; }
.code-block pre {
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
padding: 20px; overflow-x: auto; font-size: 13px; line-height: 1.6;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
pre {
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
padding: 20px; overflow-x: auto; font-size: 13px; line-height: 1.6;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
.copy-btn {
position: absolute; top: 8px; right: 8px;
padding: 5px 10px; border-radius: 6px; font-size: 11px; font-weight: 500;
font-family: 'Inter', sans-serif; cursor: pointer; border: 1px solid var(--border);
background: var(--surface2); color: var(--text-muted); transition: all 0.15s;
display: flex; align-items: center; gap: 4px;
}
.copy-btn:hover { background: var(--surface3); color: var(--text); border-color: var(--border-hover); }
.copy-btn.copied { background: rgba(34,197,94,0.15); color: var(--success); border-color: rgba(34,197,94,0.3); }
.agent-tip {
margin-top: 20px; padding: 16px; background: var(--accent-subtle);
border: 1px solid rgba(245,158,11,0.2); border-radius: 10px;
font-size: 13px; color: var(--text-secondary); line-height: 1.5;
}
.agent-tip strong { color: var(--accent-hover); }
/* ── Voice Design ── */
.design-textarea {
width: 100%; min-height: 80px; background: var(--surface2);
border: 1px solid var(--border); border-radius: 8px; padding: 12px;
color: var(--text); font-family: 'Inter', sans-serif; font-size: 14px;
resize: vertical; line-height: 1.5;
}
.design-textarea:focus { outline: none; border-color: var(--accent); }
.design-textarea::placeholder { color: var(--text-muted); }
.design-field { margin-bottom: 16px; }
.design-label { font-size: 12px; font-weight: 500; color: var(--text-secondary); margin-bottom: 6px; }
/* ── Preset voice tiles ── */
.preset-section-title {
font-size: 13px; font-weight: 600; color: var(--text-muted);
text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 12px;
padding-bottom: 8px; border-bottom: 1px solid var(--border);
}
.preset-tile {
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
padding: 16px; transition: all 0.15s; cursor: default; position: relative;
display: flex; align-items: center; gap: 14px;
}
.preset-tile:hover { border-color: var(--border-hover); }
.preset-tile-avatar {
width: 44px; height: 44px; border-radius: 10px;
display: flex; align-items: center; justify-content: center;
font-size: 18px; font-weight: 700; color: white; flex-shrink: 0;
background: linear-gradient(135deg, #d97706, #f59e0b);
}
.preset-tile-info { flex: 1; min-width: 0; }
.preset-tile-name { font-size: 14px; font-weight: 600; margin-bottom: 2px; }
.preset-tile-tag { font-size: 11px; color: var(--text-muted); }
.preset-tile-actions { display: flex; gap: 6px; align-items: center; }
.preview-btn {
width: 32px; height: 32px; border-radius: 50%;
background: var(--surface3); border: 1px solid var(--border);
color: var(--text-secondary); cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: all 0.1s; flex-shrink: 0;
}
.preview-btn:hover { background: var(--accent); color: var(--btn-text); border-color: var(--accent); }
.preview-btn svg { width: 12px; height: 12px; }
/* ── Theme toggle ── */
.theme-btn {
width: 32px; height: 32px; border-radius: 8px; border: 1px solid var(--border);
background: var(--surface2); color: var(--text-muted); cursor: pointer;
display: flex; align-items: center; justify-content: center; transition: all 0.1s;
}
.theme-btn:hover { color: var(--text); border-color: var(--border-hover); }
.theme-btn svg { width: 16px; height: 16px; }
.footer {
text-align: center; padding: 32px 0 16px; font-size: 12px; color: var(--text-muted);
}
.footer a { color: var(--text-secondary); text-decoration: none; }
.footer a:hover { color: var(--accent); }
.cm { color: var(--text-muted); }
[data-theme="dark"] .s { color: #86efac; }
[data-theme="dark"] .k { color: #fcd34d; }
[data-theme="dark"] .f { color: #93c5fd; }
[data-theme="light"] .s { color: #166534; }
[data-theme="light"] .k { color: #92400e; }
[data-theme="light"] .f { color: #1e40af; }
/* ── Modal ── */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
backdrop-filter: blur(4px); display: none; align-items: center;
justify-content: center; z-index: 100;
}
.modal-overlay.visible { display: flex; }
.modal {
background: var(--surface); border: 1px solid var(--border);
border-radius: 16px; padding: 28px; width: 440px; max-width: 90vw;
}
.modal-title { font-size: 17px; font-weight: 600; margin-bottom: 6px; }
.modal-desc { font-size: 13px; color: var(--text-muted); margin-bottom: 20px; }
.modal-field { margin-bottom: 16px; }
.modal-field label { display: block; font-size: 12px; font-weight: 500; color: var(--text-secondary); margin-bottom: 6px; }
.modal-field input[type="text"] {
width: 100%; padding: 10px 12px; background: var(--surface2);
border: 1px solid var(--border); border-radius: 8px; color: var(--text);
font-family: 'Inter', sans-serif; font-size: 14px;
}
.modal-field input:focus { outline: none; border-color: var(--accent); }
.upload-area {
border: 2px dashed var(--border); border-radius: 10px; padding: 28px;
text-align: center; cursor: pointer; transition: all 0.15s;
color: var(--text-muted); font-size: 13px;
}
.upload-area:hover { border-color: var(--accent); color: var(--text-secondary); }
.upload-area.has-file { border-color: var(--success); border-style: solid; color: var(--success); }
.upload-area input { display: none; }
.upload-area-sub { font-size: 11px; margin-top: 4px; opacity: 0.7; }
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; }
.btn-modal { padding: 9px 20px; border-radius: 8px; font-size: 13px; font-weight: 500; font-family: 'Inter', sans-serif; cursor: pointer; transition: all 0.1s; border: none; }
.btn-cancel { background: var(--surface2); color: var(--text-secondary); border: 1px solid var(--border); }
.btn-cancel:hover { background: var(--surface3); color: var(--text); }
.btn-save { background: var(--accent); color: var(--btn-text); font-weight: 600; }
.btn-save:hover { background: var(--accent-hover); }
.btn-save:disabled { opacity: 0.4; cursor: not-allowed; }
/* ── Landing ── */
#landing {
display: flex; flex-direction: column; align-items: center; justify-content: center;
min-height: 100vh; text-align: center; gap: 20px;
background: radial-gradient(ellipse at 50% 0%, var(--accent-glow) 0%, transparent 60%);
}
#landing h1 {
font-size: 52px; font-weight: 700; letter-spacing: -1.5px;
background: linear-gradient(135deg, var(--text) 0%, var(--text-secondary) 100%);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
#landing .subtitle { color: var(--text-muted); font-size: 17px; max-width: 420px; line-height: 1.5; }
.btn-hf {
padding: 13px 32px; background: #ff9d00; color: #000; border: none; border-radius: 10px;
font-size: 15px; font-weight: 600; font-family: 'Inter', sans-serif; cursor: pointer;
display: inline-flex; align-items: center; gap: 8px; transition: all 0.15s;
box-shadow: 0 4px 0 var(--btn-shadow-hf), 0 6px 12px var(--shadow-color);
}
.btn-hf:hover { background: #ffb340; transform: translateY(-2px); box-shadow: 0 6px 0 var(--btn-shadow-hf), 0 8px 16px var(--shadow-color); }
.btn-hf:active { transform: translateY(2px); box-shadow: 0 1px 0 var(--btn-shadow-hf), 0 2px 4px var(--shadow-color); }
.landing-note { font-size: 12px; color: var(--text-muted); }
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--border-hover); }
@media (max-width: 768px) {
.app-layout { grid-template-columns: 1fr; }
.sidebar { display: none; }
#landing h1 { font-size: 36px; }
.voices-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<script>
// Theme: auto (system), dark, light — runs before paint to avoid flash
(function() {
const saved = localStorage.getItem("zl_theme");
function getEffective(pref) {
if (pref === "light" || pref === "dark") return pref;
return window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
}
const effective = getEffective(saved || "auto");
document.documentElement.setAttribute("data-theme", effective);
window._themePref = saved || "auto"; // auto, dark, light
window.cycleTheme = function() {
const order = ["auto", "light", "dark"];
const idx = order.indexOf(window._themePref);
window._themePref = order[(idx + 1) % 3];
localStorage.setItem("zl_theme", window._themePref);
const eff = getEffective(window._themePref);
document.documentElement.setAttribute("data-theme", eff);
updateThemeIcons(eff);
};
window.updateThemeIcons = function(eff) {
const sun = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>';
const moon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>';
const auto = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 2a10 10 0 0 1 0 20V2z" fill="currentColor"/></svg>';
const icon = window._themePref === "auto" ? auto : eff === "dark" ? moon : sun;
document.querySelectorAll(".theme-btn").forEach(b => b.innerHTML = icon);
};
// Listen for system theme changes when in auto mode
window.matchMedia("(prefers-color-scheme: light)").addEventListener("change", () => {
if (window._themePref === "auto") {
const eff = getEffective("auto");
document.documentElement.setAttribute("data-theme", eff);
updateThemeIcons(eff);
}
});
// Update icons after DOM loads
document.addEventListener("DOMContentLoaded", () => updateThemeIcons(effective));
})();
</script>
<div id="landing">
<div class="logo-icon" style="width:56px;height:56px;font-size:28px;border-radius:14px">0</div>
<h1>ZeroLabs</h1>
<p class="subtitle">Voice cloning powered by open-source models. 100x cheaper than proprietary competitors like ElevenLabs.</p>
<button class="btn-hf" onclick="login()">🤗 Sign in with Hugging Face</button>
<p class="landing-note">Uses your HF ZeroGPU quota &middot; Free tier included</p>
<button class="theme-btn" onclick="cycleTheme()" title="Toggle theme" style="position:fixed;top:16px;right:16px">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
</button>
</div>
<div id="app" class="app-layout" style="display:none">
<div class="header">
<div class="logo">
<div class="logo-icon">0</div>
ZeroLabs
</div>
<div class="header-right">
<button class="theme-btn" onclick="cycleTheme()" title="Toggle theme" id="theme-btn-app">
<svg id="theme-icon-app" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
</button>
<div class="user-pill" id="user-pill">
<img id="user-avatar" src="" alt="">
<span id="user-name"></span>
</div>
</div>
</div>
<div class="sidebar">
<div class="sidebar-section">
<div class="sidebar-label">Create</div>
<button class="nav-item active" data-page="synthesis" onclick="showPage('synthesis', this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="22"/></svg>
Voice Clone
</button>
<button class="nav-item" data-page="design" onclick="showPage('design', this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
Voice Design
</button>
<button class="nav-item" data-page="voices" onclick="showPage('voices', this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
Voice Library
</button>
<button class="nav-item" data-page="transcribe" onclick="showPage('transcribe', this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg>
Transcribe
</button>
<button class="nav-item" data-page="history" onclick="showPage('history', this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
History
</button>
<button class="nav-item" data-page="api" onclick="showPage('api', this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
API
</button>
</div>
<div class="sidebar-section">
<div class="sidebar-label">Quick Select</div>
</div>
<div class="sidebar-voices" id="voice-list"></div>
<!-- Usage Tracker -->
<div class="usage-tracker" id="usage-tracker">
<div class="usage-header">
<span class="usage-label">Session Usage</span>
<span class="usage-value" id="usage-value">0s used</span>
</div>
<div class="usage-bar"><div class="usage-fill" id="usage-fill" style="width:0%"></div></div>
<div class="usage-sub" id="usage-sub">Estimated from generation durations</div>
</div>
</div>
<div class="content">
<div class="content-inner">
<!-- Synthesis Page -->
<div class="page active" id="page-synthesis">
<div class="page-header">
<div class="page-title">Speech Synthesis</div>
<div class="page-desc">Select a voice and type your text to generate speech.</div>
</div>
<div class="voice-selector" id="voice-selector" onclick="toggleVoicePicker(event)">
<div class="voice-avatar" id="sel-voice-avatar" style="background:var(--surface3)">🎤</div>
<div class="voice-selector-info">
<div class="voice-selector-name" id="sel-voice-name">No voice selected</div>
<div class="voice-selector-hint" id="sel-voice-hint">Click to select or add a voice</div>
</div>
<div class="voice-selector-change">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="voice-picker-dropdown" id="voice-picker"></div>
</div>
<div class="editor">
<textarea id="text-input" placeholder="Start typing your text here..."></textarea>
<div class="editor-footer">
<div class="editor-left">
<span class="editor-stat"><span id="char-count">0</span> chars</span>
<select class="lang-select" id="lang-select">
<option value="Auto">Auto</option>
<option value="English">English</option>
<option value="Chinese">Chinese</option>
<option value="French">French</option>
<option value="German">German</option>
<option value="Spanish">Spanish</option>
<option value="Japanese">Japanese</option>
<option value="Korean">Korean</option>
<option value="Portuguese">Portuguese</option>
<option value="Italian">Italian</option>
<option value="Russian">Russian</option>
<option value="Arabic">Arabic</option>
<option value="Hindi">Hindi</option>
</select>
</div>
</div>
</div>
<div class="settings-accordion">
<button class="settings-toggle" onclick="this.classList.toggle('open');this.nextElementSibling.classList.toggle('open')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
Advanced Settings
</button>
<div class="settings-body">
<div class="settings-row">
<div class="setting-group">
<div class="setting-label">Inference Steps</div>
<div class="setting-value">
<input type="range" id="setting-steps" min="4" max="64" value="32">
<span class="setting-num" id="steps-val">32</span>
</div>
</div>
<div class="setting-group">
<div class="setting-label">Speed</div>
<div class="setting-value">
<input type="range" id="setting-speed" min="50" max="150" value="100">
<span class="setting-num" id="speed-val">1.0x</span>
</div>
</div>
</div>
</div>
</div>
<div class="generate-bar">
<button class="btn-generate" id="generate-btn" onclick="generate()" disabled>
<span class="btn-text">Generate Speech</span>
<div class="spinner"></div>
</button>
</div>
<div class="status-bar" id="status"></div>
<div class="progress-track" id="progress-bar"><div class="progress-fill" id="progress-fill"></div></div>
<div class="player-card" id="result">
<div class="player-top">
<button class="play-btn" onclick="togglePlay()">
<svg id="play-icon" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
<svg id="pause-icon" viewBox="0 0 24 24" fill="currentColor" style="display:none"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>
</button>
<div class="player-info">
<div class="player-title" id="player-title">Generated audio</div>
<div class="player-meta" id="player-meta"></div>
</div>
<div style="display:flex;gap:6px">
<button class="icon-btn" onclick="downloadAudio()" title="Download">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
</div>
</div>
<audio id="audio-player" class="player-audio"></audio>
</div>
</div>
<!-- Voice Design Page -->
<div class="page" id="page-design">
<div class="page-header">
<div class="page-title">Voice Design</div>
<div class="page-desc">Describe how you want the voice to sound. Powered by Qwen3-TTS.</div>
</div>
<div class="editor">
<textarea id="design-text" placeholder="Start typing the text you want to synthesize..."></textarea>
<div class="editor-footer">
<div class="editor-left">
<span class="editor-stat"><span id="design-char-count">0</span> chars</span>
<select class="lang-select" id="design-lang">
<option value="Auto">Auto</option>
<option value="English">English</option>
<option value="Chinese">Chinese</option>
<option value="Japanese">Japanese</option>
<option value="Korean">Korean</option>
<option value="French">French</option>
<option value="German">German</option>
<option value="Spanish">Spanish</option>
<option value="Portuguese">Portuguese</option>
<option value="Russian">Russian</option>
</select>
</div>
</div>
</div>
<div class="design-field">
<div class="design-label">Voice Description</div>
<textarea class="design-textarea" id="design-voice-desc" placeholder="e.g. Speak in a warm, friendly tone with slight excitement and a deep male voice..."></textarea>
</div>
<div class="generate-bar">
<button class="btn-generate" id="design-generate-btn" onclick="generateDesign()" disabled>
<span class="btn-text">Generate Speech</span>
<div class="spinner"></div>
</button>
</div>
<div class="status-bar" id="design-status"></div>
<div class="progress-track" id="design-progress-bar"><div class="progress-fill" id="design-progress-fill"></div></div>
<div class="player-card" id="design-result">
<div class="player-top">
<button class="play-btn" onclick="toggleDesignPlay()">
<svg id="design-play-icon" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
<svg id="design-pause-icon" viewBox="0 0 24 24" fill="currentColor" style="display:none"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>
</button>
<div class="player-info">
<div class="player-title" id="design-player-title">Generated audio</div>
<div class="player-meta" id="design-player-meta"></div>
</div>
<div style="display:flex;gap:6px">
<button class="icon-btn" onclick="downloadDesignAudio()" title="Download">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
<button class="voice-tile-btn use" onclick="saveDesignVoice()" id="save-design-voice-btn" title="Save to Voice Library" style="font-size:12px;padding:6px 12px">
Save Voice
</button>
</div>
</div>
<audio id="design-audio-player" class="player-audio"></audio>
</div>
</div>
<!-- Voice Library Page -->
<div class="page" id="page-voices">
<div class="page-header">
<div class="page-title">Voice Library</div>
<div class="page-desc">Preset speakers and your custom cloned voices. Select one to use in Voice Clone.</div>
</div>
<div class="preset-section-title">Your Voices</div>
<div class="voices-grid" id="voices-grid"></div>
<div class="preset-section-title" style="margin-top:28px">Preset Speakers</div>
<div class="voices-grid" id="presets-grid"></div>
</div>
<!-- Transcribe Page -->
<div class="page" id="page-transcribe">
<div class="page-header">
<div class="page-title">Transcribe</div>
<div class="page-desc">Upload an audio file to transcribe it to text. Powered by Cohere Transcribe.</div>
</div>
<div class="upload-area" id="transcribe-upload" style="margin-bottom:16px">
<div id="transcribe-upload-label">Drop an audio file here or click to upload<div class="upload-area-sub">MP3, WAV, FLAC, M4A supported</div></div>
<input type="file" id="transcribe-file-input" accept="audio/*">
</div>
<div style="display:flex;gap:12px;align-items:center;margin-bottom:16px">
<select class="lang-select" id="transcribe-lang" style="padding:8px 12px;font-size:13px">
<option value="en">English</option>
<option value="fr">French</option>
<option value="de">German</option>
<option value="es">Spanish</option>
<option value="pt">Portuguese</option>
<option value="it">Italian</option>
<option value="nl">Dutch</option>
<option value="pl">Polish</option>
<option value="el">Greek</option>
<option value="ar">Arabic</option>
<option value="ja">Japanese</option>
<option value="ko">Korean</option>
<option value="zh">Chinese</option>
<option value="vi">Vietnamese</option>
</select>
<button class="btn-generate" id="transcribe-btn" onclick="transcribe()" disabled style="flex:1">
<span class="btn-text">Transcribe</span>
<div class="spinner"></div>
</button>
</div>
<div class="status-bar" id="transcribe-status"></div>
<div id="transcribe-result" style="display:none">
<div class="setting-label" style="margin-bottom:8px;margin-top:16px">Transcript</div>
<div style="background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:16px;font-size:14px;line-height:1.6;position:relative">
<div id="transcribe-text"></div>
<button class="copy-btn" onclick="copyTranscript()" style="position:absolute;top:8px;right:8px">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> Copy
</button>
</div>
</div>
</div>
<!-- History Page -->
<div class="page" id="page-history">
<div class="page-header">
<div class="page-title">History</div>
<div class="page-desc">Your recent generations.</div>
</div>
<div id="history-container">
<div class="empty-state"><div class="empty-state-icon">🕐</div><div>No generations yet</div></div>
</div>
</div>
<!-- API Page -->
<div class="page" id="page-api">
<div class="page-header">
<div class="page-title">API</div>
<div class="page-desc">Use your HF token to call OmniVoice directly from your code or AI agents.</div>
</div>
<div class="api-tabs">
<button class="api-tab active" onclick="showApiTab('javascript', this)">JavaScript</button>
<button class="api-tab" onclick="showApiTab('python', this)">Python</button>
<button class="api-tab" onclick="showApiTab('curl', this)">curl</button>
</div>
<div id="api-curl" class="code-block" style="display:none">
<button class="copy-btn" onclick="copyCode(this)"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> Copy</button>
<pre><code><span class="cm"># Auth: run `huggingface-cli login` or set HF_TOKEN env var</span>
<span class="cm"># Get a token at https://huggingface.co/settings/tokens</span>
<span class="cm"># 1. Upload reference audio</span>
UPLOAD_PATH=$(<span class="f">curl</span> -s -X POST \
<span class="s">"https://k2-fsa-omnivoice.hf.space/gradio_api/upload"</span> \
-H <span class="s">"Authorization: Bearer $HF_TOKEN"</span> \
-F <span class="s">"files=@voice.mp3"</span> | jq -r <span class="s">'.[0]'</span>)
<span class="cm"># 2. Join queue</span>
SESSION=$(<span class="f">uuidgen</span>)
<span class="f">curl</span> -s -X POST \
<span class="s">"https://k2-fsa-omnivoice.hf.space/gradio_api/queue/join"</span> \
-H <span class="s">"Content-Type: application/json"</span> \
-H <span class="s">"Authorization: Bearer $HF_TOKEN"</span> \
-d <span class="s">'{
"data": ["Your text here", "English",
{"path": "'$UPLOAD_PATH'",
"meta": {"_type": "gradio.FileData"},
"orig_name": "voice.mp3"},
"", "", 32, 2.0, true, 1.0, 0, true, true],
"fn_index": 0,
"session_hash": "'$SESSION'"
}'</span>
<span class="cm"># 3. Stream result</span>
<span class="f">curl</span> -N \
<span class="s">"https://k2-fsa-omnivoice.hf.space/gradio_api/queue/data?session_hash=$SESSION"</span> \
-H <span class="s">"Authorization: Bearer $HF_TOKEN"</span></code></pre>
</div>
<div id="api-javascript" class="code-block">
<button class="copy-btn" onclick="copyCode(this)"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> Copy</button>
<pre><code><span class="cm">// Auth: set TOKEN to your HF token from https://huggingface.co/settings/tokens</span>
<span class="k">const</span> TOKEN = process.env.HF_TOKEN || <span class="s">"hf_..."</span>;
<span class="k">const</span> BASE = <span class="s">"https://k2-fsa-omnivoice.hf.space/gradio_api"</span>;
<span class="k">const</span> SESSION = <span class="f">crypto.randomUUID</span>();
<span class="cm">// 1. Upload</span>
<span class="k">const</span> form = <span class="k">new</span> <span class="f">FormData</span>();
form.<span class="f">append</span>(<span class="s">"files"</span>, audioFile);
<span class="k">const</span> [path] = <span class="k">await</span> <span class="f">fetch</span>(<span class="s">`${BASE}/upload`</span>, {
method: <span class="s">"POST"</span>,
headers: { Authorization: <span class="s">`Bearer ${TOKEN}`</span> },
body: form
}).<span class="f">then</span>(r => r.<span class="f">json</span>());
<span class="cm">// 2. Join queue</span>
<span class="k">await</span> <span class="f">fetch</span>(<span class="s">`${BASE}/queue/join`</span>, {
method: <span class="s">"POST"</span>,
headers: { <span class="s">"Content-Type"</span>: <span class="s">"application/json"</span>,
Authorization: <span class="s">`Bearer ${TOKEN}`</span> },
body: <span class="f">JSON.stringify</span>({
data: [<span class="s">"Text"</span>, <span class="s">"English"</span>,
{ path, meta: { _type: <span class="s">"gradio.FileData"</span> },
orig_name: <span class="s">"voice.mp3"</span> },
<span class="s">""</span>, <span class="s">""</span>, 32, 2.0, true, 1.0, 0, true, true],
fn_index: 0, session_hash: SESSION })
});
<span class="cm">// 3. Stream SSE</span>
<span class="k">const</span> r = <span class="k">await</span> <span class="f">fetch</span>(
<span class="s">`${BASE}/queue/data?session_hash=${SESSION}`</span>,
{ headers: { Authorization: <span class="s">`Bearer ${TOKEN}`</span> } }
);</code></pre>
</div>
<div id="api-python" class="code-block" style="display:none">
<button class="copy-btn" onclick="copyCode(this)"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> Copy</button>
<pre><code><span class="cm"># Auth: run `huggingface-cli login` or set HF_TOKEN env var</span>
<span class="cm"># Get a token at https://huggingface.co/settings/tokens</span>
<span class="k">from</span> gradio_client <span class="k">import</span> Client, handle_file
client = <span class="f">Client</span>(<span class="s">"k2-fsa/OmniVoice"</span>)
result = client.<span class="f">predict</span>(
text=<span class="s">"Your text here"</span>,
lang=<span class="s">"English"</span>,
ref_aud=<span class="f">handle_file</span>(<span class="s">"voice.mp3"</span>),
ref_text=<span class="s">""</span>, instruct=<span class="s">""</span>,
ns=<span class="s">32</span>, gs=<span class="s">2.0</span>, dn=<span class="k">True</span>,
sp=<span class="s">1.0</span>, du=<span class="s">0</span>, pp=<span class="k">True</span>, po=<span class="k">True</span>,
api_name=<span class="s">"/_clone_fn"</span>
)
<span class="f">print</span>(result)</code></pre>
</div>
<div class="agent-tip">
<strong>Use with AI agents</strong> — These API calls work great with Claude Code, Cursor, Windsurf, or any AI coding agent. Just paste the curl or Python snippet into your agent's context and ask it to clone a voice. The agent can use your <code style="background:var(--surface2);padding:1px 4px;border-radius:3px;font-size:12px">HF_TOKEN</code> environment variable to authenticate. Works with MCP servers, automation scripts, and CI/CD pipelines too.
</div>
</div>
<div class="footer">Powered by <a href="https://huggingface.co/spaces/k2-fsa/OmniVoice" target="_blank">OmniVoice</a>, <a href="https://huggingface.co/spaces/Qwen/Qwen3-TTS" target="_blank">Qwen3-TTS</a> & <a href="https://huggingface.co/spaces/CohereLabs/cohere-transcribe-03-2026" target="_blank">Cohere Transcribe</a></div>
</div>
</div>
</div>
<!-- Add Voice Modal -->
<div class="modal-overlay" id="add-voice-modal">
<div class="modal">
<div class="modal-title">Add Voice</div>
<div class="modal-desc">Upload a reference audio clip to clone a voice. Short clips (5-30s) work best.</div>
<div class="modal-field">
<label>Voice Name</label>
<input type="text" id="voice-name-input" placeholder="e.g. Joshua, Sarah...">
</div>
<div class="modal-field">
<label>Reference Audio</label>
<div class="upload-area" id="modal-upload">
<div id="modal-upload-label">Drop audio file here or click to browse<div class="upload-area-sub">MP3, WAV, FLAC &middot; 5-30 seconds recommended</div></div>
<input type="file" id="modal-file-input" accept="audio/*">
</div>
</div>
<label style="display:flex;align-items:flex-start;gap:8px;font-size:12px;color:var(--text-muted);cursor:pointer;margin-bottom:4px;line-height:1.4">
<input type="checkbox" id="voice-consent" style="margin-top:2px;accent-color:var(--accent)">
I confirm I have the right to use this voice and consent to cloning it.
</label>
<div class="modal-actions">
<button class="btn-modal btn-cancel" onclick="closeModal()">Cancel</button>
<button class="btn-modal btn-save" id="save-voice-btn" onclick="saveVoice()" disabled>Save Voice</button>
</div>
</div>
</div>
<script type="module">
import { oauthLoginUrl, oauthHandleRedirectIfPresent } from "https://esm.sh/@huggingface/hub@0.20.0";
const OMNIVOICE_BASE = "https://k2-fsa-omnivoice.hf.space/gradio_api";
const COLORS = ["#7c3aed","#ec4899","#f59e0b","#22c55e","#3b82f6","#ef4444","#06b6d4","#8b5cf6"];
const QUOTA_FREE = 240;
const QUOTA_PRO = 1500;
let accessToken = null;
let userInfo = null;
let isPro = false;
let selectedVoice = null;
let resultAudioUrl = null;
let modalFileData = null;
let history = JSON.parse(localStorage.getItem("zl_history") || "[]");
let sessionUsage = parseFloat(sessionStorage.getItem("zl_usage") || "0");
// ── IndexedDB ──
function openDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open("ZeroLabs", 1);
req.onupgradeneeded = () => req.result.createObjectStore("voices", { keyPath: "id" });
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function getVoices() {
const db = await openDB();
return new Promise((res, rej) => {
const r = db.transaction("voices","readonly").objectStore("voices").getAll();
r.onsuccess = () => res(r.result);
r.onerror = () => rej(r.error);
});
}
async function putVoice(v) {
const db = await openDB();
return new Promise((res, rej) => {
const tx = db.transaction("voices","readwrite");
tx.objectStore("voices").put(v);
tx.oncomplete = () => res();
tx.onerror = () => rej(tx.error);
});
}
async function deleteVoiceDB(id) {
const db = await openDB();
return new Promise((res, rej) => {
const tx = db.transaction("voices","readwrite");
tx.objectStore("voices").delete(id);
tx.oncomplete = () => res();
tx.onerror = () => rej(tx.error);
});
}
// ── Render sidebar voices ──
async function renderSidebarVoices() {
const voices = await getVoices();
document.getElementById("voice-list").innerHTML = voices.map(v => `
<div class="voice-card ${selectedVoice?.id === v.id ? 'selected' : ''}" onclick="selectVoice('${v.id}')">
<div class="voice-avatar" style="background:${v.color}">${v.name[0].toUpperCase()}</div>
<div class="voice-meta">
<div class="voice-name">${v.name}</div>
<div class="voice-detail">${v.fileName}</div>
</div>
</div>
`).join("");
}
// ── Render Voices page grid ──
async function renderVoicesPage() {
const voices = await getVoices();
const grid = document.getElementById("voices-grid");
grid.innerHTML = voices.filter(v => !v.isPreset).map(v => {
const date = new Date(v.createdAt).toLocaleDateString();
const escaped = v.name.replace(/'/g, "\\'").replace(/"/g, "&quot;");
return `<div class="voice-tile">
<div class="voice-tile-date">${date}</div>
<div class="voice-tile-avatar" style="background:${v.color}">${v.name[0].toUpperCase()}</div>
<div class="voice-tile-name">${v.name} <span onclick="event.stopPropagation();renameVoice('${v.id}','${escaped}')" style="cursor:pointer;opacity:0.4;font-size:12px" title="Rename">&#9998;</span></div>
<div class="voice-tile-file">${v.fileName}</div>
<div class="voice-tile-actions">
<button class="voice-tile-btn use" onclick="selectVoiceAndGo('${v.id}')">Use Voice</button>
<button class="voice-tile-btn del" onclick="deleteVoiceFromPage('${v.id}')">Remove</button>
</div>
</div>`;
}).join("") + `
<div class="add-voice-tile" onclick="openAddVoice()">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Add New Voice
</div>`;
}
window.selectVoice = async function(id) {
const voices = await getVoices();
selectedVoice = voices.find(v => v.id === id);
renderSidebarVoices();
updateVoiceSelector();
updateGenerateBtn();
};
window.selectVoiceAndGo = async function(id) {
await window.selectVoice(id);
document.querySelector('[data-page="synthesis"]').click();
};
window.deleteVoice = async function(id) {
await deleteVoiceDB(id);
if (selectedVoice?.id === id) { selectedVoice = null; updateVoiceSelector(); }
renderSidebarVoices();
renderVoicesPage();
updateGenerateBtn();
};
window.deleteVoiceFromPage = window.deleteVoice;
window.renameVoice = async function(id, currentName) {
const newName = prompt("Rename voice:", currentName);
if (!newName || newName.trim() === "" || newName.trim() === currentName) return;
const voices = await getVoices();
const voice = voices.find(v => v.id === id);
if (!voice) return;
voice.name = newName.trim();
await putVoice(voice);
if (selectedVoice?.id === id) { selectedVoice = voice; updateVoiceSelector(); }
renderSidebarVoices();
renderVoicesPage();
};
function updateVoiceSelector() {
const avatar = document.getElementById("sel-voice-avatar");
const name = document.getElementById("sel-voice-name");
const hint = document.getElementById("sel-voice-hint");
if (selectedVoice) {
avatar.textContent = selectedVoice.name[0].toUpperCase();
avatar.style.background = selectedVoice.color;
name.textContent = selectedVoice.name;
hint.textContent = selectedVoice.fileName;
} else {
avatar.textContent = "🎤";
avatar.style.background = "var(--surface3)";
name.textContent = "No voice selected";
hint.textContent = "Click to select or add a voice";
}
}
// ── Voice Picker Dropdown ──
window.toggleVoicePicker = async function(e) {
e.stopPropagation();
const picker = document.getElementById("voice-picker");
if (picker.classList.contains("visible")) {
picker.classList.remove("visible");
return;
}
const voices = await getVoices();
picker.innerHTML = voices.map(v => `
<button class="voice-picker-item" onclick="event.stopPropagation();pickVoice('${v.id}')">
<div class="voice-avatar" style="background:${v.color}">${v.name[0].toUpperCase()}</div>
<span>${v.name}</span>
</button>
`).join("") + `
<button class="voice-picker-item" onclick="event.stopPropagation();closePickerAndAdd()">
+ Add new voice
</button>`;
picker.classList.add("visible");
};
window.pickVoice = async function(id) {
document.getElementById("voice-picker").classList.remove("visible");
await window.selectVoice(id);
};
window.closePickerAndAdd = function() {
document.getElementById("voice-picker").classList.remove("visible");
openAddVoice();
};
document.addEventListener("click", () => {
document.getElementById("voice-picker")?.classList.remove("visible");
});
// ── Usage Tracker ──
function updateUsageTracker(addSeconds) {
if (addSeconds) {
sessionUsage += addSeconds;
sessionStorage.setItem("zl_usage", sessionUsage.toString());
}
const quota = isPro ? QUOTA_PRO : QUOTA_FREE;
const pct = Math.min((sessionUsage / quota) * 100, 100);
const fill = document.getElementById("usage-fill");
fill.style.width = `${pct}%`;
fill.className = "usage-fill" + (pct > 80 ? " danger" : pct > 50 ? " warning" : "");
const mins = Math.floor(sessionUsage / 60);
const secs = Math.round(sessionUsage % 60);
document.getElementById("usage-value").textContent = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
const quotaMins = Math.round(quota / 60);
const tier = isPro ? "Pro" : "Free";
document.getElementById("usage-sub").textContent = `~${Math.round(sessionUsage)}s of ${quotaMins}m ${tier} daily quota (est.)`;
}
// ── Smart Error CTAs ──
function showStatusWithCTA(errorMsg) {
const el = document.getElementById("status");
const isQuotaError = errorMsg.includes("GPU quota");
const isFreeQuota = errorMsg.includes("free GPU quota");
if (isQuotaError) {
const tryAgainMatch = errorMsg.match(/Try again in ([\d:]+)/);
const waitTime = tryAgainMatch ? tryAgainMatch[1] : null;
let html = `<div class="status-text">${errorMsg}</div>`;
if (isFreeQuota) {
html += `<a class="status-cta upgrade" href="https://huggingface.co/subscribe/pro" target="_blank">
🤗 Upgrade to HF Pro &mdash; 25 min/day GPU quota
</a>`;
} else {
html += `<a class="status-cta credits" href="https://huggingface.co/settings/billing" target="_blank">
Buy additional GPU credits &mdash; $1 per 10 min
</a>`;
}
if (waitTime && waitTime !== "0:00:00") {
html += `<div style="font-size:11px;color:var(--text-muted);margin-top:2px">Or wait ${waitTime} for your quota to reset</div>`;
}
el.className = "status-bar visible error";
el.innerHTML = html;
} else {
el.className = "status-bar visible error";
el.innerHTML = `<div class="status-text">${errorMsg}</div>`;
}
}
function showStatus(type, msg) {
const el = document.getElementById("status");
el.className = `status-bar visible ${type}`;
el.innerHTML = `<div class="status-text">${msg}</div>`;
}
// ── Modal ──
window.openAddVoice = function() {
document.getElementById("add-voice-modal").classList.add("visible");
document.getElementById("voice-name-input").value = "";
document.getElementById("modal-upload-label").innerHTML = 'Drop audio file here or click to browse<div class="upload-area-sub">MP3, WAV, FLAC &middot; 5-30 seconds recommended</div>';
document.getElementById("modal-upload").classList.remove("has-file");
document.getElementById("save-voice-btn").disabled = true;
document.getElementById("voice-consent").checked = false;
modalFileData = null;
};
window.closeModal = function() {
document.getElementById("add-voice-modal").classList.remove("visible");
};
const modalUpload = document.getElementById("modal-upload");
const modalFileInput = document.getElementById("modal-file-input");
modalUpload.addEventListener("click", () => modalFileInput.click());
modalUpload.addEventListener("dragover", e => e.preventDefault());
modalUpload.addEventListener("drop", e => { e.preventDefault(); if (e.dataTransfer.files.length) handleModalFile(e.dataTransfer.files[0]); });
modalFileInput.addEventListener("change", () => { if (modalFileInput.files.length) handleModalFile(modalFileInput.files[0]); });
function handleModalFile(file) {
const reader = new FileReader();
reader.onload = () => {
modalFileData = { name: file.name, data: reader.result, type: file.type };
document.getElementById("modal-upload-label").textContent = file.name;
modalUpload.classList.add("has-file");
checkModalSave();
};
reader.readAsArrayBuffer(file);
}
document.getElementById("voice-name-input").addEventListener("input", checkModalSave);
document.getElementById("voice-consent").addEventListener("change", checkModalSave);
function checkModalSave() {
document.getElementById("save-voice-btn").disabled = !(
document.getElementById("voice-name-input").value.trim() &&
modalFileData &&
document.getElementById("voice-consent").checked
);
}
window.saveVoice = async function() {
const voice = {
id: crypto.randomUUID(),
name: document.getElementById("voice-name-input").value.trim(),
fileName: modalFileData.name,
audioData: modalFileData.data,
audioType: modalFileData.type,
color: COLORS[Math.floor(Math.random() * COLORS.length)],
createdAt: Date.now()
};
await putVoice(voice);
selectedVoice = voice;
closeModal();
renderSidebarVoices();
renderVoicesPage();
updateVoiceSelector();
updateGenerateBtn();
};
// ── Preset Speakers ──
const PRESETS = [
{ name: "Aiden", tag: "Male, English" },
{ name: "Dylan", tag: "Male, English" },
{ name: "Eric", tag: "Male, English" },
{ name: "Ono_anna", tag: "Female, Japanese" },
{ name: "Ryan", tag: "Male, English" },
{ name: "Serena", tag: "Female, English" },
{ name: "Sohee", tag: "Female, Korean" },
{ name: "Uncle_fu", tag: "Male, Chinese" },
{ name: "Vivian", tag: "Female, English" },
];
const previewAudio = new Audio();
let currentPreview = null;
function renderPresets() {
const grid = document.getElementById("presets-grid");
grid.innerHTML = PRESETS.map(p => `
<div class="preset-tile">
<div class="preset-tile-avatar">${p.name[0]}</div>
<div class="preset-tile-info">
<div class="preset-tile-name">${p.name.replace("_", " ")}</div>
<div class="preset-tile-tag">${p.tag}</div>
</div>
<div class="preset-tile-actions">
<button class="preview-btn" onclick="previewPreset('${p.name}')" title="Preview">
<svg viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
</button>
<button class="voice-tile-btn use" onclick="usePreset('${p.name}')">Use</button>
</div>
</div>
`).join("");
}
window.previewPreset = function(name) {
if (currentPreview === name && !previewAudio.paused) {
previewAudio.pause();
currentPreview = null;
return;
}
previewAudio.src = `samples/${name}.wav`;
previewAudio.play();
currentPreview = name;
};
window.usePreset = async function(name) {
// Fetch the sample wav and store as a voice in IndexedDB
const res = await fetch(`samples/${name}.wav`);
const data = await res.arrayBuffer();
const voice = {
id: `preset_${name}`,
name: name.replace("_", " "),
fileName: `${name}.wav`,
audioData: data,
audioType: "audio/wav",
color: "#d97706",
createdAt: Date.now(),
isPreset: true
};
await putVoice(voice);
selectedVoice = voice;
renderSidebarVoices();
renderVoicesPage();
updateVoiceSelector();
updateGenerateBtn();
// Switch to voice clone page
document.querySelector('[data-page="synthesis"]').click();
};
// ── Voice Design (Qwen3-TTS) ──
const QWEN_BASE = "https://qwen-qwen3-tts.hf.space/gradio_api";
let designAudioUrl = null;
const designText = document.getElementById("design-text");
const designDesc = document.getElementById("design-voice-desc");
const designBtn = document.getElementById("design-generate-btn");
designText.addEventListener("input", () => {
document.getElementById("design-char-count").textContent = designText.value.length;
designBtn.disabled = !(designText.value.trim() && designDesc.value.trim());
});
designDesc.addEventListener("input", () => {
designBtn.disabled = !(designText.value.trim() && designDesc.value.trim());
});
window.generateDesign = async function() {
const text = designText.value.trim();
const desc = designDesc.value.trim();
const lang = document.getElementById("design-lang").value;
const btn = designBtn;
const progressBar = document.getElementById("design-progress-bar");
const progressFill = document.getElementById("design-progress-fill");
const result = document.getElementById("design-result");
btn.disabled = true;
btn.classList.add("loading");
result.classList.remove("visible");
showDesignStatus("info", "Joining queue...");
progressBar.classList.add("visible");
progressFill.style.width = "0%";
const sessionHash = crypto.randomUUID();
try {
const joinRes = await fetch(`${QWEN_BASE}/queue/join`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {})
},
body: JSON.stringify({
data: [text, lang, desc],
fn_index: 0,
session_hash: sessionHash
})
});
if (!joinRes.ok) throw new Error(`Queue join failed: ${joinRes.status}`);
const streamRes = await fetch(
`${QWEN_BASE}/queue/data?session_hash=${sessionHash}`,
{ headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : {} }
);
const reader = streamRes.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n\n");
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const data = JSON.parse(line.slice(6));
if (data.msg === "estimation") showDesignStatus("info", `In queue (position ${data.rank})...`);
else if (data.msg === "process_starts") { showDesignStatus("info", "Generating..."); progressFill.style.width = "30%"; }
else if (data.msg === "log") showDesignStatus("info", data.log);
else if (data.msg === "process_completed") {
progressFill.style.width = "100%";
if (data.success) {
const dur = data.output.duration || 0;
designAudioUrl = data.output.data[0].url;
document.getElementById("design-audio-player").src = designAudioUrl;
document.getElementById("design-player-title").textContent = text.slice(0, 60) + (text.length > 60 ? "..." : "");
document.getElementById("design-player-meta").textContent = `Voice Design · ${dur.toFixed(1)}s · ${text.length} chars`;
result.classList.add("visible");
showDesignStatus("success", `Generated in ${dur.toFixed(1)}s`);
addToHistory(text, "Voice Design", dur, designAudioUrl);
updateUsageTracker(dur * 0.5);
} else {
const errMsg = data.output?.error || "Generation failed";
showDesignStatusWithCTA(errMsg);
}
}
}
}
} catch (err) {
showDesignStatusWithCTA(err.message);
} finally {
btn.disabled = false;
btn.classList.remove("loading");
setTimeout(() => { progressBar.classList.remove("visible"); }, 2000);
designBtn.disabled = !(designText.value.trim() && designDesc.value.trim());
}
};
function showDesignStatus(type, msg) {
const el = document.getElementById("design-status");
el.className = `status-bar visible ${type}`;
el.innerHTML = `<div class="status-text">${msg}</div>`;
}
function showDesignStatusWithCTA(errMsg) {
const el = document.getElementById("design-status");
const isQuotaError = errMsg.includes("GPU quota");
const isFreeQuota = errMsg.includes("free GPU quota");
let html = `<div class="status-text">${errMsg}</div>`;
if (isQuotaError) {
if (isFreeQuota) {
html += `<a class="status-cta upgrade" href="https://huggingface.co/subscribe/pro" target="_blank">🤗 Upgrade to HF Pro — 25 min/day GPU quota</a>`;
} else {
html += `<a class="status-cta credits" href="https://huggingface.co/settings/billing" target="_blank">Buy additional GPU credits — $1 per 10 min</a>`;
}
const m = errMsg.match(/Try again in ([\d:]+)/);
if (m && m[1] !== "0:00:00") html += `<div style="font-size:11px;color:var(--text-muted);margin-top:2px">Or wait ${m[1]} for your quota to reset</div>`;
}
el.className = "status-bar visible error";
el.innerHTML = html;
}
const designAudioEl = document.getElementById("design-audio-player");
window.toggleDesignPlay = function() { designAudioEl.paused ? designAudioEl.play() : designAudioEl.pause(); };
designAudioEl.addEventListener("play", () => { document.getElementById("design-play-icon").style.display = "none"; document.getElementById("design-pause-icon").style.display = "block"; });
designAudioEl.addEventListener("pause", () => { document.getElementById("design-play-icon").style.display = "block"; document.getElementById("design-pause-icon").style.display = "none"; });
window.downloadDesignAudio = async function() {
if (!designAudioUrl) return;
const blob = await fetch(designAudioUrl).then(r => r.blob());
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "zerolabs_design.wav";
a.click();
URL.revokeObjectURL(a.href);
};
window.saveDesignVoice = async function() {
if (!designAudioUrl) return;
const btn = document.getElementById("save-design-voice-btn");
btn.textContent = "Saving...";
btn.disabled = true;
try {
const res = await fetch(designAudioUrl);
const data = await res.arrayBuffer();
const desc = designDesc.value.trim();
const name = desc.length > 20 ? desc.slice(0, 20) + "..." : desc || "Designed Voice";
const voice = {
id: crypto.randomUUID(),
name,
fileName: `designed_voice_${Date.now()}.wav`,
audioData: data,
audioType: "audio/wav",
color: COLORS[Math.floor(Math.random() * COLORS.length)],
createdAt: Date.now()
};
await putVoice(voice);
selectedVoice = voice;
renderSidebarVoices();
renderVoicesPage();
updateVoiceSelector();
updateGenerateBtn();
btn.textContent = "Saved!";
setTimeout(() => { btn.textContent = "Save Voice"; btn.disabled = false; }, 2000);
} catch (err) {
btn.textContent = "Save Voice";
btn.disabled = false;
}
};
// ── Transcribe (Cohere) ──
const COHERE_BASE = "https://coherelabs-cohere-transcribe-03-2026.hf.space/gradio_api";
let transcribeFile = null;
const transcribeUpload = document.getElementById("transcribe-upload");
const transcribeFileInput = document.getElementById("transcribe-file-input");
const transcribeBtn = document.getElementById("transcribe-btn");
transcribeUpload.addEventListener("click", () => transcribeFileInput.click());
transcribeUpload.addEventListener("dragover", e => e.preventDefault());
transcribeUpload.addEventListener("drop", e => {
e.preventDefault();
if (e.dataTransfer.files.length) handleTranscribeFile(e.dataTransfer.files[0]);
});
transcribeFileInput.addEventListener("change", () => {
if (transcribeFileInput.files.length) handleTranscribeFile(transcribeFileInput.files[0]);
});
function handleTranscribeFile(file) {
transcribeFile = file;
document.getElementById("transcribe-upload-label").textContent = file.name;
transcribeUpload.classList.add("has-file");
transcribeBtn.disabled = false;
}
window.transcribe = async function() {
if (!transcribeFile) return;
const lang = document.getElementById("transcribe-lang").value;
const btn = transcribeBtn;
const result = document.getElementById("transcribe-result");
btn.disabled = true;
btn.classList.add("loading");
result.style.display = "none";
showTranscribeStatus("info", "Uploading audio...");
try {
const form = new FormData();
form.append("files", transcribeFile);
const uploadRes = await fetch(`${COHERE_BASE}/upload`, {
method: "POST",
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : {},
body: form
});
const [uploadPath] = await uploadRes.json();
showTranscribeStatus("info", "Transcribing...");
const sessionHash = crypto.randomUUID();
const joinRes = await fetch(`${COHERE_BASE}/queue/join`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {})
},
body: JSON.stringify({
data: [
{ path: uploadPath, meta: { _type: "gradio.FileData" }, orig_name: transcribeFile.name },
lang
],
fn_index: 2,
session_hash: sessionHash
})
});
if (!joinRes.ok) throw new Error(`Queue join failed: ${joinRes.status}`);
const streamRes = await fetch(
`${COHERE_BASE}/queue/data?session_hash=${sessionHash}`,
{ headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : {} }
);
const reader = streamRes.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n\n");
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const data = JSON.parse(line.slice(6));
if (data.msg === "estimation") showTranscribeStatus("info", `In queue (position ${data.rank})...`);
else if (data.msg === "process_starts") showTranscribeStatus("info", "Transcribing...");
else if (data.msg === "log") showTranscribeStatus("info", data.log);
else if (data.msg === "process_completed") {
if (data.success) {
const transcript = data.output.data[0];
document.getElementById("transcribe-text").textContent = transcript;
result.style.display = "block";
showTranscribeStatus("success", `Transcribed in ${data.output.duration?.toFixed(1)}s`);
updateUsageTracker(data.output.duration * 0.5);
} else {
showTranscribeStatusWithCTA(data.output?.error || "Transcription failed");
}
}
}
}
} catch (err) {
showTranscribeStatusWithCTA(err.message);
} finally {
btn.disabled = false;
btn.classList.remove("loading");
}
};
function showTranscribeStatus(type, msg) {
const el = document.getElementById("transcribe-status");
el.className = `status-bar visible ${type}`;
el.innerHTML = `<div class="status-text">${msg}</div>`;
}
function showTranscribeStatusWithCTA(errMsg) {
const el = document.getElementById("transcribe-status");
const isQuotaError = errMsg.includes("GPU quota");
const isFreeQuota = errMsg.includes("free GPU quota");
let html = `<div class="status-text">${errMsg}</div>`;
if (isQuotaError) {
if (isFreeQuota) {
html += `<a class="status-cta upgrade" href="https://huggingface.co/subscribe/pro" target="_blank">🤗 Upgrade to HF Pro — 25 min/day GPU quota</a>`;
} else {
html += `<a class="status-cta credits" href="https://huggingface.co/settings/billing" target="_blank">Buy additional GPU credits — $1 per 10 min</a>`;
}
const m = errMsg.match(/Try again in ([\d:]+)/);
if (m && m[1] !== "0:00:00") html += `<div style="font-size:11px;color:var(--text-muted);margin-top:2px">Or wait ${m[1]} for your quota to reset</div>`;
}
el.className = "status-bar visible error";
el.innerHTML = html;
}
window.copyTranscript = function() {
const text = document.getElementById("transcribe-text").textContent;
navigator.clipboard.writeText(text).then(() => {
const btn = document.querySelector("#transcribe-result .copy-btn");
btn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg> Copied!';
btn.classList.add("copied");
setTimeout(() => {
btn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> Copy';
btn.classList.remove("copied");
}, 2000);
});
};
// ── OAuth ──
async function initOAuth() {
// Try restoring from OAuth redirect first
const result = await oauthHandleRedirectIfPresent();
if (result) {
accessToken = result.accessToken;
userInfo = result.userInfo;
// Persist for page reloads with expiration
const expiresAt = Date.now() + (result.expiresIn || 28800) * 1000;
sessionStorage.setItem("zl_token", accessToken);
sessionStorage.setItem("zl_user", JSON.stringify(userInfo));
sessionStorage.setItem("zl_expires", expiresAt.toString());
await checkProAndShow();
return;
}
// Try restoring from sessionStorage
const savedToken = sessionStorage.getItem("zl_token");
const savedUser = sessionStorage.getItem("zl_user");
const savedExpires = sessionStorage.getItem("zl_expires");
if (savedToken && savedUser) {
// Check expiration first
if (savedExpires && Date.now() > parseInt(savedExpires)) {
sessionStorage.removeItem("zl_token");
sessionStorage.removeItem("zl_user");
sessionStorage.removeItem("zl_expires");
return;
}
accessToken = savedToken;
userInfo = JSON.parse(savedUser);
// Verify token is still valid
try {
const whoami = await fetch("https://huggingface.co/api/whoami-v2", {
headers: { Authorization: `Bearer ${accessToken}` }
}).then(r => r.json());
if (whoami.name) {
isPro = !!whoami.isPro;
showApp();
return;
}
} catch {}
// Token invalid, clear it
sessionStorage.removeItem("zl_token");
sessionStorage.removeItem("zl_user");
sessionStorage.removeItem("zl_expires");
}
}
async function checkProAndShow() {
try {
const whoami = await fetch("https://huggingface.co/api/whoami-v2", {
headers: { Authorization: `Bearer ${accessToken}` }
}).then(r => r.json());
isPro = !!whoami.isPro;
} catch { isPro = false; }
showApp();
}
window.login = async function() {
window.location.href = await oauthLoginUrl({ scopes: "openid profile read-billing" });
};
function showApp() {
document.getElementById("landing").style.display = "none";
document.getElementById("app").style.display = "grid";
document.getElementById("user-avatar").src = userInfo.avatarUrl;
document.getElementById("user-name").textContent = userInfo.name;
renderSidebarVoices();
renderPresets();
renderVoicesPage();
renderHistory();
updateUsageTracker();
}
// ── Pages ──
window.showPage = function(page, el) {
document.querySelectorAll(".page").forEach(p => p.classList.remove("active"));
document.getElementById(`page-${page}`).classList.add("active");
document.querySelectorAll(".nav-item").forEach(n => n.classList.remove("active"));
el.classList.add("active");
if (page === "voices") { renderPresets(); renderVoicesPage(); }
if (page === "history") renderHistory();
};
window.copyCode = function(btn) {
const code = btn.parentElement.querySelector("code").textContent;
navigator.clipboard.writeText(code).then(() => {
btn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg> Copied!';
btn.classList.add("copied");
setTimeout(() => {
btn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> Copy';
btn.classList.remove("copied");
}, 2000);
});
};
window.showApiTab = function(tab, el) {
document.querySelectorAll('[id^="api-"]').forEach(t => t.style.display = "none");
document.getElementById(`api-${tab}`).style.display = "block";
document.querySelectorAll(".api-tab").forEach(t => t.classList.remove("active"));
el.classList.add("active");
};
// ── Text ──
const textInput = document.getElementById("text-input");
textInput.addEventListener("input", () => {
document.getElementById("char-count").textContent = textInput.value.length;
updateGenerateBtn();
});
function updateGenerateBtn() {
document.getElementById("generate-btn").disabled = !(selectedVoice && textInput.value.trim());
}
// ── Settings ──
const stepsSlider = document.getElementById("setting-steps");
const speedSlider = document.getElementById("setting-speed");
stepsSlider.addEventListener("input", () => document.getElementById("steps-val").textContent = stepsSlider.value);
speedSlider.addEventListener("input", () => document.getElementById("speed-val").textContent = (speedSlider.value / 100).toFixed(1) + "x");
// ── Generate ──
window.generate = async function() {
if (!selectedVoice) return;
const text = textInput.value.trim();
const lang = document.getElementById("lang-select").value;
const steps = parseInt(stepsSlider.value);
const speed = speedSlider.value / 100;
const btn = document.getElementById("generate-btn");
const progressBar = document.getElementById("progress-bar");
const progressFill = document.getElementById("progress-fill");
const result = document.getElementById("result");
btn.disabled = true;
btn.classList.add("loading");
result.classList.remove("visible");
showStatus("info", "Uploading voice...");
progressBar.classList.add("visible");
progressFill.style.width = "0%";
try {
const blob = new Blob([selectedVoice.audioData], { type: selectedVoice.audioType || "audio/mpeg" });
const form = new FormData();
form.append("files", blob, selectedVoice.fileName);
const uploadRes = await fetch(`${OMNIVOICE_BASE}/upload`, {
method: "POST",
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : {},
body: form
});
const [uploadPath] = await uploadRes.json();
showStatus("info", "Joining queue...");
const sessionHash = crypto.randomUUID();
const joinRes = await fetch(`${OMNIVOICE_BASE}/queue/join`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {})
},
body: JSON.stringify({
data: [text, lang,
{ path: uploadPath, meta: { _type: "gradio.FileData" }, orig_name: selectedVoice.fileName },
"", "", steps, 2.0, true, speed, 0, true, true],
fn_index: 0, session_hash: sessionHash
})
});
if (!joinRes.ok) throw new Error(`Queue join failed: ${joinRes.status}`);
const streamRes = await fetch(
`${OMNIVOICE_BASE}/queue/data?session_hash=${sessionHash}`,
{ headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : {} }
);
const reader = streamRes.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n\n");
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const data = JSON.parse(line.slice(6));
if (data.msg === "estimation") showStatus("info", `In queue (position ${data.rank})...`);
else if (data.msg === "process_starts") { showStatus("info", "Processing..."); progressFill.style.width = "10%"; }
else if (data.msg === "log") showStatus("info", data.log);
else if (data.msg === "progress" && data.progress_data?.length) {
const p = data.progress_data[0];
if (p.length) {
progressFill.style.width = `${Math.round((p.index / p.length) * 90) + 10}%`;
showStatus("info", p.desc || `Step ${p.index}/${p.length}`);
}
} else if (data.msg === "process_completed") {
progressFill.style.width = "100%";
if (data.success) {
const dur = data.output.duration || 0;
resultAudioUrl = data.output.data[0].url;
document.getElementById("audio-player").src = resultAudioUrl;
document.getElementById("player-title").textContent = text.slice(0, 60) + (text.length > 60 ? "..." : "");
document.getElementById("player-meta").textContent = `${selectedVoice.name} · ${dur.toFixed(1)}s · ${text.length} chars`;
result.classList.add("visible");
showStatus("success", `Generated in ${dur.toFixed(1)}s`);
addToHistory(text, selectedVoice.name, dur, resultAudioUrl);
// Estimate GPU usage (~50% of wall time based on our measurements)
updateUsageTracker(dur * 0.5);
} else {
const errMsg = data.output?.error || "Generation failed";
showStatusWithCTA(errMsg);
}
}
}
}
} catch (err) {
showStatusWithCTA(err.message);
} finally {
btn.disabled = false;
btn.classList.remove("loading");
setTimeout(() => { progressBar.classList.remove("visible"); updateGenerateBtn(); }, 2000);
}
};
// ── Player ──
const audioEl = document.getElementById("audio-player");
window.togglePlay = function() { audioEl.paused ? audioEl.play() : audioEl.pause(); };
audioEl.addEventListener("play", () => { document.getElementById("play-icon").style.display = "none"; document.getElementById("pause-icon").style.display = "block"; });
audioEl.addEventListener("pause", () => { document.getElementById("play-icon").style.display = "block"; document.getElementById("pause-icon").style.display = "none"; });
window.downloadAudio = async function() {
if (!resultAudioUrl) return;
const blob = await fetch(resultAudioUrl).then(r => r.blob());
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "zerolabs_output.wav";
a.click();
URL.revokeObjectURL(a.href);
};
// ── History ──
function addToHistory(text, voiceName, duration, url) {
history.unshift({ text, voiceName, duration, url, timestamp: Date.now() });
if (history.length > 50) history.pop();
localStorage.setItem("zl_history", JSON.stringify(history));
renderHistory();
}
function renderHistory() {
const c = document.getElementById("history-container");
if (!history.length) { c.innerHTML = '<div class="empty-state"><div class="empty-state-icon">🕐</div><div>No generations yet</div></div>'; return; }
c.innerHTML = '<div class="history-list">' + history.map((h, i) => {
const t = new Date(h.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
return `<div class="history-item">
<button class="history-play" onclick="playHistory(${i})"><svg viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg></button>
<div class="history-info"><div class="history-text">${h.text}</div><div class="history-sub">${h.voiceName} · ${h.duration?.toFixed(1)}s · ${t}</div></div>
<button class="history-download" onclick="downloadHistoryItem(${i})">Download</button>
</div>`;
}).join("") + '</div>';
}
window.playHistory = function(i) { const h = history[i]; if (h.url) { audioEl.src = h.url; audioEl.play(); } };
window.downloadHistoryItem = async function(i) {
const h = history[i]; if (!h.url) return;
try { const b = await fetch(h.url).then(r=>r.blob()); const a=document.createElement("a"); a.href=URL.createObjectURL(b); a.download=`zerolabs_${i}.wav`; a.click(); URL.revokeObjectURL(a.href); } catch {}
};
// ── Init ──
initOAuth();
</script>
</body>
</html>