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:
Richard R 2026-04-04 17:53:57 -06:00
parent 4a985b3bf1
commit d6ae2baa6f
17 changed files with 960 additions and 786 deletions

View file

@ -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';

View file

@ -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]);

View file

@ -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

View file

@ -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);

View file

@ -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;

View file

@ -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

View file

@ -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.

View file

@ -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]);

View 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;
},
};
}

View 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;
},
};
}

View 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,
);
}

View 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;
}

View 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>,
};
}

View 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,
};
}

View file

@ -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;

View 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;
}

View 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({});
});
});