openreader/src/lib/server/tts/segments.ts
Richard R 766c04d08d refactor(pdf): implement ONNX-based Docling layout parsing and block-level TTS for PDFs
Migrate PDF parsing to use ONNX Docling layout model for structured block extraction, enabling improved TTS segmentation and chaptering. Add compute backend abstraction for heavy tasks (alignment, layout parsing) with configuration via `OPENREADER_COMPUTE_MODE`. Integrate block-level locators and document settings for per-document PDF parsing options. Update S3 storage for parsed PDF JSON, add migration and schema changes, and extend client and server APIs for parsed data and settings. Remove legacy word highlight flag in favor of compute capability detection.

- Add ONNX model download, local/none compute modes, and `onnxruntime-node` dependency
- Update PDF viewer and TTS pipeline to use parsed blocks and block-level locators
- Add document settings storage and APIs for per-document PDF options
- Update S3 storage layout for parsed PDF JSON
- Add admin/config/docs updates for new compute and parsing features
- Remove obsolete word highlight runtime flag and UI

BREAKING CHANGE: PDF parsing and word highlighting now require `OPENREADER_COMPUTE_MODE=local` and ONNX model; document settings and S3 layout updated; legacy word highlight flag removed.
2026-05-17 21:18:51 -06:00

312 lines
10 KiB
TypeScript

