Redesign TTS segment storage by decomposing the `tts_segments` table into two normalized tables: `tts_segment_entries` for unique segment identity and locator projection, and `tts_segment_variants` for per-settings audio variants. Update schema, queries, and API routes to use the new structure, including manifest pagination and segment resolution logic. Refactor segment locator handling to use stable projections and manifest cursors. Migrate tests and data cleanup utilities to support the new model. BREAKING CHANGE: Database schema for TTS segments is now split into entry and variant tables; all code and consumers must use the new structure. Persisted data and APIs relying on the old `tts_segments` table are incompatible.
196 lines
6 KiB
TypeScript
196 lines
6 KiB
TypeScript
import { expect, test } from '@playwright/test';
|
|
import {
|
|
buildProportionalAlignment,
|
|
buildTtsSegmentEntryId,
|
|
buildTtsSegmentId,
|
|
buildTtsSegmentSettingsHash,
|
|
buildTtsSegmentTextHash,
|
|
locatorFingerprint,
|
|
normalizeLocator,
|
|
normalizeSegmentText,
|
|
projectSegmentLocator,
|
|
} from '../../src/lib/server/tts/segments';
|
|
|
|
test.describe('tts segment helpers', () => {
|
|
test('builds stable settings hash', () => {
|
|
const a = buildTtsSegmentSettingsHash({
|
|
ttsProvider: 'openai',
|
|
ttsModel: 'gpt-4o-mini-tts',
|
|
voice: 'alloy',
|
|
nativeSpeed: 1,
|
|
ttsInstructions: 'calm',
|
|
});
|
|
const b = buildTtsSegmentSettingsHash({
|
|
ttsProvider: 'openai',
|
|
ttsModel: 'gpt-4o-mini-tts',
|
|
voice: 'alloy',
|
|
nativeSpeed: 1,
|
|
ttsInstructions: 'calm',
|
|
});
|
|
expect(a).toBe(b);
|
|
});
|
|
|
|
test('builds deterministic segment id', () => {
|
|
const id1 = buildTtsSegmentId({
|
|
documentId: 'doc',
|
|
documentVersion: 1,
|
|
settingsHash: 'abc',
|
|
segmentIndex: 2,
|
|
normalizedText: 'hello world',
|
|
locatorFingerprint: 'loc',
|
|
});
|
|
const id2 = buildTtsSegmentId({
|
|
documentId: 'doc',
|
|
documentVersion: 1,
|
|
settingsHash: 'abc',
|
|
segmentIndex: 2,
|
|
normalizedText: 'hello world',
|
|
locatorFingerprint: 'loc',
|
|
});
|
|
const id3 = buildTtsSegmentId({
|
|
documentId: 'doc',
|
|
documentVersion: 1,
|
|
settingsHash: 'abc',
|
|
segmentIndex: 3,
|
|
normalizedText: 'hello world',
|
|
locatorFingerprint: 'loc',
|
|
});
|
|
expect(id1).toBe(id2);
|
|
expect(id1).not.toBe(id3);
|
|
});
|
|
|
|
test('builds deterministic segment entry id independent of settings', () => {
|
|
const entry1 = buildTtsSegmentEntryId({
|
|
documentId: 'doc',
|
|
documentVersion: 1,
|
|
segmentIndex: 2,
|
|
segmentKey: 'doc:v1:segment-a',
|
|
locatorIdentityKey: 'epub:2:OEBPS/ch02.xhtml:128',
|
|
textHash: 'abc123',
|
|
});
|
|
const entry2 = buildTtsSegmentEntryId({
|
|
documentId: 'doc',
|
|
documentVersion: 1,
|
|
segmentIndex: 2,
|
|
segmentKey: 'doc:v1:segment-a',
|
|
locatorIdentityKey: 'epub:2:OEBPS/ch02.xhtml:128',
|
|
textHash: 'abc123',
|
|
});
|
|
const entry3 = buildTtsSegmentEntryId({
|
|
documentId: 'doc',
|
|
documentVersion: 1,
|
|
segmentIndex: 2,
|
|
segmentKey: 'doc:v1:segment-b',
|
|
locatorIdentityKey: 'epub:2:OEBPS/ch02.xhtml:128',
|
|
textHash: 'abc123',
|
|
});
|
|
expect(entry1).toBe(entry2);
|
|
expect(entry1).not.toBe(entry3);
|
|
});
|
|
|
|
test('canonical segment key makes id independent of locator and index', () => {
|
|
const id1 = buildTtsSegmentId({
|
|
documentId: 'doc',
|
|
documentVersion: 1,
|
|
settingsHash: 'abc',
|
|
segmentIndex: 2,
|
|
segmentKey: 'doc:v1:segment-a',
|
|
normalizedText: 'hello world',
|
|
locatorFingerprint: 'loc-a',
|
|
});
|
|
const id2 = buildTtsSegmentId({
|
|
documentId: 'doc',
|
|
documentVersion: 1,
|
|
settingsHash: 'abc',
|
|
segmentIndex: 99,
|
|
segmentKey: 'doc:v1:segment-a',
|
|
normalizedText: 'hello world',
|
|
locatorFingerprint: 'loc-b',
|
|
});
|
|
const id3 = buildTtsSegmentId({
|
|
documentId: 'doc',
|
|
documentVersion: 1,
|
|
settingsHash: 'abc',
|
|
segmentIndex: 2,
|
|
segmentKey: 'doc:v1:segment-b',
|
|
normalizedText: 'hello world',
|
|
locatorFingerprint: 'loc-a',
|
|
});
|
|
expect(id1).toBe(id2);
|
|
expect(id1).not.toBe(id3);
|
|
});
|
|
|
|
test('does not leak plaintext via text hash', () => {
|
|
const hash = buildTtsSegmentTextHash('plain sentence', 'secret');
|
|
expect(hash).not.toContain('plain');
|
|
expect(hash).toHaveLength(64);
|
|
});
|
|
|
|
test('normalizes PDF locators and creates fingerprints', () => {
|
|
const locator = normalizeLocator({ readerType: 'pdf', page: 2.9 });
|
|
if (!locator) throw new Error('expected normalized pdf locator');
|
|
expect(locator).toEqual({ readerType: 'pdf', page: 2 });
|
|
expect(locatorFingerprint(locator)).toHaveLength(64);
|
|
expect(projectSegmentLocator(locator)).toMatchObject({
|
|
locatorReaderRank: 1,
|
|
locatorReaderType: 'pdf',
|
|
locatorPage: 2,
|
|
locatorIdentityKey: 'pdf:2',
|
|
});
|
|
});
|
|
|
|
test('normalizes stable EPUB locators and rejects legacy CFI-only drafts', () => {
|
|
// Stable shape: passes through with floors and trims applied.
|
|
const stable = normalizeLocator({
|
|
readerType: 'epub',
|
|
spineHref: ' OEBPS/ch02.xhtml ',
|
|
spineIndex: 2.7,
|
|
charOffset: 128.4,
|
|
cfi: ' epubcfi(/6/4!/4:0) ',
|
|
});
|
|
if (!stable) throw new Error('expected normalized epub locator');
|
|
expect(stable).toEqual({
|
|
readerType: 'epub',
|
|
spineHref: 'OEBPS/ch02.xhtml',
|
|
spineIndex: 2,
|
|
charOffset: 128,
|
|
cfi: 'epubcfi(/6/4!/4:0)',
|
|
});
|
|
expect(locatorFingerprint(stable)).toHaveLength(64);
|
|
expect(projectSegmentLocator(stable)).toMatchObject({
|
|
locatorReaderRank: 0,
|
|
locatorReaderType: 'epub',
|
|
locatorSpineIndex: 2,
|
|
locatorCharOffset: 128,
|
|
locatorSpineHref: 'OEBPS/ch02.xhtml',
|
|
locatorIdentityKey: 'epub:2:OEBPS/ch02.xhtml:128',
|
|
});
|
|
|
|
// Legacy/draft EPUB shape (CFI in `location` but no spine coords) is
|
|
// rejected so we never persist viewport-dependent locators.
|
|
const legacy = normalizeLocator({ readerType: 'epub', location: 'epubcfi(/6/4!/4:0)' });
|
|
expect(legacy).toBeNull();
|
|
});
|
|
|
|
test('projects html locators into stable manifest sort fields', () => {
|
|
expect(projectSegmentLocator({ readerType: 'html', location: '#intro' })).toMatchObject({
|
|
locatorReaderRank: 2,
|
|
locatorReaderType: 'html',
|
|
locatorLocation: '#intro',
|
|
locatorIdentityKey: 'html:#intro',
|
|
});
|
|
});
|
|
|
|
test('builds proportional alignment preserving order', () => {
|
|
const alignment = buildProportionalAlignment({
|
|
sentence: normalizeSegmentText('Hello world again'),
|
|
sentenceIndex: 5,
|
|
durationMs: 1500,
|
|
});
|
|
expect(alignment.sentenceIndex).toBe(5);
|
|
expect(alignment.words.length).toBe(3);
|
|
expect(alignment.words[0].startSec).toBe(0);
|
|
expect(alignment.words[2].endSec).toBeGreaterThan(1.4);
|
|
expect(alignment.words[1].charStart).toBeGreaterThan(alignment.words[0].charStart);
|
|
});
|
|
});
|