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