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 220c4985..5e0d0f17 100644 --- a/apps/readest-app/src/app/reader/components/tts/TTSControl.tsx +++ b/apps/readest-app/src/app/reader/components/tts/TTSControl.tsx @@ -99,7 +99,7 @@ const TTSControl = () => { const view = getView(bookKey); const viewSettings = getViewSettings(bookKey); const bookData = getBookData(bookKey); - if (!view || !viewSettings || !bookData) return; + if (!view || !viewSettings || !bookData || !bookData.book) return; if (bookData.book?.format === 'PDF') { eventDispatcher.dispatch('toast', { message: _('TTS not supported for PDF'), @@ -108,6 +108,7 @@ const TTSControl = () => { return; } + const primaryLang = bookData.book.primaryLanguage; setBookKey(bookKey); if (ttsControllerRef.current) { @@ -128,9 +129,13 @@ const TTSControl = () => { await ttsController.initViewTTS(); const ssml = view.tts?.from(range); if (ssml) { - const lang = parseSSMLLang(ssml) || 'en'; - setTtsLang(lang); + let lang = parseSSMLLang(ssml) || 'en'; + // We will not trust 'en' language from ssml, as it may be a fallback or hardcoded value + if (lang === 'en' && primaryLang && primaryLang !== 'en') { + lang = primaryLang.split('-')[0]!; + } setIsPlaying(true); + setTtsLang(lang); ttsController.setLang(lang); ttsController.setRate(viewSettings.ttsRate); diff --git a/apps/readest-app/src/services/appService.ts b/apps/readest-app/src/services/appService.ts index 2d0ad613..ab1d8b96 100644 --- a/apps/readest-app/src/services/appService.ts +++ b/apps/readest-app/src/services/appService.ts @@ -15,6 +15,7 @@ import { formatTitle, formatAuthors, getFilename, + getPrimaryLanguage, } from '@/utils/book'; import { partialMD5 } from '@/utils/md5'; import { BookDoc, DocumentLoader } from '@/libs/document'; @@ -198,6 +199,7 @@ export abstract class BaseAppService implements AppService { format, title: formatTitle(loadedBook.metadata.title), author: formatAuthors(loadedBook.metadata.author, loadedBook.metadata.language), + primaryLanguage: getPrimaryLanguage(loadedBook.metadata.language), createdAt: existingBook ? existingBook.createdAt : Date.now(), uploadedAt: existingBook ? existingBook.uploadedAt : null, deletedAt: transient ? Date.now() : null, @@ -208,6 +210,7 @@ export abstract class BaseAppService implements AppService { if (existingBook) { existingBook.title = book.title; existingBook.author = book.author; + existingBook.primaryLanguage = existingBook.primaryLanguage ?? book.primaryLanguage; } if (!(await this.fs.exists(getDir(book), 'Books'))) { diff --git a/apps/readest-app/src/services/constants.ts b/apps/readest-app/src/services/constants.ts index 4401a273..13ea1874 100644 --- a/apps/readest-app/src/services/constants.ts +++ b/apps/readest-app/src/services/constants.ts @@ -588,3 +588,5 @@ export const TRANSLATED_LANGS = { 'zh-CN': '简体中文', 'zh-TW': '正體中文', }; + +export const SUPPORTED_LANGS: Record = { ...TRANSLATED_LANGS, zh: '中文' }; diff --git a/apps/readest-app/src/services/tts/EdgeTTSClient.ts b/apps/readest-app/src/services/tts/EdgeTTSClient.ts index 1804290c..501c53e7 100644 --- a/apps/readest-app/src/services/tts/EdgeTTSClient.ts +++ b/apps/readest-app/src/services/tts/EdgeTTSClient.ts @@ -6,6 +6,7 @@ import { TTSGranularity } from '@/types/view'; import { TTSUtils } from './TTSUtils'; export class EdgeTTSClient implements TTSClient { + #primaryLang = 'en'; #rate = 1.0; #pitch = 1.0; #voice: TTSVoice | null = null; @@ -50,7 +51,10 @@ export class EdgeTTSClient implements TTSClient { preload = false, ): AsyncGenerator { const { marks } = parseSSMLMarks(ssml); - const lang = parseSSMLLang(ssml) || 'en'; + let lang = parseSSMLLang(ssml) || 'en'; + if (lang === 'en' && this.#primaryLang && this.#primaryLang !== 'en') { + lang = this.#primaryLang; + } let voiceId = 'en-US-AriaNeural'; if (!this.#voice || this.#currentVoiceLang !== lang) { const preferredVoiceId = TTSUtils.getPreferredVoice('edge-tts', lang); @@ -207,6 +211,10 @@ export class EdgeTTSClient implements TTSClient { } } + setPrimaryLang(lang: string) { + this.#primaryLang = lang; + } + async setRate(rate: number) { // The Edge TTS API uses rate in [0.5 .. 2.0]. this.#rate = rate; diff --git a/apps/readest-app/src/services/tts/TTSClient.ts b/apps/readest-app/src/services/tts/TTSClient.ts index 92906c36..12aa95f2 100644 --- a/apps/readest-app/src/services/tts/TTSClient.ts +++ b/apps/readest-app/src/services/tts/TTSClient.ts @@ -21,6 +21,7 @@ export interface TTSClient { pause(): Promise; resume(): Promise; stop(): Promise; + setPrimaryLang(lang: string): void; setRate(rate: number): Promise; setPitch(pitch: number): 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 09a4cf3a..2f6e33c7 100644 --- a/apps/readest-app/src/services/tts/TTSController.ts +++ b/apps/readest-app/src/services/tts/TTSController.ts @@ -219,6 +219,12 @@ export class TTSController extends EventTarget { async setLang(lang: string) { this.ttsLang = lang; + this.setPrimaryLang(lang); + } + + async setPrimaryLang(lang: string) { + this.ttsEdgeClient.setPrimaryLang(lang); + this.ttsWebClient.setPrimaryLang(lang); } async setRate(rate: number) { diff --git a/apps/readest-app/src/services/tts/WebSpeechClient.ts b/apps/readest-app/src/services/tts/WebSpeechClient.ts index 89957436..92ee07fd 100644 --- a/apps/readest-app/src/services/tts/WebSpeechClient.ts +++ b/apps/readest-app/src/services/tts/WebSpeechClient.ts @@ -173,6 +173,7 @@ async function* speakWithMarks( } export class WebSpeechClient implements TTSClient { + #primaryLang = 'en'; #rate = 1.0; #pitch = 1.0; #voice: SpeechSynthesisVoice | null = null; @@ -215,7 +216,10 @@ export class WebSpeechClient implements TTSClient { // no need to preload for web speech if (preload) return; - const lang = parseSSMLLang(ssml) || 'en'; + let lang = parseSSMLLang(ssml) || 'en'; + if (lang === 'en' && this.#primaryLang && this.#primaryLang !== 'en') { + lang = this.#primaryLang; + } if (!this.#voice || this.#currentVoiceLang !== lang) { const preferredVoiceId = TTSUtils.getPreferredVoice('web-speech', lang); const preferredVoice = this.#voices.find((v) => v.voiceURI === preferredVoiceId); @@ -262,6 +266,10 @@ export class WebSpeechClient implements TTSClient { this.#synth.cancel(); } + setPrimaryLang(lang: string) { + this.#primaryLang = lang; + } + async setRate(rate: number) { // The Web Speech API uses utterance.rate in [0.1 .. 10], this.#rate = rate; diff --git a/apps/readest-app/src/store/readerStore.ts b/apps/readest-app/src/store/readerStore.ts index f19f7422..0f74629d 100644 --- a/apps/readest-app/src/store/readerStore.ts +++ b/apps/readest-app/src/store/readerStore.ts @@ -8,6 +8,7 @@ import { updateTocCFI, updateTocID } from '@/utils/toc'; import { useSettingsStore } from './settingsStore'; import { useBookDataStore } from './bookDataStore'; import { useLibraryStore } from './libraryStore'; +import { getPrimaryLanguage } from '@/utils/book'; interface ViewState { /* Unique key for each book view */ @@ -136,6 +137,9 @@ export const useReaderStore = create((set, get) => ({ }, {}); updateTocCFI(bookDoc, bookDoc.toc, sections); } + // Set the book's language for formerly imported books, newly imported books have this field set + book.primaryLanguage = + book.primaryLanguage ?? getPrimaryLanguage(bookDoc.metadata.language); useBookDataStore.setState((state) => ({ booksData: { ...state.booksData, diff --git a/apps/readest-app/src/types/book.ts b/apps/readest-app/src/types/book.ts index 9b214aa9..d616fbd8 100644 --- a/apps/readest-app/src/types/book.ts +++ b/apps/readest-app/src/types/book.ts @@ -27,6 +27,7 @@ export interface Book { lastUpdated?: number; // deprecated in favor of updatedAt progress?: [number, number]; // Add progress field: [current, total], 1-based page number + primaryLanguage?: string; } export interface BookGroupType { diff --git a/apps/readest-app/src/utils/book.ts b/apps/readest-app/src/utils/book.ts index bbebe667..1b6ddb70 100644 --- a/apps/readest-app/src/utils/book.ts +++ b/apps/readest-app/src/utils/book.ts @@ -3,6 +3,7 @@ import { Book, BookConfig, BookProgress, WritingMode } from '@/types/book'; import { getUserLang, isContentURI, isValidURL, makeSafeFilename } from './misc'; import { getStorageType } from './object'; import { getDirFromLanguage } from './rtl'; +import { SUPPORTED_LANGS } from '@/services/constants'; export const getDir = (book: Book) => { return `${book.hash}`; @@ -107,11 +108,17 @@ export const formatPublisher = (publisher: string | LanguageMap) => { return typeof publisher === 'string' ? publisher : formatLanguageMap(publisher); }; -export const formatLanguage = (lang: string | string[] | undefined) => { - return Array.isArray(lang) ? lang.join(', ') : lang; +const langCodeToLangName = (langCode: string) => { + return SUPPORTED_LANGS[langCode] || langCode.toUpperCase(); }; -export const primaryLanguage = (lang: string | string[] | undefined) => { +export const formatLanguage = (lang: string | string[] | undefined): string => { + return Array.isArray(lang) + ? lang.map(langCodeToLangName).join(', ') + : langCodeToLangName(lang || ''); +}; + +export const getPrimaryLanguage = (lang: string | string[] | undefined) => { return Array.isArray(lang) ? lang[0] : lang; }; @@ -159,6 +166,6 @@ export const getBookDirFromWritingMode = (writingMode: WritingMode) => { }; export const getBookDirFromLanguage = (language: string | string[] | undefined) => { - const lang = primaryLanguage(language) || ''; + const lang = getPrimaryLanguage(language) || ''; return getDirFromLanguage(lang); };