diff --git a/apps/readest-app/package.json b/apps/readest-app/package.json index 0808ee61..acc28de2 100644 --- a/apps/readest-app/package.json +++ b/apps/readest-app/package.json @@ -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" }, diff --git a/apps/readest-app/src/app/reader/components/tts/TTSControl.tsx b/apps/readest-app/src/app/reader/components/tts/TTSControl.tsx index 93e439dd..59250209 100644 --- a/apps/readest-app/src/app/reader/components/tts/TTSControl.tsx +++ b/apps/readest-app/src/app/reader/components/tts/TTSControl.tsx @@ -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(''); + const [ttsLang, setTtsLang] = useState('en'); const [isPlaying, setIsPlaying] = useState(false); const [showPanel, setShowPanel] = useState(false); const [panelPosition, setPanelPosition] = useState(); @@ -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 (
+ {showPanel && ( +
+ )} {ttsControllerRef.current && (
@@ -152,12 +187,16 @@ const TTSControl = () => { className='bg-base-200 absolute flex shadow-lg' > )} diff --git a/apps/readest-app/src/app/reader/components/tts/TTSPanel.tsx b/apps/readest-app/src/app/reader/components/tts/TTSPanel.tsx index 8bc4529b..0bdd967d 100644 --- a/apps/readest-app/src/app/reader/components/tts/TTSPanel.tsx +++ b/apps/readest-app/src/app/reader/components/tts/TTSPanel.tsx @@ -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; + 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([]); + const [rate, setRate] = useState(viewSettings?.ttsRate ?? 1.0); + const [selectedVoice, setSelectedVoice] = useState(viewSettings?.ttsVoice ?? ''); + + const handleSetRate = (e: ChangeEvent) => { + 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 (
{ - const newRate = parseFloat(e.target.value); - setRate(newRate); - onSetRate(newRate); - }} + value={rate} + onChange={handleSetRate} />
| @@ -57,7 +101,7 @@ const TTSPanel = ({ {_('Fast')}
-
+
@@ -74,6 +118,29 @@ const TTSPanel = ({ +
+ +
    + {voices.map((voice) => ( +
  • handleSelectVoice(voice)}> +
    + + {selectedVoice === voice && } + + {voice} +
    +
  • + ))} +
+
); diff --git a/apps/readest-app/src/services/appService.ts b/apps/readest-app/src/services/appService.ts index 921e70e8..dbd7bc5d 100644 --- a/apps/readest-app/src/services/appService.ts +++ b/apps/readest-app/src/services/appService.ts @@ -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, }, }; diff --git a/apps/readest-app/src/services/constants.ts b/apps/readest-app/src/services/constants.ts index 4d7b380d..d8f4478c 100644 --- a/apps/readest-app/src/services/constants.ts +++ b/apps/readest-app/src/services/constants.ts @@ -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, diff --git a/apps/readest-app/src/services/tts/TTSClient.ts b/apps/readest-app/src/services/tts/TTSClient.ts index 49aef2ba..34f193ef 100644 --- a/apps/readest-app/src/services/tts/TTSClient.ts +++ b/apps/readest-app/src/services/tts/TTSClient.ts @@ -14,4 +14,6 @@ export interface TTSClient { stop(): Promise; setRate(rate: number): Promise; setPitch(pitch: number): Promise; + getVoices(lang: string): Promise; + setVoice(voice: string): Promise; } diff --git a/apps/readest-app/src/services/tts/TTSController.ts b/apps/readest-app/src/services/tts/TTSController.ts index 060d51ed..dad703d7 100644 --- a/apps/readest-app/src/services/tts/TTSController.ts +++ b/apps/readest-app/src/services/tts/TTSController.ts @@ -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'; diff --git a/apps/readest-app/src/services/tts/WebSpeechClient.ts b/apps/readest-app/src/services/tts/WebSpeechClient.ts index e1e53651..d8c27917 100644 --- a/apps/readest-app/src/services/tts/WebSpeechClient.ts +++ b/apps/readest-app/src/services/tts/WebSpeechClient.ts @@ -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(); 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((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((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 { - 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; + } + } } diff --git a/apps/readest-app/src/types/book.ts b/apps/readest-app/src/types/book.ts index f691728d..a11d43ce 100644 --- a/apps/readest-app/src/types/book.ts +++ b/apps/readest-app/src/types/book.ts @@ -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; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e8a9fee7..97686471 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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