From e0163fa70e24037651e92aa640d7e4f2ce38e1ec Mon Sep 17 00:00:00 2001 From: kev Date: Mon, 22 Jan 2024 17:30:42 +0100 Subject: [PATCH] Feat/use speech synthesis (#126) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ⚡️ add SpeechSynthesis Hook and context * 0.2.44 * chore: add export useBrowserSpeech --- lib/hooks/index.ts | 24 +++- lib/hooks/useBrowserSpeech.tsx | 210 +++++++++++++++++++++++++++++++++ package.json | 2 +- 3 files changed, 234 insertions(+), 2 deletions(-) create mode 100644 lib/hooks/useBrowserSpeech.tsx diff --git a/lib/hooks/index.ts b/lib/hooks/index.ts index 5b198bf..7f51885 100644 --- a/lib/hooks/index.ts +++ b/lib/hooks/index.ts @@ -1,5 +1,27 @@ import usePolyfire, { PolyfireProvider } from "./usePolyfire"; import useChat from "./useChat"; import useAgent, { ActionAgent, Agent, DefinitionAction } from "./useAgent"; +import { + useBrowserSpeechContext, + BrowserSpeechContextType, + useBrowserSpeech, + BrowserSpeechProvider, + BrowserSpeechOptions, + UseBrowserSpeech, +} from "./useBrowserSpeech"; -export { usePolyfire, PolyfireProvider, useChat, useAgent, ActionAgent, Agent, DefinitionAction }; +export { + usePolyfire, + PolyfireProvider, + BrowserSpeechProvider, + useChat, + useAgent, + useBrowserSpeechContext, + useBrowserSpeech, + ActionAgent, + Agent, + DefinitionAction, + BrowserSpeechContextType, + BrowserSpeechOptions, + UseBrowserSpeech, +}; diff --git a/lib/hooks/useBrowserSpeech.tsx b/lib/hooks/useBrowserSpeech.tsx new file mode 100644 index 0000000..9573a8a --- /dev/null +++ b/lib/hooks/useBrowserSpeech.tsx @@ -0,0 +1,210 @@ +/* eslint-env browser */ + +import React, { + useState, + useEffect, + useCallback, + ReactNode, + createContext, + useContext, +} from "react"; + +export type BrowserSpeechOptions = Partial; + +export type UseBrowserSpeech = { + togglePlay: (content: string, speechId: string) => void; + togglePause: () => void; + speaking: boolean; + isPaused: boolean; + activeSpeechId: string | null; + voices: SpeechSynthesisVoice[]; +}; + +export type BrowserSpeechContextType = { + startSpeaking: (content: string, speechId: string) => void; + togglePause: () => void; + speaking: boolean; + isPaused: boolean; + activeSpeechId: string | null; + voices?: SpeechSynthesisVoice[]; +}; + +const defaultOptions: BrowserSpeechOptions = { + pitch: 1, + rate: 1, +}; + +// Hook + +export const useBrowserSpeech = ( + onActiveSpeechChange?: (speechId: string | null) => void, + options: BrowserSpeechOptions = defaultOptions, +): UseBrowserSpeech => { + const [speaking, setSpeaking] = useState(false); + const [isPaused, setIsPaused] = useState(false); + const [voices, setVoices] = useState([]); + const [activeSpeechId, setActiveSpeechId] = useState(null); + + const stop = useCallback(() => { + window.speechSynthesis.cancel(); + }, []); + + const pause = useCallback(() => { + window.speechSynthesis.pause(); + }, []); + + const resume = useCallback(() => { + window.speechSynthesis.resume(); + }, []); + + const isCurrentlySpeaking = useCallback(() => { + return window.speechSynthesis.speaking; + }, []); + + const speakSentence = useCallback( + (sentence: string, voiceIndex: number) => { + const utterance = new SpeechSynthesisUtterance(sentence); + if (voices.length > 0 && voices[voiceIndex]) { + utterance.voice = voices[voiceIndex]; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const utteranceWithOptions = utterance as any; + + Object.keys(options).forEach((key) => { + if (key in utterance) { + utteranceWithOptions[key] = options[key as keyof BrowserSpeechOptions]; + } + }); + + window.speechSynthesis.speak(utteranceWithOptions as SpeechSynthesisUtterance); + }, + [voices, options], + ); + + const start = useCallback( + (text: string, voiceIndex = 0) => { + const speak = () => { + const sentences = text.match(/[^.!?]+[.!?]+/g); + if (sentences) { + sentences.forEach((sentence) => speakSentence(sentence, voiceIndex)); + } else { + speakSentence(text, voiceIndex); + } + }; + + if (voices.length > 0 && !isCurrentlySpeaking()) { + speak(); + } + }, + [speakSentence, voices], + ); + + useEffect(() => { + const checkSpeaking = () => { + setSpeaking(window.speechSynthesis.speaking); + }; + const interval = setInterval(checkSpeaking, 100); + return () => clearInterval(interval); + }, []); + + useEffect(() => { + setVoices(window.speechSynthesis.getVoices()); + + const handleVoicesChanged = () => { + setVoices(window.speechSynthesis.getVoices()); + }; + window.speechSynthesis.onvoiceschanged = handleVoicesChanged; + + return () => { + window.speechSynthesis.onvoiceschanged = null; + }; + }, []); + + const togglePlay = useCallback( + (content: string, speechId: string) => { + if (speaking && activeSpeechId === speechId) { + stop(); + setSpeaking(false); + setActiveSpeechId(null); + onActiveSpeechChange?.(null); + } else { + start(content); + setSpeaking(true); + setIsPaused(false); + setActiveSpeechId(speechId); + onActiveSpeechChange?.(speechId); + } + }, + [speaking, activeSpeechId, stop, onActiveSpeechChange, start], + ); + + const togglePause = useCallback(() => { + if (isPaused) { + resume(); + } else { + pause(); + } + setIsPaused(!isPaused); + }, [isPaused, pause, resume]); + + return { + togglePlay, + togglePause, + speaking, + isPaused, + activeSpeechId, + voices, + }; +}; + +// Context + +const BrowserSpeechContext = createContext({ + startSpeaking: () => {}, + togglePause: () => {}, + speaking: false, + isPaused: false, + activeSpeechId: null, +}); + +export const useBrowserSpeechContext = (): BrowserSpeechContextType => + useContext(BrowserSpeechContext); + +export const BrowserSpeechProvider = ({ + children, + options, +}: { + children: ReactNode; + options?: BrowserSpeechOptions; +}): ReactNode => { + const [activeSpeechId, setActiveSpeechId] = useState(null); + const { togglePlay, togglePause, speaking, isPaused, voices } = useBrowserSpeech( + (newActiveSpeechId) => { + setActiveSpeechId(newActiveSpeechId); + }, + options, + ); + + const startSpeaking = (content: string, speechId: string) => { + if (activeSpeechId && activeSpeechId !== speechId) { + togglePlay("", activeSpeechId); + } + togglePlay(content, speechId); + }; + + return ( + + {children} + + ); +}; diff --git a/package.json b/package.json index 3141ffb..787c46a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "polyfire-js", - "version": "0.2.43", + "version": "0.2.44", "main": "index.js", "types": "index.d.ts", "author": "Lancelot Owczarczak ",