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 <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-13 10:32:27 +08:00
committed by GitHub
parent 852d0ae3e9
commit c72afe269a
7 changed files with 186 additions and 23 deletions
@@ -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<typeof import('@/services/tts/TTSUtils')>();
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 () => {
@@ -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');
@@ -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');
});
});
@@ -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,
};
@@ -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<string, TTSVoice[]>();
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,
)
@@ -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] || '';
@@ -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<string>();
@@ -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];