From 112dbc0da9fde5f2ee622024fa5fb105ce84fa19 Mon Sep 17 00:00:00 2001 From: Richard R Date: Sat, 6 Jun 2026 10:50:05 -0600 Subject: [PATCH] fix(voices): prevent mixed-language Kokoro selections --- src/components/player/VoicesControlBase.tsx | 26 ++++++++++++++------- src/lib/shared/language.ts | 24 ++++++++++++------- tests/unit/language.vitest.spec.ts | 22 ++++++++++++++--- 3 files changed, 53 insertions(+), 19 deletions(-) diff --git a/src/components/player/VoicesControlBase.tsx b/src/components/player/VoicesControlBase.tsx index fd2cad9..b314902 100644 --- a/src/components/player/VoicesControlBase.tsx +++ b/src/components/player/VoicesControlBase.tsx @@ -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); } } diff --git a/src/lib/shared/language.ts b/src/lib/shared/language.ts index 9432235..b278bd0 100644 --- a/src/lib/shared/language.ts +++ b/src/lib/shared/language.ts @@ -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)}.`, diff --git a/tests/unit/language.vitest.spec.ts b/tests/unit/language.vitest.spec.ts index 2842d80..b50552c 100644 --- a/tests/unit/language.vitest.spec.ts +++ b/tests/unit/language.vitest.spec.ts @@ -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',