| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>NeuTTS-FastAPI</title> |
| <link rel="preconnect" href="https://fonts.googleapis.com"> |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> |
| <style> |
| :root { |
| --bg: #0f172a; |
| --fg: #7c6aef; |
| --fg2: #a78bfa; |
| --surface: rgba(30, 41, 59, 1); |
| --surface2: rgba(15, 23, 42, 0.5); |
| --text: #f8fafc; |
| --text-dim: #94a3b8; |
| --border: rgba(148, 163, 184, 0.15); |
| --success: #4ade80; |
| --error: #f87171; |
| --warning: #fbbf24; |
| --radius: 12px; |
| --font: 'Inter', system-ui, -apple-system, sans-serif; |
| } |
| * { margin: 0; padding: 0; box-sizing: border-box; } |
| html { width: 100%; height: 100%; overflow-x: hidden; } |
| body { |
| font-family: var(--font); |
| background: var(--bg); |
| color: var(--text); |
| min-height: 100vh; |
| line-height: 1.5; |
| } |
| |
| |
| .bg-glow { |
| position: fixed; inset: 0; pointer-events: none; z-index: 0; |
| background: radial-gradient(ellipse at 20% 0%, rgba(124,106,239,0.15) 0%, transparent 60%), |
| radial-gradient(ellipse at 80% 100%, rgba(167,139,250,0.08) 0%, transparent 50%); |
| } |
| .bg-grid { |
| position: fixed; inset: 0; pointer-events: none; z-index: 0; |
| background-image: |
| repeating-linear-gradient(0deg, rgba(255,255,255,0.02) 0px, rgba(255,255,255,0.02) 1px, transparent 1px, transparent 24px), |
| repeating-linear-gradient(90deg, rgba(255,255,255,0.02) 0px, rgba(255,255,255,0.02) 1px, transparent 1px, transparent 24px); |
| } |
| |
| .app { position: relative; z-index: 1; max-width: 1100px; margin: 0 auto; padding: 2rem 1.5rem; } |
| |
| |
| header { text-align: center; margin-bottom: 2rem; } |
| .logo { |
| font-size: 2.2rem; font-weight: 700; |
| background: linear-gradient(135deg, var(--fg), var(--fg2), #c084fc); |
| -webkit-background-clip: text; -webkit-text-fill-color: transparent; |
| } |
| .subtitle { color: var(--text-dim); font-size: 0.9rem; margin-top: 4px; } |
| .badges { display: flex; gap: 8px; justify-content: center; margin-top: 12px; flex-wrap: wrap; } |
| .badge { |
| display: flex; align-items: center; gap: 6px; |
| background: var(--surface); border: 1px solid var(--border); |
| border-radius: 20px; padding: 5px 12px; font-size: 0.75rem; color: var(--text-dim); |
| } |
| .dot { width: 7px; height: 7px; border-radius: 50%; } |
| .dot-ok { background: var(--success); box-shadow: 0 0 6px var(--success); } |
| .dot-off { background: var(--error); } |
| .dot-warn { background: var(--warning); } |
| |
| |
| .device-badge { |
| display: inline-flex; align-items: center; gap: 5px; |
| padding: 5px 12px; border-radius: 20px; font-size: 0.75rem; font-weight: 600; |
| background: var(--surface); border: 1px solid var(--border); |
| } |
| .device-badge.gpu { |
| color: #4ade80; border-color: rgba(74,222,128,0.3); |
| background: rgba(74,222,128,0.08); |
| } |
| .device-badge.cpu { |
| color: var(--text-dim); border-color: var(--border); |
| } |
| .device-badge svg { width: 14px; height: 14px; } |
| |
| |
| .gpu-warning { |
| background: rgba(251,191,36,0.1); border: 1px solid rgba(251,191,36,0.3); |
| border-radius: var(--radius); padding: 14px 18px; margin-bottom: 1.5rem; |
| display: flex; align-items: flex-start; gap: 12px; |
| font-size: 0.85rem; color: var(--warning); line-height: 1.5; |
| } |
| .gpu-warning-icon { font-size: 1.2rem; flex-shrink: 0; margin-top: 1px; } |
| .gpu-warning-content { flex: 1; } |
| .gpu-warning-title { font-weight: 700; margin-bottom: 4px; } |
| .gpu-warning-text { font-size: 0.78rem; opacity: 0.9; white-space: pre-wrap; font-family: 'Consolas', 'Monaco', monospace; } |
| .gpu-warning-dismiss { |
| background: none; border: 1px solid rgba(251,191,36,0.3); border-radius: 4px; |
| color: var(--warning); cursor: pointer; padding: 2px 8px; font-size: 0.72rem; |
| font-family: var(--font); flex-shrink: 0; transition: all 0.2s; |
| } |
| .gpu-warning-dismiss:hover { background: rgba(251,191,36,0.15); } |
| |
| |
| .main-grid { |
| display: grid; |
| grid-template-columns: 1fr 340px; |
| gap: 1.5rem; |
| align-items: start; |
| } |
| @media (max-width: 860px) { |
| .main-grid { grid-template-columns: 1fr; } |
| } |
| |
| |
| .card { |
| background: var(--surface); |
| border: 1px solid var(--border); |
| border-radius: var(--radius); |
| padding: 1.25rem; |
| } |
| .card-label { |
| font-size: 0.72rem; font-weight: 600; text-transform: uppercase; |
| letter-spacing: 0.6px; color: var(--text-dim); margin-bottom: 12px; |
| } |
| |
| |
| .text-editor textarea { |
| width: 100%; min-height: 180px; |
| background: var(--surface2); border: 1px solid var(--border); |
| border-radius: 8px; padding: 14px; color: var(--text); |
| font-size: 0.95rem; font-family: var(--font); resize: vertical; |
| transition: border-color 0.2s; |
| } |
| .text-editor textarea:focus { outline: none; border-color: var(--fg); } |
| .text-meta { |
| display: flex; justify-content: space-between; align-items: center; |
| margin-top: 6px; font-size: 0.72rem; color: var(--text-dim); |
| } |
| .text-meta kbd { |
| background: var(--surface2); border: 1px solid var(--border); |
| border-radius: 4px; padding: 1px 5px; font-size: 0.68rem; |
| } |
| |
| |
| .player { margin-top: 1.25rem; } |
| .player-bar { |
| display: flex; align-items: center; gap: 10px; |
| background: var(--surface2); border: 1px solid var(--border); |
| border-radius: 10px; padding: 8px 12px; height: 48px; |
| } |
| .player-btn { |
| width: 36px; height: 36px; border-radius: 50%; |
| background: var(--fg); border: none; cursor: pointer; |
| display: flex; align-items: center; justify-content: center; |
| transition: all 0.2s; flex-shrink: 0; |
| } |
| .player-btn:hover { transform: scale(1.08); box-shadow: 0 0 16px rgba(124,106,239,0.3); } |
| .player-btn svg { fill: white; width: 16px; height: 16px; } |
| .seek-wrap { flex: 1; min-width: 0; } |
| .seek-slider { |
| -webkit-appearance: none; width: 100%; height: 4px; |
| background: rgba(124,106,239,0.2); border-radius: 2px; |
| outline: none; cursor: pointer; |
| } |
| .seek-slider::-webkit-slider-thumb { |
| -webkit-appearance: none; width: 14px; height: 14px; |
| border-radius: 50%; background: var(--fg); cursor: pointer; |
| transition: transform 0.15s; |
| } |
| .seek-slider::-webkit-slider-thumb:hover { transform: scale(1.2); } |
| .seek-slider::-moz-range-thumb { |
| width: 14px; height: 14px; border: none; |
| border-radius: 50%; background: var(--fg); cursor: pointer; |
| } |
| .time-display { |
| font-size: 0.78rem; color: var(--text-dim); min-width: 80px; |
| text-align: center; font-variant-numeric: tabular-nums; |
| border-left: 1px solid var(--border); padding-left: 10px; |
| } |
| .vol-group { |
| display: flex; align-items: center; gap: 6px; |
| border-left: 1px solid var(--border); padding-left: 10px; |
| } |
| .vol-icon { color: var(--fg); opacity: 0.7; flex-shrink: 0; } |
| .vol-slider { |
| -webkit-appearance: none; width: 70px; height: 4px; |
| background: rgba(124,106,239,0.2); border-radius: 2px; |
| outline: none; cursor: pointer; |
| } |
| .vol-slider::-webkit-slider-thumb { |
| -webkit-appearance: none; width: 12px; height: 12px; |
| border-radius: 50%; background: var(--fg); cursor: pointer; |
| } |
| .vol-slider::-moz-range-thumb { |
| width: 12px; height: 12px; border: none; |
| border-radius: 50%; background: var(--fg); cursor: pointer; |
| } |
| |
| |
| .wave-box { |
| position: relative; width: 100%; height: 56px; |
| background: var(--surface2); border-radius: 8px; |
| overflow: hidden; margin-top: 8px; |
| } |
| .wave-box canvas { width: 100%; height: 100%; } |
| .gen-progress { |
| position: absolute; bottom: 0; left: 0; height: 3px; |
| background: var(--fg); border-radius: 2px; transition: width 0.3s; |
| } |
| |
| |
| .dl-wrap { |
| position: absolute; bottom: 8px; right: 8px; z-index: 5; |
| opacity: 0; pointer-events: none; transition: opacity 0.3s; |
| } |
| .dl-wrap.ready { opacity: 1; pointer-events: auto; } |
| .dl-btn { |
| width: 34px; height: 34px; border-radius: 6px; |
| background: var(--surface); border: 1px solid var(--border); |
| display: flex; align-items: center; justify-content: center; |
| cursor: pointer; color: var(--text); transition: all 0.2s; |
| position: relative; overflow: visible; |
| } |
| .dl-btn:hover { box-shadow: 0 0 12px rgba(124,106,239,0.3); transform: scale(1.05); } |
| .dl-glow { |
| position: absolute; inset: -4px; border-radius: 8px; |
| background: conic-gradient(from 0deg, var(--fg), #a78bfa, var(--fg)); |
| animation: glow-spin 3s linear infinite; filter: blur(6px); opacity: 0.4; z-index: -1; |
| } |
| @keyframes glow-spin { to { transform: rotate(360deg); } } |
| |
| |
| .controls-panel { display: flex; flex-direction: column; gap: 1rem; } |
| |
| |
| .voice-list { max-height: 160px; overflow-y: auto; } |
| .voice-item { |
| display: flex; align-items: center; gap: 8px; |
| padding: 8px 10px; border-radius: 6px; cursor: pointer; |
| transition: background 0.15s; font-size: 0.85rem; |
| } |
| .voice-item:hover { background: rgba(124,106,239,0.1); } |
| .voice-item.active { background: rgba(124,106,239,0.2); border: 1px solid rgba(124,106,239,0.3); } |
| .voice-item .v-name { font-weight: 500; } |
| .voice-item .v-meta { font-size: 0.72rem; color: var(--text-dim); margin-left: auto; } |
| .voice-item .v-lang { |
| font-size: 0.65rem; background: rgba(124,106,239,0.15); |
| color: var(--fg2); border-radius: 4px; padding: 1px 6px; |
| } |
| .voice-item .v-delete { |
| font-size: 0.72rem; color: var(--error); cursor: pointer; opacity: 0.5; |
| font-weight: 700; transition: opacity 0.2s; margin-left: 4px; |
| } |
| .voice-item .v-delete:hover { opacity: 1; } |
| .voice-item.voice-unavailable { |
| opacity: 0.4; cursor: not-allowed; pointer-events: none; |
| } |
| .voice-item.voice-unavailable .v-name::after { |
| content: ' (no audio)'; font-size: 0.65rem; font-weight: 400; color: var(--text-dim); |
| } |
| |
| |
| .voice-upload-toggle { |
| width: 100%; padding: 10px; margin-top: 10px; |
| background: transparent; border: 2px dashed var(--border); |
| border-radius: 8px; color: var(--text-dim); font-size: 0.82rem; |
| cursor: pointer; font-family: var(--font); transition: all 0.2s; |
| } |
| .voice-upload-toggle:hover { border-color: var(--fg); color: var(--fg2); } |
| .voice-upload-panel { |
| display: none; margin-top: 10px; padding: 12px; |
| background: var(--surface2); border: 1px solid var(--border); |
| border-radius: 8px; |
| } |
| .voice-upload-panel.open { display: block; } |
| .voice-upload-panel input[type="text"], |
| .voice-upload-panel textarea, |
| .voice-upload-panel select { |
| width: 100%; padding: 8px 10px; |
| background: var(--bg); border: 1px solid var(--border); |
| border-radius: 6px; color: var(--text); font-size: 0.82rem; |
| font-family: var(--font); margin-bottom: 8px; transition: border-color 0.2s; |
| } |
| .voice-upload-panel input:focus, |
| .voice-upload-panel textarea:focus, |
| .voice-upload-panel select:focus { outline: none; border-color: var(--fg); } |
| .voice-upload-panel textarea { min-height: 60px; resize: vertical; } |
| .voice-upload-panel .field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px; } |
| |
| .drop-zone { |
| border: 2px dashed var(--border); border-radius: 8px; |
| padding: 20px; text-align: center; cursor: pointer; |
| color: var(--text-dim); font-size: 0.8rem; transition: all 0.2s; |
| margin-bottom: 8px; |
| } |
| .drop-zone:hover, .drop-zone.dragover { |
| border-color: var(--fg); color: var(--fg2); |
| background: rgba(124,106,239,0.05); |
| } |
| .drop-zone.has-file { |
| border-color: var(--success); color: var(--success); |
| background: rgba(74,222,128,0.05); |
| } |
| |
| .record-section { |
| text-align: center; margin: 8px 0; |
| padding: 8px 0; border-top: 1px solid var(--border); |
| } |
| .record-section-label { font-size: 0.7rem; color: var(--text-dim); margin-bottom: 6px; } |
| .record-btn { |
| width: 44px; height: 44px; border-radius: 50%; |
| background: rgba(248,113,113,0.15); border: 2px solid rgba(248,113,113,0.3); |
| color: var(--error); cursor: pointer; font-size: 0.7rem; font-weight: 600; |
| font-family: var(--font); transition: all 0.2s; |
| display: inline-flex; align-items: center; justify-content: center; |
| } |
| .record-btn:hover { background: rgba(248,113,113,0.25); transform: scale(1.05); } |
| .record-btn.recording { |
| background: var(--error); color: white; border-color: var(--error); |
| animation: record-pulse 1.2s ease-in-out infinite; |
| } |
| @keyframes record-pulse { |
| 0%, 100% { box-shadow: 0 0 0 0 rgba(248,113,113,0.5); } |
| 50% { box-shadow: 0 0 0 8px rgba(248,113,113,0); } |
| } |
| .record-timer { |
| display: inline-block; margin-left: 8px; |
| font-size: 0.82rem; font-variant-numeric: tabular-nums; |
| color: var(--text-dim); min-width: 36px; |
| } |
| |
| .voice-upload-progress { |
| height: 4px; background: var(--border); border-radius: 2px; |
| overflow: hidden; margin-bottom: 8px; display: none; |
| } |
| .voice-upload-progress-bar { |
| height: 100%; background: linear-gradient(90deg, var(--fg), var(--fg2)); |
| border-radius: 2px; width: 0%; transition: width 0.3s; |
| } |
| |
| .btn-upload { |
| width: 100%; padding: 10px; border: none; border-radius: 6px; |
| background: linear-gradient(135deg, var(--fg), #6d5ce7); |
| color: white; font-size: 0.85rem; font-weight: 600; |
| cursor: pointer; font-family: var(--font); transition: all 0.2s; |
| } |
| .btn-upload:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(124,106,239,0.3); } |
| .btn-upload:disabled { opacity: 0.4; cursor: not-allowed; transform: none; } |
| |
| .voice-upload-status { |
| margin-top: 8px; padding: 8px; border-radius: 6px; |
| font-size: 0.78rem; text-align: center; display: none; |
| } |
| .voice-upload-status.upload-success { |
| display: block; background: rgba(74,222,128,0.1); |
| border: 1px solid rgba(74,222,128,0.2); color: var(--success); |
| } |
| .voice-upload-status.upload-error { |
| display: block; background: rgba(248,113,113,0.1); |
| border: 1px solid rgba(248,113,113,0.2); color: var(--error); |
| } |
| |
| |
| select, input[type="text"] { |
| width: 100%; padding: 9px 10px; |
| background: var(--surface2); border: 1px solid var(--border); |
| border-radius: 8px; color: var(--text); font-size: 0.85rem; |
| font-family: var(--font); cursor: pointer; transition: border-color 0.2s; |
| } |
| select:focus, input[type="text"]:focus { outline: none; border-color: var(--fg); } |
| |
| .field { margin-bottom: 12px; } |
| .field-label { |
| font-size: 0.75rem; font-weight: 500; color: var(--text-dim); |
| margin-bottom: 5px; display: block; |
| } |
| .field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } |
| |
| |
| .speed-row { display: flex; align-items: center; gap: 10px; } |
| .speed-range { |
| flex: 1; -webkit-appearance: none; height: 5px; |
| background: rgba(124,106,239,0.2); border-radius: 3px; outline: none; |
| accent-color: var(--fg); |
| } |
| .speed-range::-webkit-slider-thumb { |
| -webkit-appearance: none; width: 16px; height: 16px; |
| border-radius: 50%; background: var(--fg); cursor: pointer; |
| } |
| .speed-range::-moz-range-thumb { |
| width: 16px; height: 16px; border: none; |
| border-radius: 50%; background: var(--fg); cursor: pointer; |
| } |
| .speed-val { |
| font-weight: 600; color: var(--fg2); min-width: 42px; text-align: center; |
| font-size: 0.9rem; |
| } |
| |
| |
| .check-row { |
| display: flex; align-items: center; gap: 7px; |
| font-size: 0.82rem; color: var(--text-dim); cursor: pointer; |
| } |
| .check-row input { accent-color: var(--fg); width: 15px; height: 15px; cursor: pointer; } |
| |
| |
| .btn { |
| width: 100%; padding: 12px 20px; border: none; border-radius: 8px; |
| font-size: 0.95rem; font-weight: 600; cursor: pointer; |
| transition: all 0.2s; font-family: var(--font); |
| display: flex; align-items: center; justify-content: center; gap: 8px; |
| } |
| .btn-gen { |
| background: linear-gradient(135deg, var(--fg), #6d5ce7); |
| color: white; position: relative; overflow: hidden; |
| } |
| .btn-gen:hover:not(:disabled) { |
| transform: translateY(-1px); |
| box-shadow: 0 6px 20px rgba(124,106,239,0.35); |
| } |
| .btn-gen:disabled { opacity: 0.5; cursor: not-allowed; transform: none; } |
| .btn-cancel { |
| background: rgba(248,113,113,0.15); color: var(--error); |
| border: 1px solid rgba(248,113,113,0.3); margin-top: 8px; |
| } |
| .btn-cancel:hover { background: rgba(248,113,113,0.25); } |
| |
| |
| .spinner { |
| width: 18px; height: 18px; border: 2px solid rgba(255,255,255,0.3); |
| border-top-color: white; border-radius: 50%; |
| animation: spin 0.7s linear infinite; display: none; |
| } |
| .spinning .spinner { display: block; } |
| .spinning .btn-label { display: none; } |
| @keyframes spin { to { transform: rotate(360deg); } } |
| |
| |
| .model-panel { margin-top: 1rem; } |
| .model-tag { |
| display: inline-flex; align-items: center; gap: 6px; |
| background: rgba(74,222,128,0.1); border: 1px solid rgba(74,222,128,0.2); |
| border-radius: 6px; padding: 4px 10px; font-size: 0.78rem; |
| color: var(--success); margin: 3px; |
| } |
| .model-tag .unload-x { |
| cursor: pointer; opacity: 0.6; font-weight: 700; |
| transition: opacity 0.2s; |
| } |
| .model-tag .unload-x:hover { opacity: 1; } |
| .model-load-row { display: flex; gap: 8px; margin-top: 8px; } |
| .btn-sm { |
| padding: 7px 14px; border-radius: 6px; border: 1px solid var(--border); |
| background: var(--surface2); color: var(--text); font-size: 0.8rem; |
| cursor: pointer; font-family: var(--font); transition: all 0.2s; |
| } |
| .btn-sm:hover { border-color: var(--fg); color: var(--fg2); } |
| |
| |
| .model-tag-expanded { |
| display: flex; align-items: center; gap: 8px; |
| background: rgba(74,222,128,0.06); border: 1px solid rgba(74,222,128,0.15); |
| border-radius: 8px; padding: 8px 12px; font-size: 0.8rem; |
| color: var(--text); margin-bottom: 6px; |
| } |
| .model-tag-expanded .model-name { font-weight: 600; color: var(--success); } |
| .model-tag-expanded .model-device-badge { |
| font-size: 0.68rem; font-weight: 600; padding: 2px 7px; |
| border-radius: 4px; text-transform: uppercase; |
| } |
| .model-device-badge.gpu-badge { |
| background: rgba(74,222,128,0.15); color: #4ade80; border: 1px solid rgba(74,222,128,0.25); |
| } |
| .model-device-badge.cpu-badge { |
| background: rgba(148,163,184,0.1); color: var(--text-dim); border: 1px solid var(--border); |
| } |
| .model-tag-expanded .model-lang { |
| font-size: 0.65rem; background: rgba(124,106,239,0.12); |
| color: var(--fg2); border-radius: 4px; padding: 1px 6px; |
| } |
| .model-tag-expanded .model-actions { margin-left: auto; display: flex; gap: 4px; } |
| .model-tag-expanded .toggle-device-btn { |
| padding: 3px 8px; border-radius: 4px; border: 1px solid var(--border); |
| background: var(--surface2); color: var(--text-dim); font-size: 0.68rem; |
| cursor: pointer; font-family: var(--font); transition: all 0.2s; |
| } |
| .model-tag-expanded .toggle-device-btn:hover { border-color: var(--fg); color: var(--fg2); } |
| .model-tag-expanded .unload-btn { |
| padding: 3px 8px; border-radius: 4px; border: 1px solid rgba(248,113,113,0.2); |
| background: rgba(248,113,113,0.06); color: var(--error); font-size: 0.68rem; |
| cursor: pointer; font-family: var(--font); transition: all 0.2s; font-weight: 600; |
| } |
| .model-tag-expanded .unload-btn:hover { background: rgba(248,113,113,0.15); } |
| |
| |
| .loading-card { |
| display: flex; align-items: center; gap: 10px; |
| background: rgba(124,106,239,0.06); border: 1px solid rgba(124,106,239,0.15); |
| border-radius: 8px; padding: 10px 12px; margin-bottom: 6px; |
| font-size: 0.8rem; color: var(--text-dim); flex-wrap: wrap; |
| } |
| .loading-spinner-sm { |
| width: 16px; height: 16px; border: 2px solid rgba(124,106,239,0.2); |
| border-top-color: var(--fg); border-radius: 50%; |
| animation: spin 0.8s linear infinite; flex-shrink: 0; |
| } |
| .loading-card .loading-model { font-weight: 600; color: var(--fg2); } |
| .loading-card .loading-phase { color: var(--text-dim); font-size: 0.72rem; } |
| .loading-card .loading-time { margin-left: auto; font-size: 0.72rem; font-variant-numeric: tabular-nums; } |
| .loading-card.error-card { |
| background: rgba(248,113,113,0.06); border-color: rgba(248,113,113,0.2); |
| } |
| .loading-card.error-card .loading-model { color: var(--error); } |
| .loading-card.ready-card { |
| background: rgba(74,222,128,0.06); border-color: rgba(74,222,128,0.2); |
| } |
| .loading-card.ready-card .loading-model { color: var(--success); } |
| |
| .loading-progress-bar { |
| width: 100%; height: 3px; background: rgba(124,106,239,0.1); |
| border-radius: 2px; margin-top: 4px; overflow: hidden; |
| flex-basis: 100%; |
| } |
| .loading-progress-bar-fill { |
| height: 100%; border-radius: 2px; transition: width 0.5s, background 0.5s; |
| } |
| |
| |
| .lang-filter { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 8px; } |
| .lang-pill { |
| padding: 3px 10px; border-radius: 12px; font-size: 0.7rem; |
| border: 1px solid var(--border); background: var(--surface2); |
| color: var(--text-dim); cursor: pointer; transition: all 0.2s; |
| font-family: var(--font); |
| } |
| .lang-pill:hover { border-color: var(--fg); color: var(--fg2); } |
| .lang-pill.active { |
| background: rgba(124,106,239,0.15); border-color: rgba(124,106,239,0.3); |
| color: var(--fg2); font-weight: 600; |
| } |
| |
| |
| .voice-hint { |
| background: rgba(251,191,36,0.08); border: 1px solid rgba(251,191,36,0.2); |
| border-radius: 6px; padding: 8px 10px; font-size: 0.75rem; |
| color: var(--warning); margin-top: 8px; line-height: 1.4; |
| } |
| |
| |
| .status-msg { |
| padding: 10px 14px; border-radius: 8px; |
| font-size: 0.82rem; font-weight: 500; text-align: center; |
| margin-top: 10px; transition: all 0.3s; opacity: 0; |
| } |
| .status-msg.info { opacity: 1; background: rgba(124,106,239,0.1); border: 1px solid rgba(124,106,239,0.2); } |
| .status-msg.error { opacity: 1; background: rgba(248,113,113,0.1); border: 1px solid rgba(248,113,113,0.2); color: var(--error); } |
| .status-msg.success { opacity: 1; background: rgba(74,222,128,0.1); border: 1px solid rgba(74,222,128,0.2); color: var(--success); } |
| |
| |
| ::-webkit-scrollbar { width: 6px; } |
| ::-webkit-scrollbar-track { background: transparent; } |
| ::-webkit-scrollbar-thumb { background: rgba(124,106,239,0.3); border-radius: 3px; } |
| |
| |
| @media (max-width: 860px) { |
| .player-bar { flex-wrap: wrap; height: auto; gap: 8px; padding: 10px; } |
| .seek-wrap { order: 10; flex-basis: 100%; } |
| .vol-group { border: none; padding: 0; } |
| .time-display { border: none; padding: 0; min-width: 60px; } |
| .wave-box { height: 40px; } |
| } |
| </style> |
| </head> |
| <body> |
| <div class="bg-glow"></div> |
| <div class="bg-grid"></div> |
|
|
| <div class="app"> |
| <header> |
| <div class="logo">NeuTTS-FastAPI</div> |
| <p class="subtitle">OpenAI-compatible Text-to-Speech powered by Neuphonic NeuTTS</p> |
| <div class="badges"> |
| <div class="badge"><span class="dot dot-off" id="srv-dot"></span><span id="srv-text">Connecting...</span></div> |
| <div class="badge" id="model-badge">Models: ...</div> |
| <div class="badge" id="voice-badge">Voices: ...</div> |
| <div class="device-badge cpu" id="device-badge"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="16" height="16" rx="2"/><path d="M9 1v3M15 1v3M9 20v3M15 20v3M1 9h3M1 15h3M20 9h3M20 15h3"/></svg> |
| <span id="device-badge-text">CPU Only</span> |
| </div> |
| </div> |
| </header> |
|
|
| <div id="gpu-warning-banner" style="display:none"></div> |
|
|
| <div class="main-grid"> |
| |
| <div class="left-col"> |
| <div class="card text-editor"> |
| <div class="card-label">Text Input</div> |
| <textarea id="tts-text" placeholder="Enter text to synthesize..." maxlength="10000">Hello, this is a test of the NeuTTS text-to-speech system. It supports multiple languages including English, German, French and Spanish.</textarea> |
| <div class="text-meta"> |
| <span><span id="char-cnt">0</span> / 10,000 characters</span> |
| <span><kbd>Ctrl</kbd>+<kbd>Enter</kbd> to generate</span> |
| </div> |
| </div> |
|
|
| <div class="card player"> |
| <div class="card-label">Audio Player</div> |
| <div class="player-bar"> |
| <button class="player-btn" id="play-btn" title="Play/Pause"> |
| <svg id="play-icon" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg> |
| <svg id="pause-icon" viewBox="0 0 24 24" style="display:none"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg> |
| </button> |
| <div class="seek-wrap"> |
| <input type="range" class="seek-slider" id="seek-slider" min="0" max="1000" value="0"> |
| </div> |
| <div class="time-display" id="time-disp">0:00 / 0:00</div> |
| <div class="vol-group"> |
| <svg class="vol-icon" width="18" height="18" viewBox="0 0 24 24"><path fill="currentColor" d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/></svg> |
| <input type="range" class="vol-slider" id="vol-slider" min="0" max="100" value="100"> |
| </div> |
| </div> |
| <div class="wave-box" id="wave-box"> |
| <canvas id="wave-canvas"></canvas> |
| <div class="gen-progress" id="gen-progress" style="width:0%"></div> |
| <div class="dl-wrap" id="dl-wrap"> |
| <a class="dl-btn" id="dl-btn" download title="Download audio"> |
| <div class="dl-glow"></div> |
| <svg width="14" height="14" viewBox="0 0 16 16" fill="none"> |
| <path d="M8 10L4.5 6.5h7L8 10z" fill="currentColor"/> |
| <path d="M8 2v8M3 13h10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/> |
| </svg> |
| </a> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="controls-panel"> |
| |
| <div class="card"> |
| <div class="card-label">Voice</div> |
| <input type="text" id="voice-search" placeholder="Search voices..." autocomplete="off"> |
| <div class="voice-list" id="voice-list"></div> |
| <div id="voice-hint-container"></div> |
| <button class="voice-upload-toggle" id="voice-upload-toggle">+ Clone a Voice (Upload or Record)</button> |
| <div class="voice-upload-panel" id="voice-upload-panel"> |
| <input type="text" id="upload-voice-id" placeholder="Voice ID (e.g. my-voice)" autocomplete="off"> |
| <div class="field-row"> |
| <select id="upload-language"> |
| <option value="unknown">Language...</option> |
| <option value="en-us">English (en-us)</option> |
| <option value="de">German (de)</option> |
| <option value="fr-fr">French (fr-fr)</option> |
| <option value="es">Spanish (es)</option> |
| <option value="other">Other</option> |
| </select> |
| <select id="upload-gender"> |
| <option value="unknown">Gender...</option> |
| <option value="male">Male</option> |
| <option value="female">Female</option> |
| <option value="other">Other</option> |
| </select> |
| </div> |
| <div class="drop-zone" id="upload-drop-zone">Drop WAV file here or click to browse |
| <input type="file" id="upload-file-input" accept=".wav,audio/wav" style="display:none"> |
| </div> |
| <div class="record-section"> |
| <div class="record-section-label">or record from microphone</div> |
| <button class="record-btn" id="record-btn" title="Record from microphone">REC</button> |
| <span class="record-timer" id="record-timer">0:00</span> |
| </div> |
| <textarea id="upload-ref-text" placeholder="Exact transcription of the audio..."></textarea> |
| <div class="voice-upload-progress" id="upload-progress"> |
| <div class="voice-upload-progress-bar" id="upload-progress-bar"></div> |
| </div> |
| <button class="btn-upload" id="upload-btn" disabled>Upload & Clone Voice</button> |
| <div class="voice-upload-status" id="upload-status"></div> |
| </div> |
| </div> |
|
|
| |
| <div class="card"> |
| <div class="card-label">Settings</div> |
| <div class="field-row"> |
| <div class="field"> |
| <label class="field-label">Format</label> |
| <select id="fmt-select"> |
| <option value="mp3" selected>MP3</option> |
| <option value="wav">WAV</option> |
| <option value="opus">Opus (OGG)</option> |
| <option value="flac">FLAC</option> |
| <option value="aac">AAC</option> |
| <option value="pcm">PCM (raw)</option> |
| </select> |
| </div> |
| <div class="field"> |
| <label class="field-label">Model</label> |
| <select id="model-select"></select> |
| </div> |
| </div> |
| <div class="field"> |
| <label class="field-label">Speed</label> |
| <div class="speed-row"> |
| <span style="font-size:0.7rem;color:var(--text-dim)">0.25x</span> |
| <input type="range" class="speed-range" id="speed-range" min="0.25" max="4.0" step="0.05" value="1.0"> |
| <span style="font-size:0.7rem;color:var(--text-dim)">4.0x</span> |
| <span class="speed-val" id="speed-val">1.00x</span> |
| </div> |
| </div> |
| <div style="display:flex;gap:16px;flex-wrap:wrap"> |
| <label class="check-row"><input type="checkbox" id="stream-chk"> Streaming</label> |
| <label class="check-row"><input type="checkbox" id="autoplay-chk" checked> Auto-play</label> |
| </div> |
| </div> |
|
|
| |
| <div class="card"> |
| <button class="btn btn-gen" id="gen-btn" disabled> |
| <span class="btn-label">Generate Speech</span> |
| <div class="spinner"></div> |
| </button> |
| <button class="btn btn-cancel" id="cancel-btn" style="display:none">Cancel</button> |
| <div class="status-msg" id="status-msg"></div> |
| </div> |
|
|
| |
| <div class="card model-panel"> |
| <div class="card-label">Model Management</div> |
| <div id="loaded-models-list"></div> |
| <div id="loading-tasks-list"></div> |
| <div class="lang-filter" id="lang-filter"></div> |
| <div class="model-load-row"> |
| <select id="registry-select" style="font-size:0.8rem"></select> |
| <button class="btn-sm" id="load-btn">Load</button> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| <audio id="audio-el" preload="none"></audio> |
|
|
| <script> |
| (function() { |
| const API = location.origin; |
| |
| |
| const $ = id => document.getElementById(id); |
| const ttsText = $('tts-text'); |
| const charCnt = $('char-cnt'); |
| const voiceSearch = $('voice-search'); |
| const voiceList = $('voice-list'); |
| const modelSelect = $('model-select'); |
| const fmtSelect = $('fmt-select'); |
| const speedRange = $('speed-range'); |
| const speedVal = $('speed-val'); |
| const streamChk = $('stream-chk'); |
| const autoplayChk = $('autoplay-chk'); |
| const genBtn = $('gen-btn'); |
| const cancelBtn = $('cancel-btn'); |
| const statusMsg = $('status-msg'); |
| const srvDot = $('srv-dot'); |
| const srvText = $('srv-text'); |
| const modelBadge = $('model-badge'); |
| const voiceBadge = $('voice-badge'); |
| const deviceBadge = $('device-badge'); |
| const deviceBadgeText = $('device-badge-text'); |
| const playBtn = $('play-btn'); |
| const playIcon = $('play-icon'); |
| const pauseIcon = $('pause-icon'); |
| const seekSlider = $('seek-slider'); |
| const timeDisp = $('time-disp'); |
| const volSlider = $('vol-slider'); |
| const waveCanvas = $('wave-canvas'); |
| const genProgress = $('gen-progress'); |
| const dlWrap = $('dl-wrap'); |
| const dlBtn = $('dl-btn'); |
| const loadedModelsList = $('loaded-models-list'); |
| const loadingTasksList = $('loading-tasks-list'); |
| const langFilter = $('lang-filter'); |
| const registrySelect = $('registry-select'); |
| const loadBtn = $('load-btn'); |
| const voiceHintContainer = $('voice-hint-container'); |
| const gpuWarningBanner = $('gpu-warning-banner'); |
| const audio = $('audio-el'); |
| |
| |
| const uploadToggle = $('voice-upload-toggle'); |
| const uploadPanel = $('voice-upload-panel'); |
| const uploadVoiceId = $('upload-voice-id'); |
| const uploadLanguage = $('upload-language'); |
| const uploadGender = $('upload-gender'); |
| const uploadDropZone = $('upload-drop-zone'); |
| const uploadFileInput = $('upload-file-input'); |
| const uploadRefText = $('upload-ref-text'); |
| const uploadProgress = $('upload-progress'); |
| const uploadProgressBar = $('upload-progress-bar'); |
| const uploadBtn = $('upload-btn'); |
| const uploadStatus = $('upload-status'); |
| const recordBtn = $('record-btn'); |
| const recordTimer = $('record-timer'); |
| |
| let voices = []; |
| let selectedVoice = ''; |
| let loadedModels = []; |
| let abortCtrl = null; |
| let waveCtx = waveCanvas.getContext('2d'); |
| let waveAnim = null; |
| let analyser = null; |
| let audioCtx = null; |
| let audioSource = null; |
| let currentBlobUrl = null; |
| let gpuAvailable = false; |
| let gpuName = ''; |
| let loadingTaskTimers = {}; |
| let registryData = []; |
| let activeLangFilter = 'all'; |
| let gpuWarningDismissed = false; |
| |
| |
| let uploadFile = null; |
| let mediaRecorder = null; |
| let recordChunks = []; |
| let recordTimerInterval = null; |
| let recordStartTime = 0; |
| |
| |
| function ensureAudioCtx() { |
| if (!audioCtx) { |
| audioCtx = new (window.AudioContext || window.webkitAudioContext)(); |
| analyser = audioCtx.createAnalyser(); |
| analyser.fftSize = 256; |
| analyser.smoothingTimeConstant = 0.8; |
| audioSource = audioCtx.createMediaElementSource(audio); |
| audioSource.connect(analyser); |
| analyser.connect(audioCtx.destination); |
| } |
| } |
| |
| |
| function resizeCanvas() { |
| const box = $('wave-box'); |
| waveCanvas.width = box.clientWidth * window.devicePixelRatio; |
| waveCanvas.height = box.clientHeight * window.devicePixelRatio; |
| waveCtx.scale(window.devicePixelRatio, window.devicePixelRatio); |
| } |
| resizeCanvas(); |
| window.addEventListener('resize', resizeCanvas); |
| |
| function drawWave() { |
| const w = waveCanvas.width / window.devicePixelRatio; |
| const h = waveCanvas.height / window.devicePixelRatio; |
| waveCtx.clearRect(0, 0, w, h); |
| |
| if (analyser && !audio.paused) { |
| const data = new Uint8Array(analyser.frequencyBinCount); |
| analyser.getByteFrequencyData(data); |
| |
| const barCount = Math.min(data.length, 80); |
| const barW = w / barCount; |
| const grad = waveCtx.createLinearGradient(0, h, 0, 0); |
| grad.addColorStop(0, 'rgba(124,106,239,0.2)'); |
| grad.addColorStop(1, 'rgba(167,139,250,0.8)'); |
| waveCtx.fillStyle = grad; |
| |
| for (let i = 0; i < barCount; i++) { |
| const barH = (data[i] / 255) * h * 0.85; |
| const x = i * barW; |
| waveCtx.fillRect(x + 1, h - barH, barW - 2, barH); |
| } |
| } else { |
| |
| const t = Date.now() / 1000; |
| waveCtx.strokeStyle = 'rgba(124,106,239,0.3)'; |
| waveCtx.lineWidth = 1.5; |
| waveCtx.beginPath(); |
| for (let x = 0; x < w; x++) { |
| const y = h/2 + Math.sin(x/30 + t*2) * 6 + Math.sin(x/15 + t*3) * 3; |
| x === 0 ? waveCtx.moveTo(x, y) : waveCtx.lineTo(x, y); |
| } |
| waveCtx.stroke(); |
| } |
| waveAnim = requestAnimationFrame(drawWave); |
| } |
| drawWave(); |
| |
| |
| ttsText.addEventListener('input', () => { charCnt.textContent = ttsText.value.length; }); |
| charCnt.textContent = ttsText.value.length; |
| |
| |
| speedRange.addEventListener('input', () => { |
| speedVal.textContent = parseFloat(speedRange.value).toFixed(2) + 'x'; |
| }); |
| |
| |
| modelSelect.addEventListener('change', () => { |
| if (!selectedVoice) return; |
| const voice = voices.find(v => v.name === selectedVoice); |
| const model = loadedModels.find(m => m.id === modelSelect.value); |
| if (voice && model && voice.language && model.language |
| && voice.language !== model.language) { |
| showLangMismatchWarning(voice.language); |
| } else { |
| clearLangMismatchWarning(); |
| } |
| }); |
| |
| |
| function fmtTime(s) { |
| if (!s || !isFinite(s)) return '0:00'; |
| const m = Math.floor(s / 60); |
| const sec = Math.floor(s % 60); |
| return m + ':' + String(sec).padStart(2, '0'); |
| } |
| |
| |
| audio.addEventListener('timeupdate', () => { |
| if (audio.duration) { |
| seekSlider.value = Math.floor((audio.currentTime / audio.duration) * 1000); |
| timeDisp.textContent = fmtTime(audio.currentTime) + ' / ' + fmtTime(audio.duration); |
| } |
| }); |
| audio.addEventListener('play', () => { |
| playIcon.style.display = 'none'; pauseIcon.style.display = 'block'; |
| ensureAudioCtx(); if (audioCtx.state === 'suspended') audioCtx.resume(); |
| }); |
| audio.addEventListener('pause', () => { |
| playIcon.style.display = 'block'; pauseIcon.style.display = 'none'; |
| }); |
| audio.addEventListener('ended', () => { |
| playIcon.style.display = 'block'; pauseIcon.style.display = 'none'; |
| seekSlider.value = 0; |
| }); |
| playBtn.addEventListener('click', () => { |
| ensureAudioCtx(); |
| if (audio.paused && audio.src) audio.play(); else audio.pause(); |
| }); |
| seekSlider.addEventListener('input', () => { |
| if (audio.duration) audio.currentTime = (seekSlider.value / 1000) * audio.duration; |
| }); |
| volSlider.addEventListener('input', () => { audio.volume = volSlider.value / 100; }); |
| |
| |
| async function refreshState() { |
| try { |
| const [hR, vR, mR, rR, sR, lR] = await Promise.all([ |
| fetch(API + '/health'), fetch(API + '/v1/audio/voices'), |
| fetch(API + '/v1/models'), fetch(API + '/v1/models/registry'), |
| fetch(API + '/debug/system'), fetch(API + '/v1/models/loaded') |
| ]); |
| const health = await hR.json(); |
| const vData = await vR.json(); |
| const mData = await mR.json(); |
| const rData = await rR.json(); |
| const sData = await sR.json(); |
| const lData = await lR.json(); |
| |
| |
| srvDot.className = 'dot dot-ok'; |
| srvText.textContent = 'Online v' + health.version; |
| |
| |
| gpuAvailable = sData.gpu_available || false; |
| if (gpuAvailable && sData.gpu_info && sData.gpu_info.length > 0) { |
| gpuName = sData.gpu_info[0].name || 'GPU'; |
| deviceBadge.className = 'device-badge gpu'; |
| deviceBadgeText.textContent = gpuName; |
| } else { |
| gpuName = ''; |
| deviceBadge.className = 'device-badge cpu'; |
| deviceBadgeText.textContent = 'CPU Only'; |
| } |
| |
| |
| if (sData.gpu_detected_but_unusable && !gpuWarningDismissed) { |
| gpuWarningBanner.style.display = 'block'; |
| gpuWarningBanner.innerHTML = '<div class="gpu-warning">' |
| + '<span class="gpu-warning-icon">⚠</span>' |
| + '<div class="gpu-warning-content">' |
| + '<div class="gpu-warning-title">GPU Detected but Unusable</div>' |
| + '<div class="gpu-warning-text">' |
| + (sData.gpu_fix_instructions || 'GPU detected but PyTorch cannot use it.') |
| + '</div></div>' |
| + '<button class="gpu-warning-dismiss" id="gpu-dismiss-btn">Dismiss</button>' |
| + '</div>'; |
| const dismissBtn = $('gpu-dismiss-btn'); |
| if (dismissBtn) dismissBtn.addEventListener('click', () => { |
| gpuWarningDismissed = true; |
| gpuWarningBanner.style.display = 'none'; |
| }); |
| } else if (!sData.gpu_detected_but_unusable) { |
| gpuWarningBanner.style.display = 'none'; |
| } |
| |
| |
| voices = vData.voices || []; |
| voiceBadge.textContent = 'Voices: ' + voices.length; |
| renderVoices(voices); |
| if (!selectedVoice && voices.length) { |
| const firstAvailable = voices.find(v => v.available !== false); |
| if (firstAvailable) selectVoice(firstAvailable.name); |
| } |
| |
| |
| const prevModel = modelSelect.value; |
| loadedModels = mData.data || []; |
| modelBadge.textContent = 'Models: ' + loadedModels.length + ' loaded'; |
| modelSelect.innerHTML = ''; |
| loadedModels.forEach(m => { |
| const o = document.createElement('option'); |
| o.value = m.id; o.textContent = m.id; |
| modelSelect.appendChild(o); |
| }); |
| |
| if (prevModel && loadedModels.some(m => m.id === prevModel)) { |
| modelSelect.value = prevModel; |
| } |
| genBtn.disabled = loadedModels.length === 0; |
| |
| |
| const loadedDetailed = lData.models || []; |
| renderLoadedModels(loadedDetailed); |
| |
| |
| updateVoiceHint(loadedDetailed); |
| |
| |
| registryData = rData.backbones || []; |
| renderLangFilter(); |
| renderRegistryDropdown(activeLangFilter); |
| } catch { |
| srvDot.className = 'dot dot-off'; |
| srvText.textContent = 'Offline'; |
| genBtn.disabled = true; |
| } |
| } |
| |
| |
| function updateVoiceHint(loadedDetailed) { |
| voiceHintContainer.innerHTML = ''; |
| const loadedLangs = new Set(loadedDetailed.map(m => m.language)); |
| loadedLangs.forEach(lang => { |
| if (!lang) return; |
| const langVoices = voices.filter(v => v.language === lang && v.available !== false); |
| if (langVoices.length <= 1) { |
| const langLabel = {de:'German',es:'Spanish','fr-fr':'French','en-us':'English'}[lang] || lang; |
| const hint = document.createElement('div'); |
| hint.className = 'voice-hint'; |
| hint.textContent = 'Only ' + langVoices.length + ' ' + langLabel |
| + ' voice' + (langVoices.length !== 1 ? 's' : '') + ' available. ' |
| + "Use 'Clone a Voice' above to record or upload a custom voice."; |
| voiceHintContainer.appendChild(hint); |
| } |
| }); |
| } |
| |
| |
| function renderLoadedModels(models) { |
| loadedModelsList.innerHTML = ''; |
| models.forEach(m => { |
| const div = document.createElement('div'); |
| div.className = 'model-tag-expanded'; |
| |
| const isCuda = m.backbone_device && m.backbone_device.startsWith('cuda'); |
| const deviceLabel = isCuda ? 'GPU' : 'CPU'; |
| const deviceClass = isCuda ? 'gpu-badge' : 'cpu-badge'; |
| const isGGUF = m.backend === 'gguf'; |
| |
| let actionsHtml = '<button class="unload-btn" data-id="' + m.model_id + '">×</button>'; |
| if (!isGGUF && gpuAvailable) { |
| const targetDevice = isCuda ? 'cpu' : 'cuda'; |
| const toggleLabel = isCuda ? 'CPU' : 'GPU'; |
| actionsHtml = '<button class="toggle-device-btn" data-id="' + m.model_id + '" data-target="' + targetDevice + '">' |
| + toggleLabel + '</button>' + actionsHtml; |
| } |
| |
| div.innerHTML = '<span class="model-name">' + m.model_id + '</span>' |
| + '<span class="model-device-badge ' + deviceClass + '">' + deviceLabel + '</span>' |
| + (m.language ? '<span class="model-lang">' + m.language + '</span>' : '') |
| + '<span class="model-actions">' + actionsHtml + '</span>'; |
| |
| loadedModelsList.appendChild(div); |
| }); |
| |
| |
| loadedModelsList.querySelectorAll('.unload-btn').forEach(el => { |
| el.addEventListener('click', () => unloadModel(el.dataset.id)); |
| }); |
| loadedModelsList.querySelectorAll('.toggle-device-btn').forEach(el => { |
| el.addEventListener('click', () => switchDevice(el.dataset.id, el.dataset.target)); |
| }); |
| } |
| |
| |
| function renderLangFilter() { |
| const langs = new Set(registryData.map(b => b.language)); |
| const allLangs = ['all', ...Array.from(langs).sort()]; |
| langFilter.innerHTML = ''; |
| allLangs.forEach(lang => { |
| const pill = document.createElement('button'); |
| pill.className = 'lang-pill' + (activeLangFilter === lang ? ' active' : ''); |
| pill.textContent = lang === 'all' ? 'All' : lang; |
| pill.addEventListener('click', () => { |
| activeLangFilter = lang; |
| renderLangFilter(); |
| renderRegistryDropdown(lang); |
| }); |
| langFilter.appendChild(pill); |
| }); |
| } |
| |
| function renderRegistryDropdown(langFilterVal) { |
| const loadedIds = new Set(loadedModels.map(m => m.id)); |
| registrySelect.innerHTML = ''; |
| registryData.forEach(b => { |
| if (loadedIds.has(b.model_id)) return; |
| if (langFilterVal !== 'all' && b.language !== langFilterVal) return; |
| const o = document.createElement('option'); |
| o.value = b.model_id; |
| o.textContent = b.model_id + ' (' + b.language + ', ' + b.backend + ')'; |
| registrySelect.appendChild(o); |
| }); |
| } |
| |
| |
| function renderVoices(list) { |
| voiceList.innerHTML = ''; |
| list.forEach(v => { |
| const isUnavailable = v.available === false; |
| const div = document.createElement('div'); |
| div.className = 'voice-item' |
| + (v.name === selectedVoice ? ' active' : '') |
| + (isUnavailable ? ' voice-unavailable' : ''); |
| |
| let html = '<span class="v-name">' + v.name + '</span>' |
| + '<span class="v-lang">' + v.language + '</span>' |
| + '<span class="v-meta">' + v.gender + '</span>'; |
| |
| if (v.custom && !isUnavailable) { |
| html += '<span class="v-delete" data-name="' + v.name + '" title="Delete voice">×</span>'; |
| } |
| |
| div.innerHTML = html; |
| if (!isUnavailable) { |
| div.addEventListener('click', (e) => { |
| if (e.target.classList.contains('v-delete')) return; |
| selectVoice(v.name); |
| }); |
| } |
| voiceList.appendChild(div); |
| }); |
| |
| |
| voiceList.querySelectorAll('.v-delete').forEach(el => { |
| el.addEventListener('click', (e) => { |
| e.stopPropagation(); |
| deleteVoice(el.dataset.name); |
| }); |
| }); |
| } |
| function selectVoice(name) { |
| selectedVoice = name; |
| voiceSearch.value = name; |
| document.querySelectorAll('.voice-item').forEach(el => { |
| el.classList.toggle('active', el.querySelector('.v-name').textContent === name); |
| }); |
| |
| autoSelectModelForVoice(name); |
| } |
| function autoSelectModelForVoice(voiceName) { |
| const voice = voices.find(v => v.name === voiceName); |
| if (!voice || !voice.language || loadedModels.length === 0) return; |
| |
| const currentModel = loadedModels.find(m => m.id === modelSelect.value); |
| if (currentModel && currentModel.language === voice.language) { |
| clearLangMismatchWarning(); |
| return; |
| } |
| |
| const match = loadedModels.find(m => m.language === voice.language); |
| if (match) { |
| modelSelect.value = match.id; |
| clearLangMismatchWarning(); |
| } else { |
| showLangMismatchWarning(voice.language); |
| } |
| } |
| function getLanguageLabel(code) { |
| const labels = { 'en-us': 'English', 'de': 'German', 'fr-fr': 'French', 'es': 'Spanish' }; |
| return labels[code] || code; |
| } |
| function showLangMismatchWarning(voiceLang) { |
| let w = $('lang-mismatch-warning'); |
| if (!w) { |
| w = document.createElement('div'); |
| w.id = 'lang-mismatch-warning'; |
| w.style.cssText = 'background:rgba(251,191,36,0.15);border:1px solid var(--warning);color:var(--warning);padding:8px 12px;border-radius:8px;font-size:0.82rem;margin-top:8px;'; |
| modelSelect.parentElement.appendChild(w); |
| } |
| const label = getLanguageLabel(voiceLang); |
| w.textContent = 'No ' + label + ' model loaded! Load a matching model from the registry below.'; |
| w.style.display = 'block'; |
| } |
| function clearLangMismatchWarning() { |
| const w = $('lang-mismatch-warning'); |
| if (w) w.style.display = 'none'; |
| } |
| voiceSearch.addEventListener('input', () => { |
| const q = voiceSearch.value.toLowerCase(); |
| renderVoices(voices.filter(v => |
| v.name.toLowerCase().includes(q) || v.language.toLowerCase().includes(q) |
| )); |
| }); |
| voiceSearch.addEventListener('focus', () => { |
| voiceSearch.select(); |
| renderVoices(voices); |
| }); |
| |
| |
| async function deleteVoice(name) { |
| if (!confirm('Delete custom voice "' + name + '"?')) return; |
| try { |
| const r = await fetch(API + '/v1/audio/voices/' + encodeURIComponent(name), { method: 'DELETE' }); |
| if (!r.ok) { const e = await r.json(); throw new Error(e.detail?.error?.message || 'Delete failed'); } |
| if (selectedVoice === name) selectedVoice = ''; |
| refreshState(); |
| } catch (e) { showStatus('Delete failed: ' + e.message, 'error'); } |
| } |
| |
| |
| uploadToggle.addEventListener('click', () => { |
| uploadPanel.classList.toggle('open'); |
| uploadToggle.textContent = uploadPanel.classList.contains('open') |
| ? '- Hide Clone Panel' : '+ Clone a Voice (Upload or Record)'; |
| }); |
| |
| |
| uploadDropZone.addEventListener('click', () => uploadFileInput.click()); |
| uploadDropZone.addEventListener('dragover', (e) => { e.preventDefault(); uploadDropZone.classList.add('dragover'); }); |
| uploadDropZone.addEventListener('dragleave', () => uploadDropZone.classList.remove('dragover')); |
| uploadDropZone.addEventListener('drop', (e) => { |
| e.preventDefault(); |
| uploadDropZone.classList.remove('dragover'); |
| if (e.dataTransfer.files.length) handleUploadFile(e.dataTransfer.files[0]); |
| }); |
| uploadFileInput.addEventListener('change', () => { |
| if (uploadFileInput.files.length) handleUploadFile(uploadFileInput.files[0]); |
| }); |
| |
| function handleUploadFile(file) { |
| if (!file.name.toLowerCase().endsWith('.wav') && file.type !== 'audio/wav') { |
| setUploadStatus('Only WAV files are supported', 'error'); |
| return; |
| } |
| uploadFile = file; |
| uploadDropZone.textContent = file.name + ' (' + (file.size / 1024).toFixed(1) + ' KB)'; |
| uploadDropZone.classList.add('has-file'); |
| validateUploadForm(); |
| } |
| |
| |
| function validateUploadForm() { |
| const hasId = uploadVoiceId.value.trim().length > 0; |
| const hasFile = uploadFile !== null; |
| const hasText = uploadRefText.value.trim().length > 0; |
| uploadBtn.disabled = !(hasId && hasFile && hasText); |
| } |
| uploadVoiceId.addEventListener('input', validateUploadForm); |
| uploadRefText.addEventListener('input', validateUploadForm); |
| |
| |
| uploadBtn.addEventListener('click', async () => { |
| if (!uploadFile || uploadBtn.disabled) return; |
| uploadBtn.disabled = true; |
| uploadBtn.textContent = 'Uploading...'; |
| uploadProgress.style.display = 'block'; |
| uploadProgressBar.style.width = '0%'; |
| setUploadStatus('', ''); |
| |
| const formData = new FormData(); |
| formData.append('voice_id', uploadVoiceId.value.trim()); |
| formData.append('ref_text', uploadRefText.value.trim()); |
| formData.append('audio', uploadFile); |
| formData.append('language', uploadLanguage.value); |
| formData.append('gender', uploadGender.value); |
| |
| try { |
| const xhr = new XMLHttpRequest(); |
| await new Promise((resolve, reject) => { |
| xhr.upload.addEventListener('progress', (e) => { |
| if (e.lengthComputable) { |
| uploadProgressBar.style.width = Math.round((e.loaded / e.total) * 100) + '%'; |
| } |
| }); |
| xhr.addEventListener('load', () => { |
| if (xhr.status >= 200 && xhr.status < 300) resolve(JSON.parse(xhr.responseText)); |
| else reject(new Error(JSON.parse(xhr.responseText)?.detail?.error?.message || 'Upload failed')); |
| }); |
| xhr.addEventListener('error', () => reject(new Error('Network error'))); |
| xhr.open('POST', API + '/v1/audio/voices/upload'); |
| xhr.send(formData); |
| }); |
| |
| setUploadStatus('Voice uploaded successfully!', 'success'); |
| |
| uploadVoiceId.value = ''; |
| uploadRefText.value = ''; |
| uploadFile = null; |
| uploadFileInput.value = ''; |
| uploadDropZone.textContent = 'Drop WAV file here or click to browse'; |
| uploadDropZone.classList.remove('has-file'); |
| uploadLanguage.value = 'unknown'; |
| uploadGender.value = 'unknown'; |
| refreshState(); |
| } catch (e) { |
| setUploadStatus(e.message, 'error'); |
| } finally { |
| uploadBtn.disabled = false; |
| uploadBtn.textContent = 'Upload & Clone Voice'; |
| uploadProgress.style.display = 'none'; |
| validateUploadForm(); |
| } |
| }); |
| |
| function setUploadStatus(msg, type) { |
| uploadStatus.textContent = msg; |
| uploadStatus.className = 'voice-upload-status' + (type ? ' upload-' + type : ''); |
| if (msg && type) { |
| setTimeout(() => { uploadStatus.className = 'voice-upload-status'; }, 5000); |
| } |
| } |
| |
| |
| recordBtn.addEventListener('click', async () => { |
| if (mediaRecorder && mediaRecorder.state === 'recording') { |
| mediaRecorder.stop(); |
| return; |
| } |
| |
| try { |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); |
| recordChunks = []; |
| mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' }); |
| |
| mediaRecorder.addEventListener('dataavailable', (e) => { |
| if (e.data.size > 0) recordChunks.push(e.data); |
| }); |
| |
| mediaRecorder.addEventListener('stop', async () => { |
| stream.getTracks().forEach(t => t.stop()); |
| recordBtn.classList.remove('recording'); |
| recordBtn.textContent = 'REC'; |
| clearInterval(recordTimerInterval); |
| |
| const webmBlob = new Blob(recordChunks, { type: 'audio/webm' }); |
| try { |
| const wavBlob = await convertToWav(webmBlob); |
| uploadFile = new File([wavBlob], 'recording.wav', { type: 'audio/wav' }); |
| uploadDropZone.textContent = 'Recording (' + (wavBlob.size / 1024).toFixed(1) + ' KB)'; |
| uploadDropZone.classList.add('has-file'); |
| validateUploadForm(); |
| } catch (err) { |
| setUploadStatus('Failed to convert recording: ' + err.message, 'error'); |
| } |
| }); |
| |
| mediaRecorder.start(); |
| recordBtn.classList.add('recording'); |
| recordBtn.textContent = 'STOP'; |
| recordStartTime = Date.now(); |
| recordTimer.textContent = '0:00'; |
| recordTimerInterval = setInterval(() => { |
| const elapsed = Math.floor((Date.now() - recordStartTime) / 1000); |
| recordTimer.textContent = Math.floor(elapsed / 60) + ':' + String(elapsed % 60).padStart(2, '0'); |
| if (elapsed >= 30) mediaRecorder.stop(); |
| }, 1000); |
| |
| } catch (err) { |
| setUploadStatus('Microphone access denied: ' + err.message, 'error'); |
| } |
| }); |
| |
| |
| async function convertToWav(webmBlob) { |
| const convCtx = new (window.AudioContext || window.webkitAudioContext)(); |
| const arrayBuf = await webmBlob.arrayBuffer(); |
| const audioBuf = await convCtx.decodeAudioData(arrayBuf); |
| convCtx.close(); |
| |
| |
| const numFrames = audioBuf.length; |
| const sampleRate = audioBuf.sampleRate; |
| let mono; |
| if (audioBuf.numberOfChannels === 1) { |
| mono = audioBuf.getChannelData(0); |
| } else { |
| mono = new Float32Array(numFrames); |
| for (let ch = 0; ch < audioBuf.numberOfChannels; ch++) { |
| const chData = audioBuf.getChannelData(ch); |
| for (let i = 0; i < numFrames; i++) mono[i] += chData[i]; |
| } |
| for (let i = 0; i < numFrames; i++) mono[i] /= audioBuf.numberOfChannels; |
| } |
| |
| |
| const dataLen = mono.length * 2; |
| const buffer = new ArrayBuffer(44 + dataLen); |
| const view = new DataView(buffer); |
| |
| function writeStr(offset, str) { for (let i = 0; i < str.length; i++) view.setUint8(offset + i, str.charCodeAt(i)); } |
| |
| writeStr(0, 'RIFF'); |
| view.setUint32(4, 36 + dataLen, true); |
| writeStr(8, 'WAVE'); |
| writeStr(12, 'fmt '); |
| view.setUint32(16, 16, true); |
| view.setUint16(20, 1, true); |
| view.setUint16(22, 1, true); |
| view.setUint32(24, sampleRate, true); |
| view.setUint32(28, sampleRate * 2, true); |
| view.setUint16(32, 2, true); |
| view.setUint16(34, 16, true); |
| writeStr(36, 'data'); |
| view.setUint32(40, dataLen, true); |
| |
| for (let i = 0; i < mono.length; i++) { |
| const s = Math.max(-1, Math.min(1, mono[i])); |
| view.setInt16(44 + i * 2, s < 0 ? s * 0x8000 : s * 0x7FFF, true); |
| } |
| |
| return new Blob([buffer], { type: 'audio/wav' }); |
| } |
| |
| |
| async function unloadModel(id) { |
| showStatus('Unloading ' + id + '...', 'info'); |
| try { |
| const r = await fetch(API + '/v1/models/' + id, { method: 'DELETE' }); |
| if (!r.ok) throw new Error(await r.text()); |
| showStatus(id + ' unloaded', 'success'); |
| refreshState(); |
| } catch (e) { showStatus('Unload failed: ' + e.message, 'error'); } |
| } |
| |
| async function switchDevice(modelId, targetDevice) { |
| showStatus('Switching ' + modelId + ' to ' + targetDevice + '...', 'info'); |
| try { |
| const r = await fetch(API + '/v1/models/' + modelId + '/switch-device', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ backbone_device: targetDevice, codec_device: targetDevice }) |
| }); |
| if (!r.ok) { |
| const e = await r.json(); |
| throw new Error(e.detail?.error?.message || 'Switch failed'); |
| } |
| const task = await r.json(); |
| startPollingTask(task.task_id); |
| showStatus('Switching ' + modelId + ' to ' + targetDevice + '...', 'info'); |
| } catch (e) { showStatus('Switch failed: ' + e.message, 'error'); } |
| } |
| |
| |
| function startPollingTask(taskId) { |
| if (loadingTaskTimers[taskId]) return; |
| renderLoadingTask({ task_id: taskId, model_id: '...', status: 'pending', progress_message: 'Starting...', elapsed_seconds: 0 }); |
| const timer = setInterval(async () => { |
| try { |
| const r = await fetch(API + '/v1/models/load/' + taskId); |
| if (!r.ok) { clearInterval(timer); delete loadingTaskTimers[taskId]; return; } |
| const task = await r.json(); |
| renderLoadingTask(task); |
| if (task.status === 'ready') { |
| clearInterval(timer); |
| delete loadingTaskTimers[taskId]; |
| showStatus(task.model_id + ' loaded!', 'success'); |
| refreshState(); |
| } else if (task.status === 'error') { |
| clearInterval(timer); |
| delete loadingTaskTimers[taskId]; |
| showStatus('Failed to load ' + task.model_id + ': ' + task.error_message, 'error'); |
| setTimeout(() => { removeLoadingCard(taskId); }, 5000); |
| } |
| } catch { |
| clearInterval(timer); |
| delete loadingTaskTimers[taskId]; |
| } |
| }, 1500); |
| loadingTaskTimers[taskId] = timer; |
| } |
| |
| function renderLoadingTask(task) { |
| let card = document.querySelector('[data-task-id="' + task.task_id + '"]'); |
| if (!card) { |
| card = document.createElement('div'); |
| card.className = 'loading-card'; |
| card.setAttribute('data-task-id', task.task_id); |
| loadingTasksList.appendChild(card); |
| } |
| |
| const statusLabels = { |
| pending: 'Queued', |
| downloading: 'Downloading', |
| loading: 'Loading', |
| ready: 'Ready', |
| error: 'Error' |
| }; |
| |
| card.className = 'loading-card' |
| + (task.status === 'error' ? ' error-card' : '') |
| + (task.status === 'ready' ? ' ready-card' : ''); |
| |
| const spinnerHtml = (task.status === 'pending' || task.status === 'downloading' || task.status === 'loading') |
| ? '<div class="loading-spinner-sm"></div>' : ''; |
| |
| |
| const progressPct = { pending: 10, downloading: 40, loading: 75, ready: 100, error: 0 }; |
| const progressColors = { |
| pending: 'rgba(148,163,184,0.5)', |
| downloading: 'linear-gradient(90deg, var(--fg), var(--fg2))', |
| loading: 'linear-gradient(90deg, #a78bfa, #c084fc)', |
| ready: 'var(--success)', |
| error: 'var(--error)' |
| }; |
| const pct = progressPct[task.status] || 0; |
| const color = progressColors[task.status] || 'var(--fg)'; |
| |
| card.innerHTML = spinnerHtml |
| + '<span class="loading-model">' + task.model_id + '</span>' |
| + '<span class="loading-phase">' + (task.progress_message || statusLabels[task.status] || task.status) + '</span>' |
| + '<span class="loading-time">' + task.elapsed_seconds.toFixed(1) + 's</span>' |
| + '<div class="loading-progress-bar"><div class="loading-progress-bar-fill" style="width:' + pct + '%;background:' + color + '"></div></div>'; |
| } |
| |
| function removeLoadingCard(taskId) { |
| const card = document.querySelector('[data-task-id="' + taskId + '"]'); |
| if (card) card.remove(); |
| } |
| |
| loadBtn.addEventListener('click', async () => { |
| const id = registrySelect.value; |
| if (!id) return; |
| loadBtn.disabled = true; loadBtn.textContent = 'Loading...'; |
| showStatus('Loading ' + id + '...', 'info'); |
| try { |
| const r = await fetch(API + '/v1/models/load', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ model_id: id }) |
| }); |
| if (!r.ok) { const e = await r.json(); throw new Error(e.detail?.error?.message || 'Load failed'); } |
| const task = await r.json(); |
| if (task.status === 'ready') { |
| showStatus(id + ' loaded!', 'success'); |
| refreshState(); |
| } else { |
| startPollingTask(task.task_id); |
| } |
| } catch (e) { showStatus('Load failed: ' + e.message, 'error'); } |
| finally { loadBtn.disabled = false; loadBtn.textContent = 'Load'; } |
| }); |
| |
| |
| async function generate() { |
| const text = ttsText.value.trim(); |
| if (!text || !modelSelect.value) return; |
| |
| |
| const selVoice = voices.find(v => v.name === selectedVoice); |
| const selModel = loadedModels.find(m => m.id === modelSelect.value); |
| if (selVoice && selModel && selVoice.language && selModel.language |
| && selVoice.language !== selModel.language) { |
| const vLang = getLanguageLabel(selVoice.language); |
| const mLang = getLanguageLabel(selModel.language); |
| if (!confirm('Language mismatch!\n\nVoice "' + selectedVoice + '" is ' + vLang |
| + ' but model "' + selModel.id + '" is ' + mLang + '.\n\n' |
| + 'This will likely produce wrong accent/pronunciation.\nContinue anyway?')) { |
| return; |
| } |
| } |
| |
| genBtn.classList.add('spinning'); |
| genBtn.disabled = true; |
| cancelBtn.style.display = 'block'; |
| dlWrap.classList.remove('ready'); |
| genProgress.style.width = '0%'; |
| showStatus('Generating...', 'info'); |
| |
| if (currentBlobUrl) { URL.revokeObjectURL(currentBlobUrl); currentBlobUrl = null; } |
| |
| abortCtrl = new AbortController(); |
| const t0 = performance.now(); |
| |
| try { |
| const res = await fetch(API + '/v1/audio/speech', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ |
| model: modelSelect.value, |
| input: text, |
| voice: selectedVoice, |
| response_format: fmtSelect.value, |
| speed: parseFloat(speedRange.value), |
| stream: streamChk.checked, |
| }), |
| signal: abortCtrl.signal, |
| }); |
| |
| if (!res.ok) { |
| const err = await res.json(); |
| throw new Error(err.detail?.error?.message || err.detail || 'Failed'); |
| } |
| |
| |
| const reader = res.body.getReader(); |
| const chunks = []; |
| let received = 0; |
| |
| while (true) { |
| const { done, value } = await reader.read(); |
| if (done) break; |
| chunks.push(value); |
| received += value.length; |
| const elapsedNow = ((performance.now() - t0) / 1000).toFixed(1); |
| const kbReceived = (received / 1024).toFixed(1); |
| statusMsg.textContent = 'Generating... ' + kbReceived + ' KB | ' + elapsedNow + 's'; |
| genProgress.style.width = Math.min(90, received / 500) + '%'; |
| } |
| |
| genProgress.style.width = '100%'; |
| const blob = new Blob(chunks); |
| const elapsed = ((performance.now() - t0) / 1000).toFixed(2); |
| const sizeKB = (blob.size / 1024).toFixed(1); |
| |
| currentBlobUrl = URL.createObjectURL(blob); |
| audio.src = currentBlobUrl; |
| dlBtn.href = currentBlobUrl; |
| dlBtn.download = 'neutts_' + selectedVoice + '_' + Date.now() + '.' + fmtSelect.value; |
| dlBtn.title = 'Download (' + sizeKB + ' KB)'; |
| dlWrap.classList.add('ready'); |
| |
| showStatus( |
| 'Done! ' + fmtSelect.value.toUpperCase() + ' | ' |
| + sizeKB + ' KB | ' |
| + elapsed + 's | ' + text.length + ' chars', |
| 'success' |
| ); |
| |
| if (autoplayChk.checked) { |
| ensureAudioCtx(); |
| audio.play().catch(() => {}); |
| } |
| |
| } catch (e) { |
| if (e.name === 'AbortError') { |
| showStatus('Cancelled', 'info'); |
| } else { |
| showStatus(e.message, 'error'); |
| } |
| } finally { |
| genBtn.classList.remove('spinning'); |
| genBtn.disabled = loadedModels.length === 0; |
| cancelBtn.style.display = 'none'; |
| abortCtrl = null; |
| setTimeout(() => { genProgress.style.width = '0%'; }, 2000); |
| } |
| } |
| |
| genBtn.addEventListener('click', generate); |
| cancelBtn.addEventListener('click', () => { if (abortCtrl) abortCtrl.abort(); }); |
| ttsText.addEventListener('keydown', e => { |
| if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { |
| e.preventDefault(); |
| if (!genBtn.disabled) generate(); |
| } |
| }); |
| |
| |
| function showStatus(msg, type) { |
| statusMsg.textContent = msg; |
| statusMsg.className = 'status-msg ' + type; |
| } |
| |
| |
| refreshState(); |
| setInterval(refreshState, 20000); |
| })(); |
| </script> |
| </body> |
| </html> |
|
|