UNPKG

react-text-to-speech

Version:

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

562 lines (553 loc) 23.6 kB
import { __spreadValues, __objRest, HiVolumeUp, HiVolumeOff, HiMiniStop, __async } from './chunks/chunk-A2UZKUIK.js'; import React2, { useState, useEffect, useRef, useMemo, useCallback, isValidElement, cloneElement } 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 defaults = { pitch: 1, rate: 1, volume: 1, lang: "", voice: "" }; var desktopChunkSize = 1e3; var directiveRegex = /\[\[(\w+)=([^\]=]+)\]\] ?/; var directiveRegexGlobal = new RegExp(directiveRegex.source, "g"); var minChunkSize = 50; var mobileChunkSize = 250; var symbolMapping = { "<": "lessthan", ">": "greaterthan" }; var sanitizeRegex = new RegExp(`[${Object.keys(symbolMapping).join("")}]|(&[^s;]+);`, "g"); var specialSymbol = "\xA0"; var sanitizedRegex = new RegExp(` (?:${Object.values(symbolMapping).join("|")})${specialSymbol}`, "g"); var sentenceDelimiters = [lineDelimiter, ...punctuationDelimiters]; var startToken = "\u200B"; // src/modules/state.ts var state = { stopReason: "manual" }; var setState = (newState) => Object.assign(state, newState); // src/modules/utils.tsx function NodeToWords(node) { if (typeof node === "string") return node; if (typeof node === "number") return String(node); if (Array.isArray(node)) return node.map(NodeToWords); if (isValidElement(node)) return NodeToWords(node.props.children); return ""; } function NodeToKey(node) { if (typeof node === "string") return node; if (typeof node === "number") return String(node); if (Array.isArray(node)) return node.map(NodeToKey).join(""); if (isValidElement(node)) { const nodeType = 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 `${nodeType}(${propsKey})[${childrenKey}]`; } return ""; } function TextToChunks(text, size, enableDirectives) { size = size ? Math.max(size, minChunkSize) : isMobile() ? mobileChunkSize : desktopChunkSize; return enableDirectives ? TextToChunksDirective(text, size) : chunkBySizeWithDelimiters(text, size); } function ToText(node) { if (typeof node === "string") return node; if (typeof node === "number") return String(node); if (Array.isArray(node)) return node.map(ToText).join(spaceDelimiter) + spaceDelimiter; if (isValidElement(node)) return ToText(node.props.children); return ""; } 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 chunkBySizeWithDelimiters(text, size) { 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) { const 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 cloneRegex = (regex) => new RegExp(regex.source, regex.flags); 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); } function parse(value) { if (value === "true") return true; if (value === "false") return false; const number = +value; if (!isNaN(number) && value !== "") return number; return value; } 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/modules/directiveUtils.ts function StripDirectives(node) { var _a; if (typeof node === "string") return node.replace(directiveRegexGlobal, ""); if (typeof node === "number") return node; if (Array.isArray(node)) return node.map(StripDirectives); if (isValidElement(node)) return cloneElement(node, { key: (_a = node.key) != null ? _a : Math.random(), children: StripDirectives(node.props.children) }); return null; } function TextToChunksDirective(text, size) { const chunks = []; let currentIndex = 0; let match; const directiveRegexClone = cloneRegex(directiveRegexGlobal); while ((match = directiveRegexClone.exec(text)) !== null) { const directiveIndex = match.index; if (directiveIndex > currentIndex) { const preDirectiveText = text.slice(currentIndex, directiveIndex); chunks.push(...chunkBySizeWithDelimiters(preDirectiveText, size)); } chunks.push(match[0]); currentIndex = directiveRegexClone.lastIndex; } if (currentIndex < text.length) { const remainingText = text.slice(currentIndex); chunks.push(...chunkBySizeWithDelimiters(remainingText, size)); } return chunks; } // src/modules/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 = defaults.pitch, rate = defaults.rate, volume = defaults.volume, lang = defaults.lang, voiceURI = defaults.voice, autoPlay = false, preserveUtteranceQueue = false, highlightText = false, showOnlyHighlightedText = false, highlightMode = "word", highlightProps, enableDirectives = false, 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 directiveRef = useRef({ event: null, delay: 0 }); const key = useMemo(() => NodeToKey(text), [text]); const stringifiedVoices = useMemo(() => JSON.stringify(voiceURI), [voiceURI]); const { sanitizedText, strippedText, words } = useMemo(() => { const strippedText2 = enableDirectives ? StripDirectives(text) : text; const words2 = NodeToWords(strippedText2); return { sanitizedText: `${startToken}${sanitize(ToText(enableDirectives ? text : words2))}`, strippedText: strippedText2, words: words2 }; }, [enableDirectives, key]); const chunks = useMemo(() => TextToChunks(sanitizedText, maxChunkSize, enableDirectives), [enableDirectives, maxChunkSize, sanitizedText]); const reactContent = useMemo(() => highlightedText(strippedText), [speakingWord, highlightText, showOnlyHighlightedText, strippedText]); const Text = useCallback(() => reactContent, [reactContent]); function reset(event = null) { var _a, _b; (_b = (_a = directiveRef.current).abortDelay) == null ? void 0 : _b.call(_a); directiveRef.current = { event, delay: 0 }; } function resumeEventHandler() { setSpeechStatus("started"); onResume == null ? void 0 : onResume(); } function pauseEventHandler() { setSpeechStatus("paused"); onPause == null ? void 0 : onPause(); } function start() { const synth = window.speechSynthesis; if (!synth) return onError(new Error("Browser not supported! Try some other browser.")); if (speechStatusRef.current === "paused") { if (directiveRef.current.event === "pause") speakFromQueue(); return synth.resume(); } if (speechStatusRef.current === "queued") return; let currentChunk = 0; let currentText = chunks[currentChunk] || ""; let processedTextLength = -startToken.length; const utterance = utteranceRef.current; utterance.text = currentText.trimStart(); let offset = processedTextLength + currentText.length - utterance.text.length; updateProps({ pitch, rate, volume, lang, voiceURI }); function handleDirectives() { let skip = false; while (currentChunk < chunks.length) { const match = directiveRegex.exec(currentText); if (!match) { if (!skip) return true; processedTextLength += calculateOriginalTextLength(currentText); } else { const key2 = match[1]; const value = parse(match[2]); switch (key2) { case "delay": directiveRef.current.delay += value; break; case "pitch": case "rate": case "volume": case "lang": case "voice": updateProps({ [key2]: value === "default" ? defaults[key2] : value }); break; case "skip": if (typeof value === "boolean") skip = value; break; } } currentText = chunks[++currentChunk]; } return false; } function stopEventHandler() { return __async(this, null, function* () { if (state.stopReason === "auto" && currentChunk < chunks.length - 1) { let continueSpeech = !enableDirectives; processedTextLength += calculateOriginalTextLength(currentText); currentText = chunks[++currentChunk]; if (enableDirectives) continueSpeech = handleDirectives(); if (continueSpeech) { utterance.text = currentText.trimStart(); offset = processedTextLength + currentText.length - utterance.text.length; if (speechStatusRef.current === "paused") return reset("pause"); const { delay } = directiveRef.current; directiveRef.current.event = "change"; if (!delay) return speakFromQueue(); const timeout = setTimeout(speakFromQueue, delay); directiveRef.current.abortDelay = () => clearTimeout(timeout); return; } } 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(); }); } utterance.onstart = () => { window.addEventListener("beforeunload", clearQueueUnload); setState({ stopReason: "auto" }); if (!directiveRef.current.delay) { if (!directiveRef.current.event) return onStart == null ? void 0 : onStart(); if (directiveRef.current.event === "pause") resumeEventHandler(); } reset(); }; utterance.onresume = resumeEventHandler; utterance.onpause = pauseEventHandler; 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(); }; if (!preserveUtteranceQueue) clearQueue(); addToQueue({ text: StripDirectives(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) || speechStatusRef.current === "queued") return stop(); if (speechStatusRef.current === "started") { if (!directiveRef.current.delay) return (_a = window.speechSynthesis) == null ? void 0 : _a.pause(); reset("pause"); pauseEventHandler(); } } function stop({ status = speechStatusRef.current, stopReason } = {}) { if (status === "stopped") return; if (status === "queued") { removeFromQueue(utteranceRef.current, onQueueChange); return setSpeechStatus("stopped"); } if (directiveRef.current.delay || directiveRef.current.event === "pause") { reset(); speakFromQueue(); } cancel(stopReason); } function highlightedText(node, parentIndex = "") { if (!highlightText || !isParent(parentIndex, speakingWord == null ? void 0 : speakingWord.index)) return !showOnlyHighlightedText && node; switch (typeof node) { case "number": node = String(node); case "string": if (!highlightText || !speakingWord) return node; const { index } = speakingWord; if (highlightMode === "paragraph") return /* @__PURE__ */ React2.createElement("mark", __spreadValues({ key: index }, highlightProps), node); const [before, highlighted, after] = splitNode(highlightMode, 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); } if (Array.isArray(node)) return node.map((child, index) => highlightedText(child, getIndex(parentIndex, index))); if (isValidElement(node)) return cloneElement(node, { children: highlightedText(node.props.children, parentIndex) }); return !showOnlyHighlightedText && node; } useEffect(() => { if (autoPlay) start(); return () => stop({ status: speechStatusRef.current }); }, [autoPlay, enableDirectives, key]); useEffect(() => { if (speechStatusRef.current !== "started") return; const timeout = setTimeout(() => { updateProps({ pitch, rate, volume, lang, voiceURI }); if (directiveRef.current.delay) return; 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; if (pitch !== void 0) utterance.pitch = pitch; if (rate !== void 0) utterance.rate = rate; if (volume !== void 0) utterance.volume = volume; if (lang !== void 0) utterance.lang = lang; if (voiceURI === void 0) return; if (!voiceURI) { utterance.voice = null; return; } 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; return; } } } 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 };