Foradc
feat: add Voxtral TTS engine + French prosody preprocessing
737927a
Raw
History Blame Contribute Delete
192 kB
<!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 shell ── */
.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;
}
/* ── Header ── */
.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 area ── */
.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 rows (arcade selector) ── */
.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; }
/* Active (loaded) row — neon arcade glow */
.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; } }
/* Loading row — pulsing glow while model is fetched */
.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); } }
/* Inactive rows — dim + pointer; hover lifts the veil */
.mode-row.incompatible { opacity: 0.42; cursor: pointer; }
/* Block inner elements from capturing clicks/focus so the row onclick fires cleanly */
.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; }
/* "LOAD ▶" chip shown on hover of incompatible rows */
.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;
}
/* Always show tags on compatible rows */
.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; }
}
/* ── Loader overlay (centered modal) ── */
.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 audio chip */
.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 button */
.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); }
/* Recording level meter */
.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;
}
/* Recording preview */
.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);
}
/* Small select */
.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 instruction textarea (auto-grow) */
.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); }
/* Full-width instruction field below a row (Custom mode) */
.instr-wrap { margin-top: 6px; display: flex; }
.instr-wrap .voice-in { flex: 1; }
/* ── Clone mode controls ── */
.clone-toggle-row { margin-bottom: 6px; }
.clone-hint {
font-size: 11px; color: var(--dim);
margin-bottom: 5px; line-height: 1.4;
}
/* ── Play button ── */
.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; }
/* ── Error / Warning ── */
.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 indicator ── */
.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; }
/* ── Progress ── */
.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 ── */
.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; }
/* ── Settings overlay ── */
.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); }
/* Force dark background on ALL native select dropdowns */
select option, select optgroup {
background-color: #1e1e2e;
color: #e2e8f0;
}
.s-row textarea { resize: vertical; min-height: 40px; max-height: 80px; }
/* Settings hint text */
.s-hint {
font-size: 11px; color: var(--dimmer);
margin: -6px 0 8px 98px;
}
/* Toggle */
.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);
}
/* Slider */
.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; }
/* ── Responsive ── */
@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; }
}
/* ── Animations ── */
@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)} }
/* ── Tabs ── */
.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); }
/* ── Estimation ── */
.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;
}
/* ── Audiobook ── */
.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; }
/* ── Book Reader Overlay ── */
.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); }
/* Book voice selector */
.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 tabs (hidden, kept for JS compat) ── */
.inner-tab-bar { display: none; }
.inner-tab { display: none; }
/* ── Engine rows: hide all by default, show only selected ── */
#innerPanelFR .mode-row { display: none; }
#innerPanelFR .mode-row.eng-active { display: block; }
/* In FR rows, hide individual play buttons — use central gen button instead */
#innerPanelFR .mode-row .play-btn { display: none; }
/* Central generate row */
.studio-gen-row { display: flex; gap: 8px; align-items: center; flex-shrink: 0; }
/* ── Engine pill selector ── */
.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;
}
/* TTS language pills */
.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 (settings for selected engine) */
.engine-panel {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 10px 12px;
}
/* ── Big generate button ── */
.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; }
/* ── Audiobook drop zone ── */
.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; }
/* ── Audiobook sections ── */
.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 (larger, more visual) ── */
.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 wider ── */
.app { max-width: 780px; }
/* ── Qwen3 loader inline ── */
.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 section header ── */
.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 -->
<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>
<!-- Tab bar -->
<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>
<!-- TTS panel -->
<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>
<!-- Engine pill selector -->
<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>
<!-- Inner tab bar (hidden, kept for JS compat) -->
<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>
<!-- ── TTS Language bar ── -->
<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>
<!-- ── Panel Voix Françaises ── -->
<div id="innerPanelFR">
<!-- Kokoro FR -->
<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>
<!-- Chatterbox -->
<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>
<!-- F5-TTS FR -->
<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>
<!-- Fish-Speech -->
<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>
<!-- Voxtral TTS -->
<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><!-- /#innerPanelFR -->
<!-- ── Panel Qwen3-TTS ── -->
<div id="innerPanelQwen" style="display:none">
<!-- Qwen3 controls bar -->
<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>
<!-- Clone -->
<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>
<!-- Simple / Advanced toggle -->
<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>
<!-- Reference audio + action buttons -->
<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>
<!-- Advanced: transcript -->
<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>
<!-- Level meter (shown during recording) -->
<div class="rec-bar-wrap" id="recBarWrap"><div class="rec-bar" id="recBar"></div></div>
<!-- Preview of last recording -->
<audio id="recPreview" class="rec-preview" controls></audio>
</div>
<!-- Custom (built-in speakers) -->
<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>
<!-- Voice Design -->
<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 &amp; nette</option>
<option value="female_young">✨ Femme jeune &amp; 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><!-- /#innerPanelQwen -->
<!-- Central generate button (FR engines) -->
<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>
<!-- Messages -->
<div class="msg-bar" id="msgBar"></div>
<div class="queue-bar" id="queueBar"></div>
<!-- Progress -->
<div class="pbar-wrap" id="pbarWrap"><div class="pbar" id="pbar"></div></div>
<!-- Results -->
<div class="results" id="results">
<div class="player-row">
<audio id="player" controls></audio>
<button class="dl-btn" onclick="dlAudio()" title="Download WAV">&darr;</button>
</div>
<div class="metrics">
<div class="met"><span class="met-k">TTFA</span><span class="met-v" id="mTTFA">&mdash;</span></div>
<div class="met"><span class="met-k">Client</span><span class="met-v" id="mClient">&mdash;</span></div>
<div class="met" id="mCloneWrap" style="display:none"><span class="met-k">Clone</span><span class="met-v" id="mClone">&mdash;</span></div>
<div class="met"><span class="met-k">RTF</span><span class="met-v" id="mRTF">&mdash;</span></div>
<div class="met"><span class="met-k">Dur</span><span class="met-v" id="mDur">&mdash;</span></div>
<div class="met"><span class="met-k">Buf</span><span class="met-v" id="mBuf">&mdash;</span></div>
<div class="wave" id="waveInd"><i></i><i></i><i></i><i></i><i></i></div>
</div>
</div>
</div>
</div><!-- /#panelTTS -->
<!-- Audiobook panel -->
<div id="panelBook" style="display:none">
<div class="book-panel">
<!-- Drop zone -->
<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>
<!-- Paste zone -->
<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>
<!-- Book voice selector -->
<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">
<!-- Preset voices -->
<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>
<!-- Ref audio (custom) -->
<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>
<!-- Audiobook-optimized params -->
<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>
<!-- Chapter list header (select controls + total) -->
<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><!-- /#panelBook -->
</div><!-- /.app -->
<!-- ── Book Reader Overlay ── -->
<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 &nbsp;·&nbsp; Shift+clic ou clic droit : fin</span>
</div>
</div>
<!-- Settings bottom sheet -->
<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>
<!-- ── Model Loader Overlay ── -->
<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>
<!-- PDF.js for PDF extraction -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<!-- JSZip for EPUB extraction -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<script>
// ── Shorthand ─────────────────────────────────────────────────────────────────
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);
}
// ── State ─────────────────────────────────────────────────────────────────────
let genMode = 'stream';
let xvecOnly = false;
let busy = false;
let refFile = null;
let presetRefId = null;
let presetRefs = [];
let loadedModel = null;
let _statusInitDone = false; // guard: only reset UI to Kokoro on first fetchStatus
let loadingModel = null;
let dlBlob = null;
let lastRtf = 3.5; // 1.7B-Base ≈ 3.5× RTF, updated after first generation
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; // rolling reference: tail of last generated fish chunk
let bookQwenDesignAnchor = null; // voice anchor: first VoiceDesign chunk → reused as voice_clone ref for all subsequent chunks
let bookSeed = null; // fixed seed for voice consistency across all chunks
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' },
];
// Web Audio
let actx = null, sproc = null, pcmQueue = [], rawPcmParts = [], rawPcmSr = 24000;
let chunkQ = Promise.resolve();
let clientT0 = 0, firstChunkAt = null, firstAudioAt = null, lastBufS = 0, firstServerWall = null;
// Recording
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;
// ── Init ──────────────────────────────────────────────────────────────────────
window.addEventListener('load', async () => {
initTheme();
setLang(appLang);
setTTSLang(ttsLang);
autoGrow($('voiceInstr'));
updateEstimation();
selectStudioEngine('kokoro');
await fetchStatus();
if (!loadedModel && availableModels.length > 0) {
// Prefer CustomVoice as default (richer demo), fall back to first available
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 });
});
// ── Theme ─────────────────────────────────────────────────────────────────────
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"/>';
}
// ── TTS Language ──────────────────────────────────────────────────────────────
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;
// Update pill UI
document.querySelectorAll('.tlpill').forEach(p => p.classList.remove('active'));
const btn = $('tlp-' + lang); if (btn) btn.classList.add('active');
// Auto-set Kokoro voice
if ($('kokoroVoice') && cfg.kokoroVoice) $('kokoroVoice').value = cfg.kokoroVoice;
// Auto-set Qwen3 language selectors
if ($('langSel')) $('langSel').value = cfg.qwen;
if ($('langSelVis')) $('langSelVis').value = cfg.qwen;
// Sync Chatterbox and F5-TTS language selectors
if ($('cbLang')) $('cbLang').value = lang;
if ($('f5Lang')) $('f5Lang').value = lang;
// Show engine compatibility note
const note = document.getElementById('ttsLangNote');
if (note) note.textContent = cfg.engines;
}
// ── Voice Design Presets ───────────────────────────────────────────────────────
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'));
// Reset dropdown label after selection
setTimeout(() => { if ($('qwenDesignPreset')) $('qwenDesignPreset').selectedIndex = 0; }, 100);
}
// ── i18n ──────────────────────────────────────────────────────────────────────
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];
// Header
$('langBtn').textContent = t.langBtn;
// Tabs
$('tabBookLabel').textContent = t.tabBook;
// Engine group labels
$('grpLabel1').textContent = t.grp1;
$('grpLabel2').textContent = t.grp2;
// Engine row labels
$('engLblKokoro').textContent = t.engKokoro;
$('engLblChatterbox').textContent = t.engChatterbox;
$('engLblF5').textContent = t.engF5;
$('engLblFish').textContent = t.engFish;
$('engLblVoxtral').textContent = t.engVoxtral;
// Buttons
$('genBtnLabel').textContent = t.genBtn;
$('bookGenBtnLabel').textContent = t.bookGenBtn;
$('bookPauseLabel').textContent = t.bookPause;
$('bookResumeLabel').textContent = t.bookResume;
// Textarea placeholder
$('textIn').placeholder = t.textPh;
// document lang
document.documentElement.lang = lang;
}
// ── Settings ──────────────────────────────────────────────────────────────────
function openSettings() { $('settingsOv').classList.add('open'); }
function closeSettings(e) {
// Close only when clicking the backdrop or via button (no event)
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; // avoid uploading when preset is selected
setXvec(false); // presets have verified transcripts — use ICL mode
$('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);
}
}
// ── Auto-transcription ─────────────────────────────────────────────────────────
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; // transcription unavailable — leave field empty
const d = await res.json();
ta.value = d.text || '';
autoGrow(ta);
} catch {
// network error — leave field empty, user can type manually
} finally {
ta.disabled = false;
if (!ta.value) ta.placeholder = 'Type the full transcript here…';
}
}
// ── Model ─────────────────────────────────────────────────────────────────────
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');
// Kokoro + F5-TTS + Chatterbox always available
$('rowKokoro').classList.add('compatible');
$('rowF5').classList.add('compatible');
$('rowChatterbox').classList.add('compatible');
// Sync visible lang selector
if ($('langSelVis')) $('langSelVis').value = $('langSel').value || 'French';
// Update Qwen3 row badges dynamically
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…';
// Poll again in 3s until loaded
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';
// Mark Qwen rows compatible
['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';
}
// Only reset to Kokoro on the very first load — never during polls or reloads
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();
}
// ── Engine pill selector ───────────────────────────────────────────────────────
let currentEngine = 'kokoro'; // selected studio engine
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;
// Deactivate all pills
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';
// Switch inner panels
$('innerPanelFR').style.display = isFR ? '' : 'none';
$('innerPanelQwen').style.display = !isFR ? '' : 'none';
// Show/hide generate button (FR: show big btn; Qwen: use inline play btns)
const genRow = $('studioGenRow');
if (genRow) genRow.style.display = isFR ? '' : 'none';
if (isFR) {
// Show only the active FR row
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 {
// Activate Qwen mode
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);
});
// Sync with hidden selector
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');
// Sync model selection from visible selector if on Qwen tab
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; // 0.6B default
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);
}
// XTTS row is independent — stays compatible if visible
const xtts = $('rowXtts');
if (xtts && xtts.style.display !== 'none') {
xtts.classList.remove('incompatible', 'loading');
xtts.classList.add('compatible');
}
}
// ── Arcade mode loader ─────────────────────────────────────────────────────────
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' },
],
};
// Filtered at runtime by available_models from /status
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);
// Rebuild model selector options
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);
}
}
// Restore selection if still available, else pick first
if (avail.has(prev)) sel.value = prev;
else if (sel.options.length > 0) sel.value = sel.options[0].value;
// Filter MODE_MODELS to available only
MODE_MODELS = {};
for (const [mode, models] of Object.entries(ALL_MODE_MODELS)) {
MODE_MODELS[mode] = models.filter(m => avail.has(m.id));
}
// Hide mode rows that have no available models; show those that do
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();
}
// ── Speakers ──────────────────────────────────────────────────────────────────
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);
// Normalize to canonical capitalization from defaultSpeakers where known
ids = ids.map(id => defaultMap.get(String(id).toLowerCase())?.id ?? id);
// Keep previous selection even if backend list doesn't include it (case-insensitive)
if (prev && !ids.some(id => id.toLowerCase() === prev.toLowerCase())) ids = [prev, ...ids];
// Order: English first (based on known descriptions), then the rest
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; // keep backend ID for requests
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] ?? '';
}
}
// ── Mic devices ───────────────────────────────────────────────────────────────
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 {}
}
// ── Reference audio ───────────────────────────────────────────────────────────
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();
}
// ── Recording ─────────────────────────────────────────────────────────────────
function toggleRec() { if (recActive) stopRec(); else startRec(); }
async function startRec() {
if (!navigator.mediaDevices?.getUserMedia) {
showMsg('err', 'Recording not supported. Use HTTPS or localhost.');
return;
}
// Secure-context guard — 0.0.0.0 will silently fail without this
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(); // refresh labels now that permission is granted
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; // near-silent, keeps graph alive
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; }
// Show preview immediately from raw buffer — no resampling needed for playback
const rawWav = bufToWav(buf);
const prev = $('recPreview');
if (prev.src) URL.revokeObjectURL(prev.src);
prev.src = URL.createObjectURL(rawWav);
prev.style.display = 'block';
// Resample to 24kHz for server use (codec's native rate; transcription server handles 16kHz resampling)
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' });
}
// ── Web Audio: gapless streaming via ScriptProcessor ─────────────────────────
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' });
}
// ── Generate ──────────────────────────────────────────────────────────────────
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); // value is already the bare ID
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; }
// Capture server wall time on first chunk
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(() => {}); // gracefully handle autoplay block
}
// ── Metrics ───────────────────────────────────────────────────────────────────
function resetMetrics() {
['mTTFA', 'mClient', 'mRTF', 'mDur', 'mBuf', 'mClone'].forEach(id => $(id).innerHTML = '&mdash;');
$('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() {
// Client TTFA: time from request to first audio heard
if (firstAudioAt != null)
$('mClient').textContent = Math.round(firstAudioAt - clientT0) + 'ms';
else if (firstChunkAt != null)
$('mClient').textContent = Math.round(firstChunkAt - clientT0) + 'ms';
// Buffer: audio queued ahead of playback
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 = '';
}
// ── Messages ──────────────────────────────────────────────────────────────────
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 &amp; 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);
}
// ── Download ──────────────────────────────────────────────────────────────────
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);
}
// ── Tabs ──────────────────────────────────────────────────────────────────────
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);
});
// Sync speaker list from TTS panel when switching to Qwen3 Custom
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;
}
}
}
// ── Estimation ────────────────────────────────────────────────────────────────
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;
}
// ── Audiobook ─────────────────────────────────────────────────────────────────
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;
// Markdown headings (# / ## / ###)
if (/^#{1,3}\s+\S/.test(t)) return true;
// Explicit chapter/part keywords (multilingual)
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;
// Standalone structural words
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;
// Chapter + spelled-out number (EN & FR)
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;
// Roman numerals alone (I, II, III … with optional period)
if (/^[IVXLCDMivxlcdm]+\.?\s*$/.test(t) && t.length < 12) return true;
// Pure number (1 to 999, with optional period/colon)
if (/^\d{1,3}[\.:]?\s*$/.test(t)) return true;
// Number + separator + title → "1. Title", "12 – The Storm", "Part 3: War"
if (/^\d{1,3}[\.\-–—:]\s*\S/.test(t) && t.length <= 100) return true;
// ALL CAPS line (common in plain-text novels: "THE JOURNEY BEGINS")
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,
};
});
// Auto-uncheck front matter (sections before first real chapter)
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; });
}
// Reconstruct readable text for the textarea
$('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();
// Group items by approximate Y position to reconstruct lines
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);
// Find OPF path from container.xml
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');
// Build manifest map id → href
const manifest = {};
opfDoc.querySelectorAll('manifest item').forEach(item => {
const href = item.getAttribute('href') || '';
const id = item.getAttribute('id') || '';
manifest[id] = opfDir + decodeURIComponent(href);
});
// Follow spine order
const spine = [];
opfDoc.querySelectorAll('spine itemref').forEach(ref => {
const id = ref.getAttribute('idref');
if (manifest[id]) spine.push(manifest[id]);
});
// Each spine item → structured chapter object
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());
// Extract title from first heading or <title>
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;
// Skip very short pages (cover, TOC, copyright, colophon) and known non-content
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; // structured array
}
function parseBook() {
let raw = $('bookText').value.trim();
if (!raw) { $('bookInfo').textContent = 'Colle du texte d\'abord.'; return; }
// ── Project Gutenberg header/footer strip (arXiv:0707.1913) ──
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() === '';
// Section separator lines (---, ***, * * *)
if (isSectionSeparator(trimmed)) {
if (current && current.lines.join('\n').trim().length > 50) rawSections.push(current);
sepCounter++;
current = { title: ${sepCounter}`, lines: [], isSep: true };
continue;
}
// Strong patterns (always a heading, even without surrounding blank lines)
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);
// ── Anti-TOC heuristic (Chapter Captor paper + chapterize) ──
// If consecutive sections have < 4 non-empty body lines → they're TOC entries, skip them.
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 : [];
// Fallback: split by blank-line paragraphs if no chapters detected
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,
};
});
// ── Multi-pass front matter deselection ──
// Pass 1: TOC sections detected by anti-TOC heuristic
if (tocEndIdx > 0) bookChapters.slice(0, tocEndIdx).forEach(ch => { ch.isContent = false; });
// Pass 2: sections before first real chapter keyword
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; });
// Pass 3: known non-content title patterns
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);
}
// ──────────────────────────────────────────────────────
// BOOK READER
// ──────────────────────────────────────────────────────
const READER_PARAS_PER_PAGE = 14;
let readerParagraphs = [];
let readerStartPara = 0;
let readerEndPara = -1;
let readerPage = 0;
let readerMode = 'start'; // 'start' | 'end'
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, // index within chapter
text: p
// globalIdx assigned after push below
});
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);
}
// Sidebar: selection info
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>`;
// Sidebar: chapter list
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;
// Collect paragraphs for this chapter that fall within the selection
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');
// Only store trimmedText if we actually trimmed something
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,'&quot;')}">${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>