fix(voices): prevent mixed-language Kokoro selections

This commit is contained in:
Richard R 2026-06-06 10:50:05 -06:00
parent 81bd355bd1
commit 112dbc0da9
3 changed files with 53 additions and 19 deletions

View file

@ -6,6 +6,7 @@ import {
import { ChevronUpDownIcon, AudioWaveIcon, CheckIcon } from '@/components/icons/Icons';
import { useEffect, useMemo, useState } from 'react';
import { buildKokoroVoiceString, parseKokoroVoiceNames } from '@/lib/shared/kokoro';
import { keepKokoroVoicesInOneLanguage } from '@/lib/shared/language';
import { type TtsProviderType } from '@/lib/shared/tts-provider-catalog';
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
import { SharedListboxButton, SharedListboxOption, SharedListboxOptions, cn } from '@/components/ui';
@ -70,8 +71,13 @@ export function VoicesControlBase({
if (initial.length > maxVoices) {
initial = initial.slice(0, maxVoices);
}
initial = keepKokoroVoicesInOneLanguage(initial);
setSelectedVoices(initial);
}, [isKokoro, maxVoices, voice, availableVoices]);
const combined = buildKokoroVoiceString(initial);
if (combined && combined !== voice) {
onChangeVoice(combined);
}
}, [isKokoro, maxVoices, voice, availableVoices, onChangeVoice]);
const currentVoice = useMemo(() => {
if (isKokoro && maxVoices > 1) {
@ -101,15 +107,19 @@ export function VoicesControlBase({
onChange={(vals: string[]) => {
if (!vals || vals.length === 0) return;
let next = vals;
if (vals.length > maxVoices) {
const newlyAdded = vals.find((v) => !selectedVoices.includes(v));
const newlyAdded = vals.find((v) => !selectedVoices.includes(v));
let next = keepKokoroVoicesInOneLanguage(vals, newlyAdded);
if (next.length > maxVoices) {
if (newlyAdded) {
const lastPrev = selectedVoices[selectedVoices.length - 1] ?? selectedVoices[0] ?? '';
const pair = Array.from(new Set([lastPrev, newlyAdded])).filter(Boolean);
next = pair.slice(0, maxVoices);
const compatiblePrevious = selectedVoices.filter(
(voiceId) => voiceId !== newlyAdded && next.includes(voiceId),
);
next = [
...compatiblePrevious.slice(-(maxVoices - 1)),
newlyAdded,
];
} else {
next = vals.slice(-maxVoices);
next = next.slice(-maxVoices);
}
}

View file

@ -90,6 +90,21 @@ export function getKokoroVoiceLanguages(voice: string | null | undefined): strin
));
}
export function keepKokoroVoicesInOneLanguage(
voices: string[],
preferredVoice?: string | null,
): string[] {
const preferredLanguage = getKokoroVoiceLanguages(preferredVoice)[0]
?? voices.flatMap((voice) => getKokoroVoiceLanguages(voice))[0];
if (!preferredLanguage) return voices;
const preferredBaseLanguage = toBaseLanguageCode(preferredLanguage);
return voices.filter((voice) => {
const voiceLanguage = getKokoroVoiceLanguages(voice)[0];
return !voiceLanguage || toBaseLanguageCode(voiceLanguage) === preferredBaseLanguage;
});
}
export function resolveTtsLanguage(input: {
configuredLanguage?: string | null;
voice?: string | null;
@ -130,14 +145,7 @@ export function getTtsLanguageCompatibilityWarnings(input: {
);
}
if (voiceBaseLanguages.size > 1) {
warnings.push(
`Selected Kokoro voices use multiple languages (${voiceLanguages.map(getLanguageDisplayName).join(', ')}).`,
);
return warnings;
}
const voiceLanguage = voiceLanguages[0];
const voiceLanguage = voiceBaseLanguages.size === 1 ? voiceLanguages[0] : undefined;
if (voiceLanguage && toBaseLanguageCode(voiceLanguage) !== documentBaseLanguage) {
warnings.push(
`Selected Kokoro voice is ${getLanguageDisplayName(voiceLanguage)}, but the document is ${getLanguageDisplayName(documentLanguage)}.`,

View file

@ -5,6 +5,7 @@ import { resolve } from 'path';
import {
getTtsLanguageCompatibilityWarnings,
inferKokoroLanguageFromVoice,
keepKokoroVoicesInOneLanguage,
normalizeOptionalLanguageTag,
normalizeUnicodeToken,
resolveTtsLanguage,
@ -27,6 +28,23 @@ describe('multilingual language utilities', () => {
expect(inferKokoroLanguageFromVoice('ff_siwis+jf_alpha')).toBeNull();
});
test('keeps Kokoro multi-voice selections within the newly selected language', () => {
expect(keepKokoroVoicesInOneLanguage(
['af_sarah', 'bf_emma', 'jf_alpha'],
'jf_alpha',
)).toEqual(['jf_alpha']);
expect(keepKokoroVoicesInOneLanguage(
['zf_xiaobei', 'zm_yunxi', 'af_sarah'],
'zm_yunxi',
)).toEqual(['zf_xiaobei', 'zm_yunxi']);
expect(keepKokoroVoicesInOneLanguage(
['af_sarah', 'bf_emma'],
'bf_emma',
)).toEqual(['af_sarah', 'bf_emma']);
});
test('normalizes valid EPUB metadata language tags and rejects invalid metadata', () => {
expect(normalizeOptionalLanguageTag(' ja-jp ')).toBe('ja-JP');
expect(normalizeOptionalLanguageTag('fr, en')).toBe('fr');
@ -53,9 +71,7 @@ describe('multilingual language utilities', () => {
model: 'kokoro',
voice: 'ff_siwis+jf_alpha',
documentLanguage: 'fr',
})).toEqual([
'Selected Kokoro voices use multiple languages (French, Japanese).',
]);
})).toEqual([]);
expect(getTtsLanguageCompatibilityWarnings({
model: 'kokoro',