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 { ChevronUpDownIcon, AudioWaveIcon, CheckIcon } from '@/components/icons/Icons';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { buildKokoroVoiceString, parseKokoroVoiceNames } from '@/lib/shared/kokoro'; import { buildKokoroVoiceString, parseKokoroVoiceNames } from '@/lib/shared/kokoro';
import { keepKokoroVoicesInOneLanguage } from '@/lib/shared/language';
import { type TtsProviderType } from '@/lib/shared/tts-provider-catalog'; import { type TtsProviderType } from '@/lib/shared/tts-provider-catalog';
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy'; import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
import { SharedListboxButton, SharedListboxOption, SharedListboxOptions, cn } from '@/components/ui'; import { SharedListboxButton, SharedListboxOption, SharedListboxOptions, cn } from '@/components/ui';
@ -70,8 +71,13 @@ export function VoicesControlBase({
if (initial.length > maxVoices) { if (initial.length > maxVoices) {
initial = initial.slice(0, maxVoices); initial = initial.slice(0, maxVoices);
} }
initial = keepKokoroVoicesInOneLanguage(initial);
setSelectedVoices(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(() => { const currentVoice = useMemo(() => {
if (isKokoro && maxVoices > 1) { if (isKokoro && maxVoices > 1) {
@ -101,15 +107,19 @@ export function VoicesControlBase({
onChange={(vals: string[]) => { onChange={(vals: string[]) => {
if (!vals || vals.length === 0) return; if (!vals || vals.length === 0) return;
let next = vals; const newlyAdded = vals.find((v) => !selectedVoices.includes(v));
if (vals.length > maxVoices) { let next = keepKokoroVoicesInOneLanguage(vals, newlyAdded);
const newlyAdded = vals.find((v) => !selectedVoices.includes(v)); if (next.length > maxVoices) {
if (newlyAdded) { if (newlyAdded) {
const lastPrev = selectedVoices[selectedVoices.length - 1] ?? selectedVoices[0] ?? ''; const compatiblePrevious = selectedVoices.filter(
const pair = Array.from(new Set([lastPrev, newlyAdded])).filter(Boolean); (voiceId) => voiceId !== newlyAdded && next.includes(voiceId),
next = pair.slice(0, maxVoices); );
next = [
...compatiblePrevious.slice(-(maxVoices - 1)),
newlyAdded,
];
} else { } 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: { export function resolveTtsLanguage(input: {
configuredLanguage?: string | null; configuredLanguage?: string | null;
voice?: string | null; voice?: string | null;
@ -130,14 +145,7 @@ export function getTtsLanguageCompatibilityWarnings(input: {
); );
} }
if (voiceBaseLanguages.size > 1) { const voiceLanguage = voiceBaseLanguages.size === 1 ? voiceLanguages[0] : undefined;
warnings.push(
`Selected Kokoro voices use multiple languages (${voiceLanguages.map(getLanguageDisplayName).join(', ')}).`,
);
return warnings;
}
const voiceLanguage = voiceLanguages[0];
if (voiceLanguage && toBaseLanguageCode(voiceLanguage) !== documentBaseLanguage) { if (voiceLanguage && toBaseLanguageCode(voiceLanguage) !== documentBaseLanguage) {
warnings.push( warnings.push(
`Selected Kokoro voice is ${getLanguageDisplayName(voiceLanguage)}, but the document is ${getLanguageDisplayName(documentLanguage)}.`, `Selected Kokoro voice is ${getLanguageDisplayName(voiceLanguage)}, but the document is ${getLanguageDisplayName(documentLanguage)}.`,

View file

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