Spaces:
Running
Running
Commit ·
c09c6fe
1
Parent(s): d5824e0
Add Generate Audiobook: per-segment voices, combine to single MP3
Browse files- index.html +370 -0
index.html
CHANGED
|
@@ -343,6 +343,39 @@ body {
|
|
| 343 |
}
|
| 344 |
.lang-select:focus { outline: none; border-color: var(--accent); }
|
| 345 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 346 |
/* Settings accordion */
|
| 347 |
.settings-accordion {
|
| 348 |
margin-bottom: 16px;
|
|
@@ -871,6 +904,10 @@ pre {
|
|
| 871 |
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="22"/></svg>
|
| 872 |
Voice Generation
|
| 873 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 874 |
<button class="nav-item" data-page="design" onclick="showPage('design', this)">
|
| 875 |
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
| 876 |
Voice Design
|
|
@@ -1031,6 +1068,74 @@ pre {
|
|
| 1031 |
</div>
|
| 1032 |
</div>
|
| 1033 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1034 |
<!-- Voice Design Page -->
|
| 1035 |
<div class="page" id="page-design">
|
| 1036 |
<div class="page-header">
|
|
@@ -2493,6 +2598,7 @@ window.showPage = function(page, el) {
|
|
| 2493 |
el.classList.add("active");
|
| 2494 |
if (page === "voices") { renderPresets(); renderVoicesPage(); }
|
| 2495 |
if (page === "history") renderHistory();
|
|
|
|
| 2496 |
};
|
| 2497 |
|
| 2498 |
window.copyCode = function(btn) {
|
|
@@ -3557,6 +3663,270 @@ window.generate = async function() {
|
|
| 3557 |
}
|
| 3558 |
};
|
| 3559 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3560 |
// ── Player ──
|
| 3561 |
|
| 3562 |
const audioEl = document.getElementById("audio-player");
|
|
|
|
| 343 |
}
|
| 344 |
.lang-select:focus { outline: none; border-color: var(--accent); }
|
| 345 |
|
| 346 |
+
/* Audiobook */
|
| 347 |
+
.ab-default-row { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
| 348 |
+
.ab-default-label { font-size: 12px; font-weight: 500; color: var(--text-secondary); }
|
| 349 |
+
.ab-split-btn {
|
| 350 |
+
padding: 6px 14px; background: var(--surface3); border: 1px solid var(--border);
|
| 351 |
+
border-radius: 7px; color: var(--text-secondary); font-size: 12px; font-weight: 600;
|
| 352 |
+
cursor: pointer; font-family: 'Inter', sans-serif;
|
| 353 |
+
}
|
| 354 |
+
.ab-split-btn:hover { color: var(--text); border-color: var(--border-hover); }
|
| 355 |
+
.ab-segments { display: flex; flex-direction: column; gap: 10px; margin-bottom: 16px; }
|
| 356 |
+
.ab-segment {
|
| 357 |
+
border: 1px solid var(--border); border-radius: 10px; background: var(--surface);
|
| 358 |
+
padding: 12px 14px; display: flex; flex-direction: column; gap: 8px;
|
| 359 |
+
}
|
| 360 |
+
.ab-segment-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
| 361 |
+
.ab-segment-idx { font-size: 11px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
| 362 |
+
.ab-segment-status { font-size: 11px; font-weight: 600; color: var(--text-muted); }
|
| 363 |
+
.ab-segment-status.done { color: #16a34a; }
|
| 364 |
+
.ab-segment-status.error { color: #dc2626; }
|
| 365 |
+
.ab-segment-status.active { color: var(--accent); }
|
| 366 |
+
.ab-segment textarea {
|
| 367 |
+
width: 100%; min-height: 56px; resize: vertical; border: 1px solid var(--border);
|
| 368 |
+
border-radius: 7px; background: var(--surface2); color: var(--text); font-size: 13px;
|
| 369 |
+
font-family: 'Inter', sans-serif; padding: 8px 10px; line-height: 1.5;
|
| 370 |
+
}
|
| 371 |
+
.ab-segment textarea:focus { outline: none; border-color: var(--accent); }
|
| 372 |
+
.ab-segment-foot { display: flex; align-items: center; gap: 8px; }
|
| 373 |
+
.ab-segment-del {
|
| 374 |
+
margin-left: auto; background: none; border: none; cursor: pointer; color: var(--text-muted);
|
| 375 |
+
font-size: 12px; padding: 2px 6px;
|
| 376 |
+
}
|
| 377 |
+
.ab-segment-del:hover { color: #dc2626; }
|
| 378 |
+
|
| 379 |
/* Settings accordion */
|
| 380 |
.settings-accordion {
|
| 381 |
margin-bottom: 16px;
|
|
|
|
| 904 |
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="22"/></svg>
|
| 905 |
Voice Generation
|
| 906 |
</button>
|
| 907 |
+
<button class="nav-item" data-page="audiobook" onclick="showPage('audiobook', this)">
|
| 908 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2Z"/></svg>
|
| 909 |
+
Audiobook
|
| 910 |
+
</button>
|
| 911 |
<button class="nav-item" data-page="design" onclick="showPage('design', this)">
|
| 912 |
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
| 913 |
Voice Design
|
|
|
|
| 1068 |
</div>
|
| 1069 |
</div>
|
| 1070 |
|
| 1071 |
+
<!-- Audiobook Page -->
|
| 1072 |
+
<div class="page" id="page-audiobook">
|
| 1073 |
+
<div class="page-header">
|
| 1074 |
+
<div class="page-title">Generate Audiobook</div>
|
| 1075 |
+
<div class="page-desc">Paste a long text, split it into paragraphs, pick a voice per segment, then export a single MP3.</div>
|
| 1076 |
+
</div>
|
| 1077 |
+
|
| 1078 |
+
<div class="ab-default-row">
|
| 1079 |
+
<span class="ab-default-label">Default voice</span>
|
| 1080 |
+
<select class="lang-select" id="ab-default-voice" onchange="abDefaultChanged()"></select>
|
| 1081 |
+
</div>
|
| 1082 |
+
|
| 1083 |
+
<div class="editor">
|
| 1084 |
+
<textarea id="ab-text" placeholder="Paste your text here. Separate paragraphs with a blank line — each paragraph becomes its own segment with its own voice."></textarea>
|
| 1085 |
+
<div class="editor-footer">
|
| 1086 |
+
<div class="editor-left">
|
| 1087 |
+
<span class="editor-stat"><span id="ab-char-count">0</span> chars</span>
|
| 1088 |
+
<select class="lang-select" id="ab-lang-select">
|
| 1089 |
+
<option value="Auto">Language (Auto)</option>
|
| 1090 |
+
<option value="English">English</option>
|
| 1091 |
+
<option value="Chinese">Chinese</option>
|
| 1092 |
+
<option value="French">French</option>
|
| 1093 |
+
<option value="German">German</option>
|
| 1094 |
+
<option value="Spanish">Spanish</option>
|
| 1095 |
+
<option value="Japanese">Japanese</option>
|
| 1096 |
+
<option value="Korean">Korean</option>
|
| 1097 |
+
<option value="Portuguese">Portuguese</option>
|
| 1098 |
+
<option value="Italian">Italian</option>
|
| 1099 |
+
<option value="Russian">Russian</option>
|
| 1100 |
+
<option value="Arabic">Arabic</option>
|
| 1101 |
+
<option value="Hindi">Hindi</option>
|
| 1102 |
+
</select>
|
| 1103 |
+
</div>
|
| 1104 |
+
<button class="ab-split-btn" type="button" onclick="splitAudiobook()">Split into segments</button>
|
| 1105 |
+
</div>
|
| 1106 |
+
</div>
|
| 1107 |
+
|
| 1108 |
+
<div class="ab-segments" id="ab-segments"></div>
|
| 1109 |
+
|
| 1110 |
+
<div class="generate-bar">
|
| 1111 |
+
<button class="btn-generate" id="ab-generate-btn" onclick="generateAudiobook()" disabled>
|
| 1112 |
+
<span class="btn-text">Generate Audiobook</span>
|
| 1113 |
+
<div class="spinner"></div>
|
| 1114 |
+
</button>
|
| 1115 |
+
</div>
|
| 1116 |
+
|
| 1117 |
+
<div class="status-bar" id="ab-status"></div>
|
| 1118 |
+
<div class="progress-track" id="ab-progress-bar"><div class="progress-fill" id="ab-progress-fill"></div></div>
|
| 1119 |
+
|
| 1120 |
+
<div class="player-card" id="ab-result">
|
| 1121 |
+
<div class="player-top">
|
| 1122 |
+
<button class="play-btn" onclick="document.getElementById('ab-audio-player').paused ? document.getElementById('ab-audio-player').play() : document.getElementById('ab-audio-player').pause()">
|
| 1123 |
+
<svg viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
| 1124 |
+
</button>
|
| 1125 |
+
<div class="player-info">
|
| 1126 |
+
<div class="player-title" id="ab-player-title">Audiobook</div>
|
| 1127 |
+
<div class="player-meta" id="ab-player-meta"></div>
|
| 1128 |
+
</div>
|
| 1129 |
+
<div style="display:flex;gap:6px">
|
| 1130 |
+
<button class="icon-btn" onclick="downloadAudiobook()" title="Download MP3">
|
| 1131 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
| 1132 |
+
</button>
|
| 1133 |
+
</div>
|
| 1134 |
+
</div>
|
| 1135 |
+
<audio id="ab-audio-player" class="player-audio" controls></audio>
|
| 1136 |
+
</div>
|
| 1137 |
+
</div>
|
| 1138 |
+
|
| 1139 |
<!-- Voice Design Page -->
|
| 1140 |
<div class="page" id="page-design">
|
| 1141 |
<div class="page-header">
|
|
|
|
| 2598 |
el.classList.add("active");
|
| 2599 |
if (page === "voices") { renderPresets(); renderVoicesPage(); }
|
| 2600 |
if (page === "history") renderHistory();
|
| 2601 |
+
if (page === "audiobook") initAudiobook();
|
| 2602 |
};
|
| 2603 |
|
| 2604 |
window.copyCode = function(btn) {
|
|
|
|
| 3663 |
}
|
| 3664 |
};
|
| 3665 |
|
| 3666 |
+
// ── Audiobook ──
|
| 3667 |
+
|
| 3668 |
+
let abSegments = []; // [{ text, voiceId, status, url }]
|
| 3669 |
+
let abVoiceOptions = []; // [{ id, label }]
|
| 3670 |
+
let abResultBlob = null;
|
| 3671 |
+
let abResultUrl = null;
|
| 3672 |
+
|
| 3673 |
+
async function audiobookVoiceOptions() {
|
| 3674 |
+
const voices = await getVoices();
|
| 3675 |
+
const usedPresetNames = new Set(voices.filter(v => v.isPreset).map(v => v.fileName.replace(/\.wav$/, "")));
|
| 3676 |
+
const opts = voices.map(v => ({ id: v.id, label: v.name }));
|
| 3677 |
+
PRESETS.filter(p => !usedPresetNames.has(p.name)).forEach(p =>
|
| 3678 |
+
opts.push({ id: `preset:${p.name}`, label: `${p.name.replace("_", " ")} (preset)` }));
|
| 3679 |
+
return opts;
|
| 3680 |
+
}
|
| 3681 |
+
|
| 3682 |
+
async function initAudiobook() {
|
| 3683 |
+
abVoiceOptions = await audiobookVoiceOptions();
|
| 3684 |
+
const sel = document.getElementById("ab-default-voice");
|
| 3685 |
+
const prev = sel.value;
|
| 3686 |
+
sel.innerHTML = abVoiceOptions.length
|
| 3687 |
+
? abVoiceOptions.map(o => `<option value="${o.id}">${o.label}</option>`).join("")
|
| 3688 |
+
: `<option value="">No voices — add one in the Voice Library</option>`;
|
| 3689 |
+
if (abVoiceOptions.some(o => o.id === prev)) sel.value = prev;
|
| 3690 |
+
else if (selectedVoice && abVoiceOptions.some(o => o.id === selectedVoice.id)) sel.value = selectedVoice.id;
|
| 3691 |
+
// re-sync any rendered segment selects with the (possibly updated) option list
|
| 3692 |
+
if (abSegments.length) renderAbSegments();
|
| 3693 |
+
abUpdateGenerateBtn();
|
| 3694 |
+
}
|
| 3695 |
+
|
| 3696 |
+
window.abDefaultChanged = function() {
|
| 3697 |
+
// segments inherit the default unless individually overridden — just re-render to refresh labels
|
| 3698 |
+
renderAbSegments();
|
| 3699 |
+
abUpdateGenerateBtn();
|
| 3700 |
+
};
|
| 3701 |
+
|
| 3702 |
+
function abUpdateGenerateBtn() {
|
| 3703 |
+
const def = document.getElementById("ab-default-voice").value;
|
| 3704 |
+
document.getElementById("ab-generate-btn").disabled = abSegments.length === 0 || !def;
|
| 3705 |
+
}
|
| 3706 |
+
|
| 3707 |
+
// "" = inherit global default; any other value = per-segment override
|
| 3708 |
+
function abVoiceSelectHtml(selectedId) {
|
| 3709 |
+
const defLabel = (abVoiceOptions.find(o => o.id === document.getElementById("ab-default-voice").value)?.label) || "—";
|
| 3710 |
+
return `<option value="" ${!selectedId ? "selected" : ""}>Default (${defLabel})</option>` +
|
| 3711 |
+
abVoiceOptions.map(o =>
|
| 3712 |
+
`<option value="${o.id}" ${o.id === selectedId ? "selected" : ""}>${o.label}</option>`).join("");
|
| 3713 |
+
}
|
| 3714 |
+
|
| 3715 |
+
window.splitAudiobook = function() {
|
| 3716 |
+
const raw = document.getElementById("ab-text").value;
|
| 3717 |
+
const paras = raw.split(/\n\s*\n/).map(p => p.trim()).filter(Boolean);
|
| 3718 |
+
abSegments = paras.map(text => ({ text, voiceId: "", status: "idle", url: null }));
|
| 3719 |
+
renderAbSegments();
|
| 3720 |
+
abUpdateGenerateBtn();
|
| 3721 |
+
};
|
| 3722 |
+
|
| 3723 |
+
function renderAbSegments() {
|
| 3724 |
+
const wrap = document.getElementById("ab-segments");
|
| 3725 |
+
wrap.innerHTML = abSegments.map((s, i) => {
|
| 3726 |
+
const statusCls = s.status === "done" ? "done" : s.status === "error" ? "error" : s.status === "active" ? "active" : "";
|
| 3727 |
+
const statusTxt = s.status === "done" ? "Done" : s.status === "error" ? "Failed" : s.status === "active" ? "Generating…" : "";
|
| 3728 |
+
return `<div class="ab-segment">
|
| 3729 |
+
<div class="ab-segment-head">
|
| 3730 |
+
<span class="ab-segment-idx">Segment ${i + 1}</span>
|
| 3731 |
+
<span class="ab-segment-status ${statusCls}">${statusTxt}</span>
|
| 3732 |
+
</div>
|
| 3733 |
+
<textarea oninput="abEditText(${i}, this.value)">${s.text.replace(/</g, "<")}</textarea>
|
| 3734 |
+
<div class="ab-segment-foot">
|
| 3735 |
+
<select class="lang-select" onchange="abSetVoice(${i}, this.value)">${abVoiceSelectHtml(s.voiceId)}</select>
|
| 3736 |
+
<button class="ab-segment-del" onclick="abDeleteSegment(${i})">Remove</button>
|
| 3737 |
+
</div>
|
| 3738 |
+
</div>`;
|
| 3739 |
+
}).join("");
|
| 3740 |
+
}
|
| 3741 |
+
|
| 3742 |
+
window.abEditText = function(i, v) { if (abSegments[i]) abSegments[i].text = v; };
|
| 3743 |
+
window.abSetVoice = function(i, v) { if (abSegments[i]) abSegments[i].voiceId = v; };
|
| 3744 |
+
window.abDeleteSegment = function(i) {
|
| 3745 |
+
abSegments.splice(i, 1);
|
| 3746 |
+
renderAbSegments();
|
| 3747 |
+
abUpdateGenerateBtn();
|
| 3748 |
+
};
|
| 3749 |
+
|
| 3750 |
+
async function resolveAbVoice(voiceId) {
|
| 3751 |
+
if (voiceId && voiceId.startsWith("preset:")) {
|
| 3752 |
+
const name = voiceId.slice(7);
|
| 3753 |
+
const data = await fetch(`samples/${name}.wav`).then(r => r.arrayBuffer());
|
| 3754 |
+
return { audioData: data, audioType: "audio/wav", fileName: `${name}.wav`, name: name.replace("_", " ") };
|
| 3755 |
+
}
|
| 3756 |
+
const voices = await getVoices();
|
| 3757 |
+
const v = voices.find(x => x.id === voiceId);
|
| 3758 |
+
if (!v) throw new Error("Voice not found");
|
| 3759 |
+
return v;
|
| 3760 |
+
}
|
| 3761 |
+
|
| 3762 |
+
// Run one OmniVoice clone request, resolve to the generated audio URL.
|
| 3763 |
+
async function omniClone(text, voice, lang, steps, speed) {
|
| 3764 |
+
const blob = new Blob([voice.audioData], { type: voice.audioType || "audio/mpeg" });
|
| 3765 |
+
const form = new FormData();
|
| 3766 |
+
form.append("files", blob, voice.fileName);
|
| 3767 |
+
const uploadRes = await fetch(`${OMNIVOICE_BASE}/upload`, {
|
| 3768 |
+
method: "POST",
|
| 3769 |
+
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : {},
|
| 3770 |
+
body: form
|
| 3771 |
+
});
|
| 3772 |
+
const [uploadPath] = await uploadRes.json();
|
| 3773 |
+
|
| 3774 |
+
const sessionHash = crypto.randomUUID();
|
| 3775 |
+
const joinRes = await fetch(`${OMNIVOICE_BASE}/queue/join`, {
|
| 3776 |
+
method: "POST",
|
| 3777 |
+
headers: { "Content-Type": "application/json", ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}) },
|
| 3778 |
+
body: JSON.stringify({
|
| 3779 |
+
data: [text, lang,
|
| 3780 |
+
{ path: uploadPath, meta: { _type: "gradio.FileData" }, orig_name: voice.fileName },
|
| 3781 |
+
"", "", steps, 2.0, true, speed, 0, true, true],
|
| 3782 |
+
fn_index: 0, session_hash: sessionHash
|
| 3783 |
+
})
|
| 3784 |
+
});
|
| 3785 |
+
if (!joinRes.ok) throw new Error(`Queue join failed: ${joinRes.status}`);
|
| 3786 |
+
|
| 3787 |
+
const streamRes = await fetch(`${OMNIVOICE_BASE}/queue/data?session_hash=${sessionHash}`,
|
| 3788 |
+
{ headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : {} });
|
| 3789 |
+
const reader = streamRes.body.getReader();
|
| 3790 |
+
const decoder = new TextDecoder();
|
| 3791 |
+
let buffer = "";
|
| 3792 |
+
while (true) {
|
| 3793 |
+
const { done, value } = await reader.read();
|
| 3794 |
+
if (done) break;
|
| 3795 |
+
buffer += decoder.decode(value, { stream: true });
|
| 3796 |
+
const lines = buffer.split("\n\n");
|
| 3797 |
+
buffer = lines.pop();
|
| 3798 |
+
for (const line of lines) {
|
| 3799 |
+
if (!line.startsWith("data: ")) continue;
|
| 3800 |
+
const data = JSON.parse(line.slice(6));
|
| 3801 |
+
if (data.msg === "process_completed") {
|
| 3802 |
+
if (data.success) return data.output.data[0].url;
|
| 3803 |
+
throw new Error(data.output?.error || "Generation failed");
|
| 3804 |
+
}
|
| 3805 |
+
}
|
| 3806 |
+
}
|
| 3807 |
+
throw new Error("Stream ended without result");
|
| 3808 |
+
}
|
| 3809 |
+
|
| 3810 |
+
// Combine remote audio URLs into a single mono MP3 blob.
|
| 3811 |
+
async function combineToMp3(urls, gapSec = 0.4) {
|
| 3812 |
+
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
| 3813 |
+
try {
|
| 3814 |
+
const buffers = [];
|
| 3815 |
+
for (const url of urls) {
|
| 3816 |
+
const ab = await fetch(url).then(r => r.arrayBuffer());
|
| 3817 |
+
buffers.push(await ctx.decodeAudioData(ab));
|
| 3818 |
+
}
|
| 3819 |
+
const sr = ctx.sampleRate;
|
| 3820 |
+
const gap = Math.round(gapSec * sr);
|
| 3821 |
+
let total = 0;
|
| 3822 |
+
buffers.forEach((b, i) => { total += b.length + (i < buffers.length - 1 ? gap : 0); });
|
| 3823 |
+
const mono = new Float32Array(total);
|
| 3824 |
+
let off = 0;
|
| 3825 |
+
buffers.forEach((b, i) => {
|
| 3826 |
+
const ch0 = b.getChannelData(0);
|
| 3827 |
+
if (b.numberOfChannels > 1) {
|
| 3828 |
+
const ch1 = b.getChannelData(1);
|
| 3829 |
+
for (let j = 0; j < b.length; j++) mono[off + j] = (ch0[j] + ch1[j]) / 2;
|
| 3830 |
+
} else {
|
| 3831 |
+
mono.set(ch0, off);
|
| 3832 |
+
}
|
| 3833 |
+
off += b.length + (i < buffers.length - 1 ? gap : 0);
|
| 3834 |
+
});
|
| 3835 |
+
const int16 = new Int16Array(mono.length);
|
| 3836 |
+
for (let i = 0; i < mono.length; i++) {
|
| 3837 |
+
const s = Math.max(-1, Math.min(1, mono[i]));
|
| 3838 |
+
int16[i] = s < 0 ? s * 32768 : s * 32767;
|
| 3839 |
+
}
|
| 3840 |
+
const lamejs = await import("https://esm.sh/@breezystack/lamejs@1.2.7");
|
| 3841 |
+
const enc = new lamejs.Mp3Encoder(1, sr, 128);
|
| 3842 |
+
const block = 1152;
|
| 3843 |
+
const parts = [];
|
| 3844 |
+
for (let i = 0; i < int16.length; i += block) {
|
| 3845 |
+
const mp3 = enc.encodeBuffer(int16.subarray(i, i + block));
|
| 3846 |
+
if (mp3.length > 0) parts.push(new Uint8Array(mp3));
|
| 3847 |
+
}
|
| 3848 |
+
const end = enc.flush();
|
| 3849 |
+
if (end.length > 0) parts.push(new Uint8Array(end));
|
| 3850 |
+
const durSec = total / sr;
|
| 3851 |
+
return { blob: new Blob(parts, { type: "audio/mpeg" }), durSec };
|
| 3852 |
+
} finally {
|
| 3853 |
+
ctx.close();
|
| 3854 |
+
}
|
| 3855 |
+
}
|
| 3856 |
+
|
| 3857 |
+
window.generateAudiobook = async function() {
|
| 3858 |
+
if (!abSegments.length) return;
|
| 3859 |
+
const lang = document.getElementById("ab-lang-select").value;
|
| 3860 |
+
const defaultVoiceId = document.getElementById("ab-default-voice").value;
|
| 3861 |
+
const btn = document.getElementById("ab-generate-btn");
|
| 3862 |
+
const progressBar = document.getElementById("ab-progress-bar");
|
| 3863 |
+
const progressFill = document.getElementById("ab-progress-fill");
|
| 3864 |
+
const result = document.getElementById("ab-result");
|
| 3865 |
+
const statusEl = document.getElementById("ab-status");
|
| 3866 |
+
|
| 3867 |
+
btn.disabled = true;
|
| 3868 |
+
btn.classList.add("loading");
|
| 3869 |
+
result.classList.remove("visible");
|
| 3870 |
+
progressBar.classList.add("visible");
|
| 3871 |
+
progressFill.style.width = "0%";
|
| 3872 |
+
|
| 3873 |
+
const abShowStatus = (type, msg) => { statusEl.className = `status-bar visible ${type}`; statusEl.innerHTML = `<div class="status-text">${msg}</div>`; };
|
| 3874 |
+
const t0 = Date.now();
|
| 3875 |
+
try {
|
| 3876 |
+
const urls = [];
|
| 3877 |
+
for (let i = 0; i < abSegments.length; i++) {
|
| 3878 |
+
const seg = abSegments[i];
|
| 3879 |
+
seg.status = "active"; renderAbSegments();
|
| 3880 |
+
abShowStatus("info", `Generating segment ${i + 1} of ${abSegments.length}…`);
|
| 3881 |
+
try {
|
| 3882 |
+
const voice = await resolveAbVoice(seg.voiceId || defaultVoiceId);
|
| 3883 |
+
const url = await omniClone(seg.text, voice, lang, 32, 1.0);
|
| 3884 |
+
seg.url = url; seg.status = "done"; urls.push(url);
|
| 3885 |
+
} catch (err) {
|
| 3886 |
+
seg.status = "error"; renderAbSegments();
|
| 3887 |
+
abShowStatus("error", `Segment ${i + 1} failed: ${err.message}`);
|
| 3888 |
+
if (statusEl.innerHTML && err.message.includes("GPU quota")) { await refreshQuota(); statusEl.innerHTML = quotaErrorBodyHtml(err.message); statusEl.className = "status-bar visible error"; }
|
| 3889 |
+
return;
|
| 3890 |
+
}
|
| 3891 |
+
renderAbSegments();
|
| 3892 |
+
progressFill.style.width = `${Math.round(((i + 1) / abSegments.length) * 85)}%`;
|
| 3893 |
+
refreshQuota();
|
| 3894 |
+
}
|
| 3895 |
+
|
| 3896 |
+
abShowStatus("info", "Combining segments into one MP3…");
|
| 3897 |
+
progressFill.style.width = "92%";
|
| 3898 |
+
const { blob, durSec } = await combineToMp3(urls);
|
| 3899 |
+
abResultBlob = blob;
|
| 3900 |
+
if (abResultUrl) URL.revokeObjectURL(abResultUrl);
|
| 3901 |
+
abResultUrl = URL.createObjectURL(blob);
|
| 3902 |
+
progressFill.style.width = "100%";
|
| 3903 |
+
|
| 3904 |
+
document.getElementById("ab-audio-player").src = abResultUrl;
|
| 3905 |
+
document.getElementById("ab-player-title").textContent = `Audiobook · ${abSegments.length} segment${abSegments.length > 1 ? "s" : ""}`;
|
| 3906 |
+
document.getElementById("ab-player-meta").textContent = `${Math.round(durSec)}s · ${(blob.size / 1048576).toFixed(1)} MB MP3`;
|
| 3907 |
+
result.classList.add("visible");
|
| 3908 |
+
abShowStatus("success", `Audiobook ready in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
|
| 3909 |
+
} catch (err) {
|
| 3910 |
+
abShowStatus("error", err.message);
|
| 3911 |
+
} finally {
|
| 3912 |
+
btn.disabled = false;
|
| 3913 |
+
btn.classList.remove("loading");
|
| 3914 |
+
setTimeout(() => { progressBar.classList.remove("visible"); }, 2000);
|
| 3915 |
+
}
|
| 3916 |
+
};
|
| 3917 |
+
|
| 3918 |
+
window.downloadAudiobook = function() {
|
| 3919 |
+
if (!abResultBlob) return;
|
| 3920 |
+
const a = document.createElement("a");
|
| 3921 |
+
a.href = abResultUrl;
|
| 3922 |
+
a.download = "audiobook.mp3";
|
| 3923 |
+
a.click();
|
| 3924 |
+
};
|
| 3925 |
+
|
| 3926 |
+
document.getElementById("ab-text").addEventListener("input", e => {
|
| 3927 |
+
document.getElementById("ab-char-count").textContent = e.target.value.length;
|
| 3928 |
+
});
|
| 3929 |
+
|
| 3930 |
// ── Player ──
|
| 3931 |
|
| 3932 |
const audioEl = document.getElementById("audio-player");
|