openreader/src/lib/shared/tts-epub-handoff.ts
Richard R e8d4c11434 refactor(tts,epub,locator): introduce canonical segment keys and locator comparison utilities
Refactor TTS segment identification to support canonical segment keys, decoupling segment IDs from locator and index for more robust deduplication and handoff. Add `segmentKey` to segment types, manifest, and ensure route. Implement natural sorting for EPUB CFI locations and segment locators using new comparison utilities. Update sidebar and manifest helpers to use locator-aware sorting. Add foundational modules for EPUB word highlighting, TTS segment planning, and EPUB handoff logic. Expand types and tests to cover new canonical segment and locator behaviors.

BREAKING CHANGE: TTS segment IDs now support canonical segment keys; APIs and manifest consumers must handle `segmentKey` and updated locator comparison logic.
2026-05-11 05:17:41 -06:00

96 lines
2.7 KiB
TypeScript

import { preprocessSentenceForAudio } from '@/lib/shared/nlp';
import type { CanonicalTtsSegment } from '@/lib/shared/tts-segment-plan';
export type CompletedEpubBoundarySegment = {
key: string;
fingerprint: string;
completedAt: number;
};
export type EpubReplaySuppressionAction =
| { kind: 'none' }
| { kind: 'skip-to-index'; index: number }
| { kind: 'pause' };
export const EPUB_BOUNDARY_HANDOFF_MAX_AGE_MS = 2 * 60 * 1000;
export const fingerprintEpubBoundarySegment = (text: string): string =>
preprocessSentenceForAudio(text)
.toLowerCase()
.replace(/\s+/g, ' ')
.trim();
export const completedEpubBoundarySegment = (
segment: CanonicalTtsSegment | null | undefined,
now = Date.now(),
): CompletedEpubBoundarySegment | null => {
if (!segment?.spansSourceBoundary) return null;
const fingerprint = fingerprintEpubBoundarySegment(segment.text);
if (!fingerprint) return null;
return {
key: segment.key,
fingerprint,
completedAt: now,
};
};
export const resolveEpubBoundaryHandoffStartIndex = (
segments: CanonicalTtsSegment[],
completed: CompletedEpubBoundarySegment | null,
now = Date.now(),
): number => {
if (!completed) return 0;
if (now - completed.completedAt > EPUB_BOUNDARY_HANDOFF_MAX_AGE_MS) return 0;
let index = 0;
while (index < segments.length) {
const segment = segments[index];
if (segment.key === completed.key) {
index += 1;
continue;
}
if (fingerprintEpubBoundarySegment(segment.text) === completed.fingerprint) {
index += 1;
continue;
}
break;
}
return index;
};
export const shouldSuppressCompletedEpubBoundaryReplay = (
segment: CanonicalTtsSegment | null | undefined,
completed: CompletedEpubBoundarySegment | null,
now = Date.now(),
): boolean => {
if (!segment || !completed) return false;
if (now - completed.completedAt > EPUB_BOUNDARY_HANDOFF_MAX_AGE_MS) return false;
if (segment.key === completed.key) return true;
return fingerprintEpubBoundarySegment(segment.text) === completed.fingerprint;
};
export const resolveEpubReplaySuppressionAction = (
segments: CanonicalTtsSegment[],
currentIndex: number,
completed: CompletedEpubBoundarySegment | null,
now = Date.now(),
): EpubReplaySuppressionAction => {
if (!shouldSuppressCompletedEpubBoundaryReplay(segments[currentIndex], completed, now)) {
return { kind: 'none' };
}
let nextIndex = currentIndex + 1;
while (
nextIndex < segments.length
&& shouldSuppressCompletedEpubBoundaryReplay(segments[nextIndex], completed, now)
) {
nextIndex += 1;
}
if (nextIndex < segments.length) {
return { kind: 'skip-to-index', index: nextIndex };
}
return { kind: 'pause' };
};