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.
This commit is contained in:
Richard R 2026-06-06 15:51:02 -06:00
parent 925c995274
commit f8182c97df
6 changed files with 147 additions and 3 deletions

View file

@ -15,7 +15,11 @@ import { LRUCache } from 'lru-cache';
import { createHash } from 'crypto';
import { access, readFile } from 'fs/promises';
import { resolve } from 'path';
import { getLanguageDisplayName, toBaseLanguageCode } from '@/lib/shared/language';
import {
getLanguageDisplayName,
resolveReplicateKokoroLanguageCode,
toBaseLanguageCode,
} from '@/lib/shared/language';
export interface ServerTTSRequest {
text: string;
@ -434,7 +438,7 @@ async function fetchTTSBufferWithRetry(
}
}
async function buildReplicateInput(request: ResolvedServerTTSRequest): Promise<Record<string, unknown>> {
export async function buildReplicateInput(request: ResolvedServerTTSRequest): Promise<Record<string, unknown>> {
const model = request.model as string;
if (model === 'google/gemini-3.1-flash-tts') {
@ -518,6 +522,16 @@ async function addReplicateLanguageInput(
request: ResolvedServerTTSRequest,
): Promise<Record<string, unknown>> {
if (!request.language) return input;
if (request.model === REPLICATE_KOKORO_82M_VERSIONED_MODEL) {
const languageCode = resolveReplicateKokoroLanguageCode({
language: request.language,
voice: request.voice,
});
if (languageCode) {
input.language_code = languageCode;
}
return input;
}
const languageInput = await resolveReplicateLanguageInput({
provider: 'replicate',
model: request.model as string,

View file

@ -311,7 +311,7 @@ export async function resolveReplicateLanguageInput({
model,
apiKey = '',
}: ResolveVoicesOptions): Promise<ReplicateLanguageInput | null> {
if (provider !== 'replicate' || REPLICATE_BUILT_IN_MODELS.has(model) || !apiKey) return null;
if (provider !== 'replicate' || !apiKey) return null;
const cached = replicateLanguageInputCache.get(model);
if (cached) return cached;

View file

@ -28,6 +28,28 @@ const KOKORO_LANGUAGE_BY_PREFIX: Readonly<Record<string, string>> = {
zm: 'zh-CN',
};
const REPLICATE_KOKORO_LANGUAGE_CODE_BY_TAG: Readonly<Record<string, string>> = {
'en-US': 'a',
'en-GB': 'b',
es: 'e',
fr: 'f',
hi: 'h',
it: 'i',
ja: 'j',
'pt-BR': 'p',
'zh-CN': 'z',
};
const REPLICATE_KOKORO_LANGUAGE_CODE_BY_BASE_TAG: Readonly<Record<string, string>> = {
es: 'e',
fr: 'f',
hi: 'h',
it: 'i',
ja: 'j',
pt: 'p',
zh: 'z',
};
export const KOKORO_SUPPORTED_LANGUAGES = [
'en',
'es',
@ -79,6 +101,26 @@ export function inferKokoroLanguageFromVoice(voice: string | null | undefined):
return languages.size === 1 ? [...languages][0] : null;
}
export function resolveReplicateKokoroLanguageCode(input: {
language?: string | null;
voice?: string | null;
}): string | null {
const normalizedLanguage = input.language ? normalizeLanguageTag(input.language) : null;
const voiceLanguage = inferKokoroLanguageFromVoice(input.voice);
for (const candidate of [normalizedLanguage, voiceLanguage]) {
if (!candidate) continue;
const exactCode = REPLICATE_KOKORO_LANGUAGE_CODE_BY_TAG[candidate];
if (exactCode) return exactCode;
const baseCode = REPLICATE_KOKORO_LANGUAGE_CODE_BY_BASE_TAG[toBaseLanguageCode(candidate)];
if (baseCode) return baseCode;
}
return null;
}
export function getKokoroVoiceLanguages(voice: string | null | undefined): string[] {
if (!voice?.trim()) return [];
return Array.from(new Set(

View file

@ -8,6 +8,7 @@ import {
keepKokoroVoicesInOneLanguage,
normalizeOptionalLanguageTag,
normalizeUnicodeToken,
resolveReplicateKokoroLanguageCode,
resolveTtsLanguage,
segmentSentences,
segmentWords,
@ -95,6 +96,13 @@ describe('multilingual language utilities', () => {
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('日本語');

View file

@ -1,10 +1,12 @@
import { describe, expect, test } from 'vitest';
import {
buildReplicateInput,
buildTTSCacheKey,
extractReplicateAudioUrl,
resolveReplicateLanguageValue,
} from '../../src/lib/server/tts/generate';
import { REPLICATE_KOKORO_82M_VERSIONED_MODEL } from '../../src/lib/shared/tts-provider-catalog';
describe('replicate output URL extraction', () => {
test('returns direct URL string output', () => {
@ -70,4 +72,38 @@ describe('Replicate language schema values', () => {
expect(resolveReplicateLanguageValue('ja-JP', ['English', 'Japanese'])).toBe('Japanese');
expect(resolveReplicateLanguageValue('ja-JP', ['English', 'French'])).toBeNull();
});
test('includes language_code for the built-in Replicate Kokoro model', async () => {
await expect(buildReplicateInput({
text: 'Hello world',
voice: 'af_sarah',
speed: 1,
format: 'mp3',
model: REPLICATE_KOKORO_82M_VERSIONED_MODEL,
language: 'en',
provider: 'replicate',
apiKey: 'r8_token',
testNamespace: null,
})).resolves.toEqual({
text: 'Hello world',
voice: 'af_sarah',
language_code: 'a',
});
await expect(buildReplicateInput({
text: 'Hello world',
voice: 'bf_emma',
speed: 1,
format: 'mp3',
model: REPLICATE_KOKORO_82M_VERSIONED_MODEL,
language: 'en',
provider: 'replicate',
apiKey: 'r8_token',
testNamespace: null,
})).resolves.toEqual({
text: 'Hello world',
voice: 'bf_emma',
language_code: 'b',
});
});
});

View file

@ -435,6 +435,50 @@ describe('tts provider catalog', () => {
globalThis.fetch = originalFetch;
}
});
test('discovers Replicate language input key for built-in models too', async () => {
const originalFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = async () => {
calls += 1;
return {
ok: true,
json: async () => ({
latest_version: {
openapi_schema: {
components: {
schemas: {
Input: {
type: 'object',
properties: {
text: { type: 'string' },
language: { type: 'string', enum: ['auto', 'en', 'ja'] },
},
},
},
},
},
},
}),
} as Response;
};
try {
await expect(resolveReplicateLanguageInputKey({
provider: 'replicate',
model: 'minimax/speech-2.8-turbo',
apiKey: 'r8_token',
})).resolves.toBe('language');
await expect(resolveReplicateLanguageInputKey({
provider: 'replicate',
model: 'minimax/speech-2.8-turbo',
apiKey: 'r8_token',
})).resolves.toBe('language');
expect(calls).toBe(1);
} finally {
globalThis.fetch = originalFetch;
}
});
});
describe('config helpers', () => {