openreader/src/types/client.ts
Richard R c648d2bf6a refactor(api,tts,types): remove legacy /api/tts endpoint and update error handling
Eliminate the deprecated `/api/tts` route and its client usage, consolidating TTS generation logic under chapter and segment endpoints. Update client API utilities to remove direct TTS buffer fetching and improve error propagation for chapter generation failures. Extend TTS segment error metadata to include upstream rate limit and retry details, and standardize error typing in shared types. Adjust tests and helpers to reflect the new TTS mocking approach and endpoint structure. This streamlines TTS API surface and enhances error reporting consistency.
2026-05-12 18:40:46 -06:00

227 lines
6.1 KiB
TypeScript

import type {
TTSAudiobookChapter,
TTSSentenceAlignment,
TTSAudioBytes,
TTSAudiobookFormat,
} from '@/types/tts';
// --- TTS Client Request Types ---
// Headers used when calling TTS-related endpoints from the client.
export type TTSRequestHeaders = Record<string, string>;
// Options for retrying TTS requests on failure in withRetry
export interface TTSRetryOptions {
maxRetries?: number;
initialDelay?: number;
maxDelay?: number;
backoffFactor?: number;
}
export interface TTSRequestError extends Error {
status?: number;
code?: string;
type?: string;
title?: string;
detail?: string;
}
// --- Audiobook API Types ---
export interface AudiobookStatusResponse {
exists: boolean;
chapters: TTSAudiobookChapter[];
bookId: string | null;
hasComplete: boolean;
settings?: AudiobookGenerationSettings | null;
}
export interface AudiobookGenerationSettings {
ttsProvider: string;
ttsModel: string;
voice: string;
nativeSpeed: number;
postSpeed: number;
format: TTSAudiobookFormat;
ttsInstructions?: string;
}
export interface CreateChapterPayload {
chapterTitle: string;
text: string;
bookId: string;
format: TTSAudiobookFormat;
chapterIndex: number;
settings?: AudiobookGenerationSettings;
}
// --- TTS Voices API Types ---
export interface VoicesResponse {
voices: string[];
}
// --- Whisper API Types ---
export interface AlignmentPayload {
text: string;
audio: TTSAudioBytes; // Array.from(new Uint8Array(arrayBuffer))
}
export interface AlignmentResponse {
alignments: TTSSentenceAlignment[];
}
export interface TTSSegmentSettings {
ttsProvider: string;
ttsModel: string;
voice: string;
nativeSpeed: number;
ttsInstructions?: string;
}
type TTSReaderType = 'pdf' | 'epub' | 'html';
/**
* Locator describing where a TTS segment came from inside a document.
*
* Field usage by readerType:
* - PDF: `page` (1-based).
* - HTML: `location` (free-form fragment id / scroll anchor).
* - EPUB: **stable book coordinates** — `spineHref`, `spineIndex`, `charOffset`.
* `cfi` is a best-effort jump hint only; it is NOT used for identity,
* grouping, sorting, or matching. The stable coordinates are the same
* across devices and window sizes.
*
* The interface is structurally permissive for backwards compatibility with
* in-flight code paths, but the server-side `normalizeLocator` enforces the
* required fields per readerType before persisting. Use the
* `isStableEpubLocator` / `isPdfLocator` / `isHtmlLocator` guards at read sites
* that need a particular shape.
*/
export interface TTSSegmentLocator {
readerType?: TTSReaderType;
// PDF / legacy
page?: number;
// HTML / legacy EPUB CFI (kept for in-flight drafts; not persisted for EPUB)
location?: string;
// Stable EPUB coordinates
spineHref?: string;
spineIndex?: number;
charOffset?: number;
/** Best-effort jump hint for EPUB; not part of identity/sort/group. */
cfi?: string;
}
export function isPdfLocator(
locator: TTSSegmentLocator | null | undefined,
): locator is TTSSegmentLocator & { readerType: 'pdf'; page: number } {
return !!locator
&& locator.readerType === 'pdf'
&& typeof locator.page === 'number'
&& Number.isFinite(locator.page);
}
export function isHtmlLocator(
locator: TTSSegmentLocator | null | undefined,
): locator is TTSSegmentLocator & { readerType: 'html'; location: string } {
return !!locator
&& locator.readerType === 'html'
&& typeof locator.location === 'string'
&& locator.location.length > 0;
}
/**
* Narrow to a fully-stable EPUB locator. Returns false for EPUB drafts that
* only carry a CFI — those must be resolved to spine coordinates before being
* sent to the server.
*/
export function isStableEpubLocator(
locator: TTSSegmentLocator | null | undefined,
): locator is TTSSegmentLocator & {
readerType: 'epub';
spineHref: string;
spineIndex: number;
charOffset: number;
} {
return !!locator
&& locator.readerType === 'epub'
&& typeof locator.spineHref === 'string'
&& locator.spineHref.length > 0
&& typeof locator.spineIndex === 'number'
&& Number.isFinite(locator.spineIndex)
&& typeof locator.charOffset === 'number'
&& Number.isFinite(locator.charOffset);
}
export interface TTSSegmentInput {
segmentIndex: number;
segmentKey?: string;
text: string;
locator?: TTSSegmentLocator;
}
export interface TTSSegmentsEnsureRequest {
documentId: string;
segments: TTSSegmentInput[];
settings: TTSSegmentSettings;
}
export interface TTSSegmentManifestItem {
segmentId: string;
segmentIndex: number;
segmentKey?: string | null;
audioPresignUrl: string | null;
audioFallbackUrl: string | null;
durationMs: number;
alignment: TTSSentenceAlignment | null;
locator: TTSSegmentLocator | null;
status: 'pending' | 'completed' | 'error';
error?: {
code: string;
detail?: string;
upstreamStatus?: number;
retryAfterSeconds?: number;
} | null;
}
export interface TTSSegmentsEnsureResponse {
documentId: string;
segments: TTSSegmentManifestItem[];
}
export interface TTSSegmentVariant {
segmentId: string;
settings: TTSSegmentSettings | null;
audioPresignUrl: string | null;
audioFallbackUrl: string | null;
durationMs: number | null;
status: 'pending' | 'completed' | 'error';
textLength: number;
alignmentWordCount: number;
audioKey: string | null;
updatedAt: number | null;
}
export interface TTSSegmentRow {
segmentIndex: number;
/**
* Content-stable identity for this segment, derived from the normalized
* sentence text on the client (see `buildSegmentKey` in
* `lib/shared/tts-segment-plan.ts`). The sidebar uses this to merge
* locally-synthesized current-page rows with persisted manifest rows of the
* same content, so audio/variants attach to the visible text row instead of
* showing as a separate listing.
*/
segmentKey: string | null;
locator: TTSSegmentLocator | null;
variants: TTSSegmentVariant[];
}
export interface TTSSegmentsManifestResponse {
documentId: string;
segments: TTSSegmentRow[];
nextCursor: string | null;
hasMore: boolean;
}