Adds 'speech-sdk' as a fifth built-in TTS provider backed by @speech-sdk/core (Apache 2.0). Models use the provider/model format; requests go from the server directly to the provider's API with the user's own key. Existing providers, defaults, and config are untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
552 lines
17 KiB
TypeScript
552 lines
17 KiB
TypeScript
import { describe, expect, test } from 'vitest';
|
|
|
|
import {
|
|
REPLICATE_KOKORO_82M_VERSIONED_MODEL,
|
|
getDefaultVoices,
|
|
providerSupportsCustomModel,
|
|
resolveProviderModels,
|
|
speechSdkProviderPrefix,
|
|
supportsNativeModelSpeed,
|
|
supportsTtsInstructions,
|
|
} from '../../src/lib/shared/tts-provider-catalog';
|
|
import { normalizeLegacyProviderRef, resolveProviderDefaults, resolveTtsModelForProvider } from '../../src/lib/shared/tts-provider-policy';
|
|
import {
|
|
resolveReplicateLanguageInputKey,
|
|
resolveReplicateVoiceInputKey,
|
|
resolveVoices,
|
|
} from '../../src/lib/server/tts/voice-resolution';
|
|
import { applyConfigUpdate, getVoicePreferenceKey } from '../../src/lib/client/config/updates';
|
|
import { buildSyncedPreferencePatch } from '../../src/lib/client/config/preferences';
|
|
|
|
describe('tts provider catalog', () => {
|
|
test('resolves provider models with fixed Deepinfra catalog', () => {
|
|
expect(resolveProviderModels('openai').map((model) => model.id)).toEqual([
|
|
'tts-1',
|
|
'tts-1-hd',
|
|
'gpt-4o-mini-tts',
|
|
]);
|
|
|
|
expect(resolveProviderModels('deepinfra', {
|
|
apiKey: '',
|
|
}).map((model) => model.id)).toEqual([
|
|
'hexgrad/Kokoro-82M',
|
|
'canopylabs/orpheus-3b-0.1-ft',
|
|
'sesame/csm-1b',
|
|
'ResembleAI/chatterbox',
|
|
'Zyphra/Zonos-v0.1-hybrid',
|
|
'Zyphra/Zonos-v0.1-transformer',
|
|
'custom',
|
|
]);
|
|
});
|
|
|
|
test('resolves default voices and instruction support unchanged', () => {
|
|
expect(getDefaultVoices('openai', 'gpt-4o-mini-tts')).toContain('sage');
|
|
expect(getDefaultVoices('custom-openai', 'kokoro')).toContain('af_sarah');
|
|
// Non-kokoro custom-openai models fall back to the canonical OpenAI six.
|
|
expect(getDefaultVoices('custom-openai', 'supertonic-3')).toEqual(['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer']);
|
|
expect(getDefaultVoices('deepinfra', 'ResembleAI/chatterbox')).toEqual(['None']);
|
|
expect(getDefaultVoices('deepinfra', 'Zyphra/Zonos-v0.1-transformer')).toEqual(['random']);
|
|
expect(supportsTtsInstructions('gpt-4o-mini-tts')).toBe(true);
|
|
expect(supportsTtsInstructions('tts-1')).toBe(false);
|
|
expect(providerSupportsCustomModel('openai')).toBe(false);
|
|
expect(providerSupportsCustomModel('deepinfra')).toBe(true);
|
|
});
|
|
|
|
test('resolves speech-sdk models, voices, and prefix parsing', () => {
|
|
expect(resolveProviderModels('speech-sdk').map((model) => model.id)).toContain('openai/gpt-4o-mini-tts');
|
|
expect(providerSupportsCustomModel('speech-sdk')).toBe(true);
|
|
|
|
expect(speechSdkProviderPrefix('elevenlabs/eleven_multilingual_v2')).toBe('elevenlabs');
|
|
// fal model ids are path-style, so only the first slash delimits the provider.
|
|
expect(speechSdkProviderPrefix('fal-ai/kokoro/american-english')).toBe('fal-ai');
|
|
|
|
expect(getDefaultVoices('speech-sdk', 'openai/gpt-4o-mini-tts')).toContain('sage');
|
|
expect(getDefaultVoices('speech-sdk', 'deepgram/aura-2')).toContain('thalia-en');
|
|
// Providers without a static voice list fall back to the provider default.
|
|
expect(getDefaultVoices('speech-sdk', 'mistral/voxtral-mini-tts-2603')).toEqual(['default']);
|
|
|
|
// Speed is applied client-side for speech-sdk so cached audio is speed-independent.
|
|
expect(supportsNativeModelSpeed('speech-sdk', 'openai/gpt-4o-mini-tts')).toBe(false);
|
|
});
|
|
|
|
test('uses blocklist semantics for Replicate native speed support', () => {
|
|
expect(supportsNativeModelSpeed('replicate', REPLICATE_KOKORO_82M_VERSIONED_MODEL)).toBe(true);
|
|
expect(supportsNativeModelSpeed('replicate', 'acme/runtime-speed-model')).toBe(true);
|
|
expect(supportsNativeModelSpeed('replicate', 'google/gemini-3.1-flash-tts')).toBe(false);
|
|
expect(supportsNativeModelSpeed('replicate', 'qwen/qwen3-tts')).toBe(false);
|
|
});
|
|
|
|
test('preserves default-openai unless explicit non-default fallback is provided', () => {
|
|
expect(normalizeLegacyProviderRef('default-openai', 'shared-replicate')).toBe('shared-replicate');
|
|
expect(normalizeLegacyProviderRef('default-openai', '')).toBe('default-openai');
|
|
expect(normalizeLegacyProviderRef('default-openai', 'default-openai')).toBe('default-openai');
|
|
expect(normalizeLegacyProviderRef('default-openai')).toBe('default-openai');
|
|
});
|
|
|
|
test('resolveProviderDefaults keeps runtime/admin default provider ref when valid', () => {
|
|
const defaults = resolveProviderDefaults({
|
|
providerRef: 'default-openai',
|
|
providerType: 'unknown',
|
|
});
|
|
expect(defaults.providerRef).toBe('default-openai');
|
|
expect(defaults.providerType).toBe('unknown');
|
|
});
|
|
|
|
test('resolves shared-provider default model as authoritative', () => {
|
|
const defaults = resolveProviderDefaults({
|
|
providerRef: 'shared-replicate',
|
|
providerType: 'unknown',
|
|
fallbackProviderRef: 'shared-replicate',
|
|
sharedProviders: [
|
|
{
|
|
slug: 'shared-replicate',
|
|
providerType: 'replicate',
|
|
defaultModel: REPLICATE_KOKORO_82M_VERSIONED_MODEL,
|
|
defaultInstructions: 'Narrate warmly',
|
|
},
|
|
],
|
|
});
|
|
expect(defaults.providerRef).toBe('shared-replicate');
|
|
expect(defaults.providerType).toBe('replicate');
|
|
expect(defaults.defaultModel).toBe(REPLICATE_KOKORO_82M_VERSIONED_MODEL);
|
|
expect(defaults.defaultInstructions).toBe('Narrate warmly');
|
|
});
|
|
|
|
test('resolveTtsModelForProvider enforces shared default model when showAllProviderModels is false', () => {
|
|
const resolved = resolveTtsModelForProvider({
|
|
providerRef: 'shared-replicate',
|
|
providerType: 'unknown',
|
|
model: 'acme/custom-model',
|
|
showAllProviderModels: false,
|
|
sharedProviders: [
|
|
{
|
|
slug: 'shared-replicate',
|
|
providerType: 'replicate',
|
|
defaultModel: REPLICATE_KOKORO_82M_VERSIONED_MODEL,
|
|
defaultInstructions: null,
|
|
},
|
|
],
|
|
});
|
|
expect(resolved).toBe(REPLICATE_KOKORO_82M_VERSIONED_MODEL);
|
|
});
|
|
|
|
test('resolveTtsModelForProvider keeps requested model when showAllProviderModels is true', () => {
|
|
const resolved = resolveTtsModelForProvider({
|
|
providerRef: 'shared-replicate',
|
|
providerType: 'unknown',
|
|
model: 'acme/custom-model',
|
|
showAllProviderModels: true,
|
|
sharedProviders: [
|
|
{
|
|
slug: 'shared-replicate',
|
|
providerType: 'replicate',
|
|
defaultModel: REPLICATE_KOKORO_82M_VERSIONED_MODEL,
|
|
defaultInstructions: null,
|
|
},
|
|
],
|
|
});
|
|
expect(resolved).toBe('acme/custom-model');
|
|
});
|
|
|
|
test('resolveTtsModelForProvider falls back to built-in provider default model when locked', () => {
|
|
const resolved = resolveTtsModelForProvider({
|
|
providerRef: 'openai',
|
|
providerType: 'openai',
|
|
model: 'gpt-4o-mini-tts',
|
|
showAllProviderModels: false,
|
|
});
|
|
expect(resolved).toBe('tts-1');
|
|
});
|
|
|
|
test('keeps explicit empty custom-openai voices response', async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = async () => ({
|
|
ok: true,
|
|
json: async () => ({ voices: [] }),
|
|
}) as Response;
|
|
|
|
try {
|
|
await expect(resolveVoices({
|
|
provider: 'custom-openai',
|
|
model: 'kokoro',
|
|
apiKey: 'token',
|
|
baseUrl: 'https://example.com',
|
|
})).resolves.toEqual([]);
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
test('fetches custom Replicate model voices from model schema', async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
let calls = 0;
|
|
let url = '';
|
|
globalThis.fetch = async (input) => {
|
|
calls += 1;
|
|
url = String(input);
|
|
return {
|
|
ok: true,
|
|
json: async () => ({
|
|
latest_version: {
|
|
openapi_schema: {
|
|
components: {
|
|
schemas: {
|
|
Input: {
|
|
type: 'object',
|
|
properties: {
|
|
voice: {
|
|
type: 'string',
|
|
enum: ['Narrator A', 'Narrator B'],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
} as Response;
|
|
};
|
|
|
|
try {
|
|
await expect(resolveVoices({
|
|
provider: 'replicate',
|
|
model: 'acme/my-tts-model-voices',
|
|
apiKey: 'r8_token',
|
|
})).resolves.toEqual(['Narrator A', 'Narrator B']);
|
|
expect(calls).toBe(1);
|
|
expect(url).toContain('/v1/models/acme/my-tts-model-voices');
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
test('falls back to default voice for custom Replicate models when schema lookup fails', async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = async () => ({
|
|
ok: false,
|
|
status: 404,
|
|
json: async () => ({}),
|
|
}) as Response;
|
|
|
|
try {
|
|
await expect(resolveVoices({
|
|
provider: 'replicate',
|
|
model: 'acme/my-tts-model-fallback',
|
|
apiKey: 'r8_token',
|
|
})).resolves.toEqual(['default']);
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
test('uses schema voices first for built-in Replicate models', 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: {
|
|
speaker: {
|
|
type: 'string',
|
|
enum: ['Schema Aiden', 'Schema Dylan'],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
} as Response;
|
|
};
|
|
|
|
try {
|
|
await expect(resolveVoices({
|
|
provider: 'replicate',
|
|
model: 'qwen/qwen3-tts',
|
|
apiKey: 'r8_token',
|
|
})).resolves.toEqual(['Schema Aiden', 'Schema Dylan']);
|
|
expect(calls).toBe(1);
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
test('falls back to built-in Replicate static voices when schema has no voice enum', 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: {
|
|
speaker: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
} as Response;
|
|
};
|
|
|
|
try {
|
|
await expect(resolveVoices({
|
|
provider: 'replicate',
|
|
model: 'inworld/tts-1.5-mini',
|
|
apiKey: 'r8_token',
|
|
})).resolves.toEqual(['Ashley', 'Dennis', 'Alex', 'Darlene']);
|
|
expect(calls).toBe(1);
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
test('resolves Replicate custom-model voice input key and caches it', 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: {
|
|
voice_id: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
} as Response;
|
|
};
|
|
|
|
try {
|
|
await expect(resolveReplicateVoiceInputKey({
|
|
provider: 'replicate',
|
|
model: 'acme/custom-voice-model',
|
|
apiKey: 'r8_token',
|
|
})).resolves.toBe('voice_id');
|
|
|
|
await expect(resolveReplicateVoiceInputKey({
|
|
provider: 'replicate',
|
|
model: 'acme/custom-voice-model',
|
|
apiKey: 'r8_token',
|
|
})).resolves.toBe('voice_id');
|
|
|
|
expect(calls).toBe(1);
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
test('shares Replicate schema fetch between voices and voice-key resolution', 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: {
|
|
voice: {
|
|
type: 'string',
|
|
enum: ['V1', 'V2'],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
} as Response;
|
|
};
|
|
|
|
try {
|
|
await expect(resolveVoices({
|
|
provider: 'replicate',
|
|
model: 'acme/shared-schema-model',
|
|
apiKey: 'r8_token',
|
|
})).resolves.toEqual(['V1', 'V2']);
|
|
|
|
await expect(resolveReplicateVoiceInputKey({
|
|
provider: 'replicate',
|
|
model: 'acme/shared-schema-model',
|
|
apiKey: 'r8_token',
|
|
})).resolves.toBe('voice');
|
|
|
|
expect(calls).toBe(1);
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
test('discovers Replicate language input key from model schema', 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_code: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
} as Response;
|
|
};
|
|
|
|
try {
|
|
await expect(resolveReplicateLanguageInputKey({
|
|
provider: 'replicate',
|
|
model: 'acme/schema-language-model',
|
|
apiKey: 'r8_token',
|
|
})).resolves.toBe('language_code');
|
|
await expect(resolveReplicateLanguageInputKey({
|
|
provider: 'replicate',
|
|
model: 'acme/schema-language-model',
|
|
apiKey: 'r8_token',
|
|
})).resolves.toBe('language_code');
|
|
expect(calls).toBe(1);
|
|
} finally {
|
|
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', () => {
|
|
test('persists voice preferences by provider/model pair', () => {
|
|
expect(getVoicePreferenceKey('openai', 'tts-1')).toBe('openai:tts-1');
|
|
|
|
const voiceUpdate = applyConfigUpdate({
|
|
providerRef: 'openai',
|
|
providerType: 'openai',
|
|
ttsModel: 'tts-1',
|
|
savedVoices: {},
|
|
}, 'voice', 'alloy');
|
|
expect(voiceUpdate.storagePatch).toEqual({
|
|
savedVoices: { 'openai:tts-1': 'alloy' },
|
|
voice: 'alloy',
|
|
});
|
|
|
|
const providerUpdate = applyConfigUpdate({
|
|
providerRef: 'openai',
|
|
providerType: 'openai',
|
|
ttsModel: 'tts-1',
|
|
savedVoices: {
|
|
'deepinfra:hexgrad/Kokoro-82M': 'af_sarah',
|
|
},
|
|
}, 'providerRef', 'deepinfra');
|
|
expect(providerUpdate.storagePatch).toEqual({
|
|
providerRef: 'deepinfra',
|
|
voice: '',
|
|
});
|
|
});
|
|
|
|
test('builds synced preference patches and honors non-default filtering', () => {
|
|
expect(buildSyncedPreferencePatch({
|
|
voiceSpeed: 1.2,
|
|
baseUrl: 'http://localhost',
|
|
ttsModel: 'kokoro',
|
|
})).toEqual({
|
|
voiceSpeed: 1.2,
|
|
ttsModel: 'kokoro',
|
|
});
|
|
|
|
// The user's provider/model now default to empty ("inherit the admin
|
|
// default"), so empty values are treated as defaults and filtered out.
|
|
expect(buildSyncedPreferencePatch({
|
|
voiceSpeed: 1,
|
|
providerRef: '',
|
|
providerType: 'unknown',
|
|
ttsModel: '',
|
|
}, { nonDefaultOnly: true })).toEqual({});
|
|
});
|
|
});
|