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 { 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)}.`,
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue