| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1, user-scalable=no"> |
| <title>Boovore — Multi-Engine TTS Studio</title> |
| <style> |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } |
| |
| :root { |
| --bg: #09090b; |
| --surface: #18181b; |
| --surface2: #27272a; |
| --border: #27272a; |
| --border2: #3f3f46; |
| --text: #fafafa; |
| --text2: #a1a1aa; |
| --dim: #71717a; |
| --dimmer: #52525b; |
| --accent: #8b5cf6; |
| --accent2: #7c3aed; |
| --accent-bg: rgba(139,92,246,0.1); |
| --accent-ring: rgba(139,92,246,0.3); |
| --green: #22c55e; |
| --amber: #eab308; |
| --red: #ef4444; |
| --radius: 10px; |
| --radius-sm: 6px; |
| --shadow: 0 1px 3px rgba(0,0,0,0.4); |
| } |
| :root[data-theme="light"] { |
| --bg: #fafafa; |
| --surface: #ffffff; |
| --surface2: #f4f4f5; |
| --border: #e4e4e7; |
| --border2: #d4d4d8; |
| --text: #09090b; |
| --text2: #52525b; |
| --dim: #71717a; |
| --dimmer: #a1a1aa; |
| --accent: #7c3aed; |
| --accent2: #6d28d9; |
| --accent-bg: rgba(124,58,237,0.07); |
| --accent-ring: rgba(124,58,237,0.2); |
| --shadow: 0 1px 3px rgba(0,0,0,0.08); |
| } |
| |
| html, body { height: 100%; overflow: auto; } |
| |
| body { |
| background: var(--bg); |
| color: var(--text); |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; |
| font-size: 14px; |
| line-height: 1.5; |
| } |
| |
| |
| .app { |
| display: flex; |
| flex-direction: column; |
| justify-content: flex-start; |
| min-height: 100vh; |
| min-height: 100dvh; |
| max-width: 680px; |
| margin: 0 auto; |
| padding: 10px 16px; |
| padding-bottom: max(10px, env(safe-area-inset-bottom)); |
| gap: 8px; |
| } |
| |
| |
| .hdr { |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| flex-shrink: 0; |
| } |
| .hdr h1 { font-size: 15px; font-weight: 600; letter-spacing: -0.3px; } |
| .badge { |
| font-size: 9px; font-weight: 700; letter-spacing: 0.8px; |
| background: var(--accent-bg); color: var(--accent); |
| border: 1px solid var(--accent-ring); |
| padding: 1px 6px; border-radius: 100px; |
| } |
| .spacer { flex: 1; } |
| .mpill { |
| display: flex; align-items: center; gap: 5px; |
| font-size: 11px; color: var(--dim); |
| padding: 4px 10px; |
| background: var(--surface); border: 1px solid var(--border); |
| border-radius: 20px; cursor: pointer; |
| transition: border-color 0.15s; |
| } |
| .mpill:hover { border-color: var(--border2); } |
| .mdot { |
| width: 6px; height: 6px; border-radius: 50%; |
| background: var(--dimmer); flex-shrink: 0; |
| transition: background 0.3s; |
| } |
| .mdot.loaded { background: var(--green); } |
| .mdot.loading { background: var(--amber); animation: blink 0.7s infinite; } |
| .mdot.error { background: var(--red); } |
| .ibtn { |
| width: 30px; height: 30px; |
| display: flex; align-items: center; justify-content: center; |
| background: transparent; |
| border: 1px solid var(--border); border-radius: 8px; |
| color: var(--dim); cursor: pointer; |
| transition: all 0.15s; |
| } |
| .ibtn:hover { border-color: var(--border2); color: var(--text); } |
| .ibtn svg { width: 15px; height: 15px; } |
| |
| |
| .main { |
| display: flex; flex-direction: column; gap: 6px; |
| } |
| .text-wrap textarea { |
| width: 100%; height: 120px; |
| background: var(--surface); border: 1px solid var(--border); |
| border-radius: var(--radius); |
| color: var(--text); font-size: 15px; font-family: inherit; |
| padding: 10px 12px; resize: none; outline: none; |
| transition: border-color 0.15s; |
| } |
| .text-wrap textarea:focus { border-color: var(--accent-ring); } |
| .text-wrap textarea::placeholder { color: var(--dimmer); } |
| |
| |
| .mode-row { |
| flex-shrink: 0; |
| padding: 7px 9px; border-radius: var(--radius-sm); |
| transition: box-shadow 0.2s, opacity 0.2s; |
| } |
| .mode-row .label { |
| font-size: 11px; color: var(--dim); |
| margin-bottom: 4px; |
| display: flex; align-items: center; gap: 8px; |
| transition: color 0.2s; |
| } |
| .mode-row .label::after { |
| content: ''; flex: 1; height: 1px; |
| background: var(--border); |
| transition: background 0.2s; |
| } |
| .mode-row .row { display: flex; align-items: flex-start; gap: 6px; } |
| |
| |
| .mode-row.compatible { |
| box-shadow: 0 0 0 1px var(--accent), |
| 0 0 12px rgba(139,92,246,0.45), |
| 0 0 24px rgba(139,92,246,0.15); |
| } |
| .mode-row.compatible .label { |
| color: var(--accent); font-weight: 600; letter-spacing: 0.03em; |
| } |
| .mode-row.compatible .label::before { |
| content: '▶'; color: var(--accent); |
| font-size: 9px; |
| animation: blink-cursor 1s step-end infinite; |
| } |
| .mode-row.compatible .label::after { background: var(--accent-ring); } |
| @keyframes blink-cursor { 0%, 100% { opacity: 1; } 50% { opacity: 0; } } |
| |
| |
| .mode-row.loading { |
| animation: row-pulse 0.7s ease-in-out infinite alternate; |
| } |
| .mode-row.loading .label { |
| color: var(--accent); font-weight: 600; letter-spacing: 0.03em; |
| } |
| .mode-row.loading .label::before { |
| content: '◌'; color: var(--accent); font-size: 11px; |
| animation: spin-ring 1s linear infinite; |
| } |
| .mode-row.loading .label::after { background: var(--accent-ring); } |
| @keyframes row-pulse { |
| from { box-shadow: 0 0 0 1px rgba(139,92,246,0.4), 0 0 6px rgba(139,92,246,0.2); } |
| to { box-shadow: 0 0 0 1px var(--accent), 0 0 20px rgba(139,92,246,0.55), 0 0 36px rgba(139,92,246,0.2); } |
| } |
| @keyframes spin-ring { to { transform: rotate(360deg); } } |
| |
| |
| .mode-row.incompatible { opacity: 0.42; cursor: pointer; } |
| |
| .mode-row.incompatible > * { pointer-events: none; } |
| .mode-row.incompatible:hover { |
| opacity: 0.9; |
| box-shadow: 0 0 0 1px var(--border2), 0 0 10px rgba(139,92,246,0.2); |
| } |
| .mode-row.incompatible:hover .label { color: var(--text2); } |
| .mode-row.incompatible:hover .switch-tag { display: inline-flex; } |
| |
| |
| .switch-tag { |
| display: none; align-items: center; |
| font-size: 9px; font-family: monospace; font-weight: 700; |
| letter-spacing: 0.12em; text-transform: uppercase; |
| color: var(--accent); padding: 1px 5px; |
| border: 1px solid var(--accent-ring); border-radius: 3px; |
| background: var(--accent-bg); |
| animation: flicker-in 0.35s step-end; |
| } |
| |
| .mode-row.compatible .switch-tag { display: inline-flex; } |
| @keyframes flicker-in { |
| 0% { opacity: 0; } 20% { opacity: 1; } 35% { opacity: 0.2; } |
| 55% { opacity: 1; } 70% { opacity: 0.5; } 100% { opacity: 1; } |
| } |
| |
| |
| .lov { |
| position: fixed; inset: 0; |
| background: rgba(0,0,0,0.75); |
| backdrop-filter: blur(2px); |
| z-index: 110; |
| display: flex; align-items: center; justify-content: center; |
| padding: 20px; |
| opacity: 0; pointer-events: none; |
| transition: opacity 0.2s; |
| } |
| .lov.open { opacity: 1; pointer-events: auto; } |
| .lpanel { |
| background: var(--bg); |
| border: 1px solid var(--border); |
| border-top: 2px solid var(--accent); |
| border-radius: 16px; |
| width: 100%; max-width: 460px; |
| max-height: 85dvh; overflow-y: auto; |
| padding: 20px 20px 24px; |
| transform: scale(0.93) translateY(12px); |
| transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1); |
| box-shadow: 0 0 40px rgba(139,92,246,0.2), 0 20px 60px rgba(0,0,0,0.5); |
| } |
| .lov.open .lpanel { transform: scale(1) translateY(0); } |
| .l-header { |
| display: flex; align-items: center; margin-bottom: 14px; |
| } |
| .l-back { |
| display: flex; align-items: center; gap: 4px; |
| font-size: 11px; font-family: monospace; color: var(--dim); |
| cursor: pointer; border: none; background: none; |
| padding: 0; margin-right: auto; |
| transition: color 0.15s; |
| } |
| .l-back:hover { color: var(--text2); } |
| .l-title { |
| font-family: monospace; font-size: 12px; font-weight: 700; |
| letter-spacing: 0.22em; color: var(--accent); |
| text-align: center; flex: 1; |
| text-shadow: 0 0 8px rgba(139,92,246,0.6); |
| } |
| .lcard-grid { |
| display: grid; |
| grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); |
| gap: 8px; margin-bottom: 14px; |
| } |
| .lcard { |
| border: 1px solid var(--border2); border-radius: var(--radius-sm); |
| padding: 10px 12px; cursor: pointer; transition: all 0.15s; |
| background: var(--surface); |
| } |
| .lcard:hover { border-color: var(--accent-ring); background: var(--accent-bg); } |
| .lcard.sel { |
| border-color: var(--accent); background: var(--accent-bg); |
| box-shadow: 0 0 8px rgba(139,92,246,0.35); |
| } |
| .lcard .lc-name { font-size: 13px; font-weight: 600; color: var(--text); } |
| .lcard .lc-sub { font-size: 11px; color: var(--dim); margin-top: 2px; } |
| .lcard.sel .lc-name { color: var(--accent); } |
| |
| |
| .ref-chip { |
| display: inline-flex; align-items: center; gap: 5px; |
| padding: 6px 10px; |
| background: var(--surface); border: 1px dashed var(--border2); |
| border-radius: var(--radius-sm); |
| font-size: 12px; color: var(--dim); |
| cursor: pointer; transition: all 0.15s; |
| position: relative; overflow: hidden; |
| max-width: 220px; |
| } |
| .ref-chip:hover { border-color: var(--accent); color: var(--accent); } |
| .ref-chip.has { border-style: solid; color: var(--accent); border-color: var(--accent-ring); } |
| .ref-chip input[type="file"] { |
| position: absolute; inset: 0; opacity: 0; cursor: pointer; |
| } |
| .ref-chip svg { width: 14px; height: 14px; flex-shrink: 0; } |
| .ref-chip span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| |
| |
| .mic-btn { |
| width: 32px; height: 32px; |
| display: flex; align-items: center; justify-content: center; |
| background: var(--surface); border: 1px solid var(--border); |
| border-radius: var(--radius-sm); |
| color: var(--dim); cursor: pointer; transition: all 0.15s; |
| flex-shrink: 0; |
| } |
| .mic-btn:hover { border-color: var(--accent); color: var(--accent); } |
| .mic-btn.rec { border-color: var(--red); color: var(--red); animation: blink 1s infinite; } |
| .mic-btn svg { width: 14px; height: 14px; } |
| |
| .preset-row { |
| display: contents; |
| } |
| .preset-row:empty { display: none; } |
| .preset-btn { |
| background: var(--surface2); |
| border: 1px solid var(--border2); |
| color: var(--text2); |
| padding: 6px 10px; |
| border-radius: var(--radius-sm); |
| font-size: 12px; |
| cursor: pointer; transition: all 0.15s; |
| flex-shrink: 0; |
| } |
| .preset-btn:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-bg); } |
| .preset-btn.active { border-color: var(--accent); color: var(--accent); background: var(--accent-bg); } |
| |
| .rec-time { |
| font-size: 11px; color: var(--red); |
| font-family: 'SF Mono', 'Fira Code', monospace; |
| min-width: 28px; |
| } |
| .rec-status { font-size: 11px; color: var(--dim); } |
| |
| |
| .rec-bar-wrap { |
| height: 3px; background: var(--border); border-radius: 2px; |
| margin-top: 4px; display: none; |
| } |
| .rec-bar { |
| height: 100%; width: 0%; |
| background: linear-gradient(90deg, var(--green), var(--amber), var(--red)); |
| border-radius: 2px; transition: width 0.05s; |
| } |
| |
| |
| .rec-preview { |
| width: 100%; height: 28px; border-radius: 4px; |
| margin-top: 4px; display: none; |
| } |
| :root:not([data-theme="light"]) .rec-preview { |
| filter: invert(0.88) hue-rotate(180deg); |
| } |
| |
| |
| .sm-sel { |
| padding: 6px 28px 6px 10px; |
| background: var(--surface); border: 1px solid var(--border); |
| border-radius: var(--radius-sm); |
| color: var(--text); font-size: 12px; font-family: inherit; |
| outline: none; cursor: pointer; appearance: none; |
| background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5'%3E%3Cpath fill='%2371717a' d='M4 5 0 0h8z'/%3E%3C/svg%3E"); |
| background-repeat: no-repeat; background-position: right 8px center; |
| } |
| .sm-sel option { background: var(--surface); } |
| |
| |
| .voice-in { |
| flex: 1; padding: 6px 10px; |
| background: var(--surface); border: 1px solid var(--border); |
| border-radius: var(--radius-sm); |
| color: var(--text); font-size: 12px; font-family: inherit; |
| outline: none; transition: border-color 0.15s; |
| resize: none; overflow: hidden; |
| min-height: 32px; line-height: 1.45; |
| } |
| .voice-in:focus { border-color: var(--accent-ring); } |
| .voice-in::placeholder { color: var(--dimmer); } |
| |
| .instr-wrap { margin-top: 6px; display: flex; } |
| .instr-wrap .voice-in { flex: 1; } |
| |
| |
| .clone-toggle-row { margin-bottom: 6px; } |
| .clone-hint { |
| font-size: 11px; color: var(--dim); |
| margin-bottom: 5px; line-height: 1.4; |
| } |
| |
| |
| .play-btn { |
| width: 32px; height: 32px; |
| display: flex; align-items: center; justify-content: center; |
| background: var(--accent); color: #fff; border: none; |
| border-radius: var(--radius-sm); cursor: pointer; flex-shrink: 0; |
| margin-left: auto; |
| transition: all 0.15s; |
| } |
| .play-btn:hover:not(:disabled) { background: var(--accent2); } |
| .play-btn:disabled { opacity: 0.35; cursor: not-allowed; } |
| .play-btn svg { width: 14px; height: 14px; margin-left: 1px; } |
| |
| |
| .msg-bar { |
| display: none; padding: 8px 12px; |
| border-radius: var(--radius-sm); |
| font-size: 12px; flex-shrink: 0; |
| align-items: center; gap: 8px; |
| } |
| .msg-bar.show { display: flex; } |
| .msg-bar.err { |
| background: rgba(239,68,68,0.08); border: 1px solid rgba(239,68,68,0.2); |
| color: #fca5a5; |
| } |
| :root[data-theme="light"] .msg-bar.err { color: #dc2626; } |
| .msg-bar.warn { |
| background: rgba(234,179,8,0.08); border: 1px solid rgba(234,179,8,0.2); |
| color: #fcd34d; |
| } |
| :root[data-theme="light"] .msg-bar.warn { color: #a16207; } |
| .msg-bar .sw-btn { |
| margin-left: auto; padding: 3px 10px; |
| background: rgba(234,179,8,0.15); border: none; border-radius: 4px; |
| color: inherit; font-size: 11px; font-weight: 600; |
| cursor: pointer; white-space: nowrap; |
| } |
| .msg-bar .sw-btn:hover { background: rgba(234,179,8,0.3); } |
| |
| |
| .queue-bar { |
| display: none; padding: 6px 12px; |
| border-radius: var(--radius-sm); font-size: 13px; color: var(--text2); |
| background: rgba(99,102,241,0.08); border: 1px solid rgba(99,102,241,0.2); |
| } |
| .queue-bar.show { display: flex; align-items: center; gap: 8px; } |
| |
| |
| .pbar-wrap { |
| height: 2px; background: var(--border); |
| border-radius: 1px; overflow: hidden; |
| flex-shrink: 0; display: none; |
| } |
| .pbar-wrap.show { display: block; } |
| .pbar { |
| height: 100%; background: var(--accent); |
| border-radius: 1px; transition: width 0.4s; width: 0%; |
| } |
| .pbar.spin { |
| width: 25% !important; |
| animation: sweep 1.4s ease-in-out infinite; |
| } |
| |
| |
| .results { |
| flex-shrink: 0; display: none; |
| flex-direction: column; gap: 6px; |
| } |
| .results.show { display: flex; } |
| .player-row { display: flex; align-items: center; gap: 6px; } |
| .player-row audio { flex: 1; height: 32px; border-radius: 4px; } |
| :root:not([data-theme="light"]) .player-row audio { filter: invert(0.88) hue-rotate(180deg); } |
| .dl-btn { |
| width: 28px; height: 28px; |
| display: flex; align-items: center; justify-content: center; |
| background: transparent; border: 1px solid var(--border); |
| border-radius: var(--radius-sm); |
| color: var(--dim); cursor: pointer; font-size: 13px; |
| transition: all 0.15s; flex-shrink: 0; |
| } |
| .dl-btn:hover { border-color: var(--accent); color: var(--accent); } |
| |
| .metrics { |
| display: flex; align-items: center; gap: 10px; flex-wrap: wrap; |
| padding: 5px 10px; |
| background: var(--surface); border: 1px solid var(--border); |
| border-radius: var(--radius-sm); |
| font-size: 11px; |
| } |
| .met { display: flex; align-items: baseline; gap: 3px; } |
| .met-k { |
| color: var(--dim); text-transform: uppercase; |
| letter-spacing: 0.5px; font-size: 10px; |
| } |
| .met-v { |
| font-family: 'SF Mono', 'Fira Code', monospace; |
| font-weight: 600; font-size: 12px; |
| } |
| .wave { |
| display: flex; align-items: center; gap: 2px; height: 12px; |
| margin-left: auto; |
| } |
| .wave.off { display: none; } |
| .wave i { |
| width: 2px; border-radius: 1px; background: var(--accent); |
| animation: wavebar 0.7s ease-in-out infinite; |
| display: block; |
| } |
| .wave i:nth-child(1) { height: 3px; animation-delay: 0s; } |
| .wave i:nth-child(2) { height: 7px; animation-delay: 0.1s; } |
| .wave i:nth-child(3) { height: 11px; animation-delay: 0.2s; } |
| .wave i:nth-child(4) { height: 7px; animation-delay: 0.3s; } |
| .wave i:nth-child(5) { height: 3px; animation-delay: 0.4s; } |
| |
| |
| .sov { |
| position: fixed; inset: 0; |
| background: rgba(0,0,0,0.5); |
| z-index: 100; |
| display: flex; align-items: flex-end; justify-content: center; |
| opacity: 0; pointer-events: none; |
| transition: opacity 0.2s; |
| } |
| .sov.open { opacity: 1; pointer-events: auto; } |
| .spanel { |
| background: var(--bg); |
| border: 1px solid var(--border); |
| border-radius: 16px 16px 0 0; |
| width: 100%; max-width: 680px; |
| max-height: 75dvh; |
| overflow-y: auto; |
| padding: 16px 20px 24px; |
| transform: translateY(100%); |
| transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1); |
| } |
| .sov.open .spanel { transform: translateY(0); } |
| .s-handle { |
| width: 32px; height: 4px; |
| background: var(--border2); border-radius: 2px; |
| margin: 0 auto 14px; |
| } |
| .s-head { |
| display: flex; align-items: center; |
| margin-bottom: 16px; |
| } |
| .s-head span { font-size: 14px; font-weight: 600; } |
| .s-head .spacer { flex: 1; } |
| .s-section { |
| font-size: 10px; font-weight: 600; letter-spacing: 0.8px; |
| text-transform: uppercase; color: var(--dim); |
| margin: 16px 0 8px; padding-top: 12px; |
| border-top: 1px solid var(--border); |
| } |
| .s-section:first-of-type { margin-top: 0; padding-top: 0; border-top: none; } |
| .s-row { |
| display: flex; align-items: center; gap: 8px; |
| margin-bottom: 10px; |
| } |
| .s-row label { |
| font-size: 12px; color: var(--text2); |
| min-width: 90px; flex-shrink: 0; |
| } |
| .s-row select, .s-row input[type="text"], .s-row textarea { |
| flex: 1; padding: 7px 10px; |
| background: var(--surface); border: 1px solid var(--border); |
| border-radius: var(--radius-sm); |
| color: var(--text); font-size: 13px; font-family: inherit; |
| outline: none; |
| } |
| .s-row select { |
| appearance: none; padding-right: 28px; |
| background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5'%3E%3Cpath fill='%2371717a' d='M4 5 0 0h8z'/%3E%3C/svg%3E"); |
| background-repeat: no-repeat; background-position: right 10px center; |
| cursor: pointer; |
| } |
| .s-row select option { background: var(--surface); } |
| |
| select option, select optgroup { |
| background-color: #1e1e2e; |
| color: #e2e8f0; |
| } |
| .s-row textarea { resize: vertical; min-height: 40px; max-height: 80px; } |
| |
| |
| .s-hint { |
| font-size: 11px; color: var(--dimmer); |
| margin: -6px 0 8px 98px; |
| } |
| |
| |
| .toggle { |
| display: flex; background: var(--surface); |
| border: 1px solid var(--border); border-radius: var(--radius-sm); |
| padding: 2px; flex: 1; |
| } |
| .tog { |
| flex: 1; padding: 4px 8px; font-size: 11px; font-weight: 500; |
| text-align: center; cursor: pointer; border-radius: 4px; |
| color: var(--dim); transition: all 0.15s; user-select: none; |
| } |
| .tog.on { |
| background: var(--surface2); color: var(--text); |
| box-shadow: var(--shadow); |
| } |
| |
| |
| .s-slider { |
| flex: 1; display: flex; align-items: center; gap: 8px; |
| } |
| .s-slider input[type="range"] { |
| -webkit-appearance: none; flex: 1; |
| height: 3px; background: var(--border2); border-radius: 2px; |
| outline: none; cursor: pointer; |
| } |
| .s-slider input[type="range"]::-webkit-slider-thumb { |
| -webkit-appearance: none; width: 13px; height: 13px; |
| border-radius: 50%; background: var(--accent); cursor: pointer; |
| } |
| .s-slider .sv { |
| font-size: 11px; color: var(--dim); |
| font-family: 'SF Mono', 'Fira Code', monospace; |
| min-width: 30px; text-align: right; |
| } |
| .s-btn { |
| padding: 6px 14px; font-size: 12px; font-weight: 600; |
| background: var(--accent); color: #fff; border: none; |
| border-radius: var(--radius-sm); cursor: pointer; |
| font-family: inherit; transition: background 0.15s; |
| } |
| .s-btn:hover { background: var(--accent2); } |
| .s-btn:disabled { opacity: 0.4; cursor: not-allowed; } |
| |
| |
| @media (min-width: 640px) { |
| .sov { align-items: center; } |
| .spanel { border-radius: 16px; max-height: 70vh; } |
| .s-grid3 { |
| display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; |
| } |
| .s-grid3 .s-row { flex-direction: column; align-items: stretch; margin-bottom: 0; } |
| .s-grid3 .s-row label { min-width: unset; margin-bottom: 4px; } |
| } |
| @media (max-height: 520px) { |
| .app { padding: 6px 12px; gap: 6px; } |
| .hdr h1 { font-size: 13px; } |
| .mode-row .label { font-size: 10px; margin-bottom: 2px; } |
| .play-btn { width: 28px; height: 28px; } |
| .play-btn svg { width: 12px; height: 12px; } |
| .player-row audio { height: 28px; } |
| } |
| |
| |
| @keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.2} } |
| @keyframes sweep { 0%{transform:translateX(-200%)} 100%{transform:translateX(500%)} } |
| @keyframes wavebar { 0%,100%{transform:scaleY(0.3)} 50%{transform:scaleY(1)} } |
| |
| |
| .tab-bar { |
| display: flex; gap: 2px; flex-shrink: 0; |
| border-bottom: 1px solid var(--border); padding-bottom: 4px; |
| } |
| .tab { |
| padding: 4px 14px; font-size: 12px; font-weight: 500; |
| background: transparent; border: none; border-radius: var(--radius-sm); |
| color: var(--dim); cursor: pointer; font-family: inherit; transition: all 0.15s; |
| } |
| .tab:hover { color: var(--text); background: var(--surface); } |
| .tab.active { color: var(--accent); background: var(--accent-bg); } |
| |
| |
| .est-bar { |
| font-size: 12px; color: var(--text2); padding: 3px 6px; min-height: 20px; |
| font-family: 'SF Mono', 'Fira Code', monospace; |
| background: var(--surface2); border-radius: 0 0 var(--radius) var(--radius); |
| border: 1px solid var(--border); border-top: none; |
| } |
| |
| |
| .book-panel { display: flex; flex-direction: column; gap: 8px; } |
| .book-textarea { |
| width: 100%; height: 120px; |
| background: var(--surface); border: 1px solid var(--border); |
| border-radius: var(--radius); color: var(--text); font-size: 14px; |
| font-family: inherit; padding: 10px 12px; resize: none; outline: none; |
| transition: border-color 0.15s; |
| } |
| .book-textarea:focus { border-color: var(--accent-ring); } |
| .book-textarea::placeholder { color: var(--dimmer); } |
| .book-row { display: flex; gap: 6px; align-items: center; } |
| .upload-btn { |
| padding: 6px 12px; font-size: 12px; font-weight: 500; |
| background: var(--surface); border: 1px solid var(--border); |
| border-radius: var(--radius-sm); color: var(--text2); cursor: pointer; |
| transition: all 0.15s; flex-shrink: 0; position: relative; overflow: hidden; |
| font-family: inherit; |
| } |
| .upload-btn:hover { border-color: var(--accent); color: var(--accent); } |
| .upload-btn input[type="file"] { position: absolute; inset: 0; opacity: 0; cursor: pointer; } |
| .parse-btn { |
| padding: 6px 14px; font-size: 12px; font-weight: 600; |
| background: var(--accent); color: #fff; border: none; |
| border-radius: var(--radius-sm); cursor: pointer; font-family: inherit; |
| transition: background 0.15s; flex-shrink: 0; |
| } |
| .parse-btn:hover:not(:disabled) { background: var(--accent2); } |
| .parse-btn:disabled { opacity: 0.4; cursor: not-allowed; } |
| .book-hint { font-size: 11px; color: var(--dimmer); margin-left: auto; } |
| .chapter-list { |
| display: flex; flex-direction: column; gap: 3px; |
| max-height: 260px; overflow-y: auto; |
| } |
| .chapter-item { |
| display: flex; align-items: center; gap: 8px; padding: 6px 10px; |
| background: var(--surface); border: 1px solid var(--border); |
| border-radius: var(--radius-sm); font-size: 12px; |
| } |
| .chapter-item input[type="checkbox"] { flex-shrink: 0; accent-color: var(--accent); } |
| .chapter-item .ch-title { |
| flex: 1; font-weight: 500; color: var(--text); |
| overflow: hidden; text-overflow: ellipsis; white-space: nowrap; |
| } |
| .chapter-item .ch-chars { font-size: 10px; color: var(--dimmer); flex-shrink: 0; font-family: monospace; } |
| .chapter-item .ch-status { font-size: 10px; color: var(--dim); flex-shrink: 0; min-width: 50px; text-align: right; font-family: monospace; } |
| .chapter-item.ch-done { border-color: rgba(34,197,94,0.3); } |
| .chapter-item.ch-done .ch-status { color: var(--green); } |
| .chapter-item.ch-active { border-color: var(--accent); box-shadow: 0 0 6px rgba(139,92,246,0.3); } |
| .chapter-item.ch-error { border-color: rgba(239,68,68,0.3); } |
| .chapter-item.ch-error .ch-status { color: var(--red); } |
| .ch-dl { |
| padding: 1px 7px; font-size: 11px; background: var(--surface2); |
| border: 1px solid var(--border2); border-radius: 3px; cursor: pointer; |
| color: var(--text2); flex-shrink: 0; |
| } |
| .ch-dl:hover { border-color: var(--accent); color: var(--accent); } |
| .book-gen-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; } |
| .book-stats { font-size: 11px; color: var(--dim); font-family: monospace; } |
| .book-voice-note { font-size: 11px; color: var(--dimmer); display:none; } |
| |
| |
| .reader-overlay { |
| position: fixed; inset: 0; z-index: 200; |
| background: var(--bg); display: flex; flex-direction: column; |
| } |
| .reader-topbar { |
| display: flex; align-items: center; gap: 8px; |
| padding: 8px 14px; border-bottom: 1px solid var(--border); |
| background: var(--surface); flex-shrink: 0; flex-wrap: wrap; |
| } |
| .reader-title { |
| font-size: 13px; font-weight: 600; flex: 1; |
| white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 80px; |
| } |
| .reader-body { display: flex; flex: 1; overflow: hidden; } |
| .reader-text-col { |
| flex: 1; overflow-y: auto; padding: 28px 48px; |
| max-width: 760px; margin: 0 auto; |
| } |
| .reader-sidebar { |
| width: 210px; flex-shrink: 0; border-left: 1px solid var(--border); |
| overflow-y: auto; padding: 12px 10px; |
| display: flex; flex-direction: column; gap: 6px; |
| } |
| .reader-para { |
| margin: 0 0 0.9em; line-height: 1.8; font-size: 15px; color: var(--text); |
| padding: 5px 10px; border-radius: 5px; cursor: pointer; |
| transition: background 0.1s; position: relative; border-left: 3px solid transparent; border-right: 3px solid transparent; |
| } |
| .reader-para:hover { background: var(--surface2); } |
| .reader-para.rp-selected { background: rgba(139,92,246,0.1); } |
| .reader-para.rp-start { border-left-color: var(--green); } |
| .reader-para.rp-end { border-right-color: var(--red); } |
| .reader-para.rp-start::before { |
| content: '▶'; position: absolute; left: -16px; color: var(--green); |
| font-size: 10px; top: 7px; |
| } |
| .reader-para.rp-end::after { |
| content: '⏹'; position: absolute; right: -16px; color: var(--red); |
| font-size: 10px; top: 7px; |
| } |
| .reader-ch-sep { |
| font-size: 10px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase; |
| color: var(--accent); padding: 18px 10px 4px; border-top: 1px solid var(--border); margin-top: 10px; |
| } |
| .reader-ch-sep:first-child { padding-top: 0; border-top: none; margin-top: 0; } |
| .reader-nav { |
| display: flex; align-items: center; gap: 8px; justify-content: center; |
| padding: 8px 14px; border-top: 1px solid var(--border); |
| flex-shrink: 0; background: var(--surface); flex-wrap: wrap; |
| } |
| .reader-page-info { font-size: 12px; color: var(--dim); font-family: monospace; min-width: 70px; text-align: center; } |
| .reader-mode-btn { |
| padding: 4px 10px; border-radius: 6px; border: 1px solid var(--border); |
| font-size: 11px; cursor: pointer; background: var(--surface2); color: var(--text2); |
| transition: all 0.12s; font-family: inherit; |
| } |
| .reader-mode-btn.mode-start.ractive { background: rgba(34,197,94,0.2); border-color: var(--green); color: var(--green); font-weight: 700; } |
| .reader-mode-btn.mode-end.ractive { background: rgba(239,68,68,0.2); border-color: var(--red); color: var(--red); font-weight: 700; } |
| .reader-apply-btn { |
| padding: 5px 14px; background: var(--accent); color: #fff; |
| border: none; border-radius: 6px; font-size: 12px; font-weight: 600; |
| cursor: pointer; font-family: inherit; |
| } |
| .reader-apply-btn:hover { background: var(--accent2); } |
| .reader-side-lbl { |
| font-size: 10px; font-weight: 700; letter-spacing: .6px; |
| text-transform: uppercase; color: var(--dimmer); margin-top: 6px; |
| } |
| .reader-ch-item { |
| padding: 3px 6px; border-radius: 4px; cursor: pointer; font-size: 11px; |
| transition: background 0.1s; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; |
| } |
| .reader-ch-item.rci-in { background: var(--accent-bg); color: var(--accent); } |
| .reader-ch-item.rci-out { color: var(--dimmer); } |
| .reader-ch-item:hover { background: var(--surface2); } |
| |
| .bvs-wrap { border: 1px solid var(--border); border-radius: 8px; padding: 10px; background: var(--bg2,var(--surface)); margin-bottom: 2px; } |
| .bvs-label { font-size: 10px; color: var(--dim); font-weight: 700; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.6px; } |
| .bvs-tabs { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 8px; } |
| .bvs-tab { padding: 3px 8px; font-size: 11px; border: 1px solid var(--border); border-radius: 4px; background: var(--surface); color: var(--text2); cursor: pointer; transition: all 0.15s; font-family: inherit; } |
| .bvs-tab:hover { border-color: var(--accent); color: var(--accent); } |
| .bvs-tab.active { background: var(--accent); color: #fff; border-color: var(--accent); } |
| .bvs-panel { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; min-height: 28px; } |
| .bvs-select { background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: 6px; padding: 4px 8px; font-size: 12px; flex: 1; min-width: 120px; } |
| .bvs-file { font-size: 11px; color: var(--dim); flex: 2; min-width: 140px; } |
| .book-progress-row { display: flex; align-items: center; gap: 8px; flex-shrink: 0; margin-top: 2px; } |
| .book-pct { font-weight: 700; font-family: monospace; min-width: 36px; color: var(--accent); flex-shrink: 0; } |
| .book-eta { color: var(--dim); margin-left: auto; font-family: monospace; font-size: 11px; } |
| .book-speed-info { color: var(--dim); font-family: monospace; font-size: 11px; flex-shrink: 0; } |
| .chapter-item .ch-retry { |
| font-size: 10px; color: var(--amber); cursor: pointer; flex-shrink: 0; |
| padding: 1px 6px; border: 1px solid rgba(234,179,8,0.3); border-radius: 3px; |
| background: none; font-family: inherit; |
| } |
| .chapter-item .ch-retry:hover { background: rgba(234,179,8,0.1); } |
| .auto-dl-label { |
| display: flex; align-items: center; gap: 4px; |
| font-size: 11px; color: var(--dim); cursor: pointer; flex-shrink: 0; |
| } |
| .auto-dl-label input { accent-color: var(--accent); } |
| |
| |
| .inner-tab-bar { display: none; } |
| .inner-tab { display: none; } |
| |
| |
| #innerPanelFR .mode-row { display: none; } |
| #innerPanelFR .mode-row.eng-active { display: block; } |
| |
| #innerPanelFR .mode-row .play-btn { display: none; } |
| |
| |
| .studio-gen-row { display: flex; gap: 8px; align-items: center; flex-shrink: 0; } |
| |
| |
| .engine-section { |
| display: flex; flex-direction: column; gap: 10px; |
| background: var(--surface); border: 1px solid var(--border); |
| border-radius: var(--radius); padding: 12px 14px; |
| } |
| .engine-group { display: flex; flex-direction: column; gap: 6px; } |
| .engine-group-label { |
| font-size: 10px; font-weight: 600; letter-spacing: 0.7px; |
| text-transform: uppercase; color: var(--dimmer); |
| } |
| .engine-pills-row { display: flex; gap: 5px; flex-wrap: wrap; } |
| .epill { |
| display: flex; align-items: center; gap: 5px; |
| padding: 6px 11px; border-radius: 20px; |
| border: 1px solid var(--border2); |
| background: var(--surface2); cursor: pointer; |
| font-size: 12px; font-weight: 500; color: var(--text2); |
| font-family: inherit; transition: all 0.15s; |
| white-space: nowrap; |
| } |
| .epill:hover { border-color: var(--border); color: var(--text); background: var(--surface); } |
| .epill.active { |
| border-color: var(--ep-color, var(--accent)); |
| color: var(--ep-color, var(--accent)); |
| background: color-mix(in srgb, var(--ep-color, var(--accent)) 10%, transparent); |
| box-shadow: 0 0 0 1px var(--ep-color, var(--accent)); |
| } |
| .ep-dot { |
| width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; |
| } |
| .ep-tag { |
| font-size: 9px; font-weight: 700; letter-spacing: 0.5px; |
| padding: 1px 5px; border-radius: 10px; color: #fff; |
| margin-left: 2px; |
| } |
| |
| .tlpill { |
| display: flex; align-items: center; gap: 4px; |
| padding: 3px 9px; border-radius: 20px; |
| border: 1px solid var(--border2); |
| background: var(--surface2); cursor: pointer; |
| font-size: 11px; font-weight: 500; color: var(--text2); |
| font-family: inherit; transition: all 0.13s; white-space: nowrap; |
| } |
| .tlpill:hover { border-color: var(--border); color: var(--text); } |
| .tlpill.active { |
| border-color: var(--accent); color: var(--accent); |
| background: color-mix(in srgb, var(--accent) 12%, transparent); |
| } |
| .engine-divider { |
| height: 1px; background: var(--border); margin: 2px 0; |
| } |
| |
| .engine-panel { |
| background: var(--surface); border: 1px solid var(--border); |
| border-radius: var(--radius); padding: 10px 12px; |
| } |
| |
| .gen-section { flex-shrink: 0; } |
| .gen-btn { |
| width: 100%; padding: 13px 20px; |
| background: var(--accent); color: #fff; |
| border: none; border-radius: var(--radius); |
| font-size: 15px; font-weight: 700; font-family: inherit; |
| cursor: pointer; transition: all 0.18s; |
| display: flex; align-items: center; justify-content: center; gap: 9px; |
| letter-spacing: 0.02em; |
| } |
| .gen-btn:hover:not(:disabled) { background: var(--accent2); transform: translateY(-1px); box-shadow: 0 4px 16px rgba(139,92,246,0.35); } |
| .gen-btn:active { transform: translateY(0); } |
| .gen-btn:disabled { opacity: 0.38; cursor: not-allowed; transform: none; box-shadow: none; } |
| .gen-btn svg { width: 16px; height: 16px; } |
| |
| |
| .drop-zone { |
| border: 2px dashed var(--border2); border-radius: var(--radius); |
| padding: 22px 16px; text-align: center; cursor: pointer; |
| transition: all 0.18s; background: var(--surface); |
| display: flex; flex-direction: column; align-items: center; gap: 8px; |
| } |
| .drop-zone:hover, .drop-zone.drag-over { |
| border-color: var(--accent); background: var(--accent-bg); |
| } |
| .drop-zone svg { width: 28px; height: 28px; color: var(--dim); } |
| .drop-zone.drag-over svg { color: var(--accent); } |
| .dz-title { font-size: 13px; font-weight: 600; color: var(--text2); } |
| .dz-sub { font-size: 11px; color: var(--dimmer); } |
| .dz-btn { |
| margin-top: 4px; padding: 6px 16px; font-size: 12px; font-weight: 500; |
| background: var(--surface2); border: 1px solid var(--border2); |
| border-radius: var(--radius-sm); color: var(--text2); cursor: pointer; |
| font-family: inherit; transition: all 0.15s; position: relative; overflow: hidden; |
| } |
| .dz-btn:hover { border-color: var(--accent); color: var(--accent); } |
| .dz-btn input[type="file"] { position: absolute; inset: 0; opacity: 0; cursor: pointer; } |
| |
| |
| .book-section { |
| border: 1px solid var(--border); border-radius: var(--radius); |
| overflow: hidden; |
| } |
| .book-section-hdr { |
| padding: 8px 14px; background: var(--surface); |
| border-bottom: 1px solid var(--border); |
| font-size: 10px; font-weight: 700; letter-spacing: 0.8px; |
| text-transform: uppercase; color: var(--dim); |
| display: flex; align-items: center; gap: 8px; |
| } |
| .book-section-body { padding: 12px; display: flex; flex-direction: column; gap: 8px; } |
| |
| |
| .tab-bar { |
| display: flex; gap: 2px; flex-shrink: 0; |
| border-bottom: 1px solid var(--border); padding-bottom: 0; |
| } |
| .tab { |
| padding: 8px 16px; font-size: 13px; font-weight: 500; |
| background: transparent; border: none; border-bottom: 2px solid transparent; |
| color: var(--dim); cursor: pointer; font-family: inherit; |
| transition: all 0.15s; display: flex; align-items: center; gap: 6px; |
| margin-bottom: -1px; |
| } |
| .tab:hover { color: var(--text); } |
| .tab.active { color: var(--accent); border-bottom-color: var(--accent); } |
| .tab svg { width: 14px; height: 14px; } |
| |
| |
| .app { max-width: 780px; } |
| |
| |
| .qwen-loader { |
| display: flex; gap: 6px; align-items: center; flex-wrap: wrap; |
| padding: 8px 10px; background: var(--bg); |
| border: 1px solid var(--border); border-radius: var(--radius-sm); |
| margin-bottom: 6px; |
| } |
| .qwen-loader-group { display: flex; align-items: center; gap: 5px; flex: 1; min-width: 120px; } |
| .qwen-loader-label { font-size: 11px; color: var(--dim); white-space: nowrap; } |
| |
| |
| .results-hdr { |
| font-size: 10px; font-weight: 700; letter-spacing: 0.7px; |
| text-transform: uppercase; color: var(--dimmer); |
| flex-shrink: 0; |
| } |
| </style> |
| </head> |
| <body> |
| <div class="app"> |
|
|
| |
| <header class="hdr"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="width:20px;height:20px;color:var(--accent);flex-shrink:0"><path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"/><path d="M19 10v2a7 7 0 01-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg> |
| <h1 style="font-size:16px;letter-spacing:-0.5px">Boovore</h1> |
| <span class="badge" style="font-size:9px">MULTI-ENGINE</span> |
| <span class="spacer"></span> |
| <button class="mpill" onclick="openSettings()" title="Modèle Qwen3"> |
| <span class="mdot" id="mdot"></span> |
| <span id="mtext">not loaded</span> |
| </button> |
| <button class="ibtn" onclick="openSettings()" title="Paramètres Qwen3"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg> |
| </button> |
| <button class="ibtn" id="langBtn" onclick="toggleLang()" title="Switch language / Changer la langue" style="font-size:11px;font-weight:700;letter-spacing:0.5px;padding:0 8px;min-width:36px">🌐 EN</button> |
| <button class="ibtn" id="themeBtn" onclick="toggleTheme()" title="Thème"> |
| <svg id="themeIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg> |
| </button> |
| </header> |
|
|
| |
| <div class="tab-bar"> |
| <button class="tab active" data-tab="tts" onclick="switchTab('tts')"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"/><path d="M19 10v2a7 7 0 01-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/></svg> |
| Studio |
| </button> |
| <button class="tab" data-tab="book" onclick="switchTab('book')"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg> |
| <span id="tabBookLabel">Livre Audio</span> |
| </button> |
| </div> |
|
|
| |
| <div id="panelTTS"> |
| <div class="main"> |
| <div class="text-wrap"> |
| <textarea id="textIn" placeholder="Entrez votre texte ici..." oninput="updateEstimation()">La découverte d'une technologie quantique révolutionnaire déclenche une guerre d'IA entre les puissantes dynasties de la Terre et les colonies du système solaire.</textarea> |
| <div class="est-bar" id="estBar"></div> |
| </div> |
|
|
| |
| <div class="engine-section" id="engineSection"> |
| <div class="engine-group"> |
| <div class="engine-group-label" id="grpLabel1">Instantané · prêt à l'emploi</div> |
| <div class="engine-pills-row"> |
| <button class="epill active" id="pill-kokoro" style="--ep-color:#22c55e" onclick="selectStudioEngine('kokoro')"> |
| <span class="ep-dot" style="background:#22c55e"></span> |
| Kokoro FR |
| <span class="ep-tag" style="background:#22c55e">FAST</span> |
| </button> |
| <button class="epill" id="pill-chatterbox" style="--ep-color:#059669" onclick="selectStudioEngine('chatterbox')"> |
| <span class="ep-dot" style="background:#059669"></span> |
| Chatterbox |
| <span class="ep-tag" style="background:#059669">BEST</span> |
| </button> |
| <button class="epill" id="pill-f5" style="--ep-color:#7c3aed" onclick="selectStudioEngine('f5')"> |
| <span class="ep-dot" style="background:#7c3aed"></span> |
| F5-TTS |
| <span class="ep-tag" style="background:#7c3aed">CLONE</span> |
| </button> |
| <button class="epill" id="pill-fish" style="--ep-color:#0ea5e9" onclick="selectStudioEngine('fish')"> |
| <span class="ep-dot" style="background:#0ea5e9"></span> |
| Fish-Speech |
| <span class="ep-tag" style="background:#0ea5e9">NEW</span> |
| </button> |
| <button class="epill" id="pill-voxtral" style="--ep-color:#f59e0b" onclick="selectStudioEngine('voxtral')"> |
| <span class="ep-dot" style="background:#f59e0b"></span> |
| Voxtral |
| <span class="ep-tag" style="background:#f59e0b;color:#000">FR★</span> |
| </button> |
| </div> |
| </div> |
| <div class="engine-divider"></div> |
| <div class="engine-group"> |
| <div class="engine-group-label" id="grpLabel2">Qwen3-TTS · nécessite chargement</div> |
| <div class="engine-pills-row"> |
| <button class="epill" id="pill-clone" style="--ep-color:#8b5cf6" onclick="selectStudioEngine('clone')"> |
| <span class="ep-dot" style="background:#8b5cf6"></span> |
| Clone |
| </button> |
| <button class="epill" id="pill-custom" style="--ep-color:#8b5cf6" onclick="selectStudioEngine('custom')"> |
| <span class="ep-dot" style="background:#8b5cf6"></span> |
| Custom |
| </button> |
| <button class="epill" id="pill-design" style="--ep-color:#8b5cf6" onclick="selectStudioEngine('design')"> |
| <span class="ep-dot" style="background:#8b5cf6"></span> |
| Voice Design |
| </button> |
| </div> |
| </div> |
| </div> |
| |
| <div class="inner-tab-bar"> |
| <button class="inner-tab active" data-itab="fr" onclick="switchInnerTab('fr')">🇫🇷 Voix FR</button> |
| <button class="inner-tab" data-itab="qwen" onclick="switchInnerTab('qwen')">⚡ Qwen3-TTS</button> |
| </div> |
|
|
| |
| <div id="ttsLangBar" style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:6px 0 2px 0"> |
| <span style="font-size:10px;color:var(--dim);letter-spacing:.5px;flex-shrink:0">🌍 LANGUE TTS :</span> |
| <button class="tlpill active" id="tlp-fr" onclick="setTTSLang('fr')">🇫🇷 Français</button> |
| <button class="tlpill" id="tlp-en" onclick="setTTSLang('en')">🇬🇧 English</button> |
| <button class="tlpill" id="tlp-de" onclick="setTTSLang('de')">🇩🇪 Deutsch</button> |
| <button class="tlpill" id="tlp-es" onclick="setTTSLang('es')">🇪🇸 Español</button> |
| <button class="tlpill" id="tlp-zh" onclick="setTTSLang('zh')">🇨🇳 中文</button> |
| <button class="tlpill" id="tlp-ja" onclick="setTTSLang('ja')">🇯🇵 日本語</button> |
| <button class="tlpill" id="tlp-pt" onclick="setTTSLang('pt')">🇧🇷 Português</button> |
| <span id="ttsLangNote" style="font-size:10px;color:var(--dimmer);font-family:monospace;margin-left:4px"></span> |
| </div> |
|
|
| |
| <div id="innerPanelFR"> |
| |
| <div class="mode-row" id="rowKokoro"> |
| <div class="label"><span id="engLblKokoro">Kokoro FR — voix françaises natives</span><span class="switch-tag" style="background:var(--accent);color:#fff">FAST</span></div> |
| <div class="row"> |
| <select id="kokoroVoice" style="background:var(--surface2);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:4px 8px;font-size:12px;flex:1;max-width:240px"> |
| <optgroup label="🇫🇷 Français"> |
| <option value="ff_siwis">Siwis — FR Femme ★</option> |
| </optgroup> |
| <optgroup label="🇺🇸 English — Female"> |
| <option value="af_heart">Heart ★</option> |
| <option value="af_bella">Bella</option> |
| <option value="af_nicole">Nicole (ASMR)</option> |
| <option value="af_sarah">Sarah</option> |
| <option value="af_sky">Sky</option> |
| </optgroup> |
| <optgroup label="🇺🇸 English — Male"> |
| <option value="am_echo">Echo</option> |
| <option value="am_michael">Michael</option> |
| <option value="am_adam">Adam</option> |
| </optgroup> |
| <optgroup label="🇬🇧 British — Female"> |
| <option value="bf_emma">Emma</option> |
| <option value="bf_isabella">Isabella</option> |
| </optgroup> |
| <optgroup label="🇬🇧 British — Male"> |
| <option value="bm_george">George</option> |
| <option value="bm_lewis">Lewis</option> |
| </optgroup> |
| </select> |
| <label style="font-size:11px;color:var(--dim);flex-shrink:0">Vitesse</label> |
| <input type="range" id="kokoroSpeed" min="0.5" max="2.0" step="0.05" value="1.0" style="width:64px" oninput="$('kokoroSpeedV').textContent=parseFloat(this.value).toFixed(2)"> |
| <span id="kokoroSpeedV" style="font-size:11px;color:var(--dim);min-width:32px">1.00</span> |
| <button class="play-btn" onclick="generateKokoro()" title="Générer"> |
| <svg viewBox="0 0 24 24" fill="currentColor"><polygon points="6,3 20,12 6,21"/></svg> |
| </button> |
| </div> |
| </div> |
| |
| <div class="mode-row" id="rowChatterbox"> |
| <div class="label"><span id="engLblChatterbox">Chatterbox — clonage zero-shot (ResembleAI)</span><span class="switch-tag" style="background:#059669;color:#fff">BEST</span></div> |
| <div class="row" style="flex-wrap:wrap;gap:6px"> |
| <label style="font-size:11px;color:var(--dim)">🌍</label> |
| <select id="cbLang" style="font-size:11px;background:var(--surface2);color:var(--text);border:1px solid var(--border);border-radius:var(--radius-sm);padding:3px 6px"> |
| <option value="fr">🇫🇷 Français</option> |
| <option value="en" selected>🇬🇧 English</option> |
| <option value="de">🇩🇪 Deutsch</option> |
| <option value="es">🇪🇸 Español</option> |
| <option value="zh">🇨🇳 中文</option> |
| <option value="ja">🇯🇵 日本語</option> |
| <option value="pt">🇧🇷 Português</option> |
| <option value="it">🇮🇹 Italiano</option> |
| <option value="ar">🇸🇦 العربية</option> |
| </select> |
| <label style="font-size:11px;color:var(--dim)">Réf :</label> |
| <input type="file" id="cbRefWav" accept="audio/*" style="font-size:11px;color:var(--dim);flex:2;max-width:160px"> |
| <label style="font-size:11px;color:var(--dim)" title="Intensité émotionnelle de la voix (0=neutre, 1=expressif)">Exag</label> |
| <input type="range" id="cbExag" min="0" max="1" step="0.05" value="0.5" style="width:60px" oninput="$('cbExagVal').textContent=parseFloat(this.value).toFixed(2)"> |
| <span id="cbExagVal" style="font-size:11px;color:var(--dim);min-width:32px">0.50</span> |
| <label style="font-size:11px;color:var(--dim)" title="Guidance — 0=plus libre/créatif, 1=plus fidèle au texte">CFG</label> |
| <input type="range" id="cbCfg" min="0" max="1" step="0.05" value="0.5" style="width:60px" oninput="$('cbCfgVal').textContent=parseFloat(this.value).toFixed(2)"> |
| <span id="cbCfgVal" style="font-size:11px;color:var(--dim);min-width:32px">0.50</span> |
| <label style="font-size:11px;color:var(--dim)" title="Température — contrôle la variabilité de la voix">Temp</label> |
| <input type="range" id="cbTemp" min="0.1" max="2.0" step="0.05" value="0.8" style="width:60px" oninput="$('cbTempVal').textContent=parseFloat(this.value).toFixed(2)"> |
| <span id="cbTempVal" style="font-size:11px;color:var(--dim);min-width:32px">0.80</span> |
| <label style="font-size:11px;color:var(--dim)" title="Graine aléatoire — même valeur = même voix">Seed</label> |
| <input type="number" id="cbSeed" placeholder="auto" style="width:68px;font-size:11px;background:var(--surface2);color:var(--text);border:1px solid var(--border);border-radius:var(--radius-sm);padding:3px 6px;font-family:monospace"> |
| <button class="play-btn" onclick="generateChatterbox()" title="Générer"> |
| <svg viewBox="0 0 24 24" fill="currentColor"><polygon points="6,3 20,12 6,21"/></svg> |
| </button> |
| </div> |
| </div> |
| |
| <div class="mode-row" id="rowF5"> |
| <div class="label"><span id="engLblF5">F5-TTS FR — clonage vocal français</span><span class="switch-tag" style="background:#7c3aed;color:#fff">CLONE</span></div> |
| <div class="row" style="flex-wrap:wrap;gap:6px"> |
| <label style="font-size:11px;color:var(--dim)">🌍</label> |
| <select id="f5Lang" style="font-size:11px;background:var(--surface2);color:var(--text);border:1px solid var(--border);border-radius:var(--radius-sm);padding:3px 6px"> |
| <option value="fr" selected>🇫🇷 Français</option> |
| <option value="en">🇬🇧 English</option> |
| <option value="de">🇩🇪 Deutsch</option> |
| <option value="es">🇪🇸 Español</option> |
| <option value="zh">🇨🇳 中文</option> |
| <option value="ja">🇯🇵 日本語</option> |
| <option value="pt">🇧🇷 Português</option> |
| <option value="it">🇮🇹 Italiano</option> |
| <option value="ar">🇸🇦 العربية</option> |
| </select> |
| <label style="font-size:11px;color:var(--dim);flex-shrink:0">Réf audio :</label> |
| <input type="file" id="f5RefWav" accept="audio/*" style="font-size:11px;color:var(--dim);flex:2;min-width:100px;max-width:160px"> |
| <label style="font-size:11px;color:var(--dim);flex-shrink:0">Texte réf :</label> |
| <input type="text" id="f5RefText" placeholder="Transcription du clip audio..." style="font-size:11px;flex:3;min-width:120px;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);padding:4px 6px"> |
| </div> |
| <div class="row" style="flex-wrap:wrap;gap:8px;margin-top:6px"> |
| <label style="font-size:11px;color:var(--dim)" title="Vitesse de lecture (0.5=lent, 1=normal, 2=rapide)">Vitesse</label> |
| <input type="range" id="f5Speed" min="0.5" max="2.0" step="0.05" value="1.0" style="width:64px" oninput="$('f5SpeedV').textContent=parseFloat(this.value).toFixed(2)"> |
| <span id="f5SpeedV" style="font-size:11px;color:var(--dim);min-width:32px">1.00</span> |
| <label style="font-size:11px;color:var(--dim)" title="Nombre d'étapes de diffusion — plus = meilleure qualité, plus lent (8-64)">Qualité</label> |
| <input type="range" id="f5Nfe" min="8" max="64" step="4" value="32" style="width:64px" oninput="$('f5NfeV').textContent=this.value"> |
| <span id="f5NfeV" style="font-size:11px;color:var(--dim);min-width:28px">32</span> |
| <label style="font-size:11px;color:var(--dim)" title="Durée du fondu entre segments (0=coupure nette, 0.15=doux)">Cross-fade</label> |
| <input type="range" id="f5Cross" min="0.0" max="0.5" step="0.05" value="0.15" style="width:56px" oninput="$('f5CrossV').textContent=parseFloat(this.value).toFixed(2)"> |
| <span id="f5CrossV" style="font-size:11px;color:var(--dim);min-width:32px">0.15</span> |
| <label style="font-size:11px;color:var(--dim)">Seed</label> |
| <input type="number" id="f5Seed" placeholder="auto" style="width:68px;font-size:11px;background:var(--surface2);color:var(--text);border:1px solid var(--border);border-radius:var(--radius-sm);padding:3px 6px;font-family:monospace"> |
| <button class="play-btn" onclick="generateF5()" title="Générer"> |
| <svg viewBox="0 0 24 24" fill="currentColor"><polygon points="6,3 20,12 6,21"/></svg> |
| </button> |
| </div> |
| </div> |
| |
| <div class="mode-row" id="rowFish"> |
| <div class="label"><span id="engLblFish">Fish-Speech 1.5 — clonage vocal multilingue</span><span class="switch-tag" style="background:#0ea5e9;color:#fff">NEW</span></div> |
| <div class="row" style="flex-wrap:wrap;gap:6px"> |
| <label style="font-size:11px;color:var(--dim);flex-shrink:0">Réf audio :</label> |
| <input type="file" id="fishRefWav" accept="audio/*" style="font-size:11px;color:var(--dim);flex:2;min-width:120px;max-width:180px"> |
| <label style="font-size:11px;color:var(--dim);flex-shrink:0">Texte réf :</label> |
| <input type="text" id="fishRefText" placeholder="Transcription du clip..." style="font-size:11px;flex:3;min-width:140px;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);padding:4px 6px"> |
| </div> |
| <div class="row" style="flex-wrap:wrap;gap:8px;margin-top:6px"> |
| <label style="font-size:11px;color:var(--dim);flex-shrink:0">Temp</label> |
| <input type="range" id="fishTemp" min="0.1" max="1.0" step="0.05" value="0.8" style="width:70px" oninput="$('fishTempV').textContent=this.value"> |
| <span id="fishTempV" style="font-size:11px;color:var(--dim);min-width:28px">0.8</span> |
| <label style="font-size:11px;color:var(--dim);flex-shrink:0">Top-P</label> |
| <input type="range" id="fishTopP" min="0.1" max="1.0" step="0.05" value="0.8" style="width:70px" oninput="$('fishTopPV').textContent=this.value"> |
| <span id="fishTopPV" style="font-size:11px;color:var(--dim);min-width:28px">0.8</span> |
| <label style="font-size:11px;color:var(--dim);flex-shrink:0">Rep</label> |
| <input type="range" id="fishRep" min="0.9" max="2.0" step="0.05" value="1.1" style="width:70px" oninput="$('fishRepV').textContent=parseFloat(this.value).toFixed(2)"> |
| <span id="fishRepV" style="font-size:11px;color:var(--dim);min-width:32px">1.10</span> |
| </div> |
| <div class="row" style="flex-wrap:wrap;gap:8px;margin-top:4px"> |
| <label style="font-size:11px;color:var(--dim);flex-shrink:0">Latence</label> |
| <select id="fishLatency" style="font-size:11px;background:var(--surface2);color:var(--text);border:1px solid var(--border);border-radius:var(--radius-sm);padding:3px 6px"> |
| <option value="normal">Normal</option> |
| <option value="balanced">Balanced</option> |
| </select> |
| <label style="font-size:11px;color:var(--dim);flex-shrink:0">Chunk</label> |
| <input type="range" id="fishChunk" min="100" max="1000" step="50" value="200" style="width:70px" oninput="$('fishChunkV').textContent=this.value"> |
| <span id="fishChunkV" style="font-size:11px;color:var(--dim);min-width:28px">200</span> |
| <label style="font-size:11px;color:var(--dim);flex-shrink:0">Max tokens</label> |
| <input type="number" id="fishMaxTokens" value="1024" min="64" max="4096" style="width:64px;font-size:11px;background:var(--surface2);color:var(--text);border:1px solid var(--border);border-radius:var(--radius-sm);padding:3px 6px;font-family:monospace"> |
| <label style="font-size:11px;color:var(--dim);flex-shrink:0;display:flex;align-items:center;gap:4px;cursor:pointer"> |
| <input type="checkbox" id="fishNormalize" checked style="accent-color:var(--accent)"> Normalize |
| </label> |
| <label style="font-size:11px;color:var(--dim);flex-shrink:0">Seed</label> |
| <input type="number" id="fishSeed" placeholder="auto" style="width:72px;font-size:11px;background:var(--surface2);color:var(--text);border:1px solid var(--border);border-radius:var(--radius-sm);padding:3px 6px;font-family:monospace"> |
| <button class="play-btn" onclick="generateFish()" title="Générer" style="display:none"> |
| <svg viewBox="0 0 24 24" fill="currentColor"><polygon points="6,3 20,12 6,21"/></svg> |
| </button> |
| </div> |
| </div> |
| |
| <div class="mode-row" id="rowVoxtral"> |
| <div class="label"><span id="engLblVoxtral">Voxtral 4B — TTS français Mistral AI (vLLM-Omni)</span><span class="switch-tag" style="background:#f59e0b;color:#000">FR★</span></div> |
| <div class="row" style="flex-wrap:wrap;gap:6px"> |
| <label style="font-size:11px;color:var(--dim);flex-shrink:0">Réf audio :</label> |
| <input type="file" id="voxtralRefWav" accept="audio/*" style="font-size:11px;color:var(--dim);flex:2;min-width:120px;max-width:180px"> |
| <label style="font-size:11px;color:var(--dim);flex-shrink:0">Texte réf :</label> |
| <input type="text" id="voxtralRefText" placeholder="Transcription du clip..." style="font-size:11px;flex:3;min-width:140px;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);padding:4px 6px"> |
| </div> |
| <div style="font-size:11px;color:var(--dim);margin-top:4px"> |
| Nécessite un serveur vLLM-Omni local (<code style="font-size:10px;color:var(--accent)">python3 voxtral_server.py</code>). |
| Si aucun clip de référence, utilise <code style="font-size:10px;color:var(--accent)">narrator_reference.wav</code>. |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div id="innerPanelQwen" style="display:none"> |
|
|
| |
| <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:2px"> |
| <div style="display:flex;align-items:center;gap:6px;flex:1;min-width:140px"> |
| <label style="font-size:11px;color:var(--dim);white-space:nowrap">🌐 Langue</label> |
| <select id="langSelVis" onchange="$('langSel').value=this.value" |
| style="flex:1;background:var(--surface2);color:var(--text);border:1px solid var(--border2);border-radius:6px;padding:4px 8px;font-size:12px"> |
| <option value="French">French 🇫🇷</option> |
| <option value="English">English 🇬🇧</option> |
| <option value="Chinese">Chinese 🇨🇳</option> |
| <option value="Japanese">Japanese 🇯🇵</option> |
| <option value="Korean">Korean 🇰🇷</option> |
| <option value="German">German 🇩🇪</option> |
| <option value="Spanish">Spanish 🇪🇸</option> |
| <option value="Portuguese">Portuguese 🇵🇹</option> |
| <option value="Russian">Russian 🇷🇺</option> |
| <option value="Italian">Italian 🇮🇹</option> |
| <option value="Portuguese">Portuguese 🇵🇹</option> |
| <option value="Dutch">Dutch 🇳🇱</option> |
| <option value="Arabic">Arabic 🇸🇦</option> |
| <option value="Hindi">Hindi 🇮🇳</option> |
| </select> |
| </div> |
| <div style="display:flex;align-items:center;gap:6px"> |
| <label style="font-size:11px;color:var(--dim);white-space:nowrap">⚙️ Modèle</label> |
| <select id="modelSelVis" onchange="onQwenModelChange(this.value)" |
| style="background:var(--surface2);color:var(--text);border:1px solid var(--border2);border-radius:6px;padding:4px 8px;font-size:12px"> |
| </select> |
| <button id="loadBtnVis" onclick="loadModel()" style="padding:4px 12px;background:var(--accent);color:#fff;border:none;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer">Load</button> |
| </div> |
| </div> |
|
|
| |
| <div class="mode-row" id="rowClone" onclick="onModeRowClick('voice_clone')"> |
| <div class="label">Clone — match a voice from a reference clip<span class="switch-tag" id="qwenCloneTag">LOAD ▶</span></div> |
| |
| <div class="clone-toggle-row"> |
| <div class="toggle"> |
| <div class="tog" data-x="1" onclick="setXvec(true)">Simple</div> |
| <div class="tog on" data-x="0" onclick="setXvec(false)">Advanced</div> |
| </div> |
| </div> |
| |
| <div class="row"> |
| <label class="ref-chip" id="refChip"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/></svg> |
| <span id="refLabel">Reference audio</span> |
| <input type="file" id="refInput" accept=".wav,.mp3,.flac,.ogg" onchange="pickRef(this)"> |
| </label> |
| <button class="mic-btn" id="micBtn" onclick="toggleRec()" title="Record from microphone"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"/><path d="M19 10v2a7 7 0 01-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg> |
| </button> |
| <span class="rec-time" id="recTime"></span> |
| <span class="rec-status" id="recStatus"></span> |
| <div class="preset-row" id="presetRow"></div> |
| <button class="play-btn" onclick="event.stopPropagation();generate('voice_clone')" title="Generate"> |
| <svg viewBox="0 0 24 24" fill="currentColor"><polygon points="6,3 20,12 6,21"/></svg> |
| </button> |
| </div> |
| |
| <div id="advTranscript"> |
| <div class="clone-hint">Advanced cloning uses the transcript of the reference clip for higher accuracy. Upload or record to auto-transcribe.</div> |
| <div class="instr-wrap"> |
| <textarea id="refTextIn" class="voice-in" rows="2" |
| placeholder="Upload or record audio to auto-transcribe…" |
| oninput="autoGrow(this)"></textarea> |
| </div> |
| </div> |
| |
| <div class="rec-bar-wrap" id="recBarWrap"><div class="rec-bar" id="recBar"></div></div> |
| |
| <audio id="recPreview" class="rec-preview" controls></audio> |
| </div> |
|
|
| |
| <div class="mode-row" id="rowChar" onclick="onModeRowClick('custom')"> |
| <div class="label">Custom — use a built-in speaker<span class="switch-tag" id="qwenCustomTag">LOAD ▶</span></div> |
| <div class="row"> |
| <select class="sm-sel" id="speakerId" style="flex:1"> |
| <option value="">Select speaker...</option> |
| </select> |
| <button class="play-btn" onclick="event.stopPropagation();generate('custom')" title="Generate"> |
| <svg viewBox="0 0 24 24" fill="currentColor"><polygon points="6,3 20,12 6,21"/></svg> |
| </button> |
| </div> |
| <div class="instr-wrap"> |
| <textarea id="customInstr" class="voice-in" rows="1" |
| placeholder="Voice style instructions (optional)…" |
| oninput="autoGrow(this)"></textarea> |
| </div> |
| </div> |
|
|
| |
| <div class="mode-row" id="rowCustom" onclick="onModeRowClick('voice_design')"> |
| <div class="label">Voice Design — describe any voice<span class="switch-tag" id="qwenVdTag">LOAD ▶</span></div> |
| <div class="row" style="gap:6px"> |
| <select id="qwenDesignPreset" onclick="event.stopPropagation()" onchange="applyDesignPreset(this.value)" |
| style="flex:1;font-size:11px;background:var(--surface2);color:var(--text);border:1px solid var(--border);border-radius:var(--radius-sm);padding:4px 6px;max-width:220px"> |
| <option value="">— Preset voix —</option> |
| <option value="narrator_warm">🎙 Narrateur chaleureux</option> |
| <option value="narrator_epic">🎭 Narrateur épique</option> |
| <option value="narrator_audiobook">📖 Narrateur audiobook</option> |
| <option value="female_warm">👩 Femme chaleureuse</option> |
| <option value="female_clear">📢 Femme claire & nette</option> |
| <option value="female_young">✨ Femme jeune & dynamique</option> |
| <option value="male_deep">🎤 Masculin grave</option> |
| <option value="male_warm">🎵 Masculin chaleureux</option> |
| <option value="assistant">🤖 Assistant IA</option> |
| <option value="dramatic">🎬 Dramatique</option> |
| <option value="asmr">😴 Doux / ASMR</option> |
| <option value="child">🧒 Enfant espiègle</option> |
| <option value="news">📺 Présentateur news</option> |
| <option value="elder">👴 Voix sénior</option> |
| </select> |
| </div> |
| <div class="row"> |
| <textarea class="voice-in" id="voiceInstr" rows="1" |
| placeholder="e.g. warm, calm female narrator…" |
| oninput="autoGrow(this)">A warm, calm narrator with a clear and engaging delivery.</textarea> |
| <button class="play-btn" onclick="event.stopPropagation();generate('voice_design')" title="Generate"> |
| <svg viewBox="0 0 24 24" fill="currentColor"><polygon points="6,3 20,12 6,21"/></svg> |
| </button> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="studio-gen-row" id="studioGenRow"> |
| <button class="gen-btn" id="genBtn" onclick="generateCurrent()"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polygon points="5,3 19,12 5,21" fill="currentColor" stroke="none"/></svg> |
| <span id="genBtnLabel">Générer</span> |
| </button> |
| </div> |
|
|
| |
| <div class="msg-bar" id="msgBar"></div> |
| <div class="queue-bar" id="queueBar"></div> |
|
|
| |
| <div class="pbar-wrap" id="pbarWrap"><div class="pbar" id="pbar"></div></div> |
|
|
| |
| <div class="results" id="results"> |
| <div class="player-row"> |
| <audio id="player" controls></audio> |
| <button class="dl-btn" onclick="dlAudio()" title="Download WAV">↓</button> |
| </div> |
| <div class="metrics"> |
| <div class="met"><span class="met-k">TTFA</span><span class="met-v" id="mTTFA">—</span></div> |
| <div class="met"><span class="met-k">Client</span><span class="met-v" id="mClient">—</span></div> |
| <div class="met" id="mCloneWrap" style="display:none"><span class="met-k">Clone</span><span class="met-v" id="mClone">—</span></div> |
| <div class="met"><span class="met-k">RTF</span><span class="met-v" id="mRTF">—</span></div> |
| <div class="met"><span class="met-k">Dur</span><span class="met-v" id="mDur">—</span></div> |
| <div class="met"><span class="met-k">Buf</span><span class="met-v" id="mBuf">—</span></div> |
| <div class="wave" id="waveInd"><i></i><i></i><i></i><i></i><i></i></div> |
| </div> |
| </div> |
|
|
| </div> |
| </div> |
|
|
| |
| <div id="panelBook" style="display:none"> |
| <div class="book-panel"> |
|
|
| |
| <div class="drop-zone" id="bookDropZone" ondragover="event.preventDefault();this.classList.add('drag-over')" ondragleave="this.classList.remove('drag-over')" ondrop="onBookDrop(event)"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/><line x1="12" y1="7" x2="12" y2="13"/><line x1="9" y1="10" x2="15" y2="10"/></svg> |
| <div class="dz-title">Glisse un fichier ici</div> |
| <div class="dz-sub">.txt · .pdf · .epub — ou colle du texte dans la zone ci-dessous</div> |
| <div class="dz-btn"> |
| Parcourir… |
| <input type="file" accept=".txt,.pdf,.epub" onchange="loadBookFile(this)"> |
| </div> |
| </div> |
|
|
| |
| <div class="book-row" style="margin-top:6px"> |
| <textarea id="bookText" class="book-textarea" placeholder="…ou colle ton texte ici directement." style="height:80px"></textarea> |
| </div> |
| <div class="book-row" style="display:flex;gap:6px;align-items:center"> |
| <button class="parse-btn" onclick="parseBook()">⟳ Détecter les chapitres</button> |
| <span id="bookInfo" class="book-hint" style="flex:1"></span> |
| </div> |
| |
| <div class="bvs-wrap"> |
| <div class="bvs-label">Voix pour le livre</div> |
| <div class="bvs-tabs"> |
| <button class="bvs-tab" onclick="switchBookEngine('kokoro')">🇫🇷 Kokoro FR</button> |
| <button class="bvs-tab active" onclick="switchBookEngine('qwen_design')">⚡ Voice Design</button> |
| <button class="bvs-tab" onclick="switchBookEngine('fish')">🐟 Fish-Speech</button> |
| <button class="bvs-tab" onclick="switchBookEngine('chatterbox')">🎤 Chatterbox</button> |
| <button class="bvs-tab" onclick="switchBookEngine('f5')">🟣 F5-TTS</button> |
| <button class="bvs-tab" onclick="switchBookEngine('qwen_custom')">⚡ Qwen3 Custom</button> |
| <button class="bvs-tab" onclick="switchBookEngine('qwen_clone')">⚡ Qwen3 Clone</button> |
| </div> |
| <div class="bvs-panel" id="bvs-kokoro" style="display:none"> |
| <span style="font-size:11px;color:var(--dim)">Voix :</span> |
| <select id="bookKokoroVoice" class="bvs-select" style="max-width:240px"> |
| <optgroup label="🇫🇷 Français"> |
| <option value="ff_siwis">Siwis — FR Femme ★</option> |
| </optgroup> |
| <optgroup label="🇺🇸 English — Female"> |
| <option value="af_heart">Heart ★</option> |
| <option value="af_bella">Bella</option> |
| <option value="af_nicole">Nicole (ASMR)</option> |
| <option value="af_sarah">Sarah</option> |
| <option value="af_sky">Sky</option> |
| </optgroup> |
| <optgroup label="🇺🇸 English — Male"> |
| <option value="am_echo">Echo</option> |
| <option value="am_michael">Michael</option> |
| <option value="am_adam">Adam</option> |
| </optgroup> |
| <optgroup label="🇬🇧 British — Female"> |
| <option value="bf_emma">Emma</option> |
| <option value="bf_isabella">Isabella</option> |
| </optgroup> |
| <optgroup label="🇬🇧 British — Male"> |
| <option value="bm_george">George</option> |
| <option value="bm_lewis">Lewis</option> |
| </optgroup> |
| </select> |
| </div> |
| <div class="bvs-panel" id="bvs-qwen_design" style="flex-direction:column;gap:6px"> |
| <div id="qwenBookWarning" style="display:none;background:rgba(239,68,68,0.15);border:1px solid var(--red);border-radius:6px;padding:6px 10px;font-size:11px;color:var(--red)"> |
| ⚠️ Voice Design nécessite un modèle Qwen3 chargé. Allez dans <b>Studio → Qwen3-TTS → Voice Design → LOAD</b> d'abord. |
| </div> |
| <div style="display:flex;gap:6px;align-items:center;width:100%"> |
| <span style="font-size:11px;color:var(--dim);flex-shrink:0">Langue :</span> |
| <select id="bookQwenLangVD" class="bvs-select" style="max-width:110px"> |
| <option selected>French</option><option>English</option><option>Chinese</option><option>German</option><option>Spanish</option> |
| </select> |
| </div> |
| <textarea id="bookVoiceInstr" rows="3" style="width:100%;background:var(--surface);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:6px 10px;font-size:12px;font-family:inherit;resize:none;outline:none">A professional French audiobook narrator with a warm, deep, and engaging voice. Speak at a measured pace — not too fast, not too slow. Respect punctuation: pause naturally at commas, breathe at periods, and hold longer at paragraph breaks. Maintain consistent tone throughout, slightly lower and richer than everyday speech. Distinguish narration from dialogue with subtle vocal shifts.</textarea> |
| <div style="display:flex;gap:6px;align-items:center;margin-top:4px"> |
| <span style="font-size:11px;color:var(--dim);flex-shrink:0">Graine vocale :</span> |
| <input id="bookSeedIn" type="number" placeholder="auto" style="width:100px;background:var(--surface);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:3px 8px;font-size:12px;font-family:monospace"> |
| <span id="bookSeedDisplay" style="font-size:11px;color:var(--dim);font-family:monospace"></span> |
| <span style="font-size:10px;color:var(--dimmer)">← même valeur = même voix à chaque fois</span> |
| </div> |
| </div> |
| <div class="bvs-panel" id="bvs-fish" style="display:none;flex-direction:column;gap:6px"> |
| |
| <div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap"> |
| <span style="font-size:11px;color:var(--dim);flex-shrink:0">Voix :</span> |
| <select id="bookFishVoicePreset" class="bvs-select" style="max-width:240px" onchange="applyFishPreset(this.value)"> |
| <option value="">— Référence audio personnalisée —</option> |
| <optgroup label="📖 Narrateurs (Hommes)"> |
| <option value="42">Roland · grave, posé, classique</option> |
| <option value="1337">Marc · chaleureux, narrateur BBC</option> |
| <option value="8192">Léo · jeune, dynamique, SF</option> |
| <option value="31415">Victor · neutre, documentaire</option> |
| </optgroup> |
| <optgroup label="📖 Narratrices (Femmes)"> |
| <option value="512">Claire · claire, littéraire</option> |
| <option value="777">Sophie · douce, romans</option> |
| <option value="2048">Hélène · chaleureuse, intimiste</option> |
| <option value="9999">Nadia · posée, polar/thriller</option> |
| </optgroup> |
| </select> |
| </div> |
| |
| <div id="bookFishRefRow" style="display:flex;gap:6px;align-items:center;flex-wrap:wrap"> |
| <span style="font-size:11px;color:var(--dim)">Référence :</span> |
| <input type="file" id="bookFishRef" accept="audio/*" class="bvs-file" onchange="bookFishRefFile=this.files[0];$('bookFishVoicePreset').value=''"> |
| <span style="font-size:11px;color:var(--dim)">Texte réf :</span> |
| <input type="text" id="bookFishRefText" placeholder="Transcription..." style="font-size:11px;flex:3;min-width:120px;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);padding:3px 6px;max-width:200px"> |
| </div> |
| |
| <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding-top:5px;border-top:1px solid var(--border)"> |
| <span style="font-size:10px;color:var(--accent);font-weight:700;letter-spacing:.5px">📖 AUDIOBOOK</span> |
| <label style="font-size:11px;color:var(--dim)" title="Tokens max — augmenter pour longs chapitres">Max tok</label> |
| <input type="number" id="bookFishMaxTokens" value="4096" min="512" max="8192" step="512" |
| style="width:68px;font-size:11px;background:var(--surface2);color:var(--text);border:1px solid var(--border);border-radius:var(--radius-sm);padding:3px 6px;font-family:monospace"> |
| <label style="font-size:11px;color:var(--dim)" title="Chunk interne — 400+ réduit l'overhead entre segments">Chunk</label> |
| <input type="range" id="bookFishChunk" min="100" max="600" step="50" value="400" style="width:56px" |
| oninput="$('bookFishChunkV').textContent=this.value"> |
| <span id="bookFishChunkV" style="font-size:11px;color:var(--dim);min-width:28px">400</span> |
| <label style="font-size:11px;color:var(--dim)">Latence</label> |
| <select id="bookFishLatency" style="font-size:11px;background:var(--surface2);color:var(--text);border:1px solid var(--border);border-radius:var(--radius-sm);padding:3px 6px"> |
| <option value="balanced" selected>Balanced</option> |
| <option value="normal">Normal</option> |
| </select> |
| <label style="font-size:11px;color:var(--dim);display:flex;align-items:center;gap:3px;cursor:pointer"> |
| <input type="checkbox" id="bookFishNorm" checked style="accent-color:var(--accent)"> Normalize |
| </label> |
| <label style="font-size:11px;color:var(--accent);display:flex;align-items:center;gap:3px;cursor:pointer" |
| title="Découpe les longs chapitres en paragraphes pour éviter les truncations"> |
| <input type="checkbox" id="bookFishAutoSplit" checked style="accent-color:var(--accent)"> Auto-split |
| </label> |
| </div> |
| </div> |
| <div class="bvs-panel" id="bvs-chatterbox" style="display:none"> |
| <span style="font-size:11px;color:var(--dim)">Référence :</span> |
| <input type="file" id="bookCbRef" accept="audio/*" class="bvs-file" onchange="bookCbRefFile=this.files[0]"> |
| <span style="font-size:11px;color:var(--dim)">Exag</span> |
| <input type="range" id="bookCbExag" min="0" max="1" step="0.05" value="0.5" style="width:60px" oninput="$('bookCbExagVal').textContent=this.value"> |
| <span id="bookCbExagVal" style="font-size:11px;color:var(--dim);min-width:24px">0.5</span> |
| </div> |
| <div class="bvs-panel" id="bvs-f5" style="display:none;flex-wrap:wrap;gap:6px"> |
| <span style="font-size:11px;color:var(--dim)">Référence :</span> |
| <input type="file" id="bookF5Ref" accept="audio/*" class="bvs-file" onchange="bookF5RefFile=this.files[0]"> |
| <span style="font-size:11px;color:var(--dim)">Texte réf :</span> |
| <input type="text" id="bookF5RefText" placeholder="Transcription du clip..." style="font-size:11px;flex:1;min-width:120px;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);padding:3px 6px"> |
| </div> |
| <div class="bvs-panel" id="bvs-qwen_custom" style="display:none"> |
| <span style="font-size:11px;color:var(--dim)">Voix :</span> |
| <select id="bookQwenSpeaker" class="bvs-select" style="max-width:200px"> |
| <option value="Aiden">Aiden — English Male</option> |
| </select> |
| <span style="font-size:11px;color:var(--dim)">Langue :</span> |
| <select id="bookQwenLangC" class="bvs-select" style="max-width:110px"> |
| <option>French</option><option selected>English</option><option>Chinese</option><option>German</option><option>Spanish</option> |
| </select> |
| </div> |
| <div class="bvs-panel" id="bvs-qwen_clone" style="display:none"> |
| <span style="font-size:11px;color:var(--dim)">Référence :</span> |
| <input type="file" id="bookQwenRef" accept="audio/*" class="bvs-file" onchange="bookQwenRefFile=this.files[0]"> |
| <span style="font-size:11px;color:var(--dim)">Langue :</span> |
| <select id="bookQwenLangK" class="bvs-select" style="max-width:110px"> |
| <option>French</option><option selected>English</option><option>Chinese</option><option>German</option><option>Spanish</option> |
| </select> |
| </div> |
| </div> |
| |
| <div id="chapterListHeader" style="display:none;align-items:center;gap:6px;flex-wrap:wrap;margin-top:4px"> |
| <span style="font-size:10px;font-weight:700;letter-spacing:0.6px;text-transform:uppercase;color:var(--dimmer)">Chapitres</span> |
| <button class="ch-dl" onclick="openReader()" style="font-size:10px;background:var(--accent-bg);border-color:var(--accent);color:var(--accent)">📖 Parcourir</button> |
| <button class="ch-dl" onclick="selectAllChapters(true)" style="font-size:10px">Tout sélectionner</button> |
| <button class="ch-dl" onclick="selectAllChapters(false)" style="font-size:10px">Tout désélectionner</button> |
| <button class="ch-dl" onclick="selectContentChapters()" style="font-size:10px">Sélection auto</button> |
| <span class="spacer"></span> |
| <span id="chapterTotalStats" style="font-size:11px;color:var(--dim);font-family:monospace"></span> |
| </div> |
| <div id="chapterList" class="chapter-list" style="display:none"></div> |
| <div class="book-gen-row" id="bookGenRow" style="display:none"> |
| <button class="gen-btn" id="bookGenBtn" onclick="generateBook()" style="flex:1;padding:10px;font-size:13px"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polygon points="5,3 19,12 5,21" fill="currentColor" stroke="none"/></svg> |
| <span id="bookGenBtnLabel">Générer la sélection</span> |
| </button> |
| <button class="upload-btn" id="bookCancelBtn" onclick="cancelBook()" style="display:none">⏸ <span id="bookPauseLabel">Pause</span></button> |
| <button class="upload-btn" id="bookResumeBtn" onclick="generateBook()" style="display:none">▶ <span id="bookResumeLabel">Reprendre</span></button> |
| <label class="auto-dl-label" title="Télécharger chaque chapitre automatiquement"><input type="checkbox" id="autoDlCheck">Auto-DL</label> |
| <label class="auto-dl-label" style="color:var(--accent)" title="Mode expressif : émotions plus marquées, ton varié selon le contenu"><input type="checkbox" id="bookExpressive">🎭 Expressif</label> |
| <span class="book-stats" id="bookStats"></span> |
| </div> |
| <div class="book-progress-row" id="bookProgressRow" style="display:none"> |
| <div class="pbar-wrap" style="flex:1;display:block;margin:0"><div class="pbar" id="bookPbar"></div></div> |
| <span class="book-pct" id="bookPct">0%</span> |
| <span class="book-speed-info" id="bookSpeed"></span> |
| <span class="book-eta" id="bookEta"></span> |
| </div> |
| <div style="display:flex;gap:6px;flex-wrap:wrap" id="bookDlRow"> |
| <button class="upload-btn" id="bookDlAllBtn" onclick="downloadAllChapters()" style="display:none">Tout télécharger</button> |
| <button class="upload-btn" id="bookMergeBtn" onclick="mergeAllChapters()" style="display:none">Fusionner en 1 WAV</button> |
| </div> |
| <div class="book-voice-note">Voix : utilise le clip de référence défini dans l'onglet TTS.</div> |
| </div> |
| </div> |
|
|
| </div> |
|
|
| |
| <div id="readerOverlay" class="reader-overlay" style="display:none"> |
| <div class="reader-topbar"> |
| <button class="ibtn" onclick="closeReader()" title="Retour" style="font-size:18px;padding:2px 8px">←</button> |
| <span class="reader-title" id="readerBookTitle">—</span> |
| <button class="reader-mode-btn mode-start ractive" id="readerModeStart" onclick="setReaderMode('start')">▶ Début</button> |
| <button class="reader-mode-btn mode-end" id="readerModeEnd" onclick="setReaderMode('end')">⏹ Fin</button> |
| <button class="reader-apply-btn" onclick="applyReaderSelection()">Appliquer ✓</button> |
| </div> |
| <div class="reader-body"> |
| <div class="reader-text-col" id="readerTextCol"></div> |
| <div class="reader-sidebar"> |
| <div class="reader-side-lbl">Sélection</div> |
| <div id="readerSideSel" style="font-size:12px;color:var(--dim);line-height:1.7"></div> |
| <div class="reader-side-lbl" style="margin-top:10px">Chapitres</div> |
| <div id="readerSideChapters" style="display:flex;flex-direction:column;gap:2px"></div> |
| </div> |
| </div> |
| <div class="reader-nav"> |
| <button class="ch-dl" onclick="readerPrevPage()">← Préc</button> |
| <span class="reader-page-info" id="readerPageInfo">Page 1/1</span> |
| <button class="ch-dl" onclick="readerNextPage()">Suiv →</button> |
| <span style="flex:1"></span> |
| <span style="font-size:10px;color:var(--dimmer)">Clic : début · Shift+clic ou clic droit : fin</span> |
| </div> |
| </div> |
|
|
| |
| <div class="sov" id="settingsOv" onclick="closeSettings(event)"> |
| <div class="spanel" onclick="event.stopPropagation()"> |
| <div class="s-handle"></div> |
| <div class="s-head"> |
| <span>Settings</span> |
| <span class="spacer"></span> |
| <button class="ibtn" onclick="closeSettings()" title="Close"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> |
| </button> |
| </div> |
|
|
| <div class="s-section">Model</div> |
| <div class="s-row"> |
| <label>Model</label> |
| <select id="modelSel"></select> |
| <button class="s-btn" id="loadBtn" onclick="loadModel()">Load</button> |
| </div> |
|
|
| <div class="s-section">Generation</div> |
| <div class="s-row"> |
| <label>Language</label> |
| <select id="langSel"> |
| <option>French</option><option>English</option><option>Chinese</option> |
| <option>Japanese</option><option>Korean</option><option>German</option> |
| <option>Spanish</option><option>Italian</option><option>Portuguese</option> |
| <option>Russian</option><option>Dutch</option><option>Arabic</option> |
| <option>Hindi</option><option>Auto</option> |
| </select> |
| </div> |
| <div class="s-row"> |
| <label>Mode</label> |
| <div class="toggle"> |
| <div class="tog on" data-m="stream" onclick="setMode('stream')">Streaming</div> |
| <div class="tog" data-m="non" onclick="setMode('non')">Non-streaming</div> |
| </div> |
| </div> |
| <div class="s-row" id="chunkRow"> |
| <label>Chunk size</label> |
| <div class="s-slider"> |
| <input type="range" id="chunkSl" min="1" max="24" value="8" |
| oninput="$('chunkV').textContent=this.value"> |
| <span class="sv" id="chunkV">8</span> |
| </div> |
| </div> |
|
|
| <div class="s-section">Sampling</div> |
| <div class="s-grid3"> |
| <div class="s-row"> |
| <label>Temperature</label> |
| <div class="s-slider"> |
| <input type="range" id="tempSl" min="0.1" max="2.0" step="0.05" value="0.7" |
| oninput="$('tempV').textContent=this.value"> |
| <span class="sv" id="tempV">0.7</span> |
| </div> |
| </div> |
| <div class="s-row"> |
| <label>Top-K</label> |
| <div class="s-slider"> |
| <input type="range" id="topkSl" min="1" max="100" value="50" |
| oninput="$('topkV').textContent=this.value"> |
| <span class="sv" id="topkV">50</span> |
| </div> |
| </div> |
| <div class="s-row"> |
| <label>Rep. penalty</label> |
| <div class="s-slider"> |
| <input type="range" id="repSl" min="1.0" max="1.5" step="0.01" value="1.05" |
| oninput="$('repV').textContent=this.value"> |
| <span class="sv" id="repV">1.05</span> |
| </div> |
| </div> |
| </div> |
|
|
| <div class="s-section">Recording</div> |
| <div class="s-row"> |
| <label>Microphone</label> |
| <select id="micSel"></select> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="lov" id="loaderOv" onclick="closeLoader(event)"> |
| <div class="lpanel" onclick="event.stopPropagation()"> |
| <div class="l-header"> |
| <button class="l-back" onclick="$('loaderOv').classList.remove('open')">◀ BACK</button> |
| <div class="l-title">⬡ <span id="loaderModeLabel"></span> ⬡</div> |
| </div> |
| <div class="lcard-grid" id="loaderGrid"></div> |
| <button class="btn-primary" style="width:100%" onclick="loadFromSheet()">LOAD ▶</button> |
| </div> |
| </div> |
|
|
| |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script> |
| |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script> |
| <script> |
| |
| const $ = id => document.getElementById(id); |
| function autoGrow(el) { |
| el.style.height = 'auto'; |
| el.style.height = el.scrollHeight + 'px'; |
| } |
| |
| function setPlayBtns(disabled) { |
| document.querySelectorAll('.play-btn').forEach(b => b.disabled = disabled); |
| } |
| |
| |
| let genMode = 'stream'; |
| let xvecOnly = false; |
| let busy = false; |
| let refFile = null; |
| let presetRefId = null; |
| let presetRefs = []; |
| let loadedModel = null; |
| let _statusInitDone = false; |
| let loadingModel = null; |
| let dlBlob = null; |
| let lastRtf = 3.5; |
| let bookBusy = false; |
| let bookChapters = []; |
| let bookTitle = ''; |
| let bookCancelled = false; |
| let bookStartTime = 0; |
| let bookChaptersDone = 0; |
| let bookEngine = 'qwen_design'; |
| let bookCbRefFile = null; |
| let bookF5RefFile = null; |
| let bookQwenRefFile = null; |
| let bookFishRefFile = null; |
| let bookFishRollingRef = null; |
| let bookQwenDesignAnchor = null; |
| let bookSeed = null; |
| |
| const defaultSpeakers = [ |
| { id: 'Vivian', desc: 'Chinese — Bright young female' }, |
| { id: 'Serena', desc: 'Chinese — Warm gentle female' }, |
| { id: 'Uncle_Fu', desc: 'Chinese — Seasoned low male' }, |
| { id: 'Dylan', desc: 'Chinese — Youthful Beijing male' }, |
| { id: 'Eric', desc: 'Chinese (Sichuan) — Lively male' }, |
| { id: 'Ryan', desc: 'English — Dynamic male' }, |
| { id: 'Aiden', desc: 'English — Sunny American male' }, |
| { id: 'Ono_Anna', desc: 'Japanese — Playful female' }, |
| { id: 'Sohee', desc: 'Korean — Warm female' }, |
| ]; |
| |
| |
| let actx = null, sproc = null, pcmQueue = [], rawPcmParts = [], rawPcmSr = 24000; |
| let chunkQ = Promise.resolve(); |
| let clientT0 = 0, firstChunkAt = null, firstAudioAt = null, lastBufS = 0, firstServerWall = null; |
| |
| |
| let recStream = null, recCtx = null, recSource = null, recProc = null; |
| let recGain = null, recAnalyser = null, recMeterRaf = null, recMeterBuf = null; |
| let recChunks = [], recActive = false, recDeviceId = null; |
| let recPeak = 0, recHasSignal = false, recSignalTs = null, recSignalCount = 0; |
| const REC_SR = 24000; |
| |
| |
| window.addEventListener('load', async () => { |
| initTheme(); |
| setLang(appLang); |
| setTTSLang(ttsLang); |
| autoGrow($('voiceInstr')); |
| updateEstimation(); |
| selectStudioEngine('kokoro'); |
| await fetchStatus(); |
| if (!loadedModel && availableModels.length > 0) { |
| |
| const preferred = availableModels.find(m => m.includes('CustomVoice')) |
| || availableModels[0]; |
| $('modelSel').value = preferred; |
| loadModel(); |
| } |
| loadMics(); |
| populateSpeakers([]); |
| const warm = () => { warmAudio(); window.removeEventListener('pointerdown', warm); }; |
| window.addEventListener('pointerdown', warm, { passive: true }); |
| }); |
| |
| |
| function initTheme() { |
| const t = localStorage.getItem('theme') || |
| (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'); |
| applyTheme(t); |
| } |
| function toggleTheme() { |
| applyTheme((document.documentElement.dataset.theme || 'dark') === 'dark' ? 'light' : 'dark'); |
| } |
| function applyTheme(t) { |
| document.documentElement.dataset.theme = t; |
| localStorage.setItem('theme', t); |
| $('themeIcon').innerHTML = t === 'dark' |
| ? '<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>' |
| : '<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/>'; |
| } |
| |
| |
| const TTS_LANG_CONFIG = { |
| fr: { label:'Français', qwen:'French', kokoroVoice:'ff_siwis', kokoroNote:'✓ Voix native FR', engines:'Kokoro ★ F5-TTS ★ Fish ✓ Qwen3 ✓ Chatterbox ~' }, |
| en: { label:'English', qwen:'English', kokoroVoice:'af_heart', kokoroNote:'✓ Native EN voices', engines:'Kokoro ★ Chatterbox ★ Fish ✓ Qwen3 ✓ F5-TTS ~' }, |
| de: { label:'Deutsch', qwen:'German', kokoroVoice:'af_heart', kokoroNote:'~ EN voice (no DE)', engines:'Fish ★ Qwen3 ★ Chatterbox ~ Kokoro ~' }, |
| es: { label:'Español', qwen:'Spanish', kokoroVoice:'af_heart', kokoroNote:'~ EN voice (no ES)', engines:'Fish ★ Qwen3 ★ Chatterbox ~ Kokoro ~' }, |
| zh: { label:'中文', qwen:'Chinese', kokoroVoice:'af_heart', kokoroNote:'✗ No Mandarin voice', engines:'Fish ★ Qwen3 ★' }, |
| ja: { label:'日本語', qwen:'Japanese', kokoroVoice:'af_heart', kokoroNote:'✗ No JP voice', engines:'Fish ★ Qwen3 ★' }, |
| pt: { label:'Português', qwen:'Portuguese', kokoroVoice:'af_heart', kokoroNote:'~ EN voice (no PT)', engines:'Fish ★ Qwen3 ★ Chatterbox ~' }, |
| }; |
| let ttsLang = localStorage.getItem('ttsLang') || 'fr'; |
| |
| function setTTSLang(lang) { |
| ttsLang = lang; |
| localStorage.setItem('ttsLang', lang); |
| const cfg = TTS_LANG_CONFIG[lang] || TTS_LANG_CONFIG.fr; |
| |
| document.querySelectorAll('.tlpill').forEach(p => p.classList.remove('active')); |
| const btn = $('tlp-' + lang); if (btn) btn.classList.add('active'); |
| |
| if ($('kokoroVoice') && cfg.kokoroVoice) $('kokoroVoice').value = cfg.kokoroVoice; |
| |
| if ($('langSel')) $('langSel').value = cfg.qwen; |
| if ($('langSelVis')) $('langSelVis').value = cfg.qwen; |
| |
| if ($('cbLang')) $('cbLang').value = lang; |
| if ($('f5Lang')) $('f5Lang').value = lang; |
| |
| const note = document.getElementById('ttsLangNote'); |
| if (note) note.textContent = cfg.engines; |
| } |
| |
| |
| const DESIGN_PRESETS = { |
| narrator_warm: "A warm, resonant male narrator with clear diction, calm and measured pacing, and an engaging storytelling voice. Rich, trustworthy, and professional.", |
| narrator_epic: "An epic, commanding male narrator with a deep resonant voice, dramatic pacing, powerful delivery, and cinematic gravitas. Perfect for trailers and epic stories.", |
| narrator_audiobook:"A clear, pleasant audiobook narrator with natural conversational flow, warm tone, excellent diction, and engaging pacing that keeps listeners captivated.", |
| female_warm: "A warm, friendly female voice with a natural conversational tone, gentle smile in the voice, clear pronunciation, and an inviting, trustworthy quality.", |
| female_clear: "A clear, articulate female voice with bright confident tone, crisp professional delivery, and excellent diction. Energetic and authoritative.", |
| female_young: "A bright, youthful female voice with natural energy, upbeat cheerful delivery, clear pronunciation, and an approachable friendly quality.", |
| male_deep: "A deep, authoritative baritone male voice with confident measured delivery, natural gravitas, and a commanding presence that conveys expertise.", |
| male_warm: "A warm, friendly male voice with a natural conversational flow, pleasant mid-range tone, and an approachable, trustworthy quality.", |
| assistant: "A clear, helpful assistant voice — friendly yet professional, warm but neutral, easy to understand, with natural pacing and approachable tone.", |
| dramatic: "An expressive, theatrical voice with rich emotional range, intense delivery, dynamic pacing that rises and falls with the narrative, and powerful presence.", |
| asmr: "A soft, gentle, breathy whisper voice with extremely slow and deliberate pacing, creating a deeply soothing and relaxing sensation.", |
| child: "A bright, cheerful child's voice with natural enthusiasm, playful energy, innocent expressiveness, and a light, carefree quality.", |
| news: "A clear, authoritative broadcast journalist voice with measured pacing, confident delivery, professional neutrality, and crisp diction.", |
| elder: "A wise, warm elder voice with a slightly slower pace, gentle gravitas, and the natural warmth and authenticity of lived experience.", |
| }; |
| function applyDesignPreset(key) { |
| if (!key || !DESIGN_PRESETS[key]) return; |
| $('voiceInstr').value = DESIGN_PRESETS[key]; |
| autoGrow($('voiceInstr')); |
| |
| setTimeout(() => { if ($('qwenDesignPreset')) $('qwenDesignPreset').selectedIndex = 0; }, 100); |
| } |
| |
| |
| const I18N = { |
| fr: { |
| tabBook: 'Livre Audio', |
| grp1: 'Instantané · prêt à l\'emploi', |
| grp2: 'Qwen3-TTS · nécessite chargement', |
| engKokoro: 'Kokoro FR — voix françaises natives', |
| engChatterbox: 'Chatterbox — clonage zero-shot (ResembleAI)', |
| engF5: 'F5-TTS FR — clonage vocal français', |
| engFish: 'Fish-Speech 1.5 — clonage vocal multilingue', |
| engVoxtral: 'Voxtral 4B — TTS français Mistral AI (vLLM-Omni)', |
| genBtn: 'Générer', |
| bookGenBtn: 'Générer la sélection', |
| bookPause: 'Pause', |
| bookResume: 'Reprendre', |
| textPh: 'Entrez votre texte ici...', |
| langBtn: '🌐 EN', |
| }, |
| en: { |
| tabBook: 'Audiobook', |
| grp1: 'Instant · ready to use', |
| grp2: 'Qwen3-TTS · requires loading', |
| engKokoro: 'Kokoro FR — native French voices', |
| engChatterbox: 'Chatterbox — zero-shot cloning (ResembleAI)', |
| engF5: 'F5-TTS FR — French voice cloning', |
| engFish: 'Fish-Speech 1.5 — multilingual voice cloning', |
| engVoxtral: 'Voxtral 4B — Mistral AI French TTS (vLLM-Omni)', |
| genBtn: 'Generate', |
| bookGenBtn: 'Generate selection', |
| bookPause: 'Pause', |
| bookResume: 'Resume', |
| textPh: 'Enter your text here...', |
| langBtn: '🌐 FR', |
| } |
| }; |
| let appLang = localStorage.getItem('bvLang') || 'fr'; |
| function toggleLang() { setLang(appLang === 'fr' ? 'en' : 'fr'); } |
| function setLang(lang) { |
| appLang = lang; |
| localStorage.setItem('bvLang', lang); |
| const t = I18N[lang]; |
| |
| $('langBtn').textContent = t.langBtn; |
| |
| $('tabBookLabel').textContent = t.tabBook; |
| |
| $('grpLabel1').textContent = t.grp1; |
| $('grpLabel2').textContent = t.grp2; |
| |
| $('engLblKokoro').textContent = t.engKokoro; |
| $('engLblChatterbox').textContent = t.engChatterbox; |
| $('engLblF5').textContent = t.engF5; |
| $('engLblFish').textContent = t.engFish; |
| $('engLblVoxtral').textContent = t.engVoxtral; |
| |
| $('genBtnLabel').textContent = t.genBtn; |
| $('bookGenBtnLabel').textContent = t.bookGenBtn; |
| $('bookPauseLabel').textContent = t.bookPause; |
| $('bookResumeLabel').textContent = t.bookResume; |
| |
| $('textIn').placeholder = t.textPh; |
| |
| document.documentElement.lang = lang; |
| } |
| |
| |
| function openSettings() { $('settingsOv').classList.add('open'); } |
| function closeSettings(e) { |
| |
| if (e && e.target !== $('settingsOv')) return; |
| $('settingsOv').classList.remove('open'); |
| } |
| function setMode(m) { |
| genMode = m; |
| document.querySelectorAll('.tog[data-m]').forEach(el => |
| el.classList.toggle('on', el.dataset.m === m)); |
| $('chunkRow').style.display = m === 'stream' ? '' : 'none'; |
| } |
| function setXvec(v) { |
| xvecOnly = v; |
| document.querySelectorAll('.tog[data-x]').forEach(el => |
| el.classList.toggle('on', el.dataset.x === (v ? '1' : '0'))); |
| $('advTranscript').style.display = v ? 'none' : ''; |
| } |
| |
| function renderPresetRefs(list) { |
| presetRefs = list || []; |
| const row = $('presetRow'); |
| if (!row) return; |
| row.innerHTML = ''; |
| presetRefs.forEach(p => { |
| const b = document.createElement('button'); |
| b.type = 'button'; |
| b.className = 'preset-btn'; |
| b.textContent = p.label || p.id; |
| b.dataset.id = p.id; |
| b.onclick = () => selectPresetRef(p.id); |
| row.appendChild(b); |
| }); |
| updatePresetButtons(); |
| if (presetRefs.length > 0 && !presetRefId) selectPresetRef(presetRefs[0].id); |
| } |
| |
| function updatePresetButtons() { |
| const row = $('presetRow'); |
| if (!row) return; |
| row.querySelectorAll('.preset-btn').forEach(btn => { |
| if (btn.dataset.id === presetRefId) btn.classList.add('active'); |
| else btn.classList.remove('active'); |
| }); |
| } |
| |
| function clearPresetSelection() { |
| presetRefId = null; |
| updatePresetButtons(); |
| } |
| |
| async function selectPresetRef(id) { |
| try { |
| const d = await fetch(`/preset_ref/${id}`).then(r => r.json()); |
| const bytes = Uint8Array.from(atob(d.audio_b64), c => c.charCodeAt(0)); |
| const blob = new Blob([bytes], { type: 'audio/wav' }); |
| const file = new File([blob], d.filename || `${id}.wav`, { type: 'audio/wav' }); |
| |
| presetRefId = id; |
| refFile = null; |
| setXvec(false); |
| $('refLabel').textContent = d.label || d.filename || 'Reference audio'; |
| $('refChip').classList.add('has'); |
| const prev = $('recPreview'); |
| if (prev.src) URL.revokeObjectURL(prev.src); |
| prev.src = URL.createObjectURL(file); |
| prev.style.display = 'block'; |
| $('refTextIn').value = d.ref_text || ''; |
| autoGrow($('refTextIn')); |
| updatePresetButtons(); |
| } catch (e) { |
| console.warn('Preset audio not available:', e); |
| } |
| } |
| |
| |
| async function transcribeRef() { |
| if (!refFile) return; |
| const ta = $('refTextIn'); |
| ta.disabled = true; |
| ta.value = ''; |
| ta.placeholder = 'Transcribing…'; |
| autoGrow(ta); |
| try { |
| const fd = new FormData(); |
| fd.append('audio', refFile); |
| const res = await fetch('/transcribe', { method: 'POST', body: fd }); |
| if (!res.ok) return; |
| const d = await res.json(); |
| ta.value = d.text || ''; |
| autoGrow(ta); |
| } catch { |
| |
| } finally { |
| ta.disabled = false; |
| if (!ta.value) ta.placeholder = 'Type the full transcript here…'; |
| } |
| } |
| |
| |
| async function fetchStatus() { |
| try { |
| const d = await fetch('/status').then(r => r.json()); |
| applyAvailableModels(d.available_models || []); |
| syncQwenModelSel(d.available_models || []); |
| if (d.loaded && d.model) { |
| loadedModel = d.model; |
| $('modelSel').value = d.model; |
| updateQwenBookWarning(); |
| } |
| populateSpeakers(d.speakers || []); |
| renderPresetRefs(d.preset_refs || []); |
| setPill(d.loaded ? 'loaded' : 'off', d.loaded ? 'ready' : 'not loaded'); |
| |
| $('rowKokoro').classList.add('compatible'); |
| $('rowF5').classList.add('compatible'); |
| $('rowChatterbox').classList.add('compatible'); |
| |
| if ($('langSelVis')) $('langSelVis').value = $('langSel').value || 'French'; |
| |
| const _qwenTags = ['qwenCloneTag', 'qwenCustomTag', 'qwenVdTag']; |
| if (d.loading) { |
| _qwenTags.forEach(id => { const el = $(id); if (el) el.textContent = '⟳ …'; }); |
| if ($('grpLabel2')) $('grpLabel2').textContent = 'Qwen3-TTS · chargement en cours…'; |
| if ($('loadBtnVis')) $('loadBtnVis').textContent = '⟳ Chargement…'; |
| |
| setTimeout(fetchStatus, 3000); |
| } else if (d.loaded) { |
| _qwenTags.forEach(id => { const el = $(id); if (el) { el.textContent = '✓ PRÊT'; el.style.background = '#22c55e'; } }); |
| const shortName = (d.model || '').split('/').pop().replace('Qwen3-TTS-12Hz-', '').replace('-Base','').replace('-CustomVoice',' Custom').replace('-VoiceDesign',' VD'); |
| if ($('grpLabel2')) $('grpLabel2').textContent = `Qwen3-TTS · ${shortName} ✓`; |
| if ($('loadBtnVis')) $('loadBtnVis').textContent = '✓ Loaded'; |
| |
| ['rowClone', 'rowChar', 'rowCustom'].forEach(id => { if ($(id)) $(id).classList.add('compatible'); }); |
| } else { |
| _qwenTags.forEach(id => { const el = $(id); if (el) { el.textContent = 'LOAD ▶'; el.style.background = ''; } }); |
| if ($('grpLabel2')) $('grpLabel2').textContent = appLang === 'en' ? 'Qwen3-TTS · requires loading' : 'Qwen3-TTS · nécessite chargement'; |
| if ($('loadBtnVis')) $('loadBtnVis').textContent = 'Load'; |
| } |
| |
| if (!_statusInitDone) { |
| _statusInitDone = true; |
| switchInnerTab('fr'); |
| selectStudioEngine('kokoro'); |
| } |
| } catch { setPill('off', 'offline'); } |
| } |
| |
| function switchInnerTab(tab) { |
| document.querySelectorAll('.inner-tab').forEach(t => t.classList.toggle('active', t.dataset.itab === tab)); |
| $('innerPanelFR').style.display = tab === 'fr' ? '' : 'none'; |
| $('innerPanelQwen').style.display = tab === 'qwen' ? '' : 'none'; |
| if (tab === 'fr') setFrenchPreset(); |
| } |
| |
| |
| let currentEngine = 'kokoro'; |
| |
| const ENGINE_TO_ITAB = { kokoro:'fr', chatterbox:'fr', f5:'fr', fish:'fr', voxtral:'fr', clone:'qwen', custom:'qwen', design:'qwen' }; |
| const ENGINE_TO_ROW = { kokoro:'rowKokoro', chatterbox:'rowChatterbox', f5:'rowF5', fish:'rowFish', voxtral:'rowVoxtral' }; |
| const ENGINE_TO_QWEN = { clone:'voice_clone', custom:'custom', design:'voice_design' }; |
| |
| function selectStudioEngine(engine) { |
| currentEngine = engine; |
| |
| document.querySelectorAll('.epill').forEach(p => p.classList.remove('active')); |
| const pill = $('pill-' + engine); |
| if (pill) pill.classList.add('active'); |
| |
| const isFR = ENGINE_TO_ITAB[engine] === 'fr'; |
| |
| |
| $('innerPanelFR').style.display = isFR ? '' : 'none'; |
| $('innerPanelQwen').style.display = !isFR ? '' : 'none'; |
| |
| |
| const genRow = $('studioGenRow'); |
| if (genRow) genRow.style.display = isFR ? '' : 'none'; |
| |
| if (isFR) { |
| |
| document.querySelectorAll('#innerPanelFR .mode-row').forEach(r => r.classList.remove('eng-active')); |
| const rowId = ENGINE_TO_ROW[engine]; |
| if (rowId && $(rowId)) $(rowId).classList.add('eng-active'); |
| } else { |
| |
| const qwenMode = ENGINE_TO_QWEN[engine]; |
| if (qwenMode) onModeRowClick(qwenMode); |
| } |
| } |
| |
| async function generateCurrent() { |
| switch (currentEngine) { |
| case 'kokoro': await generateKokoro(); break; |
| case 'chatterbox': await generateChatterbox(); break; |
| case 'f5': await generateF5(); break; |
| case 'fish': await generateFish(); break; |
| case 'voxtral': await generateVoxtral(); break; |
| default: break; |
| } |
| } |
| |
| function onQwenModelChange(val) { |
| $('modelSel').value = val; |
| } |
| |
| function syncQwenModelSel(models) { |
| const sel = $('modelSelVis'); |
| if (!sel) return; |
| sel.innerHTML = ''; |
| models.forEach(m => { |
| const o = document.createElement('option'); |
| o.value = m; |
| const label = m.includes('1.7B') ? '1.7B ★' : m.includes('0.6B') ? '0.6B Fast' : m.split('/').pop(); |
| o.textContent = label; |
| sel.appendChild(o); |
| }); |
| |
| if ($('modelSel').value) sel.value = $('modelSel').value; |
| } |
| |
| function setFrenchPreset() { |
| const t = $('tempSl'); if (t) { t.value = 0.7; $('tempV').textContent = '0.7'; } |
| const k = $('topkSl'); if (k) { k.value = 30; $('topkV').textContent = '30'; } |
| const r = $('repSl'); if (r) { r.value = 1.1; $('repV').textContent = '1.10'; } |
| showMsg('ok', 'Preset FR appliqué — Temp 0.7 · Top-K 30 · Rep 1.1'); |
| setTimeout(hideMsg, 2000); |
| } |
| |
| async function generateF5() { |
| if (busy) return; |
| const text = $('textIn').value.trim(); |
| if (!text) { showMsg('err', 'Entre du texte d\'abord.'); return; } |
| hideMsg(); |
| busy = true; setPlayBtns(true); |
| $('results').classList.add('show'); |
| $('player').parentElement.style.display = 'none'; |
| $('waveInd').classList.remove('off'); |
| $('pbarWrap').classList.add('show'); |
| const pb = $('pbar'); pb.style.width = ''; pb.classList.add('spin'); |
| resetMetrics(); |
| const t0 = performance.now(); |
| try { |
| const fd = new FormData(); |
| fd.append('text', text); |
| const refFile = $('f5RefWav').files[0]; |
| if (refFile) fd.append('ref_wav', refFile); |
| const f5rt = $('f5RefText').value.trim(); |
| if (f5rt) fd.append('ref_text', f5rt); |
| fd.append('speed', $('f5Speed').value); |
| fd.append('nfe_step', $('f5Nfe').value); |
| fd.append('cross_fade_duration', $('f5Cross').value); |
| const f5Seed = $('f5Seed').value.trim(); if (f5Seed) fd.append('seed', f5Seed); |
| const res = await fetch('/generate/f5_fr', { method: 'POST', body: fd }); |
| if (!res.ok) { const e = await res.json(); throw new Error(e.detail || 'Erreur F5-TTS'); } |
| const blob = new Blob([await res.arrayBuffer()], { type: 'audio/wav' }); |
| const genSecs = (performance.now() - t0) / 1000; |
| setPlayer(blob); |
| $('player').play().catch(() => {}); |
| pb.classList.remove('spin'); pb.style.width = '100%'; |
| $('waveInd').classList.add('off'); |
| $('mClient').textContent = genSecs.toFixed(1) + 's'; |
| } catch(e) { |
| showMsg('err', e.message); |
| pb.classList.remove('spin'); pb.style.width = '0%'; |
| } |
| busy = false; setPlayBtns(false); |
| } |
| |
| async function generateKokoro() { |
| if (busy) return; |
| const text = $('textIn').value.trim(); |
| if (!text) { showMsg('err', 'Entre du texte d\'abord.'); return; } |
| hideMsg(); |
| busy = true; setPlayBtns(true); |
| $('results').classList.add('show'); |
| $('player').parentElement.style.display = 'none'; |
| $('waveInd').classList.remove('off'); |
| $('pbarWrap').classList.add('show'); |
| const pb = $('pbar'); pb.style.width = ''; pb.classList.add('spin'); |
| resetMetrics(); |
| const t0 = performance.now(); |
| try { |
| const fd = new FormData(); |
| fd.append('text', text); |
| fd.append('voice', $('kokoroVoice').value); |
| fd.append('speed', $('kokoroSpeed').value); |
| const res = await fetch('/generate/kokoro_fr', { method: 'POST', body: fd }); |
| if (!res.ok) { const e = await res.json(); throw new Error(e.detail || 'Erreur Kokoro'); } |
| const blob = new Blob([await res.arrayBuffer()], { type: 'audio/wav' }); |
| const genSecs = (performance.now() - t0) / 1000; |
| setPlayer(blob); |
| $('player').play().catch(() => {}); |
| pb.classList.remove('spin'); pb.style.width = '100%'; |
| $('waveInd').classList.add('off'); |
| $('mClient').textContent = genSecs.toFixed(1) + 's'; |
| } catch(e) { |
| showMsg('err', e.message); |
| pb.classList.remove('spin'); pb.style.width = '0%'; |
| } |
| busy = false; setPlayBtns(false); |
| } |
| |
| async function generateChatterbox() { |
| if (busy) return; |
| const text = $('textIn').value.trim(); |
| if (!text) { showMsg('err', 'Entre du texte d\'abord.'); return; } |
| hideMsg(); |
| busy = true; setPlayBtns(true); |
| $('results').classList.add('show'); |
| $('player').parentElement.style.display = 'none'; |
| $('waveInd').classList.remove('off'); |
| $('pbarWrap').classList.add('show'); |
| const pb = $('pbar'); pb.style.width = ''; pb.classList.add('spin'); |
| resetMetrics(); |
| const t0 = performance.now(); |
| try { |
| const fd = new FormData(); |
| fd.append('text', text); |
| fd.append('exaggeration', $('cbExag').value); |
| fd.append('cfg_weight', $('cbCfg').value); |
| fd.append('temperature', $('cbTemp').value); |
| const cbSeed = $('cbSeed').value.trim(); if (cbSeed) fd.append('seed', cbSeed); |
| const refFile = $('cbRefWav').files[0]; |
| if (refFile) fd.append('ref_wav', refFile); |
| const res = await fetch('/generate/chatterbox', { method: 'POST', body: fd }); |
| if (!res.ok) { const e = await res.json(); throw new Error(e.detail || 'Erreur Chatterbox'); } |
| const blob = new Blob([await res.arrayBuffer()], { type: 'audio/wav' }); |
| const genSecs = (performance.now() - t0) / 1000; |
| setPlayer(blob); |
| $('player').play().catch(() => {}); |
| pb.classList.remove('spin'); pb.style.width = '100%'; |
| $('waveInd').classList.add('off'); |
| $('mClient').textContent = genSecs.toFixed(1) + 's'; |
| } catch(e) { |
| showMsg('err', e.message); |
| pb.classList.remove('spin'); pb.style.width = '0%'; |
| } |
| busy = false; setPlayBtns(false); |
| } |
| |
| async function generateFish() { |
| if (busy) return; |
| const text = $('textIn').value.trim(); |
| if (!text) { showMsg('err', 'Entre du texte d\'abord.'); return; } |
| hideMsg(); |
| busy = true; setPlayBtns(true); |
| $('results').classList.add('show'); |
| $('player').parentElement.style.display = 'none'; |
| $('waveInd').classList.remove('off'); |
| $('pbarWrap').classList.add('show'); |
| const pb = $('pbar'); pb.style.width = ''; pb.classList.add('spin'); |
| resetMetrics(); |
| const t0 = performance.now(); |
| try { |
| const fd = new FormData(); |
| fd.append('text', text); |
| const refFile = $('fishRefWav') && $('fishRefWav').files[0]; |
| if (refFile) fd.append('ref_wav', refFile); |
| const refText = $('fishRefText') ? $('fishRefText').value.trim() : ''; |
| if (refText) fd.append('ref_text', refText); |
| fd.append('temperature', $('fishTemp') ? $('fishTemp').value : '0.8'); |
| fd.append('top_p', $('fishTopP') ? $('fishTopP').value : '0.8'); |
| fd.append('repetition_penalty', $('fishRep') ? $('fishRep').value : '1.1'); |
| fd.append('max_new_tokens', $('fishMaxTokens') ? $('fishMaxTokens').value : '1024'); |
| fd.append('chunk_length', $('fishChunk') ? $('fishChunk').value : '200'); |
| fd.append('latency', $('fishLatency') ? $('fishLatency').value : 'normal'); |
| fd.append('normalize', $('fishNormalize') ? $('fishNormalize').checked : true); |
| const seed = $('fishSeed')?.value ? parseInt($('fishSeed').value, 10) : null; |
| if (seed !== null && !isNaN(seed)) fd.append('seed', seed); |
| const res = await fetch('/generate/fish', { method: 'POST', body: fd }); |
| if (!res.ok) { const e = await res.json(); throw new Error(e.detail || 'Erreur Fish-Speech'); } |
| const blob = new Blob([await res.arrayBuffer()], { type: 'audio/wav' }); |
| const genSecs = (performance.now() - t0) / 1000; |
| setPlayer(blob); |
| $('player').play().catch(() => {}); |
| pb.classList.remove('spin'); pb.style.width = '100%'; |
| $('waveInd').classList.add('off'); |
| $('mClient').textContent = genSecs.toFixed(1) + 's'; |
| } catch(e) { |
| showMsg('err', e.message); |
| pb.classList.remove('spin'); pb.style.width = '0%'; |
| } |
| busy = false; setPlayBtns(false); |
| } |
| |
| async function generateVoxtral() { |
| if (busy) return; |
| const text = $('textIn').value.trim(); |
| if (!text) { showMsg('err', 'Entre du texte d\'abord.'); return; } |
| hideMsg(); |
| busy = true; setPlayBtns(true); |
| $('results').classList.add('show'); |
| $('player').parentElement.style.display = 'none'; |
| $('waveInd').classList.remove('off'); |
| $('pbarWrap').classList.add('show'); |
| const pb = $('pbar'); pb.style.width = ''; pb.classList.add('spin'); |
| resetMetrics(); |
| const t0 = performance.now(); |
| try { |
| const fd = new FormData(); |
| fd.append('text', text); |
| const refFile = $('voxtralRefWav') && $('voxtralRefWav').files[0]; |
| if (refFile) fd.append('ref_wav', refFile); |
| const refText = $('voxtralRefText') ? $('voxtralRefText').value.trim() : ''; |
| if (refText) fd.append('ref_text', refText); |
| const res = await fetch('/generate/voxtral', { method: 'POST', body: fd }); |
| if (!res.ok) { const e = await res.json(); throw new Error(e.detail || 'Erreur Voxtral'); } |
| const blob = new Blob([await res.arrayBuffer()], { type: 'audio/wav' }); |
| const genSecs = (performance.now() - t0) / 1000; |
| setPlayer(blob); |
| $('player').play().catch(() => {}); |
| pb.classList.remove('spin'); pb.style.width = '100%'; |
| $('waveInd').classList.add('off'); |
| $('mClient').textContent = genSecs.toFixed(1) + 's'; |
| } catch(e) { |
| showMsg('err', e.message); |
| pb.classList.remove('spin'); pb.style.width = '0%'; |
| } |
| busy = false; setPlayBtns(false); |
| } |
| |
| async function loadModel() { |
| const btn = $('loadBtn'); |
| const btnVis = $('loadBtnVis'); |
| |
| if ($('modelSelVis') && $('innerPanelQwen').style.display !== 'none') { |
| $('modelSel').value = $('modelSelVis').value; |
| } |
| btn.disabled = true; btn.textContent = 'Loading...'; |
| if (btnVis) { btnVis.disabled = true; btnVis.textContent = 'Loading...'; } |
| loadingModel = $('modelSel').value; |
| loadedModel = null; |
| setPlayBtns(true); |
| setPill('loading', 'loading...'); |
| try { |
| const fd = new FormData(); |
| fd.append('model_id', loadingModel); |
| const d = await fetch('/load', { method: 'POST', body: fd }).then(r => r.json()); |
| if (d.status === 'loaded' || d.status === 'already_loaded') { |
| loadedModel = loadingModel; |
| loadingModel = null; |
| lastRtf = loadedModel?.includes('1.7B') ? 3.5 : 4.0; |
| setPill('loaded', 'ready'); |
| if ($('loadBtnVis')) { $('loadBtnVis').disabled = false; $('loadBtnVis').textContent = '✓ Loaded'; } |
| updateEstimation(); |
| fetchStatus(); |
| } else { |
| loadingModel = null; |
| setPill('error', 'failed'); |
| } |
| } catch { |
| loadingModel = null; |
| setPill('error', 'error'); |
| } |
| btn.disabled = false; btn.textContent = 'Load'; |
| setPlayBtns(false); |
| } |
| |
| function setPill(state, label) { |
| $('mdot').className = 'mdot ' + state; |
| $('mtext').textContent = label; |
| updateModeRows(); |
| } |
| |
| function updateModeRows() { |
| function modelToRow(m) { |
| if (!m) return null; |
| if (m.includes('VoiceDesign')) return 'rowCustom'; |
| if (m.includes('CustomVoice')) return 'rowChar'; |
| return 'rowClone'; |
| } |
| const activeRow = modelToRow(loadingModel ?? loadedModel); |
| const isLoading = !!loadingModel; |
| for (const id of ['rowClone', 'rowChar', 'rowCustom']) { |
| const el = $(id); |
| el.classList.toggle('compatible', id === activeRow && !isLoading); |
| el.classList.toggle('loading', id === activeRow && isLoading); |
| el.classList.toggle('incompatible', activeRow !== null && id !== activeRow); |
| } |
| |
| const xtts = $('rowXtts'); |
| if (xtts && xtts.style.display !== 'none') { |
| xtts.classList.remove('incompatible', 'loading'); |
| xtts.classList.add('compatible'); |
| } |
| } |
| |
| |
| const ALL_MODE_MODELS = { |
| voice_clone: [ |
| { id: 'Qwen/Qwen3-TTS-12Hz-0.6B-Base', name: '0.6B Base', sub: 'Fastest · RTF ~4×' }, |
| { id: 'Qwen/Qwen3-TTS-12Hz-1.7B-Base', name: '1.7B Base', sub: 'Higher quality · RTF ~3.5×' }, |
| ], |
| custom: [ |
| { id: 'Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice', name: '0.6B CustomVoice', sub: 'Fastest' }, |
| { id: 'Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice', name: '1.7B CustomVoice', sub: 'Higher quality' }, |
| ], |
| voice_design: [ |
| { id: 'Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign', name: '1.7B VoiceDesign', sub: 'Describe any voice' }, |
| ], |
| }; |
| |
| let MODE_MODELS = ALL_MODE_MODELS; |
| const MODE_LABELS = { voice_clone: 'Clone', custom: 'Custom', voice_design: 'Voice Design' }; |
| let loaderMode = null; |
| let loaderSelectedModel = null; |
| let availableModels = []; |
| |
| function applyAvailableModels(available) { |
| availableModels = available || []; |
| const avail = new Set(availableModels); |
| |
| |
| const sel = $('modelSel'); |
| const prev = sel.value; |
| sel.innerHTML = ''; |
| for (const [mode, models] of Object.entries(ALL_MODE_MODELS)) { |
| for (const m of models) { |
| if (!avail.has(m.id)) continue; |
| const opt = document.createElement('option'); |
| opt.value = m.id; |
| opt.textContent = m.name; |
| sel.appendChild(opt); |
| } |
| } |
| |
| if (avail.has(prev)) sel.value = prev; |
| else if (sel.options.length > 0) sel.value = sel.options[0].value; |
| |
| |
| MODE_MODELS = {}; |
| for (const [mode, models] of Object.entries(ALL_MODE_MODELS)) { |
| MODE_MODELS[mode] = models.filter(m => avail.has(m.id)); |
| } |
| |
| |
| const modeRows = { voice_clone: 'rowClone', custom: 'rowChar', voice_design: 'rowCustom' }; |
| for (const [mode, rowId] of Object.entries(modeRows)) { |
| $(rowId).style.display = MODE_MODELS[mode].length > 0 ? '' : 'none'; |
| } |
| } |
| |
| function onModeRowClick(mode) { |
| const rowMap = { voice_clone: 'rowClone', custom: 'rowChar', voice_design: 'rowCustom' }; |
| if (!$(rowMap[mode]).classList.contains('incompatible')) return; |
| openModelLoader(mode); |
| } |
| |
| function openModelLoader(mode) { |
| loaderMode = mode; |
| const models = MODE_MODELS[mode]; |
| loaderSelectedModel = models[0].id; |
| |
| const grid = $('loaderGrid'); |
| grid.innerHTML = ''; |
| models.forEach(m => { |
| const card = document.createElement('div'); |
| card.className = 'lcard' + (m.id === loaderSelectedModel ? ' sel' : ''); |
| card.dataset.id = m.id; |
| card.innerHTML = `<div class="lc-name">${m.name}</div><div class="lc-sub">${m.sub}</div>`; |
| card.onclick = () => { |
| loaderSelectedModel = m.id; |
| grid.querySelectorAll('.lcard').forEach(c => c.classList.toggle('sel', c.dataset.id === m.id)); |
| }; |
| grid.appendChild(card); |
| }); |
| |
| $('loaderModeLabel').textContent = MODE_LABELS[mode] || mode; |
| $('loaderOv').classList.add('open'); |
| } |
| |
| function closeLoader(e) { |
| if (e && e.target !== $('loaderOv')) return; |
| $('loaderOv').classList.remove('open'); |
| } |
| |
| async function loadFromSheet() { |
| if (!loaderSelectedModel) return; |
| $('loaderOv').classList.remove('open'); |
| $('modelSel').value = loaderSelectedModel; |
| await loadModel(); |
| } |
| |
| |
| function populateSpeakers(list) { |
| const sel = $('speakerId'); |
| const prev = sel.value; |
| sel.innerHTML = '<option value="" disabled hidden>Select speaker...</option>'; |
| let ids = Array.isArray(list) ? list : Object.keys(list || {}); |
| const defaultMap = new Map(defaultSpeakers.map(s => [s.id.toLowerCase(), s])); |
| if (!ids.length) ids = defaultSpeakers.map(s => s.id); |
| |
| |
| ids = ids.map(id => defaultMap.get(String(id).toLowerCase())?.id ?? id); |
| |
| |
| if (prev && !ids.some(id => id.toLowerCase() === prev.toLowerCase())) ids = [prev, ...ids]; |
| |
| |
| ids.sort((a, b) => { |
| const aInfo = defaultMap.get(String(a).toLowerCase()); |
| const bInfo = defaultMap.get(String(b).toLowerCase()); |
| const aEn = aInfo && aInfo.desc.includes('English') ? 0 : 1; |
| const bEn = bInfo && bInfo.desc.includes('English') ? 0 : 1; |
| if (aEn !== bEn) return aEn - bEn; |
| return String(a).localeCompare(String(b)); |
| }); |
| |
| ids.forEach(id => { |
| const o = document.createElement('option'); |
| o.value = id; |
| const info = defaultMap.get(String(id).toLowerCase()); |
| const label = info ? `${info.id} — ${info.desc}` : id; |
| o.textContent = label; |
| sel.appendChild(o); |
| }); |
| |
| if (prev) { |
| sel.value = prev; |
| } else { |
| const defaultId = 'Aiden'; |
| sel.value = ids.find(id => id.toLowerCase() === defaultId.toLowerCase()) ?? ids[0] ?? ''; |
| } |
| } |
| |
| |
| async function loadMics() { |
| if (!navigator.mediaDevices?.enumerateDevices) return; |
| try { |
| const devs = await navigator.mediaDevices.enumerateDevices(); |
| const inputs = devs.filter(d => d.kind === 'audioinput'); |
| const sel = $('micSel'); |
| const prevSelection = recDeviceId || sel.value; |
| sel.innerHTML = ''; |
| let defaultId = null; |
| inputs.forEach((d, i) => { |
| const o = document.createElement('option'); |
| o.value = d.deviceId; |
| o.textContent = d.label || `Microphone ${i + 1}`; |
| sel.appendChild(o); |
| const lbl = (d.label || '').toLowerCase(); |
| if (!defaultId && (d.deviceId === 'default' || lbl.includes('default'))) { |
| defaultId = d.deviceId; |
| } |
| }); |
| sel.onchange = () => { recDeviceId = sel.value; }; |
| if (prevSelection && inputs.some(d => d.deviceId === prevSelection)) { |
| recDeviceId = prevSelection; |
| sel.value = prevSelection; |
| } else if (defaultId) { |
| recDeviceId = defaultId; |
| sel.value = defaultId; |
| } else if (inputs.length) { |
| recDeviceId = inputs[0].deviceId; |
| sel.value = inputs[0].deviceId; |
| } |
| } catch {} |
| } |
| |
| |
| function pickRef(input) { const f = input.files[0]; if (f) setRef(f); } |
| function setRef(f) { |
| clearPresetSelection(); |
| refFile = f; |
| $('refLabel').textContent = f.name; |
| $('refChip').classList.add('has'); |
| const prev = $('recPreview'); |
| if (prev.src) URL.revokeObjectURL(prev.src); |
| prev.src = URL.createObjectURL(f); |
| prev.style.display = 'block'; |
| transcribeRef(); |
| } |
| |
| |
| function toggleRec() { if (recActive) stopRec(); else startRec(); } |
| |
| async function startRec() { |
| if (!navigator.mediaDevices?.getUserMedia) { |
| showMsg('err', 'Recording not supported. Use HTTPS or localhost.'); |
| return; |
| } |
| |
| if (!window.isSecureContext) { |
| const { hostname, port } = window.location; |
| if (hostname !== 'localhost' && hostname !== '127.0.0.1') { |
| showMsg('err', `Recording requires HTTPS or localhost. Try http://localhost:${port}/`); |
| return; |
| } |
| } |
| hideMsg(); |
| recChunks = []; recPeak = 0; |
| recHasSignal = false; recSignalTs = null; recSignalCount = 0; |
| recActive = true; |
| $('micBtn').classList.add('rec'); |
| $('recStatus').textContent = 'Starting mic…'; |
| $('recBarWrap').style.display = 'block'; |
| $('recPreview').style.display = 'none'; |
| |
| try { |
| const constraints = { |
| audio: { channelCount: 1, echoCancellation: false, noiseSuppression: false, autoGainControl: false } |
| }; |
| if (recDeviceId) constraints.audio.deviceId = { exact: recDeviceId }; |
| recStream = await navigator.mediaDevices.getUserMedia(constraints); |
| await loadMics(); |
| |
| recCtx = new (window.AudioContext || window.webkitAudioContext)({ latencyHint: 'interactive' }); |
| if (recCtx.state === 'suspended') await recCtx.resume(); |
| |
| recSource = recCtx.createMediaStreamSource(recStream); |
| recAnalyser = recCtx.createAnalyser(); |
| recAnalyser.fftSize = 2048; |
| recGain = recCtx.createGain(); |
| recGain.gain.value = 0.0001; |
| |
| recProc = recCtx.createScriptProcessor(4096, 1, 1); |
| recProc.onaudioprocess = e => { |
| if (!recActive) return; |
| const inp = e.inputBuffer.getChannelData(0); |
| const copy = new Float32Array(inp.length); |
| for (let i = 0; i < inp.length; i++) { |
| copy[i] = inp[i]; |
| const a = Math.abs(inp[i]); |
| if (a > recPeak) recPeak = a; |
| } |
| recChunks.push(copy); |
| }; |
| |
| recSource.connect(recAnalyser); |
| recAnalyser.connect(recProc); |
| recProc.connect(recGain); |
| recGain.connect(recCtx.destination); |
| |
| recMeterBuf = new Uint8Array(recAnalyser.fftSize); |
| |
| const tick = () => { |
| if (!recActive) return; |
| recAnalyser.getByteTimeDomainData(recMeterBuf); |
| let peak = 0; |
| for (let i = 0; i < recMeterBuf.length; i++) { |
| const v = Math.abs(recMeterBuf[i] - 128); |
| if (v > peak) peak = v; |
| } |
| const norm = Math.min(1, peak / 64); |
| $('recBar').style.width = Math.round(norm * 100) + '%'; |
| |
| if (norm > 0.02) recSignalCount++; |
| else recSignalCount = 0; |
| if (!recHasSignal && recSignalCount >= 4) { |
| recHasSignal = true; |
| recSignalTs = performance.now(); |
| } |
| |
| $('recStatus').textContent = recHasSignal ? 'Recording…' : 'Waiting for signal…'; |
| if (recHasSignal) { |
| const s = Math.floor((performance.now() - recSignalTs) / 1000); |
| $('recTime').textContent = Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); |
| } |
| recMeterRaf = requestAnimationFrame(tick); |
| }; |
| recMeterRaf = requestAnimationFrame(tick); |
| |
| } catch (e) { |
| showMsg('err', 'Mic failed: ' + e.message); |
| cleanupRec(); |
| } |
| } |
| |
| async function stopRec() { |
| recActive = false; |
| if (recMeterRaf) { cancelAnimationFrame(recMeterRaf); recMeterRaf = null; } |
| $('micBtn').classList.remove('rec'); |
| $('recStatus').textContent = ''; |
| $('recTime').textContent = ''; |
| $('recBar').style.width = '0%'; |
| $('recBarWrap').style.display = 'none'; |
| |
| if (!recChunks.length) { |
| showMsg('err', 'No audio captured.'); |
| cleanupRec(); return; |
| } |
| |
| try { |
| const total = recChunks.reduce((s, c) => s + c.length, 0); |
| const buf = recCtx.createBuffer(1, total, recCtx.sampleRate); |
| const ch = buf.getChannelData(0); |
| let off = 0; |
| for (const c of recChunks) { ch.set(c, off); off += c.length; } |
| |
| |
| const rawWav = bufToWav(buf); |
| const prev = $('recPreview'); |
| if (prev.src) URL.revokeObjectURL(prev.src); |
| prev.src = URL.createObjectURL(rawWav); |
| prev.style.display = 'block'; |
| |
| |
| let out = buf; |
| if (buf.sampleRate !== REC_SR) { |
| const len = Math.ceil(buf.duration * REC_SR); |
| const offCtx = new OfflineAudioContext(1, len, REC_SR); |
| const src = offCtx.createBufferSource(); |
| src.buffer = buf; src.connect(offCtx.destination); src.start(0); |
| out = await offCtx.startRendering(); |
| } |
| const wav = bufToWav(out); |
| clearPresetSelection(); |
| refFile = new File([wav], 'recorded.wav', { type: 'audio/wav' }); |
| $('refLabel').textContent = 'recorded.wav'; |
| $('refChip').classList.add('has'); |
| transcribeRef(); |
| } catch (e) { |
| showMsg('err', 'Recording failed: ' + e.message); |
| } |
| cleanupRec(); |
| } |
| |
| function cleanupRec() { |
| if (recStream) recStream.getTracks().forEach(t => t.stop()); |
| recStream = null; recActive = false; |
| if (recMeterRaf) { cancelAnimationFrame(recMeterRaf); recMeterRaf = null; } |
| recMeterBuf = null; |
| if (recAnalyser) recAnalyser.disconnect(); |
| if (recProc) recProc.disconnect(); |
| if (recGain) recGain.disconnect(); |
| if (recSource) recSource.disconnect(); |
| if (recCtx) recCtx.close().catch(() => {}); |
| recAnalyser = null; recProc = null; recGain = null; recSource = null; recCtx = null; |
| $('micBtn').classList.remove('rec'); |
| } |
| |
| function bufToWav(buffer) { |
| const pcm = buffer.getChannelData(0); |
| const len = pcm.length, sr = buffer.sampleRate; |
| const ab = new ArrayBuffer(44 + len * 2); |
| const v = new DataView(ab); |
| const ws = (o, s) => [...s].forEach((c, i) => v.setUint8(o + i, c.charCodeAt(0))); |
| ws(0, 'RIFF'); v.setUint32(4, 36 + len * 2, true); |
| ws(8, 'WAVE'); ws(12, 'fmt '); |
| v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true); |
| v.setUint32(24, sr, true); v.setUint32(28, sr * 2, true); |
| v.setUint16(32, 2, true); v.setUint16(34, 16, true); |
| ws(36, 'data'); v.setUint32(40, len * 2, true); |
| let off = 44; |
| for (let i = 0; i < len; i++, off += 2) { |
| const s = Math.max(-1, Math.min(1, pcm[i])); |
| v.setInt16(off, s < 0 ? s * 0x8000 : s * 0x7fff, true); |
| } |
| return new Blob([ab], { type: 'audio/wav' }); |
| } |
| |
| |
| async function warmAudio() { |
| if (actx) return; |
| try { await initAudio(24000); } catch {} |
| } |
| |
| async function initAudio(sr) { |
| rawPcmSr = sr || 24000; |
| pcmQueue = []; rawPcmParts = []; |
| chunkQ = Promise.resolve(); dlBlob = null; |
| firstChunkAt = null; firstAudioAt = null; lastBufS = 0; |
| |
| if (actx) { |
| if (actx.state === 'suspended') await actx.resume(); |
| return; |
| } |
| actx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: rawPcmSr }); |
| sproc = actx.createScriptProcessor(256, 0, 1); |
| sproc.onaudioprocess = e => { |
| const out = e.outputBuffer.getChannelData(0); |
| let i = 0, wrote = false; |
| while (i < out.length) { |
| if (!pcmQueue.length) { out.fill(0, i); break; } |
| const seg = pcmQueue[0]; |
| const take = Math.min(out.length - i, seg.data.length - seg.pos); |
| out.set(seg.data.subarray(seg.pos, seg.pos + take), i); |
| seg.pos += take; i += take; wrote = true; |
| if (seg.pos >= seg.data.length) pcmQueue.shift(); |
| } |
| if (wrote && firstAudioAt == null) { |
| firstAudioAt = performance.now(); |
| pushClientMetrics(); |
| } |
| }; |
| if (actx.state === 'suspended') await actx.resume(); |
| sproc.connect(actx.destination); |
| } |
| |
| function parseWav(bytes) { |
| const v = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); |
| const sr = v.getUint32(24, true); |
| let off = 12; |
| while (off + 8 <= bytes.length) { |
| const id = String.fromCharCode(bytes[off], bytes[off+1], bytes[off+2], bytes[off+3]); |
| const size = v.getUint32(off + 4, true); |
| if (id === 'data') { |
| const raw = bytes.slice(off + 8, off + 8 + size); |
| const i16 = new Int16Array(raw.buffer, raw.byteOffset, raw.byteLength / 2); |
| const f32 = new Float32Array(i16.length); |
| for (let i = 0; i < i16.length; i++) f32[i] = i16[i] / 32768.0; |
| return { pcm: f32, rawPcm: raw, sr }; |
| } |
| off += 8 + size; |
| } |
| return null; |
| } |
| |
| function enqueueChunk(b64) { |
| chunkQ = chunkQ.then(() => { |
| if (!actx) return; |
| const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0)); |
| const p = parseWav(bytes); |
| if (!p) return; |
| rawPcmParts.push(p.rawPcm); |
| pcmQueue.push({ data: p.pcm, pos: 0 }); |
| if (firstChunkAt == null) firstChunkAt = performance.now(); |
| lastBufS = pcmQueue.reduce((s, seg) => s + (seg.data.length - seg.pos), 0) / rawPcmSr; |
| pushClientMetrics(); |
| }); |
| } |
| |
| function buildFinalWav() { |
| if (!rawPcmParts.length) return null; |
| const totalPcm = rawPcmParts.reduce((s, p) => s + p.length, 0); |
| const ab = new ArrayBuffer(44 + totalPcm); |
| const v = new DataView(ab); |
| const ws = (o, s) => [...s].forEach((c, i) => v.setUint8(o + i, c.charCodeAt(0))); |
| ws(0, 'RIFF'); v.setUint32(4, 36 + totalPcm, true); |
| ws(8, 'WAVE'); ws(12, 'fmt '); |
| v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true); |
| v.setUint32(24, rawPcmSr, true); v.setUint32(28, rawPcmSr * 2, true); |
| v.setUint16(32, 2, true); v.setUint16(34, 16, true); |
| ws(36, 'data'); v.setUint32(40, totalPcm, true); |
| const out = new Uint8Array(ab, 44); |
| let offset = 0; |
| for (const part of rawPcmParts) { out.set(part, offset); offset += part.length; } |
| return new Blob([ab], { type: 'audio/wav' }); |
| } |
| |
| |
| async function generate(mode) { |
| if (busy) return; |
| |
| const isVD = loadedModel?.includes('VoiceDesign'); |
| const isCV = loadedModel?.includes('CustomVoice'); |
| if (mode === 'voice_design' && loadedModel && !isVD) { |
| showMsg('warn', 'Voice design requires the 1.7B-VoiceDesign model.', |
| 'Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign', mode); return; |
| } |
| if (mode === 'custom' && loadedModel && !isCV) { |
| showMsg('warn', 'Custom speaker requires a CustomVoice model.', |
| 'Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice', mode); return; |
| } |
| if (mode === 'voice_clone' && loadedModel && (isVD || isCV)) { |
| showMsg('warn', 'Voice clone needs a Base model.', |
| 'Qwen/Qwen3-TTS-12Hz-0.6B-Base', mode); return; |
| } |
| if (mode === 'voice_clone' && !refFile && !presetRefId) { |
| showMsg('err', 'Add reference audio first.'); |
| return; |
| } |
| if (mode === 'custom' && !$('speakerId').value) { showMsg('err', 'Select a speaker first.'); return; } |
| hideMsg(); |
| $('queueBar').className = 'queue-bar'; |
| |
| busy = true; |
| setPlayBtns(true); |
| |
| $('results').classList.add('show'); |
| $('player').parentElement.style.display = 'none'; |
| $('waveInd').classList.remove('off'); |
| $('pbarWrap').classList.add('show'); |
| const pb = $('pbar'); |
| pb.style.width = ''; pb.classList.add('spin'); |
| resetMetrics(); |
| |
| const fd = new FormData(); |
| fd.append('text', $('textIn').value); |
| fd.append('mode', mode); |
| fd.append('language', $('langSel').value); |
| fd.append('temperature', $('tempSl').value); |
| fd.append('top_k', $('topkSl').value); |
| fd.append('repetition_penalty', $('repSl').value); |
| |
| if (mode === 'voice_clone') { |
| if (presetRefId) { |
| fd.append('ref_preset', presetRefId); |
| } else if (refFile) { |
| fd.append('ref_audio', refFile); |
| } |
| fd.append('xvec_only', xvecOnly ? 'true' : 'false'); |
| if (!xvecOnly) fd.append('ref_text', $('refTextIn').value); |
| } else if (mode === 'custom') { |
| fd.append('speaker', $('speakerId').value); |
| fd.append('instruct', $('customInstr').value); |
| } else { |
| fd.append('instruct', $('voiceInstr').value); |
| } |
| |
| try { |
| if (genMode === 'stream') { |
| fd.append('chunk_size', $('chunkSl').value); |
| await runStream(fd); |
| } else { |
| await runNonStream(fd); |
| } |
| } catch (e) { |
| showMsg('err', 'Generation failed: ' + e.message); |
| pb.classList.remove('spin'); pb.style.width = '0%'; |
| } |
| |
| busy = false; |
| setPlayBtns(false); |
| } |
| |
| async function runStream(fd) { |
| clientT0 = performance.now(); |
| firstChunkAt = null; firstAudioAt = null; lastBufS = 0; firstServerWall = null; |
| |
| const res = await fetch('/generate/stream', { method: 'POST', body: fd }); |
| if (!res.ok) { const e = await res.json(); throw new Error(e.detail || 'Request failed'); } |
| |
| const reader = res.body.getReader(); |
| const dec = new TextDecoder(); |
| let buf = '', audioInited = false; |
| |
| while (true) { |
| const { done, value } = await reader.read(); |
| if (done) break; |
| buf += dec.decode(value, { stream: true }); |
| const lines = buf.split('\n'); |
| buf = lines.pop(); |
| |
| for (const line of lines) { |
| if (!line.startsWith('data: ')) continue; |
| const d = JSON.parse(line.slice(6)); |
| |
| if (d.type === 'queued') { |
| const bar = $('queueBar'); |
| bar.textContent = d.position === 1 |
| ? 'Waiting... (1 request ahead of you)' |
| : `Waiting... (${d.position} requests ahead of you)`; |
| bar.classList.add('show'); |
| |
| } else if (d.type === 'chunk') { |
| $('queueBar').className = 'queue-bar'; |
| if (!audioInited) { await initAudio(d.sample_rate); audioInited = true; } |
| |
| if (firstChunkAt == null && d.elapsed_ms != null) firstServerWall = d.elapsed_ms; |
| pushMetrics(d.ttfa_ms, d.rtf, d.total_audio_s, d.voice_clone_ms); |
| enqueueChunk(d.audio_b64); |
| |
| } else if (d.type === 'done') { |
| $('queueBar').className = 'queue-bar'; |
| pushMetrics(d.ttfa_ms, d.rtf, d.total_audio_s, d.voice_clone_ms); |
| await chunkQ; |
| setDone(d.total_ms / 1000, d.total_audio_s); |
| const blob = buildFinalWav(); |
| if (blob) setPlayer(blob); |
| |
| } else if (d.type === 'error') { |
| $('queueBar').className = 'queue-bar'; |
| throw new Error(d.message); |
| } |
| } |
| } |
| } |
| |
| async function runNonStream(fd) { |
| $('waveInd').classList.add('off'); |
| const res = await fetch('/generate', { method: 'POST', body: fd }); |
| if (!res.ok) { const e = await res.json(); throw new Error(e.detail || 'Request failed'); } |
| const d = await res.json(); |
| const m = d.metrics; |
| pushMetrics(m.total_ms, m.rtf, m.audio_duration_s, m.voice_clone_ms); |
| setDone(m.total_ms / 1000, m.audio_duration_s); |
| const bytes = Uint8Array.from(atob(d.audio_b64), c => c.charCodeAt(0)); |
| const blob = new Blob([bytes], { type: 'audio/wav' }); |
| setPlayer(blob); |
| $('player').play().catch(() => {}); |
| } |
| |
| |
| function resetMetrics() { |
| ['mTTFA', 'mClient', 'mRTF', 'mDur', 'mBuf', 'mClone'].forEach(id => $(id).innerHTML = '—'); |
| $('mCloneWrap').style.display = 'none'; |
| } |
| function pushMetrics(ttfa, rtf, dur, cloneMs) { |
| if (ttfa != null) $('mTTFA').textContent = Math.round(ttfa) + 'ms'; |
| if (rtf != null) { $('mRTF').textContent = rtf.toFixed(2) + 'x'; if (rtf > 0) lastRtf = rtf; } |
| if (dur != null) $('mDur').textContent = dur.toFixed(1) + 's'; |
| if (cloneMs != null) { |
| $('mClone').textContent = Math.round(cloneMs) + 'ms'; |
| $('mCloneWrap').style.display = ''; |
| } |
| } |
| function pushClientMetrics() { |
| |
| if (firstAudioAt != null) |
| $('mClient').textContent = Math.round(firstAudioAt - clientT0) + 'ms'; |
| else if (firstChunkAt != null) |
| $('mClient').textContent = Math.round(firstChunkAt - clientT0) + 'ms'; |
| |
| if (lastBufS != null) |
| $('mBuf').textContent = lastBufS.toFixed(2) + 's'; |
| } |
| function setDone(actualGenSecs, actualAudioSecs) { |
| $('waveInd').classList.add('off'); |
| const pb = $('pbar'); |
| pb.classList.remove('spin'); pb.style.width = '100%'; |
| if (actualGenSecs != null) updateEstimation(actualGenSecs, actualAudioSecs); |
| } |
| function setPlayer(blob) { |
| dlBlob = blob; |
| $('player').src = URL.createObjectURL(blob); |
| $('player').parentElement.style.display = ''; |
| } |
| |
| |
| let pendingMode = null; |
| function showMsg(type, text, switchModel, mode) { |
| const el = $('msgBar'); |
| el.className = 'msg-bar show ' + type; |
| if (switchModel) { |
| pendingMode = mode; |
| el.innerHTML = '<span>' + text + '</span>' + |
| '<button class="sw-btn" onclick="switchAndGen(\'' + switchModel + '\')">Load & generate</button>'; |
| } else { |
| el.textContent = text; |
| } |
| } |
| function hideMsg() { $('msgBar').className = 'msg-bar'; } |
| |
| async function switchAndGen(target) { |
| if (busy) return; |
| const mode = pendingMode; |
| busy = true; hideMsg(); setPlayBtns(true); |
| $('modelSel').value = target; |
| try { await loadModel(); } finally { busy = false; } |
| setPlayBtns(false); |
| generate(mode); |
| } |
| |
| |
| function dlAudio() { |
| if (!dlBlob) return; |
| const a = document.createElement('a'); |
| a.href = URL.createObjectURL(dlBlob); |
| a.download = 'generated_speech.wav'; |
| a.click(); |
| URL.revokeObjectURL(a.href); |
| } |
| |
| |
| function switchTab(tab) { |
| document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab)); |
| $('panelTTS').style.display = tab === 'tts' ? '' : 'none'; |
| $('panelBook').style.display = tab === 'book' ? '' : 'none'; |
| } |
| |
| function applyFishPreset(seed) { |
| if (seed) { |
| bookSeed = parseInt(seed); |
| bookFishRefFile = null; |
| const inp = document.getElementById('bookFishRef'); |
| if (inp) inp.value = ''; |
| } |
| } |
| |
| function updateQwenBookWarning() { |
| const warn = $('qwenBookWarning'); |
| if (!warn) return; |
| const needsQwen = ['qwen_design','qwen_custom','qwen_clone'].includes(bookEngine); |
| const missingModel = needsQwen && !loadedModel; |
| const wrongModel = bookEngine === 'qwen_design' && loadedModel && !loadedModel.includes('VoiceDesign'); |
| warn.style.display = (missingModel || wrongModel) ? '' : 'none'; |
| if (wrongModel) warn.innerHTML = '⚠️ Voice Design nécessite le modèle <b>1.7B-VoiceDesign</b>. Chargez-le dans Studio → Qwen3 → Voice Design → LOAD.'; |
| else warn.innerHTML = '⚠️ Voice Design nécessite un modèle Qwen3 chargé. Allez dans <b>Studio → Qwen3-TTS → Voice Design → LOAD</b> d\'abord.'; |
| } |
| |
| function switchBookEngine(engine) { |
| bookEngine = engine; |
| const engines = ['kokoro', 'qwen_design', 'fish', 'chatterbox', 'f5', 'qwen_custom', 'qwen_clone']; |
| engines.forEach(e => { $(`bvs-${e}`).style.display = e === engine ? '' : 'none'; }); |
| updateQwenBookWarning(); |
| document.querySelectorAll('.bvs-tab').forEach((btn, i) => { |
| btn.classList.toggle('active', engines[i] === engine); |
| }); |
| |
| if (engine === 'qwen_custom') { |
| const src = $('speakerId'); |
| const dst = $('bookQwenSpeaker'); |
| if (src.options.length > 1 && dst.options.length <= 1) { |
| dst.innerHTML = src.innerHTML; |
| dst.value = src.value; |
| } |
| } |
| } |
| |
| |
| function updateEstimation(actualGenSecs, actualAudioSecs) { |
| const text = $('textIn').value || ''; |
| const words = text.trim().split(/\s+/).filter(Boolean).length; |
| const el = $('estBar'); |
| if (!words) { el.textContent = ''; return; } |
| const MAX_AUDIO_S = 1800 / 12; |
| const rawAudio = words / 2.5; |
| const audioSecs = Math.min(rawAudio, MAX_AUDIO_S); |
| const genSecs = audioSecs / lastRtf; |
| const capped = rawAudio > MAX_AUDIO_S; |
| let txt = `~${audioSecs.toFixed(0)}s audio · ~${genSecs.toFixed(0)}s génération`; |
| if (capped) txt += ' ⚠ texte trop long (max ~150s)'; |
| if (actualGenSecs != null) |
| txt += ` — réel : ${actualAudioSecs.toFixed(1)}s audio en ${actualGenSecs.toFixed(1)}s`; |
| el.textContent = txt; |
| } |
| |
| |
| const NON_CONTENT_TITLES = new Set([ |
| 'acknowledgements','acknowledgments','remerciements','preface','préface', |
| 'foreword','avant-propos','dedication','dédicace','about the author', |
| 'à propos de l\'auteur','index','bibliography','bibliographie','notes', |
| 'appendix','annexe','table of contents','table des matières','contents', |
| 'sommaire','references','références','also by','copyright','credits', |
| 'permissions','colophon', |
| ]); |
| |
| function isNonContent(title) { |
| const t = title.toLowerCase().trim(); |
| return NON_CONTENT_TITLES.has(t) || |
| t.startsWith('acknowledg') || t.startsWith('appendix') || t.startsWith('annexe') || |
| t.startsWith('about the') || t.startsWith('à propos'); |
| } |
| |
| function formatChapterTitle(title) { |
| const t = title.trim(); |
| if (/^\d{1,3}\.?\s*$/.test(t)) return 'Chapitre ' + t.replace('.', ''); |
| if (/^[IVXLCDMivxlcdm]+\.?\s*$/.test(t) && t.length < 8) return 'Chapitre ' + t.replace('.', '').toUpperCase(); |
| return title; |
| } |
| |
| function isChapterHeading(line) { |
| const t = line.trim(); |
| if (!t || t.length > 150) return false; |
| |
| |
| if (/^#{1,3}\s+\S/.test(t)) return true; |
| |
| |
| if (/^(chapter|chapitre|kapitel|cap[íi]tulo|capitolo|hoofdstuk|глава)\s+/i.test(t)) return true; |
| if (/^(part|partie|teil|parte|partie)\s+\S/i.test(t)) return true; |
| |
| |
| if (/^(prologue|épilogue|epilogue|interlude|intermède|prélude|prelude|conclusion|introduction|afterword|foreword|postface|avant-propos|dédicace|acknowledgements?|remerciements?|sommaire|contents?|index)\s*$/i.test(t)) return true; |
| |
| |
| if (/^(chapter|chapitre)\s+(one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety|hundred|un|deux|trois|quatre|cinq|six|sept|huit|neuf|dix|onze|douze|treize|quatorze|quinze|seize|dix-sept|dix-huit|dix-neuf|vingt|trente|quarante|cinquante)\b/i.test(t)) return true; |
| |
| |
| if (/^[IVXLCDMivxlcdm]+\.?\s*$/.test(t) && t.length < 12) return true; |
| |
| |
| if (/^\d{1,3}[\.:]?\s*$/.test(t)) return true; |
| |
| |
| if (/^\d{1,3}[\.\-–—:]\s*\S/.test(t) && t.length <= 100) return true; |
| |
| |
| if (t.length >= 3 && t.length <= 80 && t === t.toUpperCase() && |
| /[A-Z]{3,}/.test(t) && !/^\d/.test(t) && !/^[^A-Z]+$/.test(t)) return true; |
| |
| return false; |
| } |
| |
| function isSectionSeparator(line) { |
| const t = line.trim(); |
| return /^[\-\*~_=]{3,}$/.test(t) || /^\*\s*\*\s*\*$/.test(t) || /^#\s*#\s*#$/.test(t); |
| } |
| |
| async function loadBookFile(input) { |
| const file = input.files[0]; |
| if (!file) return; |
| bookTitle = file.name.replace(/\.[^.]+$/, ''); |
| $('bookInfo').textContent = 'Extraction en cours...'; |
| try { |
| if (file.name.endsWith('.epub')) { |
| const chapters = await extractEpubText(file); |
| if (chapters.length > 0) { |
| bookChapters = chapters.map((c, i) => { |
| const wordCount = c.text.split(/\s+/).filter(Boolean).length; |
| return { |
| index: i, |
| title: c.title || `Section ${i + 1}`, |
| text: c.text, |
| isContent: !isNonContent(c.title || '') && wordCount >= 20, |
| trimmedText: null, status: 'pending', blob: null, |
| }; |
| }); |
| |
| const firstRealIdx = bookChapters.findIndex(ch => |
| /^(chapter|chapitre|part[ie]*|prologue|épilogue|\d+\.?\s*$|[IVX]{1,6}\.?\s*$)/i.test(ch.title.trim()) |
| ); |
| if (firstRealIdx > 0) { |
| bookChapters.slice(0, firstRealIdx).forEach(ch => { ch.isContent = false; }); |
| } |
| |
| $('bookText').value = chapters.map(c => |
| (c.title ? c.title + '\n' + '─'.repeat(Math.min(c.title.length, 40)) + '\n\n' : '') + c.text |
| ).join('\n\n---\n\n'); |
| renderChapterList(); |
| $('bookInfo').textContent = `${bookChapters.length} chapitres détectés (EPUB natif)`; |
| return; |
| } |
| } |
| let text = ''; |
| if (file.name.endsWith('.pdf')) { |
| text = await extractPdfText(file); |
| } else { |
| text = await file.text(); |
| } |
| $('bookText').value = text; |
| parseBook(); |
| } catch (e) { |
| $('bookInfo').textContent = 'Erreur: ' + e.message; |
| } |
| } |
| |
| async function extractPdfText(file) { |
| if (!window.pdfjsLib) throw new Error('PDF.js non chargé'); |
| pdfjsLib.GlobalWorkerOptions.workerSrc = |
| 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js'; |
| const ab = await file.arrayBuffer(); |
| const pdf = await pdfjsLib.getDocument({ data: ab }).promise; |
| let text = ''; |
| for (let i = 1; i <= pdf.numPages; i++) { |
| const page = await pdf.getPage(i); |
| const content = await page.getTextContent(); |
| |
| const lines = {}; |
| for (const item of content.items) { |
| const y = Math.round(item.transform[5]); |
| if (!lines[y]) lines[y] = []; |
| lines[y].push(item.str); |
| } |
| const sorted = Object.keys(lines).map(Number).sort((a, b) => b - a); |
| for (const y of sorted) text += lines[y].join(' ') + '\n'; |
| text += '\n'; |
| } |
| return text.trim(); |
| } |
| |
| async function extractEpubText(file) { |
| if (!window.JSZip) throw new Error('JSZip non chargé'); |
| const ab = await file.arrayBuffer(); |
| const zip = await JSZip.loadAsync(ab); |
| |
| |
| let opfPath = ''; |
| const containerFile = zip.file('META-INF/container.xml'); |
| if (containerFile) { |
| const xml = await containerFile.async('text'); |
| const match = xml.match(/full-path="([^"]+\.opf)"/i); |
| if (match) opfPath = match[1]; |
| } |
| if (!opfPath) { |
| opfPath = Object.keys(zip.files).find(f => f.endsWith('.opf')) || ''; |
| } |
| if (!opfPath) throw new Error('Fichier OPF introuvable dans l\'EPUB'); |
| |
| const opfDir = opfPath.includes('/') ? opfPath.slice(0, opfPath.lastIndexOf('/') + 1) : ''; |
| const opfContent = await zip.file(opfPath).async('text'); |
| const parser = new DOMParser(); |
| const opfDoc = parser.parseFromString(opfContent, 'text/xml'); |
| |
| |
| const manifest = {}; |
| opfDoc.querySelectorAll('manifest item').forEach(item => { |
| const href = item.getAttribute('href') || ''; |
| const id = item.getAttribute('id') || ''; |
| manifest[id] = opfDir + decodeURIComponent(href); |
| }); |
| |
| |
| const spine = []; |
| opfDoc.querySelectorAll('spine itemref').forEach(ref => { |
| const id = ref.getAttribute('idref'); |
| if (manifest[id]) spine.push(manifest[id]); |
| }); |
| |
| |
| const chapters = []; |
| for (const href of spine) { |
| const f = zip.file(href) || zip.file(href.replace(/^.*\//, '')); |
| if (!f) continue; |
| const html = await f.async('text'); |
| const doc = parser.parseFromString(html, 'text/html'); |
| doc.querySelectorAll('script,style,nav').forEach(el => el.remove()); |
| |
| |
| const hTag = doc.querySelector('h1, h2, h3'); |
| const titleTag = doc.querySelector('title'); |
| let title = (hTag?.textContent || titleTag?.textContent || '').trim(); |
| title = title.replace(/^\d+[\.\-]\s*/, '').slice(0, 120).trim(); |
| |
| const body = doc.body?.textContent || ''; |
| const text = body.replace(/\s{3,}/g, '\n\n').trim(); |
| const wordCount = text.split(/\s+/).filter(Boolean).length; |
| |
| const titleLow = title.toLowerCase(); |
| const isNonContent = wordCount < 80 || |
| /^(table|copyright|droits?|couverture?|cover|colophon|about|à propos|remerciements?|acknowledgment|dedication|dédicace|sommaire|contents?)/.test(titleLow); |
| if (!isNonContent) chapters.push({ title, text }); |
| } |
| return chapters; |
| } |
| |
| function parseBook() { |
| let raw = $('bookText').value.trim(); |
| if (!raw) { $('bookInfo').textContent = 'Colle du texte d\'abord.'; return; } |
| |
| |
| const pgStart = raw.match(/\*{3}\s*START OF (THIS|THE) PROJECT GUTENBERG EBOOK[^\n]*/i); |
| const pgEnd = raw.match(/\*{3}\s*END OF (THIS|THE) PROJECT GUTENBERG EBOOK[^\n]*/i); |
| if (pgStart) raw = raw.slice(raw.indexOf(pgStart[0]) + pgStart[0].length).trimStart(); |
| if (pgEnd) raw = raw.slice(0, raw.indexOf(pgEnd[0])).trimEnd(); |
| |
| const lines = raw.split('\n'); |
| const rawSections = []; |
| let current = null; |
| let sepCounter = 0; |
| |
| for (let i = 0; i < lines.length; i++) { |
| const trimmed = lines[i].trim(); |
| const prevEmpty = i === 0 || lines[i-1].trim() === ''; |
| const nextEmpty = i >= lines.length - 1 || lines[i+1].trim() === ''; |
| |
| |
| if (isSectionSeparator(trimmed)) { |
| if (current && current.lines.join('\n').trim().length > 50) rawSections.push(current); |
| sepCounter++; |
| current = { title: `§${sepCounter}`, lines: [], isSep: true }; |
| continue; |
| } |
| |
| |
| const isStrong = trimmed && ( |
| /^#{1,3}\s+\S/.test(trimmed) || |
| /^(chapter|chapitre|kapitel|cap[íi]tulo|capitolo)\s+/i.test(trimmed) || |
| /^(part|partie|teil|parte)\s+\S/i.test(trimmed) || |
| /^\d{1,3}[\.\-–—:]\s*\S/.test(trimmed) || |
| /^\d{1,3}\.?\s*$/.test(trimmed) || |
| /^[IVXLCDMivxlcdm]+\.?\s*$/.test(trimmed) |
| ); |
| |
| if (trimmed && isChapterHeading(trimmed) && (prevEmpty || nextEmpty || isStrong)) { |
| if (current && current.lines.join('\n').trim().length > 50) |
| rawSections.push(current); |
| const cleanTitle = trimmed.replace(/^#{1,3}\s*/, ''); |
| current = { title: cleanTitle, lines: [] }; |
| } else if (current) { |
| current.lines.push(lines[i]); |
| } else { |
| current = { title: 'Début', lines: [lines[i]] }; |
| } |
| } |
| if (current && current.lines.join('\n').trim().length > 50) rawSections.push(current); |
| |
| |
| |
| let tocEndIdx = 0; |
| if (rawSections.length > 4) { |
| let run = 0; |
| for (let si = 0; si < Math.min(rawSections.length, 20); si++) { |
| const bodyLines = rawSections[si].lines.filter(l => l.trim().length > 0).length; |
| if (bodyLines < 4) { run++; } |
| else { if (run >= 3) tocEndIdx = si; break; } |
| } |
| if (run >= 3 && tocEndIdx === 0) tocEndIdx = run; |
| } |
| |
| let sections = rawSections.length ? rawSections : []; |
| |
| |
| if (!sections.length) { |
| const paras = raw.split(/\n{3,}/); |
| if (paras.length > 1) { |
| paras.forEach((p, i) => { if (p.trim()) sections.push({ title: `Partie ${i+1}`, lines: p.split('\n') }); }); |
| } else { |
| sections.push({ title: 'Texte complet', lines: raw.split('\n') }); |
| } |
| } |
| |
| bookChapters = sections.map((s, i) => { |
| const text = s.lines.join('\n').trim(); |
| const wordCount = text.split(/\s+/).filter(Boolean).length; |
| return { |
| index: i, title: s.title, text, trimmedText: null, |
| isContent: !isNonContent(s.title) && wordCount >= 30, |
| status: 'pending', blob: null, |
| }; |
| }); |
| |
| |
| |
| if (tocEndIdx > 0) bookChapters.slice(0, tocEndIdx).forEach(ch => { ch.isContent = false; }); |
| |
| const firstRealIdx = bookChapters.findIndex(ch => |
| /^(chapter|chapitre|part[ie]*|prologue|épilogue|livre\s+\d|book\s+\d|\d+\.?\s*$|[IVX]{1,6}\.?\s*$)/i.test(ch.title.trim()) |
| ); |
| if (firstRealIdx > 0) bookChapters.slice(0, firstRealIdx).forEach(ch => { ch.isContent = false; }); |
| |
| bookChapters.forEach(ch => { if (isNonContent(ch.title)) ch.isContent = false; }); |
| |
| renderChapterList(); |
| const selected = bookChapters.filter(c => c.isContent).length; |
| const totalWords = bookChapters.filter(c => c.isContent).reduce((s, c) => s + c.text.split(/\s+/).filter(Boolean).length, 0); |
| $('bookInfo').textContent = `${bookChapters.length} sections · ${selected} sélectionnées · ~${Math.round(totalWords/130)} min audio`; |
| } |
| |
| function selectAllChapters(checked) { |
| bookChapters.forEach((ch, i) => { |
| ch.isContent = checked; |
| const cb = document.querySelector(`#ch-${i} input[type=checkbox]`); |
| if (cb) cb.checked = checked; |
| }); |
| updateChapterTotalStats(); |
| } |
| |
| function selectContentChapters() { |
| bookChapters.forEach((ch, i) => { |
| const words = ch.text.split(/\s+/).filter(Boolean).length; |
| ch.isContent = !isNonContent(ch.title) && words >= 30; |
| const cb = document.querySelector(`#ch-${i} input[type=checkbox]`); |
| if (cb) cb.checked = ch.isContent; |
| }); |
| updateChapterTotalStats(); |
| } |
| |
| function updateChapterTotalStats() { |
| const sel = bookChapters.filter(c => c.isContent); |
| const totalWords = sel.reduce((s, c) => s + c.text.split(/\s+/).filter(Boolean).length, 0); |
| const estMin = Math.round(totalWords / 130); |
| const el = $('chapterTotalStats'); |
| if (el) el.textContent = `${sel.length}/${bookChapters.length} · ~${estMin} min`; |
| } |
| |
| function onBookDrop(event) { |
| event.preventDefault(); |
| $('bookDropZone').classList.remove('drag-over'); |
| const file = event.dataTransfer.files[0]; |
| if (!file) return; |
| const fakeInput = { files: [file] }; |
| loadBookFile(fakeInput); |
| } |
| |
| |
| |
| |
| const READER_PARAS_PER_PAGE = 14; |
| let readerParagraphs = []; |
| let readerStartPara = 0; |
| let readerEndPara = -1; |
| let readerPage = 0; |
| let readerMode = 'start'; |
| |
| function buildReaderParagraphs() { |
| readerParagraphs = []; |
| bookChapters.forEach((ch, ci) => { |
| const paras = ch.text.split(/\n\s*\n+/) |
| .map(p => p.trim().replace(/\s+/g, ' ')) |
| .filter(p => p.length > 15); |
| if (!paras.length) return; |
| paras.forEach((p, pi) => { |
| readerParagraphs.push({ |
| chapterIdx: ci, |
| chapterTitle: ch.title, |
| isChapterStart: pi === 0, |
| localIdx: pi, |
| text: p |
| |
| }); |
| readerParagraphs[readerParagraphs.length - 1].globalIdx = readerParagraphs.length - 1; |
| }); |
| }); |
| } |
| |
| function openReader() { |
| if (!bookChapters.length) return; |
| buildReaderParagraphs(); |
| if (readerParagraphs.length === 0) return; |
| if (readerEndPara < 0 || readerEndPara >= readerParagraphs.length) |
| readerEndPara = readerParagraphs.length - 1; |
| $('readerBookTitle').textContent = bookTitle || 'Livre'; |
| readerPage = Math.floor(readerStartPara / READER_PARAS_PER_PAGE); |
| setReaderMode('start'); |
| renderReaderPage(); |
| $('readerOverlay').style.display = 'flex'; |
| document.body.style.overflow = 'hidden'; |
| } |
| |
| function closeReader() { |
| $('readerOverlay').style.display = 'none'; |
| document.body.style.overflow = ''; |
| } |
| |
| function setReaderMode(mode) { |
| readerMode = mode; |
| $('readerModeStart').classList.toggle('ractive', mode === 'start'); |
| $('readerModeEnd').classList.toggle('ractive', mode === 'end'); |
| } |
| |
| function readerNextPage() { |
| const total = Math.ceil(readerParagraphs.length / READER_PARAS_PER_PAGE); |
| if (readerPage < total - 1) { readerPage++; renderReaderPage(); } |
| } |
| function readerPrevPage() { |
| if (readerPage > 0) { readerPage--; renderReaderPage(); } |
| } |
| |
| function readerClickPara(globalIdx, isShift, isRight) { |
| if (isRight || isShift || readerMode === 'end') { |
| if (globalIdx >= readerStartPara) { |
| readerEndPara = globalIdx; |
| setReaderMode('start'); |
| } |
| } else { |
| readerStartPara = globalIdx; |
| if (readerEndPara < readerStartPara) readerEndPara = readerParagraphs.length - 1; |
| setReaderMode('end'); |
| } |
| renderReaderPage(); |
| } |
| |
| function renderReaderPage() { |
| const total = Math.ceil(readerParagraphs.length / READER_PARAS_PER_PAGE); |
| $('readerPageInfo').textContent = `Page ${readerPage + 1} / ${total}`; |
| |
| const start = readerPage * READER_PARAS_PER_PAGE; |
| const end = Math.min(start + READER_PARAS_PER_PAGE, readerParagraphs.length); |
| const col = $('readerTextCol'); |
| col.innerHTML = ''; |
| |
| for (let i = start; i < end; i++) { |
| const rp = readerParagraphs[i]; |
| |
| if (rp.isChapterStart) { |
| const sep = document.createElement('div'); |
| sep.className = 'reader-ch-sep'; |
| sep.textContent = rp.chapterTitle; |
| col.appendChild(sep); |
| } |
| |
| const isStart = i === readerStartPara; |
| const isEnd = i === readerEndPara; |
| const isSelected = i >= readerStartPara && i <= readerEndPara; |
| |
| const p = document.createElement('p'); |
| p.className = 'reader-para' + |
| (isSelected ? ' rp-selected' : '') + |
| (isStart ? ' rp-start' : '') + |
| (isEnd ? ' rp-end' : ''); |
| p.textContent = rp.text; |
| p.title = 'Clic : début · Shift+clic / clic droit : fin'; |
| p.onclick = e => readerClickPara(i, e.shiftKey, false); |
| p.oncontextmenu = e => { e.preventDefault(); readerClickPara(i, false, true); }; |
| col.appendChild(p); |
| } |
| |
| |
| const startRp = readerParagraphs[readerStartPara] || readerParagraphs[0]; |
| const endRp = readerParagraphs[readerEndPara] || readerParagraphs[readerParagraphs.length - 1]; |
| const selWords = readerParagraphs |
| .slice(readerStartPara, readerEndPara + 1) |
| .reduce((s, p) => s + p.text.split(/\s+/).length, 0); |
| const estMin = Math.round(selWords / 130); |
| |
| $('readerSideSel').innerHTML = ` |
| <div style="color:var(--green)">▶ <b>${(startRp?.chapterTitle || '—').slice(0, 25)}</b></div> |
| <div style="font-size:10px;color:var(--dimmer);margin-left:10px">para ${readerStartPara + 1}</div> |
| <div style="margin-top:6px;color:var(--red)">⏹ <b>${(endRp?.chapterTitle || '—').slice(0, 25)}</b></div> |
| <div style="font-size:10px;color:var(--dimmer);margin-left:10px">para ${readerEndPara + 1}</div> |
| <div style="margin-top:10px;padding-top:8px;border-top:1px solid var(--border);font-size:11px"> |
| ${readerEndPara - readerStartPara + 1} paragraphes<br> |
| ~${selWords.toLocaleString()} mots<br> |
| <b style="color:var(--accent)">~${estMin} min audio</b> |
| </div>`; |
| |
| |
| const chSide = $('readerSideChapters'); |
| chSide.innerHTML = ''; |
| const startCh = startRp?.chapterIdx ?? 0; |
| const endCh = endRp?.chapterIdx ?? bookChapters.length - 1; |
| bookChapters.forEach((ch, ci) => { |
| const inRange = ci >= startCh && ci <= endCh; |
| const div = document.createElement('div'); |
| div.className = 'reader-ch-item ' + (inRange ? 'rci-in' : 'rci-out'); |
| div.textContent = (inRange ? '☑ ' : '☐ ') + (ch.title || `Section ${ci + 1}`).slice(0, 28); |
| div.title = ch.title; |
| div.onclick = () => { |
| const firstPara = readerParagraphs.findIndex(p => p.chapterIdx === ci); |
| if (firstPara >= 0) { |
| readerPage = Math.floor(firstPara / READER_PARAS_PER_PAGE); |
| renderReaderPage(); |
| } |
| }; |
| chSide.appendChild(div); |
| }); |
| } |
| |
| function applyReaderSelection() { |
| const startRp = readerParagraphs[readerStartPara]; |
| const endRp = readerParagraphs[readerEndPara]; |
| if (!startRp || !endRp) { closeReader(); return; } |
| |
| const startCh = startRp.chapterIdx; |
| const endCh = endRp.chapterIdx; |
| |
| bookChapters.forEach((ch, ci) => { |
| if (ci < startCh || ci > endCh) { |
| ch.isContent = false; |
| ch.trimmedText = null; |
| } else { |
| ch.isContent = true; |
| |
| const parasInCh = readerParagraphs.filter(p => p.chapterIdx === ci); |
| const startLocal = (ci === startCh) |
| ? parasInCh.findIndex(p => p.globalIdx >= readerStartPara) |
| : 0; |
| let endLocal = parasInCh.length - 1; |
| if (ci === endCh) { |
| for (let li = parasInCh.length - 1; li >= 0; li--) { |
| if (parasInCh[li].globalIdx <= readerEndPara) { endLocal = li; break; } |
| } |
| } |
| const sliced = parasInCh.slice(startLocal < 0 ? 0 : startLocal, endLocal + 1).map(p => p.text).join('\n\n'); |
| |
| const fullWords = ch.text.split(/\s+/).length; |
| const slicedWords = sliced.split(/\s+/).length; |
| ch.trimmedText = (slicedWords < fullWords * 0.98) ? sliced : null; |
| } |
| }); |
| |
| renderChapterList(); |
| closeReader(); |
| |
| const sel = bookChapters.filter(c => c.isContent); |
| const words = sel.reduce((s, c) => s + (c.trimmedText || c.text).split(/\s+/).length, 0); |
| $('bookInfo').textContent = `Sélection : ${sel.length} chapitres · ~${Math.round(words / 130)} min audio`; |
| } |
| |
| function renderChapterList() { |
| const list = $('chapterList'); |
| list.innerHTML = ''; |
| list.style.display = bookChapters.length ? '' : 'none'; |
| const header = $('chapterListHeader'); |
| if (header) header.style.display = bookChapters.length ? 'flex' : 'none'; |
| |
| bookChapters.forEach((ch, i) => { |
| const words = ch.text.split(/\s+/).filter(Boolean).length; |
| const estMin = Math.round(words / 130); |
| const estSec = Math.round((words % 130) / 130 * 60); |
| const estLabel = estMin > 0 ? `~${estMin}m${estSec > 0 ? estSec + 's' : ''}` : `~${Math.round(words/2.2)}s`; |
| const displayTitle = formatChapterTitle(ch.title); |
| const statusText = ch.status === 'done' ? '✓' : |
| ch.status === 'error' ? '✗' : |
| ch.status === 'generating' ? '⟳' : ''; |
| |
| const item = document.createElement('div'); |
| item.id = `ch-${i}`; |
| item.className = 'chapter-item' + |
| (ch.status === 'done' ? ' ch-done' : ch.status === 'error' ? ' ch-error' : |
| ch.status === 'generating' ? ' ch-active' : ''); |
| item.innerHTML = ` |
| <input type="checkbox" ${ch.isContent ? 'checked' : ''} onchange="toggleChapter(${i},this.checked)"> |
| <span class="ch-title" title="${ch.title.replace(/"/g,'"')}">${displayTitle}</span> |
| <span class="ch-chars">${words.toLocaleString()} mots · ${estLabel}</span> |
| <span class="ch-status" id="ch-status-${i}">${statusText}</span> |
| ${ch.status === 'error' ? `<button class="ch-retry" onclick="retryChapter(${i})">↺</button>` : ''} |
| ${ch.blob ? `<button class="ch-dl" onclick="dlChapter(${i})">↓</button>` : ''} |
| `; |
| list.appendChild(item); |
| }); |
| updateChapterTotalStats(); |
| |
| $('bookGenRow').style.display = bookChapters.length ? '' : 'none'; |
| const done = bookChapters.filter(c => c.status === 'done'); |
| $('bookDlAllBtn').style.display = done.length > 0 ? '' : 'none'; |
| $('bookMergeBtn').style.display = done.length > 0 ? '' : 'none'; |
| updateBookStats(); |
| } |
| |
| function toggleChapter(index, checked) { |
| bookChapters[index].isContent = checked; |
| updateBookStats(); |
| updateChapterTotalStats(); |
| } |
| |
| function updateBookStats() { |
| const sel = bookChapters.filter(c => c.isContent); |
| const chars = sel.reduce((s, c) => s + c.text.length, 0); |
| const estAudio = Math.round(chars / 14); |
| const estGen = Math.round(estAudio / lastRtf); |
| $('bookStats').textContent = `${sel.length} chapitres · ~${estAudio}s audio · ~${estGen}s génération`; |
| } |
| |
| // Preprocess text for better audiobook narration: |
| // - Em-dashes → comma pause, ellipsis → natural pause, clean whitespace |
| function preprocessForNarration(text) { |
| return text |
| .replace(/\s*—\s*/g, ', ') // em-dash → comma pause |
| .replace(/\s*–\s*/g, ', ') // en-dash → comma pause |
| .replace(/\.{3}/g, '...') // normalize ellipsis |
| .replace(/([!?])\s+/g, '$1 ') // extra space after ! ? for pause |
| .replace(/\s{3,}/g, '\n\n') // normalize large whitespace gaps |
| .trim(); |
| } |
| |
| function splitIntoChunks(text, maxChars = 4000) { |
| if (text.length <= maxChars) return [text]; |
| const paragraphs = text.split(/\n\n+/); |
| const chunks = []; let current = ''; |
| for (const para of paragraphs) { |
| if (current.length + para.length + 2 <= maxChars) { |
| current += (current ? '\n\n' : '') + para; |
| } else if (para.length > maxChars) { |
| if (current) { chunks.push(current); current = ''; } |
| const sents = para.match(/[^.!?]+[.!?]+\s*/g) || [para]; |
| for (const s of sents) { |
| if (current.length + s.length <= maxChars) current += s; |
| else { if (current) chunks.push(current.trim()); current = s; } |
| } |
| } else { |
| if (current) chunks.push(current); |
| current = para; |
| } |
| } |
| if (current.trim()) chunks.push(current.trim()); |
| return chunks.filter(c => c.trim()); |
| } |
| |
| async function generateBook() { |
| if (busy || bookBusy) { showMsg('err', 'Une génération est déjà en cours.'); return; } |
| const needsQwen = bookEngine === 'qwen_custom' || bookEngine === 'qwen_clone' || bookEngine === 'qwen_design'; |
| if (needsQwen && !loadedModel) { showMsg('err', 'Charge un modèle Qwen3 d\'abord (onglet TTS).'); return; } |
| if (bookEngine === 'qwen_design' && loadedModel && !loadedModel.includes('VoiceDesign')) { |
| showMsg('err', 'Voice Design nécessite le modèle 1.7B-VoiceDesign (onglet TTS → Qwen3-TTS → Voice Design → LOAD).'); return; |
| } |
| |
| const toGen = bookChapters.filter(c => c.isContent && c.status !== 'done'); |
| if (!toGen.length) return; |
| |
| // Generate a fixed seed for voice consistency across all chunks/chapters |
| const seedInput = parseInt($('bookSeedIn')?.value); |
| bookSeed = isNaN(seedInput) ? Math.floor(Math.random() * 2147483647) : seedInput; |
| if ($('bookSeedDisplay')) $('bookSeedDisplay').textContent = `seed: ${bookSeed}`; |
| |
| bookBusy = true; bookCancelled = false; |
| bookFishRollingRef = null; // reset rolling reference at start of each book generation |
| bookQwenDesignAnchor = null; // reset voice anchor so first chunk re-designs the voice |
| bookStartTime = Date.now(); bookChaptersDone = 0; |
| $('bookGenBtn').disabled = true; $('bookGenBtn').style.display = 'none'; |
| $('bookResumeBtn').style.display = 'none'; |
| $('bookCancelBtn').style.display = ''; |
| $('bookProgressRow').style.display = ''; |
| $('bookPbar').style.width = '0%'; |
| $('bookPct').textContent = '0%'; |
| $('bookEta').textContent = ''; |
| $('bookSpeed').textContent = ''; |
| |
| let done = 0; |
| for (const ch of toGen) { |
| if (bookCancelled) break; |
| ch.status = 'generating'; updateChapterUI(ch.index); |
| scrollToChapter(ch.index); |
| try { |
| const chunks = splitIntoChunks(ch.trimmedText || ch.text, 4000); |
| const parts = []; |
| for (const chunk of chunks) { |
| if (bookCancelled) break; |
| const blob = await retryableGenerateChunk(chunk); |
| if (blob) parts.push(blob); |
| } |
| if (parts.length && !bookCancelled) { |
| ch.blob = parts.length === 1 ? parts[0] : await concatWavBlobs(parts); |
| ch.status = 'done'; |
| if ($('autoDlCheck').checked) { |
| await new Promise(r => setTimeout(r, 200)); |
| dlChapter(ch.index); |
| } |
| } else if (!bookCancelled) { |
| ch.status = 'error'; |
| } |
| } catch { ch.status = 'error'; } |
| done++; bookChaptersDone = done; |
| updateBookProgress(done, toGen.length); |
| updateChapterUI(ch.index); |
| } |
| |
| bookBusy = false; |
| $('bookCancelBtn').style.display = 'none'; |
| if (bookCancelled) { |
| $('bookResumeBtn').style.display = ''; |
| } else { |
| $('bookGenBtn').disabled = false; $('bookGenBtn').style.display = ''; |
| } |
| const doneCount = bookChapters.filter(c => c.status === 'done').length; |
| $('bookDlAllBtn').style.display = doneCount ? '' : 'none'; |
| $('bookMergeBtn').style.display = doneCount ? '' : 'none'; |
| renderChapterList(); |
| } |
| |
| function cancelBook() { |
| bookCancelled = true; |
| } |
| |
| async function retryableGenerateChunk(text, maxTries = 3) { |
| for (let attempt = 0; attempt < maxTries; attempt++) { |
| try { |
| return await generateChunk(text); |
| } catch (e) { |
| if (attempt === maxTries - 1) throw e; |
| await new Promise(r => setTimeout(r, 1000 * (attempt + 1))); |
| } |
| } |
| } |
| |
| function updateBookProgress(done, total) { |
| const pct = Math.round(done / total * 100); |
| $('bookPbar').style.width = pct + '%'; |
| $('bookPct').textContent = pct + '%'; |
| const elapsed = (Date.now() - bookStartTime) / 1000; |
| const rate = done / elapsed; |
| const etaSecs = (total - done) / rate; |
| if (done > 0 && isFinite(etaSecs)) { |
| const eta = new Date(Date.now() + etaSecs * 1000); |
| const h = eta.getHours().toString().padStart(2, '0'); |
| const m = eta.getMinutes().toString().padStart(2, '0'); |
| $('bookEta').textContent = `Fin ~${h}h${m}`; |
| $('bookSpeed').textContent = Math.round(rate * 3600) + ' ch/h'; |
| } |
| } |
| |
| function scrollToChapter(index) { |
| const el = $(`ch-${index}`); |
| if (el) el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); |
| } |
| |
| async function retryChapter(index) { |
| if (bookBusy) return; |
| const ch = bookChapters[index]; |
| if (!ch || ch.status !== 'error') return; |
| bookBusy = true; |
| ch.status = 'generating'; updateChapterUI(index); |
| try { |
| const chunks = splitIntoChunks(ch.trimmedText || ch.text, 4000); |
| const parts = []; |
| for (const chunk of chunks) { |
| const blob = await retryableGenerateChunk(chunk); |
| if (blob) parts.push(blob); |
| } |
| if (parts.length) { ch.blob = parts.length === 1 ? parts[0] : await concatWavBlobs(parts); ch.status = 'done'; } |
| else { ch.status = 'error'; } |
| } catch { ch.status = 'error'; } |
| bookBusy = false; |
| renderChapterList(); |
| } |
| |
| async function mergeAllChapters() { |
| const done = bookChapters.filter(c => c.status === 'done' && c.blob); |
| if (!done.length) return; |
| const btn = $('bookMergeBtn'); |
| btn.disabled = true; btn.textContent = 'Fusion...'; |
| try { |
| const merged = await concatWavBlobs(done.map(c => c.blob)); |
| const a = document.createElement('a'); |
| a.href = URL.createObjectURL(merged); |
| a.download = 'audiobook_complet.wav'; |
| a.click(); URL.revokeObjectURL(a.href); |
| } catch (e) { alert('Erreur fusion: ' + e.message); } |
| btn.disabled = false; btn.textContent = 'Fusionner en 1 WAV'; |
| } |
| |
| async function generateChunk(rawText) { |
| const text = preprocessForNarration(rawText); |
| const expressive = $('bookExpressive')?.checked ?? false; |
| |
| if (bookEngine === 'kokoro') { |
| const fd = new FormData(); |
| fd.append('text', text); |
| fd.append('voice', $('bookKokoroVoice').value); |
| fd.append('speed', expressive ? '1.05' : '1.0'); |
| const res = await fetch('/generate/kokoro_fr', { method: 'POST', body: fd }); |
| if (!res.ok) { const e = await res.json(); throw new Error(e.detail || 'Erreur Kokoro'); } |
| return new Blob([await res.arrayBuffer()], { type: 'audio/wav' }); |
| } |
| |
| if (bookEngine === 'fish') { |
| const fd = new FormData(); |
| fd.append('text', text); |
| // Rolling reference takes priority over static ref (voice anchoring technique) |
| if (bookFishRollingRef) { |
| fd.append('prev_wav', bookFishRollingRef, 'prev.wav'); |
| } else if (bookFishRefFile) { |
| fd.append('ref_wav', bookFishRefFile); |
| } |
| const fishRefText = $('bookFishRefText') ? $('bookFishRefText').value.trim() : ''; |
| if (fishRefText) fd.append('ref_text', fishRefText); |
| fd.append('max_new_tokens', $('bookFishMaxTokens') ? $('bookFishMaxTokens').value : '4096'); |
| fd.append('chunk_length', $('bookFishChunk') ? $('bookFishChunk').value : '400'); |
| fd.append('latency', $('bookFishLatency') ? $('bookFishLatency').value : 'balanced'); |
| fd.append('normalize', $('bookFishNorm') ? $('bookFishNorm').checked : true); |
| fd.append('auto_split', $('bookFishAutoSplit') ? $('bookFishAutoSplit').checked : true); |
| fd.append('temperature', expressive ? '0.88' : '0.65'); |
| fd.append('top_p', expressive ? '0.92' : '0.75'); |
| fd.append('repetition_penalty', expressive ? '1.02' : '1.05'); |
| fd.append('rolling_ref_secs', '6'); |
| if (bookSeed !== null) fd.append('seed', bookSeed); |
| const res = await fetch('/generate/fish', { method: 'POST', body: fd }); |
| if (!res.ok) { const e = await res.json(); throw new Error(e.detail || 'Erreur Fish-Speech'); } |
| const audioBuffer = await res.arrayBuffer(); |
| // Save tail of this chunk as rolling reference for next chapter |
| const tailB64 = res.headers.get('X-Rolling-Ref-B64'); |
| if (tailB64) { |
| const tailBytes = Uint8Array.from(atob(tailB64), c => c.charCodeAt(0)); |
| bookFishRollingRef = new Blob([tailBytes], { type: 'audio/wav' }); |
| } |
| return new Blob([audioBuffer], { type: 'audio/wav' }); |
| } |
| |
| if (bookEngine === 'chatterbox') { |
| const fd = new FormData(); |
| fd.append('text', text); |
| if (bookCbRefFile) fd.append('ref_wav', bookCbRefFile); |
| fd.append('exaggeration', expressive ? '0.75' : $('bookCbExag').value); |
| fd.append('temperature', expressive ? '1.1' : '0.8'); |
| fd.append('cfg_weight', expressive ? '0.4' : '0.5'); |
| if (bookSeed !== null) fd.append('seed', bookSeed); |
| const res = await fetch('/generate/chatterbox', { method: 'POST', body: fd }); |
| if (!res.ok) { const e = await res.json(); throw new Error(e.detail || 'Erreur Chatterbox'); } |
| return new Blob([await res.arrayBuffer()], { type: 'audio/wav' }); |
| } |
| |
| if (bookEngine === 'f5') { |
| const fd = new FormData(); |
| fd.append('text', text); |
| if (bookF5RefFile) fd.append('ref_wav', bookF5RefFile); |
| const f5RefText = $('bookF5RefText')?.value.trim(); |
| if (f5RefText) fd.append('ref_text', f5RefText); |
| fd.append('speed', expressive ? '1.05' : '1.0'); |
| fd.append('nfe_step', expressive ? '32' : '32'); |
| fd.append('cross_fade_duration', expressive ? '0.1' : '0.15'); |
| if (bookSeed !== null) fd.append('seed', bookSeed); |
| const res = await fetch('/generate/f5_fr', { method: 'POST', body: fd }); |
| if (!res.ok) { const e = await res.json(); throw new Error(e.detail || 'Erreur F5'); } |
| return new Blob([await res.arrayBuffer()], { type: 'audio/wav' }); |
| } |
| |
| if (bookEngine === 'qwen_design') { |
| const fd = new FormData(); |
| fd.append('text', text); |
| fd.append('language', $('bookQwenLangVD').value); |
| fd.append('temperature', expressive ? '0.85' : '0.7'); |
| fd.append('top_k', expressive ? '40' : '30'); |
| fd.append('repetition_penalty', expressive ? '1.05' : '1.1'); |
| if (bookSeed !== null) fd.append('seed', bookSeed); |
| |
| if (bookQwenDesignAnchor) { |
| // Chunks 2+: clone from captured voice anchor → identical voice guaranteed |
| fd.append('mode', 'voice_clone'); |
| fd.append('ref_audio', bookQwenDesignAnchor, 'anchor.wav'); |
| fd.append('xvec_only', 'true'); |
| } else { |
| // First chunk: design voice from instruct prompt |
| fd.append('mode', 'voice_design'); |
| const baseInstr = $('bookVoiceInstr').value; |
| fd.append('instruct', expressive |
| ? baseInstr + ' Be expressive like a stage-trained French narrator: stress key words, slow down during dramatic revelations, quicken pace for action, lower your voice for intimate or tense moments, and add subtle warmth for emotional passages.' |
| : baseInstr); |
| } |
| |
| const res = await fetch('/generate', { method: 'POST', body: fd }); |
| if (!res.ok) { const e = await res.json(); throw new Error(e.detail || 'Erreur VoiceDesign'); } |
| const d = await res.json(); |
| if (d.metrics?.rtf > 0) lastRtf = d.metrics.rtf; |
| const bytes = Uint8Array.from(atob(d.audio_b64), c => c.charCodeAt(0)); |
| const blob = new Blob([bytes], { type: 'audio/wav' }); |
| |
| // Capture first chunk as voice anchor (trimmed to 10s) for all subsequent chunks |
| if (!bookQwenDesignAnchor) { |
| bookQwenDesignAnchor = await trimWavBlob(blob, 10); |
| } |
| |
| return blob; |
| } |
| |
| // qwen_custom or qwen_clone → /generate endpoint |
| const isCustom = bookEngine === 'qwen_custom'; |
| const fd = new FormData(); |
| fd.append('text', text); |
| fd.append('mode', isCustom ? 'custom' : 'voice_clone'); |
| fd.append('language', isCustom ? $('bookQwenLangC').value : $('bookQwenLangK').value); |
| fd.append('temperature', expressive ? '0.85' : '0.7'); |
| fd.append('top_k', expressive ? '40' : '30'); |
| fd.append('repetition_penalty', expressive ? '1.05' : '1.1'); |
| if (bookSeed !== null) fd.append('seed', bookSeed); |
| if (isCustom) { |
| fd.append('speaker', $('bookQwenSpeaker').value); |
| } else { |
| if (bookQwenRefFile) fd.append('ref_audio', bookQwenRefFile); |
| fd.append('xvec_only', 'false'); |
| } |
| const res = await fetch('/generate', { method: 'POST', body: fd }); |
| if (!res.ok) { const e = await res.json(); throw new Error(e.detail || 'Erreur Qwen3'); } |
| const d = await res.json(); |
| if (d.metrics?.rtf > 0) lastRtf = d.metrics.rtf; |
| const bytes = Uint8Array.from(atob(d.audio_b64), c => c.charCodeAt(0)); |
| return new Blob([bytes], { type: 'audio/wav' }); |
| } |
| |
| async function concatWavBlobs(blobs) { |
| const parts = []; let sr = 24000; |
| for (const blob of blobs) { |
| const ab = await blob.arrayBuffer(); |
| const p = parseWav(new Uint8Array(ab)); |
| if (p) { parts.push(p.rawPcm); sr = p.sr; } |
| } |
| const totalLen = parts.reduce((s, p) => s + p.length, 0); |
| const outAb = new ArrayBuffer(44 + totalLen); |
| const v = new DataView(outAb); |
| const ws = (o, s) => [...s].forEach((c, i) => v.setUint8(o + i, c.charCodeAt(0))); |
| ws(0,'RIFF'); v.setUint32(4, 36 + totalLen, true); |
| ws(8,'WAVE'); ws(12,'fmt '); |
| v.setUint32(16,16,true); v.setUint16(20,1,true); v.setUint16(22,1,true); |
| v.setUint32(24,sr,true); v.setUint32(28,sr*2,true); |
| v.setUint16(32,2,true); v.setUint16(34,16,true); |
| ws(36,'data'); v.setUint32(40, totalLen, true); |
| const out = new Uint8Array(outAb, 44); |
| let offset = 0; |
| for (const p of parts) { out.set(p, offset); offset += p.length; } |
| return new Blob([outAb], { type: 'audio/wav' }); |
| } |
| |
| // Return a new WAV blob containing only the first maxSecs seconds of audio. |
| // Used to create a compact voice anchor from the first generated chunk. |
| async function trimWavBlob(blob, maxSecs) { |
| const ab = await blob.arrayBuffer(); |
| const p = parseWav(new Uint8Array(ab)); |
| if (!p) return blob; // can't parse → return original |
| const { rawPcm, sr } = p; |
| // 16-bit mono: 2 bytes per sample, sr samples per second |
| const maxBytes = Math.min(rawPcm.length, Math.floor(maxSecs * sr * 2)); |
| const trimmed = rawPcm.slice(0, maxBytes); |
| const outAb = new ArrayBuffer(44 + trimmed.length); |
| const v = new DataView(outAb); |
| const ws = (o, s) => [...s].forEach((c, i) => v.setUint8(o + i, c.charCodeAt(0))); |
| ws(0,'RIFF'); v.setUint32(4, 36 + trimmed.length, true); |
| ws(8,'WAVE'); ws(12,'fmt '); |
| v.setUint32(16,16,true); v.setUint16(20,1,true); v.setUint16(22,1,true); |
| v.setUint32(24,sr,true); v.setUint32(28,sr*2,true); |
| v.setUint16(32,2,true); v.setUint16(34,16,true); |
| ws(36,'data'); v.setUint32(40, trimmed.length, true); |
| new Uint8Array(outAb, 44).set(trimmed); |
| return new Blob([outAb], { type: 'audio/wav' }); |
| } |
| |
| function updateChapterUI(index) { |
| const ch = bookChapters[index]; |
| const item = $(`ch-${index}`); |
| if (!item) return; |
| item.className = 'chapter-item' + |
| (ch.status === 'done' ? ' ch-done' : ch.status === 'error' ? ' ch-error' : |
| ch.status === 'generating' ? ' ch-active' : ''); |
| const statusEl = $(`ch-status-${index}`); |
| if (statusEl) statusEl.textContent = |
| ch.status === 'done' ? '✓ ok' : ch.status === 'error' ? '✗ erreur' : |
| ch.status === 'generating' ? '⟳...' : ''; |
| // retry button |
| const existingRetry = item.querySelector('.ch-retry'); |
| if (ch.status === 'error' && !existingRetry) { |
| const btn = document.createElement('button'); |
| btn.className = 'ch-retry'; btn.textContent = '↺'; |
| btn.onclick = () => retryChapter(index); |
| item.appendChild(btn); |
| } else if (ch.status !== 'error' && existingRetry) { |
| existingRetry.remove(); |
| } |
| // dl button |
| if (ch.blob && !item.querySelector('.ch-dl')) { |
| const btn = document.createElement('button'); |
| btn.className = 'ch-dl'; btn.textContent = '↓'; |
| btn.onclick = () => dlChapter(index); |
| item.appendChild(btn); |
| } |
| } |
| |
| function dlChapter(index) { |
| const ch = bookChapters[index]; |
| if (!ch.blob) return; |
| const a = document.createElement('a'); |
| a.href = URL.createObjectURL(ch.blob); |
| a.download = `${String(index + 1).padStart(2,'0')}_${ch.title.replace(/[^a-z0-9]/gi,'_').slice(0,40)}.wav`; |
| a.click(); URL.revokeObjectURL(a.href); |
| } |
| |
| async function downloadAllChapters() { |
| const done = bookChapters.filter(c => c.status === 'done' && c.blob); |
| for (let i = 0; i < done.length; i++) { |
| dlChapter(done[i].index); |
| if (i < done.length - 1) await new Promise(r => setTimeout(r, 300)); |
| } |
| } |
| |
| // ── Keyboard shortcuts ──────────────────────────────────────────────────────── |
| document.addEventListener('keydown', e => { |
| if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { |
| e.preventDefault(); |
| // Pick best mode based on what's filled in |
| const mode = refFile ? 'voice_clone' : $('speakerId').value ? 'custom' : 'voice_design'; |
| generate(mode); |
| } |
| if (e.key === 'Escape') { |
| if ($('loaderOv').classList.contains('open')) $('loaderOv').classList.remove('open'); |
| else if ($('settingsOv').classList.contains('open')) closeSettings(); |
| } |
| }); |
| </script> |
| </body> |
| </html> |
|
|