import { createHash, createHmac } from 'crypto';
import { mkdtemp, rm, writeFile } from 'fs/promises';
import { join } from 'path';
import { tmpdir } from 'os';
import { preprocessSentenceForAudio } from '@/lib/shared/nlp';
import { locatorIdentityKey } from '@/lib/shared/tts-locator';
import { ffprobeAudio } from '@/lib/server/audiobooks/chapters';
import type {
TTSSegmentLocator,
TTSSegmentSettings,
} from '@/types/client';
import type { TTSSentenceAlignment, TTSSentenceWord } from '@/types/tts';
function stableStringify(value: unknown): string {
if (value === null || typeof value !== 'object') {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return `[${value.map((item) => stableStringify(item)).join(',')}]`;
}
const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b));
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(',')}}`;
}
function settingsCanonical(settings: TTSSegmentSettings): string {
return stableStringify({
providerRef: settings.providerRef,
providerType: settings.providerType,
model: settings.ttsModel,
voice: settings.voice,
speed: Number.isFinite(Number(settings.nativeSpeed)) ? Number(settings.nativeSpeed) : 1,
instructions: settings.ttsInstructions || '',
format: 'mp3',
});
}
export function buildTtsSegmentSettingsHash(settings: TTSSegmentSettings): string {
return createHash('sha256').update(settingsCanonical(settings)).digest('hex');
}
export function buildTtsSegmentSettingsJson(settings: TTSSegmentSettings): TTSSegmentSettings | string {
const canonical: TTSSegmentSettings = {
providerRef: settings.providerRef,
providerType: settings.providerType,
ttsModel: settings.ttsModel,
voice: settings.voice,
nativeSpeed: Number.isFinite(Number(settings.nativeSpeed)) ? Number(settings.nativeSpeed) : 1,
ttsInstructions: settings.ttsInstructions || '',
};
// Postgres jsonb accepts the object directly; SQLite text needs a canonical JSON string.
return process.env.POSTGRES_URL ? canonical : settingsCanonical(settings);
}
export function normalizeSegmentText(text: string): string {
return preprocessSentenceForAudio(text || '').trim();
}
export type TTSSegmentLocatorProjection = {
locatorReaderRank: number;
locatorReaderType: string;
locatorPage: number;
locatorSpineIndex: number;
locatorSpineHref: string;
locatorCharOffset: number;
locatorLocation: string;
locatorIdentityKey: string;
};
/**
* Validate and shape a locator for persistence. EPUB locators MUST carry the
* stable spine coordinates (`spineHref`, `spineIndex`, `charOffset`) — the
* legacy CFI-only shape is rejected (returns null) so we never store a
* viewport-dependent locator. PDF and HTML branches are unchanged.
*/
export function normalizeLocator(locator: TTSSegmentLocator | undefined): TTSSegmentLocator | null {
if (!locator) return null;
if (locator.readerType === 'pdf') {
if (typeof locator.page !== 'number' || !Number.isFinite(locator.page)) return null;
return {
readerType: 'pdf',
page: Math.max(1, Math.floor(locator.page)),
...(typeof locator.blockId === 'string' && locator.blockId.trim()
? { blockId: locator.blockId.trim() }
: {}),
};
}
if (locator.readerType === 'html') {
if (typeof locator.location !== 'string' || !locator.location.trim()) return null;
return {
readerType: 'html',
location: locator.location.trim(),
};
}
if (locator.readerType === 'epub') {
const spineHref = typeof locator.spineHref === 'string' ? locator.spineHref.trim() : '';
const spineIndex = typeof locator.spineIndex === 'number' && Number.isFinite(locator.spineIndex)
? Math.max(0, Math.floor(locator.spineIndex))
: -1;
const charOffset = typeof locator.charOffset === 'number' && Number.isFinite(locator.charOffset)
? Math.max(0, Math.floor(locator.charOffset))
: -1;
if (!spineHref || spineIndex < 0 || charOffset < 0) {
// Reject draft/legacy EPUB locators that lack stable coordinates. The
// client is expected to resolve these via the spine-coordinates helper
// before posting.
return null;
}
const normalized: TTSSegmentLocator = {
readerType: 'epub',
spineHref,
spineIndex,
charOffset,
};
if (typeof locator.cfi === 'string' && locator.cfi.trim()) {
normalized.cfi = locator.cfi.trim();
}
return normalized;
}
return null;
}
export function projectSegmentLocator(locator: TTSSegmentLocator): TTSSegmentLocatorProjection {
if (locator.readerType === 'epub') {
return {
locatorReaderRank: 0,
locatorReaderType: 'epub',
locatorPage: -1,
locatorSpineIndex: typeof locator.spineIndex === 'number' ? locator.spineIndex : -1,
locatorSpineHref: typeof locator.spineHref === 'string' ? locator.spineHref : '',
locatorCharOffset: typeof locator.charOffset === 'number' ? locator.charOffset : -1,
locatorLocation: '',
locatorIdentityKey: locatorIdentityKey(locator),
};
}
if (locator.readerType === 'pdf') {
return {
locatorReaderRank: 1,
locatorReaderType: 'pdf',
locatorPage: typeof locator.page === 'number' ? Math.floor(locator.page) : -1,
locatorSpineIndex: -1,
locatorSpineHref: '',
locatorCharOffset: -1,
locatorLocation: '',
locatorIdentityKey: locatorIdentityKey(locator),
};
}
if (locator.readerType === 'html') {
return {
locatorReaderRank: 2,
locatorReaderType: 'html',
locatorPage: -1,
locatorSpineIndex: -1,
locatorSpineHref: '',
locatorCharOffset: -1,
locatorLocation: typeof locator.location === 'string' ? locator.location : '',
locatorIdentityKey: locatorIdentityKey(locator),
};
}
throw new Error(`Unsupported segment locator readerType for projection: ${String(locator.readerType)}`);
}
export function locatorFingerprint(locator: TTSSegmentLocator | null): string {
if (!locator) return '';
return createHash('sha256').update(stableStringify(locator)).digest('hex');
}
export function buildTtsSegmentId(input: {
documentId: string;
documentVersion: number;
settingsHash: string;
segmentIndex: number;
segmentKey?: string | null;
normalizedText: string;
locatorFingerprint: string;
}): string {
const canonical = stableStringify({
d: input.documentId,
v: input.documentVersion,
s: input.settingsHash,
k: input.segmentKey || null,
i: input.segmentKey ? null : input.segmentIndex,
t: input.normalizedText,
l: input.segmentKey ? null : input.locatorFingerprint,
});
return createHash('sha256').update(canonical).digest('hex');
}
export function buildTtsSegmentEntryId(input: {
documentId: string;
documentVersion: number;
segmentIndex: number;
segmentKey?: string | null;
locatorIdentityKey: string;
textHash: string;
}): string {
const canonical = stableStringify({
d: input.documentId,
v: input.documentVersion,
i: input.segmentIndex,
k: input.segmentKey ? input.segmentKey : null,
l: input.locatorIdentityKey,
t: input.textHash,
});
return createHash('sha256').update(canonical).digest('hex');
}
export function buildTtsSegmentTextHash(text: string, secret: string): string {
return createHmac('sha256', secret).update(text).digest('hex');
}
export function buildTtsSegmentAudioKey(input: {
storagePrefix: string;
namespace: string | null;
userId: string;
documentId: string;
documentVersion: number;
settingsHash: string;
segmentId: string;
}): string {
const nsSegment = input.namespace ? `ns/${input.namespace}/` : '';
return `${input.storagePrefix}/tts_segments_v2/${nsSegment}users/${encodeURIComponent(input.userId)}/docs/${input.documentId}/${input.documentVersion}/${input.settingsHash}/${input.segmentId}.mp3`;
}
export async function probeAudioDurationMsFromBuffer(buffer: Buffer, signal?: AbortSignal): Promise<number> {
let workDir: string | null = null;
try {
workDir = await mkdtemp(join(tmpdir(), 'openreader-tts-segment-'));
const audioPath = join(workDir, 'segment.mp3');
await writeFile(audioPath, buffer);
const probe = await ffprobeAudio(audioPath, signal);
const sec = Number(probe.durationSec ?? 0);
if (!Number.isFinite(sec) || sec <= 0) {
return 0;
}
return Math.max(0, Math.floor(sec * 1000));
} finally {
if (workDir) {
await rm(workDir, { recursive: true, force: true }).catch(() => {});
}
}
}
function alignWordsToText(sentence: string): Array<{ text: string; charStart: number; charEnd: number }> {
const words = sentence.match(/\S+/g) || [];
const aligned: Array<{ text: string; charStart: number; charEnd: number }> = [];
let cursor = 0;
const lowerSentence = sentence.toLowerCase();
for (const token of words) {
const clean = token.trim();
if (!clean) continue;
const idx = lowerSentence.indexOf(clean.toLowerCase(), cursor);
const start = idx >= 0 ? idx : cursor;
const end = Math.min(sentence.length, start + clean.length);
cursor = Math.max(cursor, end);
aligned.push({
text: clean,
charStart: start,
charEnd: end,
});
}
return aligned;
}
export function buildProportionalAlignment(input: {
sentence: string;
sentenceIndex: number;
durationMs: number;
}): TTSSentenceAlignment {
const wordsWithOffsets = alignWordsToText(input.sentence);
if (wordsWithOffsets.length === 0 || input.durationMs <= 0) {
return {
sentence: input.sentence,
sentenceIndex: input.sentenceIndex,
words: [],
};
}
const weighted = wordsWithOffsets.map((word) => ({
...word,
weight: Math.max(1, word.text.replace(/[^a-zA-Z0-9]/g, '').length),
}));
const totalWeight = weighted.reduce((sum, word) => sum + word.weight, 0);
let consumedMs = 0;
const alignedWords: TTSSentenceWord[] = weighted.map((word, index) => {
const remainingMs = Math.max(0, input.durationMs - consumedMs);
const sliceMs = index === weighted.length - 1
? remainingMs
: Math.max(1, Math.round((input.durationMs * word.weight) / Math.max(1, totalWeight)));
const startMs = consumedMs;
consumedMs += Math.min(sliceMs, remainingMs);
return {
text: word.text,
startSec: startMs / 1000,
endSec: consumedMs / 1000,
charStart: word.charStart,
charEnd: word.charEnd,
};
});
return {
sentence: input.sentence,
sentenceIndex: input.sentenceIndex,
words: alignedWords,
};
}