fix(voices): prevent mixed-language Kokoro selections
This commit is contained in:
parent
81bd355bd1
commit
112dbc0da9
3 changed files with 53 additions and 19 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}.`,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue