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.
96 lines
2.7 KiB
TypeScript
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' };
|
|
};
|