Merge pull request #84 from richardr1126/clean-code-refactor
Some checks failed
Version Docs on Tag / version-docs (push) Has been cancelled
Create and publish Docker images / prepare (push) Has been cancelled
Create and publish Docker images / build (amd64, linux/amd64, ubuntu-24.04) (push) Has been cancelled
Create and publish Docker images / build (arm64, linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Create and publish Docker images / merge (push) Has been cancelled
Some checks failed
Version Docs on Tag / version-docs (push) Has been cancelled
Create and publish Docker images / prepare (push) Has been cancelled
Create and publish Docker images / build (amd64, linux/amd64, ubuntu-24.04) (push) Has been cancelled
Create and publish Docker images / build (arm64, linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Create and publish Docker images / merge (push) Has been cancelled
Centralized TTS provider/catalog and resolver utilities. New audiobook generation pipeline with EPUB and PDF source adapters. TTS settings view model and config-update helpers for synced preferences. Improvements Generalized TTS instruction support and provider/model-aware voice defaults. Audiobook export delegated to pipeline for consistent progress, retry, and cancel handling. UI test IDs added to privacy and migration modals. Tests New unit tests for TTS catalog and config helpers; export readiness and onboarding dismissal added to tests. Chores Package version bumped to 2.1.1.
This commit is contained in:
commit
6d33de83c2
22 changed files with 1165 additions and 839 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "openreader",
|
||||
"version": "v2.1.0",
|
||||
"version": "2.1.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "node scripts/openreader-entrypoint.mjs -- pnpm dev:raw",
|
||||
|
|
|
|||
|
|
@ -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,58 +14,15 @@ 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) {
|
||||
// This catch mainly guards auth/session access failures; voice resolution itself falls back internally.
|
||||
console.error('Error in voices endpoint:', error);
|
||||
const provider = req.headers.get('x-tts-provider') || 'openai';
|
||||
const model = req.headers.get('x-tts-model') || 'tts-1';
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ export function PrivacyModal({ onAccept }: PrivacyModalProps) {
|
|||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<DialogPanel className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
|
||||
<DialogPanel data-testid="privacy-modal" className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
|
||||
<DialogTitle
|
||||
as="h3"
|
||||
className="text-lg font-semibold leading-6 text-foreground"
|
||||
|
|
@ -110,6 +110,7 @@ export function PrivacyModal({ onAccept }: PrivacyModalProps) {
|
|||
<div className="flex items-start gap-3 rounded-lg border border-offbase p-3 bg-offbase/20">
|
||||
<div className="flex h-6 items-center">
|
||||
<input
|
||||
data-testid="privacy-agree-checkbox"
|
||||
id="privacy-agree"
|
||||
type="checkbox"
|
||||
checked={agreed}
|
||||
|
|
@ -129,6 +130,7 @@ export function PrivacyModal({ onAccept }: PrivacyModalProps) {
|
|||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
data-testid="privacy-continue-button"
|
||||
type="button"
|
||||
disabled={!agreed}
|
||||
className="inline-flex justify-center rounded-lg bg-accent px-4 py-2 text-sm
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -190,12 +147,15 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
|||
checkFirstVist().catch((err) => {
|
||||
console.error('First visit check failed:', err);
|
||||
});
|
||||
}, [checkFirstVist]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalApiKey(apiKey);
|
||||
setLocalBaseUrl(baseUrl);
|
||||
setLocalTTSProvider(ttsProvider);
|
||||
setModelValue(ttsModel);
|
||||
setLocalTTSInstructions(ttsInstructions);
|
||||
}, [apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, checkFirstVist]);
|
||||
}, [apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authEnabled) {
|
||||
|
|
@ -445,7 +405,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
|||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<DialogPanel className="w-full max-w-3xl transform rounded-2xl bg-base text-left align-middle shadow-xl transition-all overflow-hidden">
|
||||
<DialogPanel data-testid="settings-modal" className="w-full max-w-3xl transform rounded-2xl bg-base text-left align-middle shadow-xl transition-all overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-offbase">
|
||||
<DialogTitle as="h3" className="text-lg font-semibold leading-6 text-foreground">
|
||||
|
|
@ -672,7 +632,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
|
||||
|
|
@ -700,6 +660,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
|||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="settings-save-button"
|
||||
type="button"
|
||||
className={`${btnPrimary} px-4 py-2`}
|
||||
disabled={!canSubmit}
|
||||
|
|
|
|||
|
|
@ -197,7 +197,7 @@ export function DexieMigrationModal() {
|
|||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<DialogPanel className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
|
||||
<DialogPanel data-testid="migration-modal" className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
|
||||
<DialogTitle as="h3" className="text-lg font-semibold leading-6 text-foreground mb-4">
|
||||
{title}
|
||||
</DialogTitle>
|
||||
|
|
@ -222,6 +222,7 @@ export function DexieMigrationModal() {
|
|||
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<Button
|
||||
data-testid="migration-skip-button"
|
||||
onClick={handleSkip}
|
||||
disabled={isUploading}
|
||||
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -280,7 +257,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [isDBReady, authEnabled, appConfig, buildSyncedPreferencePatch, isSessionPending, sessionKey]);
|
||||
}, [isDBReady, authEnabled, appConfig, isSessionPending, sessionKey]);
|
||||
|
||||
// Destructure for convenience and to match context shape
|
||||
const {
|
||||
|
|
@ -342,53 +319,21 @@ 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' ||
|
||||
syncedPreferenceKeys.has(String(key))
|
||||
) {
|
||||
queueSyncedPreferencePatch(syncPatch);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error updating config key ${String(key)}:`, error);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ import type { NavItem } from 'epubjs';
|
|||
import type { SpineItem } from 'epubjs/types/section';
|
||||
import type { Book, Rendition } from 'epubjs';
|
||||
|
||||
import { createEpubAudiobookSourceAdapter } from '@/lib/client/audiobooks/adapters/epub';
|
||||
import { regenerateAudiobookChapter, runAudiobookGeneration } from '@/lib/client/audiobooks/pipeline';
|
||||
import { setLastDocumentLocation } from '@/lib/client/dexie';
|
||||
import { scheduleDocumentProgressSync } from '@/lib/client/api/user-state';
|
||||
import { getDocumentMetadata } from '@/lib/client/api/documents';
|
||||
|
|
@ -25,18 +27,13 @@ import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
|
|||
import { createRangeCfi } from '@/lib/client/epub';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useConfig } from './ConfigContext';
|
||||
import { withRetry, getAudiobookStatus, createAudiobookChapter } from '@/lib/client/api/audiobooks';
|
||||
import { CmpStr } from 'cmpstr';
|
||||
import type {
|
||||
TTSSentenceAlignment,
|
||||
TTSAudiobookFormat,
|
||||
TTSAudiobookChapter,
|
||||
} from '@/types/tts';
|
||||
import type {
|
||||
TTSRequestHeaders,
|
||||
TTSRetryOptions,
|
||||
AudiobookGenerationSettings,
|
||||
} from '@/types/client';
|
||||
import type { AudiobookGenerationSettings } from '@/types/client';
|
||||
|
||||
interface EPUBContextType {
|
||||
currDocData: ArrayBuffer | undefined;
|
||||
|
|
@ -337,6 +334,11 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
}
|
||||
}, []);
|
||||
|
||||
const audiobookAdapter = useMemo(() => createEpubAudiobookSourceAdapter({
|
||||
extractBookText,
|
||||
getTocItems: () => tocRef.current || [],
|
||||
}), [extractBookText]);
|
||||
|
||||
/**
|
||||
* Creates a complete audiobook by processing all text through NLP and TTS
|
||||
*/
|
||||
|
|
@ -349,179 +351,23 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
settings?: AudiobookGenerationSettings
|
||||
): Promise<string> => {
|
||||
try {
|
||||
const sections = await extractBookText();
|
||||
if (!sections.length) throw new Error('No text content found in book');
|
||||
|
||||
const effectiveProvider = settings?.ttsProvider ?? ttsProvider;
|
||||
const effectiveFormat = settings?.format ?? format;
|
||||
|
||||
// Calculate total length for accurate progress tracking
|
||||
const totalLength = sections.reduce((sum, section) => sum + section.text.trim().length, 0);
|
||||
let processedLength = 0;
|
||||
let bookId: string = providedBookId || '';
|
||||
|
||||
// Get TOC for chapter titles
|
||||
const chapters = tocRef.current || [];
|
||||
|
||||
// If we have a bookId, check for existing chapters to determine which indices already exist
|
||||
const existingIndices = new Set<number>();
|
||||
if (bookId) {
|
||||
try {
|
||||
const existingData = await getAudiobookStatus(bookId);
|
||||
if (existingData.chapters && existingData.chapters.length > 0) {
|
||||
for (const ch of existingData.chapters) {
|
||||
existingIndices.add(ch.index);
|
||||
}
|
||||
// Log smallest missing index for visibility
|
||||
let nextMissing = 0;
|
||||
while (existingIndices.has(nextMissing)) nextMissing++;
|
||||
console.log(`Resuming; next missing chapter index is ${nextMissing}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking existing chapters:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a map of section hrefs to their chapter titles
|
||||
const sectionTitleMap = new Map<string, string>();
|
||||
|
||||
// First, loop through all chapters to create the mapping
|
||||
for (const chapter of chapters) {
|
||||
if (!chapter.href) continue;
|
||||
const chapterBaseHref = chapter.href.split('#')[0];
|
||||
const chapterTitle = chapter.label.trim();
|
||||
|
||||
// For each chapter, find all matching sections
|
||||
for (const section of sections) {
|
||||
const sectionHref = section.href;
|
||||
const sectionBaseHref = sectionHref.split('#')[0];
|
||||
|
||||
// If this section matches this chapter, map it
|
||||
if (sectionHref === chapter.href || sectionBaseHref === chapterBaseHref) {
|
||||
sectionTitleMap.set(sectionHref, chapterTitle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process each section
|
||||
for (let i = 0; i < sections.length; i++) {
|
||||
// Check for abort at the start of iteration
|
||||
if (signal?.aborted) {
|
||||
console.log('Generation cancelled by user');
|
||||
if (bookId) {
|
||||
return bookId; // Return bookId with partial progress
|
||||
}
|
||||
throw new Error('Audiobook generation cancelled');
|
||||
}
|
||||
|
||||
const section = sections[i];
|
||||
const trimmedText = section.text.trim();
|
||||
if (!trimmedText) continue;
|
||||
|
||||
// Skip chapters that already exist on disk (supports non-contiguous indices)
|
||||
if (existingIndices.has(i)) {
|
||||
processedLength += trimmedText.length;
|
||||
onProgress((processedLength / totalLength) * 100);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const reqHeaders: TTSRequestHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
'x-openai-key': apiKey,
|
||||
'x-openai-base-url': baseUrl,
|
||||
'x-tts-provider': effectiveProvider,
|
||||
};
|
||||
|
||||
// Allow one narrow client retry for transient browser->/api/audiobook/chapter transport failures.
|
||||
// HTTP failures are not retried client-side.
|
||||
const retryOptions: TTSRetryOptions = {
|
||||
maxRetries: 2,
|
||||
initialDelay: 300,
|
||||
maxDelay: 300,
|
||||
};
|
||||
|
||||
// Get the chapter title from our pre-computed map
|
||||
let chapterTitle = sectionTitleMap.get(section.href);
|
||||
|
||||
// If no chapter title found, use index-based naming
|
||||
if (!chapterTitle) {
|
||||
chapterTitle = `Chapter ${i + 1}`;
|
||||
}
|
||||
|
||||
const chapter = await withRetry(
|
||||
async () => {
|
||||
// Check for abort before starting chapter request
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
|
||||
return await createAudiobookChapter({
|
||||
chapterTitle,
|
||||
text: trimmedText,
|
||||
bookId,
|
||||
format: effectiveFormat,
|
||||
chapterIndex: i,
|
||||
settings
|
||||
}, reqHeaders, signal);
|
||||
},
|
||||
retryOptions
|
||||
);
|
||||
|
||||
// Check for abort before sending to server
|
||||
if (signal?.aborted) {
|
||||
console.log('Generation cancelled before saving chapter');
|
||||
if (bookId) {
|
||||
return bookId;
|
||||
}
|
||||
throw new Error('Audiobook generation cancelled');
|
||||
}
|
||||
|
||||
if (!bookId) {
|
||||
bookId = chapter.bookId!;
|
||||
}
|
||||
|
||||
// Notify about completed chapter
|
||||
if (onChapterComplete) {
|
||||
onChapterComplete(chapter);
|
||||
}
|
||||
|
||||
processedLength += trimmedText.length;
|
||||
onProgress((processedLength / totalLength) * 100);
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'))) {
|
||||
console.log('TTS request aborted, returning partial progress');
|
||||
if (bookId) {
|
||||
return bookId; // Return with partial progress
|
||||
}
|
||||
throw new Error('Audiobook generation cancelled');
|
||||
}
|
||||
console.error('Error processing section:', error);
|
||||
|
||||
// Notify about error
|
||||
if (onChapterComplete) {
|
||||
onChapterComplete({
|
||||
index: i,
|
||||
title: sectionTitleMap.get(section.href) || `Chapter ${i + 1}`,
|
||||
status: 'error',
|
||||
bookId,
|
||||
format: effectiveFormat
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!bookId) {
|
||||
throw new Error('No audio was generated from the book content');
|
||||
}
|
||||
|
||||
return bookId;
|
||||
return await runAudiobookGeneration({
|
||||
adapter: audiobookAdapter,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
defaultProvider: ttsProvider,
|
||||
onProgress,
|
||||
signal,
|
||||
onChapterComplete,
|
||||
providedBookId,
|
||||
format,
|
||||
settings,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating audiobook:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [extractBookText, apiKey, baseUrl, ttsProvider]);
|
||||
}, [audiobookAdapter, apiKey, baseUrl, ttsProvider]);
|
||||
|
||||
/**
|
||||
* Regenerates a specific chapter of the audiobook
|
||||
|
|
@ -534,82 +380,17 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
settings?: AudiobookGenerationSettings
|
||||
): Promise<TTSAudiobookChapter> => {
|
||||
try {
|
||||
const sections = await extractBookText();
|
||||
if (chapterIndex >= sections.length) {
|
||||
throw new Error('Invalid chapter index');
|
||||
}
|
||||
|
||||
const effectiveProvider = settings?.ttsProvider ?? ttsProvider;
|
||||
const effectiveFormat = settings?.format ?? format;
|
||||
|
||||
const section = sections[chapterIndex];
|
||||
const trimmedText = section.text.trim();
|
||||
|
||||
if (!trimmedText) {
|
||||
throw new Error('No text content found in chapter');
|
||||
}
|
||||
|
||||
// Get TOC for chapter title
|
||||
const chapters = tocRef.current || [];
|
||||
const sectionTitleMap = new Map<string, string>();
|
||||
|
||||
for (const chapter of chapters) {
|
||||
if (!chapter.href) continue;
|
||||
const chapterBaseHref = chapter.href.split('#')[0];
|
||||
const chapterTitle = chapter.label.trim();
|
||||
|
||||
for (const sect of sections) {
|
||||
const sectionHref = sect.href;
|
||||
const sectionBaseHref = sectionHref.split('#')[0];
|
||||
|
||||
if (sectionHref === chapter.href || sectionBaseHref === chapterBaseHref) {
|
||||
sectionTitleMap.set(sectionHref, chapterTitle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const chapterTitle = sectionTitleMap.get(section.href) || `Chapter ${chapterIndex + 1}`;
|
||||
|
||||
// Generate audio with retry logic
|
||||
const reqHeaders: TTSRequestHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
'x-openai-key': apiKey,
|
||||
'x-openai-base-url': baseUrl,
|
||||
'x-tts-provider': effectiveProvider,
|
||||
};
|
||||
|
||||
// Allow one narrow client retry for transient browser->/api/audiobook/chapter transport failures.
|
||||
// HTTP failures are not retried client-side.
|
||||
const retryOptions: TTSRetryOptions = {
|
||||
maxRetries: 2,
|
||||
initialDelay: 300,
|
||||
maxDelay: 300,
|
||||
};
|
||||
|
||||
const chapter = await withRetry(
|
||||
async () => {
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
|
||||
return await createAudiobookChapter({
|
||||
chapterTitle,
|
||||
text: trimmedText,
|
||||
bookId,
|
||||
format: effectiveFormat,
|
||||
chapterIndex,
|
||||
settings
|
||||
}, reqHeaders, signal);
|
||||
},
|
||||
retryOptions
|
||||
);
|
||||
|
||||
if (signal?.aborted) {
|
||||
throw new Error('Chapter regeneration cancelled');
|
||||
}
|
||||
|
||||
return chapter;
|
||||
|
||||
return await regenerateAudiobookChapter({
|
||||
adapter: audiobookAdapter,
|
||||
chapterIndex,
|
||||
bookId,
|
||||
format,
|
||||
signal,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
defaultProvider: ttsProvider,
|
||||
settings,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'))) {
|
||||
throw new Error('Chapter regeneration cancelled');
|
||||
|
|
@ -617,7 +398,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
console.error('Error regenerating chapter:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [extractBookText, apiKey, baseUrl, ttsProvider]);
|
||||
}, [audiobookAdapter, apiKey, baseUrl, ttsProvider]);
|
||||
|
||||
const setRendition = useCallback((rendition: Rendition) => {
|
||||
bookRef.current = rendition.book;
|
||||
|
|
|
|||
|
|
@ -28,11 +28,11 @@ import {
|
|||
import type { PDFDocumentProxy } from 'pdfjs-dist';
|
||||
|
||||
import { getDocumentMetadata } from '@/lib/client/api/documents';
|
||||
import { createPdfAudiobookSourceAdapter } from '@/lib/client/audiobooks/adapters/pdf';
|
||||
import { regenerateAudiobookChapter, runAudiobookGeneration } from '@/lib/client/audiobooks/pipeline';
|
||||
import { ensureCachedDocument } from '@/lib/client/cache/documents';
|
||||
import { useTTS } from '@/contexts/TTSContext';
|
||||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
import { normalizeTextForTts } from '@/lib/shared/nlp';
|
||||
import { withRetry, getAudiobookStatus, createAudiobookChapter } from '@/lib/client/api/audiobooks';
|
||||
import {
|
||||
extractTextFromPDF,
|
||||
highlightPattern,
|
||||
|
|
@ -46,11 +46,7 @@ import type {
|
|||
TTSAudiobookFormat,
|
||||
TTSAudiobookChapter,
|
||||
} from '@/types/tts';
|
||||
import type {
|
||||
TTSRequestHeaders,
|
||||
TTSRetryOptions,
|
||||
AudiobookGenerationSettings,
|
||||
} from '@/types/client';
|
||||
import type { AudiobookGenerationSettings } from '@/types/client';
|
||||
|
||||
/**
|
||||
* Interface defining all available methods and properties in the PDF context
|
||||
|
|
@ -139,6 +135,16 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
const [currDocText, setCurrDocText] = useState<string>();
|
||||
const [pdfDocument, setPdfDocument] = useState<PDFDocumentProxy>();
|
||||
const [isAudioCombining] = useState(false);
|
||||
const audiobookAdapter = useMemo(() => createPdfAudiobookSourceAdapter({
|
||||
pdfDocument,
|
||||
margins: {
|
||||
header: headerMargin,
|
||||
footer: footerMargin,
|
||||
left: leftMargin,
|
||||
right: rightMargin,
|
||||
},
|
||||
smartSentenceSplitting,
|
||||
}), [pdfDocument, headerMargin, footerMargin, leftMargin, rightMargin, smartSentenceSplitting]);
|
||||
const pageTextCacheRef = useRef<Map<number, string>>(new Map());
|
||||
const [currDocPage, setCurrDocPage] = useState<number>(currDocPageNumber);
|
||||
|
||||
|
|
@ -409,169 +415,23 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
settings?: AudiobookGenerationSettings
|
||||
): Promise<string> => {
|
||||
try {
|
||||
if (!pdfDocument) {
|
||||
throw new Error('No PDF document loaded');
|
||||
}
|
||||
|
||||
const effectiveProvider = settings?.ttsProvider ?? ttsProvider;
|
||||
const effectiveFormat = settings?.format ?? format;
|
||||
|
||||
// First pass: extract and measure all text
|
||||
const textPerPage: string[] = [];
|
||||
let totalLength = 0;
|
||||
|
||||
for (let pageNum = 1; pageNum <= pdfDocument.numPages; pageNum++) {
|
||||
const rawText = await extractTextFromPDF(pdfDocument, pageNum, {
|
||||
header: headerMargin,
|
||||
footer: footerMargin,
|
||||
left: leftMargin,
|
||||
right: rightMargin
|
||||
});
|
||||
const trimmedText = rawText.trim();
|
||||
if (trimmedText) {
|
||||
const processedText = smartSentenceSplitting ? normalizeTextForTts(trimmedText) : trimmedText;
|
||||
|
||||
textPerPage.push(processedText);
|
||||
totalLength += processedText.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (totalLength === 0) {
|
||||
throw new Error('No text content found in PDF');
|
||||
}
|
||||
|
||||
let processedLength = 0;
|
||||
let bookId: string = providedBookId || '';
|
||||
|
||||
// If we have a bookId, check for existing chapters to determine which indices already exist
|
||||
const existingIndices = new Set<number>();
|
||||
if (bookId) {
|
||||
try {
|
||||
const existingData = await getAudiobookStatus(bookId);
|
||||
if (existingData.chapters && existingData.chapters.length > 0) {
|
||||
for (const ch of existingData.chapters) {
|
||||
existingIndices.add(ch.index);
|
||||
}
|
||||
let nextMissing = 0;
|
||||
while (existingIndices.has(nextMissing)) nextMissing++;
|
||||
console.log(`Resuming; next missing page index is ${nextMissing} (page ${nextMissing + 1})`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking existing chapters:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: process text into audio
|
||||
for (let i = 0; i < textPerPage.length; i++) {
|
||||
// Check for abort at the start of iteration
|
||||
if (signal?.aborted) {
|
||||
console.log('Generation cancelled by user');
|
||||
if (bookId) {
|
||||
return bookId; // Return bookId with partial progress
|
||||
}
|
||||
throw new Error('Audiobook generation cancelled');
|
||||
}
|
||||
|
||||
const text = textPerPage[i];
|
||||
|
||||
// Skip pages that already exist on disk (supports non-contiguous indices)
|
||||
if (existingIndices.has(i)) {
|
||||
processedLength += text.length;
|
||||
onProgress((processedLength / totalLength) * 100);
|
||||
continue;
|
||||
}
|
||||
|
||||
const reqHeaders: TTSRequestHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
'x-openai-key': apiKey,
|
||||
'x-openai-base-url': baseUrl,
|
||||
'x-tts-provider': effectiveProvider,
|
||||
};
|
||||
|
||||
// Allow one narrow client retry for transient browser->/api/audiobook/chapter transport failures.
|
||||
// HTTP failures are not retried client-side.
|
||||
const retryOptions: TTSRetryOptions = {
|
||||
maxRetries: 2,
|
||||
initialDelay: 300,
|
||||
maxDelay: 300,
|
||||
};
|
||||
|
||||
try {
|
||||
const chapterTitle = `Page ${i + 1}`;
|
||||
|
||||
const chapter = await withRetry(
|
||||
async () => {
|
||||
// Check for abort before starting chapter request
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
|
||||
return await createAudiobookChapter({
|
||||
chapterTitle,
|
||||
text,
|
||||
bookId,
|
||||
format: effectiveFormat,
|
||||
chapterIndex: i,
|
||||
settings
|
||||
}, reqHeaders, signal);
|
||||
},
|
||||
retryOptions
|
||||
);
|
||||
|
||||
// Check for abort before sending to server
|
||||
if (signal?.aborted) {
|
||||
console.log('Generation cancelled before saving page');
|
||||
if (bookId) {
|
||||
return bookId;
|
||||
}
|
||||
throw new Error('Audiobook generation cancelled');
|
||||
}
|
||||
|
||||
if (!bookId) {
|
||||
bookId = chapter.bookId!;
|
||||
}
|
||||
|
||||
// Notify about completed chapter
|
||||
if (onChapterComplete) {
|
||||
onChapterComplete(chapter);
|
||||
}
|
||||
|
||||
processedLength += text.length;
|
||||
onProgress((processedLength / totalLength) * 100);
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'))) {
|
||||
console.log('TTS request aborted, returning partial progress');
|
||||
if (bookId) {
|
||||
return bookId; // Return with partial progress
|
||||
}
|
||||
throw new Error('Audiobook generation cancelled');
|
||||
}
|
||||
console.error('Error processing page:', error);
|
||||
|
||||
// Notify about error
|
||||
if (onChapterComplete) {
|
||||
onChapterComplete({
|
||||
index: i,
|
||||
title: `Page ${i + 1}`,
|
||||
status: 'error',
|
||||
bookId,
|
||||
format: effectiveFormat
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!bookId) {
|
||||
throw new Error('No audio was generated from the PDF content');
|
||||
}
|
||||
|
||||
return bookId;
|
||||
return await runAudiobookGeneration({
|
||||
adapter: audiobookAdapter,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
defaultProvider: ttsProvider,
|
||||
onProgress,
|
||||
signal,
|
||||
onChapterComplete,
|
||||
providedBookId,
|
||||
format,
|
||||
settings,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating audiobook:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [pdfDocument, headerMargin, footerMargin, leftMargin, rightMargin, apiKey, baseUrl, ttsProvider, smartSentenceSplitting]);
|
||||
}, [audiobookAdapter, apiKey, baseUrl, ttsProvider]);
|
||||
|
||||
/**
|
||||
* Regenerates a specific chapter (page) of the PDF audiobook
|
||||
|
|
@ -584,94 +444,17 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
settings?: AudiobookGenerationSettings
|
||||
): Promise<TTSAudiobookChapter> => {
|
||||
try {
|
||||
if (!pdfDocument) {
|
||||
throw new Error('No PDF document loaded');
|
||||
}
|
||||
|
||||
const effectiveProvider = settings?.ttsProvider ?? ttsProvider;
|
||||
const effectiveFormat = settings?.format ?? format;
|
||||
|
||||
// IMPORTANT: Chapter indices are based on non-empty pages used during generation.
|
||||
// Build a mapping of "chapterIndex" -> actual PDF page number (1-based).
|
||||
const nonEmptyPages: number[] = [];
|
||||
for (let page = 1; page <= pdfDocument.numPages; page++) {
|
||||
const pageText = await extractTextFromPDF(pdfDocument, page, {
|
||||
header: headerMargin,
|
||||
footer: footerMargin,
|
||||
left: leftMargin,
|
||||
right: rightMargin
|
||||
});
|
||||
if (pageText.trim()) {
|
||||
nonEmptyPages.push(page);
|
||||
}
|
||||
}
|
||||
|
||||
if (chapterIndex < 0 || chapterIndex >= nonEmptyPages.length) {
|
||||
throw new Error('Invalid chapter index');
|
||||
}
|
||||
|
||||
const pageNum = nonEmptyPages[chapterIndex];
|
||||
|
||||
// Extract text from the mapped page
|
||||
const rawText = await extractTextFromPDF(pdfDocument, pageNum, {
|
||||
header: headerMargin,
|
||||
footer: footerMargin,
|
||||
left: leftMargin,
|
||||
right: rightMargin
|
||||
return await regenerateAudiobookChapter({
|
||||
adapter: audiobookAdapter,
|
||||
chapterIndex,
|
||||
bookId,
|
||||
format,
|
||||
signal,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
defaultProvider: ttsProvider,
|
||||
settings,
|
||||
});
|
||||
|
||||
const trimmedText = rawText.trim();
|
||||
if (!trimmedText) {
|
||||
throw new Error('No text content found on page');
|
||||
}
|
||||
|
||||
const textForTTS = smartSentenceSplitting
|
||||
? normalizeTextForTts(trimmedText)
|
||||
: trimmedText;
|
||||
|
||||
// Use logical chapter numbering (index + 1) to match original generation titles
|
||||
const chapterTitle = `Page ${chapterIndex + 1}`;
|
||||
|
||||
// Generate audio with retry logic
|
||||
const reqHeaders: TTSRequestHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
'x-openai-key': apiKey,
|
||||
'x-openai-base-url': baseUrl,
|
||||
'x-tts-provider': effectiveProvider,
|
||||
};
|
||||
|
||||
// Allow one narrow client retry for transient browser->/api/audiobook/chapter transport failures.
|
||||
// HTTP failures are not retried client-side.
|
||||
const retryOptions: TTSRetryOptions = {
|
||||
maxRetries: 2,
|
||||
initialDelay: 300,
|
||||
maxDelay: 300,
|
||||
};
|
||||
|
||||
const chapter = await withRetry(
|
||||
async () => {
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
|
||||
return await createAudiobookChapter({
|
||||
chapterTitle,
|
||||
text: textForTTS,
|
||||
bookId,
|
||||
format: effectiveFormat,
|
||||
chapterIndex,
|
||||
settings
|
||||
}, reqHeaders, signal);
|
||||
},
|
||||
retryOptions
|
||||
);
|
||||
|
||||
if (signal?.aborted) {
|
||||
throw new Error('Page regeneration cancelled');
|
||||
}
|
||||
|
||||
return chapter;
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'))) {
|
||||
throw new Error('Page regeneration cancelled');
|
||||
|
|
@ -679,7 +462,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
console.error('Error regenerating page:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [pdfDocument, headerMargin, footerMargin, leftMargin, rightMargin, apiKey, baseUrl, ttsProvider, smartSentenceSplitting]);
|
||||
}, [audiobookAdapter, apiKey, baseUrl, ttsProvider]);
|
||||
|
||||
/**
|
||||
* Effect hook to initialize TTS as non-EPUB mode
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import { getDocumentProgress, scheduleDocumentProgressSync } from '@/lib/client/
|
|||
import { withRetry, generateTTS, alignAudio } from '@/lib/client/api/audiobooks';
|
||||
import { preprocessSentenceForAudio, splitTextToTtsBlocks, splitTextToTtsBlocksEPUB } from '@/lib/shared/nlp';
|
||||
import { isKokoroModel } from '@/lib/shared/kokoro';
|
||||
import { supportsTtsInstructions } from '@/lib/shared/tts-provider-catalog';
|
||||
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
|
||||
import type {
|
||||
TTSLocation,
|
||||
|
|
@ -1099,7 +1100,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
speed,
|
||||
format: 'mp3',
|
||||
model: ttsModel,
|
||||
instructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined,
|
||||
instructions: supportsTtsInstructions(ttsModel) ? ttsInstructions : undefined,
|
||||
};
|
||||
|
||||
// Allow one narrow client retry for transient browser->/api/tts transport failures.
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@
|
|||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { getVoices } from '@/lib/client/api/audiobooks';
|
||||
|
||||
const DEFAULT_VOICES = ['alloy', 'ash', 'coral', 'echo', 'fable', 'onyx', 'nova', 'sage', 'shimmer'];
|
||||
import { getDefaultVoices } from '@/lib/shared/tts-provider-catalog';
|
||||
|
||||
/**
|
||||
* Custom hook for managing TTS voices
|
||||
|
|
@ -36,12 +35,11 @@ export function useVoiceManagement(
|
|||
|
||||
// Ignore stale responses from older provider/model fetches.
|
||||
if (fetchSeq !== fetchSeqRef.current) return;
|
||||
setAvailableVoices(data.voices || DEFAULT_VOICES);
|
||||
setAvailableVoices(data.voices || getDefaultVoices(ttsProvider || 'openai', ttsModel || 'tts-1'));
|
||||
} catch (error) {
|
||||
console.error('Error fetching voices:', error);
|
||||
if (fetchSeq !== fetchSeqRef.current) return;
|
||||
// Set available voices to default openai voices
|
||||
setAvailableVoices(DEFAULT_VOICES);
|
||||
setAvailableVoices(getDefaultVoices(ttsProvider || 'openai', ttsModel || 'tts-1'));
|
||||
}
|
||||
}, [apiKey, baseUrl, ttsProvider, ttsModel]);
|
||||
|
||||
|
|
|
|||
70
src/lib/client/audiobooks/adapters/epub.ts
Normal file
70
src/lib/client/audiobooks/adapters/epub.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
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 = typeof chapter.label === 'string' ? chapter.label.trim() : '';
|
||||
if (!chapterTitle) continue;
|
||||
|
||||
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 {
|
||||
let preparedChaptersPromise: Promise<PreparedAudiobookChapter[]> | null = null;
|
||||
|
||||
const getPreparedChapters = () => {
|
||||
if (!preparedChaptersPromise) {
|
||||
preparedChaptersPromise = buildPreparedEpubChapters(options).catch((error) => {
|
||||
preparedChaptersPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return preparedChaptersPromise;
|
||||
};
|
||||
|
||||
return {
|
||||
noContentMessage: 'No text content found in book',
|
||||
noAudioGeneratedMessage: 'No audio was generated from the book content',
|
||||
prepareChapters: async () => getPreparedChapters(),
|
||||
prepareChapter: async (chapterIndex: number) => {
|
||||
const chapters = await getPreparedChapters();
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
256
src/lib/client/audiobooks/pipeline.ts
Normal file
256
src/lib/client/audiobooks/pipeline.ts
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
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'));
|
||||
}
|
||||
|
||||
function createAudiobookAbortError(): Error {
|
||||
const error = new Error('Audiobook generation cancelled');
|
||||
error.name = 'AbortError';
|
||||
return error;
|
||||
}
|
||||
|
||||
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) {
|
||||
if (chapter.status === 'completed') {
|
||||
existingIndices.add(chapter.index);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking existing chapters:', error);
|
||||
}
|
||||
}
|
||||
|
||||
for (const chapter of chapters) {
|
||||
if (signal?.aborted) {
|
||||
throw createAudiobookAbortError();
|
||||
}
|
||||
|
||||
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) {
|
||||
throw createAudiobookAbortError();
|
||||
}
|
||||
|
||||
if (!bookId) {
|
||||
if (createdChapter.bookId) {
|
||||
bookId = createdChapter.bookId;
|
||||
} else {
|
||||
throw new Error('Created chapter is missing bookId');
|
||||
}
|
||||
}
|
||||
|
||||
onChapterComplete?.(createdChapter);
|
||||
processedLength += trimmedText.length;
|
||||
onProgress((processedLength / totalLength) * 100);
|
||||
} catch (error) {
|
||||
if (isAbortLikeError(error)) {
|
||||
throw createAudiobookAbortError();
|
||||
}
|
||||
|
||||
console.error('Error processing chapter:', error);
|
||||
onChapterComplete?.({
|
||||
index: chapter.index,
|
||||
title: chapter.title,
|
||||
status: 'error',
|
||||
bookId,
|
||||
format: effectiveFormat,
|
||||
});
|
||||
processedLength += trimmedText.length;
|
||||
onProgress((processedLength / totalLength) * 100);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
61
src/lib/client/config/preferences.ts
Normal file
61
src/lib/client/config/preferences.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { APP_CONFIG_DEFAULTS, type AppConfigValues } from '@/types/config';
|
||||
import { SYNCED_PREFERENCE_KEYS, type SyncedPreferenceKey, type SyncedPreferencesPatch } from '@/types/user-state';
|
||||
|
||||
function isObjectLike(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function deepEqual(left: unknown, right: unknown): boolean {
|
||||
if (left === right) return true;
|
||||
|
||||
if (Array.isArray(left) !== Array.isArray(right)) return false;
|
||||
if (isObjectLike(left) !== isObjectLike(right)) return false;
|
||||
|
||||
if (Array.isArray(left) && Array.isArray(right)) {
|
||||
if (left.length !== right.length) return false;
|
||||
for (let i = 0; i < left.length; i += 1) {
|
||||
if (!deepEqual(left[i], right[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isObjectLike(left) && isObjectLike(right)) {
|
||||
const leftKeys = Object.keys(left);
|
||||
const rightKeys = Object.keys(right);
|
||||
if (leftKeys.length !== rightKeys.length) return false;
|
||||
|
||||
for (const key of leftKeys) {
|
||||
if (!(key in right)) return false;
|
||||
if (!deepEqual(left[key], right[key])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
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'
|
||||
? deepEqual(value, defaultValue)
|
||||
: value === defaultValue;
|
||||
if (same) continue;
|
||||
}
|
||||
|
||||
(out as Record<SyncedPreferenceKey, unknown>)[key] = value;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
68
src/lib/client/config/updates.ts
Normal file
68
src/lib/client/config/updates.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import type { AppConfigRow, AppConfigValues, SavedVoices } from '@/types/config';
|
||||
|
||||
export function getVoicePreferenceKey(provider: string, model: string): string {
|
||||
return `${provider}:${model}`;
|
||||
}
|
||||
|
||||
export function applyConfigUpdate<K extends keyof AppConfigValues>(
|
||||
currentConfig: Pick<AppConfigValues, 'ttsProvider' | 'ttsModel' | 'savedVoices'>,
|
||||
key: K,
|
||||
value: AppConfigValues[K],
|
||||
): {
|
||||
storagePatch: Partial<AppConfigRow>;
|
||||
syncPatch: Partial<AppConfigValues>;
|
||||
} {
|
||||
if (key === 'voice') {
|
||||
const voiceKey = getVoicePreferenceKey(currentConfig.ttsProvider, currentConfig.ttsModel);
|
||||
const updatedSavedVoices = { ...currentConfig.savedVoices, [voiceKey]: value as string };
|
||||
return {
|
||||
storagePatch: {
|
||||
savedVoices: updatedSavedVoices,
|
||||
voice: value as string,
|
||||
},
|
||||
syncPatch: {
|
||||
savedVoices: updatedSavedVoices,
|
||||
voice: value as string,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (key === 'ttsProvider' || key === 'ttsModel') {
|
||||
const newProvider = key === 'ttsProvider' ? (value as string) : currentConfig.ttsProvider;
|
||||
const newModel = key === 'ttsModel' ? (value as string) : currentConfig.ttsModel;
|
||||
const voiceKey = getVoicePreferenceKey(newProvider, newModel);
|
||||
const restoredVoice = currentConfig.savedVoices[voiceKey] || '';
|
||||
|
||||
return {
|
||||
storagePatch: {
|
||||
[key]: value as AppConfigValues[keyof AppConfigValues],
|
||||
voice: restoredVoice,
|
||||
} as Partial<AppConfigRow>,
|
||||
syncPatch: {
|
||||
[key]: value as AppConfigValues[keyof AppConfigValues],
|
||||
voice: restoredVoice,
|
||||
} as Partial<AppConfigValues>,
|
||||
};
|
||||
}
|
||||
|
||||
if (key === 'savedVoices') {
|
||||
const newSavedVoices = value as SavedVoices;
|
||||
return {
|
||||
storagePatch: {
|
||||
savedVoices: newSavedVoices,
|
||||
},
|
||||
syncPatch: {
|
||||
savedVoices: newSavedVoices,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
storagePatch: {
|
||||
[key]: value as AppConfigValues[keyof AppConfigValues],
|
||||
} as Partial<AppConfigRow>,
|
||||
syncPatch: {
|
||||
[key]: value,
|
||||
} as Partial<AppConfigValues>,
|
||||
};
|
||||
}
|
||||
48
src/lib/client/settings/tts-settings.ts
Normal file
48
src/lib/client/settings/tts-settings.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import {
|
||||
TTS_PROVIDER_DEFINITIONS,
|
||||
providerSupportsCustomModel,
|
||||
resolveProviderModels,
|
||||
type TtsModelDefinition,
|
||||
type TtsProviderDefinition,
|
||||
} from '@/lib/shared/tts-provider-catalog';
|
||||
|
||||
export interface ResolveTtsSettingsViewModelOptions {
|
||||
provider: string;
|
||||
apiKey?: string;
|
||||
modelValue: string;
|
||||
customModelInput: string;
|
||||
showAllDeepInfra: boolean;
|
||||
}
|
||||
|
||||
export interface TtsSettingsViewModel {
|
||||
providers: TtsProviderDefinition[];
|
||||
models: TtsModelDefinition[];
|
||||
supportsCustomModel: boolean;
|
||||
selectedModelId: string;
|
||||
canSubmit: boolean;
|
||||
}
|
||||
|
||||
export function resolveTtsSettingsViewModel({
|
||||
provider,
|
||||
apiKey,
|
||||
modelValue,
|
||||
customModelInput,
|
||||
showAllDeepInfra,
|
||||
}: ResolveTtsSettingsViewModelOptions): TtsSettingsViewModel {
|
||||
const models = resolveProviderModels(provider, {
|
||||
apiKey,
|
||||
showAllDeepInfra,
|
||||
});
|
||||
const supportsCustomModel = providerSupportsCustomModel(provider);
|
||||
const isPreset = models.some((model) => model.id === modelValue);
|
||||
const selectedModelId = isPreset ? modelValue : (supportsCustomModel ? 'custom' : (models[0]?.id ?? ''));
|
||||
const canSubmit = selectedModelId !== 'custom' || (supportsCustomModel && customModelInput.trim().length > 0);
|
||||
|
||||
return {
|
||||
providers: TTS_PROVIDER_DEFINITIONS,
|
||||
models,
|
||||
supportsCustomModel,
|
||||
selectedModelId,
|
||||
canSubmit,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import OpenAI from 'openai';
|
||||
import { SpeechCreateParams } from 'openai/resources/audio/speech.mjs';
|
||||
import { isKokoroModel } from '@/lib/shared/kokoro';
|
||||
import { supportsTtsInstructions } from '@/lib/shared/tts-provider-catalog';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
import { createHash } from 'crypto';
|
||||
import { access, readFile } from 'fs/promises';
|
||||
|
|
@ -83,7 +84,7 @@ function resolveTTSRequest(input: ServerTTSRequest): ResolvedServerTTSRequest {
|
|||
|
||||
const format = input.format || 'mp3';
|
||||
const speed = Number.isFinite(Number(input.speed)) ? Number(input.speed) : 1;
|
||||
const instructions = (model as string) === 'gpt-4o-mini-tts' && input.instructions
|
||||
const instructions = supportsTtsInstructions(model as string) && input.instructions
|
||||
? input.instructions
|
||||
: undefined;
|
||||
|
||||
|
|
|
|||
261
src/lib/shared/tts-provider-catalog.ts
Normal file
261
src/lib/shared/tts-provider-catalog.ts
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
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[]> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, 10_000);
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.deepinfra.com/v1/voices', {
|
||||
signal: controller.signal,
|
||||
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) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
return [];
|
||||
}
|
||||
console.error('Error fetching Deepinfra voices:', error);
|
||||
return [];
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCustomOpenAiVoices(baseUrl: string, apiKey: string): Promise<string[] | null> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, 10_000);
|
||||
|
||||
try {
|
||||
const normalizedBaseUrl = baseUrl.replace(/\/+$/, '');
|
||||
const response = await fetch(`${normalizedBaseUrl}/audio/voices`, {
|
||||
signal: controller.signal,
|
||||
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.every((voice: unknown) => typeof voice === 'string')
|
||||
? data.voices
|
||||
: null;
|
||||
} catch {
|
||||
console.log('Custom endpoint does not support voices, using defaults');
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
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 !== null) {
|
||||
return apiVoices;
|
||||
}
|
||||
}
|
||||
|
||||
return defaultVoices;
|
||||
}
|
||||
|
|
@ -109,6 +109,26 @@ async function expectChaptersBackendState(page: Page, bookId: string) {
|
|||
return json;
|
||||
}
|
||||
|
||||
async function waitForBackendDownloadReady(
|
||||
page: Page,
|
||||
bookId: string,
|
||||
{ minChapters = 1, timeoutMs = 120_000 }: { minChapters?: number; timeoutMs?: number } = {}
|
||||
) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const json = await expectChaptersBackendState(page, bookId);
|
||||
if (!json?.exists) return false;
|
||||
if (!Array.isArray(json?.chapters)) return false;
|
||||
if (json.chapters.length < minChapters) return false;
|
||||
return json.chapters.every((chapter: { duration?: number }) => Number(chapter.duration ?? 0) > 0);
|
||||
}, { timeout: timeoutMs })
|
||||
.toBe(true);
|
||||
|
||||
const fullDownloadButton = page.getByRole('button', { name: /Full Download/i });
|
||||
await expect(fullDownloadButton).toBeVisible({ timeout: timeoutMs });
|
||||
await expect(fullDownloadButton).toBeEnabled({ timeout: timeoutMs });
|
||||
}
|
||||
|
||||
async function withDownloadedFullAudiobook<T>(
|
||||
page: Page,
|
||||
fn: (args: { filePath: string; suggestedFilename: string }) => Promise<T>,
|
||||
|
|
@ -311,6 +331,9 @@ test('exports a single MP3 audiobook PDF page via chapters menu', async ({ page
|
|||
const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' });
|
||||
await expect(chapterActionsButtons.first()).toBeVisible({ timeout: 90_000 });
|
||||
|
||||
// Readiness gate: chapter row visibility can lead backend storage consistency by a small window.
|
||||
await waitForBackendDownloadReady(page, bookId, { minChapters: 1 });
|
||||
|
||||
// Download via frontend button
|
||||
await withDownloadedFullAudiobook(page, async ({ filePath }) => {
|
||||
const durationSeconds = await getAudioDurationSeconds(filePath);
|
||||
|
|
@ -492,6 +515,9 @@ test('resumes audiobook when a chapter is missing and full download succeeds (PD
|
|||
// UI should also stop showing a missing placeholder after resume completes.
|
||||
await expect(page.getByText(/Missing •/)).toHaveCount(0, { timeout: 120_000 });
|
||||
|
||||
// Ensure backend chapter metadata/object visibility is settled before combine/download.
|
||||
await waitForBackendDownloadReady(page, bookId, { minChapters: 2 });
|
||||
|
||||
await withDownloadedFullAudiobook(page, async ({ filePath }) => {
|
||||
const durationSeconds = await getAudioDurationSeconds(filePath);
|
||||
expect(durationSeconds).toBeGreaterThan(18);
|
||||
|
|
|
|||
115
tests/helpers.ts
115
tests/helpers.ts
|
|
@ -82,12 +82,23 @@ export async function uploadAndDisplay(page: Page, fileName: string) {
|
|||
const expectedId = sha256HexOfFile(fixturePath(fileName));
|
||||
const targetLink = page.locator(`a[href$="/pdf/${expectedId}"]`).first();
|
||||
await expect(targetLink).toBeVisible({ timeout: 15000 });
|
||||
await dismissOnboardingModals(page);
|
||||
await targetLink.click();
|
||||
await page.waitForSelector('.react-pdf__Document', { timeout: 15000 });
|
||||
return;
|
||||
}
|
||||
|
||||
await page.getByRole('link', { name: new RegExp(escapeRegExp(fileName), 'i') }).first().click();
|
||||
const targetLink = page.getByRole('link', { name: new RegExp(escapeRegExp(fileName), 'i') }).first();
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
await dismissOnboardingModals(page);
|
||||
try {
|
||||
await targetLink.click({ timeout: 10000 });
|
||||
break;
|
||||
} catch (error) {
|
||||
if (attempt === 2) throw error;
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
}
|
||||
|
||||
if (lower.endsWith('.pdf')) {
|
||||
await page.waitForSelector('.react-pdf__Document', { timeout: 10000 });
|
||||
|
|
@ -98,6 +109,60 @@ export async function uploadAndDisplay(page: Page, fileName: string) {
|
|||
}
|
||||
}
|
||||
|
||||
async function dismissOnboardingModals(page: Page): Promise<void> {
|
||||
const privacyDialog = page.getByTestId('privacy-modal');
|
||||
const migrationDialog = page.getByTestId('migration-modal');
|
||||
const settingsDialog = page.getByTestId('settings-modal');
|
||||
|
||||
const maxSteps = 12;
|
||||
const settleChecks = 3;
|
||||
let settledWithoutDialog = 0;
|
||||
|
||||
for (let step = 0; step < maxSteps; step += 1) {
|
||||
if (await privacyDialog.isVisible().catch(() => false)) {
|
||||
const privacyAgree = page.getByTestId('privacy-agree-checkbox');
|
||||
if (await privacyAgree.isVisible().catch(() => false)) {
|
||||
if (!(await privacyAgree.isChecked())) {
|
||||
await privacyAgree.check();
|
||||
}
|
||||
}
|
||||
const continueBtn = page.getByTestId('privacy-continue-button');
|
||||
await expect(continueBtn).toBeEnabled({ timeout: 10000 });
|
||||
await continueBtn.click();
|
||||
await privacyDialog.waitFor({ state: 'hidden', timeout: 15000 });
|
||||
await page.waitForTimeout(100);
|
||||
settledWithoutDialog = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (await migrationDialog.isVisible().catch(() => false)) {
|
||||
const skipBtn = page.getByTestId('migration-skip-button');
|
||||
await expect(skipBtn).toBeEnabled({ timeout: 10000 });
|
||||
await skipBtn.click();
|
||||
await migrationDialog.waitFor({ state: 'hidden', timeout: 15000 });
|
||||
await page.waitForTimeout(100);
|
||||
settledWithoutDialog = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (await settingsDialog.isVisible().catch(() => false)) {
|
||||
const saveBtn = page.getByTestId('settings-save-button');
|
||||
await expect(saveBtn).toBeEnabled({ timeout: 15000 });
|
||||
await saveBtn.click();
|
||||
await settingsDialog.waitFor({ state: 'hidden', timeout: 15000 });
|
||||
await page.waitForTimeout(100);
|
||||
settledWithoutDialog = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
settledWithoutDialog += 1;
|
||||
if (settledWithoutDialog >= settleChecks) {
|
||||
return;
|
||||
}
|
||||
await page.waitForTimeout(150);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the play button to be clickable and click it
|
||||
*/
|
||||
|
|
@ -206,58 +271,14 @@ export async function setupTest(page: Page, testInfo?: TestInfo) {
|
|||
.waitForSelector('.fixed.inset-0.bg-base.z-50', { state: 'detached', timeout: 15_000 })
|
||||
.catch(() => { });
|
||||
|
||||
// Privacy modal should come first in onboarding.
|
||||
// Be tolerant if it's already accepted (e.g., reused context).
|
||||
const privacyBtn = page.getByRole('button', { name: /Continue|I Understand/i });
|
||||
try {
|
||||
await expect(privacyBtn).toBeVisible({ timeout: 5000 });
|
||||
const privacyAgree = page.locator('#privacy-agree');
|
||||
if ((await privacyAgree.count()) > 0) {
|
||||
await privacyAgree.check();
|
||||
}
|
||||
await expect(privacyBtn).toBeEnabled({ timeout: 5000 });
|
||||
await privacyBtn.click();
|
||||
// HeadlessUI keeps dialogs in the DOM during leave transitions; "hidden" is enough
|
||||
// (we mainly need to ensure it no longer blocks pointer events).
|
||||
await page.getByRole('dialog', { name: /privacy/i }).waitFor({ state: 'hidden', timeout: 15000 });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Fallback: if the banner still appears, dismiss it before continuing.
|
||||
const cookieAcceptBtn = page.getByRole('button', { name: 'Accept All' });
|
||||
if (await cookieAcceptBtn.isVisible().catch(() => false)) {
|
||||
await cookieAcceptBtn.click();
|
||||
}
|
||||
|
||||
// Settings modal should appear on first visit. In non-auth mode there is no
|
||||
// privacy modal, so open Settings explicitly if onboarding did not auto-open.
|
||||
const settingsDialog = page.getByRole('dialog', { name: 'Settings' });
|
||||
const saveBtn = settingsDialog.getByRole('button', { name: 'Save' });
|
||||
|
||||
// On some local runs, Settings opens automatically but the Save button is not yet
|
||||
// visible during enter transition. Prefer waiting for it before trying to click.
|
||||
await saveBtn.waitFor({ state: 'visible', timeout: 2500 }).catch(async () => {
|
||||
const settingsBtn = page.getByRole('button', { name: 'Settings' });
|
||||
if (await settingsBtn.isVisible().catch(() => false)) {
|
||||
// Force avoids occasional pointer interception by in-flight dialog overlays.
|
||||
await settingsBtn.click({ force: true });
|
||||
}
|
||||
});
|
||||
|
||||
await expect(saveBtn).toBeVisible({ timeout: 10000 });
|
||||
// SettingsModal can briefly disable Save while it mirrors a custom model into the input field.
|
||||
await expect(saveBtn).toBeEnabled({ timeout: 15000 });
|
||||
|
||||
// If running in CI, select the "Custom OpenAI-Like" model and "Deepinfra" provider
|
||||
if (process.env.CI) {
|
||||
await page.getByRole('button', { name: 'Custom OpenAI-Like' }).click();
|
||||
await page.getByText('Deepinfra').click();
|
||||
}
|
||||
|
||||
// Click the "done" button to dismiss the welcome message
|
||||
await saveBtn.click();
|
||||
await settingsDialog.waitFor({ state: 'hidden', timeout: 15000 });
|
||||
// Close first-run dialogs (when present). These do not always appear.
|
||||
await dismissOnboardingModals(page);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
115
tests/unit/tts-provider-catalog.spec.ts
Normal file
115
tests/unit/tts-provider-catalog.spec.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
getDefaultVoices,
|
||||
providerSupportsCustomModel,
|
||||
resolveVoices,
|
||||
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('keeps explicit empty custom-openai voices response', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ voices: [] }),
|
||||
}) as Response;
|
||||
|
||||
try {
|
||||
await expect(resolveVoices({
|
||||
provider: 'custom-openai',
|
||||
model: 'kokoro',
|
||||
apiKey: 'token',
|
||||
baseUrl: 'https://example.com',
|
||||
})).resolves.toEqual([]);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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