UNPKG

react-say

Version:

A React component that synthesis text into speech using Web Speech API

498 lines (480 loc) 15.4 kB
// src/Composer.jsx import PropTypes4 from "prop-types"; import React5, { useContext as useContext3, useMemo as useMemo2, useState as useState2 } from "react"; // src/Context.mjs import React from "react"; var Context = React.createContext(); var Context_default = Context; // src/createCustomEvent.mjs function createCustomEvent(name, eventInitDict) { if (name === "error") { if (typeof ErrorEvent === "function") { return new ErrorEvent(name, eventInitDict); } } else if (typeof CustomEvent === "function") { return new CustomEvent(name, eventInitDict); } const event = document.createEvent("Event"); event.initEvent(name, true, true); Object.entries(eventInitDict || {}).forEach(([key, value]) => { event[key] = value; }); return event; } // src/createDeferred.mjs function createDeferred() { let reject, resolve; const promise = new Promise((promiseResolve, promiseReject) => { reject = promiseReject; resolve = promiseResolve; }); if (!reject || !resolve) { throw new Error("Promise is not a ES-compliant and do not run exector immediately"); } return { promise, reject, resolve }; } // src/createErrorEvent.mjs function createErrorEvent(error) { return createCustomEvent("error", { error }); } // src/QueuedUtterance.mjs async function speakUtterance(ponyfill, utterance, startCallback) { const { speechSynthesis } = ponyfill; const startDeferred = createDeferred(); const errorDeferred = createDeferred(); const endDeferred = createDeferred(); utterance.addEventListener("end", endDeferred.resolve); utterance.addEventListener("error", errorDeferred.resolve); utterance.addEventListener("start", startDeferred.resolve); speechSynthesis.speak(utterance); const startEvent = await Promise.race([errorDeferred.promise, startDeferred.promise]); if (startEvent.type === "error") { throw startEvent.error; } let finishedSpeaking; const endPromise = Promise.race([errorDeferred.promise, endDeferred.promise]); startCallback && startCallback(async () => { if (!finishedSpeaking) { speechSynthesis.cancel(); await endPromise; } }); const endEvent = await endPromise; finishedSpeaking = true; if (endEvent.type === "error") { throw endEvent.error; } } var QueuedUtterance = class { constructor(ponyfill, utterance, { onEnd, onError, onStart }) { this._cancelled = false; this._deferred = createDeferred(); this._onEnd = onEnd; this._onError = onError; this._onStart = onStart; this._ponyfill = ponyfill; this._speaking = false; this._utterance = utterance; this.promise = this._deferred.promise; } async cancel() { this._cancelled = true; this._cancel && await this._cancel(); } speak() { if (this._speaking) { console.warn(`ASSERTION: QueuedUtterance is already speaking or has spoken.`); } this._speaking = true; (async () => { if (this._cancelled) { throw new Error("cancelled"); } await speakUtterance(this._ponyfill, this._utterance, (cancel) => { if (this._cancelled) { cancel(); throw new Error("cancelled"); } else { this._cancel = cancel; this._onStart && this._onStart(createCustomEvent("start")); } }); if (this._cancelled) { throw new Error("cancelled"); } })().then( () => { this._onEnd && this._onEnd(createCustomEvent("end")); this._deferred.resolve(); }, (error) => { this._onError && this._onError(createErrorEvent(error)); this._deferred.reject(error); } ); return this.promise; } }; // src/createSynthesize.mjs function createSynthesize() { let queueWithCurrent = []; let running; const run = async () => { if (running) { return; } running = true; try { let queuedUtterance; while (queuedUtterance = queueWithCurrent[0]) { try { await queuedUtterance.speak(); } catch (err) { err.message !== "cancelled" && console.error(err); } queueWithCurrent = queueWithCurrent.filter((target) => target !== queuedUtterance); } } finally { running = false; } }; return (ponyfill, utterance, { onEnd, onError, onStart } = {}) => { if (!(utterance instanceof ponyfill.SpeechSynthesisUtterance)) { throw new Error("utterance must be instance of the ponyfill"); } const queuedUtterance = new QueuedUtterance(ponyfill, utterance, { onEnd, onError, onStart }); queueWithCurrent = [...queueWithCurrent, queuedUtterance]; run(); return { // The cancel() function returns a Promise cancel: () => queuedUtterance.cancel(), promise: queuedUtterance.promise }; }; } // src/Say.jsx import PropTypes2 from "prop-types"; import React3, { useContext as useContext2, useMemo } from "react"; // src/createNativeUtterance.mjs function createNativeUtterance({ speechSynthesis, SpeechSynthesisUtterance }, { lang, onBoundary, pitch, rate, text, voice, volume }) { const utterance = new SpeechSynthesisUtterance(text); let targetVoice; if (typeof voice === "function") { targetVoice = voice.call(speechSynthesis, speechSynthesis.getVoices()); } else { const { voiceURI } = voice || {}; targetVoice = voiceURI && [].find.call([].slice.call(speechSynthesis.getVoices()), (v) => v.voiceURI === voiceURI); } utterance.lang = lang || ""; if (pitch || pitch === 0) { utterance.pitch = pitch; } if (rate || rate === 0) { utterance.rate = rate; } if (targetVoice) { utterance.voice = targetVoice; } if (volume || volume === 0) { utterance.volume = volume; } onBoundary && utterance.addEventListener("boundary", onBoundary); return utterance; } // src/SayUtterance.jsx import PropTypes from "prop-types"; import React2, { useEffect, useRef } from "react"; // src/useSynthesize.mjs import { useContext } from "react"; function useSynthesize() { const { ponyfill, synthesize } = useContext(Context_default); return (utteranceOrText, progressFn) => { if (typeof utteranceOrText === "string") { utteranceOrText = createNativeUtterance(ponyfill, { text: utteranceOrText }); } return synthesize(ponyfill, utteranceOrText, { onStart: progressFn && (() => progressFn()) }); }; } // src/SayUtterance.jsx var SayUtterance = (props) => { const { onEnd, onError, onStart, utterance } = migrateDeprecatedProps(props); const started = useRef(false); const synthesize = useSynthesize(); useEffect(() => { if (started.current) { return console.warn("react-say: Should not change utterance after synthesis started."); } let cancelled; const { cancel, promise } = synthesize(utterance, () => { started.current = true; !cancelled && onStart && onStart(createCustomEvent("start")); }); promise.then( () => !cancelled && onEnd && onEnd(createCustomEvent("end")), (error) => !cancelled && onError && onError(createErrorEvent(error)) ); return () => { cancelled = true; cancel(); }; }, []); return false; }; SayUtterance.defaultProps = { onEnd: void 0, onError: void 0, onStart: void 0 }; SayUtterance.propTypes = { onEnd: PropTypes.func, onError: PropTypes.func, onStart: PropTypes.func }; var SayUtteranceWithContext = ({ ponyfill, ...props }) => /* @__PURE__ */ React2.createElement(Composer_default, { ponyfill }, /* @__PURE__ */ React2.createElement(SayUtterance, { ...props })); SayUtteranceWithContext.defaultProps = { ...SayUtterance.defaultProps, ponyfill: void 0 }; SayUtteranceWithContext.propTypes = { ...SayUtterance.propTypes, ponyfill: PropTypes.shape({ speechSynthesis: PropTypes.any.isRequired, SpeechSynthesisUtterance: PropTypes.any.isRequired }) }; var SayUtterance_default = SayUtteranceWithContext; // src/Say.jsx var Say = (props) => { let { lang, onBoundary, onEnd, onError, onStart, pitch, rate, speak, text, voice, volume } = migrateDeprecatedProps( props, Say ); const { ponyfill } = useContext2(Context_default); if (speak && !text) { console.warn('react-say: "speak" prop is being deprecated and renamed to "text".'); text = speak; } const utterance = useMemo( () => createNativeUtterance(ponyfill, { lang, onBoundary, pitch, rate, text, voice, volume }), [lang, onBoundary, pitch, ponyfill, rate, text, voice, volume] ); return /* @__PURE__ */ React3.createElement(SayUtterance_default, { onEnd, onError, onStart, ponyfill, utterance }); }; Say.defaultProps = { children: void 0, lang: void 0, onBoundary: void 0, onEnd: void 0, onError: void 0, onStart: void 0, pitch: void 0, rate: void 0, speak: void 0, voice: void 0, volume: void 0 }; Say.propTypes = { children: PropTypes2.any, lang: PropTypes2.string, onBoundary: PropTypes2.func, onEnd: PropTypes2.func, onError: PropTypes2.func, onStart: PropTypes2.func, pitch: PropTypes2.number, rate: PropTypes2.number, speak: PropTypes2.string, text: PropTypes2.string.isRequired, voice: PropTypes2.oneOfType([PropTypes2.any, PropTypes2.func]), volume: PropTypes2.number }; var SayWithContext = ({ ponyfill, ...props }) => /* @__PURE__ */ React3.createElement(Composer_default, { ponyfill }, /* @__PURE__ */ React3.createElement(Say, { ...props })); SayWithContext.defaultProps = { ...SayUtterance_default.defaultProps, ponyfill: void 0 }; SayWithContext.propTypes = { ...SayUtterance_default.propTypes, ponyfill: PropTypes2.shape({ speechSynthesis: PropTypes2.any.isRequired, SpeechSynthesisUtterance: PropTypes2.any.isRequired }) }; var Say_default = SayWithContext; // src/SayButton.jsx import PropTypes3 from "prop-types"; import React4, { useCallback, useState } from "react"; var SayButton = (props) => { const { children, disabled, lang, onBoundary, onEnd, onError, onStart, pitch, ponyfill, rate, text, voice, volume } = migrateDeprecatedProps(props, SayButton); const [busy, setBusy] = useState(false); const handleClick = useCallback(() => setBusy(true)); const sayProps = { lang, onBoundary, onEnd: (event) => { setBusy(false); onEnd && onEnd(event); }, onError, onStart, pitch, ponyfill, rate, text, voice, volume }; return /* @__PURE__ */ React4.createElement(React4.Fragment, null, /* @__PURE__ */ React4.createElement("button", { disabled: typeof disabled === "boolean" ? disabled : busy, onClick: handleClick }, children), busy && /* @__PURE__ */ React4.createElement(Say_default, { ...sayProps })); }; SayButton.defaultProps = { children: void 0, disabled: void 0, lang: void 0, onBoundary: void 0, onEnd: void 0, onError: void 0, onStart: void 0, pitch: void 0, ponyfill: void 0, rate: void 0, text: void 0, voice: void 0, volume: void 0 }; SayButton.propTypes = { children: PropTypes3.any, disabled: PropTypes3.bool, lang: PropTypes3.string, onBoundary: PropTypes3.func, onEnd: PropTypes3.func, onError: PropTypes3.func, onStart: PropTypes3.func, pitch: PropTypes3.number, ponyfill: PropTypes3.shape({ speechSynthesis: PropTypes3.any.isRequired, SpeechSynthesisUtterance: PropTypes3.any.isRequired }), rate: PropTypes3.number, text: PropTypes3.string, voice: PropTypes3.oneOfType([PropTypes3.any, PropTypes3.func]), volume: PropTypes3.number }; var SayButton_default = SayButton; // src/migrateDeprecatedProps.mjs var warnings = { ponyfill: true, saySpeak: true }; function migrateDeprecatedProps({ ponyfill, speak, speechSynthesis, speechSynthesisUtterance, text, ...otherProps }, componentType) { if (!ponyfill && (speechSynthesis || speechSynthesisUtterance)) { if (warnings.ponyfill) { console.warn( 'react-say: "speechSynthesis" and "speechSynthesisUtterance" props has been renamed to "ponyfill". Please update your code. The deprecated props will be removed in version >= 3.0.0.' ); warnings.ponyfill = false; } ponyfill = { speechSynthesis, SpeechSynthesisUtterance: speechSynthesisUtterance }; } if (componentType === Say_default || componentType === SayButton_default) { if (speak && !text) { if (warnings.saySpeak) { console.warn( 'react-say: "speak" prop has been renamed to "text". Please update your code. The deprecated props will be removed in version >= 3.0.0.' ); warnings.saySpeak = false; } text = speak; } } return { ponyfill, text, ...otherProps }; } // src/useImmediateEffect.mjs import { useEffect as useEffect2, useRef as useRef2 } from "react"; function useImmediateEffect(fn, deps) { const unsubscribeRef = useRef2({ first: true, id: Math.random().toString(36).substr(2, 5), unsubscribe: fn() }); useEffect2(() => { const { current } = unsubscribeRef; if (!current.first) { current.unsubscribe = fn(); } else { current.first = false; } return () => { current.unsubscribe && current.unsubscribe(); current.unsubscribe = null; }; }, deps); } // src/useEvent.mjs function useEvent(target, name, listener, options) { useImmediateEffect(() => { const handler = (event) => listener && listener(event); target.addEventListener(name, handler, options); return () => { listener = null; target.removeEventListener(name, handler, options); }; }, [listener, name, options, target]); } // src/Composer.jsx var Composer = (props) => { const { children, ponyfill: ponyfillFromProps } = migrateDeprecatedProps(props, Composer); const { ponyfill: parentPonyfill, synthesize: parentSynthesize } = useContext3(Context_default) || {}; const ponyfill = ponyfillFromProps || parentPonyfill || { speechSynthesis: window.speechSynthesis || window.webkitSpeechSynthesis, SpeechSynthesisUtterance: window.SpeechSynthesisUtterance || window.webkitSpeechSynthesisUtterance }; const synthesize = useMemo2(() => parentSynthesize || createSynthesize(), [parentSynthesize]); const { speechSynthesis } = ponyfill; const [voices, setVoices] = useState2(speechSynthesis.getVoices()); useEvent(speechSynthesis, "voiceschanged", () => setVoices(speechSynthesis.getVoices())); const context = useMemo2( () => ({ ponyfill, synthesize, voices }), [ponyfill, synthesize, voices] ); return /* @__PURE__ */ React5.createElement(Context_default.Provider, { value: context }, typeof children === "function" ? /* @__PURE__ */ React5.createElement(Context_default.Consumer, null, (context2) => children(context2)) : children); }; Composer.defaultProps = { children: void 0, ponyfill: void 0 }; Composer.propTypes = { children: PropTypes4.any, ponyfill: PropTypes4.shape({ speechSynthesis: PropTypes4.any, SpeechSynthesisUtterance: PropTypes4.any }) }; var Composer_default = Composer; // src/index.mjs var src_default = Say_default; export { Composer_default as Composer, Context_default as Context, SayButton_default as SayButton, SayUtterance_default as SayUtterance, src_default as default }; //# sourceMappingURL=react-say.mjs.map