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