fix: tts now fallbacks to metadata language of the book, closes #916 (#945)

This commit is contained in:
Huang Xin
2025-04-23 00:12:14 +08:00
committed by GitHub
parent ee93885c04
commit 4dc943d23e
10 changed files with 54 additions and 9 deletions
@@ -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);
@@ -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'))) {
@@ -588,3 +588,5 @@ export const TRANSLATED_LANGS = {
'zh-CN': '简体中文',
'zh-TW': '正體中文',
};
export const SUPPORTED_LANGS: Record<string, string> = { ...TRANSLATED_LANGS, zh: '中文' };
@@ -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<TTSMessageEvent> {
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;
@@ -21,6 +21,7 @@ export interface TTSClient {
pause(): Promise<void>;
resume(): Promise<void>;
stop(): Promise<void>;
setPrimaryLang(lang: string): void;
setRate(rate: number): Promise<void>;
setPitch(pitch: number): Promise<void>;
setVoice(voice: string): Promise<void>;
@@ -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) {
@@ -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;
@@ -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<ReaderStore>((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,
+1
View File
@@ -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 {
+11 -4
View File
@@ -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);
};