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];