openreader/tests/unit/language.vitest.spec.ts
Richard R f8182c97df feat(tts): add Replicate Kokoro language code resolution and input mapping
Implement logic to resolve and inject Replicate Kokoro model-specific language
codes based on language tag and voice, using new mapping utilities. Update
`buildReplicateInput` to set `language_code` for the built-in Kokoro model.
Expand language utilities with code mapping and expose `resolveReplicateKokoroLanguageCode`.
Enhance tests to cover language code resolution and input mapping for Replicate
Kokoro, and verify language input key discovery for built-in models.
2026-06-06 15:51:02 -06:00

132 lines
5.1 KiB
TypeScript

import { describe, expect, test } from 'vitest';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import {
getTtsLanguageCompatibilityWarnings,
inferKokoroLanguageFromVoice,
keepKokoroVoicesInOneLanguage,
normalizeOptionalLanguageTag,
normalizeUnicodeToken,
resolveReplicateKokoroLanguageCode,
resolveTtsLanguage,
segmentSentences,
segmentWords,
toBaseLanguageCode,
} from '../../src/lib/shared/language';
describe('multilingual language utilities', () => {
test('keeps the multilingual document fixture readable as UTF-8', () => {
const fixture = readFileSync(resolve(process.cwd(), 'tests/files/multilingual-sample.txt'), 'utf8');
expect(fixture).toContain('これは二番目の文です。');
expect(fixture).toContain('这是第二句话。');
expect(fixture).toContain('هذه هي الجملة الثانية.');
});
test('infers Kokoro language from a single-language voice selection', () => {
expect(inferKokoroLanguageFromVoice('jf_alpha')).toBe('ja');
expect(inferKokoroLanguageFromVoice('zf_xiaobei(0.5)+zm_yunxi(0.5)')).toBe('zh-CN');
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');
expect(normalizeOptionalLanguageTag('not_a_language')).toBeNull();
expect(normalizeOptionalLanguageTag(undefined)).toBeNull();
});
test('warns only for provable Kokoro language compatibility problems', () => {
expect(getTtsLanguageCompatibilityWarnings({
model: 'kokoro',
voice: 'jf_alpha',
documentLanguage: 'ja',
})).toEqual([]);
expect(getTtsLanguageCompatibilityWarnings({
model: 'kokoro',
voice: 'af_sarah',
documentLanguage: 'ja',
})).toEqual([
'Selected Kokoro voice is American English, but the document is Japanese.',
]);
expect(getTtsLanguageCompatibilityWarnings({
model: 'kokoro',
voice: 'ff_siwis+jf_alpha',
documentLanguage: 'fr',
})).toEqual([]);
expect(getTtsLanguageCompatibilityWarnings({
model: 'kokoro',
voice: 'af_sarah',
documentLanguage: 'ar',
})).toEqual([
"Kokoro's built-in voice catalog does not include Arabic.",
'Selected Kokoro voice is American English, but the document is Arabic.',
]);
expect(getTtsLanguageCompatibilityWarnings({
model: 'custom-unknown-model',
voice: 'af_sarah',
documentLanguage: 'ja',
})).toEqual([]);
});
test('prefers an explicit language and resolves regional tags for Whisper', () => {
expect(resolveTtsLanguage({ configuredLanguage: 'pt-BR', voice: 'jf_alpha' })).toBe('pt-BR');
expect(resolveTtsLanguage({ configuredLanguage: 'auto', voice: 'jf_alpha' })).toBe('ja');
expect(toBaseLanguageCode('zh-CN')).toBe('zh');
});
test('maps Kokoro voices and normalized language tags to Replicate language codes', () => {
expect(resolveReplicateKokoroLanguageCode({ language: 'en', voice: 'af_sarah' })).toBe('a');
expect(resolveReplicateKokoroLanguageCode({ language: 'en', voice: 'bf_emma' })).toBe('b');
expect(resolveReplicateKokoroLanguageCode({ language: 'ja-JP', voice: 'jf_alpha' })).toBe('j');
expect(resolveReplicateKokoroLanguageCode({ language: 'zh-TW', voice: 'zf_xiaobei' })).toBe('z');
});
test('normalizes Unicode tokens without dropping non-Latin scripts', () => {
expect(normalizeUnicodeToken('École!')).toBe('école');
expect(normalizeUnicodeToken('日本語。')).toBe('日本語');
expect(normalizeUnicodeToken('العربية؟')).toBe('العربية');
expect(normalizeUnicodeToken('हिन्दी।')).toBe('हिन्दी');
});
test('segments CJK sentences and no-space language words', () => {
expect(segmentSentences('これは最初の文です。これは二番目の文です。', 'ja')).toEqual([
'これは最初の文です。',
'これは二番目の文です。',
]);
const chineseWords = segmentWords('这是第一句话。', 'zh').map((token) => token.text);
expect(chineseWords.length).toBeGreaterThan(1);
expect(chineseWords.join('')).toBe('这是第一句话');
});
test('preserves source offsets for Thai word segmentation', () => {
const text = 'นี่คือประโยคแรก';
const words = segmentWords(text, 'th');
expect(words.length).toBeGreaterThan(1);
for (const word of words) {
expect(text.slice(word.start, word.end)).toBe(word.text);
}
});
});