UNPKG

react-text-to-speech

Version:

An easy-to-use React.js component that leverages the Web Speech API to convert text to speech.

434 lines (426 loc) 18.5 kB
import { __spreadValues, __objRest, HiVolumeUp, HiVolumeOff, HiMiniStop } from './chunks/chunk-3KTCESQJ.js'; import React2, { useState, useEffect, useMemo, useCallback, isValidElement, cloneElement, useRef } from 'react'; import { createPortal } from 'react-dom'; // src/constants.ts var lineDelimiter = "\n"; var punctuationDelimiters = [".", "?", "!"]; var spaceDelimiter = " "; var chunkDelimiters = [lineDelimiter, ...punctuationDelimiters.map((delimiter) => delimiter + spaceDelimiter), spaceDelimiter]; var desktopChunkSize = 1e3; var minChunkSize = 50; var mobileChunkSize = 250; var symbolMapping = { "<": "lessthan", ">": "greaterthan" }; var sanitizeRegex = new RegExp(`[${Object.keys(symbolMapping).join("")}]|(&[^s;]+);`, "g"); var sentenceDelimiters = [lineDelimiter, ...punctuationDelimiters]; var specialSymbol = "\xA0"; var sanitizedRegex = new RegExp(` (?:${Object.values(symbolMapping).join("|")})${specialSymbol}`, "g"); // src/state.ts var state = { stopReason: "manual" }; var setState = (newState) => Object.assign(state, newState); // src/utils.tsx function WordsToText(node) { if (typeof node === "string") return node; return node.map(WordsToText).join(spaceDelimiter) + spaceDelimiter; } function NodeToWords(node) { if (Array.isArray(node)) return node.map(NodeToWords); if (isValidElement(node)) return NodeToWords(node.props.children); return typeof node === "string" ? node : typeof node === "number" ? String(node) : ""; } function NodeToKey(node) { if (Array.isArray(node)) return node.map(NodeToKey).join(""); if (isValidElement(node)) { const type = typeof node.type === "string" ? node.type : "Component"; const _a = node.props, { children } = _a, props = __objRest(_a, ["children"]); const propsKey = JSON.stringify(props); const childrenKey = NodeToKey(children); return `${type}(${propsKey})[${childrenKey}]`; } return typeof node === "string" ? node : typeof node === "number" ? String(node) : ""; } function TextToChunks(text, size) { size = size ? Math.max(size, minChunkSize) : isMobile() ? mobileChunkSize : desktopChunkSize; const length = text.length; const result = []; let startIndex = 0; while (startIndex < length) { let endIndex = Math.min(startIndex + size, length); if (endIndex < length && text[endIndex] !== lineDelimiter) for (const delimiter of chunkDelimiters) { let delimiterIndex = text.lastIndexOf(delimiter, endIndex) + delimiter.length - 1; if (delimiterIndex > startIndex) { endIndex = delimiterIndex; break; } } result.push(text.slice(startIndex, endIndex)); startIndex = endIndex; } return result; } var calculateOriginalTextLength = (sanitizedText) => sanitizedText.replace(sanitizedRegex, " ").length; function cancel(stopReason = "manual") { var _a; if (typeof window === "undefined") return; setState({ stopReason }); (_a = window.speechSynthesis) == null ? void 0 : _a.cancel(); } function findCharIndex(words, index) { let currentIndex = 0; function recursiveSearch(currentWords, parentIndex = "") { if (typeof currentWords === "string") { const elementIndex = index - currentIndex; return (currentIndex += currentWords.length + 1) > index ? getIndex(parentIndex, elementIndex) : ""; } for (let i = 0; i < currentWords.length; i++) { const result = recursiveSearch(currentWords[i], i); if (result) return getIndex(parentIndex, result); } currentIndex++; return ""; } return recursiveSearch(words); } var getIndex = (parentIndex, index) => `${parentIndex === "" ? "" : parentIndex + "-"}${index}`; function isMobile(iOS = true) { var _a; let result = (_a = navigator.userAgentData) == null ? void 0 : _a.mobile; result != null ? result : result = /Android|webOS|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) || iOS && /iPhone|iPad|iPod/i.test(navigator.userAgent); return result; } function isParent(parentIndex, index) { if (!(index == null ? void 0 : index.startsWith(parentIndex))) return false; if (parentIndex) { const indexParts = index.split("-"); const parentIndexParts = parentIndex.split("-"); for (let i = 0; i < parentIndexParts.length; i++) { if (indexParts[i] !== parentIndexParts[i]) return false; } } return true; } function parent(index) { if (!index) return ""; const lastIndex = index.lastIndexOf("-"); return lastIndex === -1 ? "" : index.slice(0, lastIndex); } var sanitize = (text) => text.replace(sanitizeRegex, (match, group) => group ? group + ")" : ` ${symbolMapping[match]}${specialSymbol}`); function shouldHighlightNextPart(highlightMode, name, utterance, charIndex) { if (name === "word" && (highlightMode === "word" || !charIndex)) return true; const text = utterance.text.slice(0, charIndex).replace(/[ \t]+$/, spaceDelimiter).slice(-2); if (highlightMode === "sentence" && (text[1] === lineDelimiter || sentenceDelimiters.includes(text[0]) && text[1] === spaceDelimiter)) return true; if (highlightMode === "line" && (text[1] === lineDelimiter || text[0] === lineDelimiter && text[1] === spaceDelimiter)) return true; if (highlightMode === "paragraph" && name === "sentence") return true; return false; } function splitNode(highlightMode, node, speakingWord) { const { index, length } = speakingWord; const beforeIndex = +index.split("-").at(-1); const before = node.slice(0, beforeIndex); if (highlightMode === "word") return [before, node.slice(beforeIndex, beforeIndex + length), node.slice(beforeIndex + length)]; node = node.slice(beforeIndex); const regex = highlightMode === "sentence" ? /(.*?)(\n|[.!?]\s)(.*)/ : /(.*?)(\n)(.*)/; const match = node.match(regex); if (!match) return [before, node, ""]; const sentence = match[1] + match[2].trimEnd(); return [before, sentence, node.slice(sentence.length)]; } // src/queue.ts var queue = []; var queueListeners = []; function addToQueue(item, callback) { queue.push(item); emit(callback); } function clearQueue(cancelSpeech = false, start = 0, emitEvent = false) { if (cancelSpeech) cancel(); queue.slice(start).forEach(({ setSpeechStatus }) => setSpeechStatus("stopped")); queue.length = 0; if (emitEvent) emit(); } var clearQueueHook = () => clearQueue(true, 1, true); var clearQueueUnload = () => clearQueue(true, 1); function dequeue(index = 0) { if (index === 0) cancel(); else removeFromQueue(index); } function emit(callback) { const utteranceQueue = queue.map(({ text, utterance: { pitch, rate, volume, lang, voice } }) => ({ text, pitch, rate, volume, lang, voice })); queueListeners.forEach((listener) => listener(utteranceQueue)); callback == null ? void 0 : callback(utteranceQueue); } function removeFromQueue(utterance, callback) { const index = typeof utterance === "number" ? utterance : queue.findIndex((item2) => item2.utterance === utterance); if (index === -1) return; const [item] = queue.splice(index, 1); if (item) { if (index === 0) cancel(); else item.setSpeechStatus("stopped"); emit(callback); } } function speakFromQueue() { const item = queue[0]; if (item) window.speechSynthesis.speak(item.utterance); } function subscribe(callback) { queueListeners.push(callback); return () => { const index = queueListeners.indexOf(callback); if (index !== -1) queueListeners.splice(index, 1); }; } // src/hooks.tsx function useQueue() { const [queue2, setQueue] = useState([]); useEffect(() => subscribe(setQueue), []); return { queue: queue2, dequeue, clearQueue: clearQueueHook }; } function useSpeech({ text, pitch = 1, rate = 1, volume = 1, lang = "", voiceURI, autoPlay = false, preserveUtteranceQueue = false, highlightText = false, showOnlyHighlightedText = false, highlightMode = "word", highlightProps, maxChunkSize, onError = console.error, onStart, onResume, onPause, onStop, onBoundary, onQueueChange }) { const [speechStatus, speechStatusRef, setSpeechStatus] = useStateRef("stopped"); const [speakingWord, speakingWordRef, setSpeakingWord] = useStateRef(null); const { utteranceRef, updateProps } = useSpeechSynthesisUtterance(); const key = useMemo(() => NodeToKey(text), [text]); const stringifiedVoices = useMemo(() => JSON.stringify(voiceURI), [voiceURI]); const { words, sanitizedText } = useMemo(() => { const words2 = NodeToWords(text); return { words: words2, sanitizedText: sanitize(WordsToText(words2)) }; }, [key]); const chunks = useMemo(() => TextToChunks(sanitizedText, maxChunkSize), [sanitizedText, maxChunkSize]); const reactContent = useMemo(() => highlightedText(text), [speakingWord, key, highlightText, showOnlyHighlightedText]); const Text = useCallback(() => reactContent, [reactContent]); function start() { const synth = window.speechSynthesis; if (!synth) return onError(new Error("Browser not supported! Try some other browser.")); if (speechStatus === "paused") return synth.resume(); if (speechStatus === "queued") return; let currentChunk = 0; let currentText = chunks[currentChunk] || ""; const utterance = utteranceRef.current; utterance.text = currentText.trimStart(); let processedTextLength = 0; let offset = currentText.length - utterance.text.length; updateProps({ pitch, rate, volume, lang, voiceURI }); const stopEventHandler = (event) => { if (state.stopReason === "auto" && currentChunk < chunks.length - 1) { processedTextLength += calculateOriginalTextLength(chunks[currentChunk]); currentText = chunks[++currentChunk]; utterance.text = currentText.trimStart(); offset = processedTextLength + currentText.length - utterance.text.length; return speakFromQueue(); } if (state.stopReason === "change") { if (speakingWordRef.current) { const currentLength = utterance.text.length; utterance.text = utterance.text.slice(speakingWordRef.current.charIndex).trimStart(); offset += currentLength - utterance.text.length; setSpeakingWord(null); } return speakFromQueue(); } if (synth.paused) cancel(); window.removeEventListener("beforeunload", clearQueueUnload); setSpeechStatus("stopped"); setSpeakingWord(null); utterance.onstart = null; utterance.onresume = null; utterance.onpause = null; utterance.onend = null; utterance.onerror = null; utterance.onboundary = null; removeFromQueue(utterance, onQueueChange); speakFromQueue(); onStop == null ? void 0 : onStop(event); }; utterance.onstart = (event) => { window.addEventListener("beforeunload", clearQueueUnload); setSpeechStatus("started"); setState({ stopReason: "auto" }); onStart == null ? void 0 : onStart(event); }; utterance.onresume = (event) => { setSpeechStatus("started"); onResume == null ? void 0 : onResume(event); }; utterance.onpause = (event) => { setSpeechStatus("paused"); onPause == null ? void 0 : onPause(event); }; utterance.onend = stopEventHandler; utterance.onerror = stopEventHandler; utterance.onboundary = (event) => { var _a; const { charIndex, charLength, name } = event; const isSpecialSymbol = +(utterance.text[charIndex + charLength] === specialSymbol); const index = findCharIndex(words, offset + charIndex - isSpecialSymbol); if (shouldHighlightNextPart(highlightMode, name, utterance, charIndex) || parent(index) !== parent((_a = speakingWordRef.current) == null ? void 0 : _a.index)) setSpeakingWord({ index, charIndex: isSpecialSymbol ? charIndex + charLength + 1 : charIndex, length: isSpecialSymbol || charLength }); if (isSpecialSymbol) offset -= charLength + 1; onBoundary == null ? void 0 : onBoundary(event); }; if (!preserveUtteranceQueue) clearQueue(); addToQueue({ text: sanitizedText, utterance, setSpeechStatus }, onQueueChange); setSpeechStatus("started"); if (!synth.speaking) return speakFromQueue(); if (preserveUtteranceQueue && speechStatus !== "started") return setSpeechStatus("queued"); cancel(); } function pause() { var _a; if (isMobile(false) || speechStatus === "queued") return stop(); if (speechStatus === "started") (_a = window.speechSynthesis) == null ? void 0 : _a.pause(); } function stop({ status = speechStatus, stopReason } = {}) { if (status === "stopped") return; if (status !== "queued") return cancel(stopReason); removeFromQueue(utteranceRef.current, onQueueChange); setSpeechStatus("stopped"); } function highlightedText(node, parentIndex = "") { var _a; if (!highlightText || !isParent(parentIndex, speakingWord == null ? void 0 : speakingWord.index)) return !showOnlyHighlightedText && node; if (Array.isArray(node)) return node.map((child, index) => highlightedText(child, getIndex(parentIndex, index))); if (isValidElement(node)) return cloneElement(node, { key: (_a = node.key) != null ? _a : Math.random() }, highlightedText(node.props.children, parentIndex)); if (typeof node === "string" || typeof node === "number") { const { index } = speakingWord; if (highlightMode === "paragraph") return /* @__PURE__ */ React2.createElement("mark", __spreadValues({ key: index }, highlightProps), node); const [before, highlighted, after] = splitNode(highlightMode, String(node), speakingWord); if (showOnlyHighlightedText) return /* @__PURE__ */ React2.createElement("mark", __spreadValues({ key: index }, highlightProps), highlighted); return /* @__PURE__ */ React2.createElement("span", { key: index }, before, /* @__PURE__ */ React2.createElement("mark", __spreadValues({}, highlightProps), highlighted), after); } return !showOnlyHighlightedText && node; } useEffect(() => { if (autoPlay) start(); return () => stop({ status: speechStatusRef.current }); }, [autoPlay, key]); useEffect(() => { if (speechStatus !== "started") return; const timeout = setTimeout(() => { updateProps({ pitch, rate, volume, lang, voiceURI }); stop({ stopReason: "change" }); emit(onQueueChange); }, 500); return () => clearTimeout(timeout); }, [pitch, rate, volume, lang, stringifiedVoices]); return { Text, speechStatus, isInQueue: speechStatus === "started" || speechStatus === "queued", start, pause, stop: () => stop() }; } function useSpeechSynthesisUtterance() { const utteranceRef = useRef(typeof window === "undefined" || !window.speechSynthesis ? null : new SpeechSynthesisUtterance()); const { voices } = useVoices(); function updateProps({ pitch, rate, volume, lang, voiceURI }) { const utterance = utteranceRef.current; if (!utterance) return; utterance.pitch = pitch; utterance.rate = rate; utterance.volume = volume; utterance.lang = lang; if (!voiceURI) return utterance.voice = null; if (!Array.isArray(voiceURI)) voiceURI = [voiceURI]; for (let i = 0; i < voiceURI.length; i++) { const uri = voiceURI[i]; const voice = voices.find((voice2) => voice2.voiceURI === uri); if (voice) { utterance.voice = voice; break; } } } return { utteranceRef, updateProps }; } function useStateRef(init) { const [state2, setState2] = useState(init); const ref = useRef(init); function setStateRef(value) { ref.current = value; setState2(value); } return [state2, ref, setStateRef]; } function useVoices() { const [languages, setLanguages] = useState([]); const [voices, setVoices] = useState([]); function setData(voices2) { setLanguages([...new Set(voices2.map(({ lang }) => lang))]); setVoices(voices2); } useEffect(() => { const synth = window.speechSynthesis; if (!synth) return; const voices2 = synth.getVoices(); if (voices2.length) setData(voices2); else { const onVoicesChanged = () => setData(synth.getVoices()); synth.addEventListener("voiceschanged", onVoicesChanged); return () => synth.removeEventListener("voiceschanged", onVoicesChanged); } }, []); return { languages, voices }; } // src/index.tsx function HighlightedText(_a) { var _b = _a, { id, children } = _b, props = __objRest(_b, ["id", "children"]); const [loading, setLoading] = useState(true); useEffect(() => { setLoading(false); }, []); return /* @__PURE__ */ React2.createElement("div", __spreadValues({ id: `rtts-${id}` }, props), loading && (typeof children === "string" ? /* @__PURE__ */ React2.createElement("span", null, children) : children)); } function Speech(_a) { var _b = _a, { id, startBtn = /* @__PURE__ */ React2.createElement(HiVolumeUp, null), pauseBtn = /* @__PURE__ */ React2.createElement(HiVolumeOff, null), stopBtn = /* @__PURE__ */ React2.createElement(HiMiniStop, null), useStopOverPause = false, props = {}, children } = _b, hookProps = __objRest(_b, [ "id", "startBtn", "pauseBtn", "stopBtn", "useStopOverPause", "props", "children" ]); const [highlightContainer, setHighlightContainer] = useState(null); const _a2 = useSpeech(hookProps), { Text } = _a2, childrenOptions = __objRest(_a2, ["Text"]); const { isInQueue, start, pause, stop } = childrenOptions; useEffect(() => { if (hookProps.highlightText) setHighlightContainer(document.getElementById(`rtts-${id}`)); else setHighlightContainer(null); }, [hookProps.highlightText]); return /* @__PURE__ */ React2.createElement(React2.Fragment, null, typeof children === "function" ? children(childrenOptions) : /* @__PURE__ */ React2.createElement("div", __spreadValues({ style: { display: "flex", columnGap: "1rem" } }, props), !isInQueue ? /* @__PURE__ */ React2.createElement("span", { role: "button", onClick: start }, startBtn) : useStopOverPause ? /* @__PURE__ */ React2.createElement("span", { role: "button", onClick: stop }, stopBtn) : /* @__PURE__ */ React2.createElement("span", { role: "button", onClick: pause }, pauseBtn), !useStopOverPause && stopBtn && /* @__PURE__ */ React2.createElement("span", { role: "button", onClick: stop }, stopBtn)), highlightContainer && createPortal(/* @__PURE__ */ React2.createElement(Text, null), highlightContainer)); } export { HighlightedText, Speech as default, useQueue, useSpeech, useVoices };