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
JavaScript
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 };