| <!DOCTYPE html> |
| <html lang="en"> |
|
|
| <head> |
| <meta charset="UTF-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| <title>Kokoro TTS</title> |
| <link |
| href="https://fonts.googleapis.com/css2?family=Syne:wght@400;600;700;800&family=DM+Mono:wght@300;400;500&display=swap" |
| rel="stylesheet" /> |
| <style> |
| :root { |
| --bg: #0a0a0f; |
| --surface: #111118; |
| --surface2: #1a1a24; |
| --surface3: #22222f; |
| --border: #2a2a3a; |
| --border2: #333345; |
| --accent: #7c6af7; |
| --accent2: #a78bfa; |
| --accent-glow: rgba(124, 106, 247, 0.25); |
| --green: #34d399; |
| --red: #f87171; |
| --yellow: #fbbf24; |
| --text: #e8e8f0; |
| --text-dim: #888898; |
| --text-muted: #555565; |
| --radius: 12px; |
| --radius-sm: 8px; |
| } |
| |
| *, |
| *::before, |
| *::after { |
| box-sizing: border-box; |
| margin: 0; |
| padding: 0; |
| } |
| |
| html { |
| background: var(--bg); |
| } |
| |
| body { |
| font-family: 'DM Mono', monospace; |
| background: var(--bg); |
| color: var(--text); |
| min-height: 100vh; |
| display: grid; |
| grid-template-rows: auto 1fr auto; |
| overflow-x: hidden; |
| } |
| |
| |
| body::before { |
| content: ''; |
| position: fixed; |
| inset: 0; |
| background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E"); |
| pointer-events: none; |
| z-index: 0; |
| } |
| |
| |
| header { |
| position: relative; |
| z-index: 10; |
| padding: 24px 40px; |
| border-bottom: 1px solid var(--border); |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| background: rgba(10, 10, 15, 0.8); |
| backdrop-filter: blur(12px); |
| } |
| |
| .logo { |
| font-family: 'Syne', sans-serif; |
| font-size: 22px; |
| font-weight: 800; |
| letter-spacing: -0.5px; |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| } |
| |
| .logo-icon { |
| width: 32px; |
| height: 32px; |
| background: linear-gradient(135deg, var(--accent), #c084fc); |
| border-radius: 8px; |
| display: grid; |
| place-items: center; |
| font-size: 16px; |
| } |
| |
| .logo-text span { |
| color: var(--accent2); |
| } |
| |
| .status-badge { |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| padding: 6px 14px; |
| background: var(--surface2); |
| border: 1px solid var(--border); |
| border-radius: 100px; |
| font-size: 11px; |
| color: var(--text-dim); |
| font-family: 'DM Mono', monospace; |
| } |
| |
| .status-dot { |
| width: 7px; |
| height: 7px; |
| border-radius: 50%; |
| background: var(--text-muted); |
| transition: background 0.3s, box-shadow 0.3s; |
| } |
| |
| .status-dot.ready { |
| background: var(--green); |
| box-shadow: 0 0 8px var(--green); |
| } |
| |
| .status-dot.loading { |
| background: var(--yellow); |
| animation: pulse 1s infinite; |
| } |
| |
| .status-dot.error { |
| background: var(--red); |
| } |
| |
| @keyframes pulse { |
| |
| 0%, |
| 100% { |
| opacity: 1 |
| } |
| |
| 50% { |
| opacity: 0.4 |
| } |
| } |
| |
| |
| .tabs { |
| display: flex; |
| gap: 2px; |
| padding: 0 40px; |
| border-bottom: 1px solid var(--border); |
| background: var(--surface); |
| position: relative; |
| z-index: 9; |
| } |
| |
| .tab-btn { |
| font-family: 'Syne', sans-serif; |
| font-size: 13px; |
| font-weight: 600; |
| letter-spacing: 0.5px; |
| padding: 14px 20px; |
| border: none; |
| background: transparent; |
| color: var(--text-muted); |
| cursor: pointer; |
| position: relative; |
| transition: color 0.2s; |
| display: flex; |
| align-items: center; |
| gap: 7px; |
| } |
| |
| .tab-btn:hover { |
| color: var(--text-dim); |
| } |
| |
| .tab-btn.active { |
| color: var(--accent2); |
| } |
| |
| .tab-btn.active::after { |
| content: ''; |
| position: absolute; |
| bottom: -1px; |
| left: 0; |
| right: 0; |
| height: 2px; |
| background: var(--accent); |
| border-radius: 2px 2px 0 0; |
| } |
| |
| |
| main { |
| position: relative; |
| z-index: 5; |
| max-width: 1200px; |
| width: 100%; |
| margin: 0 auto; |
| padding: 32px 40px; |
| } |
| |
| .tab-panel { |
| display: none; |
| } |
| |
| .tab-panel.active { |
| display: block; |
| } |
| |
| |
| .generate-grid { |
| display: grid; |
| grid-template-columns: 1fr 420px; |
| gap: 24px; |
| align-items: start; |
| } |
| |
| |
| .card { |
| background: var(--surface); |
| border: 1px solid var(--border); |
| border-radius: var(--radius); |
| overflow: hidden; |
| } |
| |
| .card-header { |
| padding: 16px 20px; |
| border-bottom: 1px solid var(--border); |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| } |
| |
| .card-title { |
| font-family: 'Syne', sans-serif; |
| font-size: 12px; |
| font-weight: 700; |
| letter-spacing: 1.5px; |
| text-transform: uppercase; |
| color: var(--text-dim); |
| } |
| |
| .card-body { |
| padding: 20px; |
| } |
| |
| |
| textarea { |
| width: 100%; |
| min-height: 200px; |
| background: var(--surface2); |
| border: 1px solid var(--border); |
| border-radius: var(--radius-sm); |
| color: var(--text); |
| font-family: 'DM Mono', monospace; |
| font-size: 13px; |
| line-height: 1.7; |
| padding: 14px 16px; |
| resize: vertical; |
| transition: border-color 0.2s, box-shadow 0.2s; |
| outline: none; |
| } |
| |
| textarea:focus { |
| border-color: var(--accent); |
| box-shadow: 0 0 0 3px var(--accent-glow); |
| } |
| |
| .char-count { |
| font-size: 11px; |
| color: var(--text-muted); |
| text-align: right; |
| margin-top: 6px; |
| } |
| |
| .char-count.warn { |
| color: var(--yellow); |
| } |
| |
| .char-count.danger { |
| color: var(--red); |
| } |
| |
| |
| .examples-section { |
| margin-top: 16px; |
| } |
| |
| .examples-label { |
| font-size: 11px; |
| color: var(--text-muted); |
| letter-spacing: 1px; |
| text-transform: uppercase; |
| margin-bottom: 10px; |
| } |
| |
| .examples-grid { |
| display: flex; |
| flex-wrap: wrap; |
| gap: 8px; |
| } |
| |
| .example-chip { |
| font-family: 'DM Mono', monospace; |
| font-size: 11px; |
| padding: 5px 12px; |
| background: var(--surface2); |
| border: 1px solid var(--border); |
| border-radius: 100px; |
| color: var(--text-dim); |
| cursor: pointer; |
| transition: all 0.18s; |
| white-space: nowrap; |
| } |
| |
| .example-chip:hover { |
| border-color: var(--accent); |
| color: var(--accent2); |
| background: rgba(124, 106, 247, 0.08); |
| } |
| |
| |
| .voice-search { |
| width: 100%; |
| background: var(--surface2); |
| border: 1px solid var(--border); |
| border-radius: var(--radius-sm); |
| color: var(--text); |
| font-family: 'DM Mono', monospace; |
| font-size: 12px; |
| padding: 9px 14px; |
| outline: none; |
| transition: border-color 0.2s; |
| margin-bottom: 12px; |
| } |
| |
| .voice-search:focus { |
| border-color: var(--accent); |
| } |
| |
| .voice-list { |
| max-height: 220px; |
| overflow-y: auto; |
| display: flex; |
| flex-direction: column; |
| gap: 4px; |
| } |
| |
| .voice-list::-webkit-scrollbar { |
| width: 4px; |
| } |
| |
| .voice-list::-webkit-scrollbar-track { |
| background: transparent; |
| } |
| |
| .voice-list::-webkit-scrollbar-thumb { |
| background: var(--border2); |
| border-radius: 4px; |
| } |
| |
| .voice-item { |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| padding: 9px 12px; |
| border-radius: var(--radius-sm); |
| cursor: pointer; |
| border: 1px solid transparent; |
| transition: all 0.15s; |
| background: transparent; |
| } |
| |
| .voice-item:hover { |
| background: var(--surface2); |
| border-color: var(--border); |
| } |
| |
| .voice-item.selected { |
| background: rgba(124, 106, 247, 0.12); |
| border-color: var(--accent); |
| } |
| |
| .voice-flag { |
| font-size: 16px; |
| line-height: 1; |
| } |
| |
| .voice-info { |
| flex: 1; |
| } |
| |
| .voice-name { |
| font-family: 'Syne', sans-serif; |
| font-size: 13px; |
| font-weight: 600; |
| color: var(--text); |
| } |
| |
| .voice-meta { |
| font-size: 10px; |
| color: var(--text-muted); |
| margin-top: 1px; |
| } |
| |
| .voice-gender { |
| font-size: 10px; |
| padding: 2px 8px; |
| border-radius: 100px; |
| font-family: 'DM Mono', monospace; |
| } |
| |
| .voice-gender.female { |
| background: rgba(251, 113, 133, 0.15); |
| color: #fb7185; |
| border: 1px solid rgba(251, 113, 133, 0.2); |
| } |
| |
| .voice-gender.male { |
| background: rgba(96, 165, 250, 0.15); |
| color: #60a5fa; |
| border: 1px solid rgba(96, 165, 250, 0.2); |
| } |
| |
| |
| .control-row { |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| gap: 12px; |
| margin-top: 16px; |
| } |
| |
| .control-group { |
| display: flex; |
| flex-direction: column; |
| gap: 6px; |
| flex: 1; |
| } |
| |
| .control-label { |
| font-size: 10px; |
| letter-spacing: 1.2px; |
| text-transform: uppercase; |
| color: var(--text-muted); |
| } |
| |
| .control-value { |
| font-size: 12px; |
| color: var(--accent2); |
| font-weight: 500; |
| } |
| |
| input[type=range] { |
| width: 100%; |
| appearance: none; |
| height: 4px; |
| background: var(--surface3); |
| border-radius: 4px; |
| outline: none; |
| cursor: pointer; |
| } |
| |
| input[type=range]::-webkit-slider-thumb { |
| appearance: none; |
| width: 14px; |
| height: 14px; |
| background: var(--accent); |
| border-radius: 50%; |
| box-shadow: 0 0 6px var(--accent-glow); |
| cursor: pointer; |
| } |
| |
| .format-toggle { |
| display: flex; |
| gap: 4px; |
| background: var(--surface2); |
| border: 1px solid var(--border); |
| border-radius: var(--radius-sm); |
| padding: 3px; |
| } |
| |
| .format-btn { |
| font-family: 'DM Mono', monospace; |
| font-size: 11px; |
| padding: 5px 14px; |
| border: none; |
| border-radius: 6px; |
| cursor: pointer; |
| background: transparent; |
| color: var(--text-dim); |
| transition: all 0.15s; |
| } |
| |
| .format-btn.active { |
| background: var(--accent); |
| color: #fff; |
| box-shadow: 0 2px 8px var(--accent-glow); |
| } |
| |
| |
| .generate-btn { |
| width: 100%; |
| margin-top: 20px; |
| padding: 14px; |
| font-family: 'Syne', sans-serif; |
| font-size: 14px; |
| font-weight: 700; |
| letter-spacing: 0.5px; |
| border: none; |
| border-radius: var(--radius-sm); |
| background: linear-gradient(135deg, var(--accent) 0%, #a855f7 100%); |
| color: #fff; |
| cursor: pointer; |
| position: relative; |
| overflow: hidden; |
| transition: opacity 0.2s, transform 0.1s; |
| box-shadow: 0 4px 20px rgba(124, 106, 247, 0.35); |
| } |
| |
| .generate-btn:hover { |
| opacity: 0.9; |
| transform: translateY(-1px); |
| } |
| |
| .generate-btn:active { |
| transform: translateY(0); |
| } |
| |
| .generate-btn:disabled { |
| opacity: 0.4; |
| cursor: not-allowed; |
| transform: none; |
| } |
| |
| .generate-btn .spinner { |
| display: none; |
| width: 16px; |
| height: 16px; |
| border: 2px solid rgba(255, 255, 255, 0.3); |
| border-top-color: #fff; |
| border-radius: 50%; |
| animation: spin 0.7s linear infinite; |
| margin-right: 8px; |
| } |
| |
| .generate-btn.loading .spinner { |
| display: inline-block; |
| } |
| |
| .generate-btn.loading .btn-text::before { |
| content: 'Generating...'; |
| } |
| |
| .generate-btn:not(.loading) .btn-text::before { |
| content: '⚡ Generate Audio'; |
| } |
| |
| @keyframes spin { |
| to { |
| transform: rotate(360deg); |
| } |
| } |
| |
| |
| .output-card { |
| position: sticky; |
| top: 24px; |
| } |
| |
| .waveform-placeholder { |
| height: 80px; |
| background: var(--surface2); |
| border-radius: var(--radius-sm); |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| color: var(--text-muted); |
| font-size: 12px; |
| border: 1px dashed var(--border); |
| margin-bottom: 16px; |
| position: relative; |
| overflow: hidden; |
| } |
| |
| .waveform-bars { |
| display: flex; |
| align-items: center; |
| gap: 3px; |
| height: 50px; |
| } |
| |
| .wbar { |
| width: 3px; |
| background: var(--accent); |
| border-radius: 2px; |
| opacity: 0.4; |
| } |
| |
| audio { |
| width: 100%; |
| border-radius: var(--radius-sm); |
| accent-color: var(--accent); |
| background: var(--surface2); |
| } |
| |
| |
| .audio-player { |
| display: none; |
| flex-direction: column; |
| gap: 12px; |
| } |
| |
| .audio-player.visible { |
| display: flex; |
| } |
| |
| .player-controls { |
| display: flex; |
| align-items: center; |
| gap: 12px; |
| } |
| |
| .play-btn { |
| width: 42px; |
| height: 42px; |
| border-radius: 50%; |
| background: var(--accent); |
| border: none; |
| cursor: pointer; |
| display: grid; |
| place-items: center; |
| color: #fff; |
| font-size: 16px; |
| flex-shrink: 0; |
| transition: all 0.15s; |
| box-shadow: 0 2px 12px var(--accent-glow); |
| } |
| |
| .play-btn:hover { |
| transform: scale(1.08); |
| } |
| |
| .progress-wrap { |
| flex: 1; |
| display: flex; |
| flex-direction: column; |
| gap: 4px; |
| } |
| |
| .progress-bar-bg { |
| height: 4px; |
| background: var(--surface3); |
| border-radius: 4px; |
| cursor: pointer; |
| position: relative; |
| } |
| |
| .progress-bar-fill { |
| height: 100%; |
| background: var(--accent); |
| border-radius: 4px; |
| width: 0%; |
| transition: width 0.1s; |
| position: relative; |
| } |
| |
| .progress-bar-fill::after { |
| content: ''; |
| position: absolute; |
| right: -5px; |
| top: -4px; |
| width: 12px; |
| height: 12px; |
| background: var(--accent2); |
| border-radius: 50%; |
| box-shadow: 0 0 6px var(--accent-glow); |
| } |
| |
| .time-row { |
| display: flex; |
| justify-content: space-between; |
| font-size: 10px; |
| color: var(--text-muted); |
| } |
| |
| .download-row { |
| display: flex; |
| gap: 8px; |
| } |
| |
| .action-btn { |
| flex: 1; |
| padding: 8px; |
| font-family: 'DM Mono', monospace; |
| font-size: 11px; |
| border-radius: var(--radius-sm); |
| border: 1px solid var(--border); |
| background: var(--surface2); |
| color: var(--text-dim); |
| cursor: pointer; |
| transition: all 0.15s; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| gap: 6px; |
| } |
| |
| .action-btn:hover { |
| border-color: var(--accent); |
| color: var(--accent2); |
| background: rgba(124, 106, 247, 0.08); |
| } |
| |
| |
| .meta-row { |
| display: flex; |
| gap: 8px; |
| flex-wrap: wrap; |
| } |
| |
| .meta-badge { |
| font-size: 10px; |
| padding: 3px 10px; |
| background: var(--surface3); |
| border: 1px solid var(--border); |
| border-radius: 100px; |
| color: var(--text-dim); |
| font-family: 'DM Mono', monospace; |
| } |
| |
| |
| .history-toolbar { |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| margin-bottom: 20px; |
| } |
| |
| .hist-btn { |
| font-family: 'DM Mono', monospace; |
| font-size: 12px; |
| padding: 8px 16px; |
| border-radius: var(--radius-sm); |
| border: 1px solid var(--border); |
| background: var(--surface2); |
| color: var(--text-dim); |
| cursor: pointer; |
| transition: all 0.15s; |
| display: flex; |
| align-items: center; |
| gap: 6px; |
| } |
| |
| .hist-btn:hover { |
| border-color: var(--accent); |
| color: var(--accent2); |
| } |
| |
| .hist-btn.danger:hover { |
| border-color: var(--red); |
| color: var(--red); |
| } |
| |
| .hist-list { |
| display: flex; |
| flex-direction: column; |
| gap: 6px; |
| } |
| |
| .hist-item { |
| display: flex; |
| align-items: center; |
| gap: 14px; |
| padding: 14px 16px; |
| background: var(--surface); |
| border: 1px solid var(--border); |
| border-radius: var(--radius-sm); |
| cursor: pointer; |
| transition: all 0.15s; |
| } |
| |
| .hist-item:hover { |
| border-color: var(--border2); |
| background: var(--surface2); |
| } |
| |
| .hist-item.selected { |
| border-color: var(--accent); |
| background: rgba(124, 106, 247, 0.06); |
| } |
| |
| .hist-icon { |
| width: 36px; |
| height: 36px; |
| background: var(--surface2); |
| border: 1px solid var(--border); |
| border-radius: 8px; |
| display: grid; |
| place-items: center; |
| font-size: 16px; |
| flex-shrink: 0; |
| } |
| |
| .hist-info { |
| flex: 1; |
| min-width: 0; |
| } |
| |
| .hist-name { |
| font-size: 13px; |
| color: var(--text); |
| white-space: nowrap; |
| overflow: hidden; |
| text-overflow: ellipsis; |
| } |
| |
| .hist-sub { |
| font-size: 11px; |
| color: var(--text-muted); |
| margin-top: 2px; |
| } |
| |
| .hist-actions { |
| display: flex; |
| gap: 6px; |
| } |
| |
| .hist-action-btn { |
| width: 30px; |
| height: 30px; |
| border-radius: 6px; |
| border: 1px solid var(--border); |
| background: var(--surface2); |
| color: var(--text-dim); |
| cursor: pointer; |
| display: grid; |
| place-items: center; |
| font-size: 13px; |
| transition: all 0.15s; |
| } |
| |
| .hist-action-btn:hover { |
| border-color: var(--accent); |
| color: var(--accent2); |
| } |
| |
| .hist-action-btn.del:hover { |
| border-color: var(--red); |
| color: var(--red); |
| } |
| |
| .hist-preview { |
| margin-top: 16px; |
| padding: 16px; |
| background: var(--surface); |
| border: 1px solid var(--border); |
| border-radius: var(--radius); |
| display: none; |
| } |
| |
| .hist-preview.visible { |
| display: block; |
| } |
| |
| |
| .api-grid { |
| display: grid; |
| grid-template-columns: 1fr 1fr; |
| gap: 20px; |
| } |
| |
| .endpoint-card { |
| background: var(--surface); |
| border: 1px solid var(--border); |
| border-radius: var(--radius); |
| overflow: hidden; |
| } |
| |
| .endpoint-header { |
| padding: 14px 18px; |
| border-bottom: 1px solid var(--border); |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| } |
| |
| .method-badge { |
| font-family: 'DM Mono', monospace; |
| font-size: 11px; |
| font-weight: 500; |
| padding: 3px 10px; |
| border-radius: 6px; |
| } |
| |
| .method-badge.POST { |
| background: rgba(52, 211, 153, 0.15); |
| color: var(--green); |
| border: 1px solid rgba(52, 211, 153, 0.25); |
| } |
| |
| .method-badge.GET { |
| background: rgba(96, 165, 250, 0.15); |
| color: #60a5fa; |
| border: 1px solid rgba(96, 165, 250, 0.25); |
| } |
| |
| .method-badge.DEL { |
| background: rgba(248, 113, 113, 0.15); |
| color: var(--red); |
| border: 1px solid rgba(248, 113, 113, 0.25); |
| } |
| |
| .endpoint-path { |
| font-family: 'DM Mono', monospace; |
| font-size: 13px; |
| color: var(--text); |
| } |
| |
| .endpoint-body { |
| padding: 16px 18px; |
| } |
| |
| pre { |
| background: var(--surface2); |
| border: 1px solid var(--border); |
| border-radius: var(--radius-sm); |
| padding: 14px; |
| font-family: 'DM Mono', monospace; |
| font-size: 11px; |
| color: var(--text-dim); |
| overflow-x: auto; |
| line-height: 1.6; |
| } |
| |
| .code-key { |
| color: #a78bfa; |
| } |
| |
| .code-str { |
| color: #34d399; |
| } |
| |
| .code-num { |
| color: #fbbf24; |
| } |
| |
| .code-comment { |
| color: var(--text-muted); |
| } |
| |
| |
| .empty-state { |
| text-align: center; |
| padding: 60px 20px; |
| color: var(--text-muted); |
| } |
| |
| .empty-icon { |
| font-size: 40px; |
| margin-bottom: 12px; |
| opacity: 0.5; |
| } |
| |
| .empty-title { |
| font-family: 'Syne', sans-serif; |
| font-size: 15px; |
| color: var(--text-dim); |
| margin-bottom: 6px; |
| } |
| |
| .empty-sub { |
| font-size: 12px; |
| } |
| |
| |
| .toast-container { |
| position: fixed; |
| bottom: 24px; |
| right: 24px; |
| z-index: 1000; |
| display: flex; |
| flex-direction: column; |
| gap: 8px; |
| } |
| |
| .toast { |
| padding: 12px 18px; |
| background: var(--surface2); |
| border: 1px solid var(--border); |
| border-radius: var(--radius-sm); |
| font-size: 13px; |
| color: var(--text); |
| backdrop-filter: blur(8px); |
| animation: slideIn 0.3s ease; |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| max-width: 320px; |
| } |
| |
| .toast.success { |
| border-left: 3px solid var(--green); |
| } |
| |
| .toast.error { |
| border-left: 3px solid var(--red); |
| } |
| |
| @keyframes slideIn { |
| from { |
| transform: translateX(20px); |
| opacity: 0; |
| } |
| |
| to { |
| transform: translateX(0); |
| opacity: 1; |
| } |
| } |
| |
| @keyframes slideOut { |
| from { |
| opacity: 1; |
| } |
| |
| to { |
| opacity: 0; |
| transform: translateX(20px); |
| } |
| } |
| |
| |
| footer { |
| position: relative; |
| z-index: 10; |
| padding: 16px 40px; |
| border-top: 1px solid var(--border); |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| font-size: 11px; |
| color: var(--text-muted); |
| } |
| |
| footer a { |
| color: var(--accent2); |
| text-decoration: none; |
| } |
| |
| |
| @media (max-width: 900px) { |
| |
| header, |
| .tabs, |
| main, |
| footer { |
| padding-left: 20px; |
| padding-right: 20px; |
| } |
| |
| .generate-grid { |
| grid-template-columns: 1fr; |
| } |
| |
| .api-grid { |
| grid-template-columns: 1fr; |
| } |
| |
| .output-card { |
| position: static; |
| } |
| } |
| </style> |
| </head> |
|
|
| <body> |
|
|
| |
| <header> |
| <div class="logo"> |
| <div class="logo-icon">🔊</div> |
| <span>Kokoro <span>TTS</span></span> |
| </div> |
| <div class="status-badge"> |
| <div class="status-dot loading" id="statusDot"></div> |
| <span id="statusText">Loading model…</span> |
| </div> |
| </header> |
|
|
| |
| <div class="tabs"> |
| <button class="tab-btn active" onclick="switchTab('generate', this)"> |
| <span>⚡</span> Generate |
| </button> |
| <button class="tab-btn" onclick="switchTab('api', this)"> |
| <span>📡</span> API Docs |
| </button> |
| </div> |
|
|
| |
| <main> |
|
|
| |
| <div class="tab-panel active" id="tab-generate"> |
| <div class="generate-grid"> |
|
|
| |
| <div> |
| <div class="card"> |
| <div class="card-header"> |
| <span class="card-title">Input Text</span> |
| <span class="char-count" id="charCount">0 / 5000</span> |
| </div> |
| <div class="card-body"> |
| <textarea id="textInput" placeholder="Enter text to synthesize…" |
| oninput="updateCharCount()"></textarea> |
| </div> |
| </div> |
|
|
| |
| <div class="examples-section"> |
| <div class="examples-label">Quick Examples</div> |
| <div class="examples-grid" id="examplesGrid"></div> |
| </div> |
| </div> |
|
|
| |
| <div> |
| |
| <div class="card" style="margin-bottom:16px"> |
| <div class="card-header"> |
| <span class="card-title">Voice</span> |
| <span id="selectedVoiceLabel" style="font-size:12px;color:var(--accent2)">—</span> |
| </div> |
| <div class="card-body" style="padding-bottom:12px"> |
| <input class="voice-search" type="text" placeholder="Search voices…" |
| oninput="filterVoices(this.value)" /> |
| <div class="voice-list" id="voiceList"> |
| <div class="empty-state" style="padding:20px"> |
| <div class="empty-sub">Loading voices…</div> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="card" style="margin-bottom:16px"> |
| <div class="card-header"> |
| <span class="card-title">Settings</span> |
| </div> |
| <div class="card-body"> |
| <div class="control-row"> |
| <div class="control-group"> |
| <div style="display:flex;justify-content:space-between"> |
| <span class="control-label">Speed</span> |
| <span class="control-value" id="speedVal">1.0×</span> |
| </div> |
| <input type="range" id="speedSlider" min="0.5" max="2.0" step="0.1" value="1.0" |
| oninput="document.getElementById('speedVal').textContent = parseFloat(this.value).toFixed(1) + '×'" /> |
| </div> |
| <div style="display:flex;flex-direction:column;gap:6px;margin-left:20px"> |
| <span class="control-label">Format</span> |
| <div class="format-toggle"> |
| <button class="format-btn active" id="fmtWav" |
| onclick="setFormat('wav')">WAV</button> |
| <button class="format-btn" id="fmtMp3" onclick="setFormat('mp3')">MP3</button> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <button class="generate-btn" id="generateBtn" onclick="generateAudio()"> |
| <span class="spinner" id="btnSpinner"></span> |
| <span class="btn-text" id="btnText"></span> |
| </button> |
|
|
| |
| <div class="card output-card" style="margin-top:16px"> |
| <div class="card-header"> |
| <span class="card-title">Output</span> |
| <div class="meta-row" id="metaRow"></div> |
| </div> |
| <div class="card-body"> |
| <div class="waveform-placeholder" id="waveformPlaceholder"> |
| <div id="waveformBars" class="waveform-bars" style="display:none"></div> |
| <span id="waveformHint" style="font-size:12px">Generate audio to see output</span> |
| </div> |
|
|
| <div class="audio-player" id="audioPlayer"> |
| <audio id="audioEl" preload="auto"></audio> |
| <div class="player-controls"> |
| <button class="play-btn" id="playBtn" onclick="togglePlay()">▶</button> |
| <div class="progress-wrap"> |
| <div class="progress-bar-bg" id="progressBg" onclick="seekAudio(event)"> |
| <div class="progress-bar-fill" id="progressFill"></div> |
| </div> |
| <div class="time-row"> |
| <span id="timeEl">0:00</span> |
| <span id="durationEl">0:00</span> |
| </div> |
| </div> |
| </div> |
| <div class="download-row"> |
| <button class="action-btn" id="downloadBtn" onclick="downloadAudio()">⬇ |
| Download</button> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
|
|
|
|
| |
| <div class="tab-panel" id="tab-api"> |
| <div style="display:flex;justify-content:flex-end;margin-bottom:20px;gap:8px"> |
| <a href="/docs" target="_blank" class="hist-btn" style="text-decoration:none"> |
| <span>📋</span> Swagger UI |
| </a> |
| </div> |
| <div class="api-grid"> |
|
|
| <div class="endpoint-card"> |
| <div class="endpoint-header"> |
| <span class="method-badge POST">POST</span> |
| <span class="endpoint-path">/tts</span> |
| </div> |
| <div class="endpoint-body"> |
| <p style="font-size:12px;color:var(--text-dim);margin-bottom:12px">Synthesize text to audio. |
| Returns raw audio bytes.</p> |
| <pre><span class="code-comment">// Request body</span> |
| { |
| <span class="code-key">"text"</span>: <span class="code-str">"Hello world!"</span>, |
| <span class="code-key">"voice"</span>: <span class="code-str">"af_heart"</span>, |
| <span class="code-key">"speed"</span>: <span class="code-num">1.0</span>, |
| <span class="code-key">"output_format"</span>: <span class="code-str">"wav"</span> |
| } |
|
|
| <span class="code-comment">// Response: audio/wav or audio/mpeg stream</span> |
| <span class="code-comment">// Headers:</span> |
| <span class="code-comment">// X-Duration-Seconds: 3.45</span> |
| <span class="code-comment">// Content-Disposition: attachment; filename="kokoro_af_heart.wav"</span></pre> |
| </div> |
| </div> |
|
|
| <div class="endpoint-card"> |
| <div class="endpoint-header"> |
| <span class="method-badge GET">GET</span> |
| <span class="endpoint-path">/voices</span> |
| </div> |
| <div class="endpoint-body"> |
| <p style="font-size:12px;color:var(--text-dim);margin-bottom:12px">List all available voices. |
| </p> |
| <pre><span class="code-comment">// Response</span> |
| { |
| <span class="code-key">"voices"</span>: { |
| <span class="code-key">"af_heart"</span>: { |
| <span class="code-key">"label"</span>: <span class="code-str">"Heart"</span>, |
| <span class="code-key">"lang"</span>: <span class="code-str">"en-US"</span>, |
| <span class="code-key">"gender"</span>: <span class="code-str">"female"</span>, |
| <span class="code-key">"flag"</span>: <span class="code-str">"🇺🇸"</span> |
| }, |
| ... |
| }, |
| <span class="code-key">"total"</span>: <span class="code-num">42</span> |
| }</pre> |
| </div> |
| </div> |
|
|
| <div class="endpoint-card"> |
| <div class="endpoint-header"> |
| <span class="method-badge GET">GET</span> |
| <span class="endpoint-path">/health</span> |
| </div> |
| <div class="endpoint-body"> |
| <p style="font-size:12px;color:var(--text-dim);margin-bottom:12px">Model and device status.</p> |
| <pre><span class="code-comment">// Response</span> |
| { |
| <span class="code-key">"status"</span>: <span class="code-str">"ok"</span>, |
| <span class="code-key">"model_loaded"</span>: <span class="code-num">true</span>, |
| <span class="code-key">"device"</span>: <span class="code-str">"cuda"</span>, |
| <span class="code-key">"cuda"</span>: <span class="code-num">true</span>, |
| <span class="code-key">"pipelines"</span>: <span class="code-str">["a","b","e","f","h","i","j","p","z"]</span> |
| }</pre> |
| </div> |
| </div> |
|
|
| </div> |
|
|
| <div style="margin-top:24px"> |
| <div class="card"> |
| <div class="card-header"><span class="card-title">Quick Start (Python)</span></div> |
| <div class="card-body"> |
| <pre><span class="code-comment"># pip install requests</span> |
| import requests |
|
|
| resp = requests.post(<span class="code-str">"http://localhost:7860/tts"</span>, json={ |
| <span class="code-str">"text"</span>: <span class="code-str">"Hello from Kokoro TTS!"</span>, |
| <span class="code-str">"voice"</span>: <span class="code-str">"af_heart"</span>, |
| <span class="code-str">"speed"</span>: <span class="code-num">1.0</span>, |
| <span class="code-str">"output_format"</span>: <span class="code-str">"wav"</span> |
| }) |
|
|
| with open(<span class="code-str">"output.wav"</span>, <span class="code-str">"wb"</span>) as f: |
| f.write(resp.content) |
|
|
| duration = resp.headers.get(<span class="code-str">"X-Duration-Seconds"</span>) |
| print(<span class="code-str">f"Duration: {duration}s"</span>)</pre> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| </main> |
|
|
| |
| <footer> |
| <span>Kokoro TTS · Powered by <a href="https://huggingface.co/hexgrad/Kokoro-82M" |
| target="_blank">hexgrad/Kokoro-82M</a></span> |
| <span id="deviceInfo">—</span> |
| </footer> |
|
|
| |
| <div class="toast-container" id="toastContainer"></div> |
|
|
| <script> |
| |
| let allVoices = {}; |
| let selectedVoice = 'af_heart'; |
| let selectedFormat = 'wav'; |
| let currentAudioUrl = null; |
| let selectedHistFile = null; |
| |
| |
| const EXAMPLES = [ |
| { label: "🎙️ Welcome", text: "Welcome to Kokoro TTS! A powerful text-to-speech system running locally on your machine." }, |
| { label: "📢 Announcement", text: "Attention please! The meeting has been rescheduled to 3 PM in conference room B." }, |
| { label: "🤖 Tech Demo", text: "This system uses a lightweight 82 million parameter model to generate high-quality speech in multiple languages." }, |
| { label: "🌍 Multilingual", text: "Bonjour tout le monde! Hola amigos! नमस्ते दुनिया!" }, |
| { label: "📖 Narration", text: "In a distant galaxy, beyond the reach of ordinary telescopes, a civilization had mastered the art of turning thought into sound." }, |
| { label: "💼 Business", text: "Q3 results exceeded expectations, with revenue growing 24% year-over-year. The board has approved a special dividend." }, |
| { label: "🔢 Numbers", text: "The event starts at 9:30 AM on January 15th, 2025. Please contact us at extension 4200 for more information." }, |
| { label: "😄 Casual", text: "Hey! What's up? Just wanted to let you know that the pizza arrived and it's absolutely amazing!" }, |
| ]; |
| |
| function buildExamples() { |
| const grid = document.getElementById('examplesGrid'); |
| grid.innerHTML = ''; |
| EXAMPLES.forEach((e, i) => { |
| const btn = document.createElement('button'); |
| btn.className = 'example-chip'; |
| btn.textContent = e.label; |
| btn.onclick = () => setExample(e.text); |
| grid.appendChild(btn); |
| }); |
| } |
| |
| function setExample(text) { |
| document.getElementById('textInput').value = text; |
| updateCharCount(); |
| } |
| |
| |
| function updateCharCount() { |
| const len = document.getElementById('textInput').value.length; |
| const el = document.getElementById('charCount'); |
| el.textContent = `${len} / 5000`; |
| el.className = 'char-count' + (len > 4500 ? ' danger' : len > 4000 ? ' warn' : ''); |
| } |
| |
| |
| function switchTab(name, btn) { |
| document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active')); |
| document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); |
| document.getElementById(`tab-${name}`).classList.add('active'); |
| btn.classList.add('active'); |
| if (name === 'history') loadHistory(); |
| } |
| |
| |
| function setFormat(fmt) { |
| selectedFormat = fmt; |
| document.getElementById('fmtWav').classList.toggle('active', fmt === 'wav'); |
| document.getElementById('fmtMp3').classList.toggle('active', fmt === 'mp3'); |
| } |
| |
| |
| async function loadVoices() { |
| try { |
| const res = await fetch('/voices'); |
| const data = await res.json(); |
| allVoices = data.voices; |
| renderVoices(Object.entries(allVoices)); |
| if (allVoices[selectedVoice]) { |
| updateSelectedVoiceLabel(selectedVoice); |
| } else { |
| selectedVoice = Object.keys(allVoices)[0]; |
| updateSelectedVoiceLabel(selectedVoice); |
| } |
| } catch (e) { |
| console.error('Voice load failed:', e); |
| } |
| } |
| |
| function renderVoices(entries) { |
| const list = document.getElementById('voiceList'); |
| if (!entries.length) { |
| list.innerHTML = '<div style="padding:16px;font-size:12px;color:var(--text-muted);text-align:center">No voices found</div>'; |
| return; |
| } |
| list.innerHTML = entries.map(([id, info]) => ` |
| <div class="voice-item ${id === selectedVoice ? 'selected' : ''}" onclick="selectVoice('${id}')" id="vi_${id}"> |
| <span class="voice-flag">${info.flag}</span> |
| <div class="voice-info"> |
| <div class="voice-name">${info.label}</div> |
| <div class="voice-meta">${info.lang}</div> |
| </div> |
| <span class="voice-gender ${info.gender}">${info.gender}</span> |
| </div> |
| `).join(''); |
| } |
| |
| function filterVoices(q) { |
| const lower = q.toLowerCase(); |
| const filtered = Object.entries(allVoices).filter(([id, info]) => |
| info.label.toLowerCase().includes(lower) || |
| info.lang.toLowerCase().includes(lower) || |
| id.toLowerCase().includes(lower) |
| ); |
| renderVoices(filtered); |
| } |
| |
| function selectVoice(id) { |
| selectedVoice = id; |
| document.querySelectorAll('.voice-item').forEach(el => el.classList.remove('selected')); |
| const el = document.getElementById(`vi_${id}`); |
| if (el) el.classList.add('selected'); |
| updateSelectedVoiceLabel(id); |
| } |
| |
| function updateSelectedVoiceLabel(id) { |
| const info = allVoices[id]; |
| if (info) { |
| document.getElementById('selectedVoiceLabel').textContent = `${info.flag} ${info.label}`; |
| } |
| } |
| |
| let currentBlob = null; |
| let currentBlobUrl = null; |
| |
| async function generateAudio() { |
| const text = document.getElementById('textInput').value.trim(); |
| if (!text) { toast('Please enter some text', 'error'); return; } |
| |
| const btn = document.getElementById('generateBtn'); |
| btn.classList.add('loading'); |
| btn.disabled = true; |
| |
| try { |
| const resp = await fetch('/tts', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ |
| text, |
| voice: selectedVoice, |
| speed: parseFloat(document.getElementById('speedSlider').value), |
| output_format: selectedFormat, |
| }) |
| }); |
| |
| if (!resp.ok) { |
| const err = await resp.json(); |
| throw new Error(err.detail || 'Generation failed'); |
| } |
| |
| const duration = resp.headers.get('X-Duration-Seconds'); |
| currentBlob = await resp.blob(); |
| |
| |
| if (currentBlobUrl) URL.revokeObjectURL(currentBlobUrl); |
| currentBlobUrl = URL.createObjectURL(currentBlob); |
| |
| showAudioOutput(currentBlobUrl, duration); |
| toast(`Audio generated · ${duration}s`, 'success'); |
| } catch (e) { |
| toast(e.message, 'error'); |
| } finally { |
| btn.classList.remove('loading'); |
| btn.disabled = false; |
| } |
| } |
| |
| function downloadAudio() { |
| if (!currentBlobUrl) return; |
| const a = document.createElement('a'); |
| a.href = currentBlobUrl; |
| a.download = `kokoro_${selectedVoice}.${selectedFormat}`; |
| a.click(); |
| } |
| |
| function showAudioOutput(audioUrl, duration) { |
| const audioEl = document.getElementById('audioEl'); |
| audioEl.src = audioUrl; |
| audioEl.load(); |
| |
| |
| const bars = document.getElementById('waveformBars'); |
| const hint = document.getElementById('waveformHint'); |
| bars.innerHTML = ''; |
| for (let i = 0; i < 40; i++) { |
| const bar = document.createElement('div'); |
| bar.className = 'wbar'; |
| bar.style.height = (8 + Math.random() * 40) + 'px'; |
| bar.style.opacity = 0.3 + Math.random() * 0.6; |
| bars.appendChild(bar); |
| } |
| bars.style.display = 'flex'; |
| hint.style.display = 'none'; |
| |
| const voiceInfo = allVoices[selectedVoice] || {}; |
| document.getElementById('metaRow').innerHTML = ` |
| <span class="meta-badge">${voiceInfo.flag || ''} ${voiceInfo.label || selectedVoice}</span> |
| <span class="meta-badge">${duration}s</span> |
| <span class="meta-badge">${selectedFormat.toUpperCase()}</span> |
| `; |
| |
| document.getElementById('audioPlayer').classList.add('visible'); |
| |
| audioEl.ontimeupdate = () => { |
| const pct = (audioEl.currentTime / (audioEl.duration || 1)) * 100; |
| document.getElementById('progressFill').style.width = pct + '%'; |
| document.getElementById('timeEl').textContent = fmtTime(audioEl.currentTime); |
| }; |
| audioEl.onloadedmetadata = () => { |
| document.getElementById('durationEl').textContent = fmtTime(audioEl.duration); |
| }; |
| audioEl.onended = () => { |
| document.getElementById('playBtn').textContent = '▶'; |
| }; |
| } |
| function togglePlay() { |
| const audio = document.getElementById('audioEl'); |
| const btn = document.getElementById('playBtn'); |
| if (audio.paused) { audio.play(); btn.textContent = '⏸'; } |
| else { audio.pause(); btn.textContent = '▶'; } |
| } |
| |
| function seekAudio(e) { |
| const audio = document.getElementById('audioEl'); |
| const bg = document.getElementById('progressBg'); |
| const rect = bg.getBoundingClientRect(); |
| const pct = (e.clientX - rect.left) / rect.width; |
| audio.currentTime = pct * audio.duration; |
| } |
| |
| function fmtTime(s) { |
| if (!s || isNaN(s)) return '0:00'; |
| const m = Math.floor(s / 60), sec = Math.floor(s % 60); |
| return `${m}:${sec.toString().padStart(2, '0')}`; |
| } |
| |
| function copyAudioUrl() { |
| if (!currentAudioUrl) return; |
| navigator.clipboard.writeText(location.origin + currentAudioUrl); |
| toast('URL copied!', 'success'); |
| } |
| |
| |
| async function loadHistory() { |
| try { |
| const res = await fetch('/history'); |
| const data = await res.json(); |
| renderHistory(data.files); |
| } catch (e) { |
| console.error(e); |
| } |
| } |
| |
| |
| |
| |
| function selectHist(filename, url) { |
| selectedHistFile = filename; |
| const audio = document.getElementById('histAudio'); |
| audio.src = url; |
| document.getElementById('histPreview').classList.add('visible'); |
| document.getElementById('deleteBtn').style.display = 'flex'; |
| loadHistory(); |
| } |
| |
| function histDownload(url, filename) { |
| const a = document.createElement('a'); |
| a.href = url; a.download = filename; a.click(); |
| } |
| |
| async function deleteFile(filename) { |
| try { |
| await fetch(`/history/${filename}`, { method: 'DELETE' }); |
| toast('Deleted', 'success'); |
| if (selectedHistFile === filename) { |
| selectedHistFile = null; |
| document.getElementById('histPreview').classList.remove('visible'); |
| document.getElementById('deleteBtn').style.display = 'none'; |
| } |
| loadHistory(); |
| } catch (e) { toast('Delete failed', 'error'); } |
| } |
| |
| function deleteSelected() { |
| if (selectedHistFile) deleteFile(selectedHistFile); |
| } |
| |
| |
| async function checkHealth() { |
| try { |
| const res = await fetch('/health'); |
| const data = await res.json(); |
| const dot = document.getElementById('statusDot'); |
| const txt = document.getElementById('statusText'); |
| const dev = document.getElementById('deviceInfo'); |
| |
| if (data.model_loaded) { |
| dot.className = 'status-dot ready'; |
| txt.textContent = 'Ready'; |
| } else { |
| dot.className = 'status-dot loading'; |
| txt.textContent = 'Loading…'; |
| setTimeout(checkHealth, 3000); |
| } |
| dev.textContent = `${data.device.toUpperCase()} · ${data.pipelines.length} langs`; |
| } catch (e) { |
| document.getElementById('statusDot').className = 'status-dot error'; |
| document.getElementById('statusText').textContent = 'Offline'; |
| setTimeout(checkHealth, 5000); |
| } |
| } |
| |
| |
| function toast(msg, type = 'success') { |
| const c = document.getElementById('toastContainer'); |
| const el = document.createElement('div'); |
| el.className = `toast ${type}`; |
| el.innerHTML = `<span>${type === 'success' ? '✓' : '✕'}</span> ${msg}`; |
| c.appendChild(el); |
| setTimeout(() => { |
| el.style.animation = 'slideOut 0.3s ease forwards'; |
| setTimeout(() => el.remove(), 300); |
| }, 3000); |
| } |
| |
| |
| buildExamples(); |
| loadVoices(); |
| checkHealth(); |
| |
| |
| document.getElementById('textInput').value = "Hello! Welcome to Kokoro TTS. This is a powerful, open-source text-to-speech system running locally."; |
| updateCharCount(); |
| </script> |
| </body> |
|
|
| </html> |