diff --git a/src/app/api/tts/voices/route.ts b/src/app/api/tts/voices/route.ts index 221f1b7..913e0e2 100644 --- a/src/app/api/tts/voices/route.ts +++ b/src/app/api/tts/voices/route.ts @@ -1,96 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; -import { isKokoroModel } from '@/lib/shared/kokoro'; import { auth } from '@/lib/server/auth/auth'; - -const OPENAI_VOICES = ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer']; -const GPT4O_MINI_VOICES = ['alloy', 'ash', 'coral', 'echo', 'fable', 'onyx', 'nova', 'sage', 'shimmer']; -const CUSTOM_OPENAI_VOICES = ['af_sarah', 'af_bella', 'af_nicole', 'am_adam', 'am_michael', 'bf_emma', 'bf_isabella', 'bm_george', 'bm_lewis']; - -const KOKORO_VOICES = [ - 'af_alloy', 'af_aoede', 'af_bella', 'af_heart', 'af_jessica', 'af_kore', 'af_nicole', 'af_nova', - 'af_river', 'af_sarah', 'af_sky', 'am_adam', 'am_echo', 'am_eric', 'am_fenrir', 'am_liam', - 'am_michael', 'am_onyx', 'am_puck', 'am_santa', 'bf_alice', 'bf_emma', 'bf_isabella', 'bf_lily', - 'bm_daniel', 'bm_fable', 'bm_george', 'bm_lewis', 'ef_dora', 'em_alex', 'em_santa', 'ff_siwis', - 'hf_alpha', 'hf_beta', 'hm_omega', 'hm_psi', 'if_sara', 'im_nicola', 'jf_alpha', 'jf_gongitsune', - 'jf_nezumi', 'jf_tebukuro', 'jm_kumo', 'pf_dora', 'pm_alex', 'pm_santa', 'zf_xiaobei', 'zf_xiaoni', - 'zf_xiaoxiao', 'zf_xiaoyi', 'zm_yunjian', 'zm_yunxi', 'zm_yunxia', 'zm_yunyang' -]; - -const ORPHEUS_VOICES = ['tara', 'leah', 'jess', 'leo', 'dan', 'mia', 'zac']; - -const SESAME_VOICES = ['conversational_a', 'conversational_b', 'read_speech_a', 'read_speech_b', 'read_speech_c', 'read_speech_d', 'none']; - -function getDefaultVoices(provider: string, model: string): string[] { - // For OpenAI provider - if (provider === 'openai') { - if (model === 'gpt-4o-mini-tts') { - return GPT4O_MINI_VOICES; - } - return OPENAI_VOICES; - } - - // For Custom OpenAI-Like provider - if (provider === 'custom-openai') { - // If using Kokoro-FastAPI (model string contains 'kokoro'), expose full Kokoro voices - if (isKokoroModel(model)) { - return KOKORO_VOICES; - } - return CUSTOM_OPENAI_VOICES; - } - - // For Deepinfra provider - model-specific voices - if (provider === 'deepinfra') { - if (model === 'hexgrad/Kokoro-82M') { - return KOKORO_VOICES; - } - if (model === 'canopylabs/orpheus-3b-0.1-ft') { - return ORPHEUS_VOICES; - } - if (model === 'sesame/csm-1b') { - return SESAME_VOICES; - } - // For ResembleAI/chatterbox and Zyphra models, return special values - if (model === 'ResembleAI/chatterbox') { - return ['None']; - } - if (model === 'Zyphra/Zonos-v0.1-hybrid' || model === 'Zyphra/Zonos-v0.1-transformer') { - return ['random']; - } - // Default Deepinfra voices - return CUSTOM_OPENAI_VOICES; - } - - // Default fallback - return OPENAI_VOICES; -} - -async function fetchDeepinfraVoices(apiKey: string): Promise { - try { - const response = await fetch('https://api.deepinfra.com/v1/voices', { - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${apiKey}`, - }, - }); - - if (!response.ok) { - throw new Error('Failed to fetch Deepinfra voices'); - } - - const data = await response.json(); - - // Extract voice names from the response, excluding preset voices - 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) { - console.error('Error fetching Deepinfra voices:', error); - return []; - } -} +import { getDefaultVoices, resolveVoices } from '@/lib/shared/tts-provider-catalog'; export async function GET(req: NextRequest) { try { @@ -104,57 +14,13 @@ export async function GET(req: NextRequest) { const openApiBaseUrl = req.headers.get('x-openai-base-url') || process.env.API_BASE; const provider = req.headers.get('x-tts-provider') || 'openai'; const model = req.headers.get('x-tts-model') || 'tts-1'; - - // For OpenAI provider, use default voices (no API call needed) - if (provider === 'openai') { - return NextResponse.json({ voices: getDefaultVoices(provider, model) }); - } - - // For Deepinfra provider with specific models that need API fetching - if (provider === 'deepinfra') { - const needsApiFetch = model === 'ResembleAI/chatterbox' || - model === 'Zyphra/Zonos-v0.1-hybrid' || - model === 'Zyphra/Zonos-v0.1-transformer'; - - if (needsApiFetch) { - const apiVoices = await fetchDeepinfraVoices(openApiKey); - // Combine default voice with fetched voices - const defaultVoice = getDefaultVoices(provider, model); - if (apiVoices.length > 0) { - return NextResponse.json({ voices: [...defaultVoice, ...apiVoices] }); - } - } - - // For other Deepinfra models, return static defaults - return NextResponse.json({ voices: getDefaultVoices(provider, model) }); - } - - // For Custom OpenAI-Like provider, try to fetch voices from custom endpoint - if (provider === 'custom-openai') { - try { - const response = await fetch(`${openApiBaseUrl}/audio/voices`, { - headers: { - 'Authorization': `Bearer ${openApiKey}`, - 'Content-Type': 'application/json', - }, - }); - - if (response.ok) { - const data = await response.json(); - if (data.voices) { - return NextResponse.json({ voices: data.voices }); - } - } - } catch { - console.log('Custom endpoint does not support voices, using defaults'); - } - - // Fallback to default voices if API call fails - return NextResponse.json({ voices: getDefaultVoices(provider, model) }); - } - - // Default fallback - return NextResponse.json({ voices: getDefaultVoices(provider, model) }); + const voices = await resolveVoices({ + provider, + model, + apiKey: openApiKey, + baseUrl: openApiBaseUrl, + }); + return NextResponse.json({ voices }); } catch (error) { console.error('Error in voices endpoint:', error); const provider = req.headers.get('x-tts-provider') || 'openai'; diff --git a/src/components/AudiobookExportModal.tsx b/src/components/AudiobookExportModal.tsx index b7341f4..52838ca 100644 --- a/src/components/AudiobookExportModal.tsx +++ b/src/components/AudiobookExportModal.tsx @@ -11,6 +11,7 @@ import { LoadingSpinner } from '@/components/Spinner'; import { useConfig } from '@/contexts/ConfigContext'; import { useTTS } from '@/contexts/TTSContext'; import { VoicesControlBase } from '@/components/player/VoicesControlBase'; +import { supportsTtsInstructions } from '@/lib/shared/tts-provider-catalog'; import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts'; import { getAudiobookStatus, @@ -115,7 +116,7 @@ export function AudiobookExportModal({ nativeSpeed, postSpeed, format, - ttsInstructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined, + ttsInstructions: supportsTtsInstructions(ttsModel) ? ttsInstructions : undefined, }; }, [savedSettings, audiobookVoice, configVoice, availableVoices, ttsProvider, ttsModel, ttsInstructions, nativeSpeed, postSpeed, format]); diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 4f38f32..2eb41ae 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -34,6 +34,8 @@ import { showPrivacyModal } from '@/components/PrivacyModal'; import { deleteDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents'; import { cacheStoredDocumentFromBytes, clearDocumentCache } from '@/lib/client/cache/documents'; import { clearAllDocumentPreviewCaches, clearInMemoryDocumentPreviewCache } from '@/lib/client/cache/previews'; +import { resolveTtsSettingsViewModel } from '@/lib/client/settings/tts-settings'; +import { supportsTtsInstructions } from '@/lib/shared/tts-provider-catalog'; const enableDestructiveDelete = process.env.NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS !== 'false'; const showAllDeepInfra = process.env.NEXT_PUBLIC_SHOW_ALL_DEEPINFRA_MODELS !== 'false'; @@ -115,64 +117,19 @@ export function SettingsModal({ className = '' }: { className?: string }) { const router = useRouter(); const isBusy = isImportingLibrary; - const ttsProviders = useMemo(() => [ - { id: 'custom-openai', name: 'Custom OpenAI-Like' }, - { id: 'deepinfra', name: 'Deepinfra' }, - { id: 'openai', name: 'OpenAI' } - ], []); - - const ttsModels = useMemo(() => { - switch (localTTSProvider) { - case 'openai': - return [ - { id: 'tts-1', name: 'TTS-1' }, - { id: 'tts-1-hd', name: 'TTS-1 HD' }, - { id: 'gpt-4o-mini-tts', name: 'GPT-4o Mini TTS' } - ]; - case 'custom-openai': - return [ - { id: 'kokoro', name: 'Kokoro' }, - { id: 'kitten-tts', name: 'KittenTTS' }, - { id: 'orpheus', name: 'Orpheus' }, - { id: 'custom', name: 'Other' } - ]; - case 'deepinfra': - if (!showAllDeepInfra && !localApiKey) { - return [ - { id: 'hexgrad/Kokoro-82M', name: 'hexgrad/Kokoro-82M' } - ]; - } - return [ - { id: 'hexgrad/Kokoro-82M', name: 'hexgrad/Kokoro-82M' }, - { id: 'canopylabs/orpheus-3b-0.1-ft', name: 'canopylabs/orpheus-3b-0.1-ft' }, - { id: 'sesame/csm-1b', name: 'sesame/csm-1b' }, - { id: 'ResembleAI/chatterbox', name: 'ResembleAI/chatterbox' }, - { id: 'Zyphra/Zonos-v0.1-hybrid', name: 'Zyphra/Zonos-v0.1-hybrid' }, - { id: 'Zyphra/Zonos-v0.1-transformer', name: 'Zyphra/Zonos-v0.1-transformer' }, - { id: 'custom', name: 'Other' } - ]; - default: - return [ - { id: 'tts-1', name: 'TTS-1' } - ]; - } - }, [localTTSProvider, localApiKey]); - - const supportsCustom = useMemo(() => localTTSProvider !== 'openai', [localTTSProvider]); - - const selectedModelId = useMemo( - () => { - const isPreset = ttsModels.some(m => m.id === modelValue); - if (isPreset) return modelValue; - return supportsCustom ? 'custom' : (ttsModels[0]?.id ?? ''); - }, - [ttsModels, modelValue, supportsCustom] - ); - - const canSubmit = useMemo( - () => selectedModelId !== 'custom' || (supportsCustom && customModelInput.trim().length > 0), - [selectedModelId, supportsCustom, customModelInput] - ); + const { + providers: ttsProviders, + models: ttsModels, + supportsCustomModel: supportsCustom, + selectedModelId, + canSubmit, + } = useMemo(() => resolveTtsSettingsViewModel({ + provider: localTTSProvider, + apiKey: localApiKey, + modelValue, + customModelInput, + showAllDeepInfra, + }), [localTTSProvider, localApiKey, modelValue, customModelInput]); const checkFirstVist = useCallback(async () => { const appConfig = await getAppConfig(); @@ -672,7 +629,7 @@ export function SettingsModal({ className = '' }: { className?: string }) { - {modelValue === 'gpt-4o-mini-tts' && ( + {supportsTtsInstructions(modelValue) && (