diff --git a/src/app/api/tts/voices/route.ts b/src/app/api/tts/voices/route.ts index 31f1155..48a0be5 100644 --- a/src/app/api/tts/voices/route.ts +++ b/src/app/api/tts/voices/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { auth } from '@/lib/server/auth/auth'; -import { getDefaultVoices, resolveVoices } from '@/lib/shared/tts-provider-catalog'; +import { getDefaultVoices } from '@/lib/shared/tts-provider-catalog'; +import { resolveVoices } from '@/lib/server/tts/voice-resolution'; export async function GET(req: NextRequest) { try { diff --git a/src/lib/server/tts/generate.ts b/src/lib/server/tts/generate.ts index f1fef08..3aa51ca 100644 --- a/src/lib/server/tts/generate.ts +++ b/src/lib/server/tts/generate.ts @@ -4,10 +4,10 @@ import { SpeechCreateParams } from 'openai/resources/audio/speech.mjs'; import { isKokoroModel } from '@/lib/shared/kokoro'; import { REPLICATE_KOKORO_82M_VERSIONED_MODEL, - resolveReplicateVoiceInputKey, supportsNativeModelSpeed, supportsTtsInstructions, } from '@/lib/shared/tts-provider-catalog'; +import { resolveReplicateVoiceInputKey } from '@/lib/server/tts/voice-resolution'; import { getUpstreamRetryAfterSeconds, getUpstreamStatus } from '@/lib/server/tts/upstream-response'; import { LRUCache } from 'lru-cache'; import { createHash } from 'crypto'; @@ -52,7 +52,10 @@ type InflightEntry = { consumers: number; }; -let replicateBlockedUntilMs = 0; +const REPLICATE_COOLDOWN_SCOPE_CACHE_MAX_ENTRIES = 512; +const replicateBlockedUntilByScope = new LRUCache({ + max: REPLICATE_COOLDOWN_SCOPE_CACHE_MAX_ENTRIES, +}); const TTS_CACHE_MAX_SIZE_BYTES = Number(process.env.TTS_CACHE_MAX_SIZE_BYTES || 256 * 1024 * 1024); // 256MB const TTS_CACHE_TTL_MS = Number(process.env.TTS_CACHE_TTL_MS || 1000 * 60 * 30); // 30 minutes @@ -94,20 +97,131 @@ function sleepWithSignal(ms: number, signal: AbortSignal): Promise { }); } -function applyReplicateCooldown(cooldownMs: number) { - if (!Number.isFinite(cooldownMs) || cooldownMs <= 0) return; - const next = Date.now() + cooldownMs; - replicateBlockedUntilMs = Math.max(replicateBlockedUntilMs, next); +function getReplicateCooldownScopeKey(request: ResolvedServerTTSRequest): string { + return createHash('sha256') + .update(`replicate:${request.apiKey}:${request.model as string}`) + .digest('hex'); } -async function runWithReplicateGate(signal: AbortSignal, operation: () => Promise): Promise { - const waitMs = Math.max(0, replicateBlockedUntilMs - Date.now()); +function applyReplicateCooldown(scopeKey: string, cooldownMs: number) { + if (!Number.isFinite(cooldownMs) || cooldownMs <= 0) return; + const next = Date.now() + cooldownMs; + const current = replicateBlockedUntilByScope.get(scopeKey) ?? 0; + replicateBlockedUntilByScope.set(scopeKey, Math.max(current, next)); +} + +async function runWithReplicateGate( + scopeKey: string, + signal: AbortSignal, + operation: () => Promise +): Promise { + const blockedUntilMs = replicateBlockedUntilByScope.get(scopeKey) ?? 0; + const waitMs = Math.max(0, blockedUntilMs - Date.now()); if (waitMs > 0) { await sleepWithSignal(waitMs, signal); } return operation(); } +function normalizeReplicateUrlCandidate(value: unknown): string | null { + if (value instanceof URL) { + return value.toString(); + } + + if (typeof value !== 'string') { + return null; + } + + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + + if (trimmed.startsWith('data:')) { + return trimmed; + } + + try { + const parsed = new URL(trimmed); + return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? trimmed : null; + } catch { + return null; + } +} + +function extractReplicateAudioUrlFromValue(value: unknown, seen: Set): string | null { + const direct = normalizeReplicateUrlCandidate(value); + if (direct) { + return direct; + } + + if (typeof value !== 'object' || value === null) { + return null; + } + + if (seen.has(value)) { + return null; + } + seen.add(value); + + if (Array.isArray(value)) { + for (const item of value) { + const extracted = extractReplicateAudioUrlFromValue(item, seen); + if (extracted) { + return extracted; + } + } + return null; + } + + const maybeUrlMethod = (value as { url?: unknown }).url; + if (typeof maybeUrlMethod === 'function') { + try { + const fromUrlMethod = normalizeReplicateUrlCandidate(maybeUrlMethod.call(value)); + if (fromUrlMethod) { + return fromUrlMethod; + } + } catch { } + } else { + const fromUrlField = normalizeReplicateUrlCandidate(maybeUrlMethod); + if (fromUrlField) { + return fromUrlField; + } + } + + const maybeToString = (value as { toString?: unknown }).toString; + if (typeof maybeToString === 'function') { + try { + const fromToString = normalizeReplicateUrlCandidate(maybeToString.call(value)); + if (fromToString) { + return fromToString; + } + } catch { } + } + + const record = value as Record; + for (const key of ['audio', 'output', 'outputs', 'file', 'files', 'data', 'result']) { + if (!(key in record)) continue; + const extracted = extractReplicateAudioUrlFromValue(record[key], seen); + if (extracted) { + return extracted; + } + } + + for (const nested of Object.values(record)) { + const extracted = extractReplicateAudioUrlFromValue(nested, seen); + if (extracted) { + return extracted; + } + } + + return null; +} + +export function extractReplicateAudioUrl(output: unknown): string | null { + return extractReplicateAudioUrlFromValue(output, new Set()); +} + function resolveTTSRequest(input: ServerTTSRequest): ResolvedServerTTSRequest { const provider = input.provider || 'openai'; const rawModel = provider === 'deepinfra' && !input.model ? 'hexgrad/Kokoro-82M' @@ -319,8 +433,9 @@ async function runReplicateRequest(request: ResolvedServerTTSRequest, signal: Ab const replicate = new Replicate({ auth: request.apiKey }); const input = await buildReplicateInput(request); const modelId = request.model as `${string}/${string}`; + const cooldownScopeKey = getReplicateCooldownScopeKey(request); - return runWithReplicateGate(signal, async () => { + return runWithReplicateGate(cooldownScopeKey, signal, async () => { const maxRetries = Number(process.env.TTS_MAX_RETRIES ?? 2); let attempt = 0; @@ -328,8 +443,10 @@ async function runReplicateRequest(request: ResolvedServerTTSRequest, signal: Ab try { const output = await replicate.run(modelId, { input, signal }) as unknown; - // Output is a URI string pointing to the generated audio file - const audioUrl = typeof output === 'string' ? output : String(output); + const audioUrl = extractReplicateAudioUrl(output); + if (!audioUrl) { + throw new Error('Replicate output did not include a fetchable audio URL'); + } const audioResponse = await fetch(audioUrl, { signal }); if (!audioResponse.ok) { const error = new Error(`Failed to fetch Replicate audio: ${audioResponse.status}`) as Error & { @@ -357,7 +474,7 @@ async function runReplicateRequest(request: ResolvedServerTTSRequest, signal: Ab const retryAfterSeconds = status === 429 ? getUpstreamRetryAfterSeconds(error) : undefined; const delay = retryAfterSeconds ? Math.max(retryAfterSeconds * 1000, 1000) : 10_000; if (status === 429) { - applyReplicateCooldown(delay); + applyReplicateCooldown(cooldownScopeKey, delay); } if (!retryable || attempt >= maxRetries) { diff --git a/src/lib/server/tts/voice-resolution.ts b/src/lib/server/tts/voice-resolution.ts new file mode 100644 index 0000000..23c085d --- /dev/null +++ b/src/lib/server/tts/voice-resolution.ts @@ -0,0 +1,359 @@ +import { LRUCache } from 'lru-cache'; +import { + getDefaultVoices, + resolveProviderModels, + resolveVoiceSource, + type ReplicateVoiceInputKey, + type ResolveVoicesOptions, +} from '@/lib/shared/tts-provider-catalog'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function parseReplicateModelIdentifier(model: string): { + owner: string; + name: string; + version?: string; +} | null { + const [ref, version] = model.split(':', 2); + const segments = ref.split('/'); + if (segments.length !== 2 || !segments[0] || !segments[1]) { + return null; + } + + const parsed = { + owner: segments[0], + name: segments[1], + }; + + return version + ? { ...parsed, version } + : parsed; +} + +function extractSchemaStringEnums(schemaNode: unknown, seen = new Set()): string[] { + if (!isRecord(schemaNode)) { + return []; + } + if (seen.has(schemaNode)) { + return []; + } + seen.add(schemaNode); + + const values: string[] = []; + if (Array.isArray(schemaNode.enum)) { + values.push(...schemaNode.enum.filter((value): value is string => typeof value === 'string')); + } + if (typeof schemaNode.const === 'string') { + values.push(schemaNode.const); + } + + for (const key of ['anyOf', 'allOf', 'oneOf'] as const) { + const branch = schemaNode[key]; + if (!Array.isArray(branch)) continue; + for (const item of branch) { + values.push(...extractSchemaStringEnums(item, seen)); + } + } + + if (schemaNode.items) { + values.push(...extractSchemaStringEnums(schemaNode.items, seen)); + } + + return values; +} + +function walkRecordGraph(root: unknown, visit: (node: Record) => boolean | void): void { + if (!isRecord(root)) { + return; + } + + const stack: Record[] = [root]; + const seen = new Set(); + + while (stack.length > 0) { + const current = stack.pop(); + if (!current) { + continue; + } + if (seen.has(current)) { + continue; + } + seen.add(current); + + if (visit(current)) { + return; + } + + for (const value of Object.values(current)) { + if (Array.isArray(value)) { + for (const item of value) { + if (isRecord(item)) { + stack.push(item); + } + } + } else if (isRecord(value)) { + stack.push(value); + } + } + } +} + +const REPLICATE_VOICE_KEYS = ['voice', 'voice_id', 'speaker'] as const satisfies readonly ReplicateVoiceInputKey[]; +const REPLICATE_BUILT_IN_MODELS = new Set( + resolveProviderModels('replicate') + .map((model) => model.id) + .filter((id) => id !== 'custom') +); + +function extractReplicateVoicesFromOpenApiSchema(openApiSchema: unknown): string[] { + const voices: string[] = []; + + walkRecordGraph(openApiSchema, (node) => { + const properties = node.properties; + if (!isRecord(properties)) { + return; + } + for (const key of REPLICATE_VOICE_KEYS) { + if (!(key in properties)) continue; + voices.push(...extractSchemaStringEnums(properties[key])); + } + }); + + return Array.from( + new Set( + voices + .map((voice) => voice.trim()) + .filter((voice) => voice.length > 0) + ) + ); +} + +function extractReplicateVoiceInputKeyFromOpenApiSchema(openApiSchema: unknown): ReplicateVoiceInputKey | null { + let found: ReplicateVoiceInputKey | null = null; + + walkRecordGraph(openApiSchema, (node) => { + const properties = node.properties; + if (!isRecord(properties)) { + return; + } + for (const key of REPLICATE_VOICE_KEYS) { + if (key in properties) { + found = key; + return true; + } + } + }); + + return found; +} + +async function fetchReplicateOpenApiSchema(apiKey: string, model: string): Promise { + const parsedModel = parseReplicateModelIdentifier(model); + if (!parsedModel) { + return null; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + controller.abort(); + }, 10_000); + + try { + const endpoint = parsedModel.version + ? `https://api.replicate.com/v1/models/${parsedModel.owner}/${parsedModel.name}/versions/${parsedModel.version}` + : `https://api.replicate.com/v1/models/${parsedModel.owner}/${parsedModel.name}`; + + const response = await fetch(endpoint, { + signal: controller.signal, + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + return null; + } + + const data = await response.json(); + let openApiSchema: unknown = null; + + if (parsedModel.version) { + if (isRecord(data)) { + openApiSchema = data.openapi_schema; + } + } else if (isRecord(data) && isRecord(data.latest_version)) { + openApiSchema = data.latest_version.openapi_schema; + } + + return openApiSchema; + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + return null; + } + console.error('Error fetching Replicate model schema:', error); + return null; + } finally { + clearTimeout(timeoutId); + } +} + +const REPLICATE_VOICE_INPUT_KEY_CACHE_MAX_ENTRIES = 128; +const REPLICATE_OPENAPI_SCHEMA_PROMISE_CACHE_MAX_ENTRIES = 128; + +const replicateVoiceInputKeyCache = new LRUCache({ + max: REPLICATE_VOICE_INPUT_KEY_CACHE_MAX_ENTRIES, +}); +const replicateOpenApiSchemaPromiseCache = new LRUCache>({ + max: REPLICATE_OPENAPI_SCHEMA_PROMISE_CACHE_MAX_ENTRIES, +}); + +async function getReplicateOpenApiSchemaCached(apiKey: string, model: string): Promise { + const cachedPromise = replicateOpenApiSchemaPromiseCache.get(model); + if (cachedPromise) { + return cachedPromise; + } + + const fetchPromise = fetchReplicateOpenApiSchema(apiKey, model); + replicateOpenApiSchemaPromiseCache.set(model, fetchPromise); + + const schema = await fetchPromise; + if (schema === null) { + replicateOpenApiSchemaPromiseCache.delete(model); + } + return schema; +} + +async function fetchReplicateVoices(apiKey: string, model: string): Promise { + const openApiSchema = await getReplicateOpenApiSchemaCached(apiKey, model); + const apiVoices = extractReplicateVoicesFromOpenApiSchema(openApiSchema); + return apiVoices.length > 0 ? apiVoices : null; +} + +export async function resolveReplicateVoiceInputKey({ + provider, + model, + apiKey = '', +}: ResolveVoicesOptions): Promise { + if (provider !== 'replicate' || REPLICATE_BUILT_IN_MODELS.has(model) || !apiKey) { + return null; + } + + const cached = replicateVoiceInputKeyCache.get(model); + if (cached) { + return cached; + } + + const openApiSchema = await getReplicateOpenApiSchemaCached(apiKey, model); + const inputKey = extractReplicateVoiceInputKeyFromOpenApiSchema(openApiSchema); + if (inputKey) { + replicateVoiceInputKeyCache.set(model, inputKey); + } + return inputKey; +} + +async function fetchDeepinfraVoices(apiKey: string): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + controller.abort(); + }, 10_000); + + try { + const response = await fetch('https://api.deepinfra.com/v1/voices', { + signal: controller.signal, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + }); + + if (!response.ok) { + throw new Error('Failed to fetch Deepinfra voices'); + } + + const data = await response.json(); + if (data.voices && Array.isArray(data.voices)) { + return data.voices + .filter((voice: { user_id?: string }) => voice.user_id !== 'preset') + .map((voice: { name: string }) => voice.name); + } + return []; + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + return []; + } + console.error('Error fetching Deepinfra voices:', error); + return []; + } finally { + clearTimeout(timeoutId); + } +} + +async function fetchCustomOpenAiVoices(baseUrl: string, apiKey: string): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + controller.abort(); + }, 10_000); + + try { + const normalizedBaseUrl = baseUrl.replace(/\/+$/, ''); + const response = await fetch(`${normalizedBaseUrl}/audio/voices`, { + signal: controller.signal, + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + return null; + } + + const data = await response.json(); + return Array.isArray(data.voices) && data.voices.every((voice: unknown) => typeof voice === 'string') + ? data.voices + : null; + } catch { + console.log('Custom endpoint does not support voices, using defaults'); + return null; + } finally { + clearTimeout(timeoutId); + } +} + +export async function resolveVoices({ provider, model, apiKey = '', baseUrl = '' }: ResolveVoicesOptions): Promise { + const defaultVoices = getDefaultVoices(provider, model); + const voiceSource = resolveVoiceSource(provider, model); + + if (voiceSource === 'deepinfra-api') { + const apiVoices = await fetchDeepinfraVoices(apiKey); + if (apiVoices.length > 0) { + return [...defaultVoices, ...apiVoices]; + } + return defaultVoices; + } + + if (voiceSource === 'custom-openai-api') { + if (!baseUrl) { + return defaultVoices; + } + const apiVoices = await fetchCustomOpenAiVoices(baseUrl, apiKey); + if (apiVoices !== null) { + return apiVoices; + } + } + + if (voiceSource === 'replicate-api') { + if (!apiKey) { + return defaultVoices; + } + const apiVoices = await fetchReplicateVoices(apiKey, model); + if (apiVoices !== null) { + return apiVoices; + } + } + + return defaultVoices; +} diff --git a/src/lib/shared/tts-provider-catalog.ts b/src/lib/shared/tts-provider-catalog.ts index 08a4d99..485fe7d 100644 --- a/src/lib/shared/tts-provider-catalog.ts +++ b/src/lib/shared/tts-provider-catalog.ts @@ -69,7 +69,6 @@ const REPLICATE_MODELS: TtsModelDefinition[] = [ { id: 'inworld/tts-1.5-mini', name: 'inworld/tts-1.5-mini' }, { id: 'custom', name: 'Other' }, ]; -const REPLICATE_BUILT_IN_MODELS = new Set(REPLICATE_MODELS.map((model) => model.id).filter((id) => id !== 'custom')); const DEEPINFRA_API_VOICE_MODELS = new Set([ 'ResembleAI/chatterbox', 'Zyphra/Zonos-v0.1-hybrid', @@ -108,7 +107,6 @@ export const MINIMAX_SPEECH_VOICES = [ ] as const; export const QWEN3_TTS_VOICES = ['Aiden', 'Dylan'] as const; export const INWORLD_TTS_VOICES = ['Ashley', 'Dennis', 'Alex', 'Darlene'] as const; -const REPLICATE_VOICE_KEYS: readonly ReplicateVoiceInputKey[] = ['voice', 'voice_id', 'speaker']; const REPLICATE_DEFAULT_VOICES_BY_MODEL: Record = { [REPLICATE_KOKORO_82M_VERSIONED_MODEL]: KOKORO_DEFAULT_VOICES, 'google/gemini-3.1-flash-tts': GEMINI_FLASH_TTS_VOICES, @@ -125,55 +123,6 @@ const DEEPINFRA_DEFAULT_VOICES_BY_MODEL: Record = { 'Zyphra/Zonos-v0.1-transformer': ['random'], }; -class LRUMap { - private readonly maxEntries: number; - private readonly store = new Map(); - - constructor(maxEntries: number) { - this.maxEntries = Math.max(1, maxEntries); - } - - get(key: K): V | undefined { - const value = this.store.get(key); - if (value === undefined) { - return undefined; - } - this.store.delete(key); - this.store.set(key, value); - return value; - } - - set(key: K, value: V): this { - if (this.store.has(key)) { - this.store.delete(key); - } - this.store.set(key, value); - - if (this.store.size > this.maxEntries) { - const oldestKey = this.store.keys().next().value as K | undefined; - if (oldestKey !== undefined) { - this.store.delete(oldestKey); - } - } - - return this; - } - - delete(key: K): boolean { - return this.store.delete(key); - } -} - -const REPLICATE_VOICE_INPUT_KEY_CACHE_MAX_ENTRIES = 128; -const REPLICATE_OPENAPI_SCHEMA_PROMISE_CACHE_MAX_ENTRIES = 128; - -const replicateVoiceInputKeyCache = new LRUMap( - REPLICATE_VOICE_INPUT_KEY_CACHE_MAX_ENTRIES, -); -const replicateOpenApiSchemaPromiseCache = new LRUMap>( - REPLICATE_OPENAPI_SCHEMA_PROMISE_CACHE_MAX_ENTRIES, -); - export const TTS_PROVIDER_DEFINITIONS: TtsProviderDefinition[] = [ { id: 'custom-openai', @@ -267,10 +216,6 @@ export function getDefaultVoices(provider: string, model: string): string[] { return [...OPENAI_DEFAULT_VOICES]; } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} - function parseReplicateModelIdentifier(model: string): { owner: string; name: string; @@ -292,211 +237,6 @@ function parseReplicateModelIdentifier(model: string): { : parsed; } -function extractSchemaStringEnums(schemaNode: unknown, seen = new Set()): string[] { - if (!isRecord(schemaNode)) { - return []; - } - if (seen.has(schemaNode)) { - return []; - } - seen.add(schemaNode); - - const values: string[] = []; - if (Array.isArray(schemaNode.enum)) { - values.push(...schemaNode.enum.filter((value): value is string => typeof value === 'string')); - } - if (typeof schemaNode.const === 'string') { - values.push(schemaNode.const); - } - - for (const key of ['anyOf', 'allOf', 'oneOf'] as const) { - const branch = schemaNode[key]; - if (!Array.isArray(branch)) continue; - for (const item of branch) { - values.push(...extractSchemaStringEnums(item, seen)); - } - } - - if (schemaNode.items) { - values.push(...extractSchemaStringEnums(schemaNode.items, seen)); - } - - return values; -} - -function walkRecordGraph(root: unknown, visit: (node: Record) => boolean | void): void { - if (!isRecord(root)) { - return; - } - - const stack: Record[] = [root]; - const seen = new Set(); - - while (stack.length > 0) { - const current = stack.pop(); - if (!current) { - continue; - } - if (seen.has(current)) { - continue; - } - seen.add(current); - - if (visit(current)) { - return; - } - - for (const value of Object.values(current)) { - if (Array.isArray(value)) { - for (const item of value) { - if (isRecord(item)) { - stack.push(item); - } - } - } else if (isRecord(value)) { - stack.push(value); - } - } - } -} - -function extractReplicateVoicesFromOpenApiSchema(openApiSchema: unknown): string[] { - const voices: string[] = []; - - walkRecordGraph(openApiSchema, (node) => { - const properties = node.properties; - if (!isRecord(properties)) { - return; - } - for (const key of REPLICATE_VOICE_KEYS) { - if (!(key in properties)) continue; - voices.push(...extractSchemaStringEnums(properties[key])); - } - }); - - return Array.from( - new Set( - voices - .map((voice) => voice.trim()) - .filter((voice) => voice.length > 0) - ) - ); -} - -function extractReplicateVoiceInputKeyFromOpenApiSchema(openApiSchema: unknown): ReplicateVoiceInputKey | null { - let found: ReplicateVoiceInputKey | null = null; - - walkRecordGraph(openApiSchema, (node) => { - const properties = node.properties; - if (!isRecord(properties)) { - return; - } - for (const key of REPLICATE_VOICE_KEYS) { - if (key in properties) { - found = key; - return true; - } - } - }); - - return found; -} - -async function fetchReplicateOpenApiSchema(apiKey: string, model: string): Promise { - const parsedModel = parseReplicateModelIdentifier(model); - if (!parsedModel) { - return null; - } - - const controller = new AbortController(); - const timeoutId = setTimeout(() => { - controller.abort(); - }, 10_000); - - try { - const endpoint = parsedModel.version - ? `https://api.replicate.com/v1/models/${parsedModel.owner}/${parsedModel.name}/versions/${parsedModel.version}` - : `https://api.replicate.com/v1/models/${parsedModel.owner}/${parsedModel.name}`; - - const response = await fetch(endpoint, { - signal: controller.signal, - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { - return null; - } - - const data = await response.json(); - let openApiSchema: unknown = null; - - if (parsedModel.version) { - if (isRecord(data)) { - openApiSchema = data.openapi_schema; - } - } else if (isRecord(data) && isRecord(data.latest_version)) { - openApiSchema = data.latest_version.openapi_schema; - } - - return openApiSchema; - } catch (error) { - if (error instanceof DOMException && error.name === 'AbortError') { - return null; - } - console.error('Error fetching Replicate model schema:', error); - return null; - } finally { - clearTimeout(timeoutId); - } -} - -async function getReplicateOpenApiSchemaCached(apiKey: string, model: string): Promise { - const cachedPromise = replicateOpenApiSchemaPromiseCache.get(model); - if (cachedPromise) { - return cachedPromise; - } - - const fetchPromise = fetchReplicateOpenApiSchema(apiKey, model); - replicateOpenApiSchemaPromiseCache.set(model, fetchPromise); - - const schema = await fetchPromise; - if (schema === null) { - replicateOpenApiSchemaPromiseCache.delete(model); - } - return schema; -} - -async function fetchReplicateVoices(apiKey: string, model: string): Promise { - const openApiSchema = await getReplicateOpenApiSchemaCached(apiKey, model); - const apiVoices = extractReplicateVoicesFromOpenApiSchema(openApiSchema); - return apiVoices.length > 0 ? apiVoices : null; -} - -export async function resolveReplicateVoiceInputKey({ - provider, - model, - apiKey = '', -}: ResolveVoicesOptions): Promise { - if (provider !== 'replicate' || REPLICATE_BUILT_IN_MODELS.has(model) || !apiKey) { - return null; - } - - const cached = replicateVoiceInputKeyCache.get(model); - if (cached) { - return cached; - } - - const openApiSchema = await getReplicateOpenApiSchemaCached(apiKey, model); - const inputKey = extractReplicateVoiceInputKeyFromOpenApiSchema(openApiSchema); - if (inputKey) { - replicateVoiceInputKeyCache.set(model, inputKey); - } - return inputKey; -} - export function resolveVoiceSource(provider: string, model: string): TtsVoiceSource { if (provider === 'deepinfra' && DEEPINFRA_API_VOICE_MODELS.has(model)) { return 'deepinfra-api'; @@ -512,107 +252,3 @@ export function resolveVoiceSource(provider: string, model: string): TtsVoiceSou return 'static'; } - -async function fetchDeepinfraVoices(apiKey: string): Promise { - const controller = new AbortController(); - const timeoutId = setTimeout(() => { - controller.abort(); - }, 10_000); - - try { - const response = await fetch('https://api.deepinfra.com/v1/voices', { - signal: controller.signal, - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${apiKey}`, - }, - }); - - if (!response.ok) { - throw new Error('Failed to fetch Deepinfra voices'); - } - - const data = await response.json(); - if (data.voices && Array.isArray(data.voices)) { - return data.voices - .filter((voice: { user_id?: string }) => voice.user_id !== 'preset') - .map((voice: { name: string }) => voice.name); - } - return []; - } catch (error) { - if (error instanceof DOMException && error.name === 'AbortError') { - return []; - } - console.error('Error fetching Deepinfra voices:', error); - return []; - } finally { - clearTimeout(timeoutId); - } -} - -async function fetchCustomOpenAiVoices(baseUrl: string, apiKey: string): Promise { - const controller = new AbortController(); - const timeoutId = setTimeout(() => { - controller.abort(); - }, 10_000); - - try { - const normalizedBaseUrl = baseUrl.replace(/\/+$/, ''); - const response = await fetch(`${normalizedBaseUrl}/audio/voices`, { - signal: controller.signal, - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { - return null; - } - - const data = await response.json(); - return Array.isArray(data.voices) && data.voices.every((voice: unknown) => typeof voice === 'string') - ? data.voices - : null; - } catch { - console.log('Custom endpoint does not support voices, using defaults'); - return null; - } finally { - clearTimeout(timeoutId); - } -} - -export async function resolveVoices({ provider, model, apiKey = '', baseUrl = '' }: ResolveVoicesOptions): Promise { - const defaultVoices = getDefaultVoices(provider, model); - const voiceSource = resolveVoiceSource(provider, model); - - if (voiceSource === 'deepinfra-api') { - const apiVoices = await fetchDeepinfraVoices(apiKey); - if (apiVoices.length > 0) { - return [...defaultVoices, ...apiVoices]; - } - return defaultVoices; - } - - if (voiceSource === 'custom-openai-api') { - if (!baseUrl) { - return defaultVoices; - } - const apiVoices = await fetchCustomOpenAiVoices(baseUrl, apiKey); - if (apiVoices !== null) { - return apiVoices; - } - } - - if (voiceSource === 'replicate-api') { - if (!apiKey) { - return defaultVoices; - } - const apiVoices = await fetchReplicateVoices(apiKey, model); - if (apiVoices !== null) { - return apiVoices; - } - } - - return defaultVoices; -} diff --git a/tests/unit/tts-generate.spec.ts b/tests/unit/tts-generate.spec.ts new file mode 100644 index 0000000..364f6f2 --- /dev/null +++ b/tests/unit/tts-generate.spec.ts @@ -0,0 +1,43 @@ +import { expect, test } from '@playwright/test'; + +import { extractReplicateAudioUrl } from '../../src/lib/server/tts/generate'; + +test.describe('replicate output URL extraction', () => { + test('returns direct URL string output', () => { + expect(extractReplicateAudioUrl('https://replicate.delivery/audio.mp3')).toBe( + 'https://replicate.delivery/audio.mp3' + ); + }); + + test('extracts URL from FileOutput-like objects', () => { + const output = { + url: () => new URL('https://replicate.delivery/file.wav'), + }; + expect(extractReplicateAudioUrl(output)).toBe('https://replicate.delivery/file.wav'); + }); + + test('extracts first URL from array outputs', () => { + const output: unknown[] = [ + { value: 'not-a-url' }, + { toString: () => 'https://replicate.delivery/chunk-0.mp3' }, + 'https://replicate.delivery/chunk-1.mp3', + ]; + expect(extractReplicateAudioUrl(output)).toBe('https://replicate.delivery/chunk-0.mp3'); + }); + + test('extracts nested URL from object outputs', () => { + const output = { + output: { + audio: { + url: 'https://replicate.delivery/nested.mp3', + }, + }, + }; + expect(extractReplicateAudioUrl(output)).toBe('https://replicate.delivery/nested.mp3'); + }); + + test('returns null for non-url outputs', () => { + const output = { status: 'ok', value: 123 }; + expect(extractReplicateAudioUrl(output)).toBeNull(); + }); +}); diff --git a/tests/unit/tts-provider-catalog.spec.ts b/tests/unit/tts-provider-catalog.spec.ts index ad96897..61cfd69 100644 --- a/tests/unit/tts-provider-catalog.spec.ts +++ b/tests/unit/tts-provider-catalog.spec.ts @@ -4,12 +4,11 @@ import { REPLICATE_KOKORO_82M_VERSIONED_MODEL, getDefaultVoices, providerSupportsCustomModel, - resolveReplicateVoiceInputKey, - resolveVoices, resolveProviderModels, supportsNativeModelSpeed, supportsTtsInstructions, } from '../../src/lib/shared/tts-provider-catalog'; +import { 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';