From c72afe269adb5b028b4138dd7b29eece36dc63c6 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Sat, 13 Jun 2026 10:32:27 +0800 Subject: [PATCH] fix(tts): keep voice list stable across region variants of a language, closes #4033 (#4565) The voice panel filtered voices by the full locale of the currently speaking text (v.lang.startsWith(locale)), so a book mixing region variants of one language flip-flopped its voice list: Standard Ebooks tag their boilerplate front matter en-US (17 Edge voices) while the body text is en-GB (5 Edge voices). Filter by primary language instead (isSameLang) in all three TTS clients so every English variant yields the same voice set, and sort voices matching the requested locale first (TTSUtils.sortVoicesPreferLocaleFunc) so default-voice resolution via getVoiceIdFromLang still picks an exact-locale voice. This also fixes languages whose tags never matched a voice locale prefix at all (e.g. zh-Hans books previously got an empty Edge voice list). Co-authored-by: Claude Fable 5 --- .../services/edge-tts-client.test.ts | 57 +++++++++++++--- .../src/__tests__/services/tts-utils.test.ts | 38 +++++++++++ .../services/web-speech-client.test.ts | 67 +++++++++++++++++++ .../src/services/tts/EdgeTTSClient.ts | 10 +-- .../src/services/tts/NativeTTSClient.ts | 10 +-- apps/readest-app/src/services/tts/TTSUtils.ts | 15 +++++ .../src/services/tts/WebSpeechClient.ts | 12 ++-- 7 files changed, 186 insertions(+), 23 deletions(-) create mode 100644 apps/readest-app/src/__tests__/services/web-speech-client.test.ts diff --git a/apps/readest-app/src/__tests__/services/edge-tts-client.test.ts b/apps/readest-app/src/__tests__/services/edge-tts-client.test.ts index 6e5fd824..7fc5c10f 100644 --- a/apps/readest-app/src/__tests__/services/edge-tts-client.test.ts +++ b/apps/readest-app/src/__tests__/services/edge-tts-client.test.ts @@ -34,12 +34,17 @@ vi.mock('@/utils/misc', () => ({ getUserLocale: vi.fn((lang: string) => (lang === 'en' ? 'en-US' : lang)), })); -vi.mock('@/services/tts/TTSUtils', () => ({ - TTSUtils: { - getPreferredVoice: vi.fn(() => null), - sortVoicesFunc: (a: { id: string }, b: { id: string }) => a.id.localeCompare(b.id), - }, -})); +vi.mock('@/services/tts/TTSUtils', async (importOriginal) => { + const { TTSUtils: ActualTTSUtils } = + await importOriginal(); + return { + TTSUtils: { + getPreferredVoice: vi.fn(() => null), + sortVoicesFunc: ActualTTSUtils.sortVoicesFunc, + sortVoicesPreferLocaleFunc: ActualTTSUtils.sortVoicesPreferLocaleFunc, + }, + }; +}); import { EdgeTTSClient } from '@/services/tts/EdgeTTSClient'; import { TTSController } from '@/services/tts/TTSController'; @@ -305,11 +310,45 @@ describe('EdgeTTSClient', () => { expect(voiceIds).toContain('en-GB-SoniaNeural'); }); - test('returns sorted voices using TTSUtils.sortVoicesFunc', async () => { + test('returns sorted voices with user-locale voices first for "en"', async () => { + // getUserLocale is mocked to return en-US for 'en' const groups = await client.getVoices('en'); const voiceIds = groups[0]!.voices.map((v) => v.id); - const sorted = [...voiceIds].sort(); - expect(voiceIds).toEqual(sorted); + expect(voiceIds).toEqual(['en-US-AnaNeural', 'en-US-AriaNeural', 'en-GB-SoniaNeural']); + }); + + // #4033: the voice set must not change between parts of a single book that + // mix region variants of the same language (e.g. en-US front matter and + // en-GB body text in Standard Ebooks) + test('returns the same English voice set for any region variant', async () => { + const ids = async (lang: string) => + (await client.getVoices(lang))[0]!.voices.map((v) => v.id).sort(); + const us = await ids('en-US'); + const gb = await ids('en-GB'); + const en = await ids('en'); + expect(gb).toEqual(us); + expect(en).toEqual(us); + expect(us).toEqual(['en-GB-SoniaNeural', 'en-US-AnaNeural', 'en-US-AriaNeural']); + }); + + test('lists voices of the requested locale first', async () => { + const gb = await client.getVoices('en-GB'); + expect(gb[0]!.voices[0]!.id).toBe('en-GB-SoniaNeural'); + const us = await client.getVoices('en-US'); + expect(us[0]!.voices[0]!.id).toBe('en-US-AnaNeural'); + }); + + test('does not include voices from other languages', async () => { + const fr = await client.getVoices('fr-FR'); + expect(fr[0]!.voices.map((v) => v.id)).toEqual(['fr-FR-DeniseNeural']); + const en = await client.getVoices('en-US'); + expect(en[0]!.voices.map((v) => v.id)).not.toContain('fr-FR-DeniseNeural'); + }); + + test('getVoiceIdFromLang still resolves an exact-locale default voice', async () => { + expect(await client.getVoiceIdFromLang('en-GB')).toBe('en-GB-SoniaNeural'); + // AnaNeural sorts first for en-US but is avoided as default + expect(await client.getVoiceIdFromLang('en-US')).toBe('en-US-AriaNeural'); }); test('marks group as disabled when not initialized', async () => { diff --git a/apps/readest-app/src/__tests__/services/tts-utils.test.ts b/apps/readest-app/src/__tests__/services/tts-utils.test.ts index 024ff134..6937b4ac 100644 --- a/apps/readest-app/src/__tests__/services/tts-utils.test.ts +++ b/apps/readest-app/src/__tests__/services/tts-utils.test.ts @@ -199,6 +199,44 @@ describe('TTSUtils', () => { }); }); + describe('sortVoicesPreferLocaleFunc', () => { + const aria = { id: 'en-US-AriaNeural', name: 'Aria', lang: 'en-US' }; + const sonia = { id: 'en-GB-SoniaNeural', name: 'Sonia', lang: 'en-GB' }; + const natasha = { id: 'en-AU-NatashaNeural', name: 'Natasha', lang: 'en-AU' }; + + test('voices matching the locale sort first', () => { + const sorted = [aria, natasha, sonia].sort(TTSUtils.sortVoicesPreferLocaleFunc('en-GB')); + expect(sorted.map((v) => v.id)).toEqual([ + 'en-GB-SoniaNeural', + 'en-US-AriaNeural', + 'en-AU-NatashaNeural', + ]); + }); + + test('falls back to sortVoicesFunc when no voice matches the locale', () => { + const sorted = [natasha, sonia, aria].sort(TTSUtils.sortVoicesPreferLocaleFunc('en-NZ')); + expect(sorted.map((v) => v.id)).toEqual([ + 'en-US-AriaNeural', + 'en-GB-SoniaNeural', + 'en-AU-NatashaNeural', + ]); + }); + + test('locale match is case-insensitive', () => { + const sorted = [aria, sonia].sort(TTSUtils.sortVoicesPreferLocaleFunc('en-gb')); + expect(sorted[0]).toBe(sonia); + }); + + test('empty locale keeps sortVoicesFunc order', () => { + const sorted = [natasha, sonia, aria].sort(TTSUtils.sortVoicesPreferLocaleFunc('')); + expect(sorted.map((v) => v.id)).toEqual([ + 'en-US-AriaNeural', + 'en-GB-SoniaNeural', + 'en-AU-NatashaNeural', + ]); + }); + }); + describe('persistence', () => { test('preferences survive across calls', () => { TTSUtils.setPreferredClient('edge'); diff --git a/apps/readest-app/src/__tests__/services/web-speech-client.test.ts b/apps/readest-app/src/__tests__/services/web-speech-client.test.ts new file mode 100644 index 00000000..b1586bcf --- /dev/null +++ b/apps/readest-app/src/__tests__/services/web-speech-client.test.ts @@ -0,0 +1,67 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; + +vi.mock('@/utils/misc', () => ({ + getUserLocale: vi.fn((lang: string) => (lang === 'en' ? 'en-US' : lang)), +})); + +import { WebSpeechClient } from '@/services/tts/WebSpeechClient'; + +const makeVoice = (name: string, lang: string) => + ({ + name, + lang, + voiceURI: `${lang}.${name}`, + localService: true, + default: false, + }) as SpeechSynthesisVoice; + +const voices = [ + makeVoice('Aria', 'en-US'), + makeVoice('Ana', 'en-US'), + makeVoice('Sonia', 'en-GB'), + makeVoice('Denise', 'fr-FR'), +]; + +describe('WebSpeechClient getVoices', () => { + let client: WebSpeechClient; + + beforeEach(async () => { + Object.defineProperty(window, 'speechSynthesis', { + value: { + getVoices: () => voices, + cancel: vi.fn(), + pause: vi.fn(), + resume: vi.fn(), + speak: vi.fn(), + }, + configurable: true, + }); + client = new WebSpeechClient(); + await client.init(); + }); + + // #4033: the voice set must not change between parts of a single book that + // mix region variants of the same language + test('returns the same English voice set for any region variant', async () => { + const names = async (lang: string) => + (await client.getVoices(lang))[0]!.voices.map((v) => v.name).sort(); + const us = await names('en-US'); + const gb = await names('en-GB'); + const en = await names('en'); + expect(gb).toEqual(us); + expect(en).toEqual(us); + expect(us).toEqual(['Ana', 'Aria', 'Sonia']); + }); + + test('lists voices of the requested locale first', async () => { + const gb = await client.getVoices('en-GB'); + expect(gb[0]!.voices[0]!.name).toBe('Sonia'); + }); + + test('does not include voices from other languages', async () => { + const fr = await client.getVoices('fr-FR'); + expect(fr[0]!.voices.map((v) => v.name)).toEqual(['Denise']); + const en = await client.getVoices('en-US'); + expect(en[0]!.voices.map((v) => v.name)).not.toContain('Denise'); + }); +}); diff --git a/apps/readest-app/src/services/tts/EdgeTTSClient.ts b/apps/readest-app/src/services/tts/EdgeTTSClient.ts index b64217fb..219b9ae9 100644 --- a/apps/readest-app/src/services/tts/EdgeTTSClient.ts +++ b/apps/readest-app/src/services/tts/EdgeTTSClient.ts @@ -1,4 +1,5 @@ import { getUserLocale } from '@/utils/misc'; +import { isSameLang } from '@/utils/lang'; import { TTSClient, TTSMessageEvent } from './TTSClient'; import { EdgeSpeechTTS, EdgeTTSPayload, EDGE_TTS_PROTOCOL } from '@/libs/edgeTTS'; import { TTSGranularity, TTSVoice, TTSVoicesGroup } from './types'; @@ -320,14 +321,15 @@ export class EdgeTTSClient implements TTSClient { async getVoices(lang: string) { const locale = lang === 'en' ? getUserLocale(lang) || lang : lang; const voices = await this.getAllVoices(); - const filteredVoices = voices.filter( - (v) => v.lang.startsWith(locale) || (lang === 'en' && ['en-US', 'en-GB'].includes(v.lang)), - ); + // Match by primary language so the voice set stays the same across a book + // whose sections mix region variants (e.g. en-US front matter and en-GB + // body text); the requested locale's voices sort first. See #4033. + const filteredVoices = voices.filter((v) => isSameLang(v.lang, lang)); const voicesGroup: TTSVoicesGroup = { id: 'edge-tts', name: 'Edge TTS', - voices: filteredVoices.sort(TTSUtils.sortVoicesFunc), + voices: filteredVoices.sort(TTSUtils.sortVoicesPreferLocaleFunc(locale)), disabled: !this.initialized || filteredVoices.length === 0, }; diff --git a/apps/readest-app/src/services/tts/NativeTTSClient.ts b/apps/readest-app/src/services/tts/NativeTTSClient.ts index 9838770a..30974d55 100644 --- a/apps/readest-app/src/services/tts/NativeTTSClient.ts +++ b/apps/readest-app/src/services/tts/NativeTTSClient.ts @@ -1,6 +1,7 @@ import { invoke } from '@tauri-apps/api/core'; import { addPluginListener, PluginListener } from '@tauri-apps/api/core'; import { getUserLocale } from '@/utils/misc'; +import { isSameLang } from '@/utils/lang'; import { parseSSMLMarks } from '@/utils/ssml'; import { stubTranslation as _ } from '@/utils/misc'; import { TTSClient, TTSMessageEvent } from './TTSClient'; @@ -250,9 +251,10 @@ export class NativeTTSClient implements TTSClient { async getVoices(lang: string) { const locale = lang === 'en' ? getUserLocale(lang) || lang : lang; const voices = await this.getAllVoices(); - const filteredVoices = voices.filter( - (v) => v.lang.startsWith(locale) || (lang === 'en' && ['en-US', 'en-GB'].includes(v.lang)), - ); + // Match by primary language so the voice set stays the same across a book + // whose sections mix region variants (e.g. en-US front matter and en-GB + // body text); the requested locale's voices sort first. See #4033. + const filteredVoices = voices.filter((v) => isSameLang(v.lang, lang)); const voiceGroups = new Map(); filteredVoices.forEach((voice) => { const { name, lang } = voice; @@ -279,7 +281,7 @@ export class NativeTTSClient implements TTSClient { ({ id: groupId, name: TTSEngines[groupId] || groupId, - voices: voices.sort(TTSUtils.sortVoicesFunc), + voices: voices.sort(TTSUtils.sortVoicesPreferLocaleFunc(locale)), disabled: !this.initialized || voices.length === 0, }) as TTSVoicesGroup, ) diff --git a/apps/readest-app/src/services/tts/TTSUtils.ts b/apps/readest-app/src/services/tts/TTSUtils.ts index 1cfe296b..ac4a9805 100644 --- a/apps/readest-app/src/services/tts/TTSUtils.ts +++ b/apps/readest-app/src/services/tts/TTSUtils.ts @@ -40,6 +40,21 @@ export class TTSUtils { return storedPreferences ? JSON.parse(storedPreferences) : {}; } + // Sorts voices matching the given locale (e.g. 'en-GB') before the rest, + // falling back to sortVoicesFunc, so region-relevant voices stay on top + // while the overall voice set remains the same for every region variant. + static sortVoicesPreferLocaleFunc(locale: string): (a: TTSVoice, b: TTSVoice) => number { + const normalizedLocale = locale.toLowerCase(); + return (a: TTSVoice, b: TTSVoice): number => { + if (normalizedLocale) { + const aMatches = a.lang.toLowerCase().startsWith(normalizedLocale); + const bMatches = b.lang.toLowerCase().startsWith(normalizedLocale); + if (aMatches !== bMatches) return aMatches ? -1 : 1; + } + return TTSUtils.sortVoicesFunc(a, b); + }; + } + static sortVoicesFunc(a: TTSVoice, b: TTSVoice): number { const aRegion = a.lang.split('-')[1] || ''; const bRegion = b.lang.split('-')[1] || ''; diff --git a/apps/readest-app/src/services/tts/WebSpeechClient.ts b/apps/readest-app/src/services/tts/WebSpeechClient.ts index 5d70c4bd..55e1278a 100644 --- a/apps/readest-app/src/services/tts/WebSpeechClient.ts +++ b/apps/readest-app/src/services/tts/WebSpeechClient.ts @@ -1,4 +1,5 @@ import { getUserLocale } from '@/utils/misc'; +import { isSameLang } from '@/utils/lang'; import { TTSClient, TTSMessageEvent } from './TTSClient'; import { parseSSMLMarks } from '@/utils/ssml'; import { TTSGranularity, TTSMark, TTSVoice, TTSVoicesGroup } from './types'; @@ -228,12 +229,11 @@ export class WebSpeechClient implements TTSClient { const isNotBlacklisted = (voice: SpeechSynthesisVoice) => { return WEB_SPEECH_BLACKLISTED_VOICES.some((name) => voice.name.includes(name)) === false; }; + // Match by primary language so the voice set stays the same across a book + // whose sections mix region variants (e.g. en-US front matter and en-GB + // body text); the requested locale's voices sort first. See #4033. const filteredVoices = this.#voices - .filter( - (voice) => - voice.lang.startsWith(locale) || - (lang === 'en' && ['en-US', 'en-GB'].includes(voice.lang)), - ) + .filter((voice) => isSameLang(voice.lang, lang)) .filter((voice) => isValidVoice(voice.voiceURI || '')) .filter(isNotBlacklisted); const seenIds = new Set(); @@ -260,7 +260,7 @@ export class WebSpeechClient implements TTSClient { const voicesGroup: TTSVoicesGroup = { id: 'web-speech-api', name: 'Web TTS', - voices: voices.sort(TTSUtils.sortVoicesFunc), + voices: voices.sort(TTSUtils.sortVoicesPreferLocaleFunc(locale)), disabled: !this.initialized || voices.length === 0, }; return [voicesGroup];