| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>🎭 DramaBox — Expressive TTS with Voice Cloning</title> |
| |
| |
| <meta name="description" content="Generate highly expressive speech with voice cloning. Powered by LTX-2.3 and Resemble Perth watermarking."> |
| <meta name="theme-color" content="#07090e"> |
| |
| |
| <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@300;400;500;600;700&family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet"> |
| |
| <style> |
| :root { |
| --bg-color: #f8fafc; |
| --sidebar-bg: #ffffff; |
| --panel-bg: #ffffff; |
| --panel-border: #e2e8f0; |
| --panel-border-focus: #2cbd94; |
| --text-primary: #0f172a; |
| --text-secondary: #475569; |
| --text-muted: #94a3b8; |
| --accent-resemble: #2cbd94; |
| --accent-resemble-hover: #1fa480; |
| --accent-resemble-glow: rgba(44, 189, 148, 0.08); |
| --accent-blue: #3b82f6; |
| --accent-purple: #8b5cf6; |
| --radius-lg: 12px; |
| --radius-md: 8px; |
| --radius-sm: 6px; |
| --transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); |
| --font-mono: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; |
| } |
| |
| * { |
| box-sizing: border-box; |
| margin: 0; |
| padding: 0; |
| } |
| |
| body { |
| background-color: var(--bg-color); |
| color: var(--text-primary); |
| font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; |
| min-height: 100vh; |
| line-height: 1.5; |
| overflow: hidden; |
| position: relative; |
| } |
| |
| .app-layout { |
| display: flex; |
| width: 100vw; |
| height: 100vh; |
| position: relative; |
| overflow: hidden; |
| background-color: var(--bg-color); |
| } |
| |
| |
| .header-logo { |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| } |
| |
| .logo-dot { |
| width: 10px; |
| height: 10px; |
| background-color: var(--accent-resemble); |
| border-radius: 50%; |
| display: inline-block; |
| box-shadow: 0 0 8px var(--accent-resemble); |
| } |
| |
| .logo-text { |
| font-family: 'Outfit', sans-serif; |
| font-size: 1.15rem; |
| font-weight: 700; |
| color: var(--text-primary); |
| letter-spacing: -0.2px; |
| line-height: 1; |
| } |
| |
| .brand-badge-mini { |
| font-size: 0.6rem; |
| font-weight: 600; |
| background: var(--accent-resemble-glow); |
| color: var(--accent-resemble); |
| border: 1px solid rgba(44, 189, 148, 0.2); |
| padding: 1px 5px; |
| border-radius: var(--radius-sm); |
| text-transform: uppercase; |
| letter-spacing: 0.2px; |
| margin-left: 2px; |
| line-height: 1.2; |
| } |
| |
| |
| |
| .workspace-area { |
| flex: 1; |
| height: 100%; |
| display: flex; |
| flex-direction: column; |
| overflow-y: auto; |
| background-color: var(--bg-color); |
| } |
| |
| .workspace-header { |
| height: 64px; |
| padding: 0 32px; |
| border-bottom: 1px solid var(--panel-border); |
| background-color: #ffffff; |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| flex-shrink: 0; |
| } |
| |
| .header-left { |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| font-size: 0.88rem; |
| } |
| |
| .header-breadcrumb { |
| color: var(--text-secondary); |
| font-weight: 500; |
| } |
| |
| .header-chevron { |
| color: var(--text-muted); |
| } |
| |
| .header-current { |
| color: var(--text-primary); |
| font-weight: 600; |
| } |
| |
| .header-right { |
| display: flex; |
| align-items: center; |
| gap: 12px; |
| } |
| |
| .header-btn { |
| background: none; |
| border: 1px solid var(--panel-border); |
| color: var(--text-secondary); |
| font-size: 0.8rem; |
| font-weight: 500; |
| padding: 6px 12px; |
| border-radius: var(--radius-sm); |
| cursor: pointer; |
| transition: var(--transition); |
| } |
| |
| .header-btn:hover { |
| border-color: var(--text-muted); |
| color: var(--text-primary); |
| } |
| |
| .workspace-main { |
| flex: 1; |
| padding: 32px; |
| max-width: 800px; |
| width: 100%; |
| margin: 0 auto; |
| display: flex; |
| flex-direction: column; |
| gap: 24px; |
| } |
| |
| |
| .editor-card { |
| background-color: var(--panel-bg); |
| border: 1px solid var(--panel-border); |
| border-radius: var(--radius-lg); |
| padding: 24px; |
| box-shadow: 0 1px 3px rgba(0,0,0,0.05), 0 1px 2px rgba(0,0,0,0.02); |
| display: flex; |
| flex-direction: column; |
| gap: 16px; |
| } |
| |
| .editor-header { |
| display: flex; |
| flex-direction: column; |
| gap: 4px; |
| } |
| |
| .editor-title { |
| font-size: 0.95rem; |
| font-weight: 600; |
| color: var(--text-primary); |
| } |
| |
| .editor-desc { |
| font-size: 0.8rem; |
| color: var(--text-secondary); |
| } |
| |
| .textarea-custom { |
| width: 100%; |
| background: #ffffff; |
| border: 1px solid var(--panel-border); |
| border-radius: var(--radius-md); |
| padding: 18px; |
| color: var(--text-primary); |
| font-family: 'Inter', sans-serif; |
| font-size: 0.95rem; |
| line-height: 1.6; |
| resize: vertical; |
| min-height: 180px; |
| outline: none; |
| transition: var(--transition); |
| } |
| |
| .textarea-custom:focus { |
| border-color: var(--accent-resemble); |
| box-shadow: 0 0 0 3px var(--accent-resemble-glow); |
| } |
| |
| .editor-footer { |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| } |
| |
| .char-counter { |
| font-size: 0.8rem; |
| color: var(--text-secondary); |
| } |
| |
| .btn-generate { |
| background-color: #0f172a; |
| border: none; |
| border-radius: var(--radius-md); |
| color: #ffffff; |
| font-family: 'Inter', sans-serif; |
| font-size: 0.88rem; |
| font-weight: 600; |
| padding: 10px 20px; |
| cursor: pointer; |
| transition: var(--transition); |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| box-shadow: 0 1px 2px rgba(0,0,0,0.05); |
| } |
| |
| .btn-generate:hover:not(:disabled) { |
| background-color: var(--accent-resemble); |
| transform: translateY(-0.5px); |
| } |
| |
| .btn-generate:active:not(:disabled) { |
| transform: translateY(0); |
| } |
| |
| .btn-generate:disabled { |
| background: #f1f5f9; |
| color: var(--text-muted); |
| border: 1px solid var(--panel-border); |
| cursor: not-allowed; |
| box-shadow: none; |
| } |
| |
| |
| .output-wrapper { |
| background-color: var(--panel-bg); |
| border: 1px solid var(--panel-border); |
| border-radius: var(--radius-lg); |
| padding: 24px; |
| box-shadow: 0 1px 3px rgba(0,0,0,0.05), 0 1px 2px rgba(0,0,0,0.02); |
| min-height: 100px; |
| display: flex; |
| flex-direction: column; |
| justify-content: center; |
| align-items: center; |
| } |
| |
| .empty-placeholder { |
| color: var(--text-secondary); |
| text-align: center; |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| gap: 8px; |
| } |
| |
| .empty-icon { |
| font-size: 1.75rem; |
| color: var(--text-muted); |
| } |
| |
| .empty-text { |
| font-size: 0.8rem; |
| font-weight: 500; |
| } |
| |
| .audio-player { |
| width: 100%; |
| display: flex; |
| flex-direction: column; |
| gap: 16px; |
| } |
| |
| .visualizer-box { |
| width: 100%; |
| height: 48px; |
| background: #f8fafc; |
| border-radius: var(--radius-md); |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| border: 1px solid var(--panel-border); |
| overflow: hidden; |
| } |
| |
| .visualizer-wave { |
| display: flex; |
| align-items: center; |
| gap: 3px; |
| height: 100%; |
| width: 90%; |
| justify-content: center; |
| } |
| |
| .wave-bar { |
| width: 4px; |
| height: 6px; |
| background: var(--accent-resemble); |
| border-radius: 2px; |
| transition: var(--transition); |
| } |
| |
| .audio-player.playing .wave-bar { |
| animation: playWave 0.8s infinite ease-in-out alternate; |
| } |
| |
| @keyframes playWave { |
| 0% { height: 6px; } |
| 100% { height: 36px; } |
| } |
| |
| .wave-bar:nth-child(2n) { background: #3b82f6; animation-delay: 0.1s; } |
| .wave-bar:nth-child(3n) { background: #10b981; animation-delay: 0.2s; } |
| .wave-bar:nth-child(4n) { background: var(--accent-resemble); animation-delay: 0.3s; } |
| .wave-bar:nth-child(5n) { background: #6366f1; animation-delay: 0.4s; } |
| |
| .player-row { |
| display: flex; |
| align-items: center; |
| gap: 14px; |
| width: 100%; |
| } |
| |
| .btn-play { |
| background-color: var(--accent-resemble); |
| border: none; |
| color: #ffffff; |
| width: 40px; |
| height: 40px; |
| border-radius: 50%; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| font-size: 1rem; |
| cursor: pointer; |
| transition: var(--transition); |
| box-shadow: 0 2px 4px rgba(44, 189, 148, 0.2); |
| flex-shrink: 0; |
| } |
| |
| .btn-play:hover { |
| background-color: var(--accent-resemble-hover); |
| transform: scale(1.05); |
| } |
| |
| .progress-box { |
| flex: 1; |
| display: flex; |
| align-items: center; |
| gap: 12px; |
| } |
| |
| .time-lbl { |
| font-size: 0.8rem; |
| color: var(--text-secondary); |
| font-family: var(--font-mono); |
| min-width: 38px; |
| } |
| |
| |
| input[type="range"] { |
| -webkit-appearance: none; |
| width: 100%; |
| height: 4px; |
| background: #e2e8f0; |
| border-radius: 2px; |
| outline: none; |
| cursor: pointer; |
| } |
| |
| input[type="range"]::-webkit-slider-thumb { |
| -webkit-appearance: none; |
| width: 14px; |
| height: 14px; |
| border-radius: 50%; |
| background: #0f172a; |
| border: 2px solid #ffffff; |
| box-shadow: 0 1px 3px rgba(0,0,0,0.15); |
| transition: var(--transition); |
| } |
| |
| input[type="range"]::-webkit-slider-thumb:hover { |
| background: var(--accent-resemble); |
| transform: scale(1.15); |
| } |
| |
| .progress-box input[type="range"]::-webkit-slider-thumb { |
| background: var(--accent-resemble); |
| } |
| |
| .deck-footer { |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| width: 100%; |
| border-top: 1px solid var(--panel-border); |
| padding-top: 14px; |
| gap: 16px; |
| } |
| |
| .vol-slider { |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| max-width: 100px; |
| flex-shrink: 0; |
| } |
| |
| .vol-icon { |
| font-size: 0.9rem; |
| color: var(--text-secondary); |
| } |
| |
| .speed-deck { |
| display: flex; |
| align-items: center; |
| gap: 6px; |
| } |
| |
| .btn-speed { |
| background: #f1f5f9; |
| border: 1px solid var(--panel-border); |
| border-radius: var(--radius-sm); |
| color: var(--text-secondary); |
| font-size: 0.75rem; |
| font-weight: 600; |
| padding: 4px 8px; |
| cursor: pointer; |
| transition: var(--transition); |
| } |
| |
| .btn-speed.active, .btn-speed:hover { |
| background: var(--accent-resemble-glow); |
| color: var(--accent-resemble); |
| border-color: rgba(44, 189, 148, 0.3); |
| } |
| |
| .btn-download-wav { |
| background: #f1f5f9; |
| border: 1px solid var(--panel-border); |
| border-radius: var(--radius-sm); |
| color: var(--text-primary); |
| font-size: 0.78rem; |
| font-weight: 600; |
| padding: 5px 12px; |
| text-decoration: none; |
| display: flex; |
| align-items: center; |
| gap: 6px; |
| transition: var(--transition); |
| } |
| |
| .btn-download-wav:hover { |
| background: var(--accent-resemble); |
| color: #ffffff; |
| border-color: var(--accent-resemble); |
| } |
| |
| |
| .settings-sidebar { |
| width: 360px; |
| height: 100%; |
| background-color: var(--sidebar-bg); |
| border-left: 1px solid var(--panel-border); |
| display: flex; |
| flex-direction: column; |
| flex-shrink: 0; |
| z-index: 100; |
| transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), margin-right 0.25s cubic-bezier(0.4, 0, 0.2, 1); |
| overflow-y: auto; |
| position: relative; |
| } |
| |
| |
| .settings-sidebar.collapsed { |
| margin-right: -360px; |
| transform: translateX(100%); |
| } |
| |
| .settings-tabs { |
| display: flex; |
| border-bottom: 1px solid var(--panel-border); |
| background-color: #ffffff; |
| position: sticky; |
| top: 0; |
| z-index: 10; |
| } |
| |
| .tab-btn { |
| flex: 1; |
| background: none; |
| border: none; |
| padding: 16px; |
| font-size: 0.88rem; |
| font-weight: 600; |
| color: var(--text-secondary); |
| cursor: pointer; |
| transition: var(--transition); |
| text-align: center; |
| border-bottom: 2px solid transparent; |
| } |
| |
| .tab-btn:hover { |
| color: var(--text-primary); |
| } |
| |
| .tab-btn.active { |
| color: var(--accent-resemble); |
| border-bottom-color: var(--accent-resemble); |
| } |
| |
| .settings-pane { |
| display: flex; |
| flex-direction: column; |
| gap: 20px; |
| padding: 24px; |
| } |
| |
| .settings-card { |
| background-color: #ffffff; |
| border: 1px solid var(--panel-border); |
| border-radius: var(--radius-lg); |
| padding: 20px; |
| box-shadow: 0 1px 2px rgba(0,0,0,0.03); |
| display: flex; |
| flex-direction: column; |
| gap: 16px; |
| } |
| |
| .settings-card.flex-gap { |
| gap: 20px; |
| } |
| |
| .card-label { |
| font-size: 0.85rem; |
| font-weight: 600; |
| color: var(--text-primary); |
| } |
| |
| |
| .upload-zone { |
| border: 2.5px dashed var(--panel-border); |
| border-radius: var(--radius-md); |
| padding: 24px 16px; |
| text-align: center; |
| cursor: pointer; |
| background: #f8fafc; |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| gap: 8px; |
| transition: var(--transition); |
| } |
| |
| .upload-zone:hover, .upload-zone.dragover { |
| border-color: var(--accent-resemble); |
| background-color: var(--accent-resemble-glow); |
| } |
| |
| .upload-icon { |
| font-size: 1.5rem; |
| color: var(--accent-resemble); |
| } |
| |
| .upload-text { |
| font-size: 0.8rem; |
| color: var(--text-secondary); |
| line-height: 1.4; |
| } |
| |
| .upload-text strong { |
| color: var(--text-primary); |
| font-weight: 600; |
| } |
| |
| .audio-trimmer-container { |
| display: flex; |
| flex-direction: column; |
| gap: 12px; |
| background: #f8fafc; |
| border: 1px solid var(--panel-border); |
| border-radius: var(--radius-md); |
| padding: 14px; |
| margin-top: 4px; |
| transition: var(--transition); |
| } |
| |
| .trimmer-header { |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| border-bottom: 1px solid var(--panel-border); |
| padding-bottom: 8px; |
| } |
| |
| .trimmer-file-info { |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| } |
| |
| .trimmer-filename { |
| font-size: 0.8rem; |
| color: var(--text-primary); |
| font-weight: 500; |
| white-space: nowrap; |
| overflow: hidden; |
| text-overflow: ellipsis; |
| max-width: 180px; |
| } |
| |
| .btn-clear-trimmer { |
| background: transparent; |
| border: none; |
| color: var(--text-secondary); |
| cursor: pointer; |
| font-size: 0.85rem; |
| padding: 4px; |
| border-radius: var(--radius-sm); |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| } |
| |
| .btn-clear-trimmer:hover { |
| color: #ef4444; |
| background: #fee2e2; |
| } |
| |
| .trimmer-waveform-box { |
| position: relative; |
| width: 100%; |
| height: 60px; |
| background: #0f172a; |
| border-radius: var(--radius-sm); |
| overflow: hidden; |
| } |
| |
| .trimmer-canvas { |
| width: 100%; |
| height: 100%; |
| display: block; |
| } |
| |
| .trimmer-duration-info { |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| font-size: 0.75rem; |
| font-weight: 500; |
| } |
| |
| .duration-title { |
| color: var(--text-secondary); |
| } |
| |
| .duration-val { |
| color: var(--accent-resemble); |
| font-weight: 700; |
| font-family: var(--font-mono); |
| } |
| |
| .trim-slider-group { |
| display: flex; |
| flex-direction: column; |
| gap: 4px; |
| } |
| |
| .trim-slider-meta { |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| font-size: 0.75rem; |
| color: var(--text-secondary); |
| } |
| |
| .trim-value-label { |
| color: var(--accent-resemble); |
| font-weight: 600; |
| font-family: var(--font-mono); |
| } |
| |
| .trimmer-controls { |
| display: flex; |
| gap: 8px; |
| margin-top: 4px; |
| } |
| |
| .btn-trimmer-control { |
| flex: 1; |
| padding: 8px 12px; |
| font-size: 0.78rem; |
| font-weight: 600; |
| border-radius: var(--radius-sm); |
| cursor: pointer; |
| transition: var(--transition); |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| gap: 6px; |
| } |
| |
| .btn-trimmer-play { |
| background-color: var(--accent-resemble-glow); |
| border: 1px solid var(--accent-resemble); |
| color: var(--accent-resemble); |
| } |
| |
| .btn-trimmer-play:hover { |
| background-color: var(--accent-resemble); |
| color: #ffffff; |
| } |
| |
| .btn-trimmer-play.playing { |
| background-color: #ef4444; |
| border-color: #ef4444; |
| color: #ffffff; |
| } |
| |
| .btn-trimmer-reset { |
| background: #ffffff; |
| border: 1px solid var(--panel-border); |
| color: var(--text-secondary); |
| } |
| |
| .btn-trimmer-reset:hover { |
| background: #f1f5f9; |
| color: var(--text-primary); |
| } |
| |
| |
| .model-banner-card { |
| background: linear-gradient(135deg, #2cbd94 0%, #1fa480 100%); |
| border-radius: var(--radius-lg); |
| padding: 20px; |
| color: #ffffff; |
| box-shadow: 0 4px 12px rgba(44, 189, 148, 0.15); |
| display: flex; |
| flex-direction: column; |
| gap: 6px; |
| } |
| |
| .model-banner-title { |
| font-size: 0.72rem; |
| font-weight: 700; |
| text-transform: uppercase; |
| letter-spacing: 1px; |
| opacity: 0.85; |
| } |
| |
| .model-banner-name { |
| font-size: 1rem; |
| font-weight: 700; |
| } |
| |
| .model-banner-desc { |
| font-size: 0.8rem; |
| opacity: 0.9; |
| line-height: 1.4; |
| } |
| |
| |
| .slider-group { |
| display: flex; |
| flex-direction: column; |
| gap: 6px; |
| } |
| |
| .slider-meta { |
| display: flex; |
| justify-content: space-between; |
| font-size: 0.82rem; |
| color: var(--text-secondary); |
| font-weight: 500; |
| } |
| |
| .slider-value { |
| color: var(--accent-resemble); |
| font-weight: 700; |
| font-family: var(--font-mono); |
| background: var(--accent-resemble-glow); |
| padding: 1px 6px; |
| border-radius: var(--radius-sm); |
| border: 1px solid rgba(44, 189, 148, 0.2); |
| } |
| |
| .slider-hints { |
| display: flex; |
| justify-content: space-between; |
| font-size: 0.72rem; |
| color: var(--text-muted); |
| margin-top: -2px; |
| } |
| |
| .form-group-custom { |
| display: flex; |
| flex-direction: column; |
| gap: 6px; |
| } |
| |
| .custom-label { |
| font-size: 0.82rem; |
| font-weight: 500; |
| color: var(--text-secondary); |
| } |
| |
| .seed-row { |
| display: flex; |
| gap: 8px; |
| align-items: center; |
| } |
| |
| .input-seed { |
| flex: 1; |
| background: #ffffff; |
| border: 1px solid var(--panel-border); |
| border-radius: var(--radius-sm); |
| padding: 6px 12px; |
| color: var(--text-primary); |
| font-family: var(--font-mono); |
| outline: none; |
| font-size: 0.88rem; |
| transition: var(--transition); |
| height: 38px; |
| } |
| |
| .input-seed:focus { |
| border-color: var(--accent-resemble); |
| box-shadow: 0 0 0 2px var(--accent-resemble-glow); |
| } |
| |
| .btn-seed-random { |
| background: #ffffff; |
| border: 1px solid var(--panel-border); |
| border-radius: var(--radius-sm); |
| color: var(--text-primary); |
| height: 38px; |
| width: 38px; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| cursor: pointer; |
| transition: var(--transition); |
| font-size: 1rem; |
| } |
| |
| .btn-seed-random:hover { |
| border-color: var(--accent-resemble); |
| background: var(--accent-resemble-glow); |
| } |
| |
| |
| .guide-header { |
| font-size: 0.88rem; |
| font-weight: 600; |
| color: var(--text-primary); |
| cursor: pointer; |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| user-select: none; |
| } |
| |
| .guide-body { |
| max-height: 0; |
| overflow: hidden; |
| transition: max-height 0.25s cubic-bezier(0.4, 0, 0.2, 1); |
| font-size: 0.8rem; |
| color: var(--text-secondary); |
| display: flex; |
| flex-direction: column; |
| gap: 12px; |
| line-height: 1.5; |
| } |
| |
| .guide-body.open { |
| max-height: 380px; |
| margin-top: 14px; |
| } |
| |
| .guide-block-title { |
| color: var(--text-primary); |
| font-weight: 600; |
| margin-bottom: 4px; |
| font-size: 0.8rem; |
| } |
| |
| .guide-list { |
| padding-left: 16px; |
| display: flex; |
| flex-direction: column; |
| gap: 4px; |
| } |
| |
| |
| .demo-capsules { |
| display: flex; |
| flex-direction: column; |
| gap: 8px; |
| } |
| |
| .demo-pill { |
| background: #ffffff; |
| border: 1px solid var(--panel-border); |
| border-radius: var(--radius-md); |
| padding: 10px 14px; |
| cursor: pointer; |
| transition: var(--transition); |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| gap: 12px; |
| text-align: left; |
| } |
| |
| .demo-pill:hover { |
| border-color: var(--accent-resemble); |
| background-color: var(--accent-resemble-glow); |
| transform: translateX(1px); |
| } |
| |
| .demo-pill.active { |
| border-color: var(--accent-resemble); |
| background-color: var(--accent-resemble-glow); |
| } |
| |
| .demo-pill-title { |
| font-size: 0.82rem; |
| font-weight: 600; |
| color: var(--text-primary); |
| white-space: nowrap; |
| overflow: hidden; |
| text-overflow: ellipsis; |
| max-width: 180px; |
| } |
| |
| .pill-labels { |
| display: flex; |
| gap: 6px; |
| flex-shrink: 0; |
| } |
| |
| .pill-badge { |
| font-size: 0.65rem; |
| font-weight: 700; |
| padding: 2px 6px; |
| border-radius: var(--radius-sm); |
| text-transform: uppercase; |
| } |
| |
| .pill-badge-male { background: rgba(59, 130, 246, 0.1); color: #2563eb; } |
| .pill-badge-female { background: rgba(236, 72, 153, 0.1); color: #db2777; } |
| .pill-badge-long { background: rgba(139, 92, 246, 0.1); color: #7c3aed; } |
| |
| |
| .status-alert { |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| font-size: 0.85rem; |
| font-weight: 600; |
| padding: 12px 18px; |
| border-radius: var(--radius-md); |
| border: 1px solid transparent; |
| display: none; |
| width: 100%; |
| box-shadow: 0 1px 2px rgba(0,0,0,0.03); |
| margin-bottom: 8px; |
| } |
| |
| .status-alert.success { |
| background-color: #ecfdf5; |
| border-color: #a7f3d0; |
| color: #065f46; |
| } |
| |
| .status-alert.info { |
| background-color: #eff6ff; |
| border-color: #bfdbfe; |
| color: #1e40af; |
| } |
| |
| .status-alert.error { |
| background-color: #fef2f2; |
| border-color: #fecaca; |
| color: #991b1b; |
| } |
| |
| .alert-spinner { |
| width: 14px; |
| height: 14px; |
| border: 2px solid rgba(0, 0, 0, 0.05); |
| border-top: 2px solid currentColor; |
| border-radius: 50%; |
| animation: spin 0.8s linear infinite; |
| } |
| |
| @keyframes spin { |
| 0% { transform: rotate(0deg); } |
| 100% { transform: rotate(360deg); } |
| } |
| |
| |
| .btn-toggle-sidebar { |
| background: #ffffff; |
| border: 1px solid var(--panel-border); |
| color: var(--text-primary); |
| font-size: 0.8rem; |
| font-weight: 600; |
| padding: 8px 14px; |
| border-radius: var(--radius-sm); |
| cursor: pointer; |
| display: none; |
| align-items: center; |
| gap: 6px; |
| transition: var(--transition); |
| outline: none; |
| } |
| |
| .btn-toggle-sidebar:hover { |
| border-color: var(--text-muted); |
| background-color: var(--bg-color); |
| } |
| |
| .sidebar-overlay { |
| position: fixed; |
| top: 0; |
| left: 0; |
| width: 100vw; |
| height: 100vh; |
| background: rgba(15, 23, 42, 0.4); |
| backdrop-filter: blur(2px); |
| z-index: 99; |
| opacity: 0; |
| pointer-events: none; |
| transition: opacity 0.25s ease; |
| } |
| |
| .sidebar-overlay.active { |
| opacity: 1; |
| pointer-events: auto; |
| } |
| |
| |
| .settings-sidebar-close { |
| position: absolute; |
| right: 16px; |
| top: 16px; |
| background: none; |
| border: none; |
| font-size: 1.15rem; |
| color: var(--text-secondary); |
| cursor: pointer; |
| display: none; |
| transition: var(--transition); |
| z-index: 20; |
| } |
| |
| .settings-sidebar-close:hover { |
| color: var(--text-primary); |
| } |
| |
| |
| .ltx-banner { |
| background: #ffffff; |
| border: 1px solid var(--panel-border); |
| border-left: 4px solid var(--accent-resemble); |
| border-radius: var(--radius-md); |
| padding: 16px; |
| color: var(--text-secondary); |
| font-size: 0.82rem; |
| line-height: 1.5; |
| text-align: left; |
| box-shadow: 0 1px 2px rgba(0,0,0,0.02); |
| width: 100%; |
| } |
| |
| .ltx-banner a { |
| color: var(--accent-resemble); |
| font-weight: 600; |
| text-decoration: none; |
| } |
| |
| .ltx-banner a:hover { |
| text-decoration: underline; |
| } |
| |
| footer { |
| margin-top: auto; |
| text-align: left; |
| padding: 16px 0 0 0; |
| font-size: 0.72rem; |
| color: var(--text-muted); |
| border-top: 1px solid var(--panel-border); |
| font-weight: 500; |
| } |
| |
| .hidden-input { |
| display: none !important; |
| } |
| |
| |
| @media (max-width: 1100px) { |
| .app-layout { |
| flex-direction: row; |
| } |
| .btn-toggle-sidebar { |
| display: flex; |
| } |
| .settings-sidebar { |
| position: fixed; |
| top: 0; |
| right: 0; |
| height: 100%; |
| z-index: 1000; |
| box-shadow: -4px 0 24px rgba(15, 23, 42, 0.1); |
| } |
| .settings-sidebar-close { |
| display: block; |
| } |
| } |
| |
| @media (max-width: 768px) { |
| .workspace-header { |
| padding: 0 20px; |
| } |
| .workspace-main { |
| padding: 20px; |
| } |
| }</style> |
| </head> |
| <body> |
| <div class="app-layout"> |
| |
| |
| <div id="sidebar-overlay" class="sidebar-overlay"></div> |
|
|
| |
| <div class="workspace-area"> |
| <header class="workspace-header"> |
| <div class="header-left"> |
| <div class="header-logo"> |
| <span class="logo-dot"></span> |
| <span class="logo-text">DramaBox</span> |
| <span class="brand-badge-mini">by Resemble</span> |
| </div> |
| <span class="header-chevron" style="margin-left: 12px; margin-right: 12px; color: var(--text-muted);">/</span> |
| <span class="header-current" style="font-weight: 600; color: var(--text-secondary);">Studio</span> |
| </div> |
| |
| <div class="header-right"> |
| |
| <button id="btn-toggle-sidebar" class="btn-toggle-sidebar" title="Toggle Settings Panel"> |
| <svg class="toggle-icon-svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: middle;"><line x1="4" y1="21" x2="4" y2="14"></line><line x1="4" y1="10" x2="4" y2="3"></line><line x1="12" y1="21" x2="12" y2="12"></line><line x1="12" y1="8" x2="12" y2="3"></line><line x1="20" y1="21" x2="20" y2="16"></line><line x1="20" y1="12" x2="20" y2="3"></line><line x1="2" y1="14" x2="6" y2="14"></line><line x1="10" y1="8" x2="14" y2="8"></line><line x1="18" y1="16" x2="22" y2="16"></line></svg> |
| <span class="toggle-text" style="margin-left: 6px;">Show Settings</span> |
| </button> |
| </div> |
| </header> |
|
|
| <main class="workspace-main"> |
| |
| <div id="status-box" class="status-alert"> |
| <div class="alert-spinner"></div> |
| <span id="status-text">Connecting to DramaBox API...</span> |
| </div> |
|
|
| |
| <div class="editor-card"> |
| <div class="editor-header"> |
| <span class="editor-title">Script Prompt</span> |
| <span class="editor-desc">Double quotes for dialogue, standard style tags for pacing</span> |
| </div> |
| |
| <textarea |
| id="scene-prompt" |
| class="textarea-custom" |
| placeholder='A shadowy villain speaks with cold menace, "You have entered my domain, mortal." He chuckles darkly, "Such arrogance will be your undoing."' |
| ></textarea> |
| |
| <div class="editor-footer"> |
| <span class="char-counter" id="char-counter-text">Expressive voice generator powered by LTX-2.3</span> |
| <button id="btn-generate" class="btn-generate"> |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" style="vertical-align: middle; margin-right: 6px;"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg><span>Generate speech</span> |
| </button> |
| </div> |
| </div> |
|
|
| |
| <div class="output-wrapper"> |
| <audio id="audio-element" style="display:none;"></audio> |
| |
| |
| <div id="output-empty-state" class="empty-placeholder"> |
| <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="margin-bottom: 8px;"><path d="M2 10v3M6 6v11M10 3v18M14 8v7M18 5v13M22 10v3"/></svg> |
| <span class="empty-text">Formulate a script above to voice speech</span> |
| </div> |
|
|
| |
| <div id="custom-player" class="audio-player" style="display: none;"> |
| <div class="visualizer-box"> |
| <div class="visualizer-wave"> |
| <span class="wave-bar"></span> |
| <span class="wave-bar"></span> |
| <span class="wave-bar"></span> |
| <span class="wave-bar"></span> |
| <span class="wave-bar"></span> |
| <span class="wave-bar"></span> |
| <span class="wave-bar"></span> |
| <span class="wave-bar"></span> |
| <span class="wave-bar"></span> |
| <span class="wave-bar"></span> |
| <span class="wave-bar"></span> |
| <span class="wave-bar"></span> |
| <span class="wave-bar"></span> |
| <span class="wave-bar"></span> |
| <span class="wave-bar"></span> |
| <span class="wave-bar"></span> |
| <span class="wave-bar"></span> |
| <span class="wave-bar"></span> |
| <span class="wave-bar"></span> |
| <span class="wave-bar"></span> |
| <span class="wave-bar"></span> |
| <span class="wave-bar"></span> |
| <span class="wave-bar"></span> |
| <span class="wave-bar"></span> |
| <span class="wave-bar"></span> |
| <span class="wave-bar"></span> |
| <span class="wave-bar"></span> |
| <span class="wave-bar"></span> |
| <span class="wave-bar"></span> |
| <span class="wave-bar"></span> |
| </div> |
| </div> |
| |
| <div class="player-row"> |
| <button type="button" id="player-play" class="btn-play">▶</button> |
| <div class="progress-box"> |
| <span id="player-current-time" class="time-lbl">00:00</span> |
| <input type="range" id="player-progress" min="0" max="100" value="0"> |
| <span id="player-duration" class="time-lbl">00:00</span> |
| </div> |
| </div> |
|
|
| <div class="deck-footer"> |
| |
| <div class="vol-slider"> |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-secondary)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: middle;"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"/></svg> |
| <input type="range" id="player-volume" min="0" max="1" step="0.1" value="0.8" style="margin-left: 6px;"> |
| </div> |
|
|
| |
| <div class="speed-deck"> |
| <button type="button" class="btn-speed" data-speed="0.8">0.8x</button> |
| <button type="button" class="btn-speed active" data-speed="1.0">1.0x</button> |
| <button type="button" class="btn-speed" data-speed="1.2">1.2x</button> |
| <button type="button" class="btn-speed" data-speed="1.5">1.5x</button> |
| </div> |
|
|
| |
| <a id="player-download" class="btn-download-wav" href="#" download="dramabox_audio.wav"> |
| <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: middle; margin-right: 4px;"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg> Download |
| </a> |
| </div> |
| </div> |
| </div> |
|
|
| <div class="ltx-banner"> |
| Built on <a href="https://github.com/Lightricks/LTX-2" target="_blank">LTX-2</a> by |
| <a href="https://huggingface.co/Lightricks" target="_blank">Lightricks</a>. |
| <strong>DramaBox</strong> is <strong>Resemble AI's</strong> expressive TTS, |
| trained on top of the LTX-2.3 audio branch under the LTX-2 Community License. |
| Huge thanks to the Lightricks team for open-sourcing the base. |
| </div> |
|
|
| <footer> |
| © 2026 DramaBox. Audio output watermarked with Resemble Perth. |
| </footer> |
| </main> |
| </div> |
|
|
| |
| <aside id="app-sidebar" class="settings-sidebar"> |
| <button id="btn-close-sidebar" class="settings-sidebar-close" title="Close Panel">✕</button> |
| |
| <div class="settings-tabs"> |
| <button class="tab-btn active" id="tab-settings-btn">Settings</button> |
| <button class="tab-btn" id="tab-demos-btn">Demos</button> |
| </div> |
|
|
| |
| <div class="settings-pane" id="pane-settings"> |
| |
| <div class="settings-card"> |
| <span class="card-label">Voice Reference (Optional)</span> |
| <div id="dropzone" class="upload-zone"> |
| <svg class="upload-icon-svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="var(--accent-resemble)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-bottom: 12px;"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12"/></svg> |
| <div class="upload-text"> |
| <strong>Drop file</strong> or click to choose<br> |
| <span>WAV, MP3, etc. (10s+)</span> |
| </div> |
| </div> |
| <input type="file" id="audio-file" class="hidden-input" accept="audio/*"> |
| |
| |
| <div id="audio-trimmer-container" class="audio-trimmer-container" style="display: none;"> |
| <div class="trimmer-header"> |
| <div class="trimmer-file-info" style="display: flex; align-items: center;"> |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: middle; margin-right: 6px;"><path d="M9 18V5l12-2v13M9 9l12-2"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg> |
| <span id="trimmer-filename" class="trimmer-filename">audio.wav</span> |
| </div> |
| <button type="button" class="btn-clear-trimmer" id="btn-clear-trimmer" title="Remove audio reference">✕</button> |
| </div> |
| |
| <div class="trimmer-waveform-box"> |
| <canvas id="trimmer-canvas" class="trimmer-canvas"></canvas> |
| </div> |
| |
| <div class="trimmer-duration-info"> |
| <span class="duration-title">Trimmed Duration</span> |
| <span id="trimmer-duration-val" class="duration-val">0.0s / 0.0s</span> |
| </div> |
| |
| |
| <div class="trim-slider-group"> |
| <div class="trim-slider-meta"> |
| <span>Start Trim</span> |
| <span class="trim-value-label" id="trim-start-val">0.0s</span> |
| </div> |
| <input type="range" id="trim-start" class="trim-range-input" min="0" max="100" step="0.1" value="0"> |
| </div> |
| |
| <div class="trim-slider-group"> |
| <div class="trim-slider-meta"> |
| <span>End Trim</span> |
| <span class="trim-value-label" id="trim-end-val">100.0s</span> |
| </div> |
| <input type="range" id="trim-end" class="trim-range-input" min="0" max="100" step="0.1" value="100"> |
| </div> |
| |
| <div class="trimmer-controls"> |
| <button type="button" id="btn-trimmer-play" class="btn-trimmer-control btn-trimmer-play" title="Play trimmed section"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style="vertical-align: middle; margin-right: 4px;"><polygon points="5 3 19 12 5 21"/></svg> Play Trimmed</button> |
| <button type="button" id="btn-trimmer-reset" class="btn-trimmer-control btn-trimmer-reset" title="Reset trim selection"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: middle; margin-right: 4px;"><path d="M23 4v6h-6M1 20v-6h6M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg> Reset</button> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="model-banner-card"> |
| <div class="model-banner-title">Model Choices</div> |
| <div class="model-banner-body"> |
| <div class="model-banner-name">LTX-2.3 Expressive TTS v3</div> |
| <div class="model-banner-desc">Equipped with Resemble Perth watermarking & voice cloning.</div> |
| </div> |
| </div> |
|
|
| |
| <div class="settings-card flex-gap"> |
| |
| <div class="slider-group"> |
| <div class="slider-meta"> |
| <span>Stability / CFG Scale</span> |
| <span class="slider-value" id="val-cfg">2.5</span> |
| </div> |
| <input type="range" id="cfg" min="1.0" max="10.0" step="0.5" value="2.5"> |
| <div class="slider-hints"> |
| <span>More creative</span> |
| <span>More stable</span> |
| </div> |
| </div> |
|
|
| |
| <div class="slider-group"> |
| <div class="slider-meta"> |
| <span>Similarity / STG Scale</span> |
| <span class="slider-value" id="val-stg">1.5</span> |
| </div> |
| <input type="range" id="stg" min="0.0" max="5.0" step="0.5" value="1.5"> |
| <div class="slider-hints"> |
| <span>More expressive</span> |
| <span>High fidelity</span> |
| </div> |
| </div> |
|
|
| |
| <div class="slider-group"> |
| <div class="slider-meta"> |
| <span>Pacing / Breathing Factor</span> |
| <span class="slider-value" id="val-dur">1.10</span> |
| </div> |
| <input type="range" id="dur" min="0.8" max="2.0" step="0.05" value="1.1"> |
| <div class="slider-hints"> |
| <span>Slower</span> |
| <span>Faster</span> |
| </div> |
| </div> |
|
|
| |
| <div class="slider-group"> |
| <div class="slider-meta"> |
| <span>Fixed duration (s) - 0 = Auto</span> |
| <span class="slider-value" id="val-gendur">0.0</span> |
| </div> |
| <input type="range" id="gendur" min="0.0" max="60.0" step="1.0" value="0.0"> |
| </div> |
|
|
| |
| <div class="slider-group" style="display: none;"> |
| <div class="slider-meta"> |
| <span>Reference Window (s)</span> |
| <span class="slider-value" id="val-refdur">10.0</span> |
| </div> |
| <input type="range" id="refdur" min="3.0" max="30.0" step="1.0" value="10.0"> |
| </div> |
|
|
| |
| <div class="form-group-custom"> |
| <span class="custom-label">Seed Value</span> |
| <div class="seed-row"> |
| <input type="number" id="seed" class="input-seed" value="42"> |
| <button type="button" id="btn-random-seed" class="btn-seed-random" title="Randomize Seed"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: middle;"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg></button> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="settings-card flex-gap"> |
| <div class="guide-header" id="guide-toggle"> |
| <span><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: middle; margin-right: 6px;"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2zM22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>Prompt Guide</span> |
| <span class="accordion-icon" id="guide-arrow">▼</span> |
| </div> |
| |
| <div class="guide-body" id="guide-body"> |
| <div> |
| <div class="guide-block-title">Format Structure</div> |
| <p><code><description>, "<dialogue>" <movement/breath> "<more dialogue>"</code></p> |
| </div> |
| <div> |
| <div class="guide-block-title">Inside Quotes (Spoken)</div> |
| <ul class="guide-list"> |
| <li>Dialogue transcript: <code>"We are in position."</code></li> |
| <li>Phonetic expressions: <code>"Hahaha"</code>, <code>"Mmmm"</code>, <code>"Ugh"</code>, <code>"Argh"</code></li> |
| </ul> |
| </div> |
| <div> |
| <div class="guide-block-title">Outside Quotes (Pacing)</div> |
| <ul class="guide-list"> |
| <li>Action details: <code>She sighs deeply.</code>, <code>A long pause.</code></li> |
| <li>Tonal delivery: <code>His voice cracks.</code>, <code>He clears his throat.</code></li> |
| </ul> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="settings-pane" id="pane-demos" style="display: none;"> |
| <div class="settings-card"> |
| <span class="card-label">Quick Demos</span> |
| <div class="demo-capsules" id="examples-container"> |
| |
| </div> |
| </div> |
| </div> |
| </aside> |
|
|
| </div> |
|
|
| |
| <script type="module"> |
| import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js"; |
| |
| const EXAMPLES = [ |
| { |
| name: "Villain monologue", |
| gender: "male", |
| badge: "Male voice", |
| voice: "/assets/voices/male_harvey_keitel.mp3", |
| prompt: 'A shadowy villain speaks with cold menace, "You have entered my domain, mortal." He chuckles darkly, "Such arrogance will be your undoing." His voice rises with fury, "Kneel, or be destroyed where you stand!"', |
| duration: 0.0 |
| }, |
| { |
| name: "Talk-show host wheeze-laugh", |
| gender: "male", |
| badge: "Wheeze laugh", |
| voice: "/assets/voices/male_conan.mp3", |
| prompt: 'A talk show host gasps with shock, "No! You did NOT just say that!" He bursts into uncontrollable laughter, "Hahaha! Oh my god, oh my god!" He wheezes, "I cannot, I literally cannot breathe right now!"', |
| duration: 0.0 |
| }, |
| { |
| name: "Tender goodnight whisper", |
| gender: "female", |
| badge: "Whisper", |
| voice: "/assets/voices/female_shadowheart.wav", |
| prompt: 'A woman speaks tenderly, "It has been a long day, my love." She whispers, "Close your eyes. I am right here." She hums quietly, "Mmmm-mmm. Sleep now."', |
| duration: 0.0 |
| }, |
| { |
| name: "Old-school radio anchor", |
| gender: "male", |
| badge: "Anchor", |
| voice: "/assets/voices/male_old_movie.wav", |
| prompt: 'A radio host clears his throat, "Excuse me, pardon that." He settles into a warm, professional tone, "Good evening everyone, and welcome back to the show. We have got a wonderful lineup tonight."', |
| duration: 0.0 |
| }, |
| { |
| name: "Catgirl uncontrollable giggling", |
| gender: "female", |
| badge: "Giggling", |
| voice: "/assets/voices/female_american.wav", |
| prompt: 'A playful girl already mid-giggle, "Hehehe, oh my gosh you should see your face!" She gasps for air between giggles, "Oh my, hehe, oh my, I cannot stop!" She tries to compose herself, "Ahhhhh okay okay okay, I will stop, I promise."', |
| duration: 0.0 |
| }, |
| { |
| name: "Hero stammering courage", |
| gender: "male", |
| badge: "Heroic", |
| voice: "/assets/voices/male_arnie.mp3", |
| prompt: 'A young warrior speaks with a trembling voice, "I... I do not know if I can do this." He takes a shaky breath, "But someone has to try." His voice steadies with growing fire, "No more running. I WILL fight!"', |
| duration: 0.0 |
| }, |
| { |
| name: "Exhausted dad, fraying patience", |
| gender: "male", |
| badge: "Frustrated", |
| voice: "/assets/voices/male_petergriffin.wav", |
| prompt: 'An exhausted father speaks with fraying patience, "Sweetie, daddy is asking very nicely." He sighs deeply, "Ohhhh my goodness." He puts on an overly cheerful voice, "Hey buddy! Look at the shiny thing!" Then he laughs helplessly, "Hahaha, I am losing my mind."', |
| duration: 0.0 |
| }, |
| { |
| name: "Smug-confident announcer", |
| gender: "male", |
| badge: "Announcer", |
| voice: "/assets/voices/male_samuel_j.mp3", |
| prompt: 'A confident announcer speaks proudly, "And now, the moment you have all been waiting for." He chuckles knowingly, "Heheh, trust me, this one is going to blow you away."', |
| duration: 0.0 |
| }, |
| { |
| name: "30s • Villain soliloquy", |
| gender: "male", |
| badge: "30s clip", |
| voice: "/assets/voices/male_harvey_keitel.mp3", |
| prompt: 'A shadowy villain stands at the edge of his throne room, gazing into the dark. He speaks with slow, measured menace, "So, the little hero has come to finish me, has he?" He chuckles low and humourless, "Hehe, oh how delightfully predictable you mortals are." His voice hardens into ice, "I have lived ten thousand years. I have seen empires rise and fall like the tide." He scoffs, "And you think you, with your borrowed sword and your trembling hands, will be the one to end me?" A long pause. He whispers, almost tenderly, "I will give you a single chance to turn around and walk away." Then his voice rises with crushing finality, "Choose, child. The door behind you, or the grave at your feet."', |
| duration: 30.0 |
| }, |
| { |
| name: "30s • Late-night radio monologue", |
| gender: "male", |
| badge: "30s clip", |
| voice: "/assets/voices/male_old_movie.wav", |
| prompt: 'A radio host clears his throat softly into the microphone in the late hours of the night. He settles into a warm, smoky tone, "Good evening, dear listeners, and welcome back to the After Hours Hour." He sighs contentedly, "Mmm, what a night it has been. The rain is tapping at my window like an old friend." He chuckles softly, "Heheh, you know the kind of friend, the one that always shows up unannounced." His voice drops, intimate, "I want you to lean back, wherever you are. Pour yourself something warm." He pauses, breath audible, "Tonight we are going to talk about love, and loss, and the songs that hold us together." A smile in his voice, "And I have got the perfect record cued up to start us off, so stay right where you are."', |
| duration: 30.0 |
| }, |
| { |
| name: "30s • Bedtime story", |
| gender: "female", |
| badge: "30s clip", |
| voice: "/assets/voices/female_shadowheart.wav", |
| prompt: 'A mother sits at the edge of her child\'s bed in the dim glow of a single lamp. She speaks softly, "Once upon a time, in a kingdom by the sea, there lived a small dragon named Pip." She lowers her voice playfully, "Now Pip was not like the other dragons. Pip was afraid of fire." She smiles warmly, "Mmm, can you imagine? A dragon who was afraid of his own breath?" A gentle pause, "But Pip had something the other dragons did not have. Pip had courage in his heart." She hums softly, "Mmmmm. And one cold winter night, when the village below ran out of warmth..." Her voice drops to a whisper, "Pip closed his eyes, took a deep, deep breath, and remembered who he was."', |
| duration: 30.0 |
| } |
| ]; |
| |
| let client = null; |
| let selectedAudioFile = null; |
| let selectedAudioFilename = ""; |
| |
| |
| let trimmerAudioContext = null; |
| let originalAudioBuffer = null; |
| let trimmerAudioSource = null; |
| let isTrimmerPlaying = false; |
| |
| |
| const sidebar = document.getElementById("app-sidebar"); |
| const sidebarOverlay = document.getElementById("sidebar-overlay"); |
| const btnToggleSidebar = document.getElementById("btn-toggle-sidebar"); |
| const btnCloseSidebar = document.getElementById("btn-close-sidebar"); |
| |
| const statusBox = document.getElementById("status-box"); |
| const statusText = document.getElementById("status-text"); |
| const btnGenerate = document.getElementById("btn-generate"); |
| |
| const scenePrompt = document.getElementById("scene-prompt"); |
| const dropzone = document.getElementById("dropzone"); |
| const audioFileInput = document.getElementById("audio-file"); |
| |
| |
| const sliderCfg = document.getElementById("cfg"); |
| const valCfg = document.getElementById("val-cfg"); |
| const sliderStg = document.getElementById("stg"); |
| const valStg = document.getElementById("val-stg"); |
| const sliderDur = document.getElementById("dur"); |
| const valDur = document.getElementById("val-dur"); |
| const sliderGenDur = document.getElementById("gendur"); |
| const valGenDur = document.getElementById("val-gendur"); |
| const sliderRefDur = document.getElementById("refdur"); |
| const valRefDur = document.getElementById("val-refdur"); |
| |
| const inputSeed = document.getElementById("seed"); |
| const btnRandomSeed = document.getElementById("btn-random-seed"); |
| |
| const tabSettingsBtn = document.getElementById("tab-settings-btn"); |
| const tabDemosBtn = document.getElementById("tab-demos-btn"); |
| const paneSettings = document.getElementById("pane-settings"); |
| const paneDemos = document.getElementById("pane-demos"); |
| |
| if (tabSettingsBtn && tabDemosBtn && paneSettings && paneDemos) { |
| tabSettingsBtn.addEventListener("click", () => { |
| tabSettingsBtn.classList.add("active"); |
| tabDemosBtn.classList.remove("active"); |
| paneSettings.style.display = "flex"; |
| paneDemos.style.display = "none"; |
| }); |
| |
| tabDemosBtn.addEventListener("click", () => { |
| tabDemosBtn.classList.add("active"); |
| tabSettingsBtn.classList.remove("active"); |
| paneDemos.style.display = "flex"; |
| paneSettings.style.display = "none"; |
| }); |
| } |
| |
| |
| |
| const audioElement = document.getElementById("audio-element"); |
| const outputEmptyState = document.getElementById("output-empty-state"); |
| const customPlayer = document.getElementById("custom-player"); |
| const playerPlay = document.getElementById("player-play"); |
| const playerProgress = document.getElementById("player-progress"); |
| const playerCurrentTime = document.getElementById("player-current-time"); |
| const playerDuration = document.getElementById("player-duration"); |
| const playerVolume = document.getElementById("player-volume"); |
| const playerDownload = document.getElementById("player-download"); |
| const speedButtons = document.querySelectorAll(".btn-speed"); |
| |
| |
| const trimmerContainer = document.getElementById("audio-trimmer-container"); |
| const trimmerFilename = document.getElementById("trimmer-filename"); |
| const btnClearTrimmer = document.getElementById("btn-clear-trimmer"); |
| const trimmerCanvas = document.getElementById("trimmer-canvas"); |
| const labelTrimmerDuration = document.getElementById("trimmer-duration-val"); |
| const sliderTrimStart = document.getElementById("trim-start"); |
| const valTrimStart = document.getElementById("trim-start-val"); |
| const sliderTrimEnd = document.getElementById("trim-end"); |
| const valTrimEnd = document.getElementById("trim-end-val"); |
| const btnTrimmerPlay = document.getElementById("btn-trimmer-play"); |
| const btnTrimmerReset = document.getElementById("btn-trimmer-reset"); |
| |
| |
| const guideToggle = document.getElementById("guide-toggle"); |
| const guideBody = document.getElementById("guide-body"); |
| const guideArrow = document.getElementById("guide-arrow"); |
| |
| |
| function toggleSidebar() { |
| const isCollapsed = sidebar.classList.toggle("collapsed"); |
| sidebarOverlay.classList.toggle("active", !isCollapsed); |
| |
| const btnText = btnToggleSidebar.querySelector(".toggle-text"); |
| btnText.innerText = isCollapsed ? "Show Control Deck & Demos" : "Hide Control Deck & Demos"; |
| } |
| |
| btnToggleSidebar.addEventListener("click", toggleSidebar); |
| btnCloseSidebar.addEventListener("click", toggleSidebar); |
| sidebarOverlay.addEventListener("click", toggleSidebar); |
| |
| |
| if (window.innerWidth <= 992) { |
| sidebar.classList.add("collapsed"); |
| sidebarOverlay.classList.remove("active"); |
| } else { |
| |
| sidebar.classList.remove("collapsed"); |
| const btnText = btnToggleSidebar.querySelector(".toggle-text"); |
| btnText.innerText = "Hide Control Deck & Demos"; |
| } |
| |
| |
| function updateStatus(message, type = "info", showSpinner = true) { |
| statusBox.style.display = "flex"; |
| statusBox.className = `status-alert ${type}`; |
| statusText.innerText = message; |
| |
| const spinner = statusBox.querySelector(".alert-spinner"); |
| if (showSpinner) { |
| spinner.style.display = "block"; |
| } else { |
| spinner.style.display = "none"; |
| } |
| } |
| |
| function hideStatus() { |
| statusBox.style.display = "none"; |
| } |
| |
| |
| async function connectClient() { |
| try { |
| updateStatus("Connecting to DramaBox API...", "info"); |
| client = await Client.connect(window.location.origin); |
| updateStatus("Engine online and ready", "success", false); |
| setTimeout(hideStatus, 2500); |
| } catch (err) { |
| console.error(err); |
| updateStatus("API synchronization failed. Please refresh.", "error", false); |
| } |
| } |
| |
| |
| guideToggle.addEventListener("click", () => { |
| const isOpen = guideBody.classList.toggle("open"); |
| guideArrow.innerText = isOpen ? "▲" : "▼"; |
| }); |
| |
| |
| sliderCfg.addEventListener("input", (e) => valCfg.innerText = parseFloat(e.target.value).toFixed(1)); |
| sliderStg.addEventListener("input", (e) => valStg.innerText = parseFloat(e.target.value).toFixed(1)); |
| sliderDur.addEventListener("input", (e) => valDur.innerText = parseFloat(e.target.value).toFixed(2)); |
| sliderGenDur.addEventListener("input", (e) => valGenDur.innerText = parseFloat(e.target.value).toFixed(1)); |
| sliderRefDur.addEventListener("input", (e) => valRefDur.innerText = parseFloat(e.target.value).toFixed(1)); |
| |
| |
| btnRandomSeed.addEventListener("click", () => { |
| const randomVal = Math.floor(Math.random() * 99999999); |
| inputSeed.value = randomVal; |
| btnRandomSeed.style.transform = "rotate(360deg)"; |
| setTimeout(() => btnRandomSeed.style.transform = "none", 300); |
| }); |
| |
| |
| dropzone.addEventListener("click", () => audioFileInput.click()); |
| |
| dropzone.addEventListener("dragover", (e) => { |
| e.preventDefault(); |
| dropzone.classList.add("dragover"); |
| }); |
| |
| dropzone.addEventListener("dragleave", () => { |
| dropzone.classList.remove("dragover"); |
| }); |
| |
| dropzone.addEventListener("drop", (e) => { |
| e.preventDefault(); |
| dropzone.classList.remove("dragover"); |
| const files = e.dataTransfer.files; |
| if (files.length > 0) { |
| handleUploadedFile(files[0]); |
| } |
| }); |
| |
| audioFileInput.addEventListener("change", (e) => { |
| if (e.target.files.length > 0) { |
| handleUploadedFile(e.target.files[0]); |
| } |
| }); |
| |
| function handleUploadedFile(file) { |
| selectedAudioFile = file; |
| selectedAudioFilename = file.name; |
| loadAudioIntoTrimmer(file); |
| } |
| |
| |
| function bufferToWav(buffer) { |
| let numOfChan = buffer.numberOfChannels, |
| length = buffer.length * numOfChan * 2 + 44, |
| bufferArr = new ArrayBuffer(length), |
| view = new DataView(bufferArr), |
| channels = [], i, sample, |
| offset = 0, |
| pos = 0; |
| |
| function setUint16(data) { |
| view.setUint16(pos, data, true); |
| pos += 2; |
| } |
| |
| function setUint32(data) { |
| view.setUint32(pos, data, true); |
| pos += 4; |
| } |
| |
| setUint32(0x46464952); |
| setUint32(length - 8); |
| setUint32(0x45564157); |
| |
| setUint32(0x20746d66); |
| setUint32(16); |
| setUint16(1); |
| setUint16(numOfChan); |
| setUint32(buffer.sampleRate); |
| setUint32(buffer.sampleRate * 2 * numOfChan); |
| setUint16(numOfChan * 2); |
| setUint16(16); |
| |
| setUint32(0x61746164); |
| setUint32(length - pos - 4); |
| |
| for (i = 0; i < numOfChan; i++) { |
| channels.push(buffer.getChannelData(i)); |
| } |
| |
| while (pos < length) { |
| for (i = 0; i < numOfChan; i++) { |
| sample = Math.max(-1, Math.min(1, channels[i][offset])); |
| sample = (sample < 0 ? sample * 0x8000 : sample * 0x7FFF); |
| view.setInt16(pos, sample, true); |
| pos += 2; |
| } |
| offset++; |
| } |
| |
| return new Blob([bufferArr], { type: "audio/wav" }); |
| } |
| |
| |
| function drawWaveform(buffer, canvas, startTime, endTime) { |
| const ctx = canvas.getContext("2d"); |
| const width = canvas.width; |
| const height = canvas.height; |
| ctx.clearRect(0, 0, width, height); |
| |
| const data = buffer.getChannelData(0); |
| const step = Math.ceil(data.length / width); |
| const amp = height / 2; |
| const duration = buffer.duration; |
| const startPercent = startTime / duration; |
| const endPercent = endTime / duration; |
| |
| for (let i = 0; i < width; i++) { |
| let min = 1.0; |
| let max = -1.0; |
| for (let j = 0; j < step; j++) { |
| const idx = i * step + j; |
| if (idx >= data.length) break; |
| const datum = data[idx]; |
| if (datum < min) min = datum; |
| if (datum > max) max = datum; |
| } |
| |
| const x = i; |
| const y = amp; |
| const h = Math.max(2, (max - min) * amp * 0.85); |
| const currentPercent = i / width; |
| const isSelected = currentPercent >= startPercent && currentPercent <= endPercent; |
| |
| if (isSelected) { |
| ctx.fillStyle = "#2cbd94"; |
| } else { |
| ctx.fillStyle = "rgba(255, 255, 255, 0.2)"; |
| } |
| |
| ctx.fillRect(x, y - h / 2, 2, h); |
| } |
| } |
| |
| |
| function playTrimmedSlice(buffer, startTime, endTime, onEndedCallback) { |
| if (!trimmerAudioContext) { |
| trimmerAudioContext = new (window.AudioContext || window.webkitAudioContext)(); |
| } |
| |
| stopTrimmerPlay(); |
| |
| if (trimmerAudioContext.state === "suspended") { |
| trimmerAudioContext.resume(); |
| } |
| |
| const duration = endTime - startTime; |
| trimmerAudioSource = trimmerAudioContext.createBufferSource(); |
| trimmerAudioSource.buffer = buffer; |
| trimmerAudioSource.connect(trimmerAudioContext.destination); |
| |
| trimmerAudioSource.start(0, startTime, duration); |
| isTrimmerPlaying = true; |
| |
| trimmerAudioSource.onended = () => { |
| isTrimmerPlaying = false; |
| if (onEndedCallback) onEndedCallback(); |
| }; |
| } |
| |
| |
| function stopTrimmerPlay() { |
| if (trimmerAudioSource) { |
| try { |
| trimmerAudioSource.stop(); |
| } catch (e) {} |
| trimmerAudioSource = null; |
| } |
| isTrimmerPlaying = false; |
| } |
| |
| |
| function loadAudioIntoTrimmer(file) { |
| stopTrimmerPlay(); |
| btnTrimmerPlay.innerText = "▶ Play Trimmed"; |
| btnTrimmerPlay.classList.remove("playing"); |
| |
| const reader = new FileReader(); |
| reader.onload = async (e) => { |
| const arrayBuffer = e.target.result; |
| try { |
| updateStatus("Decoding audio file for editor...", "info"); |
| if (!trimmerAudioContext) { |
| trimmerAudioContext = new (window.AudioContext || window.webkitAudioContext)(); |
| } |
| |
| originalAudioBuffer = await trimmerAudioContext.decodeAudioData(arrayBuffer); |
| const duration = originalAudioBuffer.duration; |
| |
| |
| dropzone.style.display = "none"; |
| trimmerContainer.style.display = "flex"; |
| trimmerFilename.innerText = selectedAudioFilename; |
| |
| |
| sliderTrimStart.min = 0; |
| sliderTrimStart.max = duration; |
| sliderTrimStart.step = 0.1; |
| sliderTrimStart.value = 0; |
| |
| sliderTrimEnd.min = 0; |
| sliderTrimEnd.max = duration; |
| sliderTrimEnd.step = 0.1; |
| sliderTrimEnd.value = duration; |
| |
| valTrimStart.innerText = "0.0s"; |
| valTrimEnd.innerText = duration.toFixed(1) + "s"; |
| labelTrimmerDuration.innerText = duration.toFixed(1) + "s / " + duration.toFixed(1) + "s"; |
| |
| |
| setTimeout(() => { |
| trimmerCanvas.width = trimmerCanvas.clientWidth || 250; |
| trimmerCanvas.height = trimmerCanvas.clientHeight || 64; |
| drawWaveform(originalAudioBuffer, trimmerCanvas, 0, duration); |
| }, 50); |
| |
| updateStatus("Audio loaded successfully", "success", false); |
| setTimeout(hideStatus, 1500); |
| |
| } catch (err) { |
| console.error(err); |
| updateStatus("Failed to decode audio file for editing", "error", false); |
| } |
| }; |
| reader.readAsArrayBuffer(file); |
| } |
| |
| |
| function clearUploadedFile() { |
| stopTrimmerPlay(); |
| selectedAudioFile = null; |
| selectedAudioFilename = ""; |
| originalAudioBuffer = null; |
| audioFileInput.value = ""; |
| |
| trimmerContainer.style.display = "none"; |
| dropzone.style.display = "flex"; |
| dropzone.innerHTML = ` |
| <svg class="upload-icon-svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="var(--accent-resemble)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-bottom: 12px;"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12"/></svg> |
| <div class="upload-text"> |
| <strong>Drop file</strong> or click to choose<br> |
| <span>WAV, MP3, etc. (10s+)</span> |
| </div> |
| `; |
| } |
| |
| |
| async function loadExampleVoice(voicePath, originalFilename) { |
| try { |
| updateStatus("Buffering reference voice...", "info"); |
| const response = await fetch(voicePath); |
| if (!response.ok) throw new Error("Could not fetch remote resource."); |
| |
| const blob = await response.blob(); |
| selectedAudioFile = new File([blob], originalFilename, { type: blob.type || "audio/mpeg" }); |
| selectedAudioFilename = originalFilename; |
| |
| loadAudioIntoTrimmer(selectedAudioFile); |
| updateStatus("Reference voice mapped", "success", false); |
| setTimeout(hideStatus, 1500); |
| } catch (err) { |
| console.error(err); |
| updateStatus("Unable to map the voice example.", "error", false); |
| } |
| } |
| |
| |
| function handleTrimSliderChange() { |
| if (!originalAudioBuffer) return; |
| |
| let start = parseFloat(sliderTrimStart.value); |
| let end = parseFloat(sliderTrimEnd.value); |
| const duration = originalAudioBuffer.duration; |
| |
| |
| if (start >= end - 0.5) { |
| start = Math.max(0, end - 0.5); |
| sliderTrimStart.value = start; |
| } |
| if (end <= start + 0.5) { |
| end = Math.min(duration, start + 0.5); |
| sliderTrimEnd.value = end; |
| } |
| |
| valTrimStart.innerText = start.toFixed(1) + "s"; |
| valTrimEnd.innerText = end.toFixed(1) + "s"; |
| |
| const trimmedDur = end - start; |
| labelTrimmerDuration.innerText = trimmedDur.toFixed(1) + "s / " + duration.toFixed(1) + "s"; |
| |
| drawWaveform(originalAudioBuffer, trimmerCanvas, start, end); |
| |
| |
| if (isTrimmerPlaying) { |
| stopTrimmerPlay(); |
| btnTrimmerPlay.innerText = "▶ Play Trimmed"; |
| btnTrimmerPlay.classList.remove("playing"); |
| } |
| } |
| |
| sliderTrimStart.addEventListener("input", handleTrimSliderChange); |
| sliderTrimEnd.addEventListener("input", handleTrimSliderChange); |
| |
| |
| btnTrimmerPlay.addEventListener("click", () => { |
| if (!originalAudioBuffer) return; |
| |
| if (isTrimmerPlaying) { |
| stopTrimmerPlay(); |
| btnTrimmerPlay.innerText = "▶ Play Trimmed"; |
| btnTrimmerPlay.classList.remove("playing"); |
| } else { |
| btnTrimmerPlay.innerText = "⏸ Stop"; |
| btnTrimmerPlay.classList.add("playing"); |
| |
| const start = parseFloat(sliderTrimStart.value); |
| const end = parseFloat(sliderTrimEnd.value); |
| |
| playTrimmedSlice(originalAudioBuffer, start, end, () => { |
| btnTrimmerPlay.innerText = "▶ Play Trimmed"; |
| btnTrimmerPlay.classList.remove("playing"); |
| }); |
| } |
| }); |
| |
| |
| btnTrimmerReset.addEventListener("click", () => { |
| if (!originalAudioBuffer) return; |
| |
| stopTrimmerPlay(); |
| btnTrimmerPlay.innerText = "▶ Play Trimmed"; |
| btnTrimmerPlay.classList.remove("playing"); |
| |
| const duration = originalAudioBuffer.duration; |
| sliderTrimStart.value = 0; |
| sliderTrimEnd.value = duration; |
| |
| valTrimStart.innerText = "0.0s"; |
| valTrimEnd.innerText = duration.toFixed(1) + "s"; |
| labelTrimmerDuration.innerText = duration.toFixed(1) + "s / " + duration.toFixed(1) + "s"; |
| |
| drawWaveform(originalAudioBuffer, trimmerCanvas, 0, duration); |
| }); |
| |
| |
| btnClearTrimmer.addEventListener("click", (e) => { |
| e.stopPropagation(); |
| clearUploadedFile(); |
| }); |
| |
| |
| const examplesContainer = document.getElementById("examples-container"); |
| EXAMPLES.forEach((ex) => { |
| const pill = document.createElement("div"); |
| pill.className = "demo-pill"; |
| |
| const isLong = ex.name.startsWith("30s"); |
| const badgeHtml = ` |
| <span class="pill-badge ${ex.gender === "male" ? "pill-badge-male" : "pill-badge-female"}">${ex.gender}</span> |
| ${isLong ? '<span class="pill-badge pill-badge-long">30s</span>' : ''} |
| `; |
| |
| pill.innerHTML = ` |
| <span class="demo-pill-title">${ex.name}</span> |
| <div class="pill-labels">${badgeHtml}</div> |
| `; |
| |
| pill.addEventListener("click", () => { |
| document.querySelectorAll(".demo-pill").forEach(el => el.classList.remove("active")); |
| pill.classList.add("active"); |
| |
| scenePrompt.value = ex.prompt; |
| sliderGenDur.value = ex.duration; |
| valGenDur.innerText = ex.duration.toFixed(1); |
| |
| const filename = ex.voice.substring(ex.voice.lastIndexOf('/') + 1); |
| loadExampleVoice(ex.voice, filename); |
| |
| |
| if (window.innerWidth <= 992) { |
| toggleSidebar(); |
| } |
| }); |
| |
| examplesContainer.appendChild(pill); |
| }); |
| |
| |
| let isAudioReady = false; |
| |
| function setupAudioSource(src) { |
| audioElement.src = src; |
| audioElement.load(); |
| isAudioReady = true; |
| |
| outputEmptyState.style.display = "none"; |
| customPlayer.style.display = "flex"; |
| |
| playerPlay.innerText = "▶"; |
| customPlayer.classList.remove("playing"); |
| playerProgress.value = 0; |
| playerDownload.href = src; |
| } |
| |
| audioElement.addEventListener("loadedmetadata", () => { |
| playerDuration.innerText = formatTime(audioElement.duration); |
| }); |
| |
| audioElement.addEventListener("timeupdate", () => { |
| if (audioElement.duration) { |
| const percent = (audioElement.currentTime / audioElement.duration) * 100; |
| playerProgress.value = percent; |
| playerCurrentTime.innerText = formatTime(audioElement.currentTime); |
| } |
| }); |
| |
| audioElement.addEventListener("ended", () => { |
| playerPlay.innerText = "▶"; |
| customPlayer.classList.remove("playing"); |
| playerProgress.value = 0; |
| playerCurrentTime.innerText = "00:00"; |
| }); |
| |
| playerPlay.addEventListener("click", () => { |
| if (!isAudioReady) return; |
| |
| if (audioElement.paused) { |
| audioElement.play(); |
| playerPlay.innerText = "⏸"; |
| customPlayer.classList.add("playing"); |
| } else { |
| audioElement.pause(); |
| playerPlay.innerText = "▶"; |
| customPlayer.classList.remove("playing"); |
| } |
| }); |
| |
| playerProgress.addEventListener("input", (e) => { |
| if (!isAudioReady || !audioElement.duration) return; |
| const newTime = (e.target.value / 100) * audioElement.duration; |
| audioElement.currentTime = newTime; |
| }); |
| |
| playerVolume.addEventListener("input", (e) => { |
| audioElement.volume = e.target.value; |
| }); |
| |
| speedButtons.forEach(btn => { |
| btn.addEventListener("click", () => { |
| speedButtons.forEach(b => b.classList.remove("active")); |
| btn.classList.add("active"); |
| audioElement.playbackRate = parseFloat(btn.dataset.speed); |
| }); |
| }); |
| |
| function formatTime(secs) { |
| const minutes = Math.floor(secs / 60); |
| const seconds = Math.floor(secs % 60); |
| return `${minutes < 10 ? '0' : ''}${minutes}:${seconds < 10 ? '0' : ''}${seconds}`; |
| } |
| |
| |
| btnGenerate.addEventListener("click", async () => { |
| const prompt = scenePrompt.value.trim(); |
| if (!prompt) { |
| updateStatus("Please enter a script prompt.", "error", false); |
| return; |
| } |
| |
| if (!client) { |
| updateStatus("DramaBox is still configuring, please wait.", "error", false); |
| return; |
| } |
| |
| const cfg = parseFloat(sliderCfg.value); |
| const stg = parseFloat(sliderStg.value); |
| const durMult = parseFloat(sliderDur.value); |
| const genDur = parseFloat(sliderGenDur.value); |
| const refDur = parseFloat(sliderRefDur.value); |
| const seed = parseInt(inputSeed.value); |
| |
| try { |
| |
| if (isTrimmerPlaying) { |
| stopTrimmerPlay(); |
| btnTrimmerPlay.innerText = "▶ Play Trimmed"; |
| btnTrimmerPlay.classList.remove("playing"); |
| } |
| |
| btnGenerate.disabled = true; |
| btnGenerate.innerHTML = `<div class="alert-spinner"></div> Synthesizing...`; |
| updateStatus("Checking models & processing queues...", "info"); |
| |
| let uploadedFileData = null; |
| if (selectedAudioFile) { |
| let fileToUpload = selectedAudioFile; |
| |
| |
| if (originalAudioBuffer) { |
| const start = parseFloat(sliderTrimStart.value); |
| const end = parseFloat(sliderTrimEnd.value); |
| |
| |
| if (start > 0 || end < originalAudioBuffer.duration) { |
| const sampleRate = originalAudioBuffer.sampleRate; |
| const startSample = Math.floor(start * sampleRate); |
| const endSample = Math.min(originalAudioBuffer.length, Math.floor(end * sampleRate)); |
| const trimLength = endSample - startSample; |
| |
| if (trimLength > 0) { |
| const trimmedBuffer = trimmerAudioContext.createBuffer( |
| originalAudioBuffer.numberOfChannels, |
| trimLength, |
| sampleRate |
| ); |
| |
| for (let channel = 0; channel < originalAudioBuffer.numberOfChannels; channel++) { |
| const channelData = originalAudioBuffer.getChannelData(channel); |
| const trimmedChannelData = trimmedBuffer.getChannelData(channel); |
| trimmedChannelData.set(channelData.subarray(startSample, endSample)); |
| } |
| |
| const wavBlob = bufferToWav(trimmedBuffer); |
| const trimmedName = `trimmed_${selectedAudioFilename.replace(/\.[^/.]+$/, "")}.wav`; |
| fileToUpload = new File([wavBlob], trimmedName, { type: "audio/wav" }); |
| } |
| } |
| } |
| |
| uploadedFileData = handle_file(fileToUpload); |
| } |
| |
| |
| const predictResponse = await client.predict("/generate_audio", { |
| prompt: prompt, |
| audio_ref: uploadedFileData, |
| cfg: cfg, |
| stg: stg, |
| dur_mult: durMult, |
| gen_dur: genDur, |
| ref_dur: refDur, |
| seed: seed |
| }); |
| |
| if (predictResponse && predictResponse.data && predictResponse.data.length > 0) { |
| const audioUrl = predictResponse.data[0].url; |
| setupAudioSource(audioUrl); |
| updateStatus("Speech generation complete!", "success", false); |
| setTimeout(hideStatus, 2500); |
| } else { |
| throw new Error("No output returned from the generation server."); |
| } |
| |
| } catch (err) { |
| console.error(err); |
| updateStatus(err.message || "Speech generation failed. Please try again.", "error", false); |
| } finally { |
| btnGenerate.disabled = false; |
| btnGenerate.innerHTML = `<span>⚡</span> Generate Speech`; |
| } |
| }); |
| |
| |
| connectClient(); |
| </script> |
| </body> |
| </html> |
|
|