ZeroLabs / index.html
multimodalart's picture
multimodalart HF Staff
Reword guest CTA: free for longer when signed in
70ab5e2
Raw
History Blame Contribute Delete
192 kB
<!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 {
position: relative;
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);
cursor: pointer;
transition: all 0.1s;
}
.user-pill:hover { border-color: var(--border-hover); color: var(--text); }
.user-pill img {
width: 24px;
height: 24px;
border-radius: 50%;
}
.user-menu {
position: absolute; top: calc(100% + 6px); right: 0;
min-width: 160px; background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
box-shadow: 0 8px 24px var(--shadow-color); padding: 4px; z-index: 50;
display: none;
}
.user-menu.visible { display: block; }
.user-menu-item {
display: flex; align-items: center; gap: 8px; width: 100%;
padding: 8px 10px; border: none; background: transparent;
font-family: 'Inter', sans-serif; font-size: 13px; color: var(--text);
text-align: left; border-radius: 6px; cursor: pointer;
}
.user-menu-item:hover { background: var(--surface2); }
.user-menu-item svg { width: 14px; height: 14px; flex-shrink: 0; }
.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; position: relative; padding-right: 100px; }
.page-title { font-size: 22px; font-weight: 700; letter-spacing: -0.5px; margin-bottom: 6px; }
.page-desc { font-size: 14px; color: var(--text-muted); }
.page-api-toggle {
position: absolute; top: 2px; right: 0;
padding: 6px 11px; border-radius: 7px; border: 1px solid var(--border); background: var(--surface);
color: var(--text-muted); font-family: 'Inter', sans-serif; font-size: 12px; font-weight: 500;
display: inline-flex; align-items: center; gap: 6px; cursor: pointer; transition: all 0.1s;
}
.page-api-toggle:hover { color: var(--text); border-color: var(--border-hover); }
.page-api-toggle.open { color: var(--text); background: var(--surface2); border-color: var(--border-hover); }
.page-api-toggle svg { width: 13px; height: 13px; }
.page-api-panel { margin: -12px 0 24px 0; padding: 14px; background: var(--surface); border: 1px solid var(--border); border-radius: 10px; }
.page-api-panel .api-tabs { margin-bottom: 12px; }
.page-api-panel .code-block { margin: 0; }
/* 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); }
/* Adaptive rich-document editor (synthesis) */
#page-synthesis { position: relative; }
.gen-editor { position: relative; margin-bottom: 16px; }
/* Rich document editor */
.ab-doc {
border: 1px solid var(--border); border-radius: 12px 12px 0 0; background: var(--surface);
padding: 14px 14px 14px 6px; min-height: 180px; max-height: 60vh; overflow-y: auto;
}
/* Single-segment "simple" mode looks just like a plain text field */
.ab-doc.simple { padding: 6px 8px; }
.ab-doc.simple .ab-chip, .ab-doc.simple .ab-row-side { display: none; }
.ab-doc.simple .ab-para { padding: 8px 10px; font-size: 15px; }
.ab-doc.simple .ab-row { padding: 0; }
.ab-row { display: flex; align-items: flex-start; gap: 10px; padding: 4px 0; position: relative; }
.ab-chip {
flex: none; width: 56px; background: none; border: none; cursor: pointer; padding: 0;
display: flex; flex-direction: column; align-items: center; gap: 2px; margin-top: 1px;
}
.ab-chip-av {
width: 30px; height: 30px; border-radius: 50%; border: 2px solid var(--border);
color: #fff; font-size: 12px; font-weight: 700;
display: flex; align-items: center; justify-content: center; gap: 1px; position: relative;
}
.ab-chip-av svg { width: 9px; height: 9px; opacity: 0.85; }
.ab-chip.overridden .ab-chip-av { box-shadow: 0 0 0 2px var(--accent); }
.ab-chip-name {
font-size: 10px; font-weight: 600; color: var(--text-muted); max-width: 56px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; line-height: 1.2;
}
.ab-chip:hover .ab-chip-name { color: var(--text-secondary); }
.ab-para {
flex: 1; font-size: 15px; line-height: 1.55; color: var(--text); padding: 4px 8px;
border-radius: 6px; background: transparent; outline: none; min-height: 1.55em;
white-space: pre-wrap; word-break: break-word;
}
.ab-para:hover { background: var(--surface2); }
.ab-para:focus { background: var(--surface2); }
.ab-para:empty::before { content: "Type or paste your text here…"; color: var(--text-muted); pointer-events: none; }
.ab-row-side { flex: none; display: flex; align-items: center; gap: 6px; margin-top: 2px; }
.ab-dl-one {
width: 26px; height: 26px; border-radius: 6px; border: 1px solid var(--border);
background: var(--surface3); color: var(--text-secondary); cursor: pointer; padding: 0;
display: flex; align-items: center; justify-content: center;
}
.ab-dl-one:hover { color: var(--text); border-color: var(--border-hover); }
.ab-dl-one svg { width: 13px; height: 13px; }
.ab-dl-one.busy { opacity: 0.5; pointer-events: none; }
.ab-dl-one:disabled { opacity: 0.3; cursor: not-allowed; border-color: var(--border); }
.ab-dl-one:disabled:hover { color: var(--text-secondary); border-color: var(--border); background: var(--surface3); }
.ab-join {
position: absolute; left: 28px; bottom: -9px; z-index: 5;
width: 20px; height: 18px; border-radius: 5px; border: 1px solid var(--border);
background: var(--surface); color: var(--text-muted); cursor: pointer; padding: 0;
display: none; align-items: center; justify-content: center; opacity: 0;
transition: opacity 0.1s;
}
.ab-row:hover .ab-join { display: flex; opacity: 1; }
.ab-join:hover { color: var(--text); border-color: var(--border-hover); background: var(--surface2); }
.ab-join svg { width: 11px; height: 11px; }
.ab-row-del {
width: 24px; height: 24px; border-radius: 6px; border: 1px solid transparent;
background: none; color: var(--text-muted); cursor: pointer; padding: 0;
display: flex; align-items: center; justify-content: center; font-size: 15px; line-height: 1;
}
.ab-row-del:hover { color: #dc2626; border-color: var(--border); background: var(--surface3); }
.ab-play-one {
width: 26px; height: 26px; border-radius: 6px; border: 1px solid var(--border);
background: var(--surface3); color: var(--text-secondary); cursor: pointer; padding: 0;
display: flex; align-items: center; justify-content: center;
}
.ab-play-one:hover { color: var(--text); border-color: var(--border-hover); }
.ab-play-one svg { width: 13px; height: 13px; }
.ab-play-one.busy { opacity: 0.5; pointer-events: none; }
/* subtle "audio ready" tint once a segment has been generated */
.ab-play-one.ready { background: rgba(245,158,11,0.16); border-color: var(--accent); color: var(--accent); }
.ab-play-one.ready:hover { background: var(--accent); color: var(--btn-text); }
.ab-row-status { font-size: 11px; font-weight: 600; color: var(--text-muted); min-width: 14px; }
.ab-row-status.done { color: #16a34a; }
.ab-row-status.error { color: #dc2626; }
.ab-row-status.active { color: var(--accent); }
.ab-voice-menu {
display: none; position: absolute; z-index: 60; background: var(--surface);
border: 1px solid var(--border-hover); border-radius: 10px; box-shadow: 0 8px 24px rgba(0,0,0,0.18);
padding: 6px; min-width: 180px; max-height: 260px; overflow-y: auto;
}
.ab-voice-menu.visible { display: block; }
.ab-voice-menu button {
display: flex; align-items: center; gap: 8px; width: 100%; text-align: left;
padding: 7px 9px; border: none; background: none; cursor: pointer; border-radius: 7px;
font-size: 13px; color: var(--text); font-family: 'Inter', sans-serif;
}
.ab-voice-menu button:hover { background: var(--surface2); }
.ab-voice-menu .ab-mav {
width: 22px; height: 22px; border-radius: 50%; flex: none; color: #fff; font-size: 11px;
font-weight: 700; display: flex; align-items: center; justify-content: center;
}
.gen-suggest {
display: none; align-items: center; gap: 12px; justify-content: space-between;
padding: 10px 14px; background: rgba(245,158,11,0.08);
border: 1px solid rgba(245,158,11,0.18); border-top: none;
}
.gen-suggest.visible { display: flex; }
.gen-suggest-text { font-size: 12px; color: var(--accent-hover); line-height: 1.4; }
.gen-suggest-btn {
flex: none; padding: 6px 14px; background: var(--accent); border: none; border-radius: 7px;
color: var(--btn-text); font-size: 12px; font-weight: 600; cursor: pointer;
font-family: 'Inter', sans-serif;
}
.gen-suggest-btn:hover { background: var(--accent-hover); }
.seg-stat { display: none; }
.seg-stat.visible { display: inline; }
/* 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.visible.success { display: none; } /* hide "Generated in Xs" lines for now */
.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);
}
.voices-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.voices-head-add {
font-family: 'Inter', sans-serif; font-size: 11px; font-weight: 600;
text-transform: none; letter-spacing: 0; color: var(--accent);
background: none; border: none; cursor: pointer; padding: 0;
}
.voices-head-add:hover { color: var(--accent-hover); text-decoration: underline; }
.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 .tagline { font-size: 22px; font-weight: 600; letter-spacing: -0.3px; color: var(--text); max-width: 560px; line-height: 1.35; margin-top: -8px; }
#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); }
.btn-hf-sm {
padding: 7px 16px; font-size: 13px; border-radius: 8px;
box-shadow: 0 3px 0 var(--btn-shadow-hf), 0 4px 8px var(--shadow-color);
}
.btn-hf-sm:hover { box-shadow: 0 4px 0 var(--btn-shadow-hf), 0 6px 12px var(--shadow-color); }
.btn-hf-sm:active { box-shadow: 0 1px 0 var(--btn-shadow-hf), 0 2px 4px var(--shadow-color); }
.guest-cta {
padding: 12px; margin: 0 12px 12px;
background: var(--surface2); border: 1px solid var(--border); border-radius: 10px;
}
.guest-cta-title { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); margin-bottom: 4px; }
.guest-cta-sub { font-size: 11px; color: var(--text-secondary); line-height: 1.4; margin-bottom: 10px; }
.guest-cta-btn { width: 100%; justify-content: center; }
::-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; }
}
.audio-source-tabs { display: flex; gap: 4px; background: var(--surface2); padding: 3px; border-radius: 8px; margin-bottom: 8px; }
.audio-source-tab {
flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 6px;
padding: 7px 10px; border: none; background: transparent; color: var(--text-muted);
font-family: 'Inter', sans-serif; font-size: 12.5px; font-weight: 500; border-radius: 6px; cursor: pointer; transition: all 0.1s;
}
.audio-source-tab.active { background: var(--surface); color: var(--text); box-shadow: 0 1px 2px var(--shadow-color); }
.audio-source-tab:hover:not(.active) { color: var(--text); }
.audio-source-tab svg { width: 14px; height: 14px; flex-shrink: 0; }
.record-pane { display: flex; align-items: center; gap: 14px; padding: 18px; border: 1px dashed var(--border); border-radius: 10px; background: var(--surface); }
.record-mic-btn {
width: 52px; height: 52px; border-radius: 50%; border: none; cursor: pointer;
background: #ef4444; color: #fff; display: flex; align-items: center; justify-content: center;
flex-shrink: 0; transition: all 0.15s; box-shadow: 0 2px 6px rgba(239, 68, 68, 0.3);
}
.record-mic-btn:hover { transform: scale(1.05); box-shadow: 0 4px 10px rgba(239, 68, 68, 0.45); }
.record-mic-btn svg { width: 22px; height: 22px; }
.record-mic-btn.recording { background: var(--text); box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.6); animation: recPulse 1.4s infinite; }
.record-mic-btn.recording svg { display: none; }
.record-mic-btn.recording::after { content: ""; width: 14px; height: 14px; background: #ef4444; border-radius: 2px; }
@keyframes recPulse { 0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.55); } 70% { box-shadow: 0 0 0 14px rgba(239, 68, 68, 0); } 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); } }
.record-timer { font-size: 18px; font-weight: 600; color: var(--text); font-variant-numeric: tabular-nums; }
.record-hint { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
.lib-list { display: flex; flex-direction: column; gap: 2px; padding: 6px; background: var(--surface); border: 1px solid var(--border); border-radius: 10px; max-height: 320px; overflow-y: auto; }
.lib-item { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 8px; cursor: pointer; transition: background 0.1s; border: 1px solid transparent; }
.lib-item:hover { background: var(--surface2); }
.lib-item.selected { background: var(--surface2); border-color: var(--border-hover); }
.lib-item .voice-avatar { width: 32px; height: 32px; border-radius: 8px; font-size: 14px; }
.lib-item-meta { display: flex; flex-direction: column; min-width: 0; }
.lib-item-name { font-size: 13px; font-weight: 500; color: var(--text); }
.lib-item-tag { font-size: 11px; color: var(--text-muted); }
.lib-empty { padding: 24px 12px; text-align: center; color: var(--text-muted); font-size: 13px; }
</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" style="display:none">
<div class="logo-icon" style="width:56px;height:56px;font-size:28px;border-radius:14px">0</div>
<h1>ZeroLabs</h1>
<p class="tagline">Open-source audio AI has caught up. See for yourself.</p>
<p class="subtitle">Voice cloning, design, and conversion, audio cleanup, sound effects, and transcription. Powered by open-source models. Generous free tier, then 100x cheaper than proprietary.</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>
<button class="btn-hf btn-hf-sm" id="header-signin" onclick="login()" style="display:none">🤗 Sign in</button>
<div class="user-pill" id="user-pill" onclick="toggleUserMenu(event)">
<img id="user-avatar" src="" alt="">
<span id="user-name"></span>
<div class="user-menu" id="user-menu">
<button class="user-menu-item" type="button" onclick="event.stopPropagation();logout()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
Log out
</button>
</div>
</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 Generation
</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="changer" onclick="showPage('changer', this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="9" r="2.5"/><path d="M3 17a3 3 0 0 1 3-3"/><circle cx="18" cy="9" r="2.5"/><path d="M21 17a3 3 0 0 0-3-3"/><path d="M8 19h8"/><polyline points="14 17 16 19 14 21"/><polyline points="10 17 8 19 10 21"/></svg>
Voice Changer
</button>
<button class="nav-item" data-page="isolate" onclick="showPage('isolate', this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="8" x2="12" y2="16"/><line x1="9" y1="10" x2="9" y2="14"/><line x1="15" y1="10" x2="15" y2="14"/><path d="M6 6c-3 3-3 9 0 12"/><path d="M18 6c3 3 3 9 0 12"/></svg>
Voice Cleaner
</button>
<button class="nav-item" data-page="sfx" onclick="showPage('sfx', this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3 C 12 8 13 11 21 12 C 13 13 12 16 12 21 C 12 16 11 13 3 12 C 11 11 12 8 12 3 Z"/></svg>
Sound Effects
</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>
</div>
<div class="sidebar-section">
<div class="sidebar-label">Library</div>
<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>
</div>
<div class="sidebar-voices" id="voice-list"></div>
<div class="sidebar-section">
<div class="sidebar-label">More</div>
<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>
<!-- Guest CTA (shown when logged out) -->
<div class="guest-cta" id="guest-cta" style="display:none">
<div class="guest-cta-title">You're in guest mode</div>
<div class="guest-cta-sub">3 signed-out free runs. Sign in with Hugging Face to keep using it free for longer.</div>
<button class="btn-hf btn-hf-sm guest-cta-btn" onclick="login()">🤗 Sign in with Hugging Face</button>
</div>
<!-- Usage Tracker -->
<div class="usage-tracker" id="usage-tracker">
<div class="usage-header">
<span class="usage-label">ZeroGPU Quota</span>
<span class="usage-value" id="usage-value"></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">Loading…</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. Powered by OmniVoice.</div>
<button class="page-api-toggle" type="button" onclick="togglePageApi('synthesis', this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
<span class="page-api-label">View API</span>
</button>
</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="gen-editor">
<div class="ab-doc simple" id="gen-doc"></div>
<div class="gen-suggest" id="gen-suggest">
<span class="gen-suggest-text">That's a long passage. Split it into segments for better quality output.</span>
<button type="button" class="gen-suggest-btn" onclick="abBreakDown()">Break it down</button>
</div>
<div class="editor-footer">
<div class="editor-left">
<span class="editor-stat"><span id="char-count">0</span> chars</span>
<span class="editor-stat seg-stat" id="seg-stat"><span id="seg-count">1</span> segments</span>
<select class="lang-select" id="lang-select">
<option value="Auto">Language (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 class="ab-voice-menu" id="gen-voice-menu"></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>
<button class="page-api-toggle" type="button" onclick="togglePageApi('design', this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
<span class="page-api-label">View API</span>
</button>
</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">Language (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 Changer Page -->
<div class="page" id="page-changer">
<div class="page-header">
<div class="page-title">Voice Changer</div>
<div class="page-desc">Transform speech into any target voice. Powered by Seed-VC.</div>
<button class="page-api-toggle" type="button" onclick="togglePageApi('changer', this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
<span class="page-api-label">View API</span>
</button>
</div>
<div class="setting-label" style="margin-bottom:8px">Source audio</div>
<div class="upload-area" id="vc-source-upload" style="margin-bottom:16px">
<div id="vc-source-label">Drop source audio or click to upload<div class="upload-area-sub">The speech you want to convert</div></div>
<input type="file" id="vc-source-input" accept="audio/*">
</div>
<div class="setting-label" style="margin-bottom:8px">Target voice</div>
<div class="upload-area" id="vc-target-upload" style="margin-bottom:16px">
<div id="vc-target-label">Drop target voice or click to upload<div class="upload-area-sub">Short clip of the voice to convert to</div></div>
<input type="file" id="vc-target-input" accept="audio/*">
</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">Diffusion Steps</div>
<div class="setting-value">
<input type="range" id="vc-steps" min="4" max="50" value="10">
<span class="setting-num" id="vc-steps-val">10</span>
</div>
</div>
<div class="setting-group">
<div class="setting-label">Similarity</div>
<div class="setting-value">
<input type="range" id="vc-sim" min="0" max="100" value="70">
<span class="setting-num" id="vc-sim-val">0.7</span>
</div>
</div>
</div>
</div>
</div>
<div class="generate-bar">
<button class="btn-generate" id="vc-btn" onclick="voiceChange()" disabled>
<span class="btn-text">Convert Voice</span>
<div class="spinner"></div>
</button>
</div>
<div class="status-bar" id="vc-status"></div>
<div class="progress-track" id="vc-progress-bar"><div class="progress-fill" id="vc-progress-fill"></div></div>
<div class="player-card" id="vc-result">
<div class="player-top">
<button class="play-btn" onclick="togglePlayer('vc')">
<svg id="vc-play-icon" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
<svg id="vc-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="vc-player-title">Converted audio</div>
<div class="player-meta" id="vc-player-meta"></div>
</div>
<div style="display:flex;gap:6px">
<button class="icon-btn" onclick="downloadResult('vc')" 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="vc-audio-player" class="player-audio"></audio>
</div>
</div>
<!-- Voice Cleaner Page -->
<div class="page" id="page-isolate">
<div class="page-header">
<div class="page-title">Voice Cleaner</div>
<div class="page-desc">Strip background noise from speech and optionally restore clarity. Powered by Resemble Enhance.</div>
<button class="page-api-toggle" type="button" onclick="togglePageApi('isolate', this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
<span class="page-api-label">View API</span>
</button>
</div>
<div class="upload-area" id="iso-upload" style="margin-bottom:16px">
<div id="iso-upload-label">Drop noisy audio or click to upload<div class="upload-area-sub">MP3, WAV, FLAC, M4A &middot; speech with background noise</div></div>
<input type="file" id="iso-file-input" accept="audio/*">
</div>
<div class="audio-source-tabs" style="margin-bottom:16px" id="iso-mode-tabs">
<button class="audio-source-tab active" data-iso-mode="enhance" type="button" onclick="setIsoMode('enhance')">Remove noise &amp; enhance</button>
<button class="audio-source-tab" data-iso-mode="denoise" type="button" onclick="setIsoMode('denoise')">Remove noise</button>
</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">NFE Steps</div>
<div class="setting-value">
<input type="range" id="iso-nfe" min="1" max="128" value="64">
<span class="setting-num" id="iso-nfe-val">64</span>
</div>
</div>
<div class="setting-group">
<div class="setting-label">Prior Tau</div>
<div class="setting-value">
<input type="range" id="iso-tau" min="1" max="100" value="50">
<span class="setting-num" id="iso-tau-val">0.5</span>
</div>
</div>
</div>
</div>
</div>
<div class="generate-bar">
<button class="btn-generate" id="iso-btn" onclick="voiceIsolate()" disabled>
<span class="btn-text">Clean Audio</span>
<div class="spinner"></div>
</button>
</div>
<div class="status-bar" id="iso-status"></div>
<div class="progress-track" id="iso-progress-bar"><div class="progress-fill" id="iso-progress-fill"></div></div>
<div class="player-card" id="iso-result">
<div class="player-top">
<button class="play-btn" onclick="togglePlayer('iso')">
<svg id="iso-play-icon" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
<svg id="iso-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="iso-player-title">Enhanced audio</div>
<div class="player-meta" id="iso-player-meta"></div>
</div>
<div style="display:flex;gap:6px">
<button class="icon-btn" onclick="downloadResult('iso')" 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="iso-audio-player" class="player-audio"></audio>
</div>
</div>
<!-- Sound Effects Page -->
<div class="page" id="page-sfx">
<div class="page-header">
<div class="page-title">Sound Effects</div>
<div class="page-desc">Generate sound effects from a text prompt. Powered by Stable Audio 3.</div>
<button class="page-api-toggle" type="button" onclick="togglePageApi('sfx', this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
<span class="page-api-label">View API</span>
</button>
</div>
<div class="editor">
<textarea id="sfx-prompt" placeholder="e.g. dog barking, thunder cracking, footsteps on gravel, lo-fi vinyl crackle..."></textarea>
<div class="editor-footer">
<div class="editor-left">
<span class="editor-stat"><span id="sfx-char-count">0</span> chars</span>
</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">Duration</div>
<div class="setting-value">
<input type="range" id="sfx-duration" min="1" max="30" value="5">
<span class="setting-num" id="sfx-duration-val">5s</span>
</div>
</div>
<div class="setting-group">
<div class="setting-label">Inference Steps</div>
<div class="setting-value">
<input type="range" id="sfx-steps" min="4" max="32" value="8">
<span class="setting-num" id="sfx-steps-val">8</span>
</div>
</div>
</div>
</div>
</div>
<div class="generate-bar">
<button class="btn-generate" id="sfx-btn" onclick="soundEffect()" disabled>
<span class="btn-text">Generate Sound</span>
<div class="spinner"></div>
</button>
</div>
<div class="status-bar" id="sfx-status"></div>
<div class="progress-track" id="sfx-progress-bar"><div class="progress-fill" id="sfx-progress-fill"></div></div>
<div class="player-card" id="sfx-result">
<div class="player-top">
<button class="play-btn" onclick="togglePlayer('sfx')">
<svg id="sfx-play-icon" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
<svg id="sfx-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="sfx-player-title">Generated sound</div>
<div class="player-meta" id="sfx-player-meta"></div>
</div>
<div style="display:flex;gap:6px">
<button class="icon-btn" onclick="downloadResult('sfx')" 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="sfx-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 Generation.</div>
</div>
<div class="preset-section-title voices-head">
<span>Your Voices</span>
<button class="voices-head-add" type="button" onclick="openAddVoice()">+ Add new voice (clone)</button>
</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>
<button class="page-api-toggle" type="button" onclick="togglePageApi('transcribe', this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
<span class="page-api-label">View API</span>
</button>
</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">Call any of these features directly from your code or AI agent using your HF token.</div>
</div>
<div style="margin-bottom:14px">
<select class="lang-select" id="api-feature" style="padding:8px 12px;font-size:13px;width:100%;max-width:380px">
<option value="synthesis">Voice Generation — OmniVoice</option>
<option value="design">Voice Design — Qwen3-TTS</option>
<option value="changer">Voice Changer — Seed-VC</option>
<option value="isolate">Voice Cleaner — Resemble Enhance</option>
<option value="sfx">Sound Effects — Stable Audio 3</option>
<option value="transcribe">Transcribe — Cohere</option>
</select>
</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. Paste a snippet into your agent's context and ask it to call the feature. 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 class="api-tabs" id="api-main-tabs">
<button class="api-tab active" type="button" data-lang="javascript" onclick="showApiLang('javascript', this)">JavaScript</button>
<button class="api-tab" type="button" data-lang="python" onclick="showApiLang('python', this)">Python</button>
<button class="api-tab" type="button" data-lang="curl" onclick="showApiLang('curl', this)">curl</button>
</div>
<div 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 id="api-code"></code></pre>
</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/Plachta/Seed-VC" target="_blank">Seed-VC</a>, <a href="https://huggingface.co/spaces/multimodalart/resemble-enhance-zerogpu" target="_blank">Resemble Enhance</a>, <a href="https://huggingface.co/spaces/stabilityai/stable-audio-3" target="_blank">Stable Audio 3</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 or record 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="audio-source-tabs">
<button class="audio-source-tab active" data-mode="upload" type="button" onclick="setVoiceSourceMode('upload', true)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Upload
</button>
<button class="audio-source-tab" data-mode="record" type="button" onclick="setVoiceSourceMode('record')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0"/><line x1="12" y1="18" x2="12" y2="22"/></svg>
Record
</button>
</div>
<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 id="modal-record" style="display:none">
<div class="record-pane">
<button class="record-mic-btn" id="record-mic-btn" type="button" onclick="toggleRecord()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0"/><line x1="12" y1="18" x2="12" y2="22"/></svg>
</button>
<div class="record-meta">
<div class="record-timer" id="record-timer">0:00</div>
<div class="record-hint" id="record-hint">Click to record &middot; 5-30s recommended</div>
</div>
</div>
<audio id="record-preview" controls style="display:none;width:100%;margin-top:10px;border-radius:8px"></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 inputRecorders = {}; // prefix -> { mediaRecorder, stream, chunks, timerInterval, startTime }
const inputRecordHandlers = {}; // prefix -> (file) => void
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 quotaTimer = null;
let lastQuota = null;
// ── 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 (clone)
</div>`;
}
window.selectVoice = async function(id) {
const voices = await getVoices();
selectedVoice = voices.find(v => v.id === id);
renderSidebarVoices();
updateVoiceSelector();
updateGenerateBtn();
if (document.getElementById("gen-doc")) renderAbRows(); // refresh default-voice chips
};
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();
const usedPresetNames = new Set(voices.filter(v => v.isPreset).map(v => v.fileName.replace(/\.wav$/, "")));
const availablePresets = PRESETS.filter(p => !usedPresetNames.has(p.name));
const voicesHtml = 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("");
const presetsHtml = availablePresets.length ? `
<div style="padding:6px 10px 4px;font-size:10px;text-transform:uppercase;letter-spacing:0.06em;color:var(--text-muted);font-weight:600;border-top:1px solid var(--border);margin-top:4px">Presets</div>
${availablePresets.map(p => `
<button class="voice-picker-item" onclick="event.stopPropagation();pickPreset('${p.name}')">
<div class="voice-avatar" style="background:#d97706">${p.name[0]}</div>
<span>${p.name.replace("_", " ")}<span style="opacity:0.5;font-size:11px;margin-left:6px">${p.tag}</span></span>
</button>
`).join("")}` : "";
picker.innerHTML = `
<button class="voice-picker-item" onclick="event.stopPropagation();closePickerAndAdd()" style="border-bottom:1px solid var(--border);margin-bottom:4px;font-weight:500">
+ Add new voice (clone)
</button>` + voicesHtml + presetsHtml;
picker.classList.add("visible");
};
window.pickVoice = async function(id) {
document.getElementById("voice-picker").classList.remove("visible");
await window.selectVoice(id);
};
window.pickPreset = async function(name) {
document.getElementById("voice-picker").classList.remove("visible");
await window.usePreset(name);
};
window.closePickerAndAdd = function() {
document.getElementById("voice-picker").classList.remove("visible");
openAddVoice();
};
document.addEventListener("click", () => {
document.getElementById("voice-picker")?.classList.remove("visible");
});
// ── Usage Tracker (real ZeroGPU quota) ──
function fmtDuration(sec) {
sec = Math.max(0, Math.round(sec));
const m = Math.floor(sec / 60);
const s = sec % 60;
return m > 0 ? `${m}m ${s}s` : `${s}s`;
}
function fmtResetsIn(iso) {
if (!iso) return null;
const ms = new Date(iso).getTime() - Date.now();
if (isNaN(ms) || ms <= 0) return null;
const mins = Math.round(ms / 60000);
if (mins < 60) return `${mins}m`;
const h = Math.floor(mins / 60);
const m = mins % 60;
return m > 0 ? `${h}h ${m}m` : `${h}h`;
}
async function refreshQuota() {
if (!accessToken) return;
const tracker = document.getElementById("usage-tracker");
try {
const res = await fetch("https://huggingface.co/api/spaces/zero-gpu/quota", {
headers: { Authorization: `Bearer ${accessToken}` }
});
if (!res.ok) throw new Error(`quota ${res.status}`);
const q = await res.json();
lastQuota = q;
const base = q.base || 0;
const remaining = Math.max(0, q.current || 0);
const secUsed = Math.max(0, base - remaining);
const runs = q.runs;
const fill = document.getElementById("usage-fill");
const value = document.getElementById("usage-value");
const sub = document.getElementById("usage-sub");
if (runs && runs.limit > 0) {
// Free tier: the daily execution count is the binding limit — headline it.
const pct = Math.min((runs.used / runs.limit) * 100, 100);
fill.style.width = `${pct}%`;
fill.className = "usage-fill" + (pct >= 100 ? " danger" : pct > 60 ? " warning" : "");
value.textContent = `${Math.round(runs.used)}/${Math.round(runs.limit)} runs`;
let line = `${Math.max(0, Math.round(runs.remaining))} free runs left · ${fmtDuration(remaining)} GPU`;
const resetIn = fmtResetsIn(runs.resetsAt);
if (resetIn) line += ` · resets in ${resetIn}`;
sub.textContent = line;
} else {
// Pro / paid: GPU-seconds are the only limit.
const pct = base > 0 ? Math.min((secUsed / base) * 100, 100) : 0;
fill.style.width = `${pct}%`;
fill.className = "usage-fill" + (pct > 80 ? " danger" : pct > 50 ? " warning" : "");
value.textContent = `${fmtDuration(remaining)} left`;
let line = `${fmtDuration(secUsed)} of ${fmtDuration(base)} ${isPro ? "Pro" : ""} GPU quota`.replace(/\s+/g, " ");
if (q.overquotaUsed > 0) line += ` · +${fmtDuration(q.overquotaUsed)} over`;
const resetIn = fmtResetsIn(q.resetsAt);
if (resetIn) line += ` · resets in ${resetIn}`;
sub.textContent = line;
}
if (tracker) tracker.style.display = "";
} catch (err) {
if (tracker) tracker.style.display = "none";
}
}
// ── Smart Error CTAs ──
// Build the body of a status bar for an error. The ZeroGPU backend sends a
// misleading seconds-based message to free users whose real blocker is the
// daily execution count, and wrongly offers paid GPU credits (Pro-only).
// We reframe using the real quota data (lastQuota.runs) and account tier.
function quotaErrorBodyHtml(errMsg) {
if (!errMsg.includes("GPU quota")) {
return `<div class="status-text">${errMsg}</div>`;
}
// Guest hit the anonymous single-run quota — offer a free HF sign-in, which
// unlocks the full free daily ZeroGPU quota. No paid upsell for guests.
if (!accessToken) {
return `<div class="status-text">You've used all 3 free guest runs. Sign in with Hugging Face to keep going — it's free.</div>
<button class="status-cta upgrade" type="button" onclick="login()">🤗 Sign in with Hugging Face to continue for free</button>
<div style="font-size:11px;color:var(--text-muted);margin-top:2px">A free HF account unlocks the full daily ZeroGPU quota</div>`;
}
const runs = lastQuota && lastQuota.runs;
// Free tier hit the daily execution limit (runs present and exhausted).
if (runs && runs.limit > 0 && runs.remaining <= 0) {
const limit = Math.round(runs.limit);
const resetIn = fmtResetsIn(runs.resetsAt);
const resetPhrase = resetIn
? `Come back in ${resetIn.replace(" ", "")} for a reset, or...`
: `Come back tomorrow for a reset, or...`;
return `<div class="status-text">You've used all ${limit} free ZeroGPU runs for today. ${resetPhrase}</div>
<a class="status-cta upgrade" href="https://huggingface.co/subscribe/pro?from=ZeroGPU" target="_blank">🤗 Subscribe to HF PRO to get 40 min/day of execution quota</a>
<div style="font-size:13px;font-weight:700;color:var(--accent);margin-top:4px">That's 1,000+ voice generations a day</div>`;
}
// Free tier with runs left, or unknown tier: ran out of GPU minutes.
if (!isPro) {
return `<div class="status-text">You've used your free daily ZeroGPU minutes.</div>
<a class="status-cta upgrade" href="https://huggingface.co/subscribe/pro?from=ZeroGPU" target="_blank">🤗 Subscribe to HF PRO to get 40 min/day of execution quota</a>
<div style="font-size:13px;font-weight:700;color:var(--accent);margin-top:4px">That's 1,000+ voice generations a day</div>
<div style="font-size:11px;color:var(--text-muted);margin-top:2px">Resets every 24h</div>`;
}
// Pro / paid: real seconds message + credits CTA (credits are Pro-only).
let html = `<div class="status-text">${errMsg}</div>
<a class="status-cta credits" href="https://huggingface.co/settings/billing" target="_blank">Buy additional GPU credits &mdash; $1 per 10 min</a>
<div style="font-size:13px;font-weight:700;color:var(--accent);margin-top:4px">That's 250+ voice generations per $1</div>`;
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>`;
}
return html;
}
async function showStatusWithCTA(errorMsg) {
const el = document.getElementById("status");
el.className = "status-bar visible error";
el.innerHTML = quotaErrorBodyHtml(errorMsg);
if (errorMsg.includes("GPU quota")) { await refreshQuota(); el.innerHTML = quotaErrorBodyHtml(errorMsg); }
}
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;
resetRecorder();
setVoiceSourceMode("upload");
};
window.closeModal = function() {
document.getElementById("add-voice-modal").classList.remove("visible");
resetRecorder();
};
// ── Mic recording (alternative to upload) ──
let mediaRecorder = null;
let mediaStream = null;
let recordedChunks = [];
let recordTimerInterval = null;
let recordStartTime = 0;
function resetRecorder() {
if (mediaRecorder && mediaRecorder.state !== "inactive") {
try { mediaRecorder.stop(); } catch {}
}
if (mediaStream) {
mediaStream.getTracks().forEach(t => t.stop());
mediaStream = null;
}
if (recordTimerInterval) { clearInterval(recordTimerInterval); recordTimerInterval = null; }
mediaRecorder = null;
recordedChunks = [];
const btn = document.getElementById("record-mic-btn");
if (btn) btn.classList.remove("recording");
const preview = document.getElementById("record-preview");
if (preview) { preview.removeAttribute("src"); preview.style.display = "none"; }
const timer = document.getElementById("record-timer");
if (timer) timer.textContent = "0:00";
const hint = document.getElementById("record-hint");
if (hint) hint.textContent = "Click to record · 5-30s recommended";
}
window.setVoiceSourceMode = function(mode, openDialog) {
document.querySelectorAll(".audio-source-tab").forEach(t => t.classList.toggle("active", t.dataset.mode === mode));
document.getElementById("modal-upload").style.display = mode === "upload" ? "" : "none";
document.getElementById("modal-record").style.display = mode === "record" ? "" : "none";
if (mode === "upload" && openDialog) document.getElementById("modal-file-input").click();
};
function pickRecorderMimeType() {
const candidates = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus", "audio/ogg"];
for (const t of candidates) {
if (typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported && MediaRecorder.isTypeSupported(t)) return t;
}
return "";
}
// Decode any recorded blob and re-encode as 16-bit PCM WAV so soundfile-based
// upstreams (resemble-enhance, etc.) can read it.
async function recordedBlobToWavFile(blob, baseName) {
const Ctx = window.AudioContext || window.webkitAudioContext;
const ctx = new Ctx();
const arr = await blob.arrayBuffer();
const audioBuf = await ctx.decodeAudioData(arr.slice(0));
ctx.close?.();
const numCh = audioBuf.numberOfChannels;
const sampleRate = audioBuf.sampleRate;
const numFrames = audioBuf.length;
const dataLen = numFrames * numCh * 2;
const buf = new ArrayBuffer(44 + dataLen);
const view = new DataView(buf);
let p = 0;
const w8 = s => { for (let i = 0; i < s.length; i++) view.setUint8(p++, s.charCodeAt(i)); };
const w32 = v => { view.setUint32(p, v, true); p += 4; };
const w16 = v => { view.setUint16(p, v, true); p += 2; };
w8("RIFF"); w32(36 + dataLen); w8("WAVE");
w8("fmt "); w32(16); w16(1); w16(numCh); w32(sampleRate);
w32(sampleRate * numCh * 2); w16(numCh * 2); w16(16);
w8("data"); w32(dataLen);
const channels = [];
for (let c = 0; c < numCh; c++) channels.push(audioBuf.getChannelData(c));
for (let i = 0; i < numFrames; i++) {
for (let c = 0; c < numCh; c++) {
const s = Math.max(-1, Math.min(1, channels[c][i]));
view.setInt16(p, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
p += 2;
}
}
return new File([buf], `${baseName}.wav`, { type: "audio/wav" });
}
window.toggleRecord = async function() {
const btn = document.getElementById("record-mic-btn");
const hint = document.getElementById("record-hint");
if (mediaRecorder && mediaRecorder.state === "recording") {
mediaRecorder.stop();
return;
}
// start
if (!navigator.mediaDevices?.getUserMedia) {
hint.textContent = "Recording not supported in this browser";
return;
}
try {
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch (e) {
hint.textContent = "Microphone access denied";
return;
}
const mimeType = pickRecorderMimeType();
mediaRecorder = mimeType ? new MediaRecorder(mediaStream, { mimeType }) : new MediaRecorder(mediaStream);
recordedChunks = [];
mediaRecorder.ondataavailable = e => { if (e.data && e.data.size) recordedChunks.push(e.data); };
mediaRecorder.onstop = async () => {
clearInterval(recordTimerInterval);
btn.classList.remove("recording");
const elapsedSec = (Date.now() - recordStartTime) / 1000;
const type = mediaRecorder.mimeType || "audio/webm";
const rawBlob = new Blob(recordedChunks, { type });
hint.textContent = "Converting…";
try {
const wavFile = await recordedBlobToWavFile(rawBlob, `recording_${Date.now()}`);
const wavBuf = await wavFile.arrayBuffer();
modalFileData = { name: wavFile.name, data: wavBuf, type: "audio/wav" };
const preview = document.getElementById("record-preview");
preview.src = URL.createObjectURL(wavFile);
preview.style.display = "";
hint.textContent = `Recorded ${elapsedSec.toFixed(1)}s · click mic to re-record`;
checkModalSave();
} catch (err) {
hint.textContent = `Recording failed: ${err.message}`;
}
if (mediaStream) { mediaStream.getTracks().forEach(t => t.stop()); mediaStream = null; }
};
recordStartTime = Date.now();
recordTimerInterval = setInterval(() => {
const e = Math.floor((Date.now() - recordStartTime) / 1000);
document.getElementById("record-timer").textContent = `${Math.floor(e/60)}:${(e%60).toString().padStart(2,"0")}`;
}, 200);
hint.textContent = "Recording… click again to stop";
btn.classList.add("recording");
mediaRecorder.start();
};
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();
const t0 = Date.now();
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 = "75%"; }
else if (data.msg === "log") showDesignStatus("info", data.log);
else if (data.msg === "process_completed") {
progressFill.style.width = "100%";
if (data.success) {
const wallSec = (Date.now() - t0) / 1000;
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 ? "..." : "");
setPlayerMeta("design", "Voice Design", `${text.length} chars`);
result.classList.add("visible");
showDesignStatus("success", `Generated in ${wallSec.toFixed(1)}s`);
addToHistory(text, "Voice Design", wallSec, designAudioUrl);
refreshQuota();
} 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>`;
}
async function showDesignStatusWithCTA(errMsg) {
const el = document.getElementById("design-status");
el.className = "status-bar visible error";
el.innerHTML = quotaErrorBodyHtml(errMsg);
if (errMsg.includes("GPU quota")) { await refreshQuota(); el.innerHTML = quotaErrorBodyHtml(errMsg); }
}
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;
}
attachAudioInput("transcribe", handleTranscribeFile);
window.transcribe = async function() {
if (!transcribeFile) return;
const lang = document.getElementById("transcribe-lang").value;
const btn = transcribeBtn;
const result = document.getElementById("transcribe-result");
const t0 = Date.now();
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];
const wallSec = (Date.now() - t0) / 1000;
document.getElementById("transcribe-text").textContent = transcript;
result.style.display = "block";
showTranscribeStatus("success", `Transcribed in ${wallSec.toFixed(1)}s`);
refreshQuota();
addToHistory(transcript.slice(0, 120) + (transcript.length > 120 ? "…" : ""), `Transcribe (${transcribeFile.name})`, wallSec, null);
} 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>`;
}
async function showTranscribeStatusWithCTA(errMsg) {
const el = document.getElementById("transcribe-status");
el.className = "status-bar visible error";
el.innerHTML = quotaErrorBodyHtml(errMsg);
if (errMsg.includes("GPU quota")) { await refreshQuota(); el.innerHTML = quotaErrorBodyHtml(errMsg); }
}
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");
}
// No valid login — open the app in guest mode. Generations run against the
// anonymous ZeroGPU quota; hitting it prompts a free HF sign-in.
showApp();
}
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" });
};
window.toggleUserMenu = function(e) {
e.stopPropagation();
document.getElementById("user-menu").classList.toggle("visible");
};
window.logout = function() {
sessionStorage.removeItem("zl_token");
sessionStorage.removeItem("zl_user");
sessionStorage.removeItem("zl_expires");
// Clean OAuth params from URL if present, then reload
const url = new URL(window.location.href);
["code", "state", "oauth_redirect", "id_token", "access_token"].forEach(k => url.searchParams.delete(k));
window.location.replace(url.toString().replace(/\?$/, ""));
};
document.addEventListener("click", () => {
document.getElementById("user-menu")?.classList.remove("visible");
});
// Reflect the current auth state across the header pill and the sidebar.
// Guests see a "Sign in" button (top-right) and a sign-in CTA (bottom-left)
// in place of the personalized user pill and live quota tracker.
function updateAuthUI() {
const loggedIn = !!accessToken;
const pill = document.getElementById("user-pill");
const guestBtn = document.getElementById("header-signin");
const tracker = document.getElementById("usage-tracker");
const guestCta = document.getElementById("guest-cta");
if (loggedIn) {
if (pill) pill.style.display = "";
if (guestBtn) guestBtn.style.display = "none";
if (guestCta) guestCta.style.display = "none";
if (userInfo) {
document.getElementById("user-avatar").src = userInfo.avatarUrl;
document.getElementById("user-name").textContent = userInfo.name;
}
// usage-tracker visibility is managed by refreshQuota()
} else {
if (pill) pill.style.display = "none";
if (guestBtn) guestBtn.style.display = "";
if (tracker) tracker.style.display = "none";
if (guestCta) guestCta.style.display = "";
}
}
function showApp() {
document.getElementById("landing").style.display = "none";
document.getElementById("app").style.display = "grid";
updateAuthUI();
renderSidebarVoices();
renderPresets();
renderVoicesPage();
renderHistory();
initSynthDoc();
refreshQuota();
if (quotaTimer) clearInterval(quotaTimer);
quotaTimer = setInterval(refreshQuota, 60000);
}
// ── 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();
if (page === "synthesis") initSynthDoc();
};
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);
});
};
// ── API page: snippets ──
const API_SNIPPETS = {
synthesis: {
javascript: `// Voice Generation — k2-fsa/OmniVoice
// Auth: set TOKEN to your HF token from https://huggingface.co/settings/tokens
const TOKEN = process.env.HF_TOKEN;
const BASE = "https://k2-fsa-omnivoice.hf.space/gradio_api";
const SESSION = crypto.randomUUID();
// 1. Upload reference voice
const form = new FormData();
form.append("files", audioFile);
const [path] = await fetch(\`\${BASE}/upload\`, {
method: "POST",
headers: { Authorization: \`Bearer \${TOKEN}\` },
body: form
}).then(r => r.json());
// 2. Join queue
await fetch(\`\${BASE}/queue/join\`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: \`Bearer \${TOKEN}\` },
body: JSON.stringify({
data: ["Hello world", "English",
{ path, meta: { _type: "gradio.FileData" }, orig_name: "voice.mp3" },
"", "", 32, 2.0, true, 1.0, 0, true, true],
fn_index: 0, session_hash: SESSION })
});
// 3. Stream SSE result
const r = await fetch(\`\${BASE}/queue/data?session_hash=\${SESSION}\`,
{ headers: { Authorization: \`Bearer \${TOKEN}\` } });`,
python: `# Voice Generation — k2-fsa/OmniVoice
# Auth: run \`huggingface-cli login\` or set HF_TOKEN env var
from gradio_client import Client, handle_file
client = Client("k2-fsa/OmniVoice")
audio_path, status = client.predict(
text="Hello world",
lang="English",
ref_aud=handle_file("voice.mp3"),
ref_text="", instruct="",
ns=32, gs=2.0, dn=True,
sp=1.0, du=0, pp=True, po=True,
api_name="/_clone_fn",
)
print(audio_path)`,
curl: `# Voice Generation — k2-fsa/OmniVoice
# Auth: get a token at https://huggingface.co/settings/tokens
export HF_TOKEN=hf_xxx
BASE=https://k2-fsa-omnivoice.hf.space/gradio_api
# 1. Upload reference voice
UPLOAD_PATH=$(curl -s -X POST "$BASE/upload" \\
-H "Authorization: Bearer $HF_TOKEN" \\
-F "files=@voice.mp3" | jq -r '.[0]')
# 2. Join queue
SESSION=$(uuidgen)
curl -s -X POST "$BASE/queue/join" \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer $HF_TOKEN" \\
-d "{\\"data\\":[\\"Hello world\\",\\"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\\"}"
# 3. Stream result (SSE)
curl -N "$BASE/queue/data?session_hash=$SESSION" \\
-H "Authorization: Bearer $HF_TOKEN"`
},
design: {
javascript: `// Voice Design — Qwen/Qwen3-TTS
const TOKEN = process.env.HF_TOKEN;
const BASE = "https://qwen-qwen3-tts.hf.space/gradio_api";
const SESSION = crypto.randomUUID();
await fetch(\`\${BASE}/queue/join\`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: \`Bearer \${TOKEN}\` },
body: JSON.stringify({
data: ["Hello world", "English", "warm friendly tone, deep male voice"],
fn_index: 0, session_hash: SESSION })
});
const r = await fetch(\`\${BASE}/queue/data?session_hash=\${SESSION}\`,
{ headers: { Authorization: \`Bearer \${TOKEN}\` } });`,
python: `# Voice Design — Qwen/Qwen3-TTS
from gradio_client import Client
client = Client("Qwen/Qwen3-TTS")
audio_path = client.predict(
text="Hello world",
lang="English",
voice_description="warm friendly tone, deep male voice",
fn_index=0,
)
print(audio_path)`,
curl: `# Voice Design — Qwen/Qwen3-TTS
export HF_TOKEN=hf_xxx
BASE=https://qwen-qwen3-tts.hf.space/gradio_api
SESSION=$(uuidgen)
curl -s -X POST "$BASE/queue/join" \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer $HF_TOKEN" \\
-d "{\\"data\\":[\\"Hello world\\",\\"English\\",\\"warm friendly tone, deep male voice\\"],\\"fn_index\\":0,\\"session_hash\\":\\"$SESSION\\"}"
curl -N "$BASE/queue/data?session_hash=$SESSION" \\
-H "Authorization: Bearer $HF_TOKEN"`
},
changer: {
javascript: `// Voice Changer — Plachta/Seed-VC
const TOKEN = process.env.HF_TOKEN;
const BASE = "https://plachta-seed-vc.hf.space/gradio_api";
const SESSION = crypto.randomUUID();
// Upload source and target voices
async function up(file) {
const f = new FormData(); f.append("files", file);
const [p] = await fetch(\`\${BASE}/upload\`, {
method: "POST", headers: { Authorization: \`Bearer \${TOKEN}\` }, body: f
}).then(r => r.json());
return { path: p, meta: { _type: "gradio.FileData" }, orig_name: file.name };
}
const src = await up(sourceFile);
const tgt = await up(targetFile);
await fetch(\`\${BASE}/queue/join\`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: \`Bearer \${TOKEN}\` },
body: JSON.stringify({
data: [src, tgt, 10, 1.0, 0.0, 0.7, 0.9, 1.0, 1.0, false, false],
fn_index: 3, session_hash: SESSION })
});
// Stream and read data[1] (full audio output)
const r = await fetch(\`\${BASE}/queue/data?session_hash=\${SESSION}\`,
{ headers: { Authorization: \`Bearer \${TOKEN}\` } });`,
python: `# Voice Changer — Plachta/Seed-VC
from gradio_client import Client, handle_file
client = Client("Plachta/Seed-VC", download_files=False)
stream_audio, full_audio = client.predict(
source_audio_path=handle_file("source.wav"),
target_audio_path=handle_file("target.wav"),
diffusion_steps=10,
length_adjust=1.0,
intelligebility_cfg_rate=0.0,
similarity_cfg_rate=0.7,
top_p=0.9,
temperature=1.0,
repetition_penalty=1.0,
convert_style=False,
anonymization_only=False,
api_name="/predict",
)
print(full_audio["url"]) # full WAV; stream_audio is HLS, usually ignore`,
curl: `# Voice Changer — Plachta/Seed-VC
export HF_TOKEN=hf_xxx
BASE=https://plachta-seed-vc.hf.space/gradio_api
SRC=$(curl -s -X POST "$BASE/upload" -H "Authorization: Bearer $HF_TOKEN" -F "files=@source.wav" | jq -r '.[0]')
TGT=$(curl -s -X POST "$BASE/upload" -H "Authorization: Bearer $HF_TOKEN" -F "files=@target.wav" | jq -r '.[0]')
SESSION=$(uuidgen)
curl -s -X POST "$BASE/queue/join" \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer $HF_TOKEN" \\
-d "{\\"data\\":[{\\"path\\":\\"$SRC\\",\\"meta\\":{\\"_type\\":\\"gradio.FileData\\"},\\"orig_name\\":\\"source.wav\\"},{\\"path\\":\\"$TGT\\",\\"meta\\":{\\"_type\\":\\"gradio.FileData\\"},\\"orig_name\\":\\"target.wav\\"},10,1.0,0.0,0.7,0.9,1.0,1.0,false,false],\\"fn_index\\":3,\\"session_hash\\":\\"$SESSION\\"}"
curl -N "$BASE/queue/data?session_hash=$SESSION" \\
-H "Authorization: Bearer $HF_TOKEN"`
},
isolate: {
javascript: `// Voice Cleaner — multimodalart/resemble-enhance-zerogpu
const TOKEN = process.env.HF_TOKEN;
const BASE = "https://multimodalart-resemble-enhance-zerogpu.hf.space/gradio_api";
const SESSION = crypto.randomUUID();
const form = new FormData();
form.append("files", audioFile);
const [path] = await fetch(\`\${BASE}/upload\`, {
method: "POST",
headers: { Authorization: \`Bearer \${TOKEN}\` },
body: form
}).then(r => r.json());
await fetch(\`\${BASE}/queue/join\`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: \`Bearer \${TOKEN}\` },
body: JSON.stringify({
data: [
{ path, meta: { _type: "gradio.FileData" }, orig_name: "noisy.wav" },
"Midpoint", 64, 0.5, false
],
fn_index: 2, session_hash: SESSION })
});
// data[0] = denoised, data[1] = enhanced
const r = await fetch(\`\${BASE}/queue/data?session_hash=\${SESSION}\`,
{ headers: { Authorization: \`Bearer \${TOKEN}\` } });`,
python: `# Voice Cleaner — multimodalart/resemble-enhance-zerogpu
from gradio_client import Client, handle_file
client = Client("multimodalart/resemble-enhance-zerogpu")
denoised, enhanced = client.predict(
path=handle_file("noisy.wav"),
solver="Midpoint",
nfe=64,
tau=0.5,
denoising=False,
api_name="/predict",
)
print(enhanced)`,
curl: `# Voice Cleaner — multimodalart/resemble-enhance-zerogpu
export HF_TOKEN=hf_xxx
BASE=https://multimodalart-resemble-enhance-zerogpu.hf.space/gradio_api
UPLOAD=$(curl -s -X POST "$BASE/upload" \\
-H "Authorization: Bearer $HF_TOKEN" \\
-F "files=@noisy.wav" | jq -r '.[0]')
SESSION=$(uuidgen)
curl -s -X POST "$BASE/queue/join" \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer $HF_TOKEN" \\
-d "{\\"data\\":[{\\"path\\":\\"$UPLOAD\\",\\"meta\\":{\\"_type\\":\\"gradio.FileData\\"},\\"orig_name\\":\\"noisy.wav\\"},\\"Midpoint\\",64,0.5,false],\\"fn_index\\":2,\\"session_hash\\":\\"$SESSION\\"}"
curl -N "$BASE/queue/data?session_hash=$SESSION" \\
-H "Authorization: Bearer $HF_TOKEN"`
},
sfx: {
javascript: `// Sound Effects — stabilityai/stable-audio-3
const TOKEN = process.env.HF_TOKEN;
const BASE = "https://stabilityai-stable-audio-3.hf.space/gradio_api";
const SESSION = crypto.randomUUID();
await fetch(\`\${BASE}/queue/join\`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: \`Bearer \${TOKEN}\` },
body: JSON.stringify({
// variant_key, prompt, duration, steps, cfg_scale, sampler_type, seed
data: ["medium", "dog barking", 5, 8, 1.0, "pingpong", 0],
fn_index: 3, session_hash: SESSION })
});
const r = await fetch(\`\${BASE}/queue/data?session_hash=\${SESSION}\`,
{ headers: { Authorization: \`Bearer \${TOKEN}\` } });`,
python: `# Sound Effects — stabilityai/stable-audio-3
from gradio_client import Client
client = Client("stabilityai/stable-audio-3")
audio_path = client.predict(
variant_key="medium",
prompt="dog barking",
duration=5,
steps=8,
cfg_scale=1.0,
sampler_type="pingpong",
seed=0,
api_name="/infer",
)
print(audio_path)`,
curl: `# Sound Effects — stabilityai/stable-audio-3
export HF_TOKEN=hf_xxx
BASE=https://stabilityai-stable-audio-3.hf.space/gradio_api
SESSION=$(uuidgen)
curl -s -X POST "$BASE/queue/join" \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer $HF_TOKEN" \\
-d "{\\"data\\":[\\"medium\\",\\"dog barking\\",5,8,1.0,\\"pingpong\\",0],\\"fn_index\\":3,\\"session_hash\\":\\"$SESSION\\"}"
curl -N "$BASE/queue/data?session_hash=$SESSION" \\
-H "Authorization: Bearer $HF_TOKEN"`
},
transcribe: {
javascript: `// Transcribe — CohereLabs/cohere-transcribe-03-2026
const TOKEN = process.env.HF_TOKEN;
const BASE = "https://coherelabs-cohere-transcribe-03-2026.hf.space/gradio_api";
const SESSION = crypto.randomUUID();
const form = new FormData();
form.append("files", audioFile);
const [path] = await fetch(\`\${BASE}/upload\`, {
method: "POST",
headers: { Authorization: \`Bearer \${TOKEN}\` },
body: form
}).then(r => r.json());
await fetch(\`\${BASE}/queue/join\`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: \`Bearer \${TOKEN}\` },
body: JSON.stringify({
data: [
{ path, meta: { _type: "gradio.FileData" }, orig_name: "audio.mp3" },
"en"
],
fn_index: 2, session_hash: SESSION })
});
const r = await fetch(\`\${BASE}/queue/data?session_hash=\${SESSION}\`,
{ headers: { Authorization: \`Bearer \${TOKEN}\` } });`,
python: `# Transcribe — CohereLabs/cohere-transcribe-03-2026
from gradio_client import Client, handle_file
client = Client("CohereLabs/cohere-transcribe-03-2026")
transcript = client.predict(
audio=handle_file("audio.mp3"),
language="en",
fn_index=2,
)
print(transcript)`,
curl: `# Transcribe — CohereLabs/cohere-transcribe-03-2026
export HF_TOKEN=hf_xxx
BASE=https://coherelabs-cohere-transcribe-03-2026.hf.space/gradio_api
UPLOAD=$(curl -s -X POST "$BASE/upload" \\
-H "Authorization: Bearer $HF_TOKEN" \\
-F "files=@audio.mp3" | jq -r '.[0]')
SESSION=$(uuidgen)
curl -s -X POST "$BASE/queue/join" \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer $HF_TOKEN" \\
-d "{\\"data\\":[{\\"path\\":\\"$UPLOAD\\",\\"meta\\":{\\"_type\\":\\"gradio.FileData\\"},\\"orig_name\\":\\"audio.mp3\\"},\\"en\\"],\\"fn_index\\":2,\\"session_hash\\":\\"$SESSION\\"}"
curl -N "$BASE/queue/data?session_hash=$SESSION" \\
-H "Authorization: Bearer $HF_TOKEN"`
}
};
let currentApiLang = "javascript";
function renderApiCode() {
const feature = document.getElementById("api-feature").value;
document.getElementById("api-code").textContent = API_SNIPPETS[feature][currentApiLang];
}
function syncMainApiTabs() {
document.querySelectorAll("#api-main-tabs .api-tab").forEach(t => t.classList.toggle("active", t.dataset.lang === currentApiLang));
}
window.showApiLang = function(lang) {
currentApiLang = lang;
syncMainApiTabs();
renderApiCode();
};
document.getElementById("api-feature").addEventListener("change", renderApiCode);
renderApiCode();
// ── Per-page inline API panels ──
const API_PANEL_TABS_HTML = `
<div class="agent-tip" style="margin-bottom:12px">
<strong>Use with AI agents</strong> — Paste this snippet into Claude Code, Cursor, Windsurf, or any AI agent. 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.
</div>
<div class="api-tabs">
<button class="api-tab" type="button" data-lang="javascript" onclick="showInlineApiLang('javascript', this)">JavaScript</button>
<button class="api-tab" type="button" data-lang="python" onclick="showInlineApiLang('python', this)">Python</button>
<button class="api-tab" type="button" data-lang="curl" onclick="showInlineApiLang('curl', this)">curl</button>
</div>
<div 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></code></pre>
</div>`;
function paintInlinePanel(panel) {
const feature = panel.dataset.feature;
panel.querySelectorAll(".api-tab").forEach(t => t.classList.toggle("active", t.dataset.lang === currentApiLang));
panel.querySelector(".code-block code").textContent = API_SNIPPETS[feature][currentApiLang];
}
window.togglePageApi = function(feature, btn) {
const header = btn.closest(".page-header");
let panel = header.nextElementSibling;
if (!panel || !panel.classList.contains("page-api-panel")) {
panel = document.createElement("div");
panel.className = "page-api-panel";
panel.dataset.feature = feature;
panel.innerHTML = API_PANEL_TABS_HTML;
panel.hidden = true;
header.insertAdjacentElement("afterend", panel);
}
const label = btn.querySelector(".page-api-label") || btn;
if (panel.hidden) {
paintInlinePanel(panel);
panel.hidden = false;
btn.classList.add("open");
label.textContent = "Hide API";
} else {
panel.hidden = true;
btn.classList.remove("open");
label.textContent = "View API";
}
};
window.showInlineApiLang = function(lang, btn) {
currentApiLang = lang;
paintInlinePanel(btn.closest(".page-api-panel"));
syncMainApiTabs();
renderApiCode();
};
// ── Shared gradio helpers (for Voice Changer / Cleaner / SFX) ──
const SEED_VC_BASE = "https://plachta-seed-vc.hf.space/gradio_api";
const RESEMBLE_BASE = "https://multimodalart-resemble-enhance-zerogpu.hf.space/gradio_api";
const STABLE_AUDIO_BASE = "https://stabilityai-stable-audio-3.hf.space/gradio_api";
const resultUrls = {};
function showStatusOn(elId, type, msg) {
const el = document.getElementById(elId);
el.className = `status-bar visible ${type}`;
el.innerHTML = `<div class="status-text">${msg}</div>`;
}
async function showStatusCtaOn(elId, errMsg) {
const el = document.getElementById(elId);
el.className = "status-bar visible error";
el.innerHTML = quotaErrorBodyHtml(errMsg);
if (errMsg.includes("GPU quota")) { await refreshQuota(); el.innerHTML = quotaErrorBodyHtml(errMsg); }
}
function wireUploadArea(areaId, inputId, labelId, onFile) {
const area = document.getElementById(areaId);
const inp = document.getElementById(inputId);
area.addEventListener("click", e => { if (e.target.tagName !== "INPUT") inp.click(); });
area.addEventListener("dragover", e => { e.preventDefault(); area.classList.add("dragover"); });
area.addEventListener("dragleave", () => area.classList.remove("dragover"));
area.addEventListener("drop", e => {
e.preventDefault();
area.classList.remove("dragover");
if (e.dataTransfer.files[0]) { onFile(e.dataTransfer.files[0]); document.getElementById(labelId).textContent = e.dataTransfer.files[0].name; area.classList.add("has-file"); }
});
inp.addEventListener("change", () => {
if (inp.files[0]) { onFile(inp.files[0]); document.getElementById(labelId).textContent = inp.files[0].name; area.classList.add("has-file"); }
});
}
// ── Per-input audio recording (mic alternative on every audio upload) ──
// (state vars hoisted to top of module — see top of script)
window.setInputMode = function(prefix, mode, openDialog) {
document.querySelectorAll(`.audio-source-tabs[data-input-prefix="${prefix}"] .audio-source-tab`)
.forEach(t => t.classList.toggle("active", t.dataset.mode === mode));
const upload = document.getElementById(`${prefix}-upload`);
if (upload) upload.style.display = mode === "upload" ? "" : "none";
const recordPane = document.getElementById(`${prefix}-record-pane`);
if (recordPane) recordPane.style.display = mode === "record" ? "" : "none";
const libraryPane = document.getElementById(`${prefix}-library-pane`);
if (libraryPane) {
libraryPane.style.display = mode === "library" ? "" : "none";
if (mode === "library" && prefix === "vc-target") renderVcTargetLibrary();
}
if (mode === "upload" && openDialog) upload?.click(); // open the file picker on tab click
};
async function renderVcTargetLibrary() {
const list = document.getElementById("vc-target-library-list");
if (!list) return;
const voices = await getVoices();
const usedPresetNames = new Set(voices.filter(v => v.isPreset).map(v => v.fileName.replace(/\.wav$/, "")));
const availablePresets = PRESETS.filter(p => !usedPresetNames.has(p.name));
const userHtml = voices.length ? voices.map(v => `
<div class="lib-item" data-id="${v.id}" onclick="pickVcTargetVoice('${v.id}')">
<div class="voice-avatar" style="background:${v.color}">${v.name[0].toUpperCase()}</div>
<div class="lib-item-meta">
<div class="lib-item-name">${v.name}</div>
<div class="lib-item-tag">${v.isPreset ? "Preset" : "Your voice"}</div>
</div>
</div>`).join("") : "";
const presetHtml = availablePresets.map(p => `
<div class="lib-item" data-id="preset_${p.name}" onclick="pickVcTargetPreset('${p.name}')">
<div class="voice-avatar" style="background:#d97706">${p.name[0]}</div>
<div class="lib-item-meta">
<div class="lib-item-name">${p.name.replace("_", " ")}</div>
<div class="lib-item-tag">${p.tag}</div>
</div>
</div>`).join("");
list.innerHTML = userHtml + presetHtml || `<div class="lib-empty">No voices yet — add one in Voice Generation or Voice Design.</div>`;
}
function markVcTargetSelected(id) {
document.querySelectorAll("#vc-target-library-list .lib-item").forEach(el => el.classList.toggle("selected", el.dataset.id === id));
}
window.pickVcTargetVoice = async function(id) {
const voices = await getVoices();
const v = voices.find(x => x.id === id);
if (!v) return;
const blob = new Blob([v.audioData], { type: v.audioType || "audio/wav" });
vcTarget = new File([blob], v.fileName || `${v.name}.wav`, { type: blob.type });
document.getElementById("vc-target-label").textContent = v.name;
document.getElementById("vc-target-upload").classList.add("has-file");
markVcTargetSelected(id);
updateVcBtn();
};
window.pickVcTargetPreset = async function(name) {
const res = await fetch(`samples/${name}.wav`);
const blob = await res.blob();
vcTarget = new File([blob], `${name}.wav`, { type: blob.type || "audio/wav" });
document.getElementById("vc-target-label").textContent = name.replace("_", " ");
document.getElementById("vc-target-upload").classList.add("has-file");
markVcTargetSelected(`preset_${name}`);
updateVcBtn();
};
window.toggleInputRecord = async function(prefix) {
const btn = document.getElementById(`${prefix}-record-btn`);
const hint = document.getElementById(`${prefix}-hint`);
const r = inputRecorders[prefix];
if (r && r.mediaRecorder && r.mediaRecorder.state === "recording") {
r.mediaRecorder.stop();
return;
}
if (!navigator.mediaDevices?.getUserMedia) { hint.textContent = "Recording not supported in this browser"; return; }
let stream;
try { stream = await navigator.mediaDevices.getUserMedia({ audio: true }); }
catch { hint.textContent = "Microphone access denied"; return; }
const mimeType = pickRecorderMimeType();
const mr = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream);
const chunks = [];
mr.ondataavailable = e => { if (e.data?.size) chunks.push(e.data); };
const startTime = Date.now();
const timerInterval = setInterval(() => {
const e = Math.floor((Date.now() - startTime) / 1000);
document.getElementById(`${prefix}-timer`).textContent = `${Math.floor(e/60)}:${(e%60).toString().padStart(2,"0")}`;
}, 200);
inputRecorders[prefix] = { mediaRecorder: mr, stream, chunks, timerInterval, startTime };
mr.onstop = async () => {
clearInterval(timerInterval);
btn.classList.remove("recording");
const elapsedSec = (Date.now() - startTime) / 1000;
const type = mr.mimeType || "audio/webm";
const rawBlob = new Blob(chunks, { type });
hint.textContent = "Converting…";
try {
const file = await recordedBlobToWavFile(rawBlob, `recording_${Date.now()}`);
const preview = document.getElementById(`${prefix}-preview-audio`);
preview.src = URL.createObjectURL(file);
preview.style.display = "";
hint.textContent = `Recorded ${elapsedSec.toFixed(1)}s · click mic to re-record`;
inputRecordHandlers[prefix]?.(file);
} catch (err) {
hint.textContent = `Recording failed: ${err.message}`;
} finally {
stream.getTracks().forEach(t => t.stop());
delete inputRecorders[prefix];
}
};
hint.textContent = "Recording… click again to stop";
btn.classList.add("recording");
mr.start();
};
// Attach an Upload/Record toggle around an existing .upload-area
function attachAudioInput(prefix, onFile, opts = {}) {
const uploadId = opts.uploadId || `${prefix}-upload`;
const area = document.getElementById(uploadId);
if (!area) return;
inputRecordHandlers[prefix] = onFile;
const extraTab = opts.includeLibrary ? `
<button class="audio-source-tab" data-mode="library" type="button" onclick="setInputMode('${prefix}', 'library')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>
Library
</button>` : "";
area.insertAdjacentHTML("beforebegin", `
<div class="audio-source-tabs" data-input-prefix="${prefix}">
<button class="audio-source-tab active" data-mode="upload" type="button" onclick="setInputMode('${prefix}', 'upload', true)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Upload
</button>
<button class="audio-source-tab" data-mode="record" type="button" onclick="setInputMode('${prefix}', 'record')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0"/><line x1="12" y1="18" x2="12" y2="22"/></svg>
Record
</button>
${extraTab}
</div>
`);
area.insertAdjacentHTML("afterend", `
<div id="${prefix}-record-pane" style="display:none;margin-bottom:16px">
<div class="record-pane">
<button class="record-mic-btn" id="${prefix}-record-btn" type="button" onclick="toggleInputRecord('${prefix}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0"/><line x1="12" y1="18" x2="12" y2="22"/></svg>
</button>
<div class="record-meta">
<div class="record-timer" id="${prefix}-timer">0:00</div>
<div class="record-hint" id="${prefix}-hint">Click to record · 5-30s recommended</div>
</div>
</div>
<audio id="${prefix}-preview-audio" controls style="display:none;width:100%;margin-top:10px;border-radius:8px"></audio>
</div>
${opts.includeLibrary ? `<div id="${prefix}-library-pane" style="display:none;margin-bottom:16px"><div class="lib-list" id="${prefix}-library-list"></div></div>` : ""}
`);
}
function setupPlayer(prefix) {
const el = document.getElementById(`${prefix}-audio-player`);
el.addEventListener("play", () => {
document.getElementById(`${prefix}-play-icon`).style.display = "none";
document.getElementById(`${prefix}-pause-icon`).style.display = "block";
});
el.addEventListener("pause", () => {
document.getElementById(`${prefix}-play-icon`).style.display = "block";
document.getElementById(`${prefix}-pause-icon`).style.display = "none";
});
}
window.togglePlayer = function(prefix) {
const el = document.getElementById(`${prefix}-audio-player`);
el.paused ? el.play() : el.pause();
};
window.downloadResult = async function(prefix) {
const url = resultUrls[prefix];
if (!url) return;
const blob = await fetch(url).then(r => r.blob());
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `zerolabs_${prefix}.wav`;
a.click();
URL.revokeObjectURL(a.href);
};
// Upload a file to a Space's /upload, return a FileData ref ready for /queue/join data array
async function uploadFile(base, file) {
const form = new FormData();
form.append("files", file);
const r = await fetch(`${base}/upload`, {
method: "POST",
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : {},
body: form
});
if (!r.ok) throw new Error(`Upload failed: ${r.status}`);
const [path] = await r.json();
return { path, meta: { _type: "gradio.FileData" }, orig_name: file.name };
}
// Run a queue/join + queue/data SSE call. Returns { output, duration }.
// statusElId/progressFillId are optional and used for UI updates.
async function gradioCall({ base, fnIndex, data, statusElId, progressFillId, processingMsg }) {
const t0 = Date.now();
const session = crypto.randomUUID();
if (statusElId) showStatusOn(statusElId, "info", "Joining queue...");
const joinRes = await fetch(`${base}/queue/join`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {})
},
body: JSON.stringify({ data, fn_index: fnIndex, session_hash: session })
});
if (!joinRes.ok) throw new Error(`Queue join failed: ${joinRes.status}`);
const streamRes = await fetch(`${base}/queue/data?session_hash=${session}`, {
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 evt = JSON.parse(line.slice(6));
if (statusElId) {
if (evt.msg === "estimation") showStatusOn(statusElId, "info", `In queue (position ${evt.rank})...`);
else if (evt.msg === "process_starts") {
showStatusOn(statusElId, "info", processingMsg || "Processing...");
if (progressFillId) document.getElementById(progressFillId).style.width = "75%";
}
else if (evt.msg === "log") showStatusOn(statusElId, "info", evt.log);
else if (evt.msg === "progress" && evt.progress_data?.length) {
const p = evt.progress_data[0];
if (p.length && progressFillId) {
document.getElementById(progressFillId).style.width = `${Math.round((p.index / p.length) * 24) + 75}%`;
showStatusOn(statusElId, "info", p.desc || `Step ${p.index}/${p.length}`);
}
}
}
if (evt.msg === "process_completed") {
if (progressFillId) document.getElementById(progressFillId).style.width = "100%";
if (evt.success) {
const wallClock = (Date.now() - t0) / 1000;
return { output: evt.output, duration: evt.output.duration || wallClock, wallClock };
}
throw new Error(evt.output?.error || "Generation failed");
}
}
}
throw new Error("Stream ended without completion");
}
// Update a player-meta line with the audio's actual duration once it loads.
function setPlayerMeta(prefix, leadingText, trailingText) {
const audioId = prefix ? `${prefix}-audio-player` : "audio-player";
const metaId = prefix ? `${prefix}-player-meta` : "player-meta";
const audio = document.getElementById(audioId);
const meta = document.getElementById(metaId);
if (!audio || !meta) return;
const paint = () => {
const d = isFinite(audio.duration) && audio.duration > 0 ? `${audio.duration.toFixed(1)}s` : "";
meta.textContent = [leadingText, d, trailingText].filter(Boolean).join(" · ");
};
audio.addEventListener("loadedmetadata", paint, { once: true });
paint();
}
// Pull a URL out of a gradio Audio output (object {url} or {path} ref).
function audioUrlFromOutput(out, base) {
if (!out) return null;
if (typeof out === "string") return out;
if (out.url) return out.url;
if (out.path) return `${base}/file=${out.path}`;
return null;
}
// ── Voice Changer (Seed-VC) ──
let vcSource = null, vcTarget = null;
const vcSteps = document.getElementById("vc-steps");
const vcSim = document.getElementById("vc-sim");
vcSteps.addEventListener("input", () => document.getElementById("vc-steps-val").textContent = vcSteps.value);
vcSim.addEventListener("input", () => document.getElementById("vc-sim-val").textContent = (vcSim.value / 100).toFixed(2));
function updateVcBtn() {
document.getElementById("vc-btn").disabled = !(vcSource && vcTarget);
}
wireUploadArea("vc-source-upload", "vc-source-input", "vc-source-label", f => { vcSource = f; updateVcBtn(); });
wireUploadArea("vc-target-upload", "vc-target-input", "vc-target-label", f => { vcTarget = f; updateVcBtn(); });
attachAudioInput("vc-source", f => { vcSource = f; document.getElementById("vc-source-label").textContent = f.name; document.getElementById("vc-source-upload").classList.add("has-file"); updateVcBtn(); });
attachAudioInput("vc-target", f => { vcTarget = f; document.getElementById("vc-target-label").textContent = f.name; document.getElementById("vc-target-upload").classList.add("has-file"); updateVcBtn(); }, { includeLibrary: true });
setupPlayer("vc");
window.voiceChange = async function() {
if (!vcSource || !vcTarget) return;
const btn = document.getElementById("vc-btn");
const progressBar = document.getElementById("vc-progress-bar");
const progressFill = document.getElementById("vc-progress-fill");
const result = document.getElementById("vc-result");
btn.disabled = true; btn.classList.add("loading");
result.classList.remove("visible");
progressBar.classList.add("visible");
progressFill.style.width = "0%";
try {
showStatusOn("vc-status", "info", "Uploading audio...");
const [src, tgt] = await Promise.all([uploadFile(SEED_VC_BASE, vcSource), uploadFile(SEED_VC_BASE, vcTarget)]);
const { output, wallClock } = await gradioCall({
base: SEED_VC_BASE,
fnIndex: 3,
data: [src, tgt, parseInt(vcSteps.value), 1.0, 0.0, vcSim.value / 100, 0.9, 1.0, 1.0, false, false],
statusElId: "vc-status",
progressFillId: "vc-progress-fill",
processingMsg: "Converting voice..."
});
// Seed-VC outputs: [Stream Output (HLS), Full Output (wav)]. Use the full one.
const url = audioUrlFromOutput(output.data[1], SEED_VC_BASE);
if (!url) throw new Error("No audio returned");
resultUrls.vc = url;
document.getElementById("vc-audio-player").src = url;
setPlayerMeta("vc", `${vcSource.name}${vcTarget.name}`);
result.classList.add("visible");
showStatusOn("vc-status", "success", `Converted in ${wallClock.toFixed(1)}s`);
refreshQuota();
addToHistory(`${vcSource.name}${vcTarget.name}`, "Voice Changer", wallClock, url);
} catch (err) {
showStatusCtaOn("vc-status", err.message);
} finally {
btn.disabled = false; btn.classList.remove("loading");
setTimeout(() => { progressBar.classList.remove("visible"); updateVcBtn(); }, 2000);
}
};
// ── Voice Cleaner (Resemble Enhance) ──
let isoMode = "enhance"; // "enhance" = denoise+enhance, "denoise" = denoise-only
window.setIsoMode = function(mode) {
isoMode = mode;
document.querySelectorAll("#iso-mode-tabs .audio-source-tab").forEach(t => t.classList.toggle("active", t.dataset.isoMode === mode));
};
let isoFile = null;
const isoNfe = document.getElementById("iso-nfe");
const isoTau = document.getElementById("iso-tau");
isoNfe.addEventListener("input", () => document.getElementById("iso-nfe-val").textContent = isoNfe.value);
isoTau.addEventListener("input", () => document.getElementById("iso-tau-val").textContent = (isoTau.value / 100).toFixed(2));
wireUploadArea("iso-upload", "iso-file-input", "iso-upload-label", f => {
isoFile = f;
document.getElementById("iso-btn").disabled = false;
});
attachAudioInput("iso", f => {
isoFile = f;
document.getElementById("iso-upload-label").textContent = f.name;
document.getElementById("iso-upload").classList.add("has-file");
document.getElementById("iso-btn").disabled = false;
});
setupPlayer("iso");
window.voiceIsolate = async function() {
if (!isoFile) return;
const btn = document.getElementById("iso-btn");
const progressBar = document.getElementById("iso-progress-bar");
const progressFill = document.getElementById("iso-progress-fill");
const result = document.getElementById("iso-result");
const denoiseOnly = isoMode === "denoise";
btn.disabled = true; btn.classList.add("loading");
result.classList.remove("visible");
progressBar.classList.add("visible");
progressFill.style.width = "0%";
try {
showStatusOn("iso-status", "info", "Uploading audio...");
const fileRef = await uploadFile(RESEMBLE_BASE, isoFile);
const { output, wallClock } = await gradioCall({
base: RESEMBLE_BASE,
fnIndex: 2,
data: [fileRef, "Midpoint", parseInt(isoNfe.value), isoTau.value / 100, denoiseOnly],
statusElId: "iso-status",
progressFillId: "iso-progress-fill",
processingMsg: "Enhancing audio..."
});
// Outputs: [Denoised, Enhanced]. Pick enhanced normally; denoised if denoise-only.
const idx = denoiseOnly ? 0 : 1;
const url = audioUrlFromOutput(output.data[idx], RESEMBLE_BASE);
if (!url) throw new Error("No audio returned");
resultUrls.iso = url;
document.getElementById("iso-audio-player").src = url;
document.getElementById("iso-player-title").textContent = denoiseOnly ? "Cleaned audio" : "Enhanced audio";
setPlayerMeta("iso", isoFile.name);
result.classList.add("visible");
showStatusOn("iso-status", "success", `Done in ${wallClock.toFixed(1)}s`);
refreshQuota();
addToHistory(isoFile.name, denoiseOnly ? "Voice Cleaner (noise)" : "Voice Cleaner (enhance)", wallClock, url);
} catch (err) {
showStatusCtaOn("iso-status", err.message);
} finally {
btn.disabled = false; btn.classList.remove("loading");
setTimeout(() => { progressBar.classList.remove("visible"); }, 2000);
}
};
// ── Sound Effects (Stable Audio 3) ──
const sfxPrompt = document.getElementById("sfx-prompt");
const sfxDuration = document.getElementById("sfx-duration");
const sfxSteps = document.getElementById("sfx-steps");
sfxPrompt.addEventListener("input", () => {
document.getElementById("sfx-char-count").textContent = sfxPrompt.value.length;
document.getElementById("sfx-btn").disabled = !sfxPrompt.value.trim();
});
sfxDuration.addEventListener("input", () => document.getElementById("sfx-duration-val").textContent = `${sfxDuration.value}s`);
sfxSteps.addEventListener("input", () => document.getElementById("sfx-steps-val").textContent = sfxSteps.value);
setupPlayer("sfx");
window.soundEffect = async function() {
const prompt = sfxPrompt.value.trim();
if (!prompt) return;
const btn = document.getElementById("sfx-btn");
const progressBar = document.getElementById("sfx-progress-bar");
const progressFill = document.getElementById("sfx-progress-fill");
const result = document.getElementById("sfx-result");
btn.disabled = true; btn.classList.add("loading");
result.classList.remove("visible");
progressBar.classList.add("visible");
progressFill.style.width = "0%";
try {
const { output, wallClock } = await gradioCall({
base: STABLE_AUDIO_BASE,
fnIndex: 3,
// /infer params: variant_key, prompt, duration, steps, cfg_scale, sampler_type, seed
data: ["medium", prompt, parseInt(sfxDuration.value), parseInt(sfxSteps.value), 1.0, "pingpong", 0],
statusElId: "sfx-status",
progressFillId: "sfx-progress-fill",
processingMsg: "Generating sound..."
});
const url = audioUrlFromOutput(output.data[0], STABLE_AUDIO_BASE);
if (!url) throw new Error("No audio returned");
resultUrls.sfx = url;
document.getElementById("sfx-audio-player").src = url;
document.getElementById("sfx-player-title").textContent = prompt.slice(0, 60) + (prompt.length > 60 ? "..." : "");
setPlayerMeta("sfx", `Prompt: "${prompt.slice(0,40)}${prompt.length>40?"...":""}"`);
result.classList.add("visible");
showStatusOn("sfx-status", "success", `Generated in ${wallClock.toFixed(1)}s`);
refreshQuota();
addToHistory(prompt, "Sound Effects", wallClock, url);
} catch (err) {
showStatusCtaOn("sfx-status", err.message);
} finally {
btn.disabled = false; btn.classList.remove("loading");
setTimeout(() => { progressBar.classList.remove("visible"); }, 2000);
}
};
// ── Text ──
// The synthesis editor is the adaptive rich-document below (abSegments / renderAbRows).
function updateGenerateBtn() { abUpdateGenerateBtn(); }
// ── 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() {
const todo = abSegments.map((s, i) => ({ s, i })).filter(x => x.s.text.trim());
if (!todo.length || !selectedVoice) return;
if (todo.length === 1) return generateSingle(todo[0].i);
return generateCombined();
};
async function generateSingle(idx) {
const text = abSegments[idx].text.trim();
const lang = document.getElementById("lang-select").value;
const steps = parseInt(stepsSlider.value);
const speed = speedSlider.value / 100;
genResultBlob = null;
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%";
const t0 = Date.now();
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 = "75%"; }
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 wallSec = (Date.now() - t0) / 1000;
resultAudioUrl = data.output.data[0].url;
abSegments[idx].url = resultAudioUrl; abSegments[idx].status = "done"; abSetRowStatus(idx, "done");
document.getElementById("audio-player").src = resultAudioUrl;
document.getElementById("player-title").textContent = text.slice(0, 60) + (text.length > 60 ? "..." : "");
setPlayerMeta("", selectedVoice.name, `${text.length} chars`); // prefix="" → uses bare "audio-player"/"player-meta" ids
result.classList.add("visible");
showStatus("success", `Generated in ${wallSec.toFixed(1)}s`);
addToHistory(text, selectedVoice.name, wallSec, resultAudioUrl);
refreshQuota();
} 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);
}
};
// ── Adaptive synthesis document ──
// One continuous document: 1 segment = simple text field; 2+ segments morph into
// per-line chips + controls and combine into a single audio file.
let abSegments = []; // [{ text, voiceId, status, url }] voiceId "" = inherit default voice
let abVoiceOptions = []; // [{ id, label, color, initial }]
let genResultBlob = null; // combined-MP3 blob, when multi-segment
let genResultUrl = null;
let abMenuIndex = -1;
const WORD_LIMIT = 150; // single paragraph beyond this → suggest breaking it down
async function synthVoiceOptions() {
const voices = await getVoices();
const usedPresetNames = new Set(voices.filter(v => v.isPreset).map(v => v.fileName.replace(/\.wav$/, "")));
const opts = voices.map(v => ({ id: v.id, label: v.name, color: v.color, initial: (v.name[0] || "?") }));
PRESETS.filter(p => !usedPresetNames.has(p.name)).forEach(p =>
opts.push({ id: `preset:${p.name}`, label: `${p.name.replace("_", " ")}`, color: "#d97706", initial: p.name[0] }));
return opts;
}
async function initSynthDoc() {
abVoiceOptions = await synthVoiceOptions();
if (!abSegments.length) abSegments = [{ text: "", voiceId: "", status: "idle", url: null }];
renderAbRows();
abUpdateGenerateBtn();
}
function abUpdateGenerateBtn() {
const hasText = abSegments.some(s => s.text.trim());
const btn = document.getElementById("generate-btn");
if (btn) btn.disabled = !hasText || !selectedVoice;
}
function wordCount(t) { return (t.trim().match(/\S+/g) || []).length; }
function abUpdateCounts() {
const chars = abSegments.reduce((n, s) => n + s.text.length, 0);
const segs = abSegments.filter(s => s.text.trim()).length;
document.getElementById("char-count").textContent = chars;
document.getElementById("seg-count").textContent = segs;
document.getElementById("seg-stat").classList.toggle("visible", abSegments.length > 1);
document.getElementById("gen-doc").classList.toggle("simple", abSegments.length <= 1);
// suggest a break-down when it's still one very long paragraph
const single = abSegments.length === 1;
const longPara = single && wordCount(abSegments[0].text) >= WORD_LIMIT;
document.getElementById("gen-suggest").classList.toggle("visible", longPara);
abUpdateGenerateBtn();
}
function abEffectiveVoice(voiceId) {
if (!voiceId) {
if (!selectedVoice) return { color: "var(--surface3)", initial: "?", label: "No voice" };
return { color: selectedVoice.color, initial: (selectedVoice.name[0] || "?").toUpperCase(), label: selectedVoice.name };
}
const o = abVoiceOptions.find(x => x.id === voiceId);
if (!o) return { color: "var(--surface3)", initial: "?", label: "—" };
return { color: o.color, initial: (o.initial || "?").toUpperCase(), label: o.label };
}
// ── Caret helpers for the contenteditable lines ──
function abCaretOffset(el) {
const sel = window.getSelection();
if (!sel.rangeCount) return 0;
const r = sel.getRangeAt(0).cloneRange();
const pre = document.createRange();
pre.selectNodeContents(el);
pre.setEnd(r.endContainer, r.endOffset);
return pre.toString().length;
}
function abFocusRow(i, offset) {
const para = document.querySelector(`.ab-para[data-i="${i}"]`);
if (!para) return;
para.focus();
const sel = window.getSelection();
const range = document.createRange();
const len = (para.textContent || "").length;
const off = Math.max(0, Math.min(offset, len));
if (para.firstChild && para.firstChild.nodeType === 3) range.setStart(para.firstChild, off);
else range.setStart(para, 0);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
}
function renderAbRows() {
const doc = document.getElementById("gen-doc");
doc.innerHTML = "";
const multi = abSegments.length > 1;
abSegments.forEach((s, i) => {
const row = document.createElement("div");
row.className = "ab-row"; row.dataset.i = i;
const chip = document.createElement("button");
chip.type = "button";
chip.className = "ab-chip" + (s.voiceId ? " overridden" : "");
const eff = abEffectiveVoice(s.voiceId);
const av = document.createElement("span");
av.className = "ab-chip-av";
av.style.background = eff.color;
av.innerHTML = `<span>${eff.initial}</span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="6 9 12 15 18 9"/></svg>`;
const nm = document.createElement("span");
nm.className = "ab-chip-name";
nm.textContent = eff.label;
chip.appendChild(av); chip.appendChild(nm);
chip.title = `Voice: ${eff.label}${s.voiceId ? " (override)" : " (default)"} — click to change`;
chip.onmousedown = (e) => e.preventDefault();
chip.onclick = (e) => abOpenVoiceMenu(e, i);
const para = document.createElement("div");
para.className = "ab-para"; para.contentEditable = "true"; para.dataset.i = i;
para.textContent = s.text;
para.addEventListener("input", () => {
s.text = para.textContent;
if (s.status !== "idle") { s.status = "idle"; s.url = null; abSetRowStatus(i, "idle"); }
abUpdateCounts();
});
para.addEventListener("keydown", (e) => abParaKeydown(e, i));
para.addEventListener("paste", (e) => abParaPaste(e, i));
row.appendChild(chip); row.appendChild(para); row.appendChild(buildRowSide(s, i));
// "join with next" affordance between adjacent segments
if (multi && i < abSegments.length - 1) {
const join = document.createElement("button");
join.type = "button"; join.className = "ab-join"; join.title = "Join with the segment below";
join.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M8 7l4 4 4-4"/><path d="M8 13l4 4 4-4"/></svg>';
join.onmousedown = (e) => e.preventDefault();
join.onclick = (e) => { e.stopPropagation(); abJoinNext(i); };
row.appendChild(join);
}
doc.appendChild(row);
});
abUpdateCounts();
}
const ICON_PLAY = '<svg viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>';
const ICON_DL = '<svg 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>';
function mkRowBtn(cls, title, html, onclick) {
const b = document.createElement("button");
b.type = "button"; b.className = cls; b.title = title; b.innerHTML = html;
b.onclick = onclick;
return b;
}
function buildRowSide(s, i) {
const side = document.createElement("div"); side.className = "ab-row-side";
const done = s.status === "done" && s.url;
// Play always works; it generates on the first run, then plays the cached audio.
const play = mkRowBtn("ab-play-one" + (done ? " ready" : ""), done ? "Play this segment" : "Generate & play this segment", ICON_PLAY, () => abGenOne(i));
if (s.status === "active") play.classList.add("busy");
// Download is only meaningful once the segment exists.
const dl = mkRowBtn("ab-dl-one", done ? "Download this segment" : "Generate this segment first to download", ICON_DL, () => abDownloadOne(i));
if (!done) dl.disabled = true;
side.appendChild(play); side.appendChild(dl);
const st = document.createElement("span");
st.className = "ab-row-status " + (s.status === "idle" ? "" : s.status);
st.textContent = s.status === "error" ? "✕" : s.status === "active" ? "…" : "";
side.appendChild(st);
side.appendChild(mkRowBtn("ab-row-del", "Remove segment", "", () => abRemoveRow(i)));
side.lastChild.textContent = "✕";
return side;
}
function abSetRowStatus(i, status) {
const row = document.querySelector(`.ab-row[data-i="${i}"]`);
if (!row || !abSegments[i]) return;
abSegments[i].status = status;
const oldSide = row.querySelector(".ab-row-side");
if (oldSide) row.replaceChild(buildRowSide(abSegments[i], i), oldSide);
}
function abParaKeydown(e, i) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
const off = abCaretOffset(e.target);
const cur = abSegments[i].text;
abSegments[i].text = cur.slice(0, off);
abSegments[i].status = "idle"; abSegments[i].url = null;
abSegments.splice(i + 1, 0, { text: cur.slice(off), voiceId: abSegments[i].voiceId, status: "idle", url: null });
renderAbRows();
abFocusRow(i + 1, 0);
} else if (e.key === "Backspace") {
const sel = window.getSelection();
if (sel.isCollapsed && abCaretOffset(e.target) === 0 && i > 0) {
e.preventDefault();
const prevLen = abSegments[i - 1].text.length;
abSegments[i - 1].text += abSegments[i].text;
abSegments[i - 1].status = "idle"; abSegments[i - 1].url = null;
abSegments.splice(i, 1);
renderAbRows();
abFocusRow(i - 1, prevLen);
}
}
}
function abParaPaste(e, i) {
e.preventDefault();
const text = (e.clipboardData || window.clipboardData).getData("text");
if (!text) return;
const off = abCaretOffset(e.target);
const cur = abSegments[i].text;
const before = cur.slice(0, off), after = cur.slice(off);
const parts = text.split(/\r?\n\s*\r?\n/).map(p => p.replace(/\s*\r?\n\s*/g, " ").trim());
abSegments[i].status = "idle"; abSegments[i].url = null;
if (parts.length <= 1) {
const ins = parts[0] || "";
abSegments[i].text = before + ins + after;
renderAbRows();
abFocusRow(i, (before + ins).length);
} else {
const vid = abSegments[i].voiceId;
abSegments[i].text = before + parts[0];
const tail = [];
for (let k = 1; k < parts.length; k++) {
const t = k === parts.length - 1 ? parts[k] + after : parts[k];
tail.push({ text: t, voiceId: vid, status: "idle", url: null });
}
abSegments.splice(i + 1, 0, ...tail);
renderAbRows();
abFocusRow(i + tail.length, parts[parts.length - 1].length);
}
}
window.abRemoveRow = function(i) {
if (abSegments.length <= 1) {
abSegments = [{ text: "", voiceId: "", status: "idle", url: null }];
renderAbRows();
abFocusRow(0, 0);
return;
}
abSegments.splice(i, 1);
renderAbRows();
abFocusRow(Math.max(0, i - 1), abSegments[Math.max(0, i - 1)].text.length);
};
window.abClearDoc = function() {
abSegments = [{ text: "", voiceId: "", status: "idle", url: null }];
renderAbRows();
abFocusRow(0, 0);
};
// Join a segment with the one below it into a single segment.
window.abJoinNext = function(i) {
if (i < 0 || i >= abSegments.length - 1) return;
const a = abSegments[i], b = abSegments[i + 1];
const sep = a.text.trim() && b.text.trim() ? " " : "";
a.text = a.text + sep + b.text;
a.status = "idle"; a.url = null;
abSegments.splice(i + 1, 1);
renderAbRows();
abFocusRow(i, a.text.length);
};
// Break one long paragraph into ~50-60 word segments, splitting only on sentence periods.
window.abBreakDown = function() {
const text = (abSegments[0]?.text || "").trim();
if (!text) return;
const sentences = text.split(/(?<=\.)\s+/).map(p => p.trim()).filter(Boolean);
const chunks = [];
let cur = "", curWords = 0;
for (const sen of sentences) {
const w = wordCount(sen);
if (curWords && curWords + w > 60) { chunks.push(cur.trim()); cur = ""; curWords = 0; }
cur += (cur ? " " : "") + sen;
curWords += w;
}
if (cur.trim()) chunks.push(cur.trim());
if (chunks.length <= 1) return;
abSegments = chunks.map(t => ({ text: t, voiceId: "", status: "idle", url: null }));
renderAbRows();
abFocusRow(0, 0);
};
// ── Per-segment voice override menu ──
window.abOpenVoiceMenu = function(e, i) {
e.stopPropagation();
const menu = document.getElementById("gen-voice-menu");
abMenuIndex = i;
const defLabel = abEffectiveVoice("").label;
const item = (id, label, color, initial, checked) =>
`<button onclick="abPickVoice(event, '${id}')">
<span class="ab-mav" style="background:${color}">${(initial || "?").toUpperCase()}</span>
<span>${label}${checked ? " ✓" : ""}</span>
</button>`;
let html = item("", `Default (${defLabel})`, "var(--surface3)", "↺", !abSegments[i].voiceId);
html += abVoiceOptions.map(o => item(o.id, o.label, o.color, o.initial, abSegments[i].voiceId === o.id)).join("");
menu.innerHTML = html;
const page = document.getElementById("page-synthesis");
const pr = page.getBoundingClientRect();
const cr = e.currentTarget.getBoundingClientRect();
menu.style.left = (cr.left - pr.left) + "px";
menu.style.top = (cr.bottom - pr.top + 4) + "px";
menu.classList.add("visible");
};
window.abPickVoice = function(e, id) {
e.stopPropagation();
if (abMenuIndex >= 0 && abSegments[abMenuIndex]) {
abSegments[abMenuIndex].voiceId = id;
renderAbRows();
}
document.getElementById("gen-voice-menu").classList.remove("visible");
abMenuIndex = -1;
};
document.addEventListener("click", () => {
document.getElementById("gen-voice-menu")?.classList.remove("visible");
});
// Generate a single segment (without playing) — caches the URL for reuse in the full run.
async function abEnsureGen(i) {
const s = abSegments[i];
if (!s || !s.text.trim()) return null;
if (s.status === "done" && s.url) return s.url;
if (!s.voiceId && !selectedVoice) throw new Error("Select a default voice first");
const lang = document.getElementById("lang-select").value;
const steps = parseInt(document.getElementById("setting-steps").value);
const speed = document.getElementById("setting-speed").value / 100;
s.status = "active"; abSetRowStatus(i, "active");
const voice = await resolveAbVoice(s.voiceId || selectedVoice.id);
const url = await omniClone(s.text, voice, lang, steps, speed);
s.url = url; s.status = "done"; abSetRowStatus(i, "done");
refreshQuota();
return url;
}
function genShowError(i, err) {
abSegments[i].status = "error"; abSetRowStatus(i, "error");
const statusEl = document.getElementById("status");
statusEl.className = "status-bar visible error";
statusEl.innerHTML = `<div class="status-text">Segment ${i + 1}: ${err.message}</div>`;
if (err.message.includes("GPU quota")) { refreshQuota(); statusEl.innerHTML = quotaErrorBodyHtml(err.message); }
}
// Generate a single segment and play it inline — verify a voice/segment before the full run.
window.abGenOne = async function(i) {
const s = abSegments[i];
if (!s || !s.text.trim()) return;
const row = document.querySelector(`.ab-row[data-i="${i}"]`);
const play = row?.querySelector(".ab-play-one");
play?.classList.add("busy");
try {
const url = await abEnsureGen(i);
if (url) new Audio(url).play();
} catch (err) {
genShowError(i, err);
} finally {
play?.classList.remove("busy");
}
};
// Download a single segment's audio, generating it first if needed.
window.abDownloadOne = async function(i) {
const s = abSegments[i];
if (!s || !s.text.trim()) return;
const row = document.querySelector(`.ab-row[data-i="${i}"]`);
const dl = row?.querySelector(".ab-dl-one");
dl?.classList.add("busy");
try {
const url = await abEnsureGen(i);
if (!url) return;
const blob = await fetch(url).then(r => r.blob());
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `segment_${i + 1}.wav`;
a.click();
URL.revokeObjectURL(a.href);
} catch (err) {
genShowError(i, err);
} finally {
dl?.classList.remove("busy");
}
};
async function resolveAbVoice(voiceId) {
if (voiceId && voiceId.startsWith("preset:")) {
const name = voiceId.slice(7);
const data = await fetch(`samples/${name}.wav`).then(r => r.arrayBuffer());
return { audioData: data, audioType: "audio/wav", fileName: `${name}.wav`, name: name.replace("_", " ") };
}
const voices = await getVoices();
const v = voices.find(x => x.id === voiceId);
if (!v) throw new Error("Voice not found");
return v;
}
// Run one OmniVoice clone request, resolve to the generated audio URL.
async function omniClone(text, voice, lang, steps, speed) {
const blob = new Blob([voice.audioData], { type: voice.audioType || "audio/mpeg" });
const form = new FormData();
form.append("files", blob, voice.fileName);
const uploadRes = await fetch(`${OMNIVOICE_BASE}/upload`, {
method: "POST",
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : {},
body: form
});
const [uploadPath] = await uploadRes.json();
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: voice.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 === "process_completed") {
if (data.success) return data.output.data[0].url;
throw new Error(data.output?.error || "Generation failed");
}
}
}
throw new Error("Stream ended without result");
}
// Combine remote audio URLs into a single mono MP3 blob.
async function combineToMp3(urls, gapSec = 0.4) {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
try {
const buffers = [];
for (const url of urls) {
const ab = await fetch(url).then(r => r.arrayBuffer());
buffers.push(await ctx.decodeAudioData(ab));
}
const sr = ctx.sampleRate;
const gap = Math.round(gapSec * sr);
let total = 0;
buffers.forEach((b, i) => { total += b.length + (i < buffers.length - 1 ? gap : 0); });
const mono = new Float32Array(total);
let off = 0;
buffers.forEach((b, i) => {
const ch0 = b.getChannelData(0);
if (b.numberOfChannels > 1) {
const ch1 = b.getChannelData(1);
for (let j = 0; j < b.length; j++) mono[off + j] = (ch0[j] + ch1[j]) / 2;
} else {
mono.set(ch0, off);
}
off += b.length + (i < buffers.length - 1 ? gap : 0);
});
const int16 = new Int16Array(mono.length);
for (let i = 0; i < mono.length; i++) {
const s = Math.max(-1, Math.min(1, mono[i]));
int16[i] = s < 0 ? s * 32768 : s * 32767;
}
const lamejs = await import("https://esm.sh/@breezystack/lamejs@1.2.7");
const enc = new lamejs.Mp3Encoder(1, sr, 128);
const block = 1152;
const parts = [];
for (let i = 0; i < int16.length; i += block) {
const mp3 = enc.encodeBuffer(int16.subarray(i, i + block));
if (mp3.length > 0) parts.push(new Uint8Array(mp3));
}
const end = enc.flush();
if (end.length > 0) parts.push(new Uint8Array(end));
const durSec = total / sr;
return { blob: new Blob(parts, { type: "audio/mpeg" }), durSec };
} finally {
ctx.close();
}
}
// Multi-segment run: generate each segment, then stitch into one MP3.
async function generateCombined() {
if (!selectedVoice) return;
const lang = document.getElementById("lang-select").value;
const btn = document.getElementById("generate-btn");
const progressBar = document.getElementById("progress-bar");
const progressFill = document.getElementById("progress-fill");
const result = document.getElementById("result");
const statusEl = document.getElementById("status");
btn.disabled = true;
btn.classList.add("loading");
result.classList.remove("visible");
progressBar.classList.add("visible");
progressFill.style.width = "0%";
const abShowStatus = (type, msg) => { statusEl.className = `status-bar visible ${type}`; statusEl.innerHTML = `<div class="status-text">${msg}</div>`; };
const t0 = Date.now();
try {
const todo = abSegments.map((s, i) => ({ s, i })).filter(x => x.s.text.trim());
const urls = [];
for (let n = 0; n < todo.length; n++) {
const { s: seg, i } = todo[n];
// reuse a segment already verified with ▷ to save quota
if (seg.status === "done" && seg.url) { urls.push(seg.url); progressFill.style.width = `${Math.round(((n + 1) / todo.length) * 85)}%`; continue; }
abShowStatus("info", `Generating segment ${n + 1} of ${todo.length}…`);
try {
const url = await abEnsureGen(i);
urls.push(url);
} catch (err) {
genShowError(i, err);
return;
}
progressFill.style.width = `${Math.round(((n + 1) / todo.length) * 85)}%`;
}
if (!urls.length) { abShowStatus("error", "Nothing to generate — add some text first."); return; }
abShowStatus("info", "Combining segments into one MP3…");
progressFill.style.width = "92%";
const { blob, durSec } = await combineToMp3(urls);
genResultBlob = blob;
if (genResultUrl) URL.revokeObjectURL(genResultUrl);
genResultUrl = URL.createObjectURL(blob);
resultAudioUrl = genResultUrl;
progressFill.style.width = "100%";
document.getElementById("audio-player").src = genResultUrl;
document.getElementById("player-title").textContent = `${urls.length} segments combined`;
setPlayerMeta("", `${Math.round(durSec)}s`, `${(blob.size / 1048576).toFixed(1)} MB MP3`);
result.classList.add("visible");
abShowStatus("success", `Audio ready in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
addToHistory(todo.map(x => x.s.text).join(" "), selectedVoice.name, (Date.now() - t0) / 1000, genResultUrl);
} catch (err) {
abShowStatus("error", err.message);
} finally {
btn.disabled = false;
btn.classList.remove("loading");
setTimeout(() => { progressBar.classList.remove("visible"); }, 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 (genResultBlob && genResultUrl) { // combined multi-segment MP3
const a = document.createElement("a");
a.href = genResultUrl;
a.download = "zerolabs_speech.mp3";
a.click();
return;
}
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" });
const safeText = (h.text || "").replace(/</g, "&lt;");
const playOrIcon = h.url
? `<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-play" style="opacity:0.4;cursor:default"><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></div>`;
const action = h.url
? `<button class="history-download" onclick="downloadHistoryItem(${i})">Download</button>`
: `<button class="history-download" onclick="copyHistoryText(${i})">Copy</button>`;
return `<div class="history-item">
${playOrIcon}
<div class="history-info"><div class="history-text">${safeText}</div><div class="history-sub">${h.voiceName} · ${h.duration?.toFixed(1)}s · ${t}</div></div>
${action}
</div>`;
}).join("") + '</div>';
}
window.copyHistoryText = function(i) {
const h = history[i];
if (h?.text) navigator.clipboard.writeText(h.text);
};
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 {}
};
// ── Global keybindings ──
document.addEventListener("keydown", e => {
if (e.key === "Escape" && document.querySelector(".modal-overlay.visible")) {
e.preventDefault();
closeModal();
return;
}
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
const activePage = document.querySelector(".page.active");
if (!activePage) return;
const btn = activePage.querySelector(".btn-generate");
if (btn && !btn.disabled) {
e.preventDefault();
btn.click();
}
}
});
// ── Init ──
initOAuth();
</script>
</body>
</html>