apolinario commited on
Commit
c09c6fe
·
1 Parent(s): d5824e0

Add Generate Audiobook: per-segment voices, combine to single MP3

Browse files
Files changed (1) hide show
  1. 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, "&lt;")}</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");