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 { NextRequest, NextResponse } from 'next/server';
|
||||||
import { isKokoroModel } from '@/lib/shared/kokoro';
|
|
||||||
import { auth } from '@/lib/server/auth/auth';
|
import { auth } from '@/lib/server/auth/auth';
|
||||||
|
import { getDefaultVoices, resolveVoices } from '@/lib/shared/tts-provider-catalog';
|
||||||
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 [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
try {
|
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 openApiBaseUrl = req.headers.get('x-openai-base-url') || process.env.API_BASE;
|
||||||
const provider = req.headers.get('x-tts-provider') || 'openai';
|
const provider = req.headers.get('x-tts-provider') || 'openai';
|
||||||
const model = req.headers.get('x-tts-model') || 'tts-1';
|
const model = req.headers.get('x-tts-model') || 'tts-1';
|
||||||
|
const voices = await resolveVoices({
|
||||||
// For OpenAI provider, use default voices (no API call needed)
|
provider,
|
||||||
if (provider === 'openai') {
|
model,
|
||||||
return NextResponse.json({ voices: getDefaultVoices(provider, model) });
|
apiKey: openApiKey,
|
||||||
}
|
baseUrl: openApiBaseUrl,
|
||||||
|
});
|
||||||
// For Deepinfra provider with specific models that need API fetching
|
return NextResponse.json({ voices });
|
||||||
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) });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in voices endpoint:', error);
|
console.error('Error in voices endpoint:', error);
|
||||||
const provider = req.headers.get('x-tts-provider') || 'openai';
|
const provider = req.headers.get('x-tts-provider') || 'openai';
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { LoadingSpinner } from '@/components/Spinner';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
import { useTTS } from '@/contexts/TTSContext';
|
import { useTTS } from '@/contexts/TTSContext';
|
||||||
import { VoicesControlBase } from '@/components/player/VoicesControlBase';
|
import { VoicesControlBase } from '@/components/player/VoicesControlBase';
|
||||||
|
import { supportsTtsInstructions } from '@/lib/shared/tts-provider-catalog';
|
||||||
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
|
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
|
||||||
import {
|
import {
|
||||||
getAudiobookStatus,
|
getAudiobookStatus,
|
||||||
|
|
@ -115,7 +116,7 @@ export function AudiobookExportModal({
|
||||||
nativeSpeed,
|
nativeSpeed,
|
||||||
postSpeed,
|
postSpeed,
|
||||||
format,
|
format,
|
||||||
ttsInstructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined,
|
ttsInstructions: supportsTtsInstructions(ttsModel) ? ttsInstructions : undefined,
|
||||||
};
|
};
|
||||||
}, [savedSettings, audiobookVoice, configVoice, availableVoices, ttsProvider, ttsModel, ttsInstructions, nativeSpeed, postSpeed, format]);
|
}, [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 { deleteDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents';
|
||||||
import { cacheStoredDocumentFromBytes, clearDocumentCache } from '@/lib/client/cache/documents';
|
import { cacheStoredDocumentFromBytes, clearDocumentCache } from '@/lib/client/cache/documents';
|
||||||
import { clearAllDocumentPreviewCaches, clearInMemoryDocumentPreviewCache } from '@/lib/client/cache/previews';
|
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 enableDestructiveDelete = process.env.NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS !== 'false';
|
||||||
const showAllDeepInfra = process.env.NEXT_PUBLIC_SHOW_ALL_DEEPINFRA_MODELS !== '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 router = useRouter();
|
||||||
const isBusy = isImportingLibrary;
|
const isBusy = isImportingLibrary;
|
||||||
|
|
||||||
const ttsProviders = useMemo(() => [
|
const {
|
||||||
{ id: 'custom-openai', name: 'Custom OpenAI-Like' },
|
providers: ttsProviders,
|
||||||
{ id: 'deepinfra', name: 'Deepinfra' },
|
models: ttsModels,
|
||||||
{ id: 'openai', name: 'OpenAI' }
|
supportsCustomModel: supportsCustom,
|
||||||
], []);
|
selectedModelId,
|
||||||
|
canSubmit,
|
||||||
const ttsModels = useMemo(() => {
|
} = useMemo(() => resolveTtsSettingsViewModel({
|
||||||
switch (localTTSProvider) {
|
provider: localTTSProvider,
|
||||||
case 'openai':
|
apiKey: localApiKey,
|
||||||
return [
|
modelValue,
|
||||||
{ id: 'tts-1', name: 'TTS-1' },
|
customModelInput,
|
||||||
{ id: 'tts-1-hd', name: 'TTS-1 HD' },
|
showAllDeepInfra,
|
||||||
{ id: 'gpt-4o-mini-tts', name: 'GPT-4o Mini TTS' }
|
}), [localTTSProvider, localApiKey, modelValue, customModelInput]);
|
||||||
];
|
|
||||||
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 checkFirstVist = useCallback(async () => {
|
const checkFirstVist = useCallback(async () => {
|
||||||
const appConfig = await getAppConfig();
|
const appConfig = await getAppConfig();
|
||||||
|
|
@ -672,7 +629,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{modelValue === 'gpt-4o-mini-tts' && (
|
{supportsTtsInstructions(modelValue) && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="block text-sm font-medium text-foreground">TTS Instructions</label>
|
<label className="block text-sm font-medium text-foreground">TTS Instructions</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import { scheduleUserPreferencesSync, cancelPendingPreferenceSync, getUserPrefer
|
||||||
import { SYNCED_PREFERENCE_KEYS, type SyncedPreferenceKey, type SyncedPreferencesPatch } from '@/types/user-state';
|
import { SYNCED_PREFERENCE_KEYS, type SyncedPreferenceKey, type SyncedPreferencesPatch } from '@/types/user-state';
|
||||||
import { useAuthSession } from '@/hooks/useAuthSession';
|
import { useAuthSession } from '@/hooks/useAuthSession';
|
||||||
import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
|
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';
|
import toast from 'react-hot-toast';
|
||||||
export type { ViewType } from '@/types/config';
|
export type { ViewType } from '@/types/config';
|
||||||
|
|
||||||
|
|
@ -61,9 +63,6 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
const { data: sessionData, isPending: isSessionPending } = useAuthSession();
|
const { data: sessionData, isPending: isSessionPending } = useAuthSession();
|
||||||
const sessionKey = sessionData?.user?.id ?? 'no-session';
|
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>) => {
|
const queueSyncedPreferencePatch = useCallback((patch: Partial<AppConfigValues>) => {
|
||||||
if (!authEnabled || sessionKey === 'no-session') return;
|
if (!authEnabled || sessionKey === 'no-session') return;
|
||||||
|
|
||||||
|
|
@ -85,28 +84,6 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
};
|
};
|
||||||
}, [sessionKey]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const handler = (event: Event) => {
|
const handler = (event: Event) => {
|
||||||
const detail = (event as CustomEvent<{ status?: string; ms?: number }>).detail;
|
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]) => {
|
const updateConfigKey = async <K extends keyof AppConfigValues>(key: K, value: AppConfigValues[K]) => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
const { storagePatch, syncPatch } = applyConfigUpdate({
|
||||||
|
ttsProvider,
|
||||||
|
ttsModel,
|
||||||
|
savedVoices,
|
||||||
|
}, key, value);
|
||||||
|
|
||||||
// Special handling for voice - only update savedVoices
|
await updateAppConfig(storagePatch);
|
||||||
if (key === 'voice') {
|
if (key === 'voice' || key === 'ttsProvider' || key === 'ttsModel' || key === 'savedVoices') {
|
||||||
const voiceKey = getVoiceKey(ttsProvider, ttsModel);
|
queueSyncedPreferencePatch(syncPatch);
|
||||||
const updatedSavedVoices = { ...savedVoices, [voiceKey]: value as string };
|
} else if (syncedPreferenceKeys.has(String(key))) {
|
||||||
await updateAppConfig({
|
queueSyncedPreferencePatch(syncPatch);
|
||||||
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>);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error updating config key ${String(key)}:`, 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 { SpineItem } from 'epubjs/types/section';
|
||||||
import type { Book, Rendition } from 'epubjs';
|
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 { setLastDocumentLocation } from '@/lib/client/dexie';
|
||||||
import { scheduleDocumentProgressSync } from '@/lib/client/api/user-state';
|
import { scheduleDocumentProgressSync } from '@/lib/client/api/user-state';
|
||||||
import { getDocumentMetadata } from '@/lib/client/api/documents';
|
import { getDocumentMetadata } from '@/lib/client/api/documents';
|
||||||
|
|
@ -25,18 +27,13 @@ import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
|
||||||
import { createRangeCfi } from '@/lib/client/epub';
|
import { createRangeCfi } from '@/lib/client/epub';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import { useConfig } from './ConfigContext';
|
import { useConfig } from './ConfigContext';
|
||||||
import { withRetry, getAudiobookStatus, createAudiobookChapter } from '@/lib/client/api/audiobooks';
|
|
||||||
import { CmpStr } from 'cmpstr';
|
import { CmpStr } from 'cmpstr';
|
||||||
import type {
|
import type {
|
||||||
TTSSentenceAlignment,
|
TTSSentenceAlignment,
|
||||||
TTSAudiobookFormat,
|
TTSAudiobookFormat,
|
||||||
TTSAudiobookChapter,
|
TTSAudiobookChapter,
|
||||||
} from '@/types/tts';
|
} from '@/types/tts';
|
||||||
import type {
|
import type { AudiobookGenerationSettings } from '@/types/client';
|
||||||
TTSRequestHeaders,
|
|
||||||
TTSRetryOptions,
|
|
||||||
AudiobookGenerationSettings,
|
|
||||||
} from '@/types/client';
|
|
||||||
|
|
||||||
interface EPUBContextType {
|
interface EPUBContextType {
|
||||||
currDocData: ArrayBuffer | undefined;
|
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
|
* Creates a complete audiobook by processing all text through NLP and TTS
|
||||||
*/
|
*/
|
||||||
|
|
@ -349,179 +351,23 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
settings?: AudiobookGenerationSettings
|
settings?: AudiobookGenerationSettings
|
||||||
): Promise<string> => {
|
): Promise<string> => {
|
||||||
try {
|
try {
|
||||||
const sections = await extractBookText();
|
return await runAudiobookGeneration({
|
||||||
if (!sections.length) throw new Error('No text content found in book');
|
adapter: audiobookAdapter,
|
||||||
|
apiKey,
|
||||||
const effectiveProvider = settings?.ttsProvider ?? ttsProvider;
|
baseUrl,
|
||||||
const effectiveFormat = settings?.format ?? format;
|
defaultProvider: ttsProvider,
|
||||||
|
onProgress,
|
||||||
// Calculate total length for accurate progress tracking
|
signal,
|
||||||
const totalLength = sections.reduce((sum, section) => sum + section.text.trim().length, 0);
|
onChapterComplete,
|
||||||
let processedLength = 0;
|
providedBookId,
|
||||||
let bookId: string = providedBookId || '';
|
format,
|
||||||
|
settings,
|
||||||
// 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;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating audiobook:', error);
|
console.error('Error creating audiobook:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}, [extractBookText, apiKey, baseUrl, ttsProvider]);
|
}, [audiobookAdapter, apiKey, baseUrl, ttsProvider]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Regenerates a specific chapter of the audiobook
|
* Regenerates a specific chapter of the audiobook
|
||||||
|
|
@ -534,82 +380,17 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
settings?: AudiobookGenerationSettings
|
settings?: AudiobookGenerationSettings
|
||||||
): Promise<TTSAudiobookChapter> => {
|
): Promise<TTSAudiobookChapter> => {
|
||||||
try {
|
try {
|
||||||
const sections = await extractBookText();
|
return await regenerateAudiobookChapter({
|
||||||
if (chapterIndex >= sections.length) {
|
adapter: audiobookAdapter,
|
||||||
throw new Error('Invalid chapter index');
|
chapterIndex,
|
||||||
}
|
bookId,
|
||||||
|
format,
|
||||||
const effectiveProvider = settings?.ttsProvider ?? ttsProvider;
|
signal,
|
||||||
const effectiveFormat = settings?.format ?? format;
|
apiKey,
|
||||||
|
baseUrl,
|
||||||
const section = sections[chapterIndex];
|
defaultProvider: ttsProvider,
|
||||||
const trimmedText = section.text.trim();
|
settings,
|
||||||
|
});
|
||||||
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;
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'))) {
|
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'))) {
|
||||||
throw new Error('Chapter regeneration cancelled');
|
throw new Error('Chapter regeneration cancelled');
|
||||||
|
|
@ -617,7 +398,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
console.error('Error regenerating chapter:', error);
|
console.error('Error regenerating chapter:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}, [extractBookText, apiKey, baseUrl, ttsProvider]);
|
}, [audiobookAdapter, apiKey, baseUrl, ttsProvider]);
|
||||||
|
|
||||||
const setRendition = useCallback((rendition: Rendition) => {
|
const setRendition = useCallback((rendition: Rendition) => {
|
||||||
bookRef.current = rendition.book;
|
bookRef.current = rendition.book;
|
||||||
|
|
|
||||||
|
|
@ -28,11 +28,11 @@ import {
|
||||||
import type { PDFDocumentProxy } from 'pdfjs-dist';
|
import type { PDFDocumentProxy } from 'pdfjs-dist';
|
||||||
|
|
||||||
import { getDocumentMetadata } from '@/lib/client/api/documents';
|
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 { ensureCachedDocument } from '@/lib/client/cache/documents';
|
||||||
import { useTTS } from '@/contexts/TTSContext';
|
import { useTTS } from '@/contexts/TTSContext';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
import { normalizeTextForTts } from '@/lib/shared/nlp';
|
|
||||||
import { withRetry, getAudiobookStatus, createAudiobookChapter } from '@/lib/client/api/audiobooks';
|
|
||||||
import {
|
import {
|
||||||
extractTextFromPDF,
|
extractTextFromPDF,
|
||||||
highlightPattern,
|
highlightPattern,
|
||||||
|
|
@ -46,11 +46,7 @@ import type {
|
||||||
TTSAudiobookFormat,
|
TTSAudiobookFormat,
|
||||||
TTSAudiobookChapter,
|
TTSAudiobookChapter,
|
||||||
} from '@/types/tts';
|
} from '@/types/tts';
|
||||||
import type {
|
import type { AudiobookGenerationSettings } from '@/types/client';
|
||||||
TTSRequestHeaders,
|
|
||||||
TTSRetryOptions,
|
|
||||||
AudiobookGenerationSettings,
|
|
||||||
} from '@/types/client';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface defining all available methods and properties in the PDF context
|
* 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 [currDocText, setCurrDocText] = useState<string>();
|
||||||
const [pdfDocument, setPdfDocument] = useState<PDFDocumentProxy>();
|
const [pdfDocument, setPdfDocument] = useState<PDFDocumentProxy>();
|
||||||
const [isAudioCombining] = useState(false);
|
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 pageTextCacheRef = useRef<Map<number, string>>(new Map());
|
||||||
const [currDocPage, setCurrDocPage] = useState<number>(currDocPageNumber);
|
const [currDocPage, setCurrDocPage] = useState<number>(currDocPageNumber);
|
||||||
|
|
||||||
|
|
@ -409,169 +415,23 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
settings?: AudiobookGenerationSettings
|
settings?: AudiobookGenerationSettings
|
||||||
): Promise<string> => {
|
): Promise<string> => {
|
||||||
try {
|
try {
|
||||||
if (!pdfDocument) {
|
return await runAudiobookGeneration({
|
||||||
throw new Error('No PDF document loaded');
|
adapter: audiobookAdapter,
|
||||||
}
|
apiKey,
|
||||||
|
baseUrl,
|
||||||
const effectiveProvider = settings?.ttsProvider ?? ttsProvider;
|
defaultProvider: ttsProvider,
|
||||||
const effectiveFormat = settings?.format ?? format;
|
onProgress,
|
||||||
|
signal,
|
||||||
// First pass: extract and measure all text
|
onChapterComplete,
|
||||||
const textPerPage: string[] = [];
|
providedBookId,
|
||||||
let totalLength = 0;
|
format,
|
||||||
|
settings,
|
||||||
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;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating audiobook:', error);
|
console.error('Error creating audiobook:', error);
|
||||||
throw 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
|
* Regenerates a specific chapter (page) of the PDF audiobook
|
||||||
|
|
@ -584,94 +444,17 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
settings?: AudiobookGenerationSettings
|
settings?: AudiobookGenerationSettings
|
||||||
): Promise<TTSAudiobookChapter> => {
|
): Promise<TTSAudiobookChapter> => {
|
||||||
try {
|
try {
|
||||||
if (!pdfDocument) {
|
return await regenerateAudiobookChapter({
|
||||||
throw new Error('No PDF document loaded');
|
adapter: audiobookAdapter,
|
||||||
}
|
chapterIndex,
|
||||||
|
bookId,
|
||||||
const effectiveProvider = settings?.ttsProvider ?? ttsProvider;
|
format,
|
||||||
const effectiveFormat = settings?.format ?? format;
|
signal,
|
||||||
|
apiKey,
|
||||||
// IMPORTANT: Chapter indices are based on non-empty pages used during generation.
|
baseUrl,
|
||||||
// Build a mapping of "chapterIndex" -> actual PDF page number (1-based).
|
defaultProvider: ttsProvider,
|
||||||
const nonEmptyPages: number[] = [];
|
settings,
|
||||||
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
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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) {
|
} catch (error) {
|
||||||
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'))) {
|
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'))) {
|
||||||
throw new Error('Page regeneration cancelled');
|
throw new Error('Page regeneration cancelled');
|
||||||
|
|
@ -679,7 +462,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
console.error('Error regenerating page:', error);
|
console.error('Error regenerating page:', error);
|
||||||
throw 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
|
* 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 { withRetry, generateTTS, alignAudio } from '@/lib/client/api/audiobooks';
|
||||||
import { preprocessSentenceForAudio, splitTextToTtsBlocks, splitTextToTtsBlocksEPUB } from '@/lib/shared/nlp';
|
import { preprocessSentenceForAudio, splitTextToTtsBlocks, splitTextToTtsBlocksEPUB } from '@/lib/shared/nlp';
|
||||||
import { isKokoroModel } from '@/lib/shared/kokoro';
|
import { isKokoroModel } from '@/lib/shared/kokoro';
|
||||||
|
import { supportsTtsInstructions } from '@/lib/shared/tts-provider-catalog';
|
||||||
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
|
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
|
||||||
import type {
|
import type {
|
||||||
TTSLocation,
|
TTSLocation,
|
||||||
|
|
@ -1099,7 +1100,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
speed,
|
speed,
|
||||||
format: 'mp3',
|
format: 'mp3',
|
||||||
model: ttsModel,
|
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.
|
// Allow one narrow client retry for transient browser->/api/tts transport failures.
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,7 @@
|
||||||
|
|
||||||
import { useState, useCallback, useRef } from 'react';
|
import { useState, useCallback, useRef } from 'react';
|
||||||
import { getVoices } from '@/lib/client/api/audiobooks';
|
import { getVoices } from '@/lib/client/api/audiobooks';
|
||||||
|
import { getDefaultVoices } from '@/lib/shared/tts-provider-catalog';
|
||||||
const DEFAULT_VOICES = ['alloy', 'ash', 'coral', 'echo', 'fable', 'onyx', 'nova', 'sage', 'shimmer'];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom hook for managing TTS voices
|
* Custom hook for managing TTS voices
|
||||||
|
|
@ -36,12 +35,11 @@ export function useVoiceManagement(
|
||||||
|
|
||||||
// Ignore stale responses from older provider/model fetches.
|
// Ignore stale responses from older provider/model fetches.
|
||||||
if (fetchSeq !== fetchSeqRef.current) return;
|
if (fetchSeq !== fetchSeqRef.current) return;
|
||||||
setAvailableVoices(data.voices || DEFAULT_VOICES);
|
setAvailableVoices(data.voices || getDefaultVoices(ttsProvider || 'openai', ttsModel || 'tts-1'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching voices:', error);
|
console.error('Error fetching voices:', error);
|
||||||
if (fetchSeq !== fetchSeqRef.current) return;
|
if (fetchSeq !== fetchSeqRef.current) return;
|
||||||
// Set available voices to default openai voices
|
setAvailableVoices(getDefaultVoices(ttsProvider || 'openai', ttsModel || 'tts-1'));
|
||||||
setAvailableVoices(DEFAULT_VOICES);
|
|
||||||
}
|
}
|
||||||
}, [apiKey, baseUrl, ttsProvider, ttsModel]);
|
}, [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 OpenAI from 'openai';
|
||||||
import { SpeechCreateParams } from 'openai/resources/audio/speech.mjs';
|
import { SpeechCreateParams } from 'openai/resources/audio/speech.mjs';
|
||||||
import { isKokoroModel } from '@/lib/shared/kokoro';
|
import { isKokoroModel } from '@/lib/shared/kokoro';
|
||||||
|
import { supportsTtsInstructions } from '@/lib/shared/tts-provider-catalog';
|
||||||
import { LRUCache } from 'lru-cache';
|
import { LRUCache } from 'lru-cache';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import { access, readFile } from 'fs/promises';
|
import { access, readFile } from 'fs/promises';
|
||||||
|
|
@ -83,7 +84,7 @@ function resolveTTSRequest(input: ServerTTSRequest): ResolvedServerTTSRequest {
|
||||||
|
|
||||||
const format = input.format || 'mp3';
|
const format = input.format || 'mp3';
|
||||||
const speed = Number.isFinite(Number(input.speed)) ? Number(input.speed) : 1;
|
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
|
? input.instructions
|
||||||
: undefined;
|
: 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