diff --git a/src/lib/server/tts/generate.ts b/src/lib/server/tts/generate.ts index cfca3d3..fdeb583 100644 --- a/src/lib/server/tts/generate.ts +++ b/src/lib/server/tts/generate.ts @@ -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> { +export async function buildReplicateInput(request: ResolvedServerTTSRequest): Promise> { const model = request.model as string; if (model === 'google/gemini-3.1-flash-tts') { @@ -518,6 +522,16 @@ async function addReplicateLanguageInput( request: ResolvedServerTTSRequest, ): Promise> { 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, diff --git a/src/lib/server/tts/voice-resolution.ts b/src/lib/server/tts/voice-resolution.ts index 7d80a13..1fd9420 100644 --- a/src/lib/server/tts/voice-resolution.ts +++ b/src/lib/server/tts/voice-resolution.ts @@ -311,7 +311,7 @@ export async function resolveReplicateLanguageInput({ model, apiKey = '', }: ResolveVoicesOptions): Promise { - 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; diff --git a/src/lib/shared/language.ts b/src/lib/shared/language.ts index b278bd0..4ce259e 100644 --- a/src/lib/shared/language.ts +++ b/src/lib/shared/language.ts @@ -28,6 +28,28 @@ const KOKORO_LANGUAGE_BY_PREFIX: Readonly> = { zm: 'zh-CN', }; +const REPLICATE_KOKORO_LANGUAGE_CODE_BY_TAG: Readonly> = { + '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> = { + 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( diff --git a/tests/unit/language.vitest.spec.ts b/tests/unit/language.vitest.spec.ts index b50552c..4fd9e40 100644 --- a/tests/unit/language.vitest.spec.ts +++ b/tests/unit/language.vitest.spec.ts @@ -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('日本語'); diff --git a/tests/unit/tts-generate.vitest.spec.ts b/tests/unit/tts-generate.vitest.spec.ts index 4b35d02..d5e79ec 100644 --- a/tests/unit/tts-generate.vitest.spec.ts +++ b/tests/unit/tts-generate.vitest.spec.ts @@ -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', + }); + }); }); diff --git a/tests/unit/tts-provider-catalog.vitest.spec.ts b/tests/unit/tts-provider-catalog.vitest.spec.ts index acd5b94..02e72af 100644 --- a/tests/unit/tts-provider-catalog.vitest.spec.ts +++ b/tests/unit/tts-provider-catalog.vitest.spec.ts @@ -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', () => {