From d6ae2baa6ff25ebe586b5aedefaf06944c5cb0ba Mon Sep 17 00:00:00 2001 From: Richard R Date: Sat, 4 Apr 2026 17:53:57 -0600 Subject: [PATCH 1/8] refactor(tts,config,audiobook): centralize TTS provider catalog and extract audiobook pipeline - Introduce a shared tts-provider-catalog to centralize provider/model/voice defaults and feature flags (supportsTtsInstructions, providerSupportsCustomModel, resolveProviderModels, getDefaultVoices, etc.). Replace ad-hoc provider logic across server and client with catalog calls. - Replace inline voice/model UI logic in SettingsModal, AudiobookExportModal, TTSContext, generate server code, and voice hook to use catalog helpers (resolveTtsSettingsViewModel, supportsTtsInstructions, getDefaultVoices). - Extract audiobook generation responsibilities into a small client-side pipeline: - Add createEpubAudiobookSourceAdapter and createPdfAudiobookSourceAdapter adapter modules to prepare chapters from EPUB/PDF sources. - Add runAudiobookGeneration and regenerateAudiobookChapter pipeline functions to handle chapter preparation, progress tracking, request header construction, and retry/abort behavior. - Wire adapters/pipeline into EPUBContext and PDFContext to simplify and centralize audiobook flow. - Move config preference helpers out of ConfigContext into dedicated client modules: - buildSyncedPreferencePatch (lib/client/config/preferences.ts) - applyConfigUpdate and getVoicePreferenceKey (lib/client/config/updates.ts) - Use these helpers in ConfigContext to keep update logic small and testable. - Add TTS settings view model resolver (lib/client/settings/tts-settings.ts) and tests for the tts-provider catalog and config helpers (tests/unit/tts-provider-catalog.spec.ts). - Minor usages updated: AudiobookExportModal, SettingsModal, useVoiceManagement, TTSContext, server tts generation, and various context files to consume the new modules. Why: Reduce duplication, improve testability, and separate concerns for provider metadata, UI view-model logic, and audiobook generation flow. Breaking changes: none. --- src/app/api/tts/voices/route.ts | 150 +---------- src/components/AudiobookExportModal.tsx | 3 +- src/components/SettingsModal.tsx | 75 ++---- src/contexts/ConfigContext.tsx | 83 +----- src/contexts/EPUBContext.tsx | 285 +++----------------- src/contexts/PDFContext.tsx | 291 +++------------------ src/contexts/TTSContext.tsx | 3 +- src/hooks/audio/useVoiceManagement.ts | 8 +- src/lib/client/audiobooks/adapters/epub.ts | 57 ++++ src/lib/client/audiobooks/adapters/pdf.ts | 59 +++++ src/lib/client/audiobooks/pipeline.ts | 251 ++++++++++++++++++ src/lib/client/config/preferences.ts | 28 ++ src/lib/client/config/updates.ts | 68 +++++ src/lib/client/settings/tts-settings.ts | 48 ++++ src/lib/server/tts/generate.ts | 3 +- src/lib/shared/tts-provider-catalog.ts | 239 +++++++++++++++++ tests/unit/tts-provider-catalog.spec.ts | 95 +++++++ 17 files changed, 960 insertions(+), 786 deletions(-) create mode 100644 src/lib/client/audiobooks/adapters/epub.ts create mode 100644 src/lib/client/audiobooks/adapters/pdf.ts create mode 100644 src/lib/client/audiobooks/pipeline.ts create mode 100644 src/lib/client/config/preferences.ts create mode 100644 src/lib/client/config/updates.ts create mode 100644 src/lib/client/settings/tts-settings.ts create mode 100644 src/lib/shared/tts-provider-catalog.ts create mode 100644 tests/unit/tts-provider-catalog.spec.ts 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) && (