Spaces:
Running
Running
| <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 · 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 · 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 & 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 · 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 · 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, """); | |
| 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">✎</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 — $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 · 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, "<"); | |
| 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> | |