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.
This commit is contained in:
parent
4a985b3bf1
commit
d6ae2baa6f
17 changed files with 960 additions and 786 deletions
|
|
@ -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<string[]> {
|
||||
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';
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{modelValue === 'gpt-4o-mini-tts' && (
|
||||
{supportsTtsInstructions(modelValue) && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm font-medium text-foreground">TTS Instructions</label>
|
||||
<textarea
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import { scheduleUserPreferencesSync, cancelPendingPreferenceSync, getUserPrefer
|
|||
import { SYNCED_PREFERENCE_KEYS, type SyncedPreferenceKey, type SyncedPreferencesPatch } from '@/types/user-state';
|
||||
import { useAuthSession } from '@/hooks/useAuthSession';
|
||||
import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
|
||||
import { buildSyncedPreferencePatch } from '@/lib/client/config/preferences';
|
||||
import { applyConfigUpdate } from '@/lib/client/config/updates';
|
||||
import toast from 'react-hot-toast';
|
||||
export type { ViewType } from '@/types/config';
|
||||
|
||||
|
|
@ -61,9 +63,6 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
const { data: sessionData, isPending: isSessionPending } = useAuthSession();
|
||||
const sessionKey = sessionData?.user?.id ?? 'no-session';
|
||||
|
||||
// Helper function to generate provider-model key
|
||||
const getVoiceKey = (provider: string, model: string) => `${provider}:${model}`;
|
||||
|
||||
const queueSyncedPreferencePatch = useCallback((patch: Partial<AppConfigValues>) => {
|
||||
if (!authEnabled || sessionKey === 'no-session') return;
|
||||
|
||||
|
|
@ -85,28 +84,6 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
};
|
||||
}, [sessionKey]);
|
||||
|
||||
const buildSyncedPreferencePatch = useCallback((
|
||||
source: Partial<AppConfigValues>,
|
||||
options?: { nonDefaultOnly?: boolean },
|
||||
): SyncedPreferencesPatch => {
|
||||
const out: SyncedPreferencesPatch = {};
|
||||
for (const key of SYNCED_PREFERENCE_KEYS) {
|
||||
if (!(key in source)) continue;
|
||||
const value = source[key];
|
||||
if (value === undefined) continue;
|
||||
if (options?.nonDefaultOnly) {
|
||||
const defaultValue = APP_CONFIG_DEFAULTS[key];
|
||||
const same =
|
||||
typeof value === 'object'
|
||||
? JSON.stringify(value) === JSON.stringify(defaultValue)
|
||||
: value === defaultValue;
|
||||
if (same) continue;
|
||||
}
|
||||
(out as Record<SyncedPreferenceKey, unknown>)[key] = value;
|
||||
}
|
||||
return out;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ status?: string; ms?: number }>).detail;
|
||||
|
|
@ -342,53 +319,17 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
const updateConfigKey = async <K extends keyof AppConfigValues>(key: K, value: AppConfigValues[K]) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const { storagePatch, syncPatch } = applyConfigUpdate({
|
||||
ttsProvider,
|
||||
ttsModel,
|
||||
savedVoices,
|
||||
}, key, value);
|
||||
|
||||
// Special handling for voice - only update savedVoices
|
||||
if (key === 'voice') {
|
||||
const voiceKey = getVoiceKey(ttsProvider, ttsModel);
|
||||
const updatedSavedVoices = { ...savedVoices, [voiceKey]: value as string };
|
||||
await updateAppConfig({
|
||||
savedVoices: updatedSavedVoices,
|
||||
voice: value as string,
|
||||
});
|
||||
queueSyncedPreferencePatch({
|
||||
savedVoices: updatedSavedVoices,
|
||||
voice: value as string,
|
||||
});
|
||||
}
|
||||
// Special handling for provider/model changes - restore saved voice if available
|
||||
else if (key === 'ttsProvider' || key === 'ttsModel') {
|
||||
const newProvider = key === 'ttsProvider' ? (value as string) : ttsProvider;
|
||||
const newModel = key === 'ttsModel' ? (value as string) : ttsModel;
|
||||
const voiceKey = getVoiceKey(newProvider, newModel);
|
||||
const restoredVoice = savedVoices[voiceKey] || '';
|
||||
await updateAppConfig({
|
||||
[key]: value as AppConfigValues[keyof AppConfigValues],
|
||||
voice: restoredVoice,
|
||||
} as Partial<AppConfigRow>);
|
||||
queueSyncedPreferencePatch({
|
||||
[key]: value as AppConfigValues[keyof AppConfigValues],
|
||||
voice: restoredVoice,
|
||||
} as Partial<AppConfigValues>);
|
||||
}
|
||||
else if (key === 'savedVoices') {
|
||||
const newSavedVoices = value as SavedVoices;
|
||||
await updateAppConfig({
|
||||
savedVoices: newSavedVoices,
|
||||
});
|
||||
queueSyncedPreferencePatch({
|
||||
savedVoices: newSavedVoices,
|
||||
});
|
||||
}
|
||||
else {
|
||||
await updateAppConfig({
|
||||
[key]: value as AppConfigValues[keyof AppConfigValues],
|
||||
} as Partial<AppConfigRow>);
|
||||
if (syncedPreferenceKeys.has(String(key))) {
|
||||
queueSyncedPreferencePatch({
|
||||
[key]: value,
|
||||
} as Partial<AppConfigValues>);
|
||||
}
|
||||
await updateAppConfig(storagePatch);
|
||||
if (key === 'voice' || key === 'ttsProvider' || key === 'ttsModel' || key === 'savedVoices') {
|
||||
queueSyncedPreferencePatch(syncPatch);
|
||||
} else if (syncedPreferenceKeys.has(String(key))) {
|
||||
queueSyncedPreferencePatch(syncPatch);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error updating config key ${String(key)}:`, error);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ import type { NavItem } from 'epubjs';
|
|||
import type { SpineItem } from 'epubjs/types/section';
|
||||
import type { Book, Rendition } from 'epubjs';
|
||||
|
||||
import { createEpubAudiobookSourceAdapter } from '@/lib/client/audiobooks/adapters/epub';
|
||||
import { regenerateAudiobookChapter, runAudiobookGeneration } from '@/lib/client/audiobooks/pipeline';
|
||||
import { setLastDocumentLocation } from '@/lib/client/dexie';
|
||||
import { scheduleDocumentProgressSync } from '@/lib/client/api/user-state';
|
||||
import { getDocumentMetadata } from '@/lib/client/api/documents';
|
||||
|
|
@ -25,18 +27,13 @@ import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
|
|||
import { createRangeCfi } from '@/lib/client/epub';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useConfig } from './ConfigContext';
|
||||
import { withRetry, getAudiobookStatus, createAudiobookChapter } from '@/lib/client/api/audiobooks';
|
||||
import { CmpStr } from 'cmpstr';
|
||||
import type {
|
||||
TTSSentenceAlignment,
|
||||
TTSAudiobookFormat,
|
||||
TTSAudiobookChapter,
|
||||
} from '@/types/tts';
|
||||
import type {
|
||||
TTSRequestHeaders,
|
||||
TTSRetryOptions,
|
||||
AudiobookGenerationSettings,
|
||||
} from '@/types/client';
|
||||
import type { AudiobookGenerationSettings } from '@/types/client';
|
||||
|
||||
interface EPUBContextType {
|
||||
currDocData: ArrayBuffer | undefined;
|
||||
|
|
@ -337,6 +334,11 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
}
|
||||
}, []);
|
||||
|
||||
const audiobookAdapter = useMemo(() => createEpubAudiobookSourceAdapter({
|
||||
extractBookText,
|
||||
getTocItems: () => tocRef.current || [],
|
||||
}), [extractBookText]);
|
||||
|
||||
/**
|
||||
* Creates a complete audiobook by processing all text through NLP and TTS
|
||||
*/
|
||||
|
|
@ -349,179 +351,23 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
settings?: AudiobookGenerationSettings
|
||||
): Promise<string> => {
|
||||
try {
|
||||
const sections = await extractBookText();
|
||||
if (!sections.length) throw new Error('No text content found in book');
|
||||
|
||||
const effectiveProvider = settings?.ttsProvider ?? ttsProvider;
|
||||
const effectiveFormat = settings?.format ?? format;
|
||||
|
||||
// Calculate total length for accurate progress tracking
|
||||
const totalLength = sections.reduce((sum, section) => sum + section.text.trim().length, 0);
|
||||
let processedLength = 0;
|
||||
let bookId: string = providedBookId || '';
|
||||
|
||||
// Get TOC for chapter titles
|
||||
const chapters = tocRef.current || [];
|
||||
|
||||
// If we have a bookId, check for existing chapters to determine which indices already exist
|
||||
const existingIndices = new Set<number>();
|
||||
if (bookId) {
|
||||
try {
|
||||
const existingData = await getAudiobookStatus(bookId);
|
||||
if (existingData.chapters && existingData.chapters.length > 0) {
|
||||
for (const ch of existingData.chapters) {
|
||||
existingIndices.add(ch.index);
|
||||
}
|
||||
// Log smallest missing index for visibility
|
||||
let nextMissing = 0;
|
||||
while (existingIndices.has(nextMissing)) nextMissing++;
|
||||
console.log(`Resuming; next missing chapter index is ${nextMissing}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking existing chapters:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a map of section hrefs to their chapter titles
|
||||
const sectionTitleMap = new Map<string, string>();
|
||||
|
||||
// First, loop through all chapters to create the mapping
|
||||
for (const chapter of chapters) {
|
||||
if (!chapter.href) continue;
|
||||
const chapterBaseHref = chapter.href.split('#')[0];
|
||||
const chapterTitle = chapter.label.trim();
|
||||
|
||||
// For each chapter, find all matching sections
|
||||
for (const section of sections) {
|
||||
const sectionHref = section.href;
|
||||
const sectionBaseHref = sectionHref.split('#')[0];
|
||||
|
||||
// If this section matches this chapter, map it
|
||||
if (sectionHref === chapter.href || sectionBaseHref === chapterBaseHref) {
|
||||
sectionTitleMap.set(sectionHref, chapterTitle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process each section
|
||||
for (let i = 0; i < sections.length; i++) {
|
||||
// Check for abort at the start of iteration
|
||||
if (signal?.aborted) {
|
||||
console.log('Generation cancelled by user');
|
||||
if (bookId) {
|
||||
return bookId; // Return bookId with partial progress
|
||||
}
|
||||
throw new Error('Audiobook generation cancelled');
|
||||
}
|
||||
|
||||
const section = sections[i];
|
||||
const trimmedText = section.text.trim();
|
||||
if (!trimmedText) continue;
|
||||
|
||||
// Skip chapters that already exist on disk (supports non-contiguous indices)
|
||||
if (existingIndices.has(i)) {
|
||||
processedLength += trimmedText.length;
|
||||
onProgress((processedLength / totalLength) * 100);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const reqHeaders: TTSRequestHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
'x-openai-key': apiKey,
|
||||
'x-openai-base-url': baseUrl,
|
||||
'x-tts-provider': effectiveProvider,
|
||||
};
|
||||
|
||||
// Allow one narrow client retry for transient browser->/api/audiobook/chapter transport failures.
|
||||
// HTTP failures are not retried client-side.
|
||||
const retryOptions: TTSRetryOptions = {
|
||||
maxRetries: 2,
|
||||
initialDelay: 300,
|
||||
maxDelay: 300,
|
||||
};
|
||||
|
||||
// Get the chapter title from our pre-computed map
|
||||
let chapterTitle = sectionTitleMap.get(section.href);
|
||||
|
||||
// If no chapter title found, use index-based naming
|
||||
if (!chapterTitle) {
|
||||
chapterTitle = `Chapter ${i + 1}`;
|
||||
}
|
||||
|
||||
const chapter = await withRetry(
|
||||
async () => {
|
||||
// Check for abort before starting chapter request
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
|
||||
return await createAudiobookChapter({
|
||||
chapterTitle,
|
||||
text: trimmedText,
|
||||
bookId,
|
||||
format: effectiveFormat,
|
||||
chapterIndex: i,
|
||||
settings
|
||||
}, reqHeaders, signal);
|
||||
},
|
||||
retryOptions
|
||||
);
|
||||
|
||||
// Check for abort before sending to server
|
||||
if (signal?.aborted) {
|
||||
console.log('Generation cancelled before saving chapter');
|
||||
if (bookId) {
|
||||
return bookId;
|
||||
}
|
||||
throw new Error('Audiobook generation cancelled');
|
||||
}
|
||||
|
||||
if (!bookId) {
|
||||
bookId = chapter.bookId!;
|
||||
}
|
||||
|
||||
// Notify about completed chapter
|
||||
if (onChapterComplete) {
|
||||
onChapterComplete(chapter);
|
||||
}
|
||||
|
||||
processedLength += trimmedText.length;
|
||||
onProgress((processedLength / totalLength) * 100);
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'))) {
|
||||
console.log('TTS request aborted, returning partial progress');
|
||||
if (bookId) {
|
||||
return bookId; // Return with partial progress
|
||||
}
|
||||
throw new Error('Audiobook generation cancelled');
|
||||
}
|
||||
console.error('Error processing section:', error);
|
||||
|
||||
// Notify about error
|
||||
if (onChapterComplete) {
|
||||
onChapterComplete({
|
||||
index: i,
|
||||
title: sectionTitleMap.get(section.href) || `Chapter ${i + 1}`,
|
||||
status: 'error',
|
||||
bookId,
|
||||
format: effectiveFormat
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!bookId) {
|
||||
throw new Error('No audio was generated from the book content');
|
||||
}
|
||||
|
||||
return bookId;
|
||||
return await runAudiobookGeneration({
|
||||
adapter: audiobookAdapter,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
defaultProvider: ttsProvider,
|
||||
onProgress,
|
||||
signal,
|
||||
onChapterComplete,
|
||||
providedBookId,
|
||||
format,
|
||||
settings,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating audiobook:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [extractBookText, apiKey, baseUrl, ttsProvider]);
|
||||
}, [audiobookAdapter, apiKey, baseUrl, ttsProvider]);
|
||||
|
||||
/**
|
||||
* Regenerates a specific chapter of the audiobook
|
||||
|
|
@ -534,82 +380,17 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
settings?: AudiobookGenerationSettings
|
||||
): Promise<TTSAudiobookChapter> => {
|
||||
try {
|
||||
const sections = await extractBookText();
|
||||
if (chapterIndex >= sections.length) {
|
||||
throw new Error('Invalid chapter index');
|
||||
}
|
||||
|
||||
const effectiveProvider = settings?.ttsProvider ?? ttsProvider;
|
||||
const effectiveFormat = settings?.format ?? format;
|
||||
|
||||
const section = sections[chapterIndex];
|
||||
const trimmedText = section.text.trim();
|
||||
|
||||
if (!trimmedText) {
|
||||
throw new Error('No text content found in chapter');
|
||||
}
|
||||
|
||||
// Get TOC for chapter title
|
||||
const chapters = tocRef.current || [];
|
||||
const sectionTitleMap = new Map<string, string>();
|
||||
|
||||
for (const chapter of chapters) {
|
||||
if (!chapter.href) continue;
|
||||
const chapterBaseHref = chapter.href.split('#')[0];
|
||||
const chapterTitle = chapter.label.trim();
|
||||
|
||||
for (const sect of sections) {
|
||||
const sectionHref = sect.href;
|
||||
const sectionBaseHref = sectionHref.split('#')[0];
|
||||
|
||||
if (sectionHref === chapter.href || sectionBaseHref === chapterBaseHref) {
|
||||
sectionTitleMap.set(sectionHref, chapterTitle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const chapterTitle = sectionTitleMap.get(section.href) || `Chapter ${chapterIndex + 1}`;
|
||||
|
||||
// Generate audio with retry logic
|
||||
const reqHeaders: TTSRequestHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
'x-openai-key': apiKey,
|
||||
'x-openai-base-url': baseUrl,
|
||||
'x-tts-provider': effectiveProvider,
|
||||
};
|
||||
|
||||
// Allow one narrow client retry for transient browser->/api/audiobook/chapter transport failures.
|
||||
// HTTP failures are not retried client-side.
|
||||
const retryOptions: TTSRetryOptions = {
|
||||
maxRetries: 2,
|
||||
initialDelay: 300,
|
||||
maxDelay: 300,
|
||||
};
|
||||
|
||||
const chapter = await withRetry(
|
||||
async () => {
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
|
||||
return await createAudiobookChapter({
|
||||
chapterTitle,
|
||||
text: trimmedText,
|
||||
bookId,
|
||||
format: effectiveFormat,
|
||||
chapterIndex,
|
||||
settings
|
||||
}, reqHeaders, signal);
|
||||
},
|
||||
retryOptions
|
||||
);
|
||||
|
||||
if (signal?.aborted) {
|
||||
throw new Error('Chapter regeneration cancelled');
|
||||
}
|
||||
|
||||
return chapter;
|
||||
|
||||
return await regenerateAudiobookChapter({
|
||||
adapter: audiobookAdapter,
|
||||
chapterIndex,
|
||||
bookId,
|
||||
format,
|
||||
signal,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
defaultProvider: ttsProvider,
|
||||
settings,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'))) {
|
||||
throw new Error('Chapter regeneration cancelled');
|
||||
|
|
@ -617,7 +398,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
console.error('Error regenerating chapter:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [extractBookText, apiKey, baseUrl, ttsProvider]);
|
||||
}, [audiobookAdapter, apiKey, baseUrl, ttsProvider]);
|
||||
|
||||
const setRendition = useCallback((rendition: Rendition) => {
|
||||
bookRef.current = rendition.book;
|
||||
|
|
|
|||
|
|
@ -28,11 +28,11 @@ import {
|
|||
import type { PDFDocumentProxy } from 'pdfjs-dist';
|
||||
|
||||
import { getDocumentMetadata } from '@/lib/client/api/documents';
|
||||
import { createPdfAudiobookSourceAdapter } from '@/lib/client/audiobooks/adapters/pdf';
|
||||
import { regenerateAudiobookChapter, runAudiobookGeneration } from '@/lib/client/audiobooks/pipeline';
|
||||
import { ensureCachedDocument } from '@/lib/client/cache/documents';
|
||||
import { useTTS } from '@/contexts/TTSContext';
|
||||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
import { normalizeTextForTts } from '@/lib/shared/nlp';
|
||||
import { withRetry, getAudiobookStatus, createAudiobookChapter } from '@/lib/client/api/audiobooks';
|
||||
import {
|
||||
extractTextFromPDF,
|
||||
highlightPattern,
|
||||
|
|
@ -46,11 +46,7 @@ import type {
|
|||
TTSAudiobookFormat,
|
||||
TTSAudiobookChapter,
|
||||
} from '@/types/tts';
|
||||
import type {
|
||||
TTSRequestHeaders,
|
||||
TTSRetryOptions,
|
||||
AudiobookGenerationSettings,
|
||||
} from '@/types/client';
|
||||
import type { AudiobookGenerationSettings } from '@/types/client';
|
||||
|
||||
/**
|
||||
* Interface defining all available methods and properties in the PDF context
|
||||
|
|
@ -139,6 +135,16 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
const [currDocText, setCurrDocText] = useState<string>();
|
||||
const [pdfDocument, setPdfDocument] = useState<PDFDocumentProxy>();
|
||||
const [isAudioCombining] = useState(false);
|
||||
const audiobookAdapter = useMemo(() => createPdfAudiobookSourceAdapter({
|
||||
pdfDocument,
|
||||
margins: {
|
||||
header: headerMargin,
|
||||
footer: footerMargin,
|
||||
left: leftMargin,
|
||||
right: rightMargin,
|
||||
},
|
||||
smartSentenceSplitting,
|
||||
}), [pdfDocument, headerMargin, footerMargin, leftMargin, rightMargin, smartSentenceSplitting]);
|
||||
const pageTextCacheRef = useRef<Map<number, string>>(new Map());
|
||||
const [currDocPage, setCurrDocPage] = useState<number>(currDocPageNumber);
|
||||
|
||||
|
|
@ -409,169 +415,23 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
settings?: AudiobookGenerationSettings
|
||||
): Promise<string> => {
|
||||
try {
|
||||
if (!pdfDocument) {
|
||||
throw new Error('No PDF document loaded');
|
||||
}
|
||||
|
||||
const effectiveProvider = settings?.ttsProvider ?? ttsProvider;
|
||||
const effectiveFormat = settings?.format ?? format;
|
||||
|
||||
// First pass: extract and measure all text
|
||||
const textPerPage: string[] = [];
|
||||
let totalLength = 0;
|
||||
|
||||
for (let pageNum = 1; pageNum <= pdfDocument.numPages; pageNum++) {
|
||||
const rawText = await extractTextFromPDF(pdfDocument, pageNum, {
|
||||
header: headerMargin,
|
||||
footer: footerMargin,
|
||||
left: leftMargin,
|
||||
right: rightMargin
|
||||
});
|
||||
const trimmedText = rawText.trim();
|
||||
if (trimmedText) {
|
||||
const processedText = smartSentenceSplitting ? normalizeTextForTts(trimmedText) : trimmedText;
|
||||
|
||||
textPerPage.push(processedText);
|
||||
totalLength += processedText.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (totalLength === 0) {
|
||||
throw new Error('No text content found in PDF');
|
||||
}
|
||||
|
||||
let processedLength = 0;
|
||||
let bookId: string = providedBookId || '';
|
||||
|
||||
// If we have a bookId, check for existing chapters to determine which indices already exist
|
||||
const existingIndices = new Set<number>();
|
||||
if (bookId) {
|
||||
try {
|
||||
const existingData = await getAudiobookStatus(bookId);
|
||||
if (existingData.chapters && existingData.chapters.length > 0) {
|
||||
for (const ch of existingData.chapters) {
|
||||
existingIndices.add(ch.index);
|
||||
}
|
||||
let nextMissing = 0;
|
||||
while (existingIndices.has(nextMissing)) nextMissing++;
|
||||
console.log(`Resuming; next missing page index is ${nextMissing} (page ${nextMissing + 1})`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking existing chapters:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: process text into audio
|
||||
for (let i = 0; i < textPerPage.length; i++) {
|
||||
// Check for abort at the start of iteration
|
||||
if (signal?.aborted) {
|
||||
console.log('Generation cancelled by user');
|
||||
if (bookId) {
|
||||
return bookId; // Return bookId with partial progress
|
||||
}
|
||||
throw new Error('Audiobook generation cancelled');
|
||||
}
|
||||
|
||||
const text = textPerPage[i];
|
||||
|
||||
// Skip pages that already exist on disk (supports non-contiguous indices)
|
||||
if (existingIndices.has(i)) {
|
||||
processedLength += text.length;
|
||||
onProgress((processedLength / totalLength) * 100);
|
||||
continue;
|
||||
}
|
||||
|
||||
const reqHeaders: TTSRequestHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
'x-openai-key': apiKey,
|
||||
'x-openai-base-url': baseUrl,
|
||||
'x-tts-provider': effectiveProvider,
|
||||
};
|
||||
|
||||
// Allow one narrow client retry for transient browser->/api/audiobook/chapter transport failures.
|
||||
// HTTP failures are not retried client-side.
|
||||
const retryOptions: TTSRetryOptions = {
|
||||
maxRetries: 2,
|
||||
initialDelay: 300,
|
||||
maxDelay: 300,
|
||||
};
|
||||
|
||||
try {
|
||||
const chapterTitle = `Page ${i + 1}`;
|
||||
|
||||
const chapter = await withRetry(
|
||||
async () => {
|
||||
// Check for abort before starting chapter request
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
|
||||
return await createAudiobookChapter({
|
||||
chapterTitle,
|
||||
text,
|
||||
bookId,
|
||||
format: effectiveFormat,
|
||||
chapterIndex: i,
|
||||
settings
|
||||
}, reqHeaders, signal);
|
||||
},
|
||||
retryOptions
|
||||
);
|
||||
|
||||
// Check for abort before sending to server
|
||||
if (signal?.aborted) {
|
||||
console.log('Generation cancelled before saving page');
|
||||
if (bookId) {
|
||||
return bookId;
|
||||
}
|
||||
throw new Error('Audiobook generation cancelled');
|
||||
}
|
||||
|
||||
if (!bookId) {
|
||||
bookId = chapter.bookId!;
|
||||
}
|
||||
|
||||
// Notify about completed chapter
|
||||
if (onChapterComplete) {
|
||||
onChapterComplete(chapter);
|
||||
}
|
||||
|
||||
processedLength += text.length;
|
||||
onProgress((processedLength / totalLength) * 100);
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'))) {
|
||||
console.log('TTS request aborted, returning partial progress');
|
||||
if (bookId) {
|
||||
return bookId; // Return with partial progress
|
||||
}
|
||||
throw new Error('Audiobook generation cancelled');
|
||||
}
|
||||
console.error('Error processing page:', error);
|
||||
|
||||
// Notify about error
|
||||
if (onChapterComplete) {
|
||||
onChapterComplete({
|
||||
index: i,
|
||||
title: `Page ${i + 1}`,
|
||||
status: 'error',
|
||||
bookId,
|
||||
format: effectiveFormat
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!bookId) {
|
||||
throw new Error('No audio was generated from the PDF content');
|
||||
}
|
||||
|
||||
return bookId;
|
||||
return await runAudiobookGeneration({
|
||||
adapter: audiobookAdapter,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
defaultProvider: ttsProvider,
|
||||
onProgress,
|
||||
signal,
|
||||
onChapterComplete,
|
||||
providedBookId,
|
||||
format,
|
||||
settings,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating audiobook:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [pdfDocument, headerMargin, footerMargin, leftMargin, rightMargin, apiKey, baseUrl, ttsProvider, smartSentenceSplitting]);
|
||||
}, [audiobookAdapter, apiKey, baseUrl, ttsProvider]);
|
||||
|
||||
/**
|
||||
* Regenerates a specific chapter (page) of the PDF audiobook
|
||||
|
|
@ -584,94 +444,17 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
settings?: AudiobookGenerationSettings
|
||||
): Promise<TTSAudiobookChapter> => {
|
||||
try {
|
||||
if (!pdfDocument) {
|
||||
throw new Error('No PDF document loaded');
|
||||
}
|
||||
|
||||
const effectiveProvider = settings?.ttsProvider ?? ttsProvider;
|
||||
const effectiveFormat = settings?.format ?? format;
|
||||
|
||||
// IMPORTANT: Chapter indices are based on non-empty pages used during generation.
|
||||
// Build a mapping of "chapterIndex" -> actual PDF page number (1-based).
|
||||
const nonEmptyPages: number[] = [];
|
||||
for (let page = 1; page <= pdfDocument.numPages; page++) {
|
||||
const pageText = await extractTextFromPDF(pdfDocument, page, {
|
||||
header: headerMargin,
|
||||
footer: footerMargin,
|
||||
left: leftMargin,
|
||||
right: rightMargin
|
||||
});
|
||||
if (pageText.trim()) {
|
||||
nonEmptyPages.push(page);
|
||||
}
|
||||
}
|
||||
|
||||
if (chapterIndex < 0 || chapterIndex >= nonEmptyPages.length) {
|
||||
throw new Error('Invalid chapter index');
|
||||
}
|
||||
|
||||
const pageNum = nonEmptyPages[chapterIndex];
|
||||
|
||||
// Extract text from the mapped page
|
||||
const rawText = await extractTextFromPDF(pdfDocument, pageNum, {
|
||||
header: headerMargin,
|
||||
footer: footerMargin,
|
||||
left: leftMargin,
|
||||
right: rightMargin
|
||||
return await regenerateAudiobookChapter({
|
||||
adapter: audiobookAdapter,
|
||||
chapterIndex,
|
||||
bookId,
|
||||
format,
|
||||
signal,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
defaultProvider: ttsProvider,
|
||||
settings,
|
||||
});
|
||||
|
||||
const trimmedText = rawText.trim();
|
||||
if (!trimmedText) {
|
||||
throw new Error('No text content found on page');
|
||||
}
|
||||
|
||||
const textForTTS = smartSentenceSplitting
|
||||
? normalizeTextForTts(trimmedText)
|
||||
: trimmedText;
|
||||
|
||||
// Use logical chapter numbering (index + 1) to match original generation titles
|
||||
const chapterTitle = `Page ${chapterIndex + 1}`;
|
||||
|
||||
// Generate audio with retry logic
|
||||
const reqHeaders: TTSRequestHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
'x-openai-key': apiKey,
|
||||
'x-openai-base-url': baseUrl,
|
||||
'x-tts-provider': effectiveProvider,
|
||||
};
|
||||
|
||||
// Allow one narrow client retry for transient browser->/api/audiobook/chapter transport failures.
|
||||
// HTTP failures are not retried client-side.
|
||||
const retryOptions: TTSRetryOptions = {
|
||||
maxRetries: 2,
|
||||
initialDelay: 300,
|
||||
maxDelay: 300,
|
||||
};
|
||||
|
||||
const chapter = await withRetry(
|
||||
async () => {
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
|
||||
return await createAudiobookChapter({
|
||||
chapterTitle,
|
||||
text: textForTTS,
|
||||
bookId,
|
||||
format: effectiveFormat,
|
||||
chapterIndex,
|
||||
settings
|
||||
}, reqHeaders, signal);
|
||||
},
|
||||
retryOptions
|
||||
);
|
||||
|
||||
if (signal?.aborted) {
|
||||
throw new Error('Page regeneration cancelled');
|
||||
}
|
||||
|
||||
return chapter;
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'))) {
|
||||
throw new Error('Page regeneration cancelled');
|
||||
|
|
@ -679,7 +462,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
console.error('Error regenerating page:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [pdfDocument, headerMargin, footerMargin, leftMargin, rightMargin, apiKey, baseUrl, ttsProvider, smartSentenceSplitting]);
|
||||
}, [audiobookAdapter, apiKey, baseUrl, ttsProvider]);
|
||||
|
||||
/**
|
||||
* Effect hook to initialize TTS as non-EPUB mode
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import { getDocumentProgress, scheduleDocumentProgressSync } from '@/lib/client/
|
|||
import { withRetry, generateTTS, alignAudio } from '@/lib/client/api/audiobooks';
|
||||
import { preprocessSentenceForAudio, splitTextToTtsBlocks, splitTextToTtsBlocksEPUB } from '@/lib/shared/nlp';
|
||||
import { isKokoroModel } from '@/lib/shared/kokoro';
|
||||
import { supportsTtsInstructions } from '@/lib/shared/tts-provider-catalog';
|
||||
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
|
||||
import type {
|
||||
TTSLocation,
|
||||
|
|
@ -1099,7 +1100,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
speed,
|
||||
format: 'mp3',
|
||||
model: ttsModel,
|
||||
instructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined,
|
||||
instructions: supportsTtsInstructions(ttsModel) ? ttsInstructions : undefined,
|
||||
};
|
||||
|
||||
// Allow one narrow client retry for transient browser->/api/tts transport failures.
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@
|
|||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { getVoices } from '@/lib/client/api/audiobooks';
|
||||
|
||||
const DEFAULT_VOICES = ['alloy', 'ash', 'coral', 'echo', 'fable', 'onyx', 'nova', 'sage', 'shimmer'];
|
||||
import { getDefaultVoices } from '@/lib/shared/tts-provider-catalog';
|
||||
|
||||
/**
|
||||
* Custom hook for managing TTS voices
|
||||
|
|
@ -36,12 +35,11 @@ export function useVoiceManagement(
|
|||
|
||||
// Ignore stale responses from older provider/model fetches.
|
||||
if (fetchSeq !== fetchSeqRef.current) return;
|
||||
setAvailableVoices(data.voices || DEFAULT_VOICES);
|
||||
setAvailableVoices(data.voices || getDefaultVoices(ttsProvider || 'openai', ttsModel || 'tts-1'));
|
||||
} catch (error) {
|
||||
console.error('Error fetching voices:', error);
|
||||
if (fetchSeq !== fetchSeqRef.current) return;
|
||||
// Set available voices to default openai voices
|
||||
setAvailableVoices(DEFAULT_VOICES);
|
||||
setAvailableVoices(getDefaultVoices(ttsProvider || 'openai', ttsModel || 'tts-1'));
|
||||
}
|
||||
}, [apiKey, baseUrl, ttsProvider, ttsModel]);
|
||||
|
||||
|
|
|
|||
57
src/lib/client/audiobooks/adapters/epub.ts
Normal file
57
src/lib/client/audiobooks/adapters/epub.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import type { NavItem } from 'epubjs';
|
||||
|
||||
import type { AudiobookSourceAdapter, PreparedAudiobookChapter } from '@/lib/client/audiobooks/pipeline';
|
||||
|
||||
export interface ExtractedEpubSection {
|
||||
text: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
interface EpubAudiobookAdapterOptions {
|
||||
extractBookText: () => Promise<ExtractedEpubSection[]>;
|
||||
getTocItems: () => NavItem[];
|
||||
}
|
||||
|
||||
async function buildPreparedEpubChapters({
|
||||
extractBookText,
|
||||
getTocItems,
|
||||
}: EpubAudiobookAdapterOptions): Promise<PreparedAudiobookChapter[]> {
|
||||
const sections = await extractBookText();
|
||||
const chapters = getTocItems();
|
||||
const sectionTitleMap = new Map<string, string>();
|
||||
|
||||
for (const chapter of chapters) {
|
||||
if (!chapter.href) continue;
|
||||
const chapterBaseHref = chapter.href.split('#')[0];
|
||||
const chapterTitle = chapter.label.trim();
|
||||
|
||||
for (const section of sections) {
|
||||
const sectionBaseHref = section.href.split('#')[0];
|
||||
if (section.href === chapter.href || sectionBaseHref === chapterBaseHref) {
|
||||
sectionTitleMap.set(section.href, chapterTitle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sections.map((section, index) => ({
|
||||
index,
|
||||
title: sectionTitleMap.get(section.href) || `Chapter ${index + 1}`,
|
||||
text: section.text,
|
||||
}));
|
||||
}
|
||||
|
||||
export function createEpubAudiobookSourceAdapter(options: EpubAudiobookAdapterOptions): AudiobookSourceAdapter {
|
||||
return {
|
||||
noContentMessage: 'No text content found in book',
|
||||
noAudioGeneratedMessage: 'No audio was generated from the book content',
|
||||
prepareChapters: async () => buildPreparedEpubChapters(options),
|
||||
prepareChapter: async (chapterIndex: number) => {
|
||||
const chapters = await buildPreparedEpubChapters(options);
|
||||
const chapter = chapters[chapterIndex];
|
||||
if (!chapter) {
|
||||
throw new Error('Invalid chapter index');
|
||||
}
|
||||
return chapter;
|
||||
},
|
||||
};
|
||||
}
|
||||
59
src/lib/client/audiobooks/adapters/pdf.ts
Normal file
59
src/lib/client/audiobooks/adapters/pdf.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import type { PDFDocumentProxy } from 'pdfjs-dist';
|
||||
|
||||
import { extractTextFromPDF } from '@/lib/client/pdf';
|
||||
import type { AudiobookSourceAdapter, PreparedAudiobookChapter } from '@/lib/client/audiobooks/pipeline';
|
||||
import { normalizeTextForTts } from '@/lib/shared/nlp';
|
||||
|
||||
interface PdfAudiobookAdapterOptions {
|
||||
pdfDocument?: PDFDocumentProxy;
|
||||
margins: {
|
||||
header: number;
|
||||
footer: number;
|
||||
left: number;
|
||||
right: number;
|
||||
};
|
||||
smartSentenceSplitting: boolean;
|
||||
}
|
||||
|
||||
async function extractPreparedPdfChapters({
|
||||
pdfDocument,
|
||||
margins,
|
||||
smartSentenceSplitting,
|
||||
}: PdfAudiobookAdapterOptions): Promise<PreparedAudiobookChapter[]> {
|
||||
if (!pdfDocument) {
|
||||
throw new Error('No PDF document loaded');
|
||||
}
|
||||
|
||||
const chapters: PreparedAudiobookChapter[] = [];
|
||||
for (let pageNum = 1; pageNum <= pdfDocument.numPages; pageNum++) {
|
||||
const rawText = await extractTextFromPDF(pdfDocument, pageNum, margins);
|
||||
const trimmedText = rawText.trim();
|
||||
if (!trimmedText) {
|
||||
continue;
|
||||
}
|
||||
|
||||
chapters.push({
|
||||
index: chapters.length,
|
||||
title: `Page ${chapters.length + 1}`,
|
||||
text: smartSentenceSplitting ? normalizeTextForTts(trimmedText) : trimmedText,
|
||||
});
|
||||
}
|
||||
|
||||
return chapters;
|
||||
}
|
||||
|
||||
export function createPdfAudiobookSourceAdapter(options: PdfAudiobookAdapterOptions): AudiobookSourceAdapter {
|
||||
return {
|
||||
noContentMessage: 'No text content found in PDF',
|
||||
noAudioGeneratedMessage: 'No audio was generated from the PDF content',
|
||||
prepareChapters: async () => extractPreparedPdfChapters(options),
|
||||
prepareChapter: async (chapterIndex: number) => {
|
||||
const chapters = await extractPreparedPdfChapters(options);
|
||||
const chapter = chapters[chapterIndex];
|
||||
if (!chapter) {
|
||||
throw new Error('Invalid chapter index');
|
||||
}
|
||||
return chapter;
|
||||
},
|
||||
};
|
||||
}
|
||||
251
src/lib/client/audiobooks/pipeline.ts
Normal file
251
src/lib/client/audiobooks/pipeline.ts
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
import {
|
||||
createAudiobookChapter,
|
||||
getAudiobookStatus,
|
||||
withRetry,
|
||||
} from '@/lib/client/api/audiobooks';
|
||||
import type {
|
||||
AudiobookGenerationSettings,
|
||||
TTSRequestHeaders,
|
||||
TTSRetryOptions,
|
||||
} from '@/types/client';
|
||||
import type {
|
||||
TTSAudiobookChapter,
|
||||
TTSAudiobookFormat,
|
||||
} from '@/types/tts';
|
||||
|
||||
export interface PreparedAudiobookChapter {
|
||||
index: number;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface AudiobookSourceAdapter {
|
||||
prepareChapters: () => Promise<PreparedAudiobookChapter[]>;
|
||||
prepareChapter: (chapterIndex: number) => Promise<PreparedAudiobookChapter>;
|
||||
noContentMessage: string;
|
||||
noAudioGeneratedMessage: string;
|
||||
}
|
||||
|
||||
interface RunAudiobookGenerationOptions {
|
||||
adapter: AudiobookSourceAdapter;
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
defaultProvider: string;
|
||||
onProgress: (progress: number) => void;
|
||||
signal?: AbortSignal;
|
||||
onChapterComplete?: (chapter: TTSAudiobookChapter) => void;
|
||||
providedBookId?: string;
|
||||
format?: TTSAudiobookFormat;
|
||||
settings?: AudiobookGenerationSettings;
|
||||
retryOptions?: TTSRetryOptions;
|
||||
}
|
||||
|
||||
interface RegenerateAudiobookChapterOptions {
|
||||
adapter: AudiobookSourceAdapter;
|
||||
chapterIndex: number;
|
||||
bookId: string;
|
||||
format: TTSAudiobookFormat;
|
||||
signal: AbortSignal;
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
defaultProvider: string;
|
||||
settings?: AudiobookGenerationSettings;
|
||||
retryOptions?: TTSRetryOptions;
|
||||
}
|
||||
|
||||
interface ResolvedAudiobookRequestSettings {
|
||||
effectiveProvider: string;
|
||||
effectiveFormat: TTSAudiobookFormat;
|
||||
}
|
||||
|
||||
function resolveAudiobookRequestSettings(
|
||||
settings: AudiobookGenerationSettings | undefined,
|
||||
defaultProvider: string,
|
||||
format: TTSAudiobookFormat,
|
||||
): ResolvedAudiobookRequestSettings {
|
||||
return {
|
||||
effectiveProvider: settings?.ttsProvider ?? defaultProvider,
|
||||
effectiveFormat: settings?.format ?? format,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAudiobookRequestHeaders(
|
||||
apiKey: string,
|
||||
baseUrl: string,
|
||||
effectiveProvider: string,
|
||||
): TTSRequestHeaders {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'x-openai-key': apiKey,
|
||||
'x-openai-base-url': baseUrl,
|
||||
'x-tts-provider': effectiveProvider,
|
||||
};
|
||||
}
|
||||
|
||||
function isAbortLikeError(error: unknown): boolean {
|
||||
return error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'));
|
||||
}
|
||||
|
||||
export async function runAudiobookGeneration({
|
||||
adapter,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
defaultProvider,
|
||||
onProgress,
|
||||
signal,
|
||||
onChapterComplete,
|
||||
providedBookId = '',
|
||||
format = 'mp3',
|
||||
settings,
|
||||
retryOptions = {
|
||||
maxRetries: 2,
|
||||
initialDelay: 300,
|
||||
maxDelay: 300,
|
||||
},
|
||||
}: RunAudiobookGenerationOptions): Promise<string> {
|
||||
const chapters = await adapter.prepareChapters();
|
||||
const totalLength = chapters.reduce((sum, chapter) => sum + chapter.text.trim().length, 0);
|
||||
if (totalLength === 0) {
|
||||
throw new Error(adapter.noContentMessage);
|
||||
}
|
||||
|
||||
const { effectiveProvider, effectiveFormat } = resolveAudiobookRequestSettings(settings, defaultProvider, format);
|
||||
const reqHeaders = buildAudiobookRequestHeaders(apiKey, baseUrl, effectiveProvider);
|
||||
let processedLength = 0;
|
||||
let bookId = providedBookId;
|
||||
|
||||
const existingIndices = new Set<number>();
|
||||
if (bookId) {
|
||||
try {
|
||||
const existingData = await getAudiobookStatus(bookId);
|
||||
if (existingData.chapters && existingData.chapters.length > 0) {
|
||||
for (const chapter of existingData.chapters) {
|
||||
existingIndices.add(chapter.index);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking existing chapters:', error);
|
||||
}
|
||||
}
|
||||
|
||||
for (const chapter of chapters) {
|
||||
if (signal?.aborted) {
|
||||
if (bookId) {
|
||||
return bookId;
|
||||
}
|
||||
throw new Error('Audiobook generation cancelled');
|
||||
}
|
||||
|
||||
const trimmedText = chapter.text.trim();
|
||||
if (!trimmedText) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existingIndices.has(chapter.index)) {
|
||||
processedLength += trimmedText.length;
|
||||
onProgress((processedLength / totalLength) * 100);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const createdChapter = await withRetry(
|
||||
async () => {
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
|
||||
return createAudiobookChapter({
|
||||
chapterTitle: chapter.title,
|
||||
text: trimmedText,
|
||||
bookId,
|
||||
format: effectiveFormat,
|
||||
chapterIndex: chapter.index,
|
||||
settings,
|
||||
}, reqHeaders, signal);
|
||||
},
|
||||
retryOptions,
|
||||
);
|
||||
|
||||
if (signal?.aborted) {
|
||||
if (bookId) {
|
||||
return bookId;
|
||||
}
|
||||
throw new Error('Audiobook generation cancelled');
|
||||
}
|
||||
|
||||
if (!bookId) {
|
||||
bookId = createdChapter.bookId!;
|
||||
}
|
||||
|
||||
onChapterComplete?.(createdChapter);
|
||||
processedLength += trimmedText.length;
|
||||
onProgress((processedLength / totalLength) * 100);
|
||||
} catch (error) {
|
||||
if (isAbortLikeError(error)) {
|
||||
if (bookId) {
|
||||
return bookId;
|
||||
}
|
||||
throw new Error('Audiobook generation cancelled');
|
||||
}
|
||||
|
||||
console.error('Error processing chapter:', error);
|
||||
onChapterComplete?.({
|
||||
index: chapter.index,
|
||||
title: chapter.title,
|
||||
status: 'error',
|
||||
bookId,
|
||||
format: effectiveFormat,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!bookId) {
|
||||
throw new Error(adapter.noAudioGeneratedMessage);
|
||||
}
|
||||
|
||||
return bookId;
|
||||
}
|
||||
|
||||
export async function regenerateAudiobookChapter({
|
||||
adapter,
|
||||
chapterIndex,
|
||||
bookId,
|
||||
format,
|
||||
signal,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
defaultProvider,
|
||||
settings,
|
||||
retryOptions = {
|
||||
maxRetries: 2,
|
||||
initialDelay: 300,
|
||||
maxDelay: 300,
|
||||
},
|
||||
}: RegenerateAudiobookChapterOptions): Promise<TTSAudiobookChapter> {
|
||||
const chapter = await adapter.prepareChapter(chapterIndex);
|
||||
const trimmedText = chapter.text.trim();
|
||||
if (!trimmedText) {
|
||||
throw new Error(adapter.noContentMessage);
|
||||
}
|
||||
|
||||
const { effectiveProvider, effectiveFormat } = resolveAudiobookRequestSettings(settings, defaultProvider, format);
|
||||
const reqHeaders = buildAudiobookRequestHeaders(apiKey, baseUrl, effectiveProvider);
|
||||
|
||||
return withRetry(
|
||||
async () => {
|
||||
if (signal.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
|
||||
return createAudiobookChapter({
|
||||
chapterTitle: chapter.title,
|
||||
text: trimmedText,
|
||||
bookId,
|
||||
format: effectiveFormat,
|
||||
chapterIndex,
|
||||
settings,
|
||||
}, reqHeaders, signal);
|
||||
},
|
||||
retryOptions,
|
||||
);
|
||||
}
|
||||
28
src/lib/client/config/preferences.ts
Normal file
28
src/lib/client/config/preferences.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { APP_CONFIG_DEFAULTS, type AppConfigValues } from '@/types/config';
|
||||
import { SYNCED_PREFERENCE_KEYS, type SyncedPreferenceKey, type SyncedPreferencesPatch } from '@/types/user-state';
|
||||
|
||||
export function buildSyncedPreferencePatch(
|
||||
source: Partial<AppConfigValues>,
|
||||
options?: { nonDefaultOnly?: boolean },
|
||||
): SyncedPreferencesPatch {
|
||||
const out: SyncedPreferencesPatch = {};
|
||||
|
||||
for (const key of SYNCED_PREFERENCE_KEYS) {
|
||||
if (!(key in source)) continue;
|
||||
const value = source[key];
|
||||
if (value === undefined) continue;
|
||||
|
||||
if (options?.nonDefaultOnly) {
|
||||
const defaultValue = APP_CONFIG_DEFAULTS[key];
|
||||
const same =
|
||||
typeof value === 'object'
|
||||
? JSON.stringify(value) === JSON.stringify(defaultValue)
|
||||
: value === defaultValue;
|
||||
if (same) continue;
|
||||
}
|
||||
|
||||
(out as Record<SyncedPreferenceKey, unknown>)[key] = value;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
68
src/lib/client/config/updates.ts
Normal file
68
src/lib/client/config/updates.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import type { AppConfigRow, AppConfigValues, SavedVoices } from '@/types/config';
|
||||
|
||||
export function getVoicePreferenceKey(provider: string, model: string): string {
|
||||
return `${provider}:${model}`;
|
||||
}
|
||||
|
||||
export function applyConfigUpdate<K extends keyof AppConfigValues>(
|
||||
currentConfig: Pick<AppConfigValues, 'ttsProvider' | 'ttsModel' | 'savedVoices'>,
|
||||
key: K,
|
||||
value: AppConfigValues[K],
|
||||
): {
|
||||
storagePatch: Partial<AppConfigRow>;
|
||||
syncPatch: Partial<AppConfigValues>;
|
||||
} {
|
||||
if (key === 'voice') {
|
||||
const voiceKey = getVoicePreferenceKey(currentConfig.ttsProvider, currentConfig.ttsModel);
|
||||
const updatedSavedVoices = { ...currentConfig.savedVoices, [voiceKey]: value as string };
|
||||
return {
|
||||
storagePatch: {
|
||||
savedVoices: updatedSavedVoices,
|
||||
voice: value as string,
|
||||
},
|
||||
syncPatch: {
|
||||
savedVoices: updatedSavedVoices,
|
||||
voice: value as string,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (key === 'ttsProvider' || key === 'ttsModel') {
|
||||
const newProvider = key === 'ttsProvider' ? (value as string) : currentConfig.ttsProvider;
|
||||
const newModel = key === 'ttsModel' ? (value as string) : currentConfig.ttsModel;
|
||||
const voiceKey = getVoicePreferenceKey(newProvider, newModel);
|
||||
const restoredVoice = currentConfig.savedVoices[voiceKey] || '';
|
||||
|
||||
return {
|
||||
storagePatch: {
|
||||
[key]: value as AppConfigValues[keyof AppConfigValues],
|
||||
voice: restoredVoice,
|
||||
} as Partial<AppConfigRow>,
|
||||
syncPatch: {
|
||||
[key]: value as AppConfigValues[keyof AppConfigValues],
|
||||
voice: restoredVoice,
|
||||
} as Partial<AppConfigValues>,
|
||||
};
|
||||
}
|
||||
|
||||
if (key === 'savedVoices') {
|
||||
const newSavedVoices = value as SavedVoices;
|
||||
return {
|
||||
storagePatch: {
|
||||
savedVoices: newSavedVoices,
|
||||
},
|
||||
syncPatch: {
|
||||
savedVoices: newSavedVoices,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
storagePatch: {
|
||||
[key]: value as AppConfigValues[keyof AppConfigValues],
|
||||
} as Partial<AppConfigRow>,
|
||||
syncPatch: {
|
||||
[key]: value,
|
||||
} as Partial<AppConfigValues>,
|
||||
};
|
||||
}
|
||||
48
src/lib/client/settings/tts-settings.ts
Normal file
48
src/lib/client/settings/tts-settings.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import {
|
||||
TTS_PROVIDER_DEFINITIONS,
|
||||
providerSupportsCustomModel,
|
||||
resolveProviderModels,
|
||||
type TtsModelDefinition,
|
||||
type TtsProviderDefinition,
|
||||
} from '@/lib/shared/tts-provider-catalog';
|
||||
|
||||
export interface ResolveTtsSettingsViewModelOptions {
|
||||
provider: string;
|
||||
apiKey?: string;
|
||||
modelValue: string;
|
||||
customModelInput: string;
|
||||
showAllDeepInfra: boolean;
|
||||
}
|
||||
|
||||
export interface TtsSettingsViewModel {
|
||||
providers: TtsProviderDefinition[];
|
||||
models: TtsModelDefinition[];
|
||||
supportsCustomModel: boolean;
|
||||
selectedModelId: string;
|
||||
canSubmit: boolean;
|
||||
}
|
||||
|
||||
export function resolveTtsSettingsViewModel({
|
||||
provider,
|
||||
apiKey,
|
||||
modelValue,
|
||||
customModelInput,
|
||||
showAllDeepInfra,
|
||||
}: ResolveTtsSettingsViewModelOptions): TtsSettingsViewModel {
|
||||
const models = resolveProviderModels(provider, {
|
||||
apiKey,
|
||||
showAllDeepInfra,
|
||||
});
|
||||
const supportsCustomModel = providerSupportsCustomModel(provider);
|
||||
const isPreset = models.some((model) => model.id === modelValue);
|
||||
const selectedModelId = isPreset ? modelValue : (supportsCustomModel ? 'custom' : (models[0]?.id ?? ''));
|
||||
const canSubmit = selectedModelId !== 'custom' || (supportsCustomModel && customModelInput.trim().length > 0);
|
||||
|
||||
return {
|
||||
providers: TTS_PROVIDER_DEFINITIONS,
|
||||
models,
|
||||
supportsCustomModel,
|
||||
selectedModelId,
|
||||
canSubmit,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import OpenAI from 'openai';
|
||||
import { SpeechCreateParams } from 'openai/resources/audio/speech.mjs';
|
||||
import { isKokoroModel } from '@/lib/shared/kokoro';
|
||||
import { supportsTtsInstructions } from '@/lib/shared/tts-provider-catalog';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
import { createHash } from 'crypto';
|
||||
import { access, readFile } from 'fs/promises';
|
||||
|
|
@ -83,7 +84,7 @@ function resolveTTSRequest(input: ServerTTSRequest): ResolvedServerTTSRequest {
|
|||
|
||||
const format = input.format || 'mp3';
|
||||
const speed = Number.isFinite(Number(input.speed)) ? Number(input.speed) : 1;
|
||||
const instructions = (model as string) === 'gpt-4o-mini-tts' && input.instructions
|
||||
const instructions = supportsTtsInstructions(model as string) && input.instructions
|
||||
? input.instructions
|
||||
: undefined;
|
||||
|
||||
|
|
|
|||
239
src/lib/shared/tts-provider-catalog.ts
Normal file
239
src/lib/shared/tts-provider-catalog.ts
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
import { isKokoroModel } from '@/lib/shared/kokoro';
|
||||
|
||||
export type TtsProviderId = 'custom-openai' | 'deepinfra' | 'openai';
|
||||
export type TtsVoiceSource = 'static' | 'deepinfra-api' | 'custom-openai-api';
|
||||
|
||||
export interface TtsModelDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TtsProviderDefinition {
|
||||
id: TtsProviderId;
|
||||
name: string;
|
||||
supportsCustomModel: boolean;
|
||||
models: (context?: ResolveProviderModelsContext) => TtsModelDefinition[];
|
||||
}
|
||||
|
||||
export interface ResolveProviderModelsContext {
|
||||
apiKey?: string;
|
||||
showAllDeepInfra?: boolean;
|
||||
}
|
||||
|
||||
export interface ResolveVoicesOptions {
|
||||
provider: string;
|
||||
model: string;
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
const OPENAI_MODELS: TtsModelDefinition[] = [
|
||||
{ id: 'tts-1', name: 'TTS-1' },
|
||||
{ id: 'tts-1-hd', name: 'TTS-1 HD' },
|
||||
{ id: 'gpt-4o-mini-tts', name: 'GPT-4o Mini TTS' },
|
||||
];
|
||||
|
||||
const CUSTOM_OPENAI_MODELS: TtsModelDefinition[] = [
|
||||
{ id: 'kokoro', name: 'Kokoro' },
|
||||
{ id: 'kitten-tts', name: 'KittenTTS' },
|
||||
{ id: 'orpheus', name: 'Orpheus' },
|
||||
{ id: 'custom', name: 'Other' },
|
||||
];
|
||||
|
||||
const DEEPINFRA_MODELS_FULL: TtsModelDefinition[] = [
|
||||
{ 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' },
|
||||
];
|
||||
|
||||
const DEEPINFRA_MODELS_LIMITED: TtsModelDefinition[] = [
|
||||
{ id: 'hexgrad/Kokoro-82M', name: 'hexgrad/Kokoro-82M' },
|
||||
];
|
||||
|
||||
const DEFAULT_MODELS: TtsModelDefinition[] = [{ id: 'tts-1', name: 'TTS-1' }];
|
||||
|
||||
export const OPENAI_DEFAULT_VOICES = ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'] as const;
|
||||
export const GPT4O_MINI_DEFAULT_VOICES = ['alloy', 'ash', 'coral', 'echo', 'fable', 'onyx', 'nova', 'sage', 'shimmer'] as const;
|
||||
export const CUSTOM_OPENAI_DEFAULT_VOICES = ['af_sarah', 'af_bella', 'af_nicole', 'am_adam', 'am_michael', 'bf_emma', 'bf_isabella', 'bm_george', 'bm_lewis'] as const;
|
||||
export const KOKORO_DEFAULT_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',
|
||||
] as const;
|
||||
export const ORPHEUS_DEFAULT_VOICES = ['tara', 'leah', 'jess', 'leo', 'dan', 'mia', 'zac'] as const;
|
||||
export const SESAME_DEFAULT_VOICES = ['conversational_a', 'conversational_b', 'read_speech_a', 'read_speech_b', 'read_speech_c', 'read_speech_d', 'none'] as const;
|
||||
|
||||
export const TTS_PROVIDER_DEFINITIONS: TtsProviderDefinition[] = [
|
||||
{
|
||||
id: 'custom-openai',
|
||||
name: 'Custom OpenAI-Like',
|
||||
supportsCustomModel: true,
|
||||
models: () => CUSTOM_OPENAI_MODELS,
|
||||
},
|
||||
{
|
||||
id: 'deepinfra',
|
||||
name: 'Deepinfra',
|
||||
supportsCustomModel: true,
|
||||
models: (context) => {
|
||||
if (!context?.showAllDeepInfra && !context?.apiKey) {
|
||||
return DEEPINFRA_MODELS_LIMITED;
|
||||
}
|
||||
return DEEPINFRA_MODELS_FULL;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'openai',
|
||||
name: 'OpenAI',
|
||||
supportsCustomModel: false,
|
||||
models: () => OPENAI_MODELS,
|
||||
},
|
||||
];
|
||||
|
||||
export function supportsTtsInstructions(model: string | null | undefined): boolean {
|
||||
return model === 'gpt-4o-mini-tts';
|
||||
}
|
||||
|
||||
export function getProviderDefinition(provider: string | null | undefined): TtsProviderDefinition | undefined {
|
||||
return TTS_PROVIDER_DEFINITIONS.find((definition) => definition.id === provider);
|
||||
}
|
||||
|
||||
export function resolveProviderModels(provider: string | null | undefined, context?: ResolveProviderModelsContext): TtsModelDefinition[] {
|
||||
return getProviderDefinition(provider)?.models(context) ?? DEFAULT_MODELS;
|
||||
}
|
||||
|
||||
export function providerSupportsCustomModel(provider: string | null | undefined): boolean {
|
||||
return getProviderDefinition(provider)?.supportsCustomModel ?? false;
|
||||
}
|
||||
|
||||
export function getDefaultVoices(provider: string, model: string): string[] {
|
||||
if (provider === 'openai') {
|
||||
if (supportsTtsInstructions(model)) {
|
||||
return [...GPT4O_MINI_DEFAULT_VOICES];
|
||||
}
|
||||
return [...OPENAI_DEFAULT_VOICES];
|
||||
}
|
||||
|
||||
if (provider === 'custom-openai') {
|
||||
if (isKokoroModel(model)) {
|
||||
return [...KOKORO_DEFAULT_VOICES];
|
||||
}
|
||||
return [...CUSTOM_OPENAI_DEFAULT_VOICES];
|
||||
}
|
||||
|
||||
if (provider === 'deepinfra') {
|
||||
if (model === 'hexgrad/Kokoro-82M') {
|
||||
return [...KOKORO_DEFAULT_VOICES];
|
||||
}
|
||||
if (model === 'canopylabs/orpheus-3b-0.1-ft') {
|
||||
return [...ORPHEUS_DEFAULT_VOICES];
|
||||
}
|
||||
if (model === 'sesame/csm-1b') {
|
||||
return [...SESAME_DEFAULT_VOICES];
|
||||
}
|
||||
if (model === 'ResembleAI/chatterbox') {
|
||||
return ['None'];
|
||||
}
|
||||
if (model === 'Zyphra/Zonos-v0.1-hybrid' || model === 'Zyphra/Zonos-v0.1-transformer') {
|
||||
return ['random'];
|
||||
}
|
||||
return [...CUSTOM_OPENAI_DEFAULT_VOICES];
|
||||
}
|
||||
|
||||
return [...OPENAI_DEFAULT_VOICES];
|
||||
}
|
||||
|
||||
export function resolveVoiceSource(provider: string, model: string): TtsVoiceSource {
|
||||
if (provider === 'deepinfra' && (
|
||||
model === 'ResembleAI/chatterbox' ||
|
||||
model === 'Zyphra/Zonos-v0.1-hybrid' ||
|
||||
model === 'Zyphra/Zonos-v0.1-transformer'
|
||||
)) {
|
||||
return 'deepinfra-api';
|
||||
}
|
||||
|
||||
if (provider === 'custom-openai') {
|
||||
return 'custom-openai-api';
|
||||
}
|
||||
|
||||
return 'static';
|
||||
}
|
||||
|
||||
async function fetchDeepinfraVoices(apiKey: string): Promise<string[]> {
|
||||
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();
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCustomOpenAiVoices(baseUrl: string, apiKey: string): Promise<string[] | null> {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/audio/voices`, {
|
||||
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 : null;
|
||||
} catch {
|
||||
console.log('Custom endpoint does not support voices, using defaults');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveVoices({ provider, model, apiKey = '', baseUrl = '' }: ResolveVoicesOptions): Promise<string[]> {
|
||||
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 && apiVoices.length > 0) {
|
||||
return apiVoices;
|
||||
}
|
||||
}
|
||||
|
||||
return defaultVoices;
|
||||
}
|
||||
95
tests/unit/tts-provider-catalog.spec.ts
Normal file
95
tests/unit/tts-provider-catalog.spec.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
getDefaultVoices,
|
||||
providerSupportsCustomModel,
|
||||
resolveProviderModels,
|
||||
supportsTtsInstructions,
|
||||
} from '../../src/lib/shared/tts-provider-catalog';
|
||||
import { applyConfigUpdate, getVoicePreferenceKey } from '../../src/lib/client/config/updates';
|
||||
import { buildSyncedPreferencePatch } from '../../src/lib/client/config/preferences';
|
||||
|
||||
test.describe('tts provider catalog', () => {
|
||||
test('resolves provider models with Deepinfra gating unchanged', () => {
|
||||
expect(resolveProviderModels('openai').map((model) => model.id)).toEqual([
|
||||
'tts-1',
|
||||
'tts-1-hd',
|
||||
'gpt-4o-mini-tts',
|
||||
]);
|
||||
|
||||
expect(resolveProviderModels('deepinfra', {
|
||||
showAllDeepInfra: false,
|
||||
apiKey: '',
|
||||
}).map((model) => model.id)).toEqual([
|
||||
'hexgrad/Kokoro-82M',
|
||||
]);
|
||||
|
||||
expect(resolveProviderModels('deepinfra', {
|
||||
showAllDeepInfra: false,
|
||||
apiKey: 'token',
|
||||
}).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');
|
||||
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.describe('config helpers', () => {
|
||||
test('persists voice preferences by provider/model pair', () => {
|
||||
expect(getVoicePreferenceKey('openai', 'tts-1')).toBe('openai:tts-1');
|
||||
|
||||
const voiceUpdate = applyConfigUpdate({
|
||||
ttsProvider: 'openai',
|
||||
ttsModel: 'tts-1',
|
||||
savedVoices: {},
|
||||
}, 'voice', 'alloy');
|
||||
expect(voiceUpdate.storagePatch).toEqual({
|
||||
savedVoices: { 'openai:tts-1': 'alloy' },
|
||||
voice: 'alloy',
|
||||
});
|
||||
|
||||
const providerUpdate = applyConfigUpdate({
|
||||
ttsProvider: 'openai',
|
||||
ttsModel: 'tts-1',
|
||||
savedVoices: {
|
||||
'deepinfra:hexgrad/Kokoro-82M': 'af_sarah',
|
||||
},
|
||||
}, 'ttsProvider', 'deepinfra');
|
||||
expect(providerUpdate.storagePatch).toEqual({
|
||||
ttsProvider: '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',
|
||||
});
|
||||
|
||||
expect(buildSyncedPreferencePatch({
|
||||
voiceSpeed: 1,
|
||||
ttsModel: 'kokoro',
|
||||
}, { nonDefaultOnly: true })).toEqual({});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue