Files
Narrator/Narrator.html
T
2026-10-18 21:06:50 +00:00

652 lines
26 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Narrator</title>
<style>
* { box-sizing: border-box; border-radius: 0 !important; }
html, body { background: #fff; color: #000; }
body {
margin: 0;
font-family: "Segoe UI", Arial, sans-serif;
font-size: 16px;
line-height: 1.45;
}
.wrap { max-width: 860px; margin: 0 auto; padding: 28px 20px 60px; }
h1 { font-size: 34px; margin: 0 0 4px; letter-spacing: 1px; }
.sub { margin: 0 0 26px; color: #333; }
section {
border: 2px solid #000;
padding: 18px 20px 20px;
margin-bottom: 22px;
}
h2 { font-size: 21px; margin: 0 0 4px; }
h2 .num {
display: inline-block; width: 30px; height: 30px; line-height: 28px;
text-align: center; border: 2px solid #000; margin-right: 10px; font-size: 17px;
}
.hint { color: #444; font-size: 14px; margin: 2px 0 12px; }
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
input[type=text], select, textarea {
font: inherit; color: #000; background: #fff;
border: 2px solid #000; padding: 9px 10px;
}
input[type=text] { flex: 1 1 320px; min-width: 0; }
textarea { width: 100%; resize: vertical; }
#text { height: 260px; }
#fixes { height: 150px; font-family: Consolas, monospace; font-size: 14px; }
textarea:focus, input:focus, select:focus, button:focus-visible { outline: 3px solid #ff69b4; outline-offset: 1px; }
button {
font: inherit; font-weight: 600; cursor: pointer;
background: #fff; color: #000;
border: 2px solid #000; padding: 9px 16px;
}
button:hover:not(:disabled) { background: #000; color: #fff; }
button.primary { background: #000; color: #fff; font-size: 19px; padding: 13px 26px; }
button.primary:hover:not(:disabled) { background: #fff; color: #000; }
button:disabled { opacity: .35; cursor: not-allowed; }
label.check { display: flex; gap: 8px; align-items: center; cursor: pointer; margin-top: 10px; }
label.check input { width: 18px; height: 18px; accent-color: #000; }
.count { font-size: 14px; color: #444; margin-top: 6px; }
.voices { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 10px; }
.voice {
border: 2px solid #000; padding: 12px; display: flex; flex-direction: column; gap: 8px; cursor: pointer;
}
.voice.on { background: #000; color: #fff; }
.voice.on button { border-color: #fff; background: #000; color: #fff; }
.voice.on button:hover:not(:disabled) { background: #fff; color: #000; }
.voice .name { font-weight: 700; font-size: 18px; }
.voice .desc { font-size: 14px; }
.voice input { position: absolute; opacity: 0; pointer-events: none; }
.bar { height: 22px; border: 2px solid #000; margin-top: 14px; position: relative; }
.bar > div { height: 100%; width: 0; background: #000; transition: width .2s; }
#status { margin-top: 10px; min-height: 24px; font-weight: 600; }
#status.err { color: #b00000; }
audio { width: 100%; margin-top: 14px; }
details { border: 2px solid #000; padding: 12px 16px; margin-bottom: 22px; }
summary { cursor: pointer; font-weight: 700; font-size: 18px; }
.hidden { display: none !important; }
code { background: #f2f2f2; padding: 1px 4px; }
</style>
</head>
<body>
<div class="wrap">
<h1>Narrator</h1>
<p class="sub">Turns article text into an mp3. Free, no account, and everything runs on this computer.</p>
<section>
<h2><span class="num">1</span>Get the text</h2>
<p class="hint">Type a bloggin.space wiki article name (or paste its link) and click Load. Or skip that and paste any text into the big box yourself.</p>
<div class="row">
<input type="text" id="title" placeholder="Example: The Silmarillion">
<button id="loadBtn">Load from wiki</button>
</div>
<label class="check"><input type="checkbox" id="jokes" checked> Read joke footnotes out loud (real source links are always skipped)</label>
<textarea id="text" placeholder="The article text shows up here. You can edit it before making the mp3: cut parts you don't want read, fix words, anything. A line with * * * on it means a new section: it gets a slightly longer pause and is never read out loud."></textarea>
<div class="count" id="count">0 words</div>
</section>
<section>
<h2><span class="num">2</span>Pick a voice</h2>
<p class="hint">Click a box to choose it. Click Preview to hear a short sample first.</p>
<div class="voices" id="voices"></div>
<div class="row" style="margin-top:14px">
<strong>Speed:</strong>
<select id="speed">
<option value="0.85">Slower</option>
<option value="0.95">A little slower</option>
<option value="1" selected>Normal</option>
<option value="1.1">A little faster</option>
<option value="1.2">Faster</option>
</select>
<strong style="margin-left:18px">Pause between paragraphs:</strong>
<select id="gap">
<option value="0.12">Barely any</option>
<option value="0.3" selected>Short</option>
<option value="0.55">Normal</option>
</select>
</div>
</section>
<section>
<h2><span class="num">3</span>Make the mp3</h2>
<p class="hint">Long articles take a while. Leave this window open while it works. You can listen before you save.</p>
<div class="row">
<input type="text" id="fname" placeholder="File name">
<span>.mp3</span>
</div>
<div class="row" style="margin-top:14px">
<button class="primary" id="makeBtn">Make MP3</button>
<button id="stopBtn" class="hidden">Stop</button>
</div>
<div class="bar"><div id="barFill"></div></div>
<div id="status">Ready.</div>
<audio id="player" controls class="hidden"></audio>
<div class="row" style="margin-top:12px">
<button id="saveBtn" class="hidden">Save MP3</button>
<button id="srtBtn" class="hidden">Save subtitles (for Reel)</button>
</div>
</section>
<details>
<summary>Pronunciation fixes</summary>
<p class="hint">If the voice says a word wrong, add a line here: the word, an equals sign, then how it should sound spelled out. These are remembered for next time.</p>
<textarea id="fixes"></textarea>
</details>
<details>
<summary>Settings</summary>
<label class="check"><input type="checkbox" id="gpu"> Faster mode (uses the graphics chip; first download is bigger, about 330 MB)</label>
<p class="hint">Try faster mode if making audio is slow. If the voice sounds garbled or robotic in faster mode, turn it back off. The page reloads the voice engine when you change this.</p>
</details>
</div>
<script src="https://cdn.jsdelivr.net/npm/lamejs@1.2.1/lame.min.js"></script>
<script type="module">
import { KokoroTTS } from "https://cdn.jsdelivr.net/npm/kokoro-js@1.2.1/dist/kokoro.web.js";
const WIKI_API = "https://bloggin.space/wiki/api.php";
const MODEL = "onnx-community/Kokoro-82M-v1.0-ONNX";
const RATE = 24000;
const VOICES = [
{ id: "bm_george", name: "George", desc: "British man. Old documentary narrator." },
{ id: "bm_fable", name: "Fable", desc: "British man. Lighter, storybook reader." },
{ id: "bm_lewis", name: "Lewis", desc: "British man. Deeper and slower." },
{ id: "bm_daniel", name: "Daniel", desc: "British man. Plain, newsreader style." },
{ id: "bf_emma", name: "Emma", desc: "British woman. Calm audiobook voice." },
{ id: "am_puck", name: "Chill Surfer Dude", desc: "American man. Laid back and easygoing." },
{ id: "af_heart", name: "Heart", desc: "American woman. Warm and natural, the best of the bunch." },
{ id: "af_bella", name: "Bella", desc: "American woman. Bright and lively." },
{ id: "am_michael", name: "Michael", desc: "American man. Steady, everyday narrator." },
{ id: "am_fenrir", name: "Fenrir", desc: "American man. Deeper and firmer." },
{ id: "af_alloy", name: "Alloy", desc: "American woman. Even, businesslike." },
{ id: "af_aoede", name: "Aoede", desc: "American woman. Soft and gentle." },
{ id: "af_jessica", name: "Jessica", desc: "American woman. Casual, a bit rough." },
{ id: "af_kore", name: "Kore", desc: "American woman. Smooth and calm." },
{ id: "af_nicole", name: "Nicole", desc: "American woman. Breathy whisper. Good for creepy." },
{ id: "af_nova", name: "Nova", desc: "American woman. Clear and bright." },
{ id: "af_river", name: "River", desc: "American woman. Relaxed, a bit flat." },
{ id: "af_sarah", name: "Sarah", desc: "American woman. Friendly, everyday." },
{ id: "af_sky", name: "Sky", desc: "American woman. Light and quick." },
{ id: "am_adam", name: "Adam", desc: "American man. Plain, a bit robotic." },
{ id: "am_echo", name: "Echo", desc: "American man. Mellow." },
{ id: "am_eric", name: "Eric", desc: "American man. Casual and quick." },
{ id: "am_liam", name: "Liam", desc: "American man. Younger sounding." },
{ id: "am_onyx", name: "Onyx", desc: "American man. Deep and slow." },
{ id: "am_santa", name: "Santa", desc: "American man. Jolly old man. Yes, really." },
{ id: "bf_alice", name: "Alice", desc: "British woman. Crisp and proper." },
{ id: "bf_isabella",name: "Isabella",desc: "British woman. Warm storyteller." },
{ id: "bf_lily", name: "Lily", desc: "British woman. Light and young." },
];
const DEFAULT_FIXES = [
"Fëanor = Fay-uh-nor",
"Lúthien = Loo-thee-en",
"Ainulindalë = Eye-noo-lin-dah-lay",
"Ainur = Eye-noor",
"Maiar = My-ar",
"Valaquenta = Vah-lah-kwen-tah",
"Akallabêth = Ah-kah-lah-beth",
"Eärendil = Ay-ah-ren-dil",
"Ilúvatar = Ee-loo-vah-tar",
"Túrin = Too-rin",
"Turambar = Too-ram-bar",
"Númenor = Noo-meh-nor",
"Finwë = Fin-way",
"Quenya = Kwen-yah",
].join("\n");
const $ = (id) => document.getElementById(id);
const store = {
get(k, d) { try { const v = localStorage.getItem("narrator." + k); return v === null ? d : v; } catch { return d; } },
set(k, v) { try { localStorage.setItem("narrator." + k, v); } catch {} },
};
// ---------- UI setup ----------
let voice = store.get("voice", "bm_george");
if (!VOICES.some(v => v.id === voice)) voice = "bm_george";
function drawVoices() {
const box = $("voices");
box.innerHTML = "";
for (const v of VOICES) {
const card = document.createElement("div");
card.className = "voice" + (v.id === voice ? " on" : "");
card.innerHTML = `<div class="name">${v.name}</div><div class="desc">${v.desc}</div><button type="button">Preview</button>`;
card.addEventListener("click", (e) => {
voice = v.id; store.set("voice", voice); drawVoices();
});
card.querySelector("button").addEventListener("click", (e) => {
e.stopPropagation();
voice = v.id; store.set("voice", voice); drawVoices();
preview(v);
});
box.appendChild(card);
}
}
drawVoices();
$("fixes").value = store.get("fixes", DEFAULT_FIXES);
$("fixes").addEventListener("input", () => store.set("fixes", $("fixes").value));
$("speed").value = store.get("speed", "1");
$("speed").addEventListener("change", () => store.set("speed", $("speed").value));
$("gpu").checked = store.get("gpu", "0") === "1";
$("gpu").addEventListener("change", () => {
store.set("gpu", $("gpu").checked ? "1" : "0");
tts = null; ttsPromise = null;
status("Settings changed. The voice engine will reload next time you make audio.");
});
$("jokes").checked = store.get("jokes", "1") === "1";
$("jokes").addEventListener("change", () => store.set("jokes", $("jokes").checked ? "1" : "0"));
$("gap").value = store.get("gap", "0.3");
$("gap").addEventListener("change", () => store.set("gap", $("gap").value));
function updateCount() {
const words = ($("text").value.match(/\S+/g) || []).length;
const mins = Math.max(1, Math.round(words / 155));
$("count").textContent = words ? `${words.toLocaleString()} words, about ${mins} minute${mins === 1 ? "" : "s"} of audio` : "0 words";
}
$("text").addEventListener("input", updateCount);
function status(msg, isErr = false) {
$("status").textContent = msg;
$("status").className = isErr ? "err" : "";
}
function bar(frac) { $("barFill").style.width = Math.round(Math.min(1, Math.max(0, frac)) * 100) + "%"; }
function safeName(s) {
return s.trim().replace(/[\\/:*?"<>|]+/g, "").replace(/\s+/g, "_").slice(0, 120);
}
// ---------- Wiki loading ----------
function titleFromInput(raw) {
raw = raw.trim();
if (!raw) return "";
try {
const u = new URL(raw);
const t = u.searchParams.get("title");
if (t) return decodeURIComponent(t).replace(/_/g, " ");
const m = u.pathname.match(/index\.php\/(.+)$/) || u.pathname.match(/\/wiki\/(.+)$/);
if (m) return decodeURIComponent(m[1]).replace(/_/g, " ");
} catch {}
return raw.replace(/_/g, " ");
}
function isLinkOnly(text) {
const t = text.trim();
return !t || /^(https?:\/\/|www\.)\S+$/i.test(t) || /^\S+\.(com|net|org|gov|edu|io|space)(\/\S*)?$/i.test(t);
}
function cleanSentenceEnd(s) {
s = s.replace(/\s+/g, " ").trim();
if (s && !/[.!?:;"”']$/.test(s)) s += ".";
return s;
}
function htmlToNarration(html, title) {
const doc = new DOMParser().parseFromString(html, "text/html");
const root = doc.querySelector(".mw-parser-output") || doc.body;
const readJokes = $("jokes").checked;
// Collect footnote text by id
const notes = {};
root.querySelectorAll("ol.references li[id]").forEach(li => {
const t = (li.querySelector(".reference-text") || li).textContent.replace(/\s+/g, " ").trim();
notes[li.id] = t;
});
// Replace footnote markers
root.querySelectorAll("sup.reference").forEach(sup => {
const a = sup.querySelector("a[href^='#']");
const id = a ? decodeURIComponent(a.getAttribute("href").slice(1)) : "";
const t = notes[id] || "";
if (readJokes && t && !isLinkOnly(t)) {
sup.replaceWith(doc.createTextNode(` Footnote, ${cleanSentenceEnd(t)} `));
} else {
sup.remove();
}
});
root.querySelectorAll(".mw-editsection, style, script, .mw-empty-elt").forEach(n => n.remove());
const out = [title.trim() + "."];
for (const el of root.children) {
const tag = el.tagName.toLowerCase();
if (/^h[2-6]$/.test(tag) || el.classList.contains("mw-heading")) {
const h = el.textContent.replace(/\s+/g, " ").trim();
if (/^references$/i.test(h) || /^see also$/i.test(h) || /^external links$/i.test(h)) break;
if (out.length > 1) out.push("* * *");
continue;
}
if (tag === "p" || tag === "blockquote") {
const t = el.textContent.replace(/\s+/g, " ").trim();
if (t) out.push(t);
continue;
}
if ((tag === "ul" || tag === "ol" || tag === "dl") && !el.classList.contains("references")) {
const items = [...el.querySelectorAll(":scope > li, :scope > dd, :scope > dt")]
.map(li => cleanSentenceEnd(li.textContent))
.filter(Boolean);
if (items.length) out.push(items.join(" "));
}
// tables, divs, galleries, templates: skipped on purpose
}
return out.join("\n\n");
}
$("loadBtn").addEventListener("click", async () => {
const title = titleFromInput($("title").value);
if (!title) { status("Type an article name first.", true); return; }
$("loadBtn").disabled = true;
status(`Loading "${title}" from the wiki...`);
try {
const p = new URLSearchParams({
action: "parse", page: title, prop: "text|displaytitle",
format: "json", formatversion: "2", redirects: "1", origin: "*",
});
const r = await fetch(`${WIKI_API}?${p}`);
const j = await r.json();
if (j.error) throw new Error(j.error.code === "missingtitle" ? `There's no article called "${title}". Check the spelling.` : j.error.info);
const realTitle = j.parse.title;
$("text").value = htmlToNarration(j.parse.text, realTitle);
$("title").value = realTitle;
$("fname").value = safeName(realTitle) + "_text_to_speech";
updateCount();
status(`Loaded "${realTitle}". Look it over, then pick a voice.`);
} catch (e) {
console.error(e);
status("Couldn't load that article. " + (e.message || "") + " If the wiki is having its daily hiccup, wait two minutes and try again.", true);
} finally {
$("loadBtn").disabled = false;
}
});
// ---------- Text prep ----------
function applyFixes(text) {
const lines = $("fixes").value.split("\n");
for (const line of lines) {
const i = line.indexOf("=");
if (i < 1) continue;
const word = line.slice(0, i).trim();
const say = line.slice(i + 1).trim();
if (!word || !say) continue;
const esc = word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
text = text.replace(new RegExp(`(?<![\\p{L}\\p{N}])${esc}(?![\\p{L}\\p{N}])`, "gu"), say);
}
return text;
}
function splitLong(s, max) {
if (s.length <= max) return [s];
const parts = [];
let rest = s;
while (rest.length > max) {
let cut = Math.max(rest.lastIndexOf(", ", max), rest.lastIndexOf("; ", max), rest.lastIndexOf(" - ", max));
if (cut < max * 0.4) cut = rest.lastIndexOf(" ", max);
if (cut < 1) cut = max;
parts.push(rest.slice(0, cut + 1).trim());
rest = rest.slice(cut + 1).trim();
}
if (rest) parts.push(rest);
return parts;
}
// Smooths out punctuation that makes the voice stop and start
function soften(s) {
return s
.replace(/\s*[—–]\s*/g, ", ")
.replace(/\s+-\s+/g, ", ")
.replace(/\s*[;:]\s+/g, ", ")
.replace(/[()\[\]{}]/g, " ")
.replace(/\.{2,}|…/g, ".")
.replace(/\s*,\s*(,\s*)+/g, ", ")
.replace(/\s+([,.!?])/g, "$1")
.replace(/\s{2,}/g, " ")
.trim();
}
// Returns [{text, pauseAfter}] where pauseAfter is seconds of silence
function buildChunks(raw) {
const gap = Number($("gap").value);
const text = raw.replace(/\r/g, "");
const paras = text.split(/\n\s*\n|\n/).map(p => p.trim()).filter(Boolean);
const chunks = [];
for (const para of paras) {
if (/^(\*\s*){3}$|^-{3,}$/.test(para)) {
if (chunks.length) chunks[chunks.length - 1].pauseAfter = Math.max(gap * 2, 0.5);
continue;
}
const clean = soften(para);
const sentences = clean.match(/[^.!?]+(?:[.!?]+["”')\]]*|$)/g) || [clean];
let buf = "";
const flush = () => {
for (const piece of splitLong(buf.trim(), 420)) if (piece) chunks.push({ text: piece, pauseAfter: 0.08 });
buf = "";
};
for (const s of sentences) {
if ((buf + s).length > 400 && buf) flush();
buf += s;
}
if (buf.trim()) flush();
if (chunks.length) chunks[chunks.length - 1].pauseAfter = gap;
}
return chunks.filter(c => /[\p{L}\p{N}]/u.test(c.text));
}
// Cuts the dead air the voice leaves at the start and end of every piece
function trimSilence(a) {
const th = 0.012, pad = Math.round(RATE * 0.03);
let s = 0, e = a.length - 1;
while (s < a.length && Math.abs(a[s]) < th) s++;
while (e > s && Math.abs(a[e]) < th) e--;
return a.subarray(Math.max(0, s - pad), Math.min(a.length, e + pad + 1));
}
// ---------- Engine ----------
let tts = null, ttsPromise = null;
function getTTS() {
if (tts) return Promise.resolve(tts);
if (ttsPromise) return ttsPromise;
const gpu = $("gpu").checked && "gpu" in navigator;
const files = {};
status("Starting the voice engine. The first time, this downloads the voice model (about " + (gpu ? "330" : "90") + " MB)...");
ttsPromise = KokoroTTS.from_pretrained(MODEL, {
dtype: gpu ? "fp32" : "q8",
device: gpu ? "webgpu" : "wasm",
progress_callback: (p) => {
if (p.status === "progress" && p.total) {
files[p.file] = [p.loaded, p.total];
let l = 0, t = 0;
for (const [a, b] of Object.values(files)) { l += a; t += b; }
bar(l / t);
status(`Downloading the voice model: ${Math.round(l / 1048576)} of ${Math.round(t / 1048576)} MB`);
}
},
}).then(m => { tts = m; bar(0); return m; })
.catch(e => { ttsPromise = null; throw e; });
return ttsPromise;
}
// ---------- Audio helpers ----------
let busy = false, stopFlag = false, currentUrl = null, currentBlob = null, cues = [];
function setBusy(b) {
busy = b;
$("makeBtn").disabled = b;
$("loadBtn").disabled = b;
document.querySelectorAll(".voice button").forEach(x => x.disabled = b);
$("stopBtn").classList.toggle("hidden", !b);
}
function joinAudio(parts) {
let len = 0;
for (const p of parts) len += p.length;
const out = new Float32Array(len);
let o = 0;
for (const p of parts) { out.set(p, o); o += p.length; }
return out;
}
function toMp3(f32) {
const enc = new lamejs.Mp3Encoder(1, RATE, 64);
const block = 1152;
const pcm = new Int16Array(f32.length);
for (let i = 0; i < f32.length; i++) {
const s = Math.max(-1, Math.min(1, f32[i]));
pcm[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
}
const chunks = [];
for (let i = 0; i < pcm.length; i += block) {
const b = enc.encodeBuffer(pcm.subarray(i, i + block));
if (b.length) chunks.push(new Uint8Array(b));
}
const end = enc.flush();
if (end.length) chunks.push(new Uint8Array(end));
return new Blob(chunks, { type: "audio/mpeg" });
}
function fmtTime(sec) {
sec = Math.max(0, Math.round(sec));
const m = Math.floor(sec / 60), s = sec % 60;
return m ? `${m} min ${s} sec` : `${s} sec`;
}
const tick = () => new Promise(r => setTimeout(r, 0));
// ---------- Preview ----------
let previewAudio = null;
async function preview(v) {
if (busy) return;
setBusy(true);
try {
const m = await getTTS();
status(`Making a sample of ${v.name}...`);
await tick();
const a = await m.generate(`Hi, I'm ${v.name}. This is how I'd sound reading your article on bloggin space.`, { voice: v.id, speed: Number($("speed").value) });
const blob = a.toBlob ? a.toBlob() : new Blob([a.toWav()], { type: "audio/wav" });
if (previewAudio) { previewAudio.pause(); URL.revokeObjectURL(previewAudio.src); }
previewAudio = new Audio(URL.createObjectURL(blob));
previewAudio.play();
status(`Playing ${v.name}.`);
} catch (e) {
console.error(e);
status("The voice engine couldn't start: " + (e.message || e) + ". Check your internet connection for the first-time download, then try again.", true);
} finally {
setBusy(false);
}
}
// ---------- Make MP3 ----------
$("stopBtn").addEventListener("click", () => { stopFlag = true; status("Stopping after this sentence..."); });
$("makeBtn").addEventListener("click", async () => {
const raw = $("text").value.trim();
if (!raw) { status("There's no text yet. Load an article or paste some text in step 1.", true); return; }
if (!$("fname").value.trim()) $("fname").value = safeName($("title").value || "narration") + "_text_to_speech";
const chunks = buildChunks(raw);
if (!chunks.length) { status("There's nothing readable in the text box.", true); return; }
setBusy(true);
stopFlag = false;
$("saveBtn").classList.add("hidden");
$("srtBtn").classList.add("hidden");
$("player").classList.add("hidden");
try {
const m = await getTTS();
const speed = Number($("speed").value);
const totalChars = chunks.reduce((n, c) => n + c.text.length, 0);
let doneChars = 0;
const parts = [];
cues = [];
let pos = 0;
const t0 = performance.now();
for (let i = 0; i < chunks.length; i++) {
if (stopFlag) break;
const c = chunks[i];
const a = await m.generate(applyFixes(c.text), { voice, speed });
const said = trimSilence(a.audio);
cues.push({ start: pos / RATE, end: (pos + said.length) / RATE, text: c.text });
pos += said.length + Math.round(RATE * c.pauseAfter);
parts.push(said);
parts.push(new Float32Array(Math.round(RATE * c.pauseAfter)));
doneChars += c.text.length;
const frac = doneChars / totalChars;
const elapsed = (performance.now() - t0) / 1000;
const left = frac > 0.02 ? elapsed / frac - elapsed : null;
bar(frac);
status(`Reading... ${Math.round(frac * 100)}% done` + (left !== null ? `, about ${fmtTime(left)} left` : ""));
await tick();
}
if (!parts.length) { status("Stopped before anything was made."); return; }
status("Packing it into an mp3...");
await tick();
const audio = joinAudio(parts);
currentBlob = toMp3(audio);
if (currentUrl) URL.revokeObjectURL(currentUrl);
currentUrl = URL.createObjectURL(currentBlob);
$("player").src = currentUrl;
$("player").classList.remove("hidden");
$("saveBtn").classList.remove("hidden");
$("srtBtn").classList.remove("hidden");
bar(1);
const len = audio.length / RATE;
status((stopFlag ? "Stopped early. Here's what was made so far: " : "Done! ") + `${fmtTime(len)} of audio. Press play to check it, then click Save MP3.`);
} catch (e) {
console.error(e);
status("Something went wrong: " + (e.message || e) + (/webgpu/i.test(String(e)) ? " Turn off Faster mode in Settings and try again." : ""), true);
} finally {
setBusy(false);
}
});
$("saveBtn").addEventListener("click", () => {
if (!currentUrl) return;
const a = document.createElement("a");
a.href = currentUrl;
a.download = (safeName($("fname").value) || "narration") + ".mp3";
document.body.appendChild(a);
a.click();
a.remove();
status(`Saved ${a.download} to your Downloads folder.`);
});
// ---------- Subtitles (.srt) ----------
function srtTime(sec) {
const ms = Math.max(0, Math.round(sec * 1000));
const h = Math.floor(ms / 3600000), m = Math.floor(ms / 60000) % 60, s = Math.floor(ms / 1000) % 60;
const p = (n, w = 2) => String(n).padStart(w, "0");
return `${p(h)}:${p(m)}:${p(s)},${p(ms % 1000, 3)}`;
}
function buildSrt() {
const out = [];
for (const c of cues) {
const pieces = (c.text.match(/[^.!?]+(?:[.!?]+["”')\]]*|$)/g) || [c.text]).map(x => x.trim()).filter(Boolean);
const total = pieces.reduce((n, x) => n + x.length, 0) || 1;
let t = c.start;
for (const piece of pieces) {
const d = (c.end - c.start) * piece.length / total;
out.push({ a: t, b: t + d, text: piece });
t += d;
}
}
return out.map((q, i) => `${i + 1}\n${srtTime(q.a)} --> ${srtTime(q.b)}\n${q.text}\n`).join("\n");
}
$("srtBtn").addEventListener("click", () => {
if (!cues.length) return;
const url = URL.createObjectURL(new Blob([buildSrt()], { type: "text/plain" }));
const a = document.createElement("a");
a.href = url;
a.download = (safeName($("fname").value) || "narration") + ".srt";
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 5000);
status(`Saved ${a.download} to your Downloads folder. Load it in Reel along with the mp3.`);
});
updateCount();
</script>
</body>
</html>