Add select voice button in the TTS panel (#129)

This commit is contained in:
Huang Xin
2025-01-09 01:34:30 +01:00
committed by GitHub
parent f26de3ab0d
commit cc1396876e
10 changed files with 270 additions and 29 deletions
+1 -1
View File
@@ -59,7 +59,7 @@
"react": "19.0.0",
"react-dom": "19.0.0",
"react-i18next": "^15.2.0",
"react-icons": "^5.3.0",
"react-icons": "^5.4.0",
"tinycolor2": "^1.6.0",
"zustand": "5.0.1"
},
@@ -10,13 +10,15 @@ import Popup from '@/components/Popup';
import TTSPanel from './TTSPanel';
import TTSIcon from './TTSIcon';
const POPUP_WIDTH = 260;
const POPUP_WIDTH = 282;
const POPUP_HEIGHT = 160;
const POPUP_PADDING = 10;
const TTSControl = () => {
const _ = useTranslation();
const { getView } = useReaderStore();
const { getView, getViewSettings } = useReaderStore();
const [bookKey, setBookKey] = useState<string>('');
const [ttsLang, setTtsLang] = useState<string>('en');
const [isPlaying, setIsPlaying] = useState(false);
const [showPanel, setShowPanel] = useState(false);
const [panelPosition, setPanelPosition] = useState<Position>();
@@ -45,14 +47,21 @@ const TTSControl = () => {
const handleSpeak = async (event: CustomEvent) => {
const { bookKey, ssml } = event.detail;
const view = getView(bookKey);
if (!view) return;
const viewSettings = getViewSettings(bookKey);
if (!view || !viewSettings) return;
setBookKey(bookKey);
try {
const lang = parseSSMLLang(ssml) || 'en';
setTtsLang(lang);
const ttsClient = new WebSpeechClient();
await ttsClient.init();
const ttsController = new TTSController(ttsClient, view, lang);
ttsController.setRate(viewSettings.ttsRate);
ttsController.setVoice(viewSettings.ttsVoice);
ttsController.speak(ssml);
ttsControllerRef.current = ttsController;
ttsControllerRef.current.speak(ssml);
setIsPlaying(true);
} catch (error) {
eventDispatcher.dispatch('toast', {
@@ -104,7 +113,22 @@ const TTSControl = () => {
const handleSetRate = async (rate: number) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
await ttsController.setRate(rate);
ttsController.setRate(rate);
}
};
const handleGetVoices = async (lang: string) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
return ttsController.getVoices(lang);
}
return [];
};
const handleSetVoice = async (voice: string) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
ttsController.setVoice(voice);
}
};
@@ -136,8 +160,19 @@ const TTSControl = () => {
setShowPanel((prev) => !prev);
};
const handleDismissPopup = () => {
setShowPanel(false);
};
return (
<div>
{showPanel && (
<div
className='fixed inset-0'
onClick={handleDismissPopup}
onContextMenu={handleDismissPopup}
/>
)}
{ttsControllerRef.current && (
<div ref={iconRef} className='absolute bottom-12 right-6 h-12 w-12'>
<TTSIcon isPlaying={isPlaying} onClick={togglePopup} />
@@ -152,12 +187,16 @@ const TTSControl = () => {
className='bg-base-200 absolute flex shadow-lg'
>
<TTSPanel
bookKey={bookKey}
ttsLang={ttsLang}
isPlaying={isPlaying}
onTogglePlay={handleTogglePlay}
onBackward={handleBackward}
onForward={handleForward}
onStop={handleStop}
onSetRate={handleSetRate}
onGetVoices={handleGetVoices}
onSetVoice={handleSetVoice}
/>
</Popup>
)}
@@ -1,42 +1,86 @@
import React, { useState } from 'react';
import clsx from 'clsx';
import React, { useState, ChangeEvent, useEffect } from 'react';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { MdPlayCircle, MdPauseCircle, MdFastRewind, MdFastForward, MdStop } from 'react-icons/md';
import { RiVoiceAiFill } from 'react-icons/ri';
import { MdCheck } from 'react-icons/md';
type TTSPanelProps = {
bookKey: string;
ttsLang: string;
isPlaying: boolean;
onTogglePlay: () => void;
onBackward: () => void;
onForward: () => void;
onStop: () => void;
onSetRate: (rate: number) => void;
onGetVoices: (lang: string) => Promise<string[]>;
onSetVoice: (voice: string) => void;
};
const TTSPanel = ({
bookKey,
ttsLang,
isPlaying,
onTogglePlay,
onBackward,
onForward,
onStop,
onSetRate,
onGetVoices,
onSetVoice,
}: TTSPanelProps) => {
const _ = useTranslation();
const [rate, setRate] = useState(1.0);
const { getViewSettings, setViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey);
const [voices, setVoices] = useState<string[]>([]);
const [rate, setRate] = useState(viewSettings?.ttsRate ?? 1.0);
const [selectedVoice, setSelectedVoice] = useState(viewSettings?.ttsVoice ?? '');
const handleSetRate = (e: ChangeEvent<HTMLInputElement>) => {
let newRate = parseFloat(e.target.value);
newRate = Math.max(0.2, Math.min(3.0, newRate));
setRate(newRate);
const viewSettings = getViewSettings(bookKey)!;
viewSettings.ttsRate = newRate;
setViewSettings(bookKey, viewSettings);
};
const handleSelectVoice = (voice: string) => {
onSetVoice(voice);
setSelectedVoice(voice);
const viewSettings = getViewSettings(bookKey)!;
viewSettings.ttsVoice = voice;
setViewSettings(bookKey, viewSettings);
};
useEffect(() => {
const fetchVoices = async () => {
const voices = await onGetVoices(ttsLang);
setVoices(voices);
};
fetchVoices();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ttsLang]);
useEffect(() => {
onSetRate(rate);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rate]);
return (
<div className='flex w-full flex-col items-center justify-center gap-2 rounded-2xl p-4'>
<div className='flex w-full flex-col items-center gap-0.5'>
<input
type='range'
min={0.5}
max={3}
value={rate}
className='range'
type='range'
min={0.0}
max={3.0}
step='0.1'
onChange={(e) => {
const newRate = parseFloat(e.target.value);
setRate(newRate);
onSetRate(newRate);
}}
value={rate}
onChange={handleSetRate}
/>
<div className='grid w-full grid-cols-7 text-xs'>
<span className='text-center'>|</span>
@@ -57,7 +101,7 @@ const TTSPanel = ({
<span className='text-center'>{_('Fast')}</span>
</div>
</div>
<div className='flex items-center justify-between space-x-4'>
<div className='flex items-center justify-between space-x-2'>
<button onClick={onBackward} className='hover:bg-base-200/75 rounded-full p-1'>
<MdFastRewind size={32} />
</button>
@@ -74,6 +118,29 @@ const TTSPanel = ({
<button onClick={onStop} className='hover:bg-base-200/75 rounded-full p-1'>
<MdStop size={32} />
</button>
<div className='dropdown dropdown-top'>
<button tabIndex={0} className='hover:bg-base-200/75 rounded-full p-1'>
<RiVoiceAiFill size={32} />
</button>
<ul
tabIndex={0}
className={clsx(
'dropdown-content bgcolor-base-200 no-triangle menu rounded-box absolute right-0 z-[1] shadow',
'mt-4 max-h-96 w-64 overflow-y-scroll',
)}
>
{voices.map((voice) => (
<li key={voice} onClick={() => handleSelectVoice(voice)}>
<div className='flex items-center px-0'>
<span style={{ minWidth: '20px' }}>
{selectedVoice === voice && <MdCheck size={20} className='text-base-content' />}
</span>
<span className='text-sm'> {voice} </span>
</div>
</li>
))}
</ul>
</div>
</div>
</div>
);
@@ -25,6 +25,7 @@ import {
DEFAULT_READSETTINGS,
SYSTEM_SETTINGS_VERSION,
DEFAULT_BOOK_SEARCH_CONFIG,
DEFAULT_TTS_CONFIG,
} from './constants';
import { isValidURL } from '@/utils/misc';
import { deserializeConfig, serializeConfig } from '@/utils/serializer';
@@ -68,6 +69,7 @@ export abstract class BaseAppService implements AppService {
...DEFAULT_BOOK_STYLE,
...DEFAULT_BOOK_FONT,
...DEFAULT_VIEW_CONFIG,
...DEFAULT_TTS_CONFIG,
...settings.globalViewSettings,
};
} catch {
@@ -84,6 +86,7 @@ export abstract class BaseAppService implements AppService {
...DEFAULT_BOOK_STYLE,
...DEFAULT_BOOK_FONT,
...DEFAULT_VIEW_CONFIG,
...DEFAULT_TTS_CONFIG,
},
};
+13 -1
View File
@@ -1,4 +1,11 @@
import { BookFont, BookLayout, BookSearchConfig, BookStyle, ViewConfig } from '@/types/book';
import {
BookFont,
BookLayout,
BookSearchConfig,
BookStyle,
TTSConfig,
ViewConfig,
} from '@/types/book';
import { ReadSettings } from '@/types/settings';
export const LOCAL_BOOKS_SUBDIR = 'Readest/Books';
@@ -61,6 +68,11 @@ export const DEFAULT_VIEW_CONFIG: ViewConfig = {
sideBarTab: 'toc',
};
export const DEFAULT_TTS_CONFIG: TTSConfig = {
ttsRate: 1.0,
ttsVoice: '',
};
export const DEFAULT_BOOK_SEARCH_CONFIG: BookSearchConfig = {
scope: 'book',
matchCase: false,
@@ -14,4 +14,6 @@ export interface TTSClient {
stop(): Promise<void>;
setRate(rate: number): Promise<void>;
setPitch(pitch: number): Promise<void>;
getVoices(lang: string): Promise<string[]>;
setVoice(voice: string): Promise<void>;
}
@@ -108,6 +108,14 @@ export class TTSController extends EventTarget {
this.ttsClient.setRate(rate);
}
async getVoices(lang: string) {
return this.ttsClient.getVoices(lang);
}
async setVoice(voice: string) {
await this.ttsClient.setVoice(voice);
}
error(e: unknown) {
console.error(e);
this.state = 'stopped';
@@ -12,7 +12,12 @@ interface TTSBoundaryEvent {
error?: string;
}
async function* speakWithBoundary(ssml: string, getRate: () => number, getPitch: () => number) {
async function* speakWithBoundary(
ssml: string,
getRate: () => number,
getPitch: () => number,
getVoice: () => SpeechSynthesisVoice | null,
) {
const lang = parseSSMLLang(ssml);
const { plainText, marks } = parseSSMLMarks(ssml);
// console.log('ssml', ssml, marks);
@@ -21,17 +26,25 @@ async function* speakWithBoundary(ssml: string, getRate: () => number, getPitch:
const synth = window.speechSynthesis;
const utterance = new SpeechSynthesisUtterance(plainText);
utterance.rate = getRate();
utterance.pitch = getPitch();
const voice = getVoice();
if (voice) {
utterance.voice = voice;
}
if (lang) {
utterance.lang = lang;
}
utterance.rate = getRate();
utterance.pitch = getPitch();
const queue = new AsyncQueue<TTSBoundaryEvent>();
utterance.onboundary = (event: SpeechSynthesisEvent) => {
utterance.rate = getRate();
utterance.pitch = getPitch();
const voice = getVoice();
if (voice) {
utterance.voice = voice;
}
const mark = findSSMLMark(event.charIndex, marks);
// console.log('boundary', event.charIndex, mark);
queue.enqueue({
@@ -65,22 +78,103 @@ async function* speakWithBoundary(ssml: string, getRate: () => number, getPitch:
}
}
async function* speakWithMarks(
ssml: string,
getRate: () => number,
getPitch: () => number,
getVoice: () => SpeechSynthesisVoice | null,
) {
const { plainText, marks } = parseSSMLMarks(ssml);
const lang = parseSSMLLang(ssml);
const isCJK = (lang: string | null) => {
const cjkLangs = ['zh', 'ja', 'ko'];
if (lang && cjkLangs.some((cjk) => lang.startsWith(cjk))) return true;
return /[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/.test(plainText);
};
if (!isCJK(lang)) {
yield* speakWithBoundary(ssml, getRate, getPitch, getVoice);
return;
}
const synth = window.speechSynthesis;
for (const mark of marks) {
const utterance = new SpeechSynthesisUtterance(mark.text);
utterance.rate = getRate();
utterance.pitch = getPitch();
const voice = getVoice();
if (voice) {
utterance.voice = voice;
}
if (lang) {
utterance.lang = lang;
}
yield {
type: 'boundary',
speaking: true,
name: 'sentence',
mark: mark.name,
} as TTSBoundaryEvent;
const result = await new Promise<TTSBoundaryEvent>((resolve) => {
utterance.onend = () => resolve({ type: 'end', speaking: false });
utterance.onerror = (event) =>
resolve({
type: 'error',
speaking: false,
error: event.error,
});
synth.speak(utterance);
});
yield result;
if (result.type === 'error') {
break;
}
}
}
export class WebSpeechClient implements TTSClient {
#rate = 1.0;
#pitch = 1.0;
#voice: SpeechSynthesisVoice | null = null;
#voices: SpeechSynthesisVoice[] = [];
#synth = window.speechSynthesis;
async init() {
if (!this.#synth) {
throw new Error('Web Speech API not supported in this browser');
}
await new Promise<void>((resolve) => {
const populateVoices = () => {
this.#voices = this.#synth.getVoices();
if (this.#voices.length > 0) {
resolve();
}
};
if (this.#synth.getVoices().length > 0) {
populateVoices();
} else if (this.#synth.onvoiceschanged !== undefined) {
this.#synth.onvoiceschanged = populateVoices;
} else {
console.warn('Voiceschanged event not supported.');
resolve();
}
});
}
async *speak(ssml: string): AsyncGenerator<TTSMessageEvent> {
for await (const ev of speakWithBoundary(
for await (const ev of speakWithMarks(
ssml,
() => this.#rate,
() => this.#pitch,
() => this.#voice,
)) {
if (ev.type === 'boundary') {
yield {
@@ -117,4 +211,15 @@ export class WebSpeechClient implements TTSClient {
// The Web Speech API uses pitch in [0 .. 2].
this.#pitch = pitch;
}
async getVoices(lang: string) {
return this.#voices.filter((voice) => voice.lang.startsWith(lang)).map((voice) => voice.name);
}
async setVoice(voice: string) {
const selectedVoice = this.#voices.find((v) => v.name === voice);
if (selectedVoice) {
this.#voice = selectedVoice;
}
}
}
+6 -1
View File
@@ -80,7 +80,12 @@ export interface ViewConfig {
sideBarTab: string;
}
export interface ViewSettings extends BookLayout, BookStyle, BookFont, ViewConfig {}
export interface TTSConfig {
ttsRate: number;
ttsVoice: string;
}
export interface ViewSettings extends BookLayout, BookStyle, BookFont, ViewConfig, TTSConfig {}
export interface BookProgress {
location: string;
+5 -5
View File
@@ -123,8 +123,8 @@ importers:
specifier: ^15.2.0
version: 15.2.0(i18next@24.2.0(typescript@5.7.2))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react-icons:
specifier: ^5.3.0
version: 5.3.0(react@19.0.0)
specifier: ^5.4.0
version: 5.4.0(react@19.0.0)
tinycolor2:
specifier: ^1.6.0
version: 1.6.0
@@ -2683,8 +2683,8 @@ packages:
react-native:
optional: true
react-icons@5.3.0:
resolution: {integrity: sha512-DnUk8aFbTyQPSkCfF8dbX6kQjXA9DktMeJqfjrg6cK9vwQVMxmcA3BfP4QoiztVmEHtwlTgLFsPuH2NskKT6eg==}
react-icons@5.4.0:
resolution: {integrity: sha512-7eltJxgVt7X64oHh6wSWNwwbKTCtMfK35hcjvJS0yxEAhPM8oUKdS3+kqaW1vicIltw+kR2unHaa12S9pPALoQ==}
peerDependencies:
react: '*'
@@ -5802,7 +5802,7 @@ snapshots:
optionalDependencies:
react-dom: 19.0.0(react@19.0.0)
react-icons@5.3.0(react@19.0.0):
react-icons@5.4.0(react@19.0.0):
dependencies:
react: 19.0.0