forked from akai/readest
Add select voice button in the TTS panel (#129)
This commit is contained in:
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Generated
+5
-5
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user