|
|
@@ -0,0 +1,651 @@
|
|
|
+document.addEventListener("DOMContentLoaded", () => {
|
|
|
+ const modeBtns = {
|
|
|
+ page: document.getElementById("mode-page"),
|
|
|
+ paragraph: document.getElementById("mode-paragraph"),
|
|
|
+ selection: document.getElementById("mode-selection"),
|
|
|
+ };
|
|
|
+ const playBtn = document.getElementById("play-btn");
|
|
|
+ const grammarBtn = document.getElementById("grammar-btn");
|
|
|
+ const stopBtn = document.getElementById("stop-btn");
|
|
|
+ const closeBtn = document.getElementById("close-btn");
|
|
|
+ const statusDiv = document.getElementById("status");
|
|
|
+ const speedBtn = document.getElementById("speed-btn");
|
|
|
+
|
|
|
+ let currentMode = "paragraph";
|
|
|
+ let currentSpeed = 1.0;
|
|
|
+ const speedOptions = [1.0, 1.2, 1.5, 2.0];
|
|
|
+ let audioContext = null;
|
|
|
+ let currentAudio = null;
|
|
|
+ let playController = null;
|
|
|
+ let isPaused = false;
|
|
|
+ let isPlaying = false;
|
|
|
+
|
|
|
+ function initAudioContext() {
|
|
|
+ if (!audioContext) {
|
|
|
+ audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function setMode(mode) {
|
|
|
+ currentMode = mode;
|
|
|
+ Object.entries(modeBtns).forEach(([key, btn]) => {
|
|
|
+ btn.classList.toggle("active", key === mode);
|
|
|
+ });
|
|
|
+ updateStatus(`Mode: ${mode}`);
|
|
|
+ }
|
|
|
+
|
|
|
+ function setSpeed(speed) {
|
|
|
+ currentSpeed = speed;
|
|
|
+ speedBtn.textContent = speed === Math.floor(speed) ? `${speed.toFixed(0)}x` : `${speed}x`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function updatePlayButton() {
|
|
|
+ if (isPaused) {
|
|
|
+ playBtn.textContent = "▶";
|
|
|
+ } else if (isPlaying) {
|
|
|
+ playBtn.textContent = "⏸";
|
|
|
+ } else {
|
|
|
+ playBtn.textContent = "▶";
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function updateStatus(message) {
|
|
|
+ statusDiv.textContent = message;
|
|
|
+ }
|
|
|
+
|
|
|
+ function handleError(error) {
|
|
|
+ console.error("Error:", error);
|
|
|
+ const message = error?.message || "Unknown error";
|
|
|
+ isPaused = false;
|
|
|
+ isPlaying = false;
|
|
|
+ updatePlayButton();
|
|
|
+ updateStatus(`Error: ${message.replace(/^Error:\s*/i, "")}`);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---------- Mode Switching ----------
|
|
|
+
|
|
|
+ Object.entries(modeBtns).forEach(([key, btn]) => {
|
|
|
+ btn.addEventListener("click", () => setMode(key));
|
|
|
+ });
|
|
|
+
|
|
|
+ // ---------- Close ----------
|
|
|
+
|
|
|
+ closeBtn.addEventListener("click", async () => {
|
|
|
+ try {
|
|
|
+ const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
|
|
+ if (tab?.id) {
|
|
|
+ chrome.tabs.sendMessage(tab.id, { action: "deactivatePlayer" }).catch(() => {});
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error(e);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // ---------- Speed ----------
|
|
|
+
|
|
|
+ speedBtn.addEventListener("click", () => {
|
|
|
+ const idx = speedOptions.indexOf(currentSpeed);
|
|
|
+ const next = speedOptions[(idx + 1) % speedOptions.length];
|
|
|
+ setSpeed(next);
|
|
|
+ });
|
|
|
+
|
|
|
+ // ---------- Play / Pause / Resume ----------
|
|
|
+
|
|
|
+ playBtn.addEventListener("click", async () => {
|
|
|
+ try {
|
|
|
+ if (isPaused && currentAudio) {
|
|
|
+ const resumed = await resumeCurrentAudio();
|
|
|
+ if (!resumed) {
|
|
|
+ updateStatus("Nothing to resume");
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (isPlaying && currentAudio && !currentAudio.paused) {
|
|
|
+ const paused = pauseCurrentAudio();
|
|
|
+ if (!paused) {
|
|
|
+ updateStatus("Nothing is playing");
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ initAudioContext();
|
|
|
+ await getPlayPermission();
|
|
|
+
|
|
|
+ const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
|
|
+ if (!tab?.id) {
|
|
|
+ updateStatus("Unable to access current tab");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ let payload = null;
|
|
|
+
|
|
|
+ if (currentMode === "selection") {
|
|
|
+ const selectedText = await extractSelectedText(tab.id);
|
|
|
+ if (!selectedText) {
|
|
|
+ updateStatus("No text is selected");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ payload = { text: selectedText, scope: "selection" };
|
|
|
+ } else if (currentMode === "paragraph") {
|
|
|
+ updateStatus("Finding paragraph near cursor...");
|
|
|
+ const paragraph = await extractContextualParagraph(tab.id);
|
|
|
+ if (!paragraph?.text) {
|
|
|
+ updateStatus("No readable paragraph detected");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ payload = { ...paragraph, scope: "paragraph" };
|
|
|
+ } else {
|
|
|
+ updateStatus("Scanning article body...");
|
|
|
+ const article = await extractArticleContent(tab.id);
|
|
|
+ if (!article?.text) {
|
|
|
+ updateStatus("Could not identify main content");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ payload = { ...article, scope: "page" };
|
|
|
+ }
|
|
|
+
|
|
|
+ await startConversion(payload);
|
|
|
+ } catch (error) {
|
|
|
+ handleError(error);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // ---------- Grammar ----------
|
|
|
+
|
|
|
+ grammarBtn.addEventListener("click", async () => {
|
|
|
+ try {
|
|
|
+ const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
|
|
+ if (!tab?.id) {
|
|
|
+ updateStatus("Unable to access current tab");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ updateStatus("Analyzing grammar...");
|
|
|
+ grammarBtn.disabled = true;
|
|
|
+
|
|
|
+ const response = await new Promise((resolve) => {
|
|
|
+ chrome.tabs.sendMessage(
|
|
|
+ tab.id,
|
|
|
+ { type: "EXECUTE_GRAMMAR", mode: currentMode },
|
|
|
+ (res) => {
|
|
|
+ if (chrome.runtime.lastError) {
|
|
|
+ resolve({ error: chrome.runtime.lastError.message });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ resolve(res || { error: "No response" });
|
|
|
+ }
|
|
|
+ );
|
|
|
+ });
|
|
|
+
|
|
|
+ grammarBtn.disabled = false;
|
|
|
+
|
|
|
+ if (response.error) {
|
|
|
+ updateStatus(`Grammar error: ${response.error}`);
|
|
|
+ } else {
|
|
|
+ updateStatus("Grammar highlighted!");
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ grammarBtn.disabled = false;
|
|
|
+ handleError(error);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // ---------- Stop ----------
|
|
|
+
|
|
|
+ stopBtn.addEventListener("click", () => {
|
|
|
+ stopPlayback();
|
|
|
+ updateStatus("Playback stopped");
|
|
|
+ });
|
|
|
+
|
|
|
+ // ---------- Text Extraction ----------
|
|
|
+
|
|
|
+ async function extractSelectedText(tabId) {
|
|
|
+ return new Promise((resolve) => {
|
|
|
+ chrome.scripting.executeScript(
|
|
|
+ {
|
|
|
+ target: { tabId },
|
|
|
+ function: () => {
|
|
|
+ const selection = window.getSelection();
|
|
|
+ return selection ? selection.toString().trim() : "";
|
|
|
+ }
|
|
|
+ },
|
|
|
+ (results) => resolve(results?.[0]?.result || "")
|
|
|
+ );
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ async function extractContextualParagraph(tabId) {
|
|
|
+ return new Promise((resolve) => {
|
|
|
+ chrome.scripting.executeScript(
|
|
|
+ {
|
|
|
+ target: { tabId },
|
|
|
+ function: () => {
|
|
|
+ const adLikePattern = /(advert|广告|推广|sponsored|subscribe|buy now|click (here|to)|share this|follow us|免责声明|copyright)/i;
|
|
|
+ const minLength = 40;
|
|
|
+
|
|
|
+ function findParagraphElement(node) {
|
|
|
+ const blockTags = ["P", "DIV", "ARTICLE", "SECTION", "LI", "BLOCKQUOTE"];
|
|
|
+ while (node && node !== document.body) {
|
|
|
+ if (node.nodeType === Node.ELEMENT_NODE && blockTags.includes(node.nodeName)) {
|
|
|
+ return node;
|
|
|
+ }
|
|
|
+ node = node.parentElement;
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ function isVisible(el) {
|
|
|
+ if (!el || el.nodeType !== Node.ELEMENT_NODE) return false;
|
|
|
+ const rect = el.getBoundingClientRect();
|
|
|
+ if (!rect || rect.width === 0 || rect.height === 0) return false;
|
|
|
+ const style = window.getComputedStyle(el);
|
|
|
+ return style.display !== "none" && style.visibility !== "hidden" && parseFloat(style.opacity) > 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ function cleanText(value) {
|
|
|
+ return (value || "").replace(/\s+/g, " ").trim();
|
|
|
+ }
|
|
|
+
|
|
|
+ const selection = window.getSelection();
|
|
|
+ const anchorNode = selection ? selection.anchorNode : null;
|
|
|
+ if (anchorNode) {
|
|
|
+ const candidate = findParagraphElement(anchorNode);
|
|
|
+ if (candidate && isVisible(candidate)) {
|
|
|
+ const text = cleanText(candidate.innerText);
|
|
|
+ if (text.length >= minLength && !adLikePattern.test(text)) {
|
|
|
+ return { text };
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, {
|
|
|
+ acceptNode: (node) => {
|
|
|
+ if (!isVisible(node)) return NodeFilter.FILTER_REJECT;
|
|
|
+ const validTags = ["P", "DIV", "ARTICLE", "SECTION", "LI", "BLOCKQUOTE"];
|
|
|
+ if (!validTags.includes(node.nodeName)) return NodeFilter.FILTER_SKIP;
|
|
|
+ const text = cleanText(node.innerText);
|
|
|
+ if (text.length < minLength) return NodeFilter.FILTER_SKIP;
|
|
|
+ if (adLikePattern.test(text)) return NodeFilter.FILTER_SKIP;
|
|
|
+ return NodeFilter.FILTER_ACCEPT;
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ const node = walker.nextNode();
|
|
|
+ if (node) {
|
|
|
+ const text = cleanText(node.innerText);
|
|
|
+ return { text };
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ },
|
|
|
+ (results) => resolve(results?.[0]?.result || null)
|
|
|
+ );
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ async function extractArticleContent(tabId) {
|
|
|
+ return new Promise((resolve) => {
|
|
|
+ chrome.scripting.executeScript(
|
|
|
+ {
|
|
|
+ target: { tabId },
|
|
|
+ function: () => {
|
|
|
+ const blockedPattern = /(nav|header|footer|aside|breadcrumb|sidebar|advert|promo|sponsor|toolbar|comment|popup|share|related|recommend|subscribe|newsletter)/i;
|
|
|
+ const adKeywordPattern = /(advertisement|sponsored|promotion|buy now|click here|subscribe|share this|follow us|版权所有|广告|推广)/i;
|
|
|
+
|
|
|
+ function cleanText(value) {
|
|
|
+ return (value || "").replace(/\s+/g, " ").trim();
|
|
|
+ }
|
|
|
+
|
|
|
+ function isVisible(el) {
|
|
|
+ if (!el || el.nodeType !== Node.ELEMENT_NODE) return false;
|
|
|
+ const rect = el.getBoundingClientRect();
|
|
|
+ if (!rect || rect.width === 0 || rect.height === 0) return false;
|
|
|
+ const style = window.getComputedStyle(el);
|
|
|
+ if (style.display === "none" || style.visibility === "hidden" || parseFloat(style.opacity) === 0) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ function scoreNode(node) {
|
|
|
+ const text = cleanText(node.innerText);
|
|
|
+ if (text.length < 300) return 0;
|
|
|
+ const links = node.querySelectorAll("a");
|
|
|
+ let linkTextLength = 0;
|
|
|
+ links.forEach((link) => {
|
|
|
+ linkTextLength += cleanText(link.innerText).length;
|
|
|
+ });
|
|
|
+ const linkDensity = text.length ? Math.min(linkTextLength / text.length, 1) : 0;
|
|
|
+ const headingPenalty = node.querySelectorAll("h1,h2,h3").length * 40;
|
|
|
+ const meta = `${node.className || ""} ${node.id || ""}`;
|
|
|
+ const adPenalty = blockedPattern.test(meta) ? 200 : 0;
|
|
|
+ return text.length * (1 - linkDensity) - headingPenalty - adPenalty;
|
|
|
+ }
|
|
|
+
|
|
|
+ let candidates = Array.from(document.querySelectorAll("article, main, section"));
|
|
|
+ candidates = candidates.filter(
|
|
|
+ (node) => isVisible(node) && !blockedPattern.test(`${node.className || ""} ${node.id || ""}`)
|
|
|
+ );
|
|
|
+
|
|
|
+ if (!candidates.length) {
|
|
|
+ candidates = Array.from(document.querySelectorAll("div"));
|
|
|
+ candidates = candidates.filter(
|
|
|
+ (node) => isVisible(node) && !blockedPattern.test(`${node.className || ""} ${node.id || ""}`)
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ candidates.sort((a, b) => scoreNode(b) - scoreNode(a));
|
|
|
+ const best = candidates[0] || document.body;
|
|
|
+
|
|
|
+ const bannedSelectors = [
|
|
|
+ "header", "nav", "aside", "footer", "form", "button",
|
|
|
+ "#header", "#nav", "#footer", "#sidebar",
|
|
|
+ '[id*="ad" i]', '[class*="ad" i]',
|
|
|
+ '[id*="promo" i]', '[class*="promo" i]',
|
|
|
+ '[id*="sponsor" i]', '[class*="sponsor" i]'
|
|
|
+ ];
|
|
|
+
|
|
|
+ const uniqueTexts = new Set();
|
|
|
+ const chunks = [];
|
|
|
+
|
|
|
+ const walker = document.createTreeWalker(best, NodeFilter.SHOW_ELEMENT, {
|
|
|
+ acceptNode: (node) => {
|
|
|
+ if (!isVisible(node)) return NodeFilter.FILTER_REJECT;
|
|
|
+ for (const selector of bannedSelectors) {
|
|
|
+ if (typeof selector === "string") {
|
|
|
+ try {
|
|
|
+ if (node.closest(selector)) {
|
|
|
+ return NodeFilter.FILTER_REJECT;
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ // ignore invalid selectors
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (/^H[1-6]$/i.test(node.nodeName)) {
|
|
|
+ return NodeFilter.FILTER_REJECT;
|
|
|
+ }
|
|
|
+ const allowedTags = ["P", "DIV", "SECTION", "ARTICLE", "LI", "BLOCKQUOTE", "PRE"];
|
|
|
+ if (!allowedTags.includes(node.nodeName)) {
|
|
|
+ return NodeFilter.FILTER_SKIP;
|
|
|
+ }
|
|
|
+ const text = cleanText(node.innerText);
|
|
|
+ if (text.length < 40) return NodeFilter.FILTER_SKIP;
|
|
|
+ if (adKeywordPattern.test(text)) return NodeFilter.FILTER_SKIP;
|
|
|
+ return NodeFilter.FILTER_ACCEPT;
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ while (walker.nextNode()) {
|
|
|
+ const text = cleanText(walker.currentNode.innerText);
|
|
|
+ if (!text) continue;
|
|
|
+ const key = text.slice(0, 120);
|
|
|
+ if (uniqueTexts.has(key)) continue;
|
|
|
+ uniqueTexts.add(key);
|
|
|
+ chunks.push(text);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!chunks.length) {
|
|
|
+ const fallback = cleanText(best.innerText);
|
|
|
+ if (fallback.length < 80) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ text: fallback.slice(0, 8000),
|
|
|
+ meta: { paragraphCount: 1, charCount: Math.min(8000, fallback.length) }
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ const combined = chunks.join("\n\n");
|
|
|
+ const truncated = combined.length > 8000 ? combined.slice(0, 8000) : combined;
|
|
|
+
|
|
|
+ return {
|
|
|
+ text: truncated,
|
|
|
+ meta: { paragraphCount: chunks.length, charCount: truncated.length }
|
|
|
+ };
|
|
|
+ }
|
|
|
+ },
|
|
|
+ (results) => resolve(results?.[0]?.result || null)
|
|
|
+ );
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---------- Audio Helpers ----------
|
|
|
+
|
|
|
+ async function getPlayPermission() {
|
|
|
+ return new Promise((resolve) => {
|
|
|
+ const dummyAudio = new Audio("data:audio/wav;base64,UklGRl9vT19XQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YU");
|
|
|
+ dummyAudio
|
|
|
+ .play()
|
|
|
+ .then(() => {
|
|
|
+ dummyAudio.pause();
|
|
|
+ dummyAudio.remove();
|
|
|
+ resolve();
|
|
|
+ })
|
|
|
+ .catch(() => {
|
|
|
+ updateStatus("Click again to enable audio playback");
|
|
|
+ resolve();
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function pauseCurrentAudio() {
|
|
|
+ if (!currentAudio || currentAudio.paused || currentAudio.ended) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ currentAudio.pause();
|
|
|
+ isPaused = true;
|
|
|
+ isPlaying = false;
|
|
|
+ updatePlayButton();
|
|
|
+ updateStatus("Playback paused");
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ async function resumeCurrentAudio() {
|
|
|
+ if (!currentAudio || !currentAudio.paused || currentAudio.ended) {
|
|
|
+ isPaused = false;
|
|
|
+ isPlaying = false;
|
|
|
+ updatePlayButton();
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ initAudioContext();
|
|
|
+ if (audioContext.state !== "running") {
|
|
|
+ await audioContext.resume();
|
|
|
+ }
|
|
|
+
|
|
|
+ await currentAudio.play();
|
|
|
+ isPaused = false;
|
|
|
+ isPlaying = true;
|
|
|
+ updatePlayButton();
|
|
|
+ updateStatus("Resuming...");
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ function stopPlayback() {
|
|
|
+ if (playController) {
|
|
|
+ playController.abort();
|
|
|
+ playController = null;
|
|
|
+ }
|
|
|
+ if (currentAudio) {
|
|
|
+ currentAudio.pause();
|
|
|
+ currentAudio = null;
|
|
|
+ }
|
|
|
+ isPaused = false;
|
|
|
+ isPlaying = false;
|
|
|
+ updatePlayButton();
|
|
|
+ }
|
|
|
+
|
|
|
+ async function startConversion(payload) {
|
|
|
+ try {
|
|
|
+ if (!payload?.text) {
|
|
|
+ updateStatus("No readable text");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ stopPlayback();
|
|
|
+ playController = new AbortController();
|
|
|
+ isPaused = false;
|
|
|
+ isPlaying = true;
|
|
|
+ updatePlayButton();
|
|
|
+
|
|
|
+ let message = "Starting playback...";
|
|
|
+ if (payload.scope === "selection") {
|
|
|
+ message = "Playing selected text...";
|
|
|
+ } else if (payload.scope === "paragraph") {
|
|
|
+ message = "Playing paragraph...";
|
|
|
+ } else if (payload.scope === "page") {
|
|
|
+ const count = payload.meta?.paragraphCount;
|
|
|
+ message = count ? `Playing article (${count} sections)...` : "Playing article...";
|
|
|
+ }
|
|
|
+ updateStatus(message);
|
|
|
+
|
|
|
+ await streamTTSAndPlay(payload.text, playController.signal);
|
|
|
+ playController = null;
|
|
|
+ isPaused = false;
|
|
|
+ isPlaying = false;
|
|
|
+ updatePlayButton();
|
|
|
+ updateStatus("Playback finished");
|
|
|
+ } catch (error) {
|
|
|
+ handleError(error);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function streamTTSAndPlay(text, signal) {
|
|
|
+ try {
|
|
|
+ const port = chrome.runtime.connect({ name: "tts-stream" });
|
|
|
+
|
|
|
+ const playQueue = [];
|
|
|
+ let isPlayingQueue = false;
|
|
|
+ let streamCompleted = false;
|
|
|
+
|
|
|
+ const abortHandler = () => {
|
|
|
+ playQueue.length = 0;
|
|
|
+ port.disconnect();
|
|
|
+ if (currentAudio) {
|
|
|
+ currentAudio.pause();
|
|
|
+ currentAudio = null;
|
|
|
+ }
|
|
|
+ isPaused = false;
|
|
|
+ isPlaying = false;
|
|
|
+ updatePlayButton();
|
|
|
+ };
|
|
|
+
|
|
|
+ signal.addEventListener("abort", abortHandler, { once: true });
|
|
|
+
|
|
|
+ port.postMessage({
|
|
|
+ action: "startStreamTTS",
|
|
|
+ text,
|
|
|
+ voice: "af_heart",
|
|
|
+ speed: currentSpeed
|
|
|
+ });
|
|
|
+
|
|
|
+ port.onMessage.addListener((response) => {
|
|
|
+ if (signal.aborted) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (response.error) {
|
|
|
+ console.error("TTS error:", response.error);
|
|
|
+ updateStatus(`TTS error: ${response.error}`);
|
|
|
+ port.disconnect();
|
|
|
+ if (currentAudio) {
|
|
|
+ currentAudio.pause();
|
|
|
+ currentAudio = null;
|
|
|
+ }
|
|
|
+ isPaused = false;
|
|
|
+ isPlaying = false;
|
|
|
+ updatePlayButton();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (response.audio) {
|
|
|
+ playQueue.push({
|
|
|
+ audio: response.audio.startsWith("data:") ? response.audio : `data:audio/wav;base64,${response.audio}`,
|
|
|
+ index: response.index
|
|
|
+ });
|
|
|
+
|
|
|
+ if (!isPlayingQueue) {
|
|
|
+ void playFromQueue();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (response.completed) {
|
|
|
+ streamCompleted = true;
|
|
|
+ port.disconnect();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ function playFromQueue() {
|
|
|
+ if (signal.aborted || !playQueue.length) {
|
|
|
+ isPlayingQueue = false;
|
|
|
+ isPlaying = false;
|
|
|
+ updatePlayButton();
|
|
|
+ return Promise.resolve();
|
|
|
+ }
|
|
|
+
|
|
|
+ isPlayingQueue = true;
|
|
|
+ const chunk = playQueue.shift();
|
|
|
+
|
|
|
+ return (async () => {
|
|
|
+ try {
|
|
|
+ initAudioContext();
|
|
|
+ if (audioContext.state !== "running") {
|
|
|
+ await audioContext.resume();
|
|
|
+ }
|
|
|
+
|
|
|
+ currentAudio = new Audio(chunk.audio);
|
|
|
+ isPaused = false;
|
|
|
+ isPlaying = true;
|
|
|
+ updatePlayButton();
|
|
|
+ updateStatus(`Playing part ${chunk.index + 1}`);
|
|
|
+
|
|
|
+ await playAudio(currentAudio);
|
|
|
+ currentAudio = null;
|
|
|
+ updatePlayButton();
|
|
|
+
|
|
|
+ return playFromQueue();
|
|
|
+ } catch (error) {
|
|
|
+ console.error("Playback error:", error);
|
|
|
+ isPlayingQueue = false;
|
|
|
+ }
|
|
|
+ })();
|
|
|
+ }
|
|
|
+
|
|
|
+ await new Promise((resolve) => {
|
|
|
+ port.onDisconnect.addListener(() => {
|
|
|
+ signal.removeEventListener("abort", abortHandler);
|
|
|
+ const waitForQueue = () => {
|
|
|
+ if (!playQueue.length && (!isPlayingQueue || signal.aborted || streamCompleted)) {
|
|
|
+ resolve();
|
|
|
+ } else {
|
|
|
+ setTimeout(waitForQueue, 100);
|
|
|
+ }
|
|
|
+ };
|
|
|
+ waitForQueue();
|
|
|
+ });
|
|
|
+ });
|
|
|
+ } catch (error) {
|
|
|
+ throw error;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function playAudio(audio) {
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ audio
|
|
|
+ .play()
|
|
|
+ .then(() => {
|
|
|
+ audio.onended = resolve;
|
|
|
+ audio.onerror = reject;
|
|
|
+ })
|
|
|
+ .catch(reject);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---------- Init ----------
|
|
|
+
|
|
|
+ setMode("paragraph");
|
|
|
+ setSpeed(1.0);
|
|
|
+ updatePlayButton();
|
|
|
+});
|