Skip to content

Commit

Permalink
Feat/use speech synthesis (#126)
Browse files Browse the repository at this point in the history
* ⚡️ add SpeechSynthesis Hook and context

* 0.2.44

* chore: add export useBrowserSpeech
  • Loading branch information
kevin-btc authored Jan 22, 2024
1 parent 4488fff commit e0163fa
Show file tree
Hide file tree
Showing 3 changed files with 234 additions and 2 deletions.
24 changes: 23 additions & 1 deletion lib/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -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,
};
210 changes: 210 additions & 0 deletions lib/hooks/useBrowserSpeech.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
/* eslint-env browser */

import React, {
useState,
useEffect,
useCallback,
ReactNode,
createContext,
useContext,
} from "react";

export type BrowserSpeechOptions = Partial<SpeechSynthesisUtterance>;

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<SpeechSynthesisVoice[]>([]);
const [activeSpeechId, setActiveSpeechId] = useState<string | null>(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<BrowserSpeechContextType>({
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<string | null>(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 (
<BrowserSpeechContext.Provider
value={{
startSpeaking,
togglePause,
speaking,
isPaused,
activeSpeechId,
voices,
}}
>
{children}
</BrowserSpeechContext.Provider>
);
};
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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 <lancelot@owczarczak.fr>",
Expand Down

0 comments on commit e0163fa

Please sign in to comment